use cgi dir for better security
[iankelling.org] / _site / cgi / comment
diff --git a/_site/cgi/comment b/_site/cgi/comment
new file mode 100755 (executable)
index 0000000..b1e8796
--- /dev/null
@@ -0,0 +1,336 @@
+#!/usr/bin/env ruby
+# encoding: utf-8
+# Copyright (C) 2016 Ian Kelling
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 2 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# debian sets LANG=C when starting apache2.
+# the envoding comment above fixes the internal encoding afaik,
+# Found this at
+# https://stackoverflow.com/questions/20521371/set-utf-8-as-default-for-ruby-1-9-3
+# also note  man ruby's -E arg.
+Encoding.default_external = Encoding::UTF_8
+
+require 'cgi'
+require 'fileutils'
+require 'time'
+require 'sqlite3'
+
+Dir.chdir(File.join(File.dirname(__FILE__), '..'))
+
+require '../b'
+include B
+
+
+# constanty things
+DEBUG = true
+CAPTCHA = -> {
+  c = []
+  x = (<<EOF).split("\n")
+What does a dog wag?
+tail
+Would you sleep better on a bed or a keyboard?
+bed
+Are there more or less than one million people on the Earth?
+more
+Which of Lilly and Robert is more commonly a woman’s name?
+lilly
+Which word has fewer letters, adorable or fox?
+fox
+What is the normal color of milk?
+white
+Which of Iceland and Turkey is an island nation?
+iceland
+Is a filesystem like a tree or a rose?
+tree
+What character is a tilde?
+~
+Which is brighter, the moon or the sun?
+(the )?sun
+Which is closer, the moon or the sun?
+(the )?moon
+What language is this sentence written in?
+english
+What animal says "meow" and catches mice?
+cat
+What animal quacks and has webbed feet?
+duck
+Which are better fliers: worms or birds?
+birds
+Which typically runs first: a kernel or a web browser?
+kernel
+EOF
+  while x.length > 0
+    c << x.pop(2)
+  end
+  c
+}[]
+
+def do_captcha
+  captcha_q = CAPTCHA.sample[0]
+  puts "Content-type: text/html\n\n"
+  puts skel("#{DN}/captcha", <<EOF, header: ' / <a href="/blog.html">blog</a> / captcha')
+<p>Hello friend. I haven't read a post from #{IP}, and I only remember for a few months, so:</p>
+<p>#{captcha_q}</p>
+
+<form action="/cgi/comment" method="post">
+  <input class="misc" type="text" name="url">
+  <input name="goto" type="hidden" value="#{GOTO}">
+  <input name="question" type="hidden" value="#{captcha_q}">
+  <input name="answer">
+<br>Your comment:
+  <textarea rows="10" name="comment" maxlength="1000">#{COMMENT_TXT}</textarea>
+  <input type="submit" value="Submit">
+</form>
+EOF
+  exit 0
+end
+
+
+def fail(msg)
+  if DEBUG and msg
+    puts "Content-type: text/plain\n\n"
+    puts msg
+  else
+    redir
+  end
+  exit 0
+end
+
+def redir
+  puts 'Status: 302 Found'
+  puts "Location: #{GOTO}#comment-section\n\n"
+  exit(0)
+end
+
+
+def bn(*args)
+  File.basename *args
+end
+
+
+###### begin error checking & arg parsing ######
+cgi = CGI.new
+IP = cgi.remote_addr
+
+if cgi.has_key?('goto')
+  GOTO = cgi['goto']
+else
+  GOTO = '/'
+  fail('redir to /')
+end
+
+if (cgi.has_key?('url') && cgi['url'] != "") || ! cgi.has_key?('comment')
+  fail("comment not in form or url in form. cgi.params: #{cgi.params}")
+end
+
+COMMENT_TXT = cgi["comment"]
+
+
+if COMMENT_TXT.length > 1000 or GOTO.length > 150
+  fail('length of comment or goto is too great')
+end
+
+
+captchad = false
+if cgi.has_key?('answer') && cgi.has_key?('question')
+  if cgi['answer'].downcase !~ /^#{CAPTCHA.to_h[cgi['question']]}$/
+    do_captcha
+  end
+  captchad = true
+end
+
+
+-> {
+  found = false
+  Dir.foreach('blog') do |entry|
+    next if ['.','..'].any? { |f| f == entry }
+    if GOTO == '/blog/' + entry
+      found = true
+      break
+    end
+  end
+  fail('goto entry not found') unless found
+}[]
+######### end error checking & arg parsing ########
+
+
+$db = db_init
+state = nil
+WHITELIST_CUTOFF = NOW - 4*DAY
+
+
+####### begin: state for ips we've seen before #######
+[[5, 60], # 1 min
+ [10, 60*5], # 5 min
+ [20, 60*60], # 60 min
+ [30, 60*60*24], # 1 day
+ [60, 60*60*24*7]] # 1 week
+  .each do |max_posts, date|
+
+  if $db.execute(<<-SQL, [NOW - date])[0][0] > max_posts
+  select count(*) from c
+  where date > ? and ip = '#{IP}'
+SQL
+    state = 'rate_limited'
+  end
+end
+
+state ||= 'suspect' if $db.execute(<<-SQL)[0][0] > 0
+  select count(*) from c
+  where ip = '#{IP}' and (
+state = 'banned' or
+state = 'rate_limited')
+SQL
+
+unless state
+  older_date = NOW - DAY*2
+  last_moderated = $db.execute(<<-SQL, [older_date])[-1]
+  select date from c
+  where ip = '#{IP}' and (
+    state = 'moderated' or
+    (date < ? and (state = 'timed' or state = 'known')))
+SQL
+  last_moderated = last_moderated[0] if last_moderated
+  last_good = $db.execute(<<-SQL, [older_date])[-1]
+  select date from c
+  where ip = '#{IP}' and (
+    state = 'picked' or
+    (date < ? and (state = 'timed' or state = 'known')))
+SQL
+  last_good = last_good[0] if last_good
+  if last_moderated && last_good
+    if last_good > last_moderated
+      state = 'known'
+    else
+      # these 2 waiting conditions are not actually needed,
+      # since waiting is the default, but meh.
+      state = 'waiting'
+    end
+  elsif last_moderated
+    state = 'waiting'
+  elsif last_good
+    state = 'known'
+  end
+end
+####### end: state for ips we've seen before #######
+
+####### begin: whitelist checking #########
+glob = "../blog/#{'?'*'YYYY-MM-DD-'.length}#{bn GOTO, '.*'}.md"
+md_file = Dir[glob][0]
+unless state
+  b = bn(md_file,'.*')
+  post_date = Time.parse(b[0..DATE_LEN]).to_i
+  if post_date > WHITELIST_CUTOFF
+    state = 'timed'
+  end
+end
+###### end: whitelist checking ########
+
+state ||= 'waiting'
+
+if state != 'known' && ! captchad
+  do_captcha
+end
+
+
+# states:
+# timed
+# # was posted a whitelist period, so automatically posted.
+# # whitelist periods are per page times when legit comments are
+# # much more likely than spam, so we automatically let comments through.
+
+# known
+# # ip posted good comment before: either, one in picked state, or
+# # a timed/known comment which is over 2 days old (I saw it and didn't remove
+# # it)
+
+# picked
+# # manually marked as a good comment, so publish it.
+
+# rate_limited
+# # posting too much, consider them a spammer.
+
+# moderated
+# # bad comment, but don't ban them
+
+# banned
+# # all comments from this ip dead, new comment's dont even go into the db.
+
+# waiting
+# # waiting for manual moderation, get's posted automatically if there
+# # is none in 24 hours
+
+# suspect
+# # had a bad post in the past. does not
+# # automatically get posted in time without moderation.
+
+
+# any of the manual states
+date = $db.execute(<<-SQL)[0][0]
+select max(date) from c where
+state = 'moderated' or
+state = 'banned' or
+state = 'picked'
+SQL
+
+# not the bad automatic states
+query = <<-SQL
+select count(*) from c where
+state != 'rate_limited' and
+state != 'suspect'
+SQL
+
+
+if date
+  new_count = $db.execute(query + 'and date > ?',date)
+else
+  new_count = $db.execute(query)
+end
+
+if new_count == 1
+  require 'net/smtp'
+  def send_email(opts={})
+    opts[:to]          ||= ENV['USER']
+    opts[:server]      ||= 'localhost'
+    opts[:from]        ||= ENV['USER']
+    opts[:from_alias]  ||= ENV['USER']
+    opts[:subject]     ||= "test subject"
+    opts[:body]        ||= ""
+
+    msg = <<END_OF_MESSAGE
+From: #{opts[:from_alias]} <#{opts[:from]}>
+To: <#{opts[:to]}>
+Subject: #{opts[:subject]}
+
+#{opts[:body]}
+END_OF_MESSAGE
+
+    Net::SMTP.start(opts[:server]) do |smtp|
+      smtp.send_message msg, opts[:from], opts[:to]
+    end
+  end
+  send_email :subject => 'new comments on iankelling.org'
+end
+
+$db.execute('insert into c values (NULL, ?, ?, ?, ?, ?)',
+            [state,
+             IP,
+             NOW,
+             GOTO,
+             COMMENT_TXT])
+
+post(md_file)
+
+redir