b1e8796607706a844cf9a33ecf1a46500acfebec
[iankelling.org] / _site / cgi / comment
1 #!/usr/bin/env ruby
2 # encoding: utf-8
3 # Copyright (C) 2016 Ian Kelling
4
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation, either version 2 of the License, or
8 # (at your option) any later version.
9
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
14
15 # You should have received a copy of the GNU General Public License
16 # along with this program. If not, see <http://www.gnu.org/licenses/>.
17
18 # debian sets LANG=C when starting apache2.
19 # the envoding comment above fixes the internal encoding afaik,
20 # Found this at
21 # https://stackoverflow.com/questions/20521371/set-utf-8-as-default-for-ruby-1-9-3
22 # also note man ruby's -E arg.
23 Encoding.default_external = Encoding::UTF_8
24
25 require 'cgi'
26 require 'fileutils'
27 require 'time'
28 require 'sqlite3'
29
30 Dir.chdir(File.join(File.dirname(__FILE__), '..'))
31
32 require '../b'
33 include B
34
35
36 # constanty things
37 DEBUG = true
38 CAPTCHA = -> {
39 c = []
40 x = (<<EOF).split("\n")
41 What does a dog wag?
42 tail
43 Would you sleep better on a bed or a keyboard?
44 bed
45 Are there more or less than one million people on the Earth?
46 more
47 Which of Lilly and Robert is more commonly a woman’s name?
48 lilly
49 Which word has fewer letters, adorable or fox?
50 fox
51 What is the normal color of milk?
52 white
53 Which of Iceland and Turkey is an island nation?
54 iceland
55 Is a filesystem like a tree or a rose?
56 tree
57 What character is a tilde?
58 ~
59 Which is brighter, the moon or the sun?
60 (the )?sun
61 Which is closer, the moon or the sun?
62 (the )?moon
63 What language is this sentence written in?
64 english
65 What animal says "meow" and catches mice?
66 cat
67 What animal quacks and has webbed feet?
68 duck
69 Which are better fliers: worms or birds?
70 birds
71 Which typically runs first: a kernel or a web browser?
72 kernel
73 EOF
74 while x.length > 0
75 c << x.pop(2)
76 end
77 c
78 }[]
79
80 def do_captcha
81 captcha_q = CAPTCHA.sample[0]
82 puts "Content-type: text/html\n\n"
83 puts skel("#{DN}/captcha", <<EOF, header: ' / <a href="/blog.html">blog</a> / captcha')
84 <p>Hello friend. I haven't read a post from #{IP}, and I only remember for a few months, so:</p>
85 <p>#{captcha_q}</p>
86
87 <form action="/cgi/comment" method="post">
88 <input class="misc" type="text" name="url">
89 <input name="goto" type="hidden" value="#{GOTO}">
90 <input name="question" type="hidden" value="#{captcha_q}">
91 <input name="answer">
92 <br>Your comment:
93 <textarea rows="10" name="comment" maxlength="1000">#{COMMENT_TXT}</textarea>
94 <input type="submit" value="Submit">
95 </form>
96 EOF
97 exit 0
98 end
99
100
101 def fail(msg)
102 if DEBUG and msg
103 puts "Content-type: text/plain\n\n"
104 puts msg
105 else
106 redir
107 end
108 exit 0
109 end
110
111 def redir
112 puts 'Status: 302 Found'
113 puts "Location: #{GOTO}#comment-section\n\n"
114 exit(0)
115 end
116
117
118 def bn(*args)
119 File.basename *args
120 end
121
122
123 ###### begin error checking & arg parsing ######
124 cgi = CGI.new
125 IP = cgi.remote_addr
126
127 if cgi.has_key?('goto')
128 GOTO = cgi['goto']
129 else
130 GOTO = '/'
131 fail('redir to /')
132 end
133
134 if (cgi.has_key?('url') && cgi['url'] != "") || ! cgi.has_key?('comment')
135 fail("comment not in form or url in form. cgi.params: #{cgi.params}")
136 end
137
138 COMMENT_TXT = cgi["comment"]
139
140
141 if COMMENT_TXT.length > 1000 or GOTO.length > 150
142 fail('length of comment or goto is too great')
143 end
144
145
146 captchad = false
147 if cgi.has_key?('answer') && cgi.has_key?('question')
148 if cgi['answer'].downcase !~ /^#{CAPTCHA.to_h[cgi['question']]}$/
149 do_captcha
150 end
151 captchad = true
152 end
153
154
155 -> {
156 found = false
157 Dir.foreach('blog') do |entry|
158 next if ['.','..'].any? { |f| f == entry }
159 if GOTO == '/blog/' + entry
160 found = true
161 break
162 end
163 end
164 fail('goto entry not found') unless found
165 }[]
166 ######### end error checking & arg parsing ########
167
168
169 $db = db_init
170 state = nil
171 WHITELIST_CUTOFF = NOW - 4*DAY
172
173
174 ####### begin: state for ips we've seen before #######
175 [[5, 60], # 1 min
176 [10, 60*5], # 5 min
177 [20, 60*60], # 60 min
178 [30, 60*60*24], # 1 day
179 [60, 60*60*24*7]] # 1 week
180 .each do |max_posts, date|
181
182 if $db.execute(<<-SQL, [NOW - date])[0][0] > max_posts
183 select count(*) from c
184 where date > ? and ip = '#{IP}'
185 SQL
186 state = 'rate_limited'
187 end
188 end
189
190 state ||= 'suspect' if $db.execute(<<-SQL)[0][0] > 0
191 select count(*) from c
192 where ip = '#{IP}' and (
193 state = 'banned' or
194 state = 'rate_limited')
195 SQL
196
197 unless state
198 older_date = NOW - DAY*2
199 last_moderated = $db.execute(<<-SQL, [older_date])[-1]
200 select date from c
201 where ip = '#{IP}' and (
202 state = 'moderated' or
203 (date < ? and (state = 'timed' or state = 'known')))
204 SQL
205 last_moderated = last_moderated[0] if last_moderated
206 last_good = $db.execute(<<-SQL, [older_date])[-1]
207 select date from c
208 where ip = '#{IP}' and (
209 state = 'picked' or
210 (date < ? and (state = 'timed' or state = 'known')))
211 SQL
212 last_good = last_good[0] if last_good
213 if last_moderated && last_good
214 if last_good > last_moderated
215 state = 'known'
216 else
217 # these 2 waiting conditions are not actually needed,
218 # since waiting is the default, but meh.
219 state = 'waiting'
220 end
221 elsif last_moderated
222 state = 'waiting'
223 elsif last_good
224 state = 'known'
225 end
226 end
227 ####### end: state for ips we've seen before #######
228
229 ####### begin: whitelist checking #########
230 glob = "../blog/#{'?'*'YYYY-MM-DD-'.length}#{bn GOTO, '.*'}.md"
231 md_file = Dir[glob][0]
232 unless state
233 b = bn(md_file,'.*')
234 post_date = Time.parse(b[0..DATE_LEN]).to_i
235 if post_date > WHITELIST_CUTOFF
236 state = 'timed'
237 end
238 end
239 ###### end: whitelist checking ########
240
241 state ||= 'waiting'
242
243 if state != 'known' && ! captchad
244 do_captcha
245 end
246
247
248 # states:
249 # timed
250 # # was posted a whitelist period, so automatically posted.
251 # # whitelist periods are per page times when legit comments are
252 # # much more likely than spam, so we automatically let comments through.
253
254 # known
255 # # ip posted good comment before: either, one in picked state, or
256 # # a timed/known comment which is over 2 days old (I saw it and didn't remove
257 # # it)
258
259 # picked
260 # # manually marked as a good comment, so publish it.
261
262 # rate_limited
263 # # posting too much, consider them a spammer.
264
265 # moderated
266 # # bad comment, but don't ban them
267
268 # banned
269 # # all comments from this ip dead, new comment's dont even go into the db.
270
271 # waiting
272 # # waiting for manual moderation, get's posted automatically if there
273 # # is none in 24 hours
274
275 # suspect
276 # # had a bad post in the past. does not
277 # # automatically get posted in time without moderation.
278
279
280 # any of the manual states
281 date = $db.execute(<<-SQL)[0][0]
282 select max(date) from c where
283 state = 'moderated' or
284 state = 'banned' or
285 state = 'picked'
286 SQL
287
288 # not the bad automatic states
289 query = <<-SQL
290 select count(*) from c where
291 state != 'rate_limited' and
292 state != 'suspect'
293 SQL
294
295
296 if date
297 new_count = $db.execute(query + 'and date > ?',date)
298 else
299 new_count = $db.execute(query)
300 end
301
302 if new_count == 1
303 require 'net/smtp'
304 def send_email(opts={})
305 opts[:to] ||= ENV['USER']
306 opts[:server] ||= 'localhost'
307 opts[:from] ||= ENV['USER']
308 opts[:from_alias] ||= ENV['USER']
309 opts[:subject] ||= "test subject"
310 opts[:body] ||= ""
311
312 msg = <<END_OF_MESSAGE
313 From: #{opts[:from_alias]} <#{opts[:from]}>
314 To: <#{opts[:to]}>
315 Subject: #{opts[:subject]}
316
317 #{opts[:body]}
318 END_OF_MESSAGE
319
320 Net::SMTP.start(opts[:server]) do |smtp|
321 smtp.send_message msg, opts[:from], opts[:to]
322 end
323 end
324 send_email :subject => 'new comments on iankelling.org'
325 end
326
327 $db.execute('insert into c values (NULL, ?, ?, ?, ?, ?)',
328 [state,
329 IP,
330 NOW,
331 GOTO,
332 COMMENT_TXT])
333
334 post(md_file)
335
336 redir