#!/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("&", "&")
:gsub("<", "<")
:gsub(">", ">")
:gsub("\"", """)
:gsub("'", "'");
end
function string.unescapehtml(input)
return input:gsub("&", "&")
:gsub("<", "<")
:gsub(">", ">")
:gsub(""", "\"")
:gsub("'", "'");
end
function io.fileexists(filename)
local f = io.open(filename, "r");
if f ~= nil then
f:close();
return true;
else
return false;
end
end
function io.filesize(filename)
local fp = io.open(filename);
local size = fp:seek("end");
fp:close();
return size;
end
--
-- CGI- and HTTP-related initialization
--
-- Initialize cgi variables.
cgi.pathinfo = ENV["PATH_INFO"] and string.tokenize(ENV["PATH_INFO"]:gsub("^/", ""), "/") or {}; -- removes preceeding slashes before tokenizing
cgi.referer = ENV["HTTP_REFERER"];
--
-- Global configuration functions.
--
function global.retrieve(name)
local stmt = nanodb:prepare("SELECT Value FROM Global WHERE Name = ?");
stmt:bind_values(name);
if stmt:step() ~= sqlite3.ROW then
stmt:finalize();
return nil;
end
local result = stmt:get_value(0);
stmt:finalize();
return result;
end
function global.delete(name)
local stmt = nanodb:prepare("DELETE FROM Global WHERE Name = ?");
stmt:bind_values(name);
stmt:step();
stmt:finalize();
end
function global.set(name, value)
if global.retrieve(name) ~= nil then
global.delete(name);
end
local stmt = nanodb:prepare("INSERT INTO Global VALUES (?, ?)");
stmt:bind_values(name, value);
stmt:step();
stmt:finalize();
end
--
-- Cryptographic functions.
--
function crypto.hash(hashtype, data)
-- local bstring = digest.new(hashtype):final(data);
-- local result = {};
-- for i = 1, #bstring do
-- result[#result + 1] = string.format("%02x", string.byte(bstring:sub(i,i)));
-- end
-- return table.concat(result);
return digest.new(hashtype):final(data);
end
--
-- Board-related functions.
--
function board.list()
local boards = {}
for tbl in nanodb:nrows("SELECT Name FROM Boards ORDER BY MaxPostNumber DESC") do
boards[#boards + 1] = tbl["Name"];
end
return boards;
end
function board.retrieve(name)
local stmt = nanodb:prepare("SELECT * FROM Boards WHERE Name = ?");
stmt:bind_values(name);
if stmt:step() ~= sqlite3.ROW then
stmt:finalize();
return nil;
end
local result = stmt:get_named_values();
stmt:finalize();
return result;
end
function board.validname(name)
return name and ((not name:match("[^a-z0-9]")) and (#name > 0) and (#name <= 8));
end
function board.validtitle(title)
return title and ((#title > 0) and (#title <= 32));
end
function board.validsubtitle(subtitle)
return subtitle and ((#subtitle >= 0) and (#subtitle <= 64));
end
function board.exists(name)
local stmt = nanodb:prepare("SELECT Name FROM Boards WHERE Name = ?");
stmt:bind_values(name);
local stepret = stmt:step();
stmt:finalize();
if stepret ~= sqlite3.ROW then
return false;
else
return true;
end
end
function board.format(name)
return board.validname(name) and ("/" .. name .. "/") or nil;
end
function board.create(name, title, subtitle)
if not board.validname(name) then
return nil;
end
local maxpostnumber = 0;
local lock = 0;
local maxthreadsperhour = 0;
local minthreadchars = 0;
local bumplimit = 300;
local postlimit = 350;
local threadlimit = 200;
local displayoverboard = 1;
local requirecaptcha = 0;
local captchatrigger = 30;
local stmt = nanodb:prepare("INSERT INTO Boards VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)");
stmt:bind_values(name,
string.escapehtml(title),
string.escapehtml(subtitle),
maxpostnumber,
lock,
displayoverboard,
maxthreadsperhour,
minthreadchars,
bumplimit,
postlimit,
threadlimit,
requirecaptcha,
captchatrigger);
stmt:step();
stmt:finalize();
os.execute("mkdir " .. name);
generate.mainpage();
generate.catalog(name);
generate.overboard();
end
function board.update(board_tbl)
-- escapehtml() Title and Subtitle before passing board_tbl to this function if needed
local stmt = nanodb:prepare("UPDATE Boards SET " ..
"Title = ?, Subtitle = ?, Lock = ?, MaxThreadsPerHour = ?, MinThreadChars = ?, " ..
"BumpLimit = ?, PostLimit = ?, ThreadLimit = ?, DisplayOverboard = ?, RequireCaptcha = ?, " ..
"CaptchaTriggerPPH = ? WHERE Name = ?");
stmt:bind_values(board_tbl["Title"], board_tbl["Subtitle"],
board_tbl["Lock"], board_tbl["MaxThreadsPerHour"], board_tbl["MinThreadChars"],
board_tbl["BumpLimit"], board_tbl["PostLimit"], board_tbl["ThreadLimit"], board_tbl["DisplayOverboard"],
board_tbl["RequireCaptcha"], board_tbl["CaptchaTriggerPPH"], board_tbl["Name"]);
stmt:step();
stmt:finalize();
generate.catalog(board_tbl["Name"]);
generate.overboard();
local threads = post.listthreads(board_tbl["Name"]);
for i = 1, #threads do
generate.thread(board_tbl["Name"], threads[i]);
end
end
-- Delete a board.
function board.delete(name)
local stmt = nanodb:prepare("DELETE FROM Boards WHERE Name = ?");
stmt:bind_values(name);
stmt:step();
stmt:finalize();
stmt = secretsdb:prepare("DELETE FROM Accounts WHERE Board = ?");
stmt:bind_values(name);
stmt:step();
stmt:finalize();
stmt = nanodb:prepare("DELETE FROM Posts WHERE Board = ?");
stmt:bind_values(name);
stmt:step();
stmt:finalize();
stmt = nanodb:prepare("DELETE FROM Refs WHERE Board = ?");
stmt:bind_values(name);
stmt:step();
stmt:finalize();
generate.mainpage();
generate.overboard();
end
-- Get number of threads made in the last 'hours' hours divided by 'hours'
function board.tph(name, hours, divide)
hours = hours or 12;
local start_time = os.time() - (hours * 3600);
local stmt = nanodb:prepare("SELECT COUNT(Number) FROM Posts WHERE Board = ? AND Date > ? AND Parent = 0");
stmt:bind_values(name, start_time);
stmt:step();
local count = stmt:get_value(0);
stmt:finalize();
return divide and count / hours or count;
end
-- Get board PPH (number of posts made in the last 'hours' hours divided by 'hours')
function board.pph(name, hours, divide)
hours = hours or 12;
local start_time = os.time() - (hours * 3600);
local stmt = nanodb:prepare("SELECT COUNT(Number) FROM Posts WHERE Board = ? AND Date > ?");
stmt:bind_values(name, start_time);
stmt:step();
local count = stmt:get_value(0);
stmt:finalize();
return divide and count / hours or count;
end
--
-- Identity (account) functions.
--
function identity.list()
local identities = {};
for tbl in secretsdb:nrows("SELECT Name FROM Accounts ORDER BY Name") do
identities[#identities + 1] = tbl["Name"];
end
return identities;
end
function identity.retrieve(name)
local stmt = secretsdb:prepare("SELECT * FROM Accounts WHERE Name = ?");
stmt:bind_values(name);
if stmt:step() ~= sqlite3.ROW then
stmt:finalize();
return nil;
end
local result = stmt:get_named_values();
stmt:finalize();
return result;
end
function identity.exists(name)
return identity.retrieve(name) and true or false;
end
-- Class can be either:
-- * "admin" - Site administrator, unlimited powers
-- * "bo" - Board owner, powers limited to a single board
-- * "gvol" - Global volunteer, powers limited by site administrators
-- * "lvol" - Local volunteer, powers limited by board owners, powers limited to a single board
-- * "tvol" - Trial volunteer, powers limited by global volunteers
function identity.create(class, name, password, boardname)
boardname = boardname or "Global";
local creator = username or "System";
local stmt = secretsdb:prepare("INSERT INTO Accounts VALUES (?,?,?,?,?,?,?)");
local hash = bcrypt.digest(password, 13);
stmt:bind_values(name, class, boardname, hash, creator, -1, 0);
stmt:step();
stmt:finalize();
end
function identity.validname(name)
return (not name:match("[^a-zA-Z0-9]")) and (#name >= 1) and (#name <= 16);
end
function identity.delete(name)
local stmt = secretsdb:prepare("DELETE FROM Accounts WHERE Name = ?");
stmt:bind_values(name);
stmt:step();
stmt:finalize();
stmt = secretsdb:prepare("DELETE FROM Sessions WHERE Account = ?");
stmt:bind_values(name);
stmt:step();
stmt:finalize();
stmt = nanodb:prepare("UPDATE Logs SET Name = '<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'>>>>/%s/%d</a>", boardname, number, boardname, number)
else
return string.format("<a class='reference' href='/%s/%d.html/#post%d'>>>>/%s/%d</a>", boardname, parent, number, boardname, number)
end
else
return string.format(">>>/%s/%d", boardname, number)
end
end
return text:gsub(">>(%d+)", "<a class='reference' href='#post%1'>>>%1</a>")
:gsub(">>>/([%d%l]-)/(%s)", "<a class='reference' href='/%1'>>>>/%1/</a>%2")
:gsub(">>>/([%d%l]-)/(%d+)", absolute_reference)
:gsub("\n(>.-)\n", "\n<span class='greentext'>%1</span>\n")
:gsub("\n(>.-)\n", "\n<span class='greentext'>%1</span>\n")
:gsub("\n(<.-)\n", "\n<span class='pinktext'>%1</span>\n")
:gsub("\n(<.-)\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("'''(.-)'''", "<b>%1</b>")
:gsub("''(.-)''", "<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"], "'>>>", 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
%>