b84c45ebe6076132bf81a39b0cb1da5d3820eb00
[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 if COMMENT_TXT.length <= 2 or COMMENT_TXT =~ /\A\s*\Z/
146 fail('not enough content in comment')
147 end
148
149
150 captchad = false
151 if cgi.has_key?('answer') && cgi.has_key?('question')
152 if cgi['answer'].downcase !~ /^#{CAPTCHA.to_h[cgi['question']]}$/
153 do_captcha
154 end
155 captchad = true
156 end
157
158
159 -> {
160 found = false
161 Dir.foreach('blog') do |entry|
162 next if ['.','..'].any? { |f| f == entry }
163 if GOTO == '/blog/' + entry
164 found = true
165 break
166 end
167 end
168 fail('goto entry not found') unless found
169 }[]
170 ######### end error checking & arg parsing ########
171
172
173 $db = db_init
174 state = nil
175 WHITELIST_CUTOFF = NOW - 4*DAY
176
177
178 ####### begin: state for ips we've seen before #######
179 [[5, 60], # 1 min
180 [10, 60*5], # 5 min
181 [20, 60*60], # 60 min
182 [30, 60*60*24], # 1 day
183 [60, 60*60*24*7]] # 1 week
184 .each do |max_posts, date|
185
186 if $db.execute(<<-SQL, [NOW - date])[0][0] > max_posts
187 select count(*) from c
188 where date > ? and ip = '#{IP}'
189 SQL
190 state = 'rate_limited'
191 end
192 end
193
194 state ||= 'suspect' if $db.execute(<<-SQL)[0][0] > 0
195 select count(*) from c
196 where ip = '#{IP}' and (
197 state = 'banned' or
198 state = 'rate_limited')
199 SQL
200
201 unless state
202 older_date = NOW - DAY*2
203 last_moderated = $db.execute(<<-SQL, [older_date])[-1]
204 select date from c
205 where ip = '#{IP}' and (
206 state = 'moderated' or
207 (date < ? and (state = 'timed' or state = 'known')))
208 SQL
209 last_moderated = last_moderated[0] if last_moderated
210 last_good = $db.execute(<<-SQL, [older_date])[-1]
211 select date from c
212 where ip = '#{IP}' and (
213 state = 'picked' or
214 (date < ? and (state = 'timed' or state = 'known')))
215 SQL
216 last_good = last_good[0] if last_good
217 if last_moderated && last_good
218 if last_good > last_moderated
219 state = 'known'
220 else
221 # these 2 waiting conditions are not actually needed,
222 # since waiting is the default, but meh.
223 state = 'waiting'
224 end
225 elsif last_moderated
226 state = 'waiting'
227 elsif last_good
228 state = 'known'
229 end
230 end
231 ####### end: state for ips we've seen before #######
232
233 ####### begin: whitelist checking #########
234 glob = "../blog/#{'?'*'YYYY-MM-DD-'.length}#{bn GOTO, '.*'}.md"
235 md_file = Dir[glob][0]
236 unless state
237 b = bn(md_file,'.*')
238 post_date = Time.parse(b[0..DATE_LEN]).to_i
239 if post_date > WHITELIST_CUTOFF
240 state = 'timed'
241 end
242 end
243 ###### end: whitelist checking ########
244
245 state ||= 'waiting'
246
247 if state != 'known' && ! captchad
248 do_captcha
249 end
250
251
252 # states:
253 # timed
254 # # was posted a whitelist period, so automatically posted.
255 # # whitelist periods are per page times when legit comments are
256 # # much more likely than spam, so we automatically let comments through.
257
258 # known
259 # # ip posted good comment before: either, one in picked state, or
260 # # a timed/known comment which is over 2 days old (I saw it and didn't remove
261 # # it)
262
263 # picked
264 # # manually marked as a good comment, so publish it.
265
266 # rate_limited
267 # # posting too much, consider them a spammer.
268
269 # moderated
270 # # bad comment, but don't ban them
271
272 # banned
273 # # all comments from this ip dead, new comment's dont even go into the db.
274
275 # waiting
276 # # waiting for manual moderation, get's posted automatically if there
277 # # is none in 24 hours
278
279 # suspect
280 # # had a bad post in the past. does not
281 # # automatically get posted in time without moderation.
282
283
284 # any of the manual states
285 date = $db.execute(<<-SQL)[0][0]
286 select max(date) from c where
287 state = 'moderated' or
288 state = 'banned' or
289 state = 'picked'
290 SQL
291
292 # not the bad automatic states
293 query = <<-SQL
294 select count(*) from c where
295 state != 'rate_limited' and
296 state != 'suspect'
297 SQL
298
299 $db.execute('insert into c values (NULL, ?, ?, ?, ?, ?)',
300 [state,
301 IP,
302 NOW,
303 GOTO,
304 COMMENT_TXT])
305
306 if date
307 new_count = $db.execute(query + 'and date > ?', date)
308 else
309 new_count = $db.execute(query)
310 end
311 new_count = new_count[0][0]
312
313 if new_count == 1
314 require 'net/smtp'
315 def send_email(opts={})
316 opts[:to] ||= 'root'
317 opts[:server] ||= 'localhost'
318 opts[:from] ||= 'root'
319 opts[:from_alias] ||= 'root'
320 opts[:subject] ||= "test subject"
321 opts[:body] ||= ""
322
323 msg = <<END_OF_MESSAGE
324 From: #{opts[:from_alias]} <#{opts[:from]}>
325 To: <#{opts[:to]}>
326 Subject: #{opts[:subject]}
327
328 #{opts[:body]}
329 END_OF_MESSAGE
330
331 Net::SMTP.start(opts[:server]) do |smtp|
332 smtp.send_message msg, opts[:from], opts[:to]
333 end
334 end
335 send_email :subject => "new comment on #{DN}"
336 end
337
338 post(md_file)
339
340 redir