#!/home/x/.local/bin/haserl -u16384

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 = {};
local file = {};
local identity = {};
      identity.session = {};
local captcha = {};
local log = {};
local global = {};

local nanodb = sqlite3.open("nanochan.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)");
nanodb:exec("CREATE TABLE IF NOT EXISTS Posts (Board, Number, Parent, Date, LastBumpDate, Name, Email, Subject, Comment, File, Sticky, Cycle, Autosage, Lock)");
nanodb:exec("CREATE TABLE IF NOT EXISTS Refs (Board, Referee, Referrer)");
nanodb:exec("CREATE TABLE IF NOT EXISTS Accounts (Name, Type, Board, PwHash)");
nanodb:exec("CREATE TABLE IF NOT EXISTS Sessions (Key, Account, ExpireDate)");
nanodb:exec("CREATE TABLE IF NOT EXISTS Logs (Name, Board, Date, Description)");
nanodb:exec("CREATE TABLE IF NOT EXISTS Captchas (Text, ExpireDate)");
nanodb:busy_timeout(10000);

--
-- 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;

    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)
    local result = input;
    result = result:gsub("&", "&amp;");
    result = result:gsub("<", "&lt;");
    result = result:gsub(">", "&gt;");
    result = result:gsub("\"", "&quot;");
    result = result:gsub("'", "&#39;");
    return result;
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 {};
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);
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);
    local stepret = stmt:step();

    if stepret ~= 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 stmt = nanodb:prepare("INSERT INTO Boards VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)");

    local maxpostnumber = 0;
    local lock = 0;
    local maxthreadsperhour = 0;
    local minthreadchars = 0;
    local bumplimit = 300;
    local postlimit = 350;
    local threadlimit = 300;
    local displayoverboard = 1;
    local requirecaptcha = 0;
    local captchatrigger = 30;

    stmt:bind_values(name,
                     string.escapehtml(title),
                     string.escapehtml(subtitle),
                     maxpostnumber,
                     lock,
                     displayoverboard,
                     maxthreadsperhour,
                     minthreadchars,
                     bumplimit,
                     postlimit,
                     threadlimit,
                     requirecaptcha,
                     captchatrigger);
    stmt:step();
    stmt:finalize();

    generate.mainpage();
    generate.catalog(name);
end

function board.update(board_tbl)
    local stmt = nanodb:prepare("UPDATE Boards SET " ..
                                "Title = ?, Subtitle = ?, Lock = ?, MaxThreadsPerHour = ?, MinThreadChars = ?, " ..
                                "BumpLimit = ?, PostLimit = ?, ThreadLimit = ?, DisplayOverboard = ?, RequireCaptcha = ?, " ..
                                "CaptchaTriggerPPH = ? WHERE Name = ?");

    stmt:bind_values(string.escapehtml(board_tbl["Title"]), string.escapehtml(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 = nanodb: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)
    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 count / hours;
end

-- Get board PPH (number of posts made in the last 'hours' hours divided by 'hours')
function board.pph(name, hours)
    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 count / hours;
end

--
-- Identity (account) functions.
--

function identity.list()
    local identities = {};

    for tbl in nanodb:nrows("SELECT Name FROM Accounts ORDER BY Name") do
        identities[#identities + 1] = tbl["Name"];
    end

    return identities;
end

function identity.retrieve(name)
    local stmt = nanodb: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
function identity.create(class, name, password, boardname)
    boardname = boardname or "Global";
    local stmt = nanodb:prepare("INSERT INTO Accounts VALUES (?,?,?,?)");
    local hash = bcrypt.digest(password, 13);
    stmt:bind_values(name, class, boardname, hash);
    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 = nanodb:prepare("DELETE FROM Accounts WHERE Name = ?");
    stmt:bind_values(name);
    stmt:step();
    stmt:finalize();
    stmt = nanodb: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 = nanodb:prepare("UPDATE Accounts SET PwHash = ? WHERE Name = ?");
    stmt:bind_values(hash, name);
    stmt:step();
    stmt:finalize();
end

function identity.validpassword(password)
    return (#password >= 6) and (#password <= 64);
end

function identity.validclass(class)
    return (class == "admin" or
            class == "gvol" or
            class == "bo" or
            class == "lvol")
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 = nanodb: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() + 3600; -- key expires in 1 hour

    local stmt = nanodb: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 = nanodb: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 nanodb: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(outfile)
    local xx, yy, rr, ss, cc, bx, by = {},{},{},{},{},{},{};

    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);
        cc[i] = string.random(1, "a-z");
        bx[i] = (150 + 1.1 * xx[i]);
        by[i] = (40 + 2 * yy[i]);
    end

    os.execute(string.format(
        "gm convert -size 290x70 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:%s",
        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],
        outfile
    ));

    return table.concat(cc);
end

function captcha.create()
    local outfile = "/tmp/captcha_" .. string.random(6);
    local captcha_text = captcha.assemble(outfile);
    local stmt = nanodb:prepare("INSERT INTO Captchas VALUES (?, CAST(strftime('%s', 'now') AS INTEGER) + 900)");
    stmt:bind_values(captcha_text);
    stmt:step();
    stmt:finalize();

    nanodb:exec("DELETE FROM Captchas WHERE ExpireDate < CAST(strftime('%s', 'now') AS INTEGER)");

    local fp = io.open(outfile, "r");
    local captcha_data = fp:read("*a");
    fp:close();
    os.remove(outfile);

    return captcha_data;
end

function captcha.retrieve(answer)
    local stmt = nanodb: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 = nanodb:prepare("DELETE FROM Captchas WHERE Text = ?");
    stmt:bind_values(answer);
    stmt:step();
    stmt:finalize();
end

function captcha.valid(answer)
    local captcha_tbl = captcha.retrieve(answer);
    captcha.delete(answer);
    return (captcha_tbl ~= nil) and true or false;
end

local skey = COOKIE["session_key"];
local username = identity.session.valid(skey);
local 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)
    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
        return "png";
    elseif data:sub(1,3) == "\xFF\xD8\xFF" then
        return "jpg";
    elseif data:sub(1,6) == "GIF87a"
        or data:sub(1,6) == "GIF89a" then
        return "gif";
    elseif data:find("DOCTYPE svg", 1, true)
        or data:find("<svg", 1, true) then
        return "svg";
    elseif data:sub(1,4) == "\x1A\x45\xDF\xA3" then
        return "webm";
    elseif data:sub(5,12) == "ftypmp42"
        or data:sub(5,12) == "ftypisom" then
        return "mp4";
    elseif data:sub(1,2) == "\xFF\xFB"
        or data:sub(1,3) == "ID3" then
        return "mp3";
    elseif data:sub(1,4) == "OggS" then
        return "ogg";
    elseif data:sub(1,4) == "fLaC" then
        return "flac";
    elseif data:sub(1,4) == "%PDF" then
        return "pdf";
    elseif data:sub(1,4) == "PK\x03\x04"
       and data:sub(31,58) == "mimetypeapplication/epub+zip" then
        return "epub";
    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"
    };

    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_class = file.class(file.extension(filename));

    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, create_catalog_icon)
    local extension = file.format(path);

    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;

    if file.exists(filename) then
        if create_catalog_icon then
            -- The file.create_icon() function will not recreate the icon if it
            -- already exists, so we call it unconditionally here.
            file.create_icon(filename);
        end

        return filename;
    end

    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)
    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 = CAST(strftime('%s', 'now') AS INTEGER) WHERE Board = ? AND Number = ? AND Autosage = 0");
    stmt:bind_values(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();

    generate.overboard();

    if post_tbl["Parent"] == 0 then
	generate.catalog(boardname);
	generate.thread(boardname, number);
    else
	generate.thread(boardname, post_tbl["Parent"]);
    end
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.format(boardname, number)
    return board.format(boardname) .. number;
end

-- Turn nanochan-formatting into html.
function post.nano2html(text)
    text = "\n" .. text .. "\n";

    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+)", "<a class='reference' href='/%1/%2.html'>&gt;&gt;&gt;/%1/%2</a>")
    :gsub("\n&gt;(.-)\n", "\n<span class='greentext'>&gt;%1</span>\n")
    :gsub("\n&gt;(.-)\n", "\n<span class='greentext'>&gt;%1</span>\n")
    :gsub("\n&lt;(.-)\n", "\n<span class='pinktext'>&lt;%1</span>\n")
    :gsub("\n&lt;(.-)\n", "\n<span class='pinktext'>&lt;%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 rel='noreferrer' href='%1%2'>%1%2</a>")
    :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();

    generate.thread(boardname, post_tbl["Parent"]);
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();

    generate.catalog(boardname);
    generate.overboard();

    if post_tbl["Parent"] == 0 then
	os.remove(boardname .. "/" .. number .. ".html");
    else
	generate.thread(boardname, post_tbl["Parent"]);
    end
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(">>([0123456789]+)") do
        references[#references + 1] = tonumber(reference);
    end

    comment = comment and post.nano2html(string.escapehtml(comment)) or "";
    filename = filename or "";
    local date = os.time();
    local lastbumpdate = date;
    local autosage = email == "sage" and 1 or 0;

    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) .. ")";
        end

        name = username .. " <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 EXCLUSIVE 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);
    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) > board_tbl["CaptchaTriggerPPH"] and
       board_tbl["RequireCaptcha"] == 0 then
        board_tbl["RequireCaptcha"] = 1;
        board.update(board_tbl);
        log.create("Automatically enabled captcha due to excessive PPH", "<i>System</i>", 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);

    if board_tbl["DisplayOverboard"] == 1 then
    	generate.overboard();
    end

    return number;
end

--
-- Log access functions.
--

function log.create(desc, account, boardname)
    account = account or "<i>System</i>";
    boardname = html.string.boardlink(boardname) or "<i>Global</i>";
    local date = os.time();
    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("<!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

    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' />");

    if name and value then
        io.write("<meta http-equiv='set-cookie' content='", name, "=", value, ";Path=/Nano' />");
    end

    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='/overboard.html'>overboard</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.container.begin(type)
    io.write("<div class='container ", type or "narrow", "'>");
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(...)
    local arg = {...};
    io.write("<table>");
    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)
    return html.string.link(post.format(boardname, number) .. ".html", post.format(boardname, 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 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' autocomplete='off' 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 board_tbl["RequireCaptcha"] == 1 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='290' height='70' src='/Nano/captcha.jpg' />");
    end

    io.write("</form></fieldset>");
end

function html.post.modlinks(boardname, number)
    local post_tbl = post.retrieve(boardname, number);

    io.write("<span class='thread-mod-links'>");
    io.write("<a href='/Nano/mod/post/delete/", boardname, "/", number, "' title='Delete'>[D]</a>");

    if file.exists(post_tbl["File"]) then
        io.write("<a href='/Nano/mod/post/unlink/", boardname, "/", number, "' title='Unlink File'>[U]</a>");
        io.write("<a href='/Nano/mod/file/delete/", post_tbl["File"], "' title='Delete File'>[F]</a>");
    end

    if post_tbl["Parent"] == 0 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)
    local post_tbl = post.retrieve(boardname, number);

    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>");
    html.post.modlinks(boardname, number);
    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>");
end

-- Omitting the 'boardname' value will turn the catalog into an overboard.
function html.post.catalog(boardname)
    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 number = threadlist[i];
            html.post.render_catalog(boardname, number);
            io.write("<hr class='invisible' />");
        end
    else
        -- Overboard mode.
        for post_tbl in nanodb:nrows("SELECT Board, Number FROM Posts WHERE Parent = 0 AND Autosage = 0 AND (SELECT DisplayOverboard FROM Boards WHERE Name = Board) = 1 ORDER BY LastBumpDate DESC LIMIT 100") do
            html.post.render_catalog(post_tbl["Board"], post_tbl["Number"]);
            io.write("<hr class='invisible' />");
        end
    end

    io.write("</div>");
end

function html.post.render(boardname, number)
    local post_tbl = post.retrieve(boardname, number);

    io.write("<div class='post' id='post", number, "'>");
    io.write(  "<div class='post-header'>");

    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

    io.write(    "</span> ");
    io.write(    "<span class='post-date'>", html.string.datetime(post_tbl["Date"]), "</span> ");
    io.write(    "<span class='post-number'>No.<a href='#postform'>", post_tbl["Number"], "</a></span> ");

    if post_tbl["Parent"] == 0 then
        html.post.threadflags(boardname, number);
    end

    html.post.modlinks(boardname, number);

    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='#post", referee["Referrer"], "'>&gt;&gt;", referee["Referrer"], "</a>");
    end

    stmt:finalize();

    io.write(  "</div>");

    if file.exists(post_tbl["File"]) then
        local file_ext = file.extension(post_tbl["File"]);
        local file_class = file.class(file_ext);

        io.write("<div class='post-file-info'>");
        io.write("File: <a href='/Media/", post_tbl["File"], "' target='_blank'>", post_tbl["File"], "</a>");
        io.write(" (<a href='/Media/", post_tbl["File"], "' download>", "dl</a>)");
        io.write(" (", file.format_size(file.size(post_tbl["File"])), ")");
        io.write("</div>");

        if file.has_thumbnails(file_ext) then
            io.write("<a target='_blank' href='/Media/" .. post_tbl["File"] .. "'>");
            io.write(  "<img class='post-file-thumbnail' src='/", file.thumbnail(post_tbl["File"]), "' />");
            io.write("</a>");
        elseif file_ext == "epub" then
            io.write("<a target='_blank' href='/Media/" .. post_tbl["File"] .. "'>");
            io.write(  "<img width='100' height='70' class='post-file-thumbnail' src='/Static/document.png' />");
            io.write("</a>");
        elseif file_class == "audio" then
            io.write("<audio class='post-audio' preload='none' controls loop>");
            io.write(  "<source src='/Media/", post_tbl["File"], "' type='audio/", file_ext, "' />");
            io.write("</audio>");
        end
    end

    io.write(  "<div class='post-comment'>");
    io.write(  post_tbl["Comment"]);
    io.write(  "</div>");
    io.write("</div>");

    io.write("<br />");
end

function html.post.renderthread(boardname, number)
    local replies = post.threadreplies(boardname, number);
    html.post.render(boardname, number);

    for i = 1, #replies do
        io.write("<hr class='invisible' />");
        html.post.render(boardname, replies[i]);
    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. However, note");
    io.write(" that the nanochan 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"), ".");

    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();
    html.finish();

    nanodb:exec("END TRANSACTION");
    io.output(io.stdout);
end

function generate.thread(boardname, number)
    local post_tbl = post.retrieve(boardname, number);
    if not post_tbl then return; 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.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.
if cgi.pathinfo[1] == "captcha.jpg" then
    io.write("Content-Type: image/jpeg\n");
else
    io.write("Content-Type: text/html; charset=utf-8\n");
end

io.write("Cache-Control: no-cache\n");
io.write("\n");

--
-- This is the main part of Nanochan, where all the pages are defined.
--

if cgi.pathinfo[1] == nil then
    -- /nano
    html.redirect("/index.html");
elseif cgi.pathinfo[1] == "captcha.jpg" then
    io.write(captcha.create());
elseif cgi.pathinfo[1] == "stats" then
    html.begin("stats");
    html.redheader("Nanochan Statistics");
    html.container.begin("wide");
    html.table.begin("Board", "TPH (1h)", "TPH (12h)", "PPH (1h)", "PPH (12h)", "PPD (24h)", "Total Posts");

    local boards = board.list();
    for i = 1, #boards do
        html.table.entry(board.format(boards[i]),
                         string.format("%d", board.tph(boards[i], 1)),
                         string.format("%.1f", board.tph(boards[i], 12)),
                         string.format("%d", board.pph(boards[i], 1)),
                         string.format("%.1f", board.pph(boards[i], 12)),
                         string.format("%d", board.pph(boards[i], 24) * 24),
                         board.retrieve(boards[i])["MaxPostNumber"]);
    end

    html.table.finish();
    html.container.finish();
    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

    io.write("<div class='log-page-switcher'>");
    io.write("<a class='log-page-switcher-prev' href='?page=", page - 1, "'>[Prev]</a>");
    io.write("<a class='log-page-switcher-next' href='?page=", page + 1, "'>[Next]</a>");
    io.write("</div>");

    html.table.begin("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();
    io.write("<div class='log-page-switcher'>");
    io.write("<a class='log-page-switcher-prev' href='?page=", page - 1, "'>[Prev]</a>");
    io.write("<a class='log-page-switcher-next' href='?page=", page + 1, "'>[Next]</a>");
    io.write("</div>");
    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).
                identity.create("admin", POST["username"], POST["password"]);
                log.create("Created a new admin account for board Global: " .. POST["username"]);
                html.redirect("/Nano/mod/login");
            else
                -- User has supplied a username and a password. Check if valid.
                if 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.");
                    html.container.finish();
                    html.finish();
                else
                    html.begin("invalid credentials");
                    html.redheader("Error");
                    html.container.begin();
                    io.write("Either your username, your password, or both your username and your");
                    io.write(" password were invalid. Please ", html.string.link("/Nano/mod/login", "return"));
                    io.write(" and try again.");
                    html.container.finish();
                    html.finish();
                end
            end

            os.exit();
        end

        html.begin("moderation");
        html.redheader("Moderator login");
        html.container.begin();
        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(  "<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 />");
        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 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" then
            html.container.barheader("Global");
            html.list.begin("unordered");
            html.list.entry(html.string.link("/Nano/mod/global/announce", "Change top-bar announcement"));
            html.list.finish("unordered");
        end

        if acctclass == "admin" or acctclass == "bo" 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

            html.list.finish("unordered");
        end

        html.container.barheader("Accounts");
        html.list.begin("unordered");

        if acctclass == "admin" or acctclass == "bo" then
            html.list.entry(html.string.link("/Nano/mod/account/create", "Create an account"));
            html.list.entry(html.string.link("/Nano/mod/account/delete", "Delete an account"));
            html.list.entry(html.string.link("/Nano/mod/account/config", "Configure 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
                if board.exists(POST["board"]) then
                    io.write("That board already exists.");
                elseif not board.validname(POST["board"]) then
                    io.write("Invalid board name.");
                elseif not board.validtitle(POST["title"]) then
                    io.write("Invalid board title.");
                elseif POST["subtitle"] and not board.validsubtitle(POST["subtitle"]) then
                    io.write("Invalid board subtitle.");
                else
                    board.create(POST["board"],
                                 POST["title"],
                                 POST["subtitle"] or "");
                    log.create("Created a new board: " .. html.string.boardlink(POST["board"]), username);
                    io.write("Board created: ", html.string.boardlink(POST["board"]));
                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("Deleted board " .. board.format(POST["board"]) ..
                               (POST["reason"] and (" with reason: " .. string.escapehtml(POST["reason"])) or ""), username);
                    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

            html.begin("configure board");
            html.redheader("Configure " .. (cgi.pathinfo[4] and board.format(cgi.pathinfo[4]) or "a board"));
            html.container.begin();

            if cgi.pathinfo[4] then
                -- /Nano/mod/board/config/...
                if not board.exists(cgi.pathinfo[4]) then
                    io.write("That board does not exist. ", html.string.link("/Nano/mod/board/config", "Go back"));
                    io.write(" and try again.");
                elseif acctclass == "bo" and cgi.pathinfo[4] ~= assignboard then
                    io.write("You are not assigned to that board and are unable to configure it.");
                else
                    if POST["action"] then
                        local new_settings = {
                            Name =           cgi.pathinfo[4],
                            Title =          POST["title"] or "",
                            Subtitle =       POST["subtitle"] or "",
                            Lock =          (POST["lock"] ~= nil and 1 or 0),
                            DisplayOverboard = (POST["displayoverboard"] ~= nil and 1 or 0),
                            RequireCaptcha = (POST["requirecaptcha"] ~= nil 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("Edited board settings", username, cgi.pathinfo[4]);
                        io.write("Board settings modified.");
                    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' ", (existing["Lock"] == 0 and "" or "checked "), "/><br />");
                    io.write(  "<label for='displayoverboard'>Overboard</label><input id='displayoverboard' name='displayoverboard' type='checkbox' ",
                               (existing["DisplayOverboard"] == 0 and "" or "checked "), "/><br />");
                    io.write(  "<label for='requirecaptcha'>Captcha</label><input id='requirecaptcha' name='requirecaptcha' type='checkbox' ",
                               (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 Threads/hr</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>");
                end
            else
                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>");
            end

            html.container.finish();
            html.finish();
        end
    elseif cgi.pathinfo[2] == "global" then
        -- /Nano/mod/global
        if cgi.pathinfo[3] == "announce" then
            html.begin("edit global announcement");
            html.redheader("Edit global announcement");
            html.container.begin();

            if POST["action"] ~= nil then
                global.set("announce", POST["announce"] or "");
                log.create("Edited global announcement", username);
                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();
        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" 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 POST["class"] == "gvol" or POST["class"] == "admin" 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("Created a new " .. POST["class"] .. " account for board " ..
                               (html.string.boardlink(POST["board"]) or "Global") .. ": " .. POST["account"], username);
                    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 6 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("</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/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();

            if POST["account"] then
                if not identity.exists(POST["account"]) then
                    io.write("The account which you have specified does not exist.");
                elseif acctclass == "bo" and identity.retrieve(POST["account"])["Board"] ~= assignboard then
                    io.write("You are not authorized to delete that account.");
                else
                    identity.delete(POST["account"]);
                    log.create("Deleted account " .. POST["account"] ..
                               (POST["reason"] and (" with reason: " .. string.escapehtml(POST["reason"])) or ""), username);
                    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' /><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
                if acctclass ~= "admin" and cgi.pathinfo[4] ~= username 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

                html.begin("configure account");
                html.redheader("Configure account " .. cgi.pathinfo[4]);
                html.container.begin();

                if POST["password1"] and POST["password2"] then
                    if 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("Changed password for account: " .. cgi.pathinfo[4], username);
                        io.write("Password changed.");
                    end
                end

                html.container.barheader("Instructions");
                html.list.begin();
                html.list.entry("<b>Passwords</b> must be from 6 to 64 characters long.");
                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</label><input type='password' id='password2' name='password2' /><br />");
                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
        end
    elseif cgi.pathinfo[2] == "file" then
        local filename = cgi.pathinfo[4];

        if acctclass ~= "admin" and acctclass ~= "gvol" then
            html.pdp.authorization_denied();
            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 cgi.pathinfo[3] == "delete" then
            log.create("Deleted file " .. filename .. " from all boards", username);
            file.delete(filename);
        end

        html.redirect(cgi.referer);
    elseif cgi.pathinfo[2] == "post" then
        local boardname = cgi.pathinfo[4];
        local number = tonumber(cgi.pathinfo[5]);
        local reason = POST["reason"] and string.escapehtml(POST["reason"]) or nil;

        if not post.exists(boardname, number) then
            html.pdp.error("Invalid post", "The post you are trying to modify does not exist.");
            os.exit();
        elseif (acctclass == "bo" or acctclass == "lvol") and assignboard ~= boardname then
            html.pdp.authorization_denied();
            os.exit();
        end

        if not reason then
            html.begin();
            html.redheader("Post Modification/Deletion");
            html.container.begin();
            io.write("This is the post you are trying to modify:<br />");
            html.post.render(boardname, number);
            io.write("The action is: <b>", cgi.pathinfo[3], "</b><br />");
            io.write("<fieldset><form action='' method='POST'>");
            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' 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 cgi.pathinfo[3] == "sticky" then
            log.create("Toggled sticky on thread " .. html.string.threadlink(boardname, number) .. " for reason: " .. reason, username, boardname);
            post.toggle("Sticky", boardname, number);
        elseif cgi.pathinfo[3] == "lock" then
            log.create("Toggled lock on thread " .. html.string.threadlink(boardname, number) .. " for reason: " .. reason, username, boardname);
            post.toggle("Lock", boardname, number);
        elseif cgi.pathinfo[3] == "autosage" then
            log.create("Toggled autosage on thread " .. html.string.threadlink(boardname, number) .. " for reason: " .. reason, username, boardname);
            post.toggle("Autosage", boardname, number);
        elseif cgi.pathinfo[3] == "cycle" then
            log.create("Toggled cycle on thread " .. html.string.threadlink(boardname, number) .. " for reason: " .. reason, username, boardname);
            post.toggle("Cycle", boardname, number);
        elseif cgi.pathinfo[3] == "delete" then
            log.create("Deleted post " .. post.format(boardname, number) .. " for reason: " .. reason, username, boardname);
            post.delete(boardname, number);
        elseif cgi.pathinfo[3] == "unlink" then
            log.create("Unlinked file from post " .. post.format(boardname, number) .. " for reason: " .. reason, username, boardname);
            post.unlink(boardname, number);
        end

        html.redirect(POST["referer"] or "/overboard.html");
    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 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 parent_tbl ~= nil and parent_tbl["Lock"] == 1 and username == nil then
            html.pdp.error("Thread locked", "The thread you tried to post in is currently locked.");
            os.exit();
        elseif board_tbl["Lock"] == 1 and username == nil 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, 1) >= board_tbl["MaxThreadsPerHour"]) then
            html.pdp.error("Thread limit reached", "The board you tried to post in has reached its hourly 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", "")) > 128 then
            html.pdp.error("Too many newlines", "Your post contained over 128 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 (board_tbl["RequireCaptcha"] == 1) 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_parent == 0));

            if not post_filename then
                html.pdp.error("File error", "There was a problem with the file you uploaded.");
                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
    end
else
    html.pdp.notfound();
end
