private: No


#!/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("<label for='", id, "'>", label, "</label>");
        io.write(  "<input id='", id, "' name='", id, "' type='checkbox' ", (FORM[id] and "checked " or ""), "/><br />");
    end

    io.write("<fieldset class='recents-filter'>");
    io.write(  "<form method='post'>");
    io.write(    "<h3 id='filterby'>Filter posts by:</h3>");
    io.write(    "<input id='submit' type='submit' value='Submit' /><br />");
                 checkbox("sage", "Saged");
                 checkbox("file", "Has File");
                 checkbox("parent", "Opening Post");
                 checkbox("custname", "Modified Name");
                 checkbox("tvoldelete", "Deleted by tvol");
    io.write(    "<br />");
    io.write(    "<label for='postlimit'>Max Posts Shown</label>");
    io.write(      "<input id='postlimit' name='postlimit' type='number' max='128' placeholder='128' value='", tonumber(FORM["postlimit"]) or "", "' /><br />");
                 checkbox("checkedposts", "Check All Posts");
    io.write(    "<br />");

    if not (username and acctclass ~= "tvol") and global.retrieveflag("NanoRequireCaptcha", false) then
        io.write(  "<img id='captcha-image' width='290' height='70' src='/Nano/captcha.jpg' /><br />");
        io.write(  "<label for='captcha'>Captcha</label><input type='text' id='captcha' name='captcha' autocomplete='off' maxlength='6' /><br />");
    end

    io.write(  "</form>");
    io.write("</fieldset>");
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("<form method='post' id='ranged' action='/Nano/mod/post/range'>");
    io.write(  "<div class='recents-range-action'>");
    io.write(    "<label for='action'>Range Action</label>");
    io.write(    "<select id='action' name='action' form='ranged'>");
    io.write(      "<option value='delete'>Delete</option>");
    io.write(      "<option value='restore'>Restore</option>");
    io.write(      "<option value='unlink'>Unlink</option>");
    io.write(      "<option value='sticky'>Sticky</option>");
    io.write(      "<option value='lock'>Lock</option>");
    io.write(      "<option value='autosage'>Autosage</option>");
    io.write(      "<option value='cycle'>Cycle</option>");
    io.write(    "</select>");
    io.write(    "<input id='submit' type='submit' value='Submit'>");
    io.write(  "</div>");

    for i = 1, #posts do
        local boardname, number = posts[i]["Board"], posts[i]["Number"];
        local success = html.post.render(boardname, number, true,  {i, checkedcheckbox}, true);
        io.write((success and i ~= #posts) and "<hr class='invisible' />" or "");
    end
    
    io.write("</form>");
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{"<br /><div class='", generated_classname, "'>(Contents generated on ", os.date("!%F %T", time), ", ", tostring((timenow - time) or 0), " second", (timenow - time) == 1 and "" or "s", " ago.)</div>"};
    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("&", "&amp;")
                :gsub("<", "&lt;")
                :gsub(">", "&gt;")
                :gsub("\"", "&quot;")
                :gsub("'", "&#39;");
end

function string.unescapehtml(input)
    return input:gsub("&amp;", "&")
                :gsub("&lt;", "<")
                :gsub("&gt;", ">")
                :gsub("&quot;", "\"")
                :gsub("&#39;", "'");
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 = '<i>Deleted</i>' 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("<svg", 1, true)) then-- and ext == "svg" then
        return "svg";
    elseif data:sub(1,4) == "\x1A\x45\xDF\xA3" then-- and ext == "webm" then
        return "webm";
    elseif (data:sub(5,12) == "ftypmp42"
           or data:sub(5,12) == "ftypisom") then-- and ext == "mp4" then
        return "mp4";
    elseif (data:sub(1,2) == "\xFF\xFB"
           or data:sub(1,3) == "ID3") then-- and ext == "mp3" then
        return "mp3";
    elseif data:sub(1,4) == "OggS" then-- and ext == "ogg" then
        return "ogg";
    elseif data:sub(1,4) == "fLaC" then-- and ext == "flac" then
        return "flac";
    elseif data:sub(1,4) == "%PDF" then-- and ext == "pdf" then
        return "pdf";
    elseif data:sub(1,4) == "PK\x03\x04"
           and data:sub(31,58) == "mimetypeapplication/epub+zip" then-- and ext == "epub" then
        return "epub";
    elseif ext == "txt" then -- no restrictions on contents
        return "txt";
    else
        return nil;
    end
end

function file.extension(filename)
    return filename:match(".*%.(.-)$");
end

function file.class(extension)
    local lookup = {
        ["png"] =	"image",
        ["jpg"] =	"image",
        ["gif"] =	"image",
        ["svg"] =	"image",
        ["webm"] =	"video",
        ["mp4"] =	"video",
        ["mp3"] =	"audio",
        ["ogg"] =	"audio",
        ["flac"] =	"audio",
        ["pdf"] =	"document",
        ["epub"] =	"document",
        ["txt"] =	"document"
    };

    return lookup[extension] or extension;
end

function file.has_thumbnails(extension)
    local file_class = file.class(extension);
    return ((file_class == "image") or (file_class == "video") or (extension == "pdf"));
end

function file.pathname(filename)
    return "Media/" .. filename;
end

function file.thumbnail(filename)
    return "Media/thumb/" .. filename;
end

function file.icon(filename)
    return "Media/icon/" .. filename;
end

function file.exists(filename)
    if filename == nil or filename == "" then
        return false;
    end

    return io.fileexists(file.pathname(filename));
end

function file.size(filename)
    return io.filesize(file.pathname(filename));
end

function file.format_size(size)
    if size > (1024 * 1024) then
        return string.format("%.2f MiB", (size / 1024 / 1024));
    elseif size > 1024 then
        return string.format("%.2f KiB", (size / 1024));
    else
        return string.format("%d B", size);
    end
end

-- Create a thumbnail which will fit into a 200x200 grid.
-- Graphicsmagick (gm convert) must be installed for this to work.
-- Will not modify images which are smaller than 200x200.
function file.create_thumbnail(filename)
    local path_orig = file.pathname(filename);
    local path_thumb = file.thumbnail(filename);
    local file_extension = file.extension(filename);
    local file_class = file.class(file_extension);

    if io.fileexists(path_thumb) then
        -- Don't recreate thumbnails if they already exist.
        return 0;
    end
    
    if file_class == "video" then
        return os.execute("ffmpeg -i " .. path_orig .. " -ss 00:00:01.000 -vframes 1 -f image2 - |" ..
                          "gm convert -strip - -filter Box -thumbnail 200x200 JPEG:" .. path_thumb);
    elseif file_class == "image" or file_extension == "pdf" then
        return os.execute("gm convert -strip " .. path_orig .. "[0] -filter Box -thumbnail 200x200 " ..
                          ((file_extension == "pdf" or file_extension == "svg") and "PNG:" or "")
                          .. path_thumb);
    end
end

-- Create a catalog icon (even smaller than a normal thumbnail).
-- Catalog icons must be extremely small and quality is not particularly important.
function file.create_icon(filename)
    local path_orig = file.pathname(filename);
    local path_icon = file.icon(filename);
    local file_extension = file.extension(filename);
    local file_class = file.class(file_extension);
    
    if io.fileexists(path_icon) then
        -- Don't recreate icons if they already exist.
        return 0;
    end

    if file_class == "video" then
        return os.execute("ffmpeg -i " .. path_orig .. " -ss 00:00:01.000 -vframes 1 -f image2 - |" ..
                          "gm convert -background '#BDC' -flatten -strip - -filter Box -quality 60 " ..
                          "-thumbnail 100x70 JPEG:" .. path_icon);
    else
        return os.execute("gm convert -background '#BDC' -flatten -strip " .. path_orig ..
                          "[0] -filter Box -quality 60 -thumbnail 100x70 JPEG:"
                          .. path_icon);
    end
end

-- Save a file and return its hashed filename. Errors will result in returning nil.
-- File hashes are always SHA-256 for compatibility with 8chan and friends.
function file.save(path, origname, create_catalog_icon)
    local extension = file.format(path, file.extension(origname));

    if extension == nil then
        return nil;
    end

    local fd = io.open(path);
    local data = fd:read("*a");
    fd:close();
    os.remove(path);

    local hash = crypto.hash("sha256", data);
    local filename = hash .. "." .. extension;
    
    fd = io.open("Media/" .. filename, "w");
    fd:write(data);
    fd:close();

    file.create_thumbnail(filename);

    if create_catalog_icon then
        file.create_icon(filename);
    end

    return filename;
end

function file.delete(filename)
    local stmt = nanodb:prepare("DELETE FROM File WHERE Name = ?");
    stmt:bind_values(filename);
    stmt:step();
    stmt:finalize();
    
    os.remove(file.pathname(filename));
    os.remove(file.thumbnail(filename));
    os.remove(file.icon(filename));
end

function post.retrieve(boardname, number)
    local stmt = nanodb:prepare("SELECT * FROM Posts WHERE Board = ? AND Number = ?");
    stmt:bind_values(boardname, tonumber(number));

    if stmt:step() ~= sqlite3.ROW then
        stmt:finalize();
        return nil;
    end

    local result = stmt:get_named_values();
    stmt:finalize();
    return result;
end

function post.listthreads(boardname)
    local threads = {};

    if boardname then
        local stmt = nanodb:prepare("SELECT Number FROM Posts WHERE Board = ? AND Parent = 0 ORDER BY Sticky DESC, LastBumpDate DESC");
        stmt:bind_values(boardname);

        for tbl in stmt:nrows() do
            threads[#threads + 1] = tonumber(tbl["Number"]);
        end

        stmt:finalize();
    end

    return threads;
end

function post.exists(boardname, number)
    local stmt = nanodb:prepare("SELECT Number FROM Posts WHERE Board = ? AND Number = ?");
    stmt:bind_values(boardname, number);
    local stepret = stmt:step();
    stmt:finalize();

    if stepret ~= sqlite3.ROW then
        return false;
    else
        return true;
    end
end

function post.bump(boardname, number)
    local stmt = nanodb:prepare("UPDATE Posts SET LastBumpDate = (SELECT MAX(Date) FROM (SELECT Date FROM Posts WHERE Board = ? AND (Number = ? OR Parent = ?) AND Email NOT LIKE 'sage' ORDER BY Date LIMIT (SELECT BumpLimit FROM Boards WHERE Name = ?))) WHERE Board = ? AND Number = ? AND Autosage = 0");
    stmt:bind_values(boardname, tonumber(number), tonumber(number), boardname, boardname, tonumber(number));
    stmt:step();
    stmt:finalize();
end

function post.toggle(attribute, boardname, number)
    local post_tbl = post.retrieve(boardname, number);
    local current_value = post_tbl[attribute];
    local new_value = (current_value == 1) and 0 or 1;
    local stmt = nanodb:prepare("UPDATE Posts SET " .. attribute .. " = ? WHERE Board = ? AND Number = ?");
    stmt:bind_values(new_value, boardname, number);
    stmt:step();
    stmt:finalize();
    
    return true;
end

function post.threadreplies(boardname, number)    
    local replies = {};
    local stmt = nanodb:prepare("SELECT Number FROM Posts WHERE Board = ? AND Parent = ? ORDER BY Number");
    stmt:bind_values(boardname, number);

    for tbl in stmt:nrows() do
        replies[#replies + 1] = tonumber(tbl["Number"]);
    end

    stmt:finalize();
    return replies;
end

function post.threadfiles(boardname, number, includeop)
    -- returns a list of post numbers of posts with files attached
    local numbers = {};
    
    if includeop then
        numbers[1] = (post.retrieve(boardname, number)["File"] ~= "") and number or nil;
    end
    
    local stmt = nanodb:prepare("SELECT Number FROM Posts WHERE Board = ? AND Parent = ? AND File != '' ORDER BY Number");
    stmt:bind_values(boardname, number);
    
    for tbl in stmt:nrows() do
        numbers[#numbers + 1] = tonumber(tbl["Number"]);
    end

    stmt:finalize();
    return numbers;
end

function post.parent(boardname, number)
    local stmt = nanodb:prepare("SELECT Parent FROM Posts WHERE Board = ? AND Number = ?");
    stmt:bind_values(boardname, number);

    if stmt:step() ~= sqlite3.ROW then
        stmt:finalize();
        return nil;
    end

    local result = stmt:get_value(0);
    stmt:finalize();
    return result;
end

function post.format(boardname, number)
    return board.format(boardname) .. number;
end

-- Turn nanochan-formatting into html.
function post.nano2html(text)
    text = "\n" .. text .. "\n";
    
    local function handle_url_brackets(before, url) -- partially stolen from picochan
        local lastchar = url:sub(-1);
        local closing = {["["] = "]", ["("] = ")"};

        if closing[before] and lastchar == closing[before] then -- the last )] is part of a bracket at the start of the url
            url = url:sub(1, -2);
        else
            lastchar = ""; -- do "nothing"
        end
        return string.format("%s<a rel='noreferrer' href='%s'>%s</a>%s", before, url, url, lastchar);
    end

    local function absolute_reference(boardname, number)
        local parent = post.parent(boardname, tonumber(number))

        if parent then
            if parent == 0 then
                return string.format("<a class='reference' href='/%s/%d.html'>&gt;&gt;&gt;/%s/%d</a>", boardname, number, boardname, number)
            else
                return string.format("<a class='reference' href='/%s/%d.html/#post%d'>&gt;&gt;&gt;/%s/%d</a>", boardname, parent, number, boardname, number)
            end
        else
            return string.format("&gt;&gt;&gt;/%s/%d", boardname, number)
        end
    end

    return text:gsub("&gt;&gt;(%d+)", "<a class='reference' href='#post%1'>&gt;&gt;%1</a>")
               :gsub("&gt;&gt;&gt;/([%d%l]-)/(%s)", "<a class='reference' href='/%1'>&gt;&gt;&gt;/%1/</a>%2")
               :gsub("&gt;&gt;&gt;/([%d%l]-)/(%d+)", absolute_reference)
               :gsub("\n(&gt;.-)\n", "\n<span class='greentext'>%1</span>\n")
               :gsub("\n(&gt;.-)\n", "\n<span class='greentext'>%1</span>\n")
               :gsub("\n(&lt;.-)\n", "\n<span class='pinktext'>%1</span>\n")
               :gsub("\n(&lt;.-)\n", "\n<span class='pinktext'>%1</span>\n")
               :gsub("%(%(%((.-)%)%)%)", "<span class='kiketext'>(((%1)))</span>")
               :gsub("==(.-)==", "<span class='redtext'>%1</span>")
               :gsub("%*%*(.-)%*%*", "<span class='spoiler'>%1</span>")
               :gsub("~~(.-)~~", "<s>%1</s>")
               :gsub("__(.-)__", "<u>%1</u>")
               :gsub("&#39;&#39;&#39;(.-)&#39;&#39;&#39;", "<b>%1</b>")
               :gsub("&#39;&#39;(.-)&#39;&#39;", "<i>%1</i>")
               :gsub("(.)(https?://[a-zA-Z0-9/_=&;:#~@%%%-%+%$%*%[%(%]%),%?%!%.]+" ..
                                  "[a-zA-Z0-9/_=&;:#~@%%%-%+%$%*%[%(%]%)])", handle_url_brackets)
               :sub(2, -2)
               :gsub("\n", "<br />");
end

-- This function does not delete the actual file. It simply removes the reference to that file.
function post.unlink(boardname, number)
    local post_tbl = post.retrieve(boardname, number);

    local stmt = nanodb:prepare("UPDATE Posts SET File = '' WHERE Board = ? AND Number = ?");
    stmt:bind_values(boardname, number);
    stmt:step();
    stmt:finalize();
    
    return true;
end

function post.delete(boardname, number)
    local post_tbl = post.retrieve(boardname, number);
    local stmt = nanodb:prepare("DELETE FROM Posts WHERE Board = ? AND Number = ?");
    stmt:bind_values(boardname, number);
    stmt:step();
    stmt:finalize();

    -- Delete descendants of that post too, if that post is a thread.
    stmt = nanodb:prepare("DELETE FROM Posts WHERE Board = ? AND Parent = ?");
    stmt:bind_values(boardname, number);
    stmt:step();
    stmt:finalize();

    -- Delete references to and from that post.
    stmt = nanodb:prepare("DELETE FROM Refs WHERE Board = ? AND (Referrer = ? OR Referee = ?)");
    stmt:bind_values(boardname, number, number);
    stmt:step();
    stmt:finalize();

    -- Delete references to and from every descendant post.
    stmt = nanodb:prepare("DELETE FROM Refs WHERE Board = ? AND (Referrer = (SELECT Number FROM Posts WHERE Board = ? AND Parent = ?) OR Referee = (SELECT Number FROM Posts WHERE Board = ? AND Parent = ?))");
    stmt:bind_values(boardname, boardname, number, boardname, number);
    stmt:step();
    stmt:finalize();

    -- Bump the thread back down if it still exists
    post.bump(boardname, post_tbl["Parent"]);

    return true;
end

function post.create(boardname, parent, name, email, subject, comment, filename)
    local stmt;
    local board_tbl = board.retrieve(boardname);
    parent = parent or 0;
    name = (name and name ~= "") and string.escapehtml(name) or "Nanonymous";
    email = email and string.escapehtml(email) or "";
    subject = subject and string.escapehtml(subject) or "";
    local references = {};

    -- Find >>xxxxx in posts before formatting is applied.
    for reference in comment:gmatch(">>([%d]+)") do
        references[#references + 1] = tonumber(reference);
    end

--    comment = comment and post.nano2html(string.escapehtml(comment)) or "";
    if comment then
        comment = string.escapehtml(comment);
        comment = (email == "noformatting" or email == "nofo") and comment:gsub("\n", "<br />")
                                                                or post.nano2html(comment);
    else
        comment = "";
    end
    
    filename = filename or "";
    local date = os.time();
    local lastbumpdate = date;
    local autosage = email == "sage" and 1 or 0;

--    if name == "##" then 
    if name == "##" and username ~= nil then
        local capcode;

        if acctclass == "admin" then
            capcode = "Nanochan Administrator";
        elseif acctclass == "bo" then
            capcode = "Board Owner (" .. board.format(assignboard) .. ")";
        elseif acctclass == "gvol" then
            capcode = "Global Volunteer";
        elseif acctclass == "lvol" then
            capcode = "Board Volunteer (" .. board.format(assignboard) .. ")";
        elseif acctclass == "tvol" then
            capcode = "Trial Volunteer";
--        else
--            capcode = "Reddit Administrator";
        end

        name = (username and username or "") .. " <span class='capcode'>## " .. capcode .. "</span>";
    end

    name = name:gsub("!(.+)", "<span class='tripcode'>!%1</span>");

    if parent ~= 0 and #post.threadreplies(boardname, parent) >= board_tbl["PostLimit"] then
        -- Delete earliest replies in cyclical thread.
        local stmt = nanodb:prepare("DELETE FROM Posts WHERE Board = ? AND Number = (SELECT Number FROM Posts WHERE Parent = ? AND Board = ? ORDER BY Number LIMIT 1)");
        stmt:bind_values(boardname, parent, boardname);
        stmt:step();
        stmt:finalize();
    elseif parent == 0 and #post.listthreads(boardname) >= board_tbl["ThreadLimit"] then
        -- Slide threads off the bottom of the catalog.
        local stmt = nanodb:prepare("DELETE FROM Posts WHERE Board = ? AND Number = (SELECT Number FROM Posts WHERE Parent = 0 AND Sticky = 0 AND Board = ? ORDER BY LastBumpDate LIMIT 1)");
        stmt:bind_values(boardname, boardname);
        stmt:step();
        stmt:finalize();
    end

    nanodb:exec("BEGIN IMMEDIATE TRANSACTION");
    stmt = nanodb:prepare("UPDATE Boards SET MaxPostNumber = MaxPostNumber + 1 WHERE Name = ?");
    stmt:bind_values(boardname);
    stmt:step();
    stmt:finalize();
    stmt = nanodb:prepare("SELECT MaxPostNumber FROM Boards WHERE Name = ?");
    stmt:bind_values(boardname);
    stmt:step();
    local number = stmt:get_value(0);
    stmt:finalize();
    nanodb:exec("END TRANSACTION");

    stmt = nanodb:prepare("INSERT INTO Posts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
    stmt:bind_values(boardname, number, parent, date, lastbumpdate, name, email, subject, comment, filename, 0, 0, autosage, 0, "", 0);
    stmt:step();
    stmt:finalize()

    -- Enable the captcha if too many posts were created, and it was not already enabled.
    if board_tbl["CaptchaTriggerPPH"] > 0 and
       board.pph(boardname, 1, false) > board_tbl["CaptchaTriggerPPH"] and
       tonumber(board_tbl["RequireCaptcha"]) == 0 then
        board_tbl["RequireCaptcha"] = 1;
        board.update(board_tbl);
        log.create(nil, boardname, "Automatically enabled captcha due to excessive PPH");
    end
    
    -- Enable the captcha if MaxThreadsPerHour was hit, and it was not already enabled.
    if board_tbl["MaxThreadsPerHour"] > 0 and
       board.tph(boardname, 12, false) >= board_tbl["MaxThreadsPerHour"] and
       tonumber(board_tbl["RequireCaptcha"]) == 0 then
        global.setflag("ThreadCaptcha", true);
        
        local boards = board.list();
        for i = 1, #boards do
            generate.catalog(boards[i]);
        end
        
        log.create(nil, nil, "Automatically enabled sitewide thread captcha due to excessive TP12H in ", board.format(boardname));
    end
    
    for i = 1, #references do
        stmt = nanodb:prepare("INSERT INTO Refs SELECT ?, ?, ? WHERE (SELECT COUNT(*) FROM Refs WHERE Board = ? AND Referee = ? AND Referrer = ?) = 0");
        stmt:bind_values(boardname, references[i], number, boardname, references[i], number);
        stmt:step();
        stmt:finalize();
    end

    if parent ~= 0 then
        if not (string.lower(email) == "sage") and
           not (#post.threadreplies(boardname, parent) > board_tbl["BumpLimit"]) then
            post.bump(boardname, parent);
        end
    end

    generate.thread(boardname, (parent ~= 0 and parent or number));
    generate.catalog(boardname);
    generate.overboard();

    return number;
end

--
-- Log access functions.
--

function log.create(account, boardname, action, reason)
    account = account or "<i>System</i>";
    boardname = boardname and html.string.boardlink(boardname) or "<i>Global</i>";
    action = action or "Did nothing";
    local date = os.time();
    local desc = reason and action .. " for reason: " .. post.nano2html(string.escapehtml(reason)) or action;
    
    local stmt = nanodb:prepare("INSERT INTO Logs VALUES (?,?,?,?)");
    stmt:bind_values(account, boardname, date, desc);
    stmt:step();
    stmt:finalize();
end

function log.retrieve(limit, offset)
    limit = limit or 128;
    offset = offset or 0;
    local entries = {};

    local stmt = nanodb:prepare("SELECT * FROM Logs ORDER BY Date DESC LIMIT ? OFFSET ?");
    stmt:bind_values(limit, offset);

    for tbl in stmt:nrows() do
        entries[#entries + 1] = tbl;
    end

    stmt:finalize();
    return entries;
end

--
-- HTML output functions.
--

function html.redirect(location)
    io.write("\n");
    io.write("<!DOCTYPE html>\n");
    io.write("<html>");
    io.write(  "<head>");
    io.write(    "<title>Redirecting...</title>");
    io.write(    "<meta http-equiv='refresh' content='0;url=", location or "/", "' />");
    io.write(  "</head>");
    io.write(  "<body>");
    io.write(    "Redirecting to <a href='", location,"'>", location, "</a>");
    io.write(  "</body>");
    io.write("</html>");
end

function html.begin(title, name, value)
    if title == nil then
        title = ""
    else
        title = title .. " - "
    end
        
    if name and value then
        io.write("Set-Cookie: ", name, "=", value, ";Path=/Nano;Http-Only\n");
    end
    
    io.write("\n");
    io.write("<!DOCTYPE html>\n");
    io.write("<html>");
    io.write(  "<head>");
    io.write(    "<title>", title, "nanochan</title>");
    io.write(    "<link rel='stylesheet' type='text/css' href='/Static/nanochan.css' />");
    io.write(    "<link rel='shortcut icon' type='image/png' href='/Static/favicon.png' />");

    io.write(    "<meta charset='utf-8' />");
    io.write(    "<meta name='viewport' content='width=device-width, initial-scale=1.0' />");
    io.write(  "</head>");
    io.write(  "<body>");
    io.write(    "<div id='topbar'>");
    io.write(      "<nav id='topnav'>");
    io.write(        "<ul>");
    io.write(          "<li class='system'><a href='/index.html'>main</a></li>");
    io.write(          "<li class='system'><a href='/Nano/mod'>mod</a></li>");
    io.write(          "<li class='system'><a href='/Nano/log'>log</a></li>");
    io.write(          "<li class='system'><a href='/Nano/stats'>stats</a></li>");
    io.write(          "<li class='system'><a href='/Nano/recent'>recent</a></li>");
    io.write(          "<li class='system'><a href='/overboard.html'>overboard</a><a href='/overboarda.html'>[A]</a></li>");

    local boards = board.list();
    for i = 1, #boards do
        io.write("<li class='board'><a href='/", boards[i], "/'>", board.format(boards[i]), "</a></li>");
    end

    io.write(        "</ul>");
    io.write(      "</nav>");
    io.write(    "</div>");
    io.write(    "<div id='content'>");
end

function html.finish()
    io.write(    "</div>");
    io.write(  "</body>");
    io.write("</html>");
end

function html.redheader(text)
    io.write("<h1 class='redheader'>", text, "</h1>");
end

function html.announce()
    if global.retrieve("announce") then
        io.write("<div id='announce'>", global.retrieve("announce"), "</div>");
    end
end

function html.pageswitcher(currentpage)
    io.write("<div class='page-switcher'>");
    io.write("<a class='page-switcher-prev' href='?page=", currentpage - 1, "'>[Prev]</a>");
    io.write("<a class='page-switcher-next' href='?page=", currentpage + 1, "'>[Next]</a>");
    io.write("</div>");
end

function html.container.begin(type, classes)
    io.write("<div class='container ", type or "narrow", " ", classes or "", "'>");
end

function html.container.finish()
    io.write("</div>");
end

function html.container.barheader(text)
    io.write("<h2 class='barheader'>", text, "</h2>");
end

function html.table.begin(id, ...)
    local arg = {...};
    io.write("<table id='", id, "'>");
    io.write("<tr>");

    for i = 1, #arg do
        io.write("<th>", arg[i], "</th>");
    end

    io.write("</tr>");
end

function html.table.entry(...)
    local arg = {...};
    io.write("<tr>");

    for i = 1, #arg do
        io.write("<td>", tostring(arg[i]), "</td>");
    end

    io.write("</tr>");
end

function html.table.finish()
    io.write("</table>");
end

function html.list.begin(type)
    io.write(type == "ordered" and "<ol>" or "<ul>");
end

function html.list.entry(text, class)
    io.write("<li", class and (" class='" .. class .. "' ") or "", ">", text, "</li>");
end

function html.list.finish(type)
    io.write(type == "ordered" and "</ol>" or "</ul>");
end

-- Pre-defined pages.
function html.pdp.authorization_denied()
    html.begin("permission denied");
    html.redheader("Permission denied");
    html.container.begin();
    io.write("Your account class lacks authorization to perform this action. <a href='/Nano/mod'>Go back.</a>");
    html.container.finish();
    html.finish();
end

function html.pdp.error(heading, explanation)
    html.begin("error");
    html.redheader(heading);
    html.container.begin();
    io.write(explanation);
    html.container.finish();
    html.finish();
end

function html.pdp.notfound()
    html.begin("404");
    html.redheader("404 not found");
    html.container.begin();
    io.write("The resource which was requested does not appear to exist. Please check the URL");
    io.write(" and try again. Alternatively, if you believe this error message to in itself");
    io.write(" be an error, try contacting the nanochan administration.");
    html.container.finish();
    html.finish();
end

function html.string.link(href, text, title)
    if not href then
        return nil;
    end

    local result = "<a href='" .. href .. "'";

    if href:sub(1, 1) ~= "/" then
        result = result .. " rel='noreferrer' target='_blank'";
    end

    if title then
        result = result .. " title='" .. title .. "'";
    end

    result = result .. ">" .. (text or href) .. "</a>";
    return result;
end

function html.string.datetime(unixtime)
    local isotime = os.date("!%F %T", unixtime);
    return "<time datetime='" .. isotime .. "'>" .. isotime .. "</time>";
end

function html.string.boardlink(boardname)
    return html.string.link(board.format(boardname));
end

function html.string.threadlink(boardname, number, child)
    child = child ~= "" and child or nil;
    local childnew = child and "#post" .. child or "";
    return html.string.link(post.format(boardname, number) .. ".html" .. childnew, post.format(boardname, child or number));
end

function html.board.title(boardname)
    io.write("<h1 id='boardtitle'>", board.format(boardname), " - ", board.retrieve(boardname)["Title"], "</h1>");
end

function html.board.subtitle(boardname)
    io.write("<h2 id='boardsubtitle'>", board.retrieve(boardname)["Subtitle"], "</h2>");
end

function html.post.postform(boardname, parent)
    local board_tbl = board.retrieve(boardname);

    if tonumber(board_tbl["Lock"]) == 1 and not username then
        return;
    end

    io.write("<a id='new-post' href='#postform' accesskey='p'>", (parent == 0) and "[Start a New Thread]" or "[Make a Post]", "</a>");
    io.write("<fieldset><form id='postform' action='/Nano/post' method='post' enctype='multipart/form-data'>");
    io.write("<input type='hidden' name='board' value='", boardname, "' />");
    io.write("<input type='hidden' name='parent' value='", parent, "' />");
    io.write("<a href='##' class='close-button' accesskey='w'>[X]</a>");
    io.write("<label for='name'>Name</label><input type='text' id='name' name='name' maxlength='64' /><br />");
    io.write("<label for='email'>Email</label><input type='text' id='email' name='email' maxlength='64' /><br />");
    io.write("<label for='subject'>Subject</label><input type='text' id='subject' name='subject' maxlength='64' />");
    io.write("<input type='submit' value='Post' accesskey='s' /><br />");
    io.write("<label for='comment'>Comment</label><textarea id='comment' name='comment' form='postform' rows='5' cols='35' maxlength='32768'></textarea><br />");
    io.write("<label for='file'>File</label><input type='file' id='file' name='file' /><br />");

    if tonumber(board_tbl["RequireCaptcha"]) == 1 or (parent == 0 and global.retrieveflag("ThreadCaptcha", false)) then
        io.write("<label for='captcha'>Captcha</label><input type='text' id='captcha' name='captcha' autocomplete='off' maxlength='6' /><br />");
        io.write("<img id='captcha-image' width='312' height='70' src='/Nano/captcha.jpg' />");
    end

    io.write("</form></fieldset>");
end

function html.post.modlinks(boardname, number, pseudodeleted, filename, parent)
    io.write("<span class='thread-mod-links'>");

    io.write("<a href='/Nano/mod/post/delete/", boardname, "/", number, "' title='Delete'>[D]</a>");
    if pseudodeleted then
        io.write("<a href='/Nano/mod/post/restore/", boardname, "/", number, "' title='Restore'>[R]</a>");
    end

    if file.exists(filename) then
        io.write("<a href='/Nano/mod/post/unlink/", boardname, "/", number, "' title='Unlink File'>[U]</a>");
        io.write("<a href='/Nano/mod/file/delete/", filename, "' title='Delete File'>[F]</a>");
    end

    if not parent then
        io.write("<a href='/Nano/mod/post/sticky/", boardname, "/", number, "' title='Sticky'>[S]</a>");
        io.write("<a href='/Nano/mod/post/lock/", boardname, "/", number, "' title='Lock'>[L]</a>");
        io.write("<a href='/Nano/mod/post/autosage/", boardname, "/", number, "' title='Autosage'>[A]</a>");
        io.write("<a href='/Nano/mod/post/cycle/", boardname, "/", number, "' title='Cycle'>[C]</a>");
    end

    io.write("</span>");
end

function html.post.threadflags(boardname, number)
    local post_tbl = post.retrieve(boardname, number);
    io.write("<span class='thread-info-flags'>");
    if post_tbl["Sticky"] == 1 then io.write("(S)"); end;
    if post_tbl["Lock"] == 1 then io.write("(L)"); end;
    if post_tbl["Autosage"] == 1 then io.write("(A)"); end;
    if post_tbl["Cycle"] == 1 then io.write("(C)"); end;
    io.write("</span>");
end

function html.post.render_catalog(boardname, number, post_tbl, bypass_tvoldelete)
    local parent = (post_tbl["Parent"] ~= 0) and post_tbl["Parent"] or nil;
    local filename = post_tbl["File"];
    local pseudodeleted = (post_tbl["tvolDeleteName"] and post_tbl["tvolDeleteName"] ~= "");
    if not bypass_tvoldelete and pseudodeleted then
        return false;
    end

    io.write("<div class='catalog-thread'>");
    io.write(  "<div class='catalog-thread-link'><a href='/", boardname, "/", number, ".html'>");

    if file.exists(post_tbl["File"]) then
        local file_ext = file.extension(post_tbl["File"]);
        local file_class = file.class(file_ext);

        if file.has_thumbnails(file_ext) then
            io.write("<img src='/" .. file.icon(post_tbl["File"]) .. "' alt='***' />");
        else
            io.write("<img width='100' height='70' src='/Static/", file_class, ".png' />");
        end
    else
        io.write("***");
    end

    io.write(  "</a></div>");
    io.write(  "<div class='thread-info'>");
    io.write(    "<span class='thread-board-link'><a href='/", boardname, "'>", board.format(boardname), "</a></span> ");
    io.write(    "<span class='thread-info-replies'>R:", #post.threadreplies(boardname, number), "</span>");
    html.post.threadflags(boardname, number);
    io.write(  "</div>");

    io.write(  "<div class='catalog-thread-latest-post'>L: ");
--    html.string.datetime(post_tbl["LastBumpDate"]));
    io.write(    "<time datetime='", os.date("!%F %T", post_tbl["LastBumpDate"]), "'>");
    io.write(      os.date("!%F %H:%M", post_tbl["LastBumpDate"]));
    io.write(    "</time>");
    io.write(  "</div>");

    html.post.modlinks(boardname, number, pseudodeleted, filename, parent);

    io.write(  "<div class='catalog-thread-subject'>");
    io.write(     post_tbl["Subject"] or "");
    io.write(  "</div>");

    io.write(  "<div class='catalog-thread-comment'>");
    io.write(     post_tbl["Comment"]);
    io.write(  "</div>");
    io.write("</div>");
    
    return true;
end

-- Omitting the 'boardname' value will turn the catalog into an overboard.
function html.post.catalog(boardname, all)
    io.write("<a href='' accesskey='r'>[Update]</a>");
    io.write("<hr />");
    io.write("<div class='catalog-container'>");

    if boardname ~= nil then
        -- Catalog mode.
        local threadlist = post.listthreads(boardname);
        for i = 1, #threadlist do
            local post_tbl = post.retrieve(boardname, threadlist[i]);
            local success = html.post.render_catalog(boardname, threadlist[i], post_tbl, false);
            io.write(success and "<hr class='invisible' />" or "");
        end
    else
        -- Overboard mode.
        if all then
            for post_tbl in nanodb:nrows("SELECT Board, Number, Parent, File, tvolDeleteName, LastBumpDate, Subject, Comment FROM Posts WHERE Parent = 0 ORDER BY LastBumpDate DESC LIMIT 100") do
                local success = html.post.render_catalog(post_tbl["Board"], post_tbl["Number"], post_tbl, false);
                io.write(success and "<hr class='invisible' />" or "");
            end
        else
            for post_tbl in nanodb:nrows("SELECT Board, Number, Parent, File, tvolDeleteName, LastBumpDate, Subject, Comment FROM Posts WHERE Parent = 0 AND Autosage = 0 AND (SELECT DisplayOverboard FROM Boards WHERE Name = Board) IN (1, '1', 1.0, '1.0') ORDER BY LastBumpDate DESC LIMIT 100") do
                local success = html.post.render_catalog(post_tbl["Board"], post_tbl["Number"], post_tbl, false);
                io.write(success and "<hr class='invisible' />" or "");
            end
        end
    end

    io.write("</div>");
end

function html.post.renderthumbnail(filename)
    local file_ext = file.extension(filename);
    local file_class = file.class(file_ext);

    io.write("<div class='post-file-info'>");
    io.write("File: <a href='/Media/", filename, "' target='_blank'>", filename, "</a>");
    io.write(" (<a href='/Media/", filename, "' download>", "dl</a>)");
    io.write(" (", file.format_size(file.size(filename)), ")");
    io.write("</div>");

    if file.has_thumbnails(file_ext) then
        local width, height = file.thumbnail_dimensions_get(filename);
        io.write("<a target='_blank' href='/Media/" .. filename .. "'>");
        io.write(  "<img class='post-file-thumbnail' width='", width, "' height='", height, "' src='/", file.thumbnail(filename), "' />");
        io.write("</a>");
    elseif file_class == "audio" then
        io.write("<audio class='post-audio' preload='none' controls loop>");
        io.write(  "<source src='/Media/", filename, "' type='audio/", file_ext, "' />");
        io.write("</audio>");
    elseif file_ext == "epub" or file_ext == "txt" then
        io.write("<a target='_blank' href='/Media/" .. filename .. "'>");
        io.write(  "<img width='100' height='70' class='post-file-thumbnail' src='/Static/", file_ext == "epub" and "document" or "text", ".png' />");
        io.write("</a>");
    end
end

function html.post.render(boardname, number, standalone, showcheckbox, bypass_tvoldelete) -- showcheckbox: false/nil if hidden, table of {integer_value_of_position_in_page, checkbox_is_prechecked} if shown
    local post_tbl = post.retrieve(boardname, number);
    local parent = (post_tbl["Parent"] ~= 0) and post_tbl["Parent"] or nil;
    local filename = post_tbl["File"];
    
    local pseudodeleted = (post_tbl["tvolDeleteName"] and post_tbl["tvolDeleteName"] ~= "");
    if pseudodeleted and not bypass_tvoldelete then
        return false;
    end
    
    io.write("<div class='post-container' id='post", number, "'>");

    if showcheckbox then
        local boardnum = table.concat{"post_", showcheckbox[1], "_", boardname, "_", number};
        io.write("<input id='", boardnum, "' name='", boardnum, "' class='rangeaction' type='checkbox'", showcheckbox[2] and " checked" or "", ">");
    end

    io.write(  "<div class='post'>");
    io.write(    "<div class='post-header'>");
    
    if standalone then
        io.write("<span class='boardname'><b>" .. board.format(boardname) .. (parent or "") .. "</b> -> </span>");
    end
    
    io.write(      "<span class='post-subject'>", post_tbl["Subject"] or "", "</span> ");
    io.write(      "<span class='post-name'>");

    if post_tbl["Email"] ~= "" then
        io.write(    "<a class='post-email' href='mailto:", post_tbl["Email"], "'>");
    end

    io.write(    post_tbl["Name"]);

    if post_tbl["Email"] ~= "" then
        io.write(    "</a>");
    end

    local hrefpre = standalone and board.format(boardname) .. (parent or number) .. ".html/" or "";
    io.write(      "</span> ");
    io.write(      "<span class='post-date'>", html.string.datetime(post_tbl["Date"]), "</span> ");
    io.write(      "<span class='post-number'>");
    io.write(        "<a href='", hrefpre, "#post", number, "'>No.</a>");
    io.write(        "<a href='", hrefpre, "#postform'>", post_tbl["Number"], "</a>");
    io.write(      "</span> ");

    if post_tbl["Parent"] == 0 then
        html.post.threadflags(boardname, number);
    end
    html.post.modlinks(boardname, number, pseudodeleted, filename, parent);

    local stmt = nanodb:prepare("SELECT Referrer FROM Refs WHERE Board = ? AND Referee = ? ORDER BY Referrer");
    stmt:bind_values(boardname, number);
    for referee in stmt:nrows() do
        io.write(  " <a class='referee' href='", standalone and board.format(boardname) .. (parent or number) .. ".html/" or "", "#post", referee["Referrer"], "'>&gt;&gt;", referee["Referrer"], "</a>");
    end
    stmt:finalize();
    
    if pseudodeleted then
        io.write(  " (Deleted by <i>", post_tbl["tvolDeleteName"], "</i>",
                   " on ", os.date("!%F %T", post_tbl["tvolDeleteDate"]), ") ");
    end

    io.write(    "</div>");

    if file.exists(post_tbl["File"]) then
        html.post.renderthumbnail(post_tbl["File"]);
    end

    io.write(    "<div class='post-comment'>");
    io.write(    post_tbl["Comment"]);
    io.write(    "</div>");
    io.write(  "</div>");

    io.write(  "<br />");
    io.write("</div>");
    
    return true;
end

function html.post.renderthread(boardname, number)
    html.post.render(boardname, number, false, false, false);
    io.write("<hr class='invisible' />");

    local replies = post.threadreplies(boardname, number);
    for i = 1, #replies do
        local success = html.post.render(boardname, replies[i], false, false, false);
        io.write((success and i ~= #replies) and "<hr class='invisible' />" or "");
    end
end

function generate.mainpage()
    io.output("index.html");

    html.begin();
    html.redheader("Welcome to Nanochan");
    html.announce(global.retrieve("announce"));
    html.container.begin("narrow");
    io.write("<img id='front-page-logo' src='/Static/logo.png' alt='Nanochan logo' width=400 height=400 />");
    html.container.barheader("Boards");

    local boards = board.list();
    html.list.begin("ordered");
    for i = 1, #boards do
        local board_tbl = board.retrieve(boards[i]);
        html.list.entry(html.string.boardlink(board_tbl["Name"]) .. " - " .. board_tbl["Title"]);
    end
    html.list.finish("ordered");

    html.container.barheader("Rules");
    io.write("These rules apply to all boards on nanochan:");
    html.list.begin("ordered");
    html.list.entry("Child pornography is not permitted. Links to child pornography are not permitted either, " ..
                    "and neither are links to websites which contain a significant number of direct links to CP.");
    html.list.entry("Flooding is not permitted. We define flooding as posting similar posts more " ..
                    "than 3 times per hour, making a thread on a topic for which a thread already exists, " ..
                    "or posting in such a way that it significantly " ..
                    "changes the composition of a board. Common sense will be utilized.");
    html.list.finish("ordered");
    io.write("Individual boards may set their own rules which apply to that board, and boards which do not specify their own rules typically follow the board-specific rules of /l/. However, note that the rules stated above apply to everything done on the website.");

    html.container.barheader("Miscellaneous");
    io.write("Source code for Nanochan can be found ", html.string.link("/source.lua", "here"), ".<br />");
--    io.write("To contact the administration, send an e-mail to ", html.string.link("mailto:37564N@memeware.net", "this address"), ".");
    io.write("The post deletion log can be found ", html.string.link("/audit.log", "here"), ".<br />");
    io.write("The SHA256 TLS fingerprint for the current v3 address is:<br />");
    io.write("25:3A:27:5A:DD:3E:40:BA:88:3E:E7:4F:C6:4F:8B:FC:<br />");
    io.write("FB:41:78:7F:64:6C:45:AE:26:75:3E:3E:E3:8E:DC:59.");

    html.container.finish();
    html.finish();

    io.output(io.stdout);
end

function generate.overboard()
    io.output("overboard.html");
    nanodb:exec("BEGIN TRANSACTION");

    html.begin("overboard");
    html.redheader("Nanochan overboard");
    html.announce();
    html.post.catalog(nil, false);
    html.finish();

    nanodb:exec("END TRANSACTION");

    io.output("overboarda.html");

    html.begin("overboard");
    html.redheader("Nanochan overboard");
    html.announce();
    html.post.catalog(nil, true);
    html.finish();

    io.output(io.stdout);
end

function generate.thread(boardname, number)
    local post_tbl = post.retrieve(boardname, number);
    if not post_tbl then
        return;
    elseif post_tbl["Parent"] ~= 0 then
        post_tbl = post.retrieve(boardname, post_tbl["Parent"]);
        number = post_tbl["Number"];
    end

    io.output(boardname .. "/" .. number .. ".html");
    nanodb:exec("BEGIN TRANSACTION");

    local desc = (#post_tbl["Subject"] > 0 and post_tbl["Subject"] or string.striphtml(post_tbl["Comment"]):sub(1, 64));
    html.begin(board.format(boardname) .. ((#desc > 0) and (" - " .. desc) or ""));

    html.board.title(boardname);
    html.board.subtitle(boardname);
    html.announce();

    html.post.postform(boardname, number);
    io.write("<hr />");
    html.post.renderthread(boardname, number);
    io.write("<hr />");

    io.write("<div id='bottom-links' />");
    io.write("<a href='/", boardname, "/catalog.html'>[Catalog]</a>");
    io.write("<a href='/overboard.html'>[Overboard]</a>");
    io.write("<a href='' accesskey='r'>[Update]</a>");
    io.write("<div id='thread-reply'>");
    io.write(  "<a href='#postform'>[Reply]</a>");
    io.write(  #post.threadfiles(boardname, number, true), " files, ");
    io.write(  #post.threadreplies(boardname, number), " replies");
    io.write("</div></div>");

    html.finish();
    nanodb:exec("END TRANSACTION");
    io.output(io.stdout);
end

function generate.catalog(boardname)
    io.output(boardname .. "/" .. "catalog.html");
    nanodb:exec("BEGIN TRANSACTION");
    html.begin(board.format(boardname));

    html.board.title(boardname);
    html.board.subtitle(boardname);
    html.announce();

    html.post.postform(boardname, 0);
    html.post.catalog(boardname);

    html.finish();
    nanodb:exec("END TRANSACTION");
    io.output(io.stdout);
end

-- Write HTTP headers.
io.write("Cache-Control: no-cache\n",
         "Referrer-Policy: same-origin\n",
         "X-DNS-Prefetch-Control: off\n",
         "X-Frame-Options: deny\n");
io.write("Content-Security-Policy: default-src 'none'; connect-src 'self'; form-action 'self'; ",
                                  "img-src 'self'; style-src 'self'; media-src 'self';\n");
if cgi.pathinfo[1] == "captcha.jpg" then
    io.write("Content-Type: image/jpeg\n");
    io.write("\n");
else
    io.write("Content-Type: text/html; charset=utf-8\n");
    -- io.write("\n"); -- signifies end of headers and beginning of html document
end

--
-- This is the main part of Nanochan, where all the pages are defined.
--

if cgi.pathinfo[1] == nil or cgi.pathinfo[1] == "" then
    -- /Nano
    html.redirect("/index.html");
elseif cgi.pathinfo[1] == "captcha.jpg" then
    local hard = global.retrieveflag("HarderCaptchas", false);
    local captcha_data = captcha.create(hard);
    io.write(captcha_data);
elseif cgi.pathinfo[1] == "stats" then
    html.begin("stats");
    html.redheader("Nanochan statistics");
    
    if not (username and acctclass ~= "tvol") and global.retrieveflag("NanoRequireCaptcha", false) then
        if captcha.valid(POST["captcha"]) then
            misc.retrieve("stats", true);
        else
            misc.retrieve("stats", false);
        end
        io.write("<br />");

        html.container.begin();
        if POST["captcha"] then
            io.write("The captcha you entered was incorrect.");
        end
        html.container.barheader("Regenerate stats");
        io.write("<fieldset><form method='post'>");
        io.write(  "<img id='captcha-image' width='290' height='70' src='/Nano/captcha.jpg' /><br />");
        io.write(  "<label for='captcha'>Captcha</label><input type='text' id='captcha' name='captcha' autocomplete='off' maxlength='6' /><br />");
        io.write(  "<label for='submit'>Submit</label><input id='submit' type='submit' value='Continue' />");
        io.write("</form></fieldset>");
        html.container.finish();        
    else
        misc.retrieve("stats", true);
    end

    html.finish();
elseif cgi.pathinfo[1] == "log" then
    -- /Nano/log
    html.begin("logs");
    html.redheader("Nanochan log");
    html.container.begin("wide");

    local page = tonumber(GET["page"]);
    if page == nil or page <= 0 then
        page = 1;
    end

    html.pageswitcher(page);
    html.table.begin("log", "Account", "Board", "Time", "Description");

    local entries = log.retrieve(128, tonumber((page - 1) * 128));
    for i = 1, #entries do
        html.table.entry(entries[i]["Name"],
                         entries[i]["Board"],
                         html.string.datetime(entries[i]["Date"]),
                         entries[i]["Description"]);
    end

    html.table.finish();
    html.pageswitcher(page);
    html.container.finish();
    html.finish();
    os.exit();
elseif cgi.pathinfo[1] == "recent" then
    local reqcap = global.retrieveflag("NanoRequireCaptcha", false);
    -- /Nano/recent
    html.begin("recent posts");
    html.redheader("List of recent posts");
    html.container.begin("wide", "recents");
    
    local page = tonumber(GET["page"]);
    if page == nil or page <= 0 then
        page = 1;
    end
    html.pageswitcher(page);
    
    html.recentsfilter();
    io.write("<hr />");
    
    if reqcap and not (username and acctclass ~= "tvol") and not captcha.valid(POST["captcha"]) then
        io.write(POST["captcha"] and "The captcha you entered was incorrect" or "No captcha provided",
                 ". Filter options ignored.<br />");
        misc.retrieve("recent", false);
    else
        misc.recents(page, false);
    end
    
    io.write("<hr />");
    html.pageswitcher(page);
    html.container.finish();
    html.finish();
    os.exit();
elseif cgi.pathinfo[1] == "modlist" then
    local identities = identity.list();
    html.begin("mod list");
    html.redheader("List of Moderators");
    
    html.container.begin("wide");
    html.table.begin("modlist", "Name", "Class", "Board", "Creator", "Actions per day", "Max age of modifiable post (s)", "Configure", "Delete");
    
    for i = 1, #identities do
        local identity = identity.retrieve(identities[i]);
        local configure = "<a href='/Nano/mod/account/config/" .. identity["Name"] .. "'>[Configure]</a>";
        local delete = "<a href='/Nano/mod/account/delete/" .. identity["Name"] .. "'>[Delete]</a>";
        html.table.entry(identity["Name"], identity["Type"], identity["Board"], identity["Creator"], identity["MaxActionsPerDay"], identity["MaxModifiedPostAge"], configure, delete);
    end
    
    html.table.finish();
    html.container.finish();
    
    html.finish();
    os.exit();
elseif cgi.pathinfo[1] == "mod" then
    -- /Nano/mod/...
    if cgi.pathinfo[2] == "login" then
        -- /Nano/mod/login
        -- This area is the only area in /Nano/mod which unauthenticated users are
        -- allowed to access.
        if POST["username"] and POST["password"] then
            if #identity.list() == 0 then
                -- Special case: if there are no mod accounts, use the first supplied credentials to
                -- establish an administration account (to allow for board creation and the like).
                if not identity.validname(POST["username"]) then
                    errorstr = "Invalid account name. <br /><br />"
                elseif not identity.validpassword(POST["password"]) then
                    errorstr = "Invalid password. <br /><br />"
                else
                    identity.create("admin", POST["username"], POST["password"]);
                    os.execute("mkdir Media Media/thumb Media/icon Cached");
                    log.create(nil, nil, "Created a new admin account for board Global: " .. POST["username"]);
                    html.redirect("/Nano/mod/login");
                    os.exit();
                end
            else
                -- User has supplied a username and a password. Check if valid.
                if global.retrieveflag("NanoRequireCaptcha", false) and not captcha.valid(POST["captcha"]) then
                    html.pdp.error("Invalid captcha", "The captcha you entered was incorrect. Go back, and refresh the page to get a new one.");
                    os.exit();
                elseif identity.valid(POST["username"], POST["password"]) then
                    -- Set authentication cookie.
                    html.begin("successful login", "session_key", identity.session.create(POST["username"]));
                    html.redheader("Login successful");
                    html.container.begin();
                    io.write("You have successfully logged in. You may now ", html.string.link("/Nano/mod", "continue"), " to the moderation tools, or ", html.string.link(POST["referer"], "return"), " to the page you were just on.");
                    html.container.finish();
                    html.finish();
                else
                    html.pdp.error("Invalid credentials", "Either your username, your password, or both your username and your password were invalid. Please " .. html.string.link("/Nano/mod/login", "return") .. " and try again.");
                end
                os.exit();
            end
        end

        html.begin("moderation");
        html.redheader("Moderator login");
        html.container.begin();
        io.write(errorstr or "");
        io.write("The moderation tools require a login. Access to moderation tools is restricted");
        io.write(" to administrators, global volunteers, board owners and board volunteers.");

        if #identity.list() == 0 then
            io.write("<br /><b>There are currently no moderator accounts. As such, the credentials you");
            io.write(" type in the box below will become those of the first administrator account.</b>");
        end

        html.container.barheader("Login");
        io.write("<fieldset><form method='post'>");
        io.write(  "<input type='hidden' name='referer' value='", cgi.referer or "/Nano/mod", "' />");
        io.write(  "<label for='username'>Username</label><input type='text' id='username' name='username' /><br />");
        io.write(  "<label for='password'>Password</label><input type='password' id='password' name='password' /><br />");
        if global.retrieveflag("NanoRequireCaptcha", false) then
            io.write("<img id='captcha-image' width='290' height='70' src='/Nano/captcha.jpg' /><br />");
            io.write("<label for='captcha'>Captcha</label><input type='text' id='captcha' name='captcha' autocomplete='off' maxlength='6' /><br />");
        end
        io.write(  "<label for='submit'>Submit</label><input id='submit' type='submit' value='Continue' />");
        io.write("</form></fieldset>");
        html.container.finish();
        html.finish();
        os.exit();
    end

    if username == nil then
        -- The user does not have a valid session key. User must log in.
        html.redirect("/Nano/mod/login");
        os.exit();
    end

    if cgi.pathinfo[2] == nil or cgi.pathinfo[2] == "" then
        -- /Nano/mod
        html.begin("moderation");
        html.redheader("Moderation tools");
        html.container.begin();
        io.write("<a id='logout-button' href='/Nano/mod/logout'>[Logout]</a>");
        io.write("You are logged in as <b>", username, "</b>.");
        io.write(" Your account class is <b>", acctclass, "</b>.");

        if acctclass == "bo" or acctclass == "lvol" then
            io.write("<br />You are assigned to <b>", html.string.boardlink(assignboard), "</b></a>.");
        end
        
        if acctclass == "admin" or acctclass == "gvol" then
            html.container.barheader("Global");
            html.list.begin("unordered");
            if acctclass == "gvol" then
                html.list.entry(html.string.link("/Nano/mod/global/togglehardcap", "Toggle the captcha difficulty"));
            elseif acctclass == "admin" then
                html.list.entry(html.string.link("/Nano/mod/global/announce", "Change top-bar announcement"));
                html.list.entry(html.string.link("/Nano/mod/global/config", "Change site-wide configs"));
                html.list.entry(html.string.link("/Nano/mod/global/regenerate", "Regenerate all HTML files"));
            end
            html.list.finish("unordered");
        end

        if acctclass == "admin" or acctclass == "bo"  or acctclass == "gvol" then
            html.container.barheader("Boards");
            html.list.begin("unordered");

            if acctclass == "admin" then
                html.list.entry(html.string.link("/Nano/mod/board/create", "Create a board"));
                html.list.entry(html.string.link("/Nano/mod/board/delete", "Delete a board"));
            end

            if acctclass == "admin" then
                html.list.entry(html.string.link("/Nano/mod/board/config", "Configure a board"));
            elseif acctclass == "bo" then
                html.list.entry(html.string.link("/Nano/mod/board/config/" .. assignboard, "Configure your board"));
            end
            
            if acctclass == "admin" or acctclass == "gvol" then
                html.list.entry(html.string.link("/Nano/mod/board/modifycaptcha", "Enable or disable the captcha for a board"));
            elseif acctclass == "bo" then
                html.list.entry(html.string.link("/Nano/mod/board/modifycaptcha", "Enable or disable the captcha for your board"));
            end
            
            html.list.finish("unordered");
        end

        html.container.barheader("Accounts");
        html.list.begin("unordered");

        if acctclass == "admin" or acctclass == "bo" or acctclass == "gvol" then
            html.list.entry(html.string.link("/Nano/mod/account/create", "Create an account"));
            html.list.entry(html.string.link("/Nano/mod/account/config", "Configure an account"));
        end
        if acctclass == "admin" or acctclass == "bo" then
            html.list.entry(html.string.link("/Nano/mod/account/delete", "Delete an account"));
        end

        html.list.entry(html.string.link("/Nano/mod/account/config/" .. username, "Account settings"));
        html.list.finish("unordered");
        html.container.finish();
        html.finish();
    elseif cgi.pathinfo[2] == "logout" then
        identity.session.delete(username);
        html.redirect("/Nano/mod/login");
    elseif cgi.pathinfo[2] == "board" then
        -- /Nano/mod/board/...
        if cgi.pathinfo[3] == "create" then
            if acctclass ~= "admin" then
                html.pdp.authorization_denied();
                os.exit();
            end

            -- /Nano/mod/board/create
            html.begin("create board");
            html.redheader("Create a board");
            html.container.begin();

            if POST["board"] and POST["title"] then
                local boardname = POST["board"];
                local title = POST["title"];
                local subtitle = POST["subtitle"] and POST["subtitle"] or nil;
                
                if board.exists(boardname) then
                    io.write("That board already exists.");
                elseif not board.validname(boardname) then
                    io.write("Invalid board name.");
                elseif not board.validtitle(title) then
                    io.write("Invalid board title.");
                elseif subtitle and not board.validsubtitle(subtitle) then
                    io.write("Invalid board subtitle.");
                else
                    board.create(boardname, title, subtitle or "");
                    log.create(username, nil,"Created a new board: " .. html.string.boardlink(boardname));
                    io.write("Board created: ", html.string.boardlink(boardname), ".<br />");
                    io.write("Configure board settings ", html.string.link("/Nano/mod/board/config/" .. boardname, "here"), ".");
                end
            end

            html.container.barheader("Instructions");
            html.list.begin("unordered");
            html.list.entry("<b>Board names</b> must consist of only lowercase characters and" ..
                            " numerals. They must be from one to eight characters long.");
            html.list.entry("<b>Board titles</b> must be from one to 32 characters long.");
            html.list.entry("<b>Board subtitles</b> must be from zero to 64 characters long.");
            html.list.finish("unordered");

            html.container.barheader("Enter board information");
            io.write("<fieldset><form method='post'>");
            io.write(  "<label for='board'>Name</label><input type='text' id='board' name='board' required /><br />");
            io.write(  "<label for='title'>Title</label><input type='text' id='title' name='title' required /><br />");
            io.write(  "<label for='subtitle'>Subtitle</label><input type='text' id='subtitle' name='subtitle' /><br />");
            io.write(  "<label for='submit'>Submit</label><input type='submit' id='submit' value='Create' /><br />");
            io.write("</form></fieldset>");

            html.container.finish();
            html.finish();
        elseif cgi.pathinfo[3] == "delete" then
            -- /Nano/mod/board/delete
            if acctclass ~= "admin" then
                html.pdp.authorization_denied();
                os.exit();
            end

            html.begin("delete board");
            html.redheader("Delete a board");
            html.container.begin();

            if POST["board"] then
                if not board.exists(POST["board"]) then
                    io.write("The board you specified does not exist.");
                else
                    board.delete(POST["board"]);
                    log.create(username, nil, "Deleted board " .. board.format(POST["board"]), POST["reason"]);
                    io.write("Board deleted.");
                end
            end

            html.container.barheader("Instructions");
            io.write("Deleting a board removes the board itself, along with all posts on that board,");
            io.write(" and all accounts assigned to that board. Board deletion is irreversible.");

            html.container.barheader("Enter information");
            io.write("<fieldset><form method='post'>");
            io.write(  "<label for='board'>Board</label><input type='text' id='board' name='board' required /><br />");
            io.write(  "<label for='reason'>Reason</label><input type='text' id='reason' name='reason' /><br />");
            io.write(  "<label for='submit'>Submit</label><input type='submit' id='submit' value='Delete' /><br />");
            io.write("</form></fieldset>");

            html.container.finish();
            html.finish();
        elseif cgi.pathinfo[3] == "config" then
            -- /Nano/mod/board/config
            if acctclass ~= "admin" and acctclass ~= "bo" then
                html.pdp.authorization_denied();
                os.exit();
            end

            if POST["board"] then
                html.redirect("/Nano/mod/board/config/" .. POST["board"]);
                os.exit();
            end

            if cgi.pathinfo[4] then
                -- /Nano/mod/board/config/...
                if not board.exists(cgi.pathinfo[4]) then
                    html.pdp.error("Invalid board", "That board does not exist.");
                    os.exit();
                elseif acctclass == "bo" and cgi.pathinfo[4] ~= assignboard then
                    html.pdp.authorization_denied();
                    os.exit();
                else
                    html.begin("configure board");
                    html.redheader("Configure " .. board.format(cgi.pathinfo[4]));
                    html.container.begin();
                    
                    if POST["action"] then
                        if not board.validtitle(POST["title"]) then
                            io.write("Invalid title. Settings not saved.");
                        elseif POST["subtitle"] and not board.validsubtitle(POST["subtitle"]) then
                           io.write("Invalid subtitle. Settings not saved.");
                        else
                            local new_settings = {
                                Name =              cgi.pathinfo[4],
                                Title =    string.escapehtml(POST["title"]) or cgi.pathinfo[4],
                                Subtitle = string.escapehtml(POST["subtitle"]) or "",
                                Lock =             (POST["lock"] and 1 or 0),
                                DisplayOverboard = (POST["displayoverboard"] and 1 or 0),
                                RequireCaptcha =   (POST["requirecaptcha"] and 1 or 0),
                                CaptchaTriggerPPH = tonumber(POST["captchatrigger"]) or 0,
                                MaxThreadsPerHour = tonumber(POST["mtph"]) or 0,
                                MinThreadChars =    tonumber(POST["mtc"]) or 0,
                                BumpLimit =         tonumber(POST["bumplimit"]) or 300,
                                PostLimit =         tonumber(POST["postlimit"]) or 350,
                                ThreadLimit =       tonumber(POST["threadlimit"]) or 300
                            };

                            board.update(new_settings);
                            log.create(username, cgi.pathinfo[4], "Edited board settings");
                            io.write("Board settings modified.");
                        end
                    end

                    local existing = board.retrieve(cgi.pathinfo[4]);

                    io.write("<fieldset><form method='post'>");
                    io.write(  "<input type='hidden' name='action' value='yes' />");
                    io.write(  "<label for='name'>Name</label><input id='name' name='name' type='text' value='", existing["Name"], "' disabled /><br />");
                    io.write(  "<label for='title'>Title</label><input id='title' name='title' type='text' value='", existing["Title"], "' /><br />");
                    io.write(  "<label for='subtitle'>Subtitle</label><input id='subtitle' name='subtitle' type='text' value='", existing["Subtitle"], "' /><br />");
                    io.write(  "<label for='lock'>Lock</label><input id='lock' name='lock' type='checkbox' ", (tonumber(existing["Lock"]) == 0 and "" or "checked "), "/><br />");
                    io.write(  "<label for='displayoverboard'>Overboard</label><input id='displayoverboard' name='displayoverboard' type='checkbox' ",
                               (tonumber(existing["DisplayOverboard"]) == 0 and "" or "checked "), "/><br />");
                    io.write(  "<label for='requirecaptcha'>Captcha</label><input id='requirecaptcha' name='requirecaptcha' type='checkbox' ",
                               (tonumber(existing["RequireCaptcha"]) == 0 and "" or "checked "), "/><br />");
                    io.write(  "<label for='captchatrigger'>Captcha Trig</label><input id='captchatrigger' name='captchatrigger' type='number' value='", existing["CaptchaTriggerPPH"], "' /><br />");
                    io.write(  "<label for='mtph'>Max Thr./12h</label><input id='mtph' name='mtph' type='number' value='", existing["MaxThreadsPerHour"], "' /><br />");
                    io.write(  "<label for='mtc'>Min Thr. Len.</label><input id='mtc' name='mtc' type='number' value='", existing["MinThreadChars"], "' /><br />");
                    io.write(  "<label for='bumplimit'>Bump Limit</label><input id='bumplimit' name='bumplimit' type='number' value='", existing["BumpLimit"], "' /><br />");
                    io.write(  "<label for='postlimit'>Post Limit</label><input id='postlimit' name='postlimit' type='number' value='", existing["PostLimit"], "' /><br />");
                    io.write(  "<label for='threadliit'>Thread Limit</label><input id='threadlimit' name='threadlimit' type='number' value='", existing["ThreadLimit"], "' /><br />");
                    io.write(  "<label for='submit'>Submit</label><input id='submit' type='submit' value='Update' />");
                    io.write("</form></fieldset>");
                    
                    html.container.finish();
                    html.finish();
                end
            else
                html.begin("configure board");
                html.redheader("Configure a board");
                html.container.begin();
                
                html.container.barheader("Enter information");
                io.write("<fieldset><form method='post'>");
                io.write(  "<label for='board'>Board</label><input type='text' id='board' name='board' required /><br />");
                io.write(  "<label for='submit'>Submit</label><input type='submit' id='submit' value='Configure' /><br />");
                io.write("</form></fieldset>");
                
                html.container.finish();
                html.finish();
            end
        elseif cgi.pathinfo[3] == "modifycaptcha" then
            -- /Nano/mod/board/modifycaptcha
            if acctclass ~= "admin" and acctclass ~= "bo" and acctclass ~= "gvol" then
                html.pdp.authorization_denied();
                os.exit();
            end
            boardname = POST["board"] and POST["board"] or nil;
            action = POST["action"] and POST["action"] or nil;

            html.begin("enable/disable captcha for a board");
            html.redheader("Enable/Disable captcha for a board");
            html.container.begin();
            
            if boardname and action then
                -- /Nano/mod/board/modifycaptcha/...
                if not board.exists(boardname) then
                    io.write("That board does not exist.");
                elseif acctclass == "bo" and boardname ~= assignboard then
                    io.write("You are not assigned to that board and are unable to configure it.");
                else
                    local board_tbl = board.retrieve(boardname);
                    if action == "disable" and tonumber(board_tbl["RequireCaptcha"]) == 0 then
                        io.write("The captcha is already disabled on ", board.format(boardname), ".");
                    elseif action == "enable" and tonumber(board_tbl["RequireCaptcha"]) == 1 then
                        io.write("The captcha is already enabled on ", board.format(boardname), ".");
                    elseif action ~= "enable" and action ~= "disable" then
                        html.pdp.error("Invalid action", "There is no action associated with your request.");
                        os.exit();
                    else
                        board_tbl["RequireCaptcha"] = (action == "enable") and 1 or 0;
                        board.update(board_tbl);
                        log.create(username, boardname, action:sub(1,1):upper()..action:sub(2) .. "d the captcha");
                        io.write("Captcha ", action, "d on ", board.format(boardname), ".");
                    end
                end
            end
            
            html.container.barheader("Enter information");
            io.write("<fieldset><form id='captcha' method='post'>");
            
            io.write("<label for='action'>Action</label>");
            io.write("<select id='action' name='action' form='captcha'>");
            io.write(  "<option value='disable'>Disable</option>");
            io.write(  "<option value='enable'>Enable</option>");
            io.write("</select><br />");

            io.write(  "<label for='board'>Board</label><input type='text' id='board' name='board' required /><br />");
            io.write(  "<label for='submit'>Submit</label><input type='submit' id='submit' value='Configure' /><br />");
            io.write("</form></fieldset>");
            html.container.finish();
            html.finish();
        else
            html.pdp.notfound();
        end
    elseif cgi.pathinfo[2] == "global" then
        -- /Nano/mod/global
        
        if cgi.pathinfo[3] == "togglehardcap" then
            if not (acctclass == "admin" or acctclass == "gvol") then
                html.pdp.authorization_denied();
                os.exit();
            end

            local hardnew = not global.retrieveflag("HarderCaptchas", false);
            global.setflag("HarderCaptchas", hardnew);
            secretsdb:exec("DELETE FROM Captchas");
            
            log.create(username, nil, (hardnew and "En" or "Dis") .. "abled harder captchas");
            html.begin("captcha difficulty");
            html.redheader("Toggle captcha difficulty");
            html.container.begin();
            io.write("Toggled the captcha difficulty.");
            html.container.finish();
            html.finish();
            os.exit();
        end
        
        if acctclass ~= "admin" then
            html.pdp.authorization_denied();
            os.exit();
        end
            
        if cgi.pathinfo[3] == "announce" then
            html.begin("edit global announcement");
            html.redheader("Edit global announcement");
            html.container.begin();

            if POST["action"] then
                global.set("announce", POST["announce"] or "");
                log.create(username, nil, "Edited global announcement");
                io.write("Global announcement updated.");
                generate.mainpage();
                generate.overboard();
            end

            io.write("<fieldset><form id='globalannounce' method='post'>");
            io.write(  "<input type='hidden' name='action' value='yes' />");
            io.write(  "<label for='announce'>Announcement</label><textarea form='globalannounce' rows=5 cols=35 id='announce' name='announce'>",
                        string.escapehtml(global.retrieve("announce") or ""), "</textarea><br />");
            io.write(  "<label for='submit'>Submit</label><input type='submit' id='submit' value='Update' />");
            io.write("</form></fieldset>");

            html.container.finish();
            html.finish();
        elseif cgi.pathinfo[3] == "config" then
            html.begin("edit global configs");
            html.redheader("Edit global configs");
            html.container.begin();

            if POST["action"] then
                local threadcap = POST["threadcap"] and true or false;
                if threadcap ~= global.retrieveflag("ThreadCaptcha") then
                    global.setflag("ThreadCaptcha", threadcap);
                    local boards = board.list();
                    for i = 1, #boards do
                        generate.catalog(boards[i]);
                    end
                end
                
                local hardcap = POST["hardcap"] and true or false;
                if hardcap ~= global.retrieveflag("HarderCaptchas") then
                    global.setflag("HarderCaptchas", hardcap);
                    secretsdb:exec("DELETE FROM Captchas");
                end
                
                global.setflag("NanoRequireCaptcha", POST["nanoreqcap"]);
                log.create(username, nil, "Edited global settings");
                io.write("Global configs updated.");
            end

            io.write("<fieldset><form id='globalannounce' method='post'>");
            io.write(  "<input type='hidden' name='action' value='yes' />");

            io.write(  "<label for='threadcap'>Thread Captcha</label><input id='threadcap' name='threadcap' type='checkbox' ", (global.retrieveflag("ThreadCaptcha") and "checked " or ""), "/><br />");
            io.write(  "<label for='nanoreqcap'>/Nano Captcha</label><input id='nanoreqcap' name='nanoreqcap' type='checkbox' ", (global.retrieveflag("NanoRequireCaptcha") and "checked " or ""), "/><br />");
            io.write(  "<label for='hardcap'>Hard Captchas</label><input id='hardcap' name='hardcap' type='checkbox' ", (global.retrieveflag("HarderCaptchas") and "checked " or ""), "/><br />");

            io.write(  "<label for='submit'>Submit</label><input type='submit' id='submit' value='Update' />");
            io.write("</form></fieldset>");

            html.container.finish();
            html.finish();
        elseif cgi.pathinfo[3] == "regenerate" then
            generate.mainpage();
            generate.overboard();
            os.execute("mkdir Media Media/thumb Media/icon Cached");
            
            local boards = board.list();
            for j = 1, #boards do
                os.execute("mkdir " .. boards[j]);
                generate.catalog(boards[j]);

                local threads = post.listthreads(boards[j]);
                for i = 1, #threads do
                    generate.thread(boards[j], threads[i]);
                end
            end

            html.begin("regenerate files");
            html.redheader("Regenerate all HTML files");
            html.container.begin();

            io.write("All HTML files regenerated.");

            html.container.finish();
            html.finish();
        else
            html.pdp.notfound();
        end
    elseif cgi.pathinfo[2] == "account" then
        -- /Nano/mod/account/...
        if cgi.pathinfo[3] == "create" then
            -- /Nano/mod/account/create

            if acctclass ~= "admin" and acctclass ~= "bo" and acctclass ~= "gvol" then
                html.pdp.authorization_denied();
                os.exit();
            end

            html.begin("create account");
            html.redheader("Create an account");
            html.container.begin();

            if POST["account"] and POST["password"] then
                if acctclass == "bo" then
                    POST["class"] = "lvol";
                    POST["board"] = assignboard;
                elseif acctclass == "gvol" then
                    POST["class"] = "tvol";
                    POST["board"] = nil;
                elseif POST["class"] == "admin" or POST["class"] == "gvol" or POST["class"] == "tvol" then
                    POST["board"] = nil;
                end

                if identity.exists(POST["account"]) then
                    io.write("That account already exists.");
                elseif not identity.validname(POST["account"]) then
                    io.write("Invalid account name.");
                elseif not identity.validpassword(POST["password"]) then
                    io.write("Invalid password.");
                elseif not identity.validclass(POST["class"]) then
                    io.write("Invalid account class.");
                elseif POST["board"] and not board.exists(POST["board"]) then
                    io.write("Board does not exist.");
                else
                    identity.create(POST["class"], POST["account"], POST["password"], POST["board"]);
                    log.create(username, nil, "Created a new " .. POST["class"] .. " account " .. (POST["board"] and "for board " .. html.string.boardlink(POST["board"]) or "") .. " with username " .. POST["account"]);
                    io.write("Account created.");
                end
            end

            html.container.barheader("Instructions");
            html.list.begin("unordered");
            html.list.entry("<b>Usernames</b> can only consist of alphanumerics. They must be from 1 to 16 characters long.");
            html.list.entry("<b>Passwords</b> must be from 13 to 64 characters long.");
            if acctclass == "admin" then
                html.list.entry("An account's <b>board</b> has no effect for Global Volunteers and " ..
                                "Administrators. For Board Owners and Board Volunteers, the board " ..
                                "parameter defines the board in which that account can operate.");
            end
            html.list.finish("unordered");

            html.container.barheader("Enter account information");
            io.write("<fieldset><form id='acctinfo' method='post'>");
            if acctclass == "admin" then
                io.write("<label for='class'>Type</label>");
                io.write("<select id='class' name='class' form='acctinfo'>");
                io.write(  "<option value='admin'>Administrator</option>");
                io.write(  "<option value='gvol'>Global Volunteer</option>");
                io.write(  "<option value='bo'>Board Owner</option>");
                io.write(  "<option value='lvol'>Board Volunteer</option>");
                io.write(  "<option value='tvol'>Trial Volunteer</option>");
                io.write("</select><br />");
                io.write("<label for='board'>Board</label><input type='text' id='board' name='board' /><br />");
            end
            io.write("<label for='account'>Username</label><input type='text' id='account' name='account' required /><br />");
            io.write("<label for='password'>Password</label><input type='password' id='password' name='password' required /><br />");
            io.write("<label for='submit'>Submit</label><input type='submit' id='submit' value='Create' /><br />");
            io.write("</form></fieldset>");

            html.container.finish();
            html.finish();
        elseif cgi.pathinfo[3] == "delete" then
            -- /Nano/mod/account/delete
            if acctclass ~= "admin" and acctclass ~= "bo" then
                html.pdp.authorization_denied();
                os.exit();
            end

            html.begin("delete account");
            html.redheader("Delete an account");
            html.container.begin();

            -- only admins have the ability to delete their own account
            if POST["account"] then
                local fail = false;
                if not identity.exists(POST["account"]) then
                    io.write("The account which you have specified does not exist.");
                    fail = true;
                elseif acctclass == "bo" and (POST["account"] == username or
                   identity.retrieve(POST["account"])["Board"] ~= assignboard) then
                    io.write("You are not authorized to delete that account.");
                    fail = true;
                elseif identity.retrieve(POST["account"])["Type"] == "tvol" then
                    local stmt = nanodb:prepare("SELECT COUNT() FROM Posts WHERE tvolDeleteName = ?");
                    stmt:bind_values(POST["account"]);
                    stmt:step();
                    local actioncount = stmt:get_value(0);
                    stmt:finalize();
                    if tonumber(actioncount) > 0 then
                        io.write("The trial volunteer still has unverfied pseudo-deleted posts.");
                        fail = true;
                    end
                end
                
                if not fail then
                    identity.delete(POST["account"]);
                    log.create(username, nil, "Deleted account " .. POST["account"], POST["reason"]);
                    io.write("Account deleted.");
                end
            end

            html.container.barheader("Instructions");
            html.list.begin("unordered");
            html.list.entry("Deleting an account will log the user out of all active sessions.");
            html.list.entry("Deleting an account will replace names all logs associated with that account with '<i>Deleted</i>'.");
            html.list.finish("unordered");

            html.container.barheader("Enter information");
            io.write("<fieldset><form method='post'>");
            io.write("<label for='account'>Username</label><input type='text' id='account' name='account' value='",cgi.pathinfo[4] or "" ,"' /><br />");
            io.write("<label for='reason'>Reason</label><input type='text' id='reason' name='reason' /><br />");
            io.write("<label for='submit'>Submit</label><input type='submit' id='submit' value='Delete' /><br />");
            io.write("</form></fieldset>");
            html.container.finish();
            html.finish();
        elseif cgi.pathinfo[3] == "config" then
            -- /Nano/mod/account/config/...
            if POST["account"] then
                html.redirect("/Nano/mod/account/config/" .. POST["account"]);
                os.exit();
            end

            if cgi.pathinfo[4] then
                local ispersonalaccount = (cgi.pathinfo[4] == username) and true or false;
                
                if acctclass ~= "admin" and acctclass ~= "bo" and acctclass ~= "gvol" and not ispersonalaccount then
                    html.pdp.authorization_denied();
                    os.exit();
                elseif not identity.exists(cgi.pathinfo[4]) then
                    html.pdp.error("Account not found", "The account that you specified does not exist.");
                    os.exit();
                end
                
                local identity_tbl = identity.retrieve(cgi.pathinfo[4]);
                if not ispersonalaccount and acctclass == "bo" and
                   (identity_tbl["Board"] ~= assignboard or identity_tbl["Type"] ~= "lvol") then
                    html.pdp.error("Permission denied", "The account that you specified is not a board volunteer, or it does not belong to the board you control.");
                    os.exit();
                elseif not ispersonalaccount and acctclass == "gvol" and identity_tbl["Type"] ~= "tvol" then
                    html.pdp.error("Permission denied", "The account that you specified is not a trial volunteer.");
                    os.exit();
                end

                html.begin("configure account");
                html.redheader("Configure account " .. cgi.pathinfo[4]);
                html.container.begin();

                if POST["password1"] and POST["password2"] and POST["password1"] ~= "" then
                    if acctclass == "gvol" and not ispersonalaccount then
                        io.write("Insufficient permissions, password not changed. ");
                    elseif POST["password1"] ~= POST["password2"] then
                        io.write("The two passwords did not match. ");
                    elseif not identity.validpassword(POST["password1"]) then
                        io.write("Invalid password. ");
                    else
                        identity.changepassword(cgi.pathinfo[4], POST["password1"]);
                        log.create(username, nil, "Changed password for account: " .. cgi.pathinfo[4]);
                        io.write("Password changed. ");
                    end
                end
                
                local is_tvolmanager = (identity_tbl["Type"] == "tvol" and (acctclass == "admin" or acctclass == "gvol"));
                
                if (POST["maxactions"] or POST["maxmodpostage"]) and is_tvolmanager then
                    -- do func (check valid range, insert into db), make log, write io (if changed)
                    local success = identity.changeconfig(cgi.pathinfo[4], POST["maxactions"], POST["maxmodpostage"]);
                    if success then
                        log.create(username, nil, "Changed configs for tvol account: " .. cgi.pathinfo[4]);
                        io.write("Configurations saved.");
                        identity_tbl = identity.retrieve(cgi.pathinfo[4]); -- refresh the table
                    else
                        io.write("Invalid input, configs not saved.");
                    end
                end

                html.container.barheader("Instructions");
                html.list.begin();
                html.list.entry("<b>Passwords</b> must be from 13 to 64 characters long.");
                if is_tvolmanager then
                    html.list.entry("The password fields are ignored if the user account is a global volunteer and the target account is a trial volunteer.");
                    html.list.entry("The tvol fields are ignored if the target account is not a trial volunteer.");
                    html.list.entry("A negative value for <b>Max acts/day</b> removes the limitation, and a value of 0 disables all actions for the account."); 
                    html.list.entry("A value of 0 for <b>Max post age</b> removes the limitation, and a negative value is considered invalid. The value defines how old a post can be, in seconds, before it can no longer be deleted by the tvol."); 
                end
                html.list.finish();
                
                html.container.barheader("Enter information");
                io.write("<fieldset><form method='post'>");
                io.write("<label for='password1'>New password</label><input type='password' id='password1' name='password1' /><br />");
                io.write("<label for='password2'>Repeat pw</label><input type='password' id='password2' name='password2' /><br />");
                if is_tvolmanager then
                    io.write("<br />");
                    io.write("<label for='maxactions'>Max acts/day</label>");
                    io.write(  "<input type='text' id='maxactions' name='maxactions' value='", identity_tbl["MaxActionsPerDay"], "' /><br />");
                    io.write("<label for='maxmodpostage'>Max post age</label>");
                    io.write(  "<input type='text' id='maxmodpostage' name='maxmodpostage' value='", identity_tbl["MaxModifiedPostAge"], "' /><br />");
                end
                io.write("<label for='submit'>Submit</label><input type='submit' id='submit' value='Change' /><br />");
                io.write("</form></fieldset>");
                html.container.finish();
                html.finish();
            else
                html.begin("configure account");
                html.redheader("Configure an account");
                html.container.begin();
                html.container.barheader("Enter information");
                io.write("<fieldset><form method='post'>");
                io.write("<label for='account'>Username</label><input type='text' id='account' name='account' /><br />");
                io.write("<label for='submit'>Submit</label><input type='submit' label='submit' value='Configure' /><br />");
                io.write("</form></fieldset>");
                html.container.finish();
                html.finish();
            end
        else
            html.pdp.notfound();
        end
    elseif cgi.pathinfo[2] == "file" then
        local action = cgi.pathinfo[3];
        local filename = cgi.pathinfo[4];

        if acctclass ~= "admin" and acctclass ~= "gvol" then
            html.pdp.authorization_denied();
            os.exit();
        elseif not filename or filename == "" then
            html.pdp.error("No filename given", "There is no filename attached to your request.");
            os.exit();
        elseif not file.exists(filename) then
            html.pdp.error("Invalid file", "The file you are trying to modify does not exist.");
            os.exit();
        end

        if not POST["action"] then
            html.begin();
            html.redheader("File modification/deletion");
            html.container.begin();
            io.write("This is the file you are trying to modify:<br />");
            io.write("<div class='wrapper'><div class='post'>");
            html.post.renderthumbnail(filename);
            io.write("</div></div>");
            io.write("The action is: <b>", action, "</b><br />");
            io.write("<fieldset><form action='' method='POST'>");
            io.write(  "<input type='hidden' name='action' value='yes' />");
            io.write(  "<input type='hidden' name='referer' value='", cgi.referer or "/overboard.html", "' />");
            io.write(  "<label for='reason'>Reason</label><input type='text' id='reason' name='reason' autofocus required /><br />");
            io.write(  "<label for='submit'>Submit</label><input type='submit' id='submit' value='Modify' />");
            io.write("</form></fieldset>");
            html.container.finish();
            html.finish();
            os.exit();
        end

        if action == "delete" then
            local post_str = file.unlink(filename);
            log.create(username, nil, "Deleted file " .. filename .. " from all boards, affecting " .. post_str, POST["reason"]);
            file.delete(filename);
        else
            html.pdp.error("Invalid action", "There is no action associated with your request.");
            os.exit();
        end

        html.redirect(POST["referer"] or "/overboard.html");
    elseif cgi.pathinfo[2] == "post" then
        -- accessible to every moderator class unless otherwise specified below
        local redirect = POST["referer"] and POST["referer"] or "/overboard.html"
        local post_tbl = post.retrieve(boardname, number);
        local destboard = POST["destboard"] and POST["destboard"] or nil;
        local slac = {sticky = "Sticky", lock = "Lock", autosage = "Autosage", cycle = "Cycle"};

        local action, boardname, number, post_tbl = nil, {}, {}, {};        
        if cgi.pathinfo[3] == "range" then            
            local postlist = {}; -- postlist[i] or tbl = {"post", index, boardname, number}
            for k, _ in pairs(POST) do
                if k:match("^post_%d+_%w+_%d+$") then
                    local tbl = k:tokenize("_");
                    postlist[tbl[2]] = tbl;
                end
            end

            -- sort the jumbled post list by how they appear in the thread/page
            local indexlist = {};
            for _, tbl in pairs(postlist) do
                indexlist[#indexlist + 1] = tbl[2];
            end
            table.sort(indexlist);

            if (acctclass == "tvol" and #indexlist > 24) or #indexlist > 128 then
                html.pdp.error("Too many posts", "You are attempting to modify too many posts at once.");
                os.exit();
            end

            action = POST["action"];
            for i = 1, #indexlist do
                local tbl = postlist[indexlist[i]];
                boardname[i] = tbl[3];
                number[i] = tonumber(tbl[4]);
                post_tbl[i] = post.retrieve(boardname[i], number[i]);
            end
        else
            action = cgi.pathinfo[3];
            boardname[1] = cgi.pathinfo[4];
            number[1] = tonumber(cgi.pathinfo[5]);
            post_tbl[1] = post.retrieve(boardname[1], number[1]);
        end

        local singlepost = (#boardname <= 1);
        local timenow, identity_tbl = nil, nil;

        if acctclass == "tvol" and action ~= "delete" then
            html.pdp.authorization_denied();
            os.exit();
        elseif action == "move" and (not destboard or not board.exists(destboard)) then
            html.pdp.error("Invalid board", "No destination board provided, or the destination board you provided does not exist.");
            os.exit();
        elseif not action or action == "" or #boardname < 1 or #number < 1 then
            html.pdp.notfound();
            os.exit();
        elseif action == "delete" and acctclass == "tvol" then
            identity_tbl = identity.retrieve(username);
            timenow = os.time();

            local stmt = nanodb:prepare("SELECT COUNT() FROM Posts WHERE tvolDeleteName = ? and tvolDeleteDate > ?");
            stmt:bind_values(username, timenow - 1 * 60 * 60 * 24);
            stmt:step();
            local actioncount = stmt:get_value(0);
            stmt:finalize();

            if actioncount + #boardname > tonumber(identity_tbl["MaxActionsPerDay"]) then -- if past actions and current set of actions combined exceed maximum allowed actions for the day
                html.pdp.error("Too many actions", "Performing " .. (singlepost and "this action" or "these actions") .. " would take you over the maximum number of unverified actions you can perform for today.");
                os.exit();
            end
        end

        if not POST["performaction"] then
            html.begin();
            html.redheader("Post modification/deletion");
            html.container.begin();

            io.write(singlepost and "This is the post" or "These are the posts", " you are trying to modify:");
            io.write("<br />");
            for i = 1, #boardname do
                if post.exists(boardname[i], number[i]) then
                    html.post.render(boardname[i], number[i], true, false, true);
                else
                    io.write("<br />", post.format(boardname[i], number[i]),
                             ": This post does not exist.<br /><br />");
                end
            end

            io.write("The action is: <b>", action, "</b><br />");
            io.write("<fieldset><form action='' method='POST'>");
            io.write(  "<input type='hidden' name='performaction' value='yes' />");
            io.write(  "<input type='hidden' name='referer' value='", cgi.referer or "/overboard.html", "' />");
            if cgi.pathinfo[3] == "range" then            
                io.write("<input type='hidden' name='action' value='", action, "' />");
                for k, _ in pairs(POST) do
                    if k:match("^post_%d+_%w+_%d+$") then
                        io.write("<input type='hidden' name='", k, "' value='on' />");
                    end
                end
            end
            io.write(  "<label for='reason'>Reason</label><input type='text' id='reason' name='reason' autofocus required /><br />");
            if action == "move" then
                io.write("<label for='destboard'>Dest. Board</label><input type='text' id='destboard' name='destboard' required /><br />");
            end
            io.write(  "<label for='submit'>Submit</label><input type='submit' id='submit' value='Modify' />");
            io.write("</form></fieldset>");
            html.container.finish();
            html.finish();
            os.exit();
        end

        local regenlist = {}; -- {{boardname, number, parent}, {b, n, p}}

        local err = {}; -- an accumulating list of non-fatal errors during the processing of all actionable posts
        local err_enabled = {};
        local err_pluralize = (singlepost and "The post" or "One or more of the posts") .. " you are trying to modify ";
        local function err_enable(id, text) -- id must be unique for each error
            if not err_enabled[id] then
                err[#err + 1] = err_pluralize .. text;
                err_enabled[id] = true;
            end
        end

        for i = 1, #boardname do -- boardname[i], number[i], post_tbl[i]
            if not post.exists(boardname[i], number[i]) then
                err_enable("noexist", "does not exist.");
            elseif (acctclass == "bo" or acctclass == "lvol") and assignboard ~= boardname[i] then
                err_enable("restrictedboard", "is not from a board you control.");
            elseif action == "delete" then
                local success = false;
                if acctclass == "tvol" then -- pseudo-delete                
                    if post_tbl[i].tvolDeleteName and post_tbl[i].tvolDeleteName ~= "" then
                        err_enable("tvol_deleted", "is already pseudo-deleted.");
                    elseif post_tbl[i].Date < timenow - identity_tbl["MaxModifiedPostAge"] then
                        err_enable("tvol_postold", "is older than your account's maximum post age.");
                    else
                        post.pseudo.delete(boardname[i], number[i], timenow);
                        success = true;
                    end
                else
                    misc.audit("delete", boardname[i], number[i], POST["reason"]);
                    success = post.delete(boardname[i], number[i]);
                end

                if success then
                    if post_tbl[i].Parent == 0 then
                        os.remove(boardname[i] .. "/" .. number[i] .. ".html");
                        regenlist[#regenlist + 1] = {boardname[i], nil, nil};
                    else
                        regenlist[#regenlist + 1] = {boardname[i], number[i], post_tbl[i].Parent};
                    end
                    
                    local inthread = post_tbl[i].Parent ~= 0
                        and " in thread " .. html.string.threadlink(boardname[i], post_tbl[i].Parent, nil) or "";
                    local delstr = (post_tbl[i].tvolDeleteName and post_tbl[i].tvolDeleteName ~= "") 
                        and "Verified deletion made by tvol " .. post_tbl[i].tvolDeleteName .. " for post "
                        or "Deleted post ";
                    log.create(username, boardname[i], delstr .. post.format(boardname[i], number[i]) .. inthread, POST["reason"]);

                    if post_tbl[i].Parent == 0 then
                        redirect = "/" .. boardname[i];
                    end
                end
            elseif action == "restore" then
                if post_tbl[i].tvolDeleteName and post_tbl[i].tvolDeleteName == "" then -- if post is not pseudo-deleted
                    err_enable("tvol_notdeleted", "is not pseudo-deleted.");
                else
                    post.pseudo.restore(boardname[i], number[i]);
                    regenlist[#regenlist + 1] = {boardname[i], number[i], post_tbl[i].Parent};
                    local postlink = post_tbl[i].Parent ~= 0
                                     and html.string.threadlink(boardname[i], post_tbl[i].Parent, number[i])
                                     or html.string.threadlink(boardname[i], number[i], nil);
                    log.create(username, boardname[i], "Restored deletion made by tvol " .. post_tbl[i].tvolDeleteName .. " for post " .. postlink, POST["reason"]);
                end
            elseif action == "unlink" then
                if post.unlink(boardname[i], number[i]) then
                    regenlist[#regenlist + 1] = {boardname[i], number[i], post_tbl[i].Parent};
                end
                
                local postlink = post_tbl[i].Parent ~= 0
                                 and html.string.threadlink(boardname[i], post_tbl[i].Parent, number[i])
                                 or html.string.threadlink(boardname[i], number[i], nil);
                local logstr_tbl = {
                    "Unlinked file ",
                    "<a href='/Media/", post_tbl[i].File, "' target='_blank'>", post_tbl[i].File, "</a>",
                    " from post ", postlink
                }
                log.create(username, boardname[i], table.concat(logstr_tbl), POST["reason"]);
            elseif slac[action] then
                if post_tbl[i].Parent ~= 0 then
                    err_enable("notop", "is not an opening post.");
                else
                    log.create(username, boardname[i], "Toggled " .. action .. " on thread " .. html.string.threadlink(boardname[i], number[i], nil), POST["reason"]);
                    if post.toggle(slac[action], boardname[i], number[i]) then -- action succeeds
                        regenlist[#regenlist + 1] = {boardname[i], number[i], post_tbl[i].Parent};
                    end
                end
            end
        end
        
        local always_regen_catalog = ((action == "delete" and acctclass ~= "tvol") or action == "move"); -- catalog/overboard not regenerated when deleting non-op posts, to reduce impact of tvols on server load
        misc.groupedregen(regenlist, always_regen_catalog);

        if #err > 0 then
            err[#err + 1] = not singlepost and "All valid posts have been modified." or nil;
            html.pdp.error("Invalid post(s)", table.concat(err, "<br />"));
            os.exit();
        else
            html.redirect(singlepost and redirect or "/Nano/recent"); -- switch to "overboard.html" when range actions are added to threads
        end
    elseif cgi.pathinfo[2] == "index" then
        -- /Nano/mod/index
        local regen = false;
        if regen then io.output("indexview.html"); end
        
        local rpt = 2; -- replies shown per thread
        local tpp = 15; -- threads per page
        
        local page = tonumber(GET["page"]);
        if page == nil or page <= 0 then
            page = 1;
        end
        
        local post_tbls = {};
        local stmt = nanodb:prepare("SELECT Board, Number FROM Posts WHERE Parent = 0 AND Autosage = 0 AND (SELECT DisplayOverboard FROM Boards WHERE Name = Board) IN (1, '1', 1.0, '1.0') ORDER BY LastBumpDate DESC LIMIT ? OFFSET ?");
        stmt:bind_values(tpp, tonumber((page - 1) * tpp));
        for tbl in stmt:nrows() do
            post_tbls[#post_tbls + 1] = tbl;
        end
        
        nanodb:exec("BEGIN TRANSACTION");
        html.begin("index");
        html.redheader("Nanochan index");
        html.announce();

        io.write("<div class='index-container'>");
        html.pageswitcher(page);
        io.write("<a href='' accesskey='r' class='middle'>[Update]</a>");
        io.write(" <a href='/overboard.html' class='middle'>[Catalog]</a>");
        io.write("<hr />");
            
        for i = 1, #post_tbls do
            local board = post_tbls[i]["Board"];
            local number = post_tbls[i]["Number"];
            local replies = post.threadreplies(board, number);

            local replyomit = (#replies > rpt) and #replies - rpt or 0;
            local filelist = post.threadfiles(board, number, false);
            -- subtract (1, rpt) from #filelist for files not omitted
            
            local fileplural = (filelist == 1) and " file" or " files";
            local replyplural = (replyomit == 1) and " reply" or " replies";
            
            io.write("<div class='index-thread'>");
            html.post.render(board, number, true, false, false);
            io.write(replyomit, replyplural, " and ", #filelist, fileplural, " omitted. Click on the post number above to reply to this thread.<br /><br />");
            
            io.write(  "<div class='index-thread-children'>");
            for i = #replies - (rpt - 1), #replies do
                if replies[i] then
                    html.post.render(board, replies[i], true, false, false);
                end
            end
            io.write(  "</div>");
            
            io.write("</div>");
            io.write("<hr />");
        end
        
        html.pageswitcher(page);
        io.write("</div>");
        html.finish();
        nanodb:exec("END TRANSACTION");
        
        if regen then io.output(io.stdout); html.redirect("/indexview.html"); end
    else
        html.pdp.notfound();
    end
elseif cgi.pathinfo[1] == "post" then
    -- /Nano/post
    local post_board = POST["board"];
    local post_parent = tonumber(POST["parent"]);
    local post_name = POST["name"];
    local post_email = POST["email"];
    local post_subject = POST["subject"];
    local post_comment = POST["comment"];
    local post_tmp_filepath = HASERL["file_path"];
    local post_tmp_filename = POST["file_name"];
    local post_captcha = POST["captcha"];
    local parent_tbl = post.retrieve(post_board, post_parent);
    local board_tbl = board.retrieve(post_board);

    -- if true, the captcha is ignored/unneeded, bypass thread locks and board locks
    local ignore = (username and (acctclass == "admin" or acctclass == "gvol" or
                    (acctclass == "bo" and identity.retrieve(username)["Board"] == post_board)
                   )) and true or false;

    if POST["board"] and POST["parent"] then
        if not board_tbl then
            html.pdp.error("Invalid board", "The board you tried to post to does not exist.");
            os.exit();
        elseif post_parent ~= 0 and not post.exists(post_board, post_parent) then
            html.pdp.error("Invalid thread", "The thread you tried to post in does not exist. Perhaps it has been deleted.");
            os.exit();
        elseif not ignore and parent_tbl ~= nil and parent_tbl["Lock"] == 1 then
            html.pdp.error("Thread locked", "The thread you tried to post in is currently locked.");
            os.exit();
        elseif not ignore and tonumber(board_tbl["Lock"]) == 1 then
            html.pdp.error("Board locked", "The board you tried to post in is currently locked.");
            os.exit();
        elseif post_parent == 0 and (board_tbl["MaxThreadsPerHour"] > 0 and board.tph(post_board, 12, false) >= board_tbl["MaxThreadsPerHour"]) then
            html.pdp.error("Thread limit reached", "The board you tried to post in has reached its 12 hour thread limit.");
            os.exit();
        elseif post_parent ~= 0 and parent_tbl["Parent"] ~= 0 then
            html.pdp.error("Invalid thread", "The thread you tried to post in is not a thread. This is not supported.");
            os.exit();
        elseif post_parent == 0 and (board_tbl["MinThreadChars"] > 0 and #post_comment < board_tbl["MinThreadChars"]) then
            html.pdp.error("Post too short", "Your post text was too short. On this board, threads require at least " ..
                           tonumber(board_tbl["MinThreadChars"]) .. " characters.");
            os.exit();
        elseif post_comment and #post_comment > 32768 then
            html.pdp.error("Post too long", "Your post text was over 32 KiB. Please reduce its length.");
            os.exit();
        elseif post_comment and select(2, post_comment:gsub("\n", "")) > 512 then
            html.pdp.error("Too many newlines", "Your post contained over 512 newlines. Please reduce its length.");
            os.exit();
        elseif post_name and #post_name > 64 then
            html.pdp.error("Name too long", "The text in the name field was over 64 bytes. Please reduce its length.");
            os.exit();
        elseif post_email and #post_email > 64 then
            html.pdp.error("Email too long", "The text in the email field was over 64 bytes. Please reduce its length.");
            os.exit();
        elseif post_subject and #post_subject > 64 then
            html.pdp.error("Subject too long", "The text in the subject field was over 64 bytes. Please reduce its length.");
            os.exit();
        elseif (#post_comment == 0) and (#post_tmp_filename == 0) then
            html.pdp.error("Blank post", "You must either upload a file or write something in the comment field.");
            os.exit();
        elseif post_parent ~= 0 and parent_tbl["Cycle"] == 0 and #post.threadreplies(post_board, post_parent) >= board_tbl["PostLimit"] then
            html.pdp.error("Thread full", "The thread you tried to post in is full. Please start a new thread instead.");
            os.exit();
        elseif not ignore and (tonumber(board_tbl["RequireCaptcha"]) == 1 or 
                               (post_parent == 0 and global.retrieveflag("ThreadCaptcha", false))
                              ) and not captcha.valid(post_captcha) then
            html.pdp.error("Invalid captcha", "The captcha you entered was incorrect. Go back, and refresh the page to get a new one.");
            os.exit();
        end

        local post_filename = "";
        if post_tmp_filename and post_tmp_filename ~= "" then
            post_filename = file.save(post_tmp_filepath, post_tmp_filename, (post_parent == 0));

            if not post_filename then
                html.pdp.error("File error", "There was a problem with the file you uploaded. Possible reasons include unsupported file type, or incorrect file extension.");
                os.exit();
            end
        end

        local post_number = post.create(post_board, post_parent, post_name, post_email, post_subject, post_comment, post_filename);

        if post_parent == 0 then
            -- Redirect to the newly created thread.
            html.redirect("/" .. post_board .. "/" .. post_number .. ".html");
        else
            -- Redirect to the parent thread, but scroll down to the newly created post.
            html.redirect("/" .. post_board .. "/" .. post_parent .. ".html" .. "#post" .. post_number);
        end
    else
        html.pdp.error("No post attached", "There is no post attached to your request.");
        os.exit();
    end
else
    html.pdp.notfound();
end
%>