#!/usr/bin/haserl --upload-limit=16384 --shell=lua --accept-all <% local sqlite3 = require("lsqlite3"); local digest = require("openssl").digest; local bcrypt = require("bcrypt"); local crypto = {}; local cgi = {}; local html = {}; html.board = {}; html.post = {}; html.container = {}; html.table = {}; html.list = {}; html.pdp = {}; html.string = {}; local generate = {}; local board = {}; local post = {}; post.pseudo = {}; local file = {}; local identity = {}; identity.session = {}; local captcha = {}; local log = {}; local global = {}; local misc = {} local nanodb = sqlite3.open("nanochan.db"); local secretsdb = sqlite3.open("secrets.db"); -- Ensure all required tables exist. nanodb:exec("CREATE TABLE IF NOT EXISTS Global (Name, Value)"); nanodb:exec("CREATE TABLE IF NOT EXISTS Boards (Name, Title, Subtitle, MaxPostNumber, Lock, DisplayOverboard, MaxThreadsPerHour, MinThreadChars, BumpLimit, PostLimit, ThreadLimit, RequireCaptcha, CaptchaTriggerPPH)"); -- MaxThreadsPerHour actually 12 hours instead of 1hr nanodb:exec("CREATE TABLE IF NOT EXISTS Posts (Board, Number, Parent, Date, LastBumpDate, Name, Email, Subject, Comment, File, Sticky, Cycle, Autosage, Lock, tvolDeleteName, tvolDeleteDate)"); nanodb:exec("CREATE TABLE IF NOT EXISTS File (Name, ThumbWidth, ThumbHeight)"); nanodb:exec("CREATE TABLE IF NOT EXISTS Refs (Board, Referee, Referrer)"); nanodb:exec("CREATE TABLE IF NOT EXISTS Logs (Name, Board, Date, Description)"); nanodb:busy_timeout(10000); secretsdb:exec("CREATE TABLE IF NOT EXISTS Accounts (Name, Type, Board, PwHash, Creator, MaxActionsPerDay, MaxModifiedPostAge)"); secretsdb:exec("CREATE TABLE IF NOT EXISTS Sessions (Key, Account, ExpireDate)"); secretsdb:exec("CREATE TABLE IF NOT EXISTS Captchas (Text, ExpireDate, UnlistedDate, ImageData BLOB)"); secretsdb:busy_timeout(1000); -- -- Additional functions. -- -- called whenever math.random is used local seed_generated = false function misc.generateseed() if seed_generated then return; else seed_generated = true end local fd = io.open("/dev/urandom","rb"); local seed = 0; for i=0,string.byte(fd:read(1)) do seed = seed + string.byte(fd:read(1)); end math.randomseed(seed); fd:close(); end -- create audit entries for mod actions (only deletes are supported) function misc.audit(action, boardname, number, reason) local result = post.retrieve(boardname, number) local f = io.open("audit.log", "a"); f:write("-----------------------------BEGIN AUDIT ENTRY-----------------------------\n\n"); f:write("Username: ", username, "\n"); f:write("Action: ", action, "\n"); f:write("Reason: ", reason, "\n\n"); f:write("Board: ", result["Board"], "\n"); f:write("Post No: ", result["Number"], "\n"); f:write("File Name: ", result["File"], "\n"); f:write("Parent Thread: ", result["Parent"], "\n"); f:write("Date Created: ", result["Date"], "\n"); f:write("Name: ", result["Name"], "\n"); f:write("Email: ", result["Email"], "\n"); f:write("Subject: ", result["Subject"], "\n"); if result["Parent"] == 0 then f:write("Date of Last Bump: ", result["LastBumpDate"], "\n"); f:write("Sticky?: ", result["Sticky"], "\n"); f:write("Cycle?: ", result["Cycle"], "\n"); f:write("Autosage?: ", result["Autosage"], "\n"); f:write("Lock?: ", result["Lock"], "\n"); end f:write("\n"); f:write("Contents:\n", result["Comment"], "\n\n"); if result["Parent"] == 0 then local threads = post.threadreplies(boardname, number); for i = 1, #threads do local result2 = post.retrieve(boardname, threads[i]); f:write("----------------BEGIN CHILD POST-------------------\n"); f:write("Board: ", result2["Board"], "\n"); f:write("Post No: ", result2["Number"], "\n"); f:write("File Name: ", result2["File"], "\n"); f:write("Parent Thread: ", result2["Parent"], "\n"); f:write("Date Created: ", result2["Date"], "\n"); f:write("Name: ", result2["Name"], "\n"); f:write("Email: ", result2["Email"], "\n"); f:write("Subject: ", result2["Subject"], "\n"); f:write("\n"); f:write("Contents:\n", result2["Comment"], "\n"); f:write("-----------------END CHILD POST--------------------\n\n"); end end f:write("------------------------------END AUDIT ENTRY------------------------------\n"); f:write("\n\n\n\n"); f:close(); end function file.unlink(filename) local posts = {}; local stmt = nanodb:prepare("SELECT Board, Number, Parent FROM Posts WHERE File = ?"); stmt:bind_values(filename); for tbl in stmt:nrows() do posts[#posts + 1] = tbl; end stmt:finalize(); local stmt = nanodb:prepare("UPDATE Posts SET File = '' WHERE File = ?"); stmt:bind_values(filename); stmt:step(); stmt:finalize(); return misc.groupedregen(posts, false); end -- smart page regeneration function misc.groupedregen(posts, always_regen_catalog) -- posts[i] = {Board, Number, Parent}, Parent=0 to regen as thread -- always_regen_catalog: regen catalog and overboard despite not being an opening post (post.delete) -- if no number/parent supplied, no thread will be regenerated (post.delete, post.pseudo.delete) local generated_overboard = false; local generated_board = {}; local generated_thread = {}; local str_tbl = {"posts: "}; for i = 1, #posts do local boardname = posts[i][1]; local number = posts[i][2]; local threadparent = (number and posts[i][3] == 0) and true or false; local parent = threadparent and number or posts[i][3]; local always_regen_catalog = number and always_regen_catalog or true; -- override a_r_c for this iteration if only boardname was supplied -- create string for log entry if number then str_tbl[#str_tbl + 1] = html.string.threadlink(boardname, parent, not threadparent and number or nil); str_tbl[#str_tbl + 1] = ", "; elseif not generated_board[boardname] then str_tbl[#str_tbl + 1] = html.string.boardlink(boardname); str_tbl[#str_tbl + 1] = ", "; end if (threadparent or always_regen_catalog) and not generated_overboard then generate.overboard(); generated_overboard = true; end if (threadparent or always_regen_catalog) and not generated_board[boardname] then generate.catalog(boardname); generated_board[boardname] = true; end if number and not generated_thread[boardname .. parent] then generate.thread(boardname, parent); generated_thread[boardname .. parent] = true; end end return #str_tbl > 1 and table.concat(str_tbl) or "no posts, "; -- "posts: /b/1, /b/2, " or "no posts, " end function file.thumbnail_dimensions_get(filename) local stmt = nanodb:prepare("SELECT ThumbWidth, ThumbHeight FROM File WHERE Name = ?"); stmt:bind_values(filename); local width = 0; local height = 0; if stmt:step() ~= sqlite3.ROW then stmt:finalize(); width, height = file.thumbnail_dimensions_set(filename); else width, height = unpack(stmt:get_values()); stmt:finalize(); end width = (width and width ~= 1) and width or ""; height = (height and height ~= 1) and height or ""; return width, height; end function file.thumbnail_dimensions_set(filename) -- hack, change if thumbnails go above 255x255 local _,_,width = os.execute("width_temp=$(gm identify -format '%w' " .. file.thumbnail(filename) .. "); exit $width_temp"); local _,_,height = os.execute("height_temp=$(gm identify -format '%h' " .. file.thumbnail(filename) .. "); exit $height_temp"); local stmt = nanodb:prepare("INSERT INTO File VALUES (?, ?, ?)"); stmt:bind_values(filename, width, height); stmt:step(); stmt:finalize(); return width, height; end function global.retrieveflag(flag, default) -- default given as bool if not global.retrieve(flag) then default = default and "1" or "0"; global.set(flag, default); end return (global.retrieve(flag) == "1") and true or false; end function global.setflag(flag, value) -- value given as bool value = value and "1" or "0"; global.set(flag, value); end function html.recentsfilter() local function checkbox(id, label) io.write(""); io.write( "
"); end io.write("
"); io.write( "
"); io.write( "

Filter posts by:

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

", text, "

"); end function html.announce() if global.retrieve("announce") then io.write("
", global.retrieve("announce"), "
"); end end function html.pageswitcher(currentpage) io.write("
"); io.write("[Prev]"); io.write("[Next]"); io.write("
"); end function html.container.begin(type, classes) io.write("
"); end function html.container.finish() io.write("
"); end function html.container.barheader(text) io.write("

", text, "

"); end function html.table.begin(id, ...) local arg = {...}; io.write(""); io.write(""); for i = 1, #arg do io.write(""); end io.write(""); end function html.table.entry(...) local arg = {...}; io.write(""); for i = 1, #arg do io.write(""); end io.write(""); end function html.table.finish() io.write("
", arg[i], "
", tostring(arg[i]), "
"); end function html.list.begin(type) io.write(type == "ordered" and "
    " or "
" or ""); 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. Go back."); 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 = "" .. (text or href) .. ""; return result; end function html.string.datetime(unixtime) local isotime = os.date("!%F %T", unixtime); return ""; 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("

", board.format(boardname), " - ", board.retrieve(boardname)["Title"], "

"); end function html.board.subtitle(boardname) io.write("

", board.retrieve(boardname)["Subtitle"], "

"); 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("", (parent == 0) and "[Start a New Thread]" or "[Make a Post]", ""); io.write("
"); io.write(""); io.write(""); io.write("[X]"); io.write("
"); io.write("
"); io.write(""); io.write("
"); io.write("
"); io.write("
"); if tonumber(board_tbl["RequireCaptcha"]) == 1 or (parent == 0 and global.retrieveflag("ThreadCaptcha", false)) then io.write("
"); io.write(""); end io.write("
"); end function html.post.modlinks(boardname, number, pseudodeleted, filename, parent) io.write(""); io.write("[D]"); if pseudodeleted then io.write("[R]"); end if file.exists(filename) then io.write("[U]"); io.write("[F]"); end if not parent then io.write("[S]"); io.write("[L]"); io.write("[A]"); io.write("[C]"); end io.write(""); end function html.post.threadflags(boardname, number) local post_tbl = post.retrieve(boardname, number); io.write(""); 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(""); 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("
"); io.write( ""); io.write( "
"); io.write( "", board.format(boardname), " "); io.write( "R:", #post.threadreplies(boardname, number), ""); html.post.threadflags(boardname, number); io.write( "
"); io.write( "
L: "); -- html.string.datetime(post_tbl["LastBumpDate"])); io.write( ""); io.write( "
"); html.post.modlinks(boardname, number, pseudodeleted, filename, parent); io.write( "
"); io.write( post_tbl["Subject"] or ""); io.write( "
"); io.write( "
"); io.write( post_tbl["Comment"]); io.write( "
"); io.write("
"); return true; end -- Omitting the 'boardname' value will turn the catalog into an overboard. function html.post.catalog(boardname, all) io.write("[Update]"); io.write("
"); io.write("
"); 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 "" 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 "" 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 "" or ""); end end end io.write("
"); end function html.post.renderthumbnail(filename) local file_ext = file.extension(filename); local file_class = file.class(file_ext); io.write("
"); io.write("File: ", filename, ""); io.write(" (", "dl)"); io.write(" (", file.format_size(file.size(filename)), ")"); io.write("
"); if file.has_thumbnails(file_ext) then local width, height = file.thumbnail_dimensions_get(filename); io.write(""); io.write( ""); io.write(""); elseif file_class == "audio" then io.write(""); elseif file_ext == "epub" or file_ext == "txt" then io.write(""); io.write( ""); io.write(""); 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("
"); if showcheckbox then local boardnum = table.concat{"post_", showcheckbox[1], "_", boardname, "_", number}; io.write(""); end io.write( "
"); io.write( "
"); if standalone then io.write("" .. board.format(boardname) .. (parent or "") .. " -> "); end io.write( "", post_tbl["Subject"] or "", " "); io.write( ""); if post_tbl["Email"] ~= "" then io.write( ""); end io.write( post_tbl["Name"]); if post_tbl["Email"] ~= "" then io.write( ""); end local hrefpre = standalone and board.format(boardname) .. (parent or number) .. ".html/" or ""; io.write( " "); io.write( " "); io.write( ""); io.write( "No."); io.write( "", post_tbl["Number"], ""); io.write( " "); 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( " >>", referee["Referrer"], ""); end stmt:finalize(); if pseudodeleted then io.write( " (Deleted by ", post_tbl["tvolDeleteName"], "", " on ", os.date("!%F %T", post_tbl["tvolDeleteDate"]), ") "); end io.write( "
"); if file.exists(post_tbl["File"]) then html.post.renderthumbnail(post_tbl["File"]); end io.write( "
"); io.write( post_tbl["Comment"]); io.write( "
"); io.write( "
"); io.write( "
"); io.write("
"); return true; end function html.post.renderthread(boardname, number) html.post.render(boardname, number, false, false, false); io.write(""); 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 "" 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(""); 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"), ".
"); -- 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"), ".
"); io.write("The SHA256 TLS fingerprint for the current v3 address is:
"); io.write("25:3A:27:5A:DD:3E:40:BA:88:3E:E7:4F:C6:4F:8B:FC:
"); 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("
"); html.post.renderthread(boardname, number); io.write("
"); io.write(""); 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("
"); html.container.begin(); if POST["captcha"] then io.write("The captcha you entered was incorrect."); end html.container.barheader("Regenerate stats"); io.write("
"); io.write( "
"); io.write( "
"); io.write( ""); io.write("
"); 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("
"); 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.
"); misc.retrieve("recent", false); else misc.recents(page, false); end io.write("
"); 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 = "[Configure]"; local delete = "[Delete]"; 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.

" elseif not identity.validpassword(POST["password"]) then errorstr = "Invalid password.

" 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("
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."); end html.container.barheader("Login"); io.write("
"); io.write( ""); io.write( "
"); io.write( "
"); if global.retrieveflag("NanoRequireCaptcha", false) then io.write("
"); io.write("
"); end io.write( ""); io.write("
"); 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("[Logout]"); io.write("You are logged in as ", username, "."); io.write(" Your account class is ", acctclass, "."); if acctclass == "bo" or acctclass == "lvol" then io.write("
You are assigned to ", html.string.boardlink(assignboard), "."); 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), ".
"); 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("Board names must consist of only lowercase characters and" .. " numerals. They must be from one to eight characters long."); html.list.entry("Board titles must be from one to 32 characters long."); html.list.entry("Board subtitles must be from zero to 64 characters long."); html.list.finish("unordered"); html.container.barheader("Enter board information"); io.write("
"); io.write( "
"); io.write( "
"); io.write( "
"); io.write( "
"); io.write("
"); 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("
"); io.write( "
"); io.write( "
"); io.write( "
"); io.write("
"); 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("
"); io.write( ""); io.write( "
"); io.write( "
"); io.write( "
"); io.write( "
"); io.write( "
"); io.write( "
"); io.write( "
"); io.write( "
"); io.write( "
"); io.write( "
"); io.write( "
"); io.write( "
"); io.write( ""); io.write("
"); 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("
"); io.write( "
"); io.write( "
"); io.write("
"); 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("
"); io.write(""); io.write("
"); io.write( "
"); io.write( "
"); io.write("
"); 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("
"); io.write( ""); io.write( "
"); io.write( ""); io.write("
"); 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("
"); io.write( ""); io.write( "
"); io.write( "
"); io.write( "
"); io.write( ""); io.write("
"); 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("Usernames can only consist of alphanumerics. They must be from 1 to 16 characters long."); html.list.entry("Passwords must be from 13 to 64 characters long."); if acctclass == "admin" then html.list.entry("An account's board 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("
"); if acctclass == "admin" then io.write(""); io.write("
"); io.write("
"); end io.write("
"); io.write("
"); io.write("
"); io.write("
"); 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 'Deleted'."); html.list.finish("unordered"); html.container.barheader("Enter information"); io.write("
"); io.write("
"); io.write("
"); io.write("
"); io.write("
"); 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("Passwords 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 Max acts/day removes the limitation, and a value of 0 disables all actions for the account."); html.list.entry("A value of 0 for Max post age 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("
"); io.write("
"); io.write("
"); if is_tvolmanager then io.write("
"); io.write(""); io.write( "
"); io.write(""); io.write( "
"); end io.write("
"); io.write("
"); 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("
"); io.write("
"); io.write("
"); io.write("
"); 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:
"); io.write("
"); html.post.renderthumbnail(filename); io.write("
"); io.write("The action is: ", action, "
"); io.write("
"); io.write( ""); io.write( ""); io.write( "
"); io.write( ""); io.write("
"); 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("
"); 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("
", post.format(boardname[i], number[i]), ": This post does not exist.

"); end end io.write("The action is: ", action, "
"); io.write("
"); io.write( ""); io.write( ""); if cgi.pathinfo[3] == "range" then io.write(""); for k, _ in pairs(POST) do if k:match("^post_%d+_%w+_%d+$") then io.write(""); end end end io.write( "
"); if action == "move" then io.write("
"); end io.write( ""); io.write("
"); 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 ", "", post_tbl[i].File, "", " 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, "
")); 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("
"); html.pageswitcher(page); io.write("[Update]"); io.write(" [Catalog]"); io.write("
"); 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("
"); 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.

"); io.write( "
"); for i = #replies - (rpt - 1), #replies do if replies[i] then html.post.render(board, replies[i], true, false, false); end end io.write( "
"); io.write("
"); io.write("
"); end html.pageswitcher(page); io.write("
"); 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 %>