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