#!/usr/bin/haserl --upload-limit=16384 --shell=lua --accept-all
<%
local sqlite3 = require("lsqlite3");
local digest = require("openssl").digest;
local bcrypt = require("bcrypt");
local crypto = {};
local cgi = {};
local html = {};
html.board = {};
html.post = {};
html.container = {};
html.table = {};
html.list = {};
html.pdp = {};
html.string = {};
local generate = {};
local board = {};
local post = {};
post.pseudo = {};
local file = {};
local identity = {};
identity.session = {};
local captcha = {};
local log = {};
local global = {};
local misc = {}
local nanodb = sqlite3.open("nanochan.db");
local secretsdb = sqlite3.open("secrets.db");
-- Ensure all required tables exist.
nanodb:exec("CREATE TABLE IF NOT EXISTS Global (Name, Value)");
nanodb:exec("CREATE TABLE IF NOT EXISTS Boards (Name, Title, Subtitle, MaxPostNumber, Lock, DisplayOverboard, MaxThreadsPerHour, MinThreadChars, BumpLimit, PostLimit, ThreadLimit, RequireCaptcha, CaptchaTriggerPPH)"); -- MaxThreadsPerHour actually 12 hours instead of 1hr
nanodb:exec("CREATE TABLE IF NOT EXISTS Posts (Board, Number, Parent, Date, LastBumpDate, Name, Email, Subject, Comment, File, Sticky, Cycle, Autosage, Lock, tvolDeleteName, tvolDeleteDate)");
nanodb:exec("CREATE TABLE IF NOT EXISTS File (Name, ThumbWidth, ThumbHeight)");
nanodb:exec("CREATE TABLE IF NOT EXISTS Refs (Board, Referee, Referrer)");
nanodb:exec("CREATE TABLE IF NOT EXISTS Logs (Name, Board, Date, Description)");
nanodb:busy_timeout(10000);
secretsdb:exec("CREATE TABLE IF NOT EXISTS Accounts (Name, Type, Board, PwHash, Creator, MaxActionsPerDay, MaxModifiedPostAge)");
secretsdb:exec("CREATE TABLE IF NOT EXISTS Sessions (Key, Account, ExpireDate)");
secretsdb:exec("CREATE TABLE IF NOT EXISTS Captchas (Text, ExpireDate, UnlistedDate, ImageData BLOB)");
secretsdb:busy_timeout(1000);
--
-- Additional functions.
--
-- called whenever math.random is used
local seed_generated = false
function misc.generateseed()
if seed_generated then
return;
else
seed_generated = true
end
local fd = io.open("/dev/urandom","rb");
local seed = 0;
for i=0,string.byte(fd:read(1)) do
seed = seed + string.byte(fd:read(1));
end
math.randomseed(seed);
fd:close();
end
-- create audit entries for mod actions (only deletes are supported)
function misc.audit(action, boardname, number, reason)
local result = post.retrieve(boardname, number)
local f = io.open("audit.log", "a");
f:write("-----------------------------BEGIN AUDIT ENTRY-----------------------------\n\n");
f:write("Username: ", username, "\n");
f:write("Action: ", action, "\n");
f:write("Reason: ", reason, "\n\n");
f:write("Board: ", result["Board"], "\n");
f:write("Post No: ", result["Number"], "\n");
f:write("File Name: ", result["File"], "\n");
f:write("Parent Thread: ", result["Parent"], "\n");
f:write("Date Created: ", result["Date"], "\n");
f:write("Name: ", result["Name"], "\n");
f:write("Email: ", result["Email"], "\n");
f:write("Subject: ", result["Subject"], "\n");
if result["Parent"] == 0 then
f:write("Date of Last Bump: ", result["LastBumpDate"], "\n");
f:write("Sticky?: ", result["Sticky"], "\n");
f:write("Cycle?: ", result["Cycle"], "\n");
f:write("Autosage?: ", result["Autosage"], "\n");
f:write("Lock?: ", result["Lock"], "\n");
end
f:write("\n");
f:write("Contents:\n", result["Comment"], "\n\n");
if result["Parent"] == 0 then
local threads = post.threadreplies(boardname, number);
for i = 1, #threads do
local result2 = post.retrieve(boardname, threads[i]);
f:write("----------------BEGIN CHILD POST-------------------\n");
f:write("Board: ", result2["Board"], "\n");
f:write("Post No: ", result2["Number"], "\n");
f:write("File Name: ", result2["File"], "\n");
f:write("Parent Thread: ", result2["Parent"], "\n");
f:write("Date Created: ", result2["Date"], "\n");
f:write("Name: ", result2["Name"], "\n");
f:write("Email: ", result2["Email"], "\n");
f:write("Subject: ", result2["Subject"], "\n");
f:write("\n");
f:write("Contents:\n", result2["Comment"], "\n");
f:write("-----------------END CHILD POST--------------------\n\n");
end
end
f:write("------------------------------END AUDIT ENTRY------------------------------\n");
f:write("\n\n\n\n");
f:close();
end
function file.unlink(filename)
local posts = {};
local stmt = nanodb:prepare("SELECT Board, Number, Parent FROM Posts WHERE File = ?");
stmt:bind_values(filename);
for tbl in stmt:nrows() do
posts[#posts + 1] = tbl;
end
stmt:finalize();
local stmt = nanodb:prepare("UPDATE Posts SET File = '' WHERE File = ?");
stmt:bind_values(filename);
stmt:step();
stmt:finalize();
return misc.groupedregen(posts, false);
end
-- smart page regeneration
function misc.groupedregen(posts, always_regen_catalog) -- posts[i] = {Board, Number, Parent}, Parent=0 to regen as thread
-- always_regen_catalog: regen catalog and overboard despite not being an opening post (post.delete)
-- if no number/parent supplied, no thread will be regenerated (post.delete, post.pseudo.delete)
local generated_overboard = false;
local generated_board = {};
local generated_thread = {};
local str_tbl = {"posts: "};
for i = 1, #posts do
local boardname = posts[i][1];
local number = posts[i][2];
local threadparent = (number and posts[i][3] == 0) and true or false;
local parent = threadparent and number or posts[i][3];
local always_regen_catalog = number and always_regen_catalog or true; -- override a_r_c for this iteration if only boardname was supplied
-- create string for log entry
if number then
str_tbl[#str_tbl + 1] = html.string.threadlink(boardname, parent, not threadparent and number or nil);
str_tbl[#str_tbl + 1] = ", ";
elseif not generated_board[boardname] then
str_tbl[#str_tbl + 1] = html.string.boardlink(boardname);
str_tbl[#str_tbl + 1] = ", ";
end
if (threadparent or always_regen_catalog) and not generated_overboard then
generate.overboard();
generated_overboard = true;
end
if (threadparent or always_regen_catalog) and not generated_board[boardname] then
generate.catalog(boardname);
generated_board[boardname] = true;
end
if number and not generated_thread[boardname .. parent] then
generate.thread(boardname, parent);
generated_thread[boardname .. parent] = true;
end
end
return #str_tbl > 1 and table.concat(str_tbl) or "no posts, "; -- "posts: /b/1, /b/2, " or "no posts, "
end
function file.thumbnail_dimensions_get(filename)
local stmt = nanodb:prepare("SELECT ThumbWidth, ThumbHeight FROM File WHERE Name = ?");
stmt:bind_values(filename);
local width = 0;
local height = 0;
if stmt:step() ~= sqlite3.ROW then
stmt:finalize();
width, height = file.thumbnail_dimensions_set(filename);
else
width, height = unpack(stmt:get_values());
stmt:finalize();
end
width = (width and width ~= 1) and width or "";
height = (height and height ~= 1) and height or "";
return width, height;
end
function file.thumbnail_dimensions_set(filename)
-- hack, change if thumbnails go above 255x255
local _,_,width = os.execute("width_temp=$(gm identify -format '%w' " .. file.thumbnail(filename) .. "); exit $width_temp");
local _,_,height = os.execute("height_temp=$(gm identify -format '%h' " .. file.thumbnail(filename) .. "); exit $height_temp");
local stmt = nanodb:prepare("INSERT INTO File VALUES (?, ?, ?)");
stmt:bind_values(filename, width, height);
stmt:step();
stmt:finalize();
return width, height;
end
function global.retrieveflag(flag, default) -- default given as bool
if not global.retrieve(flag) then
default = default and "1" or "0";
global.set(flag, default);
end
return (global.retrieve(flag) == "1") and true or false;
end
function global.setflag(flag, value) -- value given as bool
value = value and "1" or "0";
global.set(flag, value);
end
function html.recentsfilter()
local function checkbox(id, label)
io.write("");
io.write( " ");
end
io.write("
");
end
function misc.recents(page, regen) -- pass regen=true when using misc.retrieve to call this func
local filterstr, checkedcheckbox, bindablevalues = nil, false, {};
local limit = 128; -- default amount of posts per page
if regen then -- ignore form inputs and use defaults
filterstr = " WHERE tvolDeleteName = ''";
else
checkedcheckbox = FORM["checkedposts"] and true or false;
limit = (tonumber(FORM["postlimit"]) and tonumber(FORM["postlimit"]) <= 128)
and FORM["postlimit"] or limit;
local filter = {};
filter[#filter + 1] = FORM["sage"] and "Email = 'sage'" or nil;
filter[#filter + 1] = FORM["file"] and "File != ''" or nil;
filter[#filter + 1] = FORM["parent"] and "Parent = 0" or nil;
filter[#filter + 1] = FORM["custname"] and "Name != 'Nanonymous' AND Name != 'Anonymous'" or nil;
filter[#filter + 1] = FORM["tvoldelete"] and "tvolDeleteName != ''" or "tvolDeleteName = ''";
filterstr = (#filter > 0 and " WHERE " or "") .. table.concat(filter, " AND ")
end
bindablevalues[#bindablevalues + 1] = limit;
bindablevalues[#bindablevalues + 1] = tonumber((page - 1) * limit);
local stmt = nanodb:prepare("SELECT Board, Number, tvolDeleteName FROM Posts" .. (filterstr or "") .. " ORDER BY Date DESC LIMIT ? OFFSET ?");
stmt:bind_values(unpack(bindablevalues));
local posts = {};
for tbl in stmt:nrows() do
posts[#posts + 1] = tbl;
end
stmt:finalize();
io.write("");
end
function html.stats()
html.container.begin("wide");
html.table.begin("stats", "Board", "TPD (24h)", "TPW (7d)", "PPH (1h)", "PPH (24h)", "PPD (24h)", "PPD (7d)", "Total Posts");
local boards = board.list();
local total = {};
for i = 1, #boards do
local rows = {board.format(boards[i]),
board.tph(boards[i], 24, false), board.tph(boards[i], 168, false),
board.pph(boards[i], 1, false), board.pph(boards[i], 24, true),
board.pph(boards[i], 24, false), board.pph(boards[i], 168, true),
board.retrieve(boards[i])["MaxPostNumber"]};
html.table.entry(rows[1],
string.format("%d", rows[2]), string.format("%d", rows[3]),
string.format("%d", rows[4]), string.format("%.1f", rows[5]),
string.format("%d", rows[6]), string.format("%.1f", rows[7] * 24),
rows[8]);
for j = 2, #rows do
total[j] = (total[j] or 0) + rows[j];
end
end
html.table.entry("total",
string.format("%d", total[2]), string.format("%d", total[3]),
string.format("%d", total[4]), string.format("%.1f", total[5]),
string.format("%d", total[6]), string.format("%.1f", total[7] * 24),
total[8]);
html.table.finish();
html.container.finish();
end
function misc.retrieve(pagetype, regen)
-- move this into func args eventually
local globalvar, cachedfilename, generated_classname, gen_funcname, regenbuffertime, gendate_before_contents, gen_args;
if pagetype == "stats" then
globalvar, cachedfilename, generated_classname = "StatsLastRegen", "stats.html", "stats-regen-on";
regenbuffertime, gendate_before_contents, gen_funcname, gen_args = 30, false, html.stats, {}; -- wait for 30 seconds before regen when regen=false
elseif pagetype == "recent" then
globalvar, cachedfilename, generated_classname = "RecentsLastRegen", "recent.html", "recents-regen-on";
regenbuffertime, gendate_before_contents, gen_funcname, gen_args = 90, true, misc.recents, {1, true};
end
cachedfilename = "Cached/" .. cachedfilename;
local time = tonumber(global.retrieve(globalvar));
local timenow = os.time();
if not io.fileexists(cachedfilename) or regen or not time or timenow - time > regenbuffertime then
time = timenow;
global.set(globalvar, tostring(time));
io.output(cachedfilename);
gen_funcname(unpack(gen_args or {}));
io.close();
io.output(io.stdout);
end
local generated_on = table.concat{"
(Contents generated on ", os.date("!%F %T", time), ", ", tostring((timenow - time) or 0), " second", (timenow - time) == 1 and "" or "s", " ago.)
"};
io.write(gendate_before_contents and generated_on or "");
local f = io.open(cachedfilename, "r");
io.write(f:read("*a"));
f:close();
io.write(gendate_before_contents and "" or generated_on);
end
function identity.changeconfig(name, maxactions, maxmodpostage)
maxactions, maxmodpostage = tonumber(maxactions), tonumber(maxmodpostage);
if not (maxactions and maxmodpostage and maxmodpostage >= 0) then -- define ranges
return false;
elseif maxactions < -1 then
maxactions = -1;
end
local stmt = secretsdb:prepare("UPDATE Accounts SET MaxActionsPerDay = ? WHERE Name = ?");
stmt:bind_values(maxactions, name);
stmt:step();
stmt:finalize();
local stmt = secretsdb:prepare("UPDATE Accounts SET MaxModifiedPostAge = ? WHERE Name = ?");
stmt:bind_values(maxmodpostage, name);
stmt:step();
stmt:finalize();
return true;
end
-- pseudo deletion functions
function post.pseudo.delete(boardname, number, timenow)
local stmt = nanodb:prepare("UPDATE Posts SET tvolDeleteName = ? WHERE Board = ? AND Number = ?");
stmt:bind_values(username, boardname, number);
stmt:step();
stmt:finalize();
local stmt = nanodb:prepare("UPDATE Posts SET tvolDeleteDate = ? WHERE Board = ? AND Number = ?");
stmt:bind_values(timenow, boardname, number);
stmt:step();
stmt:finalize();
end
function post.pseudo.restore(boardname, number)
local stmt = nanodb:prepare("UPDATE Posts SET tvolDeleteName = '' WHERE Board = ? AND Number = ?");
stmt:bind_values(boardname, number);
stmt:step();
stmt:finalize();
local stmt = nanodb:prepare("UPDATE Posts SET tvolDeleteDate = 0 WHERE Board = ? AND Number = ?");
stmt:bind_values(boardname, number);
stmt:step();
stmt:finalize();
end
--
-- Miscellaneous functions.
--
function string.tokenize(input, delimiter)
local result = {};
if input == nil then
return {};
end
for match in (input .. delimiter):gmatch("(.-)" .. delimiter) do
result[#result + 1] = match;
end
return result;
end
function string.random(length, pattern)
length = length or 64;
pattern = pattern or "a-zA-Z0-9"
local result = "";
local ascii = {};
local dict;
misc.generateseed();
for i = 0, 255 do
ascii[#ascii + 1] = string.char(i);
end
ascii = table.concat(ascii);
dict = ascii:gsub("[^" .. pattern .. "]", "");
while string.len(result) < length do
local randidx = math.random(1, string.len(dict));
local randbyte = dict:byte(randidx);
result = result .. string.char(randbyte);
end
return result;
end
function string.striphtml(input)
local result = input;
result = result:gsub("<.->", "");
return result;
end
function string.escapehtml(input)
return input:gsub("&", "&")
:gsub("<", "<")
:gsub(">", ">")
:gsub("\"", """)
:gsub("'", "'");
end
function string.unescapehtml(input)
return input:gsub("&", "&")
:gsub("<", "<")
:gsub(">", ">")
:gsub(""", "\"")
:gsub("'", "'");
end
function io.fileexists(filename)
local f = io.open(filename, "r");
if f ~= nil then
f:close();
return true;
else
return false;
end
end
function io.filesize(filename)
local fp = io.open(filename);
local size = fp:seek("end");
fp:close();
return size;
end
--
-- CGI- and HTTP-related initialization
--
-- Initialize cgi variables.
cgi.pathinfo = ENV["PATH_INFO"] and string.tokenize(ENV["PATH_INFO"]:gsub("^/", ""), "/") or {}; -- removes preceeding slashes before tokenizing
cgi.referer = ENV["HTTP_REFERER"];
--
-- Global configuration functions.
--
function global.retrieve(name)
local stmt = nanodb:prepare("SELECT Value FROM Global WHERE Name = ?");
stmt:bind_values(name);
if stmt:step() ~= sqlite3.ROW then
stmt:finalize();
return nil;
end
local result = stmt:get_value(0);
stmt:finalize();
return result;
end
function global.delete(name)
local stmt = nanodb:prepare("DELETE FROM Global WHERE Name = ?");
stmt:bind_values(name);
stmt:step();
stmt:finalize();
end
function global.set(name, value)
if global.retrieve(name) ~= nil then
global.delete(name);
end
local stmt = nanodb:prepare("INSERT INTO Global VALUES (?, ?)");
stmt:bind_values(name, value);
stmt:step();
stmt:finalize();
end
--
-- Cryptographic functions.
--
function crypto.hash(hashtype, data)
-- local bstring = digest.new(hashtype):final(data);
-- local result = {};
-- for i = 1, #bstring do
-- result[#result + 1] = string.format("%02x", string.byte(bstring:sub(i,i)));
-- end
-- return table.concat(result);
return digest.new(hashtype):final(data);
end
--
-- Board-related functions.
--
function board.list()
local boards = {}
for tbl in nanodb:nrows("SELECT Name FROM Boards ORDER BY MaxPostNumber DESC") do
boards[#boards + 1] = tbl["Name"];
end
return boards;
end
function board.retrieve(name)
local stmt = nanodb:prepare("SELECT * FROM Boards WHERE Name = ?");
stmt:bind_values(name);
if stmt:step() ~= sqlite3.ROW then
stmt:finalize();
return nil;
end
local result = stmt:get_named_values();
stmt:finalize();
return result;
end
function board.validname(name)
return name and ((not name:match("[^a-z0-9]")) and (#name > 0) and (#name <= 8));
end
function board.validtitle(title)
return title and ((#title > 0) and (#title <= 32));
end
function board.validsubtitle(subtitle)
return subtitle and ((#subtitle >= 0) and (#subtitle <= 64));
end
function board.exists(name)
local stmt = nanodb:prepare("SELECT Name FROM Boards WHERE Name = ?");
stmt:bind_values(name);
local stepret = stmt:step();
stmt:finalize();
if stepret ~= sqlite3.ROW then
return false;
else
return true;
end
end
function board.format(name)
return board.validname(name) and ("/" .. name .. "/") or nil;
end
function board.create(name, title, subtitle)
if not board.validname(name) then
return nil;
end
local maxpostnumber = 0;
local lock = 0;
local maxthreadsperhour = 0;
local minthreadchars = 0;
local bumplimit = 300;
local postlimit = 350;
local threadlimit = 200;
local displayoverboard = 1;
local requirecaptcha = 0;
local captchatrigger = 30;
local stmt = nanodb:prepare("INSERT INTO Boards VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)");
stmt:bind_values(name,
string.escapehtml(title),
string.escapehtml(subtitle),
maxpostnumber,
lock,
displayoverboard,
maxthreadsperhour,
minthreadchars,
bumplimit,
postlimit,
threadlimit,
requirecaptcha,
captchatrigger);
stmt:step();
stmt:finalize();
os.execute("mkdir " .. name);
generate.mainpage();
generate.catalog(name);
generate.overboard();
end
function board.update(board_tbl)
-- escapehtml() Title and Subtitle before passing board_tbl to this function if needed
local stmt = nanodb:prepare("UPDATE Boards SET " ..
"Title = ?, Subtitle = ?, Lock = ?, MaxThreadsPerHour = ?, MinThreadChars = ?, " ..
"BumpLimit = ?, PostLimit = ?, ThreadLimit = ?, DisplayOverboard = ?, RequireCaptcha = ?, " ..
"CaptchaTriggerPPH = ? WHERE Name = ?");
stmt:bind_values(board_tbl["Title"], board_tbl["Subtitle"],
board_tbl["Lock"], board_tbl["MaxThreadsPerHour"], board_tbl["MinThreadChars"],
board_tbl["BumpLimit"], board_tbl["PostLimit"], board_tbl["ThreadLimit"], board_tbl["DisplayOverboard"],
board_tbl["RequireCaptcha"], board_tbl["CaptchaTriggerPPH"], board_tbl["Name"]);
stmt:step();
stmt:finalize();
generate.catalog(board_tbl["Name"]);
generate.overboard();
local threads = post.listthreads(board_tbl["Name"]);
for i = 1, #threads do
generate.thread(board_tbl["Name"], threads[i]);
end
end
-- Delete a board.
function board.delete(name)
local stmt = nanodb:prepare("DELETE FROM Boards WHERE Name = ?");
stmt:bind_values(name);
stmt:step();
stmt:finalize();
stmt = secretsdb:prepare("DELETE FROM Accounts WHERE Board = ?");
stmt:bind_values(name);
stmt:step();
stmt:finalize();
stmt = nanodb:prepare("DELETE FROM Posts WHERE Board = ?");
stmt:bind_values(name);
stmt:step();
stmt:finalize();
stmt = nanodb:prepare("DELETE FROM Refs WHERE Board = ?");
stmt:bind_values(name);
stmt:step();
stmt:finalize();
generate.mainpage();
generate.overboard();
end
-- Get number of threads made in the last 'hours' hours divided by 'hours'
function board.tph(name, hours, divide)
hours = hours or 12;
local start_time = os.time() - (hours * 3600);
local stmt = nanodb:prepare("SELECT COUNT(Number) FROM Posts WHERE Board = ? AND Date > ? AND Parent = 0");
stmt:bind_values(name, start_time);
stmt:step();
local count = stmt:get_value(0);
stmt:finalize();
return divide and count / hours or count;
end
-- Get board PPH (number of posts made in the last 'hours' hours divided by 'hours')
function board.pph(name, hours, divide)
hours = hours or 12;
local start_time = os.time() - (hours * 3600);
local stmt = nanodb:prepare("SELECT COUNT(Number) FROM Posts WHERE Board = ? AND Date > ?");
stmt:bind_values(name, start_time);
stmt:step();
local count = stmt:get_value(0);
stmt:finalize();
return divide and count / hours or count;
end
--
-- Identity (account) functions.
--
function identity.list()
local identities = {};
for tbl in secretsdb:nrows("SELECT Name FROM Accounts ORDER BY Name") do
identities[#identities + 1] = tbl["Name"];
end
return identities;
end
function identity.retrieve(name)
local stmt = secretsdb:prepare("SELECT * FROM Accounts WHERE Name = ?");
stmt:bind_values(name);
if stmt:step() ~= sqlite3.ROW then
stmt:finalize();
return nil;
end
local result = stmt:get_named_values();
stmt:finalize();
return result;
end
function identity.exists(name)
return identity.retrieve(name) and true or false;
end
-- Class can be either:
-- * "admin" - Site administrator, unlimited powers
-- * "bo" - Board owner, powers limited to a single board
-- * "gvol" - Global volunteer, powers limited by site administrators
-- * "lvol" - Local volunteer, powers limited by board owners, powers limited to a single board
-- * "tvol" - Trial volunteer, powers limited by global volunteers
function identity.create(class, name, password, boardname)
boardname = boardname or "Global";
local creator = username or "System";
local stmt = secretsdb:prepare("INSERT INTO Accounts VALUES (?,?,?,?,?,?,?)");
local hash = bcrypt.digest(password, 13);
stmt:bind_values(name, class, boardname, hash, creator, -1, 0);
stmt:step();
stmt:finalize();
end
function identity.validname(name)
return (not name:match("[^a-zA-Z0-9]")) and (#name >= 1) and (#name <= 16);
end
function identity.delete(name)
local stmt = secretsdb:prepare("DELETE FROM Accounts WHERE Name = ?");
stmt:bind_values(name);
stmt:step();
stmt:finalize();
stmt = secretsdb:prepare("DELETE FROM Sessions WHERE Account = ?");
stmt:bind_values(name);
stmt:step();
stmt:finalize();
stmt = nanodb:prepare("UPDATE Logs SET Name = 'Deleted' WHERE Name = ?");
stmt:bind_values(name);
stmt:step();
stmt:finalize();
end
function identity.changepassword(name, password)
local hash = bcrypt.digest(password, 13);
local stmt = secretsdb:prepare("UPDATE Accounts SET PwHash = ? WHERE Name = ?");
stmt:bind_values(hash, name);
stmt:step();
stmt:finalize();
end
function identity.validpassword(password)
return (#password >= 13) and (#password <= 64);
end
function identity.validclass(class)
return (class == "admin" or
class == "gvol" or
class == "bo" or
class == "lvol" or
class == "tvol")
end
function identity.valid(name, password)
local identity_tbl = identity.retrieve(name);
return identity_tbl and bcrypt.verify(password, identity_tbl["PwHash"]) or false;
end
function identity.session.delete(user)
local stmt = secretsdb:prepare("DELETE FROM Sessions WHERE Account = ?");
stmt:bind_values(user);
stmt:step();
stmt:finalize();
end
function identity.session.create(user)
-- Clear any existing keys for this user to prevent duplicates.
identity.session.delete(user);
local key = string.random(32);
local expiry = os.time() + 7200; -- key expires in 2 hours
local stmt = secretsdb:prepare("INSERT INTO Sessions VALUES (?,?,?)");
stmt:bind_values(key, user, expiry);
stmt:step();
stmt:finalize();
return key;
end
function identity.session.refresh(user)
local stmt = secretsdb:prepare("UPDATE Sessions SET ExpireDate = ? WHERE Account = ?");
stmt:bind_values(os.time() + 3600, user);
stmt:step();
stmt:finalize();
end
function identity.session.valid(key)
local result = nil;
if key == nil then return nil end;
for tbl in secretsdb:nrows("SELECT * FROM Sessions") do
if os.time() >= tbl["ExpireDate"] then
-- Clean away any expired session keys.
identity.session.delete(tbl["Account"]);
elseif tbl["Key"] == key then
result = tbl["Account"];
end
end
identity.session.refresh(result);
return result;
end
-- Captcha related functions.
function captcha.assemble_old(cc)
-- easier captcha
local xx, yy, rr, ss, bx, by = {},{},{},{},{},{},{};
misc.generateseed();
for i = 1, 6 do
xx[i] = ((48 * i - 168) + math.random(-5, 5));
yy[i] = math.random(-10, 10);
rr[i] = math.random(-30, 30);
ss[i] = math.random(-40, 40);
bx[i] = (150 + 1.1 * xx[i]);
by[i] = (40 + 2 * yy[i]);
end
local fd = io.popen(string.format(
"gm convert -size 312x70 xc:white -bordercolor black -border 5 " ..
"-fill black -stroke black -strokewidth 1 -pointsize 40 " ..
"-draw \"translate %d,%d rotate %d skewX %d gravity center text 0,0 '%s'\" " ..
"-draw \"translate %d,%d rotate %d skewX %d gravity center text 0,0 '%s'\" " ..
"-draw \"translate %d,%d rotate %d skewX %d gravity center text 0,0 '%s'\" " ..
"-draw \"translate %d,%d rotate %d skewX %d gravity center text 0,0 '%s'\" " ..
"-draw \"translate %d,%d rotate %d skewX %d gravity center text 0,0 '%s'\" " ..
"-draw \"translate %d,%d rotate %d skewX %d gravity center text 0,0 '%s'\" " ..
"-fill none -strokewidth 2 " ..
"-draw 'bezier %f,%d %f,%d %f,%d %f,%d' " ..
"-draw 'polyline %f,%d %f,%d %f,%d' -quality 0 -strip -colorspace GRAY JPEG:-",
xx[1], yy[1], rr[1], ss[1], cc[1],
xx[2], yy[2], rr[2], ss[2], cc[2],
xx[3], yy[3], rr[3], ss[3], cc[3],
xx[4], yy[4], rr[4], ss[4], cc[4],
xx[5], yy[5], rr[5], ss[5], cc[5],
xx[6], yy[6], rr[6], ss[6], cc[6],
bx[1], by[1], bx[2], by[2], bx[3], by[3], bx[4], by[4],
bx[4], by[4], bx[5], by[5], bx[6], by[6]
), "r");
local data = fd:read("*a");
fd:close();
return data;
end
function captcha.assemble(cc) -- cc is table of 6 characters, a-z
if io.fileexists("/usr/bin/captcha") then
local fd = io.popen("/usr/bin/captcha " .. table.concat(cc) .. " /dev/stdout");
local data = fd:read("*a");
fd:close();
return data;
else
return captcha.assemble_old(cc); -- fallback
end
end
function captcha.list() -- only returns captchas with unlisteddates later than current time
local captcha_list = {}
for tbl in secretsdb:nrows("SELECT Text FROM Captchas WHERE UnlistedDate > CAST(strftime('%s', 'now') AS INTEGER)") do
captcha_list[#captcha_list + 1] = tbl["Text"];
end
return captcha_list
end
function captcha.create(hard)
local max_captchas = 100; --100 -- captchas stored in db at a time
local expiretime = 3600; --3600 -- captcha max age, in seconds
local unlistedratio = 0.5; --0.5 -- percentage of expire time that the time to delisting (captcha not given upon request, but still valid) should be
captcha.deleteexpired();
local captcha_list = captcha.list();
if #captcha_list < max_captchas then
local replacements = {I="i", l="L"};
local cc = {};
for i = 1, 6 do
cc[i] = string.random(1, hard and "a-z" or "a-zA-Z1-9"); -- exclude 0 for the latter pattern
cc[i] = replacements[cc[i]] or cc[i]; -- if cc[i] exists as key in replacements, replace it
end
local captcha_text = table.concat(cc):lower();
local captcha_data = captcha[hard and "assemble" or "assemble_old"](cc);
local stmt = secretsdb:prepare("INSERT INTO Captchas VALUES (?, CAST(strftime('%s', 'now') AS INTEGER) + ?, CAST(strftime('%s', 'now') AS INTEGER) + ?, ?)");
stmt:bind_values(captcha_text, expiretime, math.floor(expiretime * unlistedratio), captcha_data);
stmt:step();
stmt:finalize();
return captcha_data
else
misc.generateseed();
local captcha_text = captcha_list[math.random(1, #captcha_list)]; -- select existing captcha at random
return captcha.retrieve(captcha_text)["ImageData"];
end
end
function captcha.retrieve(answer)
local stmt = secretsdb:prepare("SELECT * FROM Captchas WHERE Text = ? AND ExpireDate > CAST(strftime('%s', 'now') AS INTEGER)");
stmt:bind_values(answer);
if stmt:step() ~= sqlite3.ROW then
stmt:finalize();
return nil;
end
local result = stmt:get_named_values();
stmt:finalize();
return result;
end
function captcha.delete(answer)
local stmt = secretsdb:prepare("DELETE FROM Captchas WHERE Text = ?");
stmt:bind_values(answer);
stmt:step();
stmt:finalize();
end
function captcha.deleteexpired()
secretsdb:exec("DELETE FROM Captchas WHERE ExpireDate < CAST(strftime('%s', 'now') AS INTEGER)");
end
function captcha.valid(answer)
local captcha_solve_limit = 30; -- perform a hard reset on available captchas after this amount of captchas solved. increase this value accordingly whenever captcha activity increases to prevent a large buildup of captchas in the database, and decrease it when captchas become harder
captcha.deleteexpired();
if answer and captcha.retrieve(answer:lower()) then
captcha.delete(answer:lower())
local solved = (global.retrieve("CaptchasSolved") or 0) + 1;
if solved >= captcha_solve_limit then
secretsdb:exec("UPDATE Captchas SET UnlistedDate = CAST(strftime('%s', 'now') AS INTEGER)"); -- unlist all available captchas
global.set("CaptchasSolved", 0)
else
global.set("CaptchasSolved", solved)
end
return true;
else
return false;
end
end
local skey = COOKIE["session_key"];
username = identity.session.valid(skey);
acctclass = username and identity.retrieve(username)["Type"] or nil;
local assignboard = username and identity.retrieve(username)["Board"] or nil;
--
-- File handling functions.
--
-- Detect the format of a file (PNG, JPG, GIF).
function file.format(path, ext) -- file path, and original provided file extension
local fd = io.open(path, "r");
local data = fd:read(128);
fd:close();
if data == nil or #data == 0 then
return nil;
end
if data:sub(1,8) == "\x89PNG\x0D\x0A\x1A\x0A" then-- and ext == "png" then
return "png";
elseif data:sub(1,3) == "\xFF\xD8\xFF" then-- and (ext == "jpg" or ext == "jpeg") then
return "jpg";
elseif (data:sub(1,6) == "GIF87a"
or data:sub(1,6) == "GIF89a") then-- and ext == "gif" then
return "gif";
elseif (data:find("DOCTYPE svg", 1, true)
or data:find("