· 6 years ago · Sep 15, 2019, 05:26 PM
1#!/usr/bin/haserl --upload-limit=16384 --shell=lua --accept-all
2<%
3local sqlite3 = require("lsqlite3");
4local digest = require("openssl").digest;
5local bcrypt = require("bcrypt");
6
7local crypto = {};
8local cgi = {};
9local html = {};
10 html.board = {};
11 html.post = {};
12 html.container = {};
13 html.table = {};
14 html.list = {};
15 html.pdp = {};
16 html.string = {};
17local generate = {};
18local board = {};
19local post = {};
20local file = {};
21local identity = {};
22 identity.session = {};
23local captcha = {};
24local log = {};
25local global = {};
26local misc = {}
27
28local nanodb = sqlite3.open("nanochan.db");
29
30-- Ensure all required tables exist.
31nanodb:exec("CREATE TABLE IF NOT EXISTS Global (Name, Value)");
32nanodb: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
33nanodb:exec("CREATE TABLE IF NOT EXISTS Posts (Board, Number, Parent, Date, LastBumpDate, Name, Email, Subject, Comment, File, Sticky, Cycle, Autosage, Lock)");
34nanodb:exec("CREATE TABLE IF NOT EXISTS File (Name, ThumbWidth, ThumbHeight)");
35nanodb:exec("CREATE TABLE IF NOT EXISTS Refs (Board, Referee, Referrer)");
36nanodb:exec("CREATE TABLE IF NOT EXISTS Accounts (Name, Type, Board, PwHash)");
37nanodb:exec("CREATE TABLE IF NOT EXISTS Sessions (Key, Account, ExpireDate)");
38nanodb:exec("CREATE TABLE IF NOT EXISTS Logs (Name, Board, Date, Description)");
39nanodb:exec("CREATE TABLE IF NOT EXISTS Captchas (Text, ExpireDate)");
40nanodb:busy_timeout(10000);
41
42--
43-- Additional functions.
44--
45
46-- called whenever math.random is used
47local seed_generated = false
48function misc.generateseed()
49 if seed_generated then
50 return;
51 else
52 seed_generated = true
53 end
54
55 local fd = io.open("/dev/urandom","rb");
56
57 local seed = 0;
58 for i=0,string.byte(fd:read(1)) do
59 seed = seed + string.byte(fd:read(1));
60 end
61
62 math.randomseed(seed);
63 fd:close();
64end
65
66-- create audit entries for mod actions (temporary)
67function misc.audit(action, boardname, number, reason)
68
69 local result = post.retrieve(boardname, number)
70 local f = io.open("audit.log", "a");
71
72 f:write("-----------------------------BEGIN AUDIT ENTRY-----------------------------\n\n");
73
74 -- make the username variable local again when this function is removed
75 f:write("Username: ", username, "\n");
76 f:write("Action: ", action, "\n");
77 f:write("Reason: ", reason, "\n\n");
78
79 f:write("Board: ", result["Board"], "\n");
80 f:write("Post No: ", result["Number"], "\n");
81 f:write("File Name: ", result["File"], "\n");
82
83 if action == "delete" then
84 f:write("Parent Thread: ", result["Parent"], "\n");
85 f:write("Date Created: ", result["Date"], "\n");
86 f:write("Name: ", result["Name"], "\n");
87 f:write("Email: ", result["Email"], "\n");
88 f:write("Subject: ", result["Subject"], "\n");
89
90 if result["Parent"] == 0 then
91 f:write("Date of Last Bump: ", result["LastBumpDate"], "\n");
92 f:write("Sticky?: ", result["Sticky"], "\n");
93 f:write("Cycle?: ", result["Cycle"], "\n");
94 f:write("Autosage?: ", result["Autosage"], "\n");
95 f:write("Lock?: ", result["Lock"], "\n");
96 end
97
98 f:write("\n");
99 f:write("Contents:\n", result["Comment"], "\n\n");
100
101 if result["Parent"] == 0 then
102 local threads = post.threadreplies(boardname, number);
103
104 for i = 1, #threads do
105 local result2 = post.retrieve(boardname, threads[i]);
106
107 f:write("----------------BEGIN CHILD POST-------------------\n");
108 f:write("Board: ", result2["Board"], "\n");
109 f:write("Post No: ", result2["Number"], "\n");
110 f:write("File Name: ", result2["File"], "\n");
111 f:write("Parent Thread: ", result2["Parent"], "\n");
112 f:write("Date Created: ", result2["Date"], "\n");
113 f:write("Name: ", result2["Name"], "\n");
114 f:write("Email: ", result2["Email"], "\n");
115 f:write("Subject: ", result2["Subject"], "\n");
116 f:write("\n");
117 f:write("Contents:\n", result2["Comment"], "\n");
118 f:write("-----------------END CHILD POST--------------------\n\n");
119 end
120 end
121 end
122
123 f:write("------------------------------END AUDIT ENTRY------------------------------\n");
124 f:write("\n\n\n\n");
125 f:close();
126end
127
128function file.unlink(filename)
129 local posts = {};
130 local stmt = nanodb:prepare("SELECT Board, Number, Parent FROM Posts WHERE File = ?");
131 stmt:bind_values(filename);
132 for tbl in stmt:nrows() do
133 posts[#posts + 1] = tbl;
134 end
135 stmt:finalize();
136
137 local stmt = nanodb:prepare("UPDATE Posts SET File = '' WHERE File = ?");
138 stmt:bind_values(filename);
139 stmt:step();
140 stmt:finalize();
141
142 -- smart page regeneration
143 local generated_overboard = false;
144 local generated_board = {};
145 local generated_thread = {};
146
147 for i = 1, #posts do
148 local boardname = posts[i]["Board"];
149 local number = posts[i]["Number"];
150 local threadparent = posts[i]["Parent"] == 0 and true or false;
151 local parent = threadparent and number or posts[i]["Parent"];
152
153 if threadparent and not generated_overboard and board.retrieve(boardname)["DisplayOverboard"] == 1 then
154 generate.overboard();
155 generated_overboard = true;
156 end
157
158 if threadparent and not generated_board[boardname] then
159 generate.catalog(boardname);
160 generated_board[boardname] = true;
161 end
162
163 if not generated_thread[boardname .. parent] then
164 generate.thread(boardname, parent);
165 generated_thread[boardname .. parent] = true;
166 end
167 end
168end
169
170function file.thumbnail_dimensions_get(filename)
171 local stmt = nanodb:prepare("SELECT ThumbWidth, ThumbHeight FROM File WHERE Name = ?");
172 stmt:bind_values(filename);
173 local width = 0;
174 local height = 0;
175
176 if stmt:step() ~= sqlite3.ROW then
177 stmt:finalize();
178 width, height = file.thumbnail_dimensions_set(filename);
179 else
180 width, height = unpack(stmt:get_values());
181 stmt:finalize();
182 end
183
184 width = (width and width ~= 1) and width or "";
185 height = (height and height ~= 1) and height or "";
186
187 return width, height;
188end
189
190function file.thumbnail_dimensions_set(filename)
191 -- hack, change if thumbnails go above 255x255
192 local _,_,width = os.execute("width_temp=$(gm identify -format '%w' " .. file.thumbnail(filename) .. "); exit $width_temp");
193 local _,_,height = os.execute("height_temp=$(gm identify -format '%h' " .. file.thumbnail(filename) .. "); exit $height_temp");
194
195 local stmt = nanodb:prepare("INSERT INTO File VALUES (?, ?, ?)");
196 stmt:bind_values(filename, width, height);
197 stmt:step();
198 stmt:finalize();
199
200 return width, height;
201end
202
203function global.retrieveflag(flag, default) -- default given as bool
204 if not global.retrieve(flag) then
205 default = default and "1" or "0";
206 global.set(flag, default);
207 end
208 return (global.retrieve(flag) == "1") and true or false;
209end
210
211function global.setflag(flag, value) -- value given as bool
212 value = value and "1" or "0";
213 global.set(flag, value);
214end
215
216function html.recentsfilter()
217 io.write("<fieldset style='padding-top: 10px; padding-bottom: 0px;'>");
218 io.write( "<form method='post' style='border: 1px solid #000; padding: 5px; padding-left: 10px; padding-right: 10px; z-index: 2; position: relative;'>");
219 io.write( "<h3 style='margin: 5px; display: inline-block;'>Filter posts by:</h3>");
220 io.write( "<input id='submit' type='submit' value='Submit' style='float: right; margin: 5px;' /><br />");
221 io.write( "<label for='sage'>Saged</label>");
222 io.write( "<input id='sage' name='sage' type='checkbox' ",(FORM["sage"] and "checked " or ""), "/><br />");
223--[[
224 io.write( "<label for='file'>Has File</label>");
225 io.write( "<input id='file' name='file' type='checkbox' ",(FORM["file"] and "checked " or ""), "/><br />");
226 io.write( "<label for='parent'>Opening Post</label>");
227 io.write( "<input id='parent' name='parent' type='checkbox' ",(FORM["parent"] and "checked " or ""), "/><br />");
228 io.write( "<label for='char-over'>Over N Chars</label>");
229 io.write( "<input id='char-over' name='char-over' type='number' value='", FORM["char-over"] or "", "'/>");
230 io.write( "<label for='char-under'>Under N Chars</label>");
231 io.write( "<input id='char-under' name='char-under' type='number' value='", FORM["char-under"] or "", "'/>");
232--]]
233 if not username and global.retrieveflag("NanoRequireCaptcha", false) then
234 io.write( "<img id='captcha-image' width='290' height='70' src='/Nano/captcha.jpg' /><br />");
235 io.write( "<label for='captcha'>Captcha</label><input type='text' id='captcha' name='captcha' autocomplete='off' maxlength='6' /><br />");
236 end
237
238 io.write( "</form>");
239 io.write("</fieldset>");
240end
241
242function misc.filterrecents(page, limit)
243 local stmt;
244 if FORM["sage"] then
245 stmt = nanodb:prepare("SELECT Board, Number FROM Posts WHERE Email = 'sage' ORDER BY Date DESC LIMIT ? OFFSET ?");
246 else -- default
247 stmt = nanodb:prepare("SELECT Board, Number FROM Posts ORDER BY Date DESC LIMIT ? OFFSET ?");
248 end
249
250 stmt:bind_values(limit, tonumber((page - 1) * limit));
251 local posts = {};
252 for tbl in stmt:nrows() do
253 posts[#posts + 1] = tbl;
254 end
255 stmt:finalize();
256
257 return posts;
258end
259
260function post.move(boardname, number, destboard)
261 local newnumber = nil;
262 return newnumber;
263end
264
265--
266-- Miscellaneous functions.
267--
268
269function string.tokenize(input, delimiter)
270 local result = {};
271
272 if input == nil then
273 return {};
274 end
275
276 for match in (input .. delimiter):gmatch("(.-)" .. delimiter) do
277 result[#result + 1] = match;
278 end
279
280 return result;
281end
282
283function string.random(length, pattern)
284 length = length or 64;
285 pattern = pattern or "a-zA-Z0-9"
286 local result = "";
287 local ascii = {};
288 local dict;
289 misc.generateseed();
290
291 for i = 0, 255 do
292 ascii[#ascii + 1] = string.char(i);
293 end
294
295 ascii = table.concat(ascii);
296 dict = ascii:gsub("[^" .. pattern .. "]", "");
297
298 while string.len(result) < length do
299 local randidx = math.random(1, string.len(dict));
300 local randbyte = dict:byte(randidx);
301 result = result .. string.char(randbyte);
302 end
303
304 return result;
305end
306
307function string.striphtml(input)
308 local result = input;
309 result = result:gsub("<.->", "");
310 return result;
311end
312
313function string.escapehtml(input)
314 return input:gsub("&", "&")
315 :gsub("<", "<")
316 :gsub(">", ">")
317 :gsub("\"", """)
318 :gsub("'", "'");
319end
320
321function string.unescapehtml(input)
322 return input:gsub("&", "&")
323 :gsub("<", "<")
324 :gsub(">", ">")
325 :gsub(""", "\"")
326 :gsub("'", "'");
327end
328
329function io.fileexists(filename)
330 local f = io.open(filename, "r");
331
332 if f ~= nil then
333 f:close();
334 return true;
335 else
336 return false;
337 end
338end
339
340function io.filesize(filename)
341 local fp = io.open(filename);
342 local size = fp:seek("end");
343 fp:close();
344 return size;
345end
346
347--
348-- CGI- and HTTP-related initialization
349--
350
351-- Initialize cgi variables.
352cgi.pathinfo = ENV["PATH_INFO"] and string.tokenize(ENV["PATH_INFO"]:gsub("^/", ""), "/") or {}; -- removes preceeding slashes before tokenizing
353cgi.referer = ENV["HTTP_REFERER"];
354
355--
356-- Global configuration functions.
357--
358
359function global.retrieve(name)
360 local stmt = nanodb:prepare("SELECT Value FROM Global WHERE Name = ?");
361 stmt:bind_values(name);
362
363 if stmt:step() ~= sqlite3.ROW then
364 stmt:finalize();
365 return nil;
366 end
367
368 local result = stmt:get_value(0);
369 stmt:finalize();
370 return result;
371end
372
373function global.delete(name)
374 local stmt = nanodb:prepare("DELETE FROM Global WHERE Name = ?");
375 stmt:bind_values(name);
376 stmt:step();
377 stmt:finalize();
378end
379
380function global.set(name, value)
381 if global.retrieve(name) ~= nil then
382 global.delete(name);
383 end
384
385 local stmt = nanodb:prepare("INSERT INTO Global VALUES (?, ?)");
386 stmt:bind_values(name, value);
387 stmt:step();
388 stmt:finalize();
389end
390
391--
392-- Cryptographic functions.
393--
394
395function crypto.hash(hashtype, data)
396-- local bstring = digest.new(hashtype):final(data);
397-- local result = {};
398-- for i = 1, #bstring do
399-- result[#result + 1] = string.format("%02x", string.byte(bstring:sub(i,i)));
400-- end
401-- return table.concat(result);
402 return digest.new(hashtype):final(data);
403end
404
405--
406-- Board-related functions.
407--
408
409function board.list()
410 local boards = {}
411
412 for tbl in nanodb:nrows("SELECT Name FROM Boards ORDER BY MaxPostNumber DESC") do
413 boards[#boards + 1] = tbl["Name"];
414 end
415
416 return boards;
417end
418
419function board.retrieve(name)
420 local stmt = nanodb:prepare("SELECT * FROM Boards WHERE Name = ?");
421 stmt:bind_values(name);
422 local stepret = stmt:step();
423
424 if stepret ~= sqlite3.ROW then
425 stmt:finalize();
426 return nil;
427 end
428
429 local result = stmt:get_named_values();
430 stmt:finalize();
431 return result;
432end
433
434function board.validname(name)
435 return name and ((not name:match("[^a-z0-9]")) and (#name > 0) and (#name <= 8));
436end
437
438function board.validtitle(title)
439 return title and ((#title > 0) and (#title <= 32));
440end
441
442function board.validsubtitle(subtitle)
443 return subtitle and ((#subtitle >= 0) and (#subtitle <= 64));
444end
445
446function board.exists(name)
447 local stmt = nanodb:prepare("SELECT Name FROM Boards WHERE Name = ?");
448 stmt:bind_values(name);
449 local stepret = stmt:step();
450 stmt:finalize();
451
452 if stepret ~= sqlite3.ROW then
453 return false;
454 else
455 return true;
456 end
457end
458
459function board.format(name)
460 return board.validname(name) and ("/" .. name .. "/") or nil;
461end
462
463function board.create(name, title, subtitle)
464 if not board.validname(name) then
465 return nil;
466 end
467
468 local maxpostnumber = 0;
469 local lock = 0;
470 local maxthreadsperhour = 0;
471 local minthreadchars = 0;
472 local bumplimit = 300;
473 local postlimit = 350;
474 local threadlimit = 200;
475 local displayoverboard = 1;
476 local requirecaptcha = 0;
477 local captchatrigger = 30;
478
479 local stmt = nanodb:prepare("INSERT INTO Boards VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)");
480 stmt:bind_values(name,
481 string.escapehtml(title),
482 string.escapehtml(subtitle),
483 maxpostnumber,
484 lock,
485 displayoverboard,
486 maxthreadsperhour,
487 minthreadchars,
488 bumplimit,
489 postlimit,
490 threadlimit,
491 requirecaptcha,
492 captchatrigger);
493 stmt:step();
494 stmt:finalize();
495
496 os.execute("mkdir " .. name);
497
498 generate.mainpage();
499 generate.catalog(name);
500 generate.overboard();
501end
502
503function board.update(board_tbl)
504 -- escapehtml() Title and Subtitle before passing board_tbl to this function if needed
505 local stmt = nanodb:prepare("UPDATE Boards SET " ..
506 "Title = ?, Subtitle = ?, Lock = ?, MaxThreadsPerHour = ?, MinThreadChars = ?, " ..
507 "BumpLimit = ?, PostLimit = ?, ThreadLimit = ?, DisplayOverboard = ?, RequireCaptcha = ?, " ..
508 "CaptchaTriggerPPH = ? WHERE Name = ?");
509
510 stmt:bind_values(board_tbl["Title"], board_tbl["Subtitle"],
511 board_tbl["Lock"], board_tbl["MaxThreadsPerHour"], board_tbl["MinThreadChars"],
512 board_tbl["BumpLimit"], board_tbl["PostLimit"], board_tbl["ThreadLimit"], board_tbl["DisplayOverboard"],
513 board_tbl["RequireCaptcha"], board_tbl["CaptchaTriggerPPH"], board_tbl["Name"]);
514 stmt:step();
515 stmt:finalize();
516
517 generate.catalog(board_tbl["Name"]);
518 generate.overboard();
519
520 local threads = post.listthreads(board_tbl["Name"]);
521 for i = 1, #threads do
522 generate.thread(board_tbl["Name"], threads[i]);
523 end
524end
525
526-- Delete a board.
527function board.delete(name)
528 local stmt = nanodb:prepare("DELETE FROM Boards WHERE Name = ?");
529 stmt:bind_values(name);
530 stmt:step();
531 stmt:finalize();
532
533 stmt = nanodb:prepare("DELETE FROM Accounts WHERE Board = ?");
534 stmt:bind_values(name);
535 stmt:step();
536 stmt:finalize();
537
538 stmt = nanodb:prepare("DELETE FROM Posts WHERE Board = ?");
539 stmt:bind_values(name);
540 stmt:step();
541 stmt:finalize();
542
543 stmt = nanodb:prepare("DELETE FROM Refs WHERE Board = ?");
544 stmt:bind_values(name);
545 stmt:step();
546 stmt:finalize();
547
548 generate.mainpage();
549 generate.overboard();
550end
551
552-- Get number of threads made in the last 'hours' hours divided by 'hours'
553function board.tph(name, hours, divide)
554 hours = hours or 12;
555 local start_time = os.time() - (hours * 3600);
556 local stmt = nanodb:prepare("SELECT COUNT(Number) FROM Posts WHERE Board = ? AND Date > ? AND Parent = 0");
557 stmt:bind_values(name, start_time);
558 stmt:step();
559 local count = stmt:get_value(0);
560 stmt:finalize();
561 return divide and count / hours or count;
562end
563
564-- Get board PPH (number of posts made in the last 'hours' hours divided by 'hours')
565function board.pph(name, hours, divide)
566 hours = hours or 12;
567 local start_time = os.time() - (hours * 3600);
568 local stmt = nanodb:prepare("SELECT COUNT(Number) FROM Posts WHERE Board = ? AND Date > ?");
569 stmt:bind_values(name, start_time);
570 stmt:step();
571 local count = stmt:get_value(0);
572 stmt:finalize();
573 return divide and count / hours or count;
574end
575
576--
577-- Identity (account) functions.
578--
579
580function identity.list()
581 local identities = {};
582
583 for tbl in nanodb:nrows("SELECT Name FROM Accounts ORDER BY Name") do
584 identities[#identities + 1] = tbl["Name"];
585 end
586
587 return identities;
588end
589
590function identity.retrieve(name)
591 local stmt = nanodb:prepare("SELECT * FROM Accounts WHERE Name = ?");
592 stmt:bind_values(name);
593
594 if stmt:step() ~= sqlite3.ROW then
595 stmt:finalize();
596 return nil;
597 end
598
599 local result = stmt:get_named_values();
600 stmt:finalize();
601 return result;
602end
603
604function identity.exists(name)
605 return identity.retrieve(name) and true or false;
606end
607
608-- Class can be either:
609-- * "admin" - Site administrator, unlimited powers
610-- * "bo" - Board owner, powers limited to a single board
611-- * "gvol" - Global volunteer, powers limited by site administrators
612-- * "lvol" - Local volunteer, powers limited by board owners, powers limited to a single board
613function identity.create(class, name, password, boardname)
614 boardname = boardname or "Global";
615 local stmt = nanodb:prepare("INSERT INTO Accounts VALUES (?,?,?,?)");
616 local hash = bcrypt.digest(password, 13);
617 stmt:bind_values(name, class, boardname, hash);
618 stmt:step();
619 stmt:finalize();
620end
621
622function identity.validname(name)
623 return (not name:match("[^a-zA-Z0-9]")) and (#name >= 1) and (#name <= 16);
624end
625
626function identity.delete(name)
627 local stmt = nanodb:prepare("DELETE FROM Accounts WHERE Name = ?");
628 stmt:bind_values(name);
629 stmt:step();
630 stmt:finalize();
631 stmt = nanodb:prepare("DELETE FROM Sessions WHERE Account = ?");
632 stmt:bind_values(name);
633 stmt:step();
634 stmt:finalize();
635 stmt = nanodb:prepare("UPDATE Logs SET Name = '<i>Deleted</i>' WHERE Name = ?");
636 stmt:bind_values(name);
637 stmt:step();
638 stmt:finalize();
639end
640
641function identity.changepassword(name, password)
642 local hash = bcrypt.digest(password, 13);
643 local stmt = nanodb:prepare("UPDATE Accounts SET PwHash = ? WHERE Name = ?");
644 stmt:bind_values(hash, name);
645 stmt:step();
646 stmt:finalize();
647end
648
649function identity.validpassword(password)
650 return (#password >= 13) and (#password <= 64);
651end
652
653function identity.validclass(class)
654 return (class == "admin" or
655 class == "gvol" or
656 class == "bo" or
657 class == "lvol")
658end
659
660function identity.valid(name, password)
661 local identity_tbl = identity.retrieve(name);
662 return identity_tbl and bcrypt.verify(password, identity_tbl["PwHash"]) or false;
663end
664
665function identity.session.delete(user)
666 local stmt = nanodb:prepare("DELETE FROM Sessions WHERE Account = ?");
667 stmt:bind_values(user);
668 stmt:step();
669 stmt:finalize();
670end
671
672function identity.session.create(user)
673 -- Clear any existing keys for this user to prevent duplicates.
674 identity.session.delete(user);
675
676 local key = string.random(32);
677 local expiry = os.time() + 3600; -- key expires in 1 hour
678
679 local stmt = nanodb:prepare("INSERT INTO Sessions VALUES (?,?,?)");
680
681 stmt:bind_values(key, user, expiry);
682 stmt:step();
683 stmt:finalize();
684
685 return key;
686end
687
688function identity.session.refresh(user)
689 local stmt = nanodb:prepare("UPDATE Sessions SET ExpireDate = ? WHERE Account = ?");
690 stmt:bind_values(os.time() + 3600, user);
691 stmt:step();
692 stmt:finalize();
693end
694
695function identity.session.valid(key)
696 local result = nil;
697 if key == nil then return nil end;
698
699 for tbl in nanodb:nrows("SELECT * FROM Sessions") do
700 if os.time() >= tbl["ExpireDate"] then
701 -- Clean away any expired session keys.
702 identity.session.delete(tbl["Account"]);
703 elseif tbl["Key"] == key then
704 result = tbl["Account"];
705 end
706 end
707
708 identity.session.refresh(result);
709 return result;
710end
711
712-- Captcha related functions.
713
714function captcha.assemble(cc, outfile) -- cc is table of 6 characters, a-z
715 local xx, yy, rr, ss, bx, by = {},{},{},{},{},{},{};
716 misc.generateseed();
717
718 for i = 1, 6 do
719 xx[i] = ((48 * i - 168) + math.random(-5, 5));
720 yy[i] = math.random(-10, 10);
721 rr[i] = math.random(-30, 30);
722 ss[i] = math.random(-40, 40);
723 bx[i] = (150 + 1.1 * xx[i]);
724 by[i] = (40 + 2 * yy[i]);
725 end
726
727 os.execute(string.format(
728 "gm convert -size 290x70 xc:white -bordercolor black -border 5 " ..
729 "-fill black -stroke black -strokewidth 1 -pointsize 40 " ..
730 "-draw \"translate %d,%d rotate %d skewX %d gravity center text 0,0 '%s'\" " ..
731 "-draw \"translate %d,%d rotate %d skewX %d gravity center text 0,0 '%s'\" " ..
732 "-draw \"translate %d,%d rotate %d skewX %d gravity center text 0,0 '%s'\" " ..
733 "-draw \"translate %d,%d rotate %d skewX %d gravity center text 0,0 '%s'\" " ..
734 "-draw \"translate %d,%d rotate %d skewX %d gravity center text 0,0 '%s'\" " ..
735 "-draw \"translate %d,%d rotate %d skewX %d gravity center text 0,0 '%s'\" " ..
736 "-fill none -strokewidth 2 " ..
737 "-draw 'bezier %f,%d %f,%d %f,%d %f,%d' " ..
738 "-draw 'polyline %f,%d %f,%d %f,%d' -quality 0 -strip -colorspace GRAY JPEG:%s",
739 xx[1], yy[1], rr[1], ss[1], cc[1],
740 xx[2], yy[2], rr[2], ss[2], cc[2],
741 xx[3], yy[3], rr[3], ss[3], cc[3],
742 xx[4], yy[4], rr[4], ss[4], cc[4],
743 xx[5], yy[5], rr[5], ss[5], cc[5],
744 xx[6], yy[6], rr[6], ss[6], cc[6],
745 bx[1], by[1], bx[2], by[2], bx[3], by[3], bx[4], by[4],
746 bx[4], by[4], bx[5], by[5], bx[6], by[6],
747 outfile
748 ));
749end
750
751function captcha.listall()
752 local captcha_list = {}
753
754 for tbl in nanodb:nrows("SELECT Text FROM Captchas") do
755 captcha_list[#captcha_list + 1] = tbl["Text"];
756 end
757
758 return captcha_list
759end
760
761function captcha.create()
762 local max_captchas = 50; -- captchas stored in db at a time, higher means lesser perf
763 --os.execute("mkdir tmp_captchas/");
764 local captcha_data;
765 local captcha_filename;
766 local captcha_text;
767 local captcha_list = captcha.listall();
768
769 if #captcha_list < max_captchas then
770 local cc = {};
771 for i = 1, 6 do
772 cc[i] = string.random(1, "a-z");
773 end
774
775 captcha_text = table.concat(cc);
776 captcha_filename = "tmp_captchas/" .. captcha_text;
777
778 captcha.assemble(cc, captcha_filename);
779
780 local stmt = nanodb:prepare("INSERT INTO Captchas VALUES (?, CAST(strftime('%s', 'now') AS INTEGER) + 1800)"); -- 30 mins to expiry
781 stmt:bind_values(captcha_text);
782 stmt:step();
783 stmt:finalize();
784 else
785 -- select existing captcha at random
786 misc.generateseed();
787 captcha_text = captcha_list[math.random(1, #captcha_list)];
788 captcha_filename = "tmp_captchas/" .. captcha_text;
789 end
790
791 captcha.deleteexpired();
792
793 local fp = io.open(captcha_filename, "r");
794 if not fp then
795 captcha.delete(captcha_text);
796 return nil;
797 else
798 captcha_data = fp:read("*a");
799 fp:close();
800 return captcha_data;
801 end
802end
803
804function captcha.retrieve(answer)
805 local stmt = nanodb:prepare("SELECT * FROM Captchas WHERE Text = ? AND ExpireDate > CAST(strftime('%s', 'now') AS INTEGER)");
806 stmt:bind_values(answer);
807
808 if stmt:step() ~= sqlite3.ROW then
809 stmt:finalize();
810 return nil;
811 end
812
813 local result = stmt:get_named_values();
814 stmt:finalize();
815 return result;
816end
817
818function captcha.delete(answer)
819 local stmt = nanodb:prepare("DELETE FROM Captchas WHERE Text = ?");
820 stmt:bind_values(answer);
821 stmt:step();
822 stmt:finalize();
823 os.remove("tmp_captchas/" .. answer);
824end
825
826function captcha.deleteexpired()
827 -- ALSO DELETE CAPTCHAS FROM TMPFOLDER
828 nanodb:exec("DELETE FROM Captchas WHERE ExpireDate < CAST(strftime('%s', 'now') AS INTEGER)");
829end
830
831function captcha.valid(answer)
832 if not answer then return nil end;
833 local captcha_tbl = captcha.retrieve(answer);
834 if captcha_tbl then captcha.delete(answer) end;
835 captcha.deleteexpired();
836 return captcha_tbl and true or false;
837end
838
839local skey = COOKIE["session_key"];
840username = identity.session.valid(skey);
841acctclass = username and identity.retrieve(username)["Type"] or nil;
842local assignboard = username and identity.retrieve(username)["Board"] or nil;
843
844--
845-- File handling functions.
846--
847
848-- Detect the format of a file (PNG, JPG, GIF).
849function file.format(path)
850 local fd = io.open(path, "r");
851 local data = fd:read(128);
852 fd:close();
853
854 if data == nil or #data == 0 then
855 return nil;
856 end
857
858 if data:sub(1,8) == "\x89PNG\x0D\x0A\x1A\x0A" then
859 return "png";
860 elseif data:sub(1,3) == "\xFF\xD8\xFF" then
861 return "jpg";
862 elseif data:sub(1,6) == "GIF87a"
863 or data:sub(1,6) == "GIF89a" then
864 return "gif";
865 elseif data:find("DOCTYPE svg", 1, true)
866 or data:find("<svg", 1, true) then
867 return "svg";
868 elseif data:sub(1,4) == "\x1A\x45\xDF\xA3" then
869 return "webm";
870 elseif data:sub(5,12) == "ftypmp42"
871 or data:sub(5,12) == "ftypisom" then
872 return "mp4";
873 elseif data:sub(1,2) == "\xFF\xFB"
874 or data:sub(1,3) == "ID3" then
875 return "mp3";
876 elseif data:sub(1,4) == "OggS" then
877 return "ogg";
878 elseif data:sub(1,4) == "fLaC" then
879 return "flac";
880 elseif data:sub(1,4) == "%PDF" then
881 return "pdf";
882 elseif data:sub(1,4) == "PK\x03\x04"
883 and data:sub(31,58) == "mimetypeapplication/epub+zip" then
884 return "epub";
885 else
886 return nil;
887 end
888end
889
890function file.extension(filename)
891 return filename:match("%.(.-)$");
892end
893
894function file.class(extension)
895 local lookup = {
896 ["png"] = "image",
897 ["jpg"] = "image",
898 ["gif"] = "image",
899 ["svg"] = "image",
900 ["webm"] = "video",
901 ["mp4"] = "video",
902 ["mp3"] = "audio",
903 ["ogg"] = "audio",
904 ["flac"] = "audio",
905 ["pdf"] = "document",
906 ["epub"] = "document"
907 };
908
909 return lookup[extension] or extension;
910end
911
912function file.has_thumbnails(extension)
913 local file_class = file.class(extension);
914 return ((file_class == "image") or (file_class == "video") or (extension == "pdf"));
915end
916
917function file.pathname(filename)
918 return "Media/" .. filename;
919end
920
921function file.thumbnail(filename)
922 return "Media/thumb/" .. filename;
923end
924
925function file.icon(filename)
926 return "Media/icon/" .. filename;
927end
928
929function file.exists(filename)
930 if filename == nil or filename == "" then
931 return false;
932 end
933
934 return io.fileexists(file.pathname(filename));
935end
936
937function file.size(filename)
938 return io.filesize(file.pathname(filename));
939end
940
941function file.format_size(size)
942 if size > (1024 * 1024) then
943 return string.format("%.2f MiB", (size / 1024 / 1024));
944 elseif size > 1024 then
945 return string.format("%.2f KiB", (size / 1024));
946 else
947 return string.format("%d B", size);
948 end
949end
950
951-- Create a thumbnail which will fit into a 200x200 grid.
952-- Graphicsmagick (gm convert) must be installed for this to work.
953-- Will not modify images which are smaller than 200x200.
954function file.create_thumbnail(filename)
955 local path_orig = file.pathname(filename);
956 local path_thumb = file.thumbnail(filename);
957 local file_extension = file.extension(filename);
958 local file_class = file.class(file_extension);
959
960 if io.fileexists(path_thumb) then
961 -- Don't recreate thumbnails if they already exist.
962 return 0;
963 end
964
965 if file_class == "video" then
966 return os.execute("ffmpeg -i " .. path_orig .. " -ss 00:00:01.000 -vframes 1 -f image2 - |" ..
967 "gm convert -strip - -filter Box -thumbnail 200x200 JPEG:" .. path_thumb);
968 elseif file_class == "image" or file_extension == "pdf" then
969 return os.execute("gm convert -strip " .. path_orig .. "[0] -filter Box -thumbnail 200x200 " ..
970 ((file_extension == "pdf" or file_extension == "svg") and "PNG:" or "")
971 .. path_thumb);
972 end
973end
974
975-- Create a catalog icon (even smaller than a normal thumbnail).
976-- Catalog icons must be extremely small and quality is not particularly important.
977function file.create_icon(filename)
978 local path_orig = file.pathname(filename);
979 local path_icon = file.icon(filename);
980 local file_class = file.class(file.extension(filename));
981
982 if io.fileexists(path_icon) then
983 -- Don't recreate icons if they already exist.
984 return 0;
985 end
986
987 if file_class == "video" then
988 return os.execute("ffmpeg -i " .. path_orig .. " -ss 00:00:01.000 -vframes 1 -f image2 - |" ..
989 "gm convert -background '#BDC' -flatten -strip - -filter Box -quality 60 " ..
990 "-thumbnail 100x70 JPEG:" .. path_icon);
991 else
992 return os.execute("gm convert -background '#BDC' -flatten -strip " .. path_orig ..
993 "[0] -filter Box -quality 60 -thumbnail 100x70 JPEG:"
994 .. path_icon);
995 end
996end
997
998-- Save a file and return its hashed filename. Errors will result in returning nil.
999-- File hashes are always SHA-256 for compatibility with 8chan and friends.
1000function file.save(path, create_catalog_icon)
1001 local extension = file.format(path);
1002
1003 if extension == nil then
1004 return nil;
1005 end
1006
1007 local fd = io.open(path);
1008 local data = fd:read("*a");
1009 fd:close();
1010 os.remove(path);
1011
1012 local hash = crypto.hash("sha256", data);
1013 local filename = hash .. "." .. extension;
1014
1015 fd = io.open("Media/" .. filename, "w");
1016 fd:write(data);
1017 fd:close();
1018
1019 file.create_thumbnail(filename);
1020
1021 if create_catalog_icon then
1022 file.create_icon(filename);
1023 end
1024
1025 return filename;
1026end
1027
1028function file.delete(filename)
1029 local stmt = nanodb:prepare("DELETE FROM File WHERE Name = ?");
1030 stmt:bind_values(filename);
1031 stmt:step();
1032 stmt:finalize();
1033
1034 os.remove(file.pathname(filename));
1035 os.remove(file.thumbnail(filename));
1036 os.remove(file.icon(filename));
1037end
1038
1039function post.retrieve(boardname, number)
1040 local stmt = nanodb:prepare("SELECT * FROM Posts WHERE Board = ? AND Number = ?");
1041 stmt:bind_values(boardname, tonumber(number));
1042
1043 if stmt:step() ~= sqlite3.ROW then
1044 stmt:finalize();
1045 return nil;
1046 end
1047
1048 local result = stmt:get_named_values();
1049 stmt:finalize();
1050 return result;
1051end
1052
1053function post.listthreads(boardname)
1054 local threads = {};
1055
1056 if boardname then
1057 local stmt = nanodb:prepare("SELECT Number FROM Posts WHERE Board = ? AND Parent = 0 ORDER BY Sticky DESC, LastBumpDate DESC");
1058 stmt:bind_values(boardname);
1059
1060 for tbl in stmt:nrows() do
1061 threads[#threads + 1] = tonumber(tbl["Number"]);
1062 end
1063
1064 stmt:finalize();
1065 end
1066
1067 return threads;
1068end
1069
1070function post.exists(boardname, number)
1071 local stmt = nanodb:prepare("SELECT Number FROM Posts WHERE Board = ? AND Number = ?");
1072 stmt:bind_values(boardname, number);
1073 local stepret = stmt:step();
1074 stmt:finalize();
1075
1076 if stepret ~= sqlite3.ROW then
1077 return false;
1078 else
1079 return true;
1080 end
1081end
1082
1083function post.bump(boardname, number)
1084 local stmt = nanodb:prepare("UPDATE Posts SET LastBumpDate = CAST(strftime('%s', 'now') AS INTEGER) WHERE Board = ? AND Number = ? AND Autosage = 0");
1085 stmt:bind_values(boardname, tonumber(number));
1086 stmt:step();
1087 stmt:finalize();
1088end
1089
1090function post.toggle(attribute, boardname, number)
1091 local post_tbl = post.retrieve(boardname, number);
1092 local current_value = post_tbl[attribute];
1093 local new_value = (current_value == 1) and 0 or 1;
1094 local stmt = nanodb:prepare("UPDATE Posts SET " .. attribute .. " = ? WHERE Board = ? AND Number = ?");
1095 stmt:bind_values(new_value, boardname, number);
1096 stmt:step();
1097 stmt:finalize();
1098
1099 if post_tbl["Parent"] == 0 then
1100 generate.catalog(boardname);
1101 generate.thread(boardname, number);
1102 if board.retrieve(boardname)["DisplayOverboard"] == 1 then
1103 generate.overboard();
1104 end
1105 else
1106 generate.thread(boardname, post_tbl["Parent"]);
1107 end
1108end
1109
1110function post.threadreplies(boardname, number)
1111 local replies = {};
1112 local stmt = nanodb:prepare("SELECT Number FROM Posts WHERE Board = ? AND Parent = ? ORDER BY Number");
1113 stmt:bind_values(boardname, number);
1114
1115 for tbl in stmt:nrows() do
1116 replies[#replies + 1] = tonumber(tbl["Number"]);
1117 end
1118
1119 stmt:finalize();
1120 return replies;
1121end
1122
1123function post.threadfiles(boardname, number, includeop)
1124 -- returns a list of post numbers of posts with attached files
1125 local numbers = {};
1126
1127 if includeop then
1128 numbers[1] = (post.retrieve(boardname, number)["File"] ~= "") and number or nil;
1129 end
1130
1131 local stmt = nanodb:prepare("SELECT Number FROM Posts WHERE Board = ? AND Parent = ? AND File != '' ORDER BY Number");
1132 stmt:bind_values(boardname, number);
1133
1134 for tbl in stmt:nrows() do
1135 numbers[#numbers + 1] = tonumber(tbl["Number"]);
1136 end
1137
1138 stmt:finalize();
1139 return numbers;
1140end
1141
1142function post.format(boardname, number)
1143 return board.format(boardname) .. number;
1144end
1145
1146-- Turn nanochan-formatting into html.
1147function post.nano2html(text)
1148 text = "\n" .. text .. "\n";
1149
1150 return text:gsub(">>(%d+)", "<a class='reference' href='#post%1'>>>%1</a>")
1151 :gsub(">>>/([%d%l]-)/(%s)", "<a class='reference' href='/%1'>>>>/%1/</a>%2")
1152 :gsub(">>>/([%d%l]-)/(%d+)", "<a class='reference' href='/%1/%2.html'>>>>/%1/%2</a>")
1153 :gsub("\n>(.-)\n", "\n<span class='greentext'>>%1</span>\n")
1154 :gsub("\n>(.-)\n", "\n<span class='greentext'>>%1</span>\n")
1155 :gsub("\n<(.-)\n", "\n<span class='pinktext'><%1</span>\n")
1156 :gsub("\n<(.-)\n", "\n<span class='pinktext'><%1</span>\n")
1157 :gsub("%(%(%((.-)%)%)%)", "<span class='kiketext'>(((%1)))</span>")
1158 :gsub("==(.-)==", "<span class='redtext'>%1</span>")
1159 :gsub("%*%*(.-)%*%*", "<span class='spoiler'>%1</span>")
1160 :gsub("~~(.-)~~", "<s>%1</s>")
1161 :gsub("__(.-)__", "<u>%1</u>")
1162 :gsub("'''(.-)'''", "<b>%1</b>")
1163 :gsub("''(.-)''", "<i>%1</i>")
1164 :gsub("(https?://)([a-zA-Z0-9%./%%_%-%+=%?&;:,#%!~]+)", "<a rel='noreferrer' href='%1%2'>%1%2</a>")
1165 :sub(2, -2)
1166 :gsub("\n", "<br />");
1167end
1168
1169-- This function does not delete the actual file. It simply removes the reference to that file.
1170function post.unlink(boardname, number)
1171 local post_tbl = post.retrieve(boardname, number);
1172
1173 local stmt = nanodb:prepare("UPDATE Posts SET File = '' WHERE Board = ? AND Number = ?");
1174 stmt:bind_values(boardname, number);
1175 stmt:step();
1176 stmt:finalize();
1177
1178 if post_tbl["Parent"] == 0 then
1179 generate.catalog(boardname);
1180 generate.thread(boardname, number);
1181 if board.retrieve(boardname)["DisplayOverboard"] == 1 then
1182 generate.overboard();
1183 end
1184 else
1185 generate.thread(boardname, post_tbl["Parent"]);
1186 end
1187end
1188
1189function post.delete(boardname, number)
1190 local post_tbl = post.retrieve(boardname, number);
1191 local stmt = nanodb:prepare("DELETE FROM Posts WHERE Board = ? AND Number = ?");
1192 stmt:bind_values(boardname, number);
1193 stmt:step();
1194 stmt:finalize();
1195
1196 -- Delete descendants of that post too, if that post is a thread.
1197 stmt = nanodb:prepare("DELETE FROM Posts WHERE Board = ? AND Parent = ?");
1198 stmt:bind_values(boardname, number);
1199 stmt:step();
1200 stmt:finalize();
1201
1202 -- Delete references to and from that post.
1203 stmt = nanodb:prepare("DELETE FROM Refs WHERE Board = ? AND (Referrer = ? OR Referee = ?)");
1204 stmt:bind_values(boardname, number, number);
1205 stmt:step();
1206 stmt:finalize();
1207
1208 -- Delete references to and from every descendant post.
1209 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 = ?))");
1210 stmt:bind_values(boardname, boardname, number, boardname, number);
1211 stmt:step();
1212 stmt:finalize();
1213
1214 generate.catalog(boardname);
1215 if board.retrieve(boardname)["DisplayOverboard"] == 1 then
1216 generate.overboard();
1217 end
1218
1219 if post_tbl["Parent"] == 0 then
1220 os.remove(boardname .. "/" .. number .. ".html");
1221 else
1222 generate.thread(boardname, post_tbl["Parent"]);
1223 end
1224end
1225
1226function post.create(boardname, parent, name, email, subject, comment, filename)
1227 local stmt;
1228 local board_tbl = board.retrieve(boardname);
1229 parent = parent or 0;
1230 name = (name and name ~= "") and string.escapehtml(name) or "Nanonymous";
1231 email = email and string.escapehtml(email) or "";
1232 subject = subject and string.escapehtml(subject) or "";
1233 local references = {};
1234
1235 -- Find >>xxxxx in posts before formatting is applied.
1236 for reference in comment:gmatch(">>([0123456789]+)") do
1237 references[#references + 1] = tonumber(reference);
1238 end
1239
1240-- comment = comment and post.nano2html(string.escapehtml(comment)) or "";
1241 if comment then
1242 comment = string.escapehtml(comment);
1243 comment = email == "noformatting" and comment:gsub("\n", "<br />") or post.nano2html(comment);
1244 else
1245 comment = "";
1246 end
1247
1248 filename = filename or "";
1249 local date = os.time();
1250 local lastbumpdate = date;
1251 local autosage = email == "sage" and 1 or 0;
1252
1253-- if name == "##" then
1254 if name == "##" and username ~= nil then
1255 local capcode;
1256
1257 if acctclass == "admin" then
1258 capcode = "Nanochan Administrator";
1259 elseif acctclass == "bo" then
1260 capcode = "Board Owner (" .. board.format(assignboard) .. ")";
1261 elseif acctclass == "gvol" then
1262 capcode = "Global Volunteer";
1263 elseif acctclass == "lvol" then
1264 capcode = "Board Volunteer (" .. board.format(assignboard) .. ")";
1265-- else
1266-- capcode = "Reddit Administrator";
1267 end
1268
1269 name = (username and username or "") .. " <span class='capcode'>## " .. capcode .. "</span>";
1270 end
1271
1272 name = name:gsub("!(.+)", "<span class='tripcode'>!%1</span>");
1273
1274 if parent ~= 0 and #post.threadreplies(boardname, parent) >= board_tbl["PostLimit"] then
1275 -- Delete earliest replies in cyclical thread.
1276 local stmt = nanodb:prepare("DELETE FROM Posts WHERE Board = ? AND Number = (SELECT Number FROM Posts WHERE Parent = ? AND Board = ? ORDER BY Number LIMIT 1)");
1277 stmt:bind_values(boardname, parent, boardname);
1278 stmt:step();
1279 stmt:finalize();
1280 elseif parent == 0 and #post.listthreads(boardname) >= board_tbl["ThreadLimit"] then
1281 -- Slide threads off the bottom of the catalog.
1282 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)");
1283 stmt:bind_values(boardname, boardname);
1284 stmt:step();
1285 stmt:finalize();
1286 end
1287
1288 nanodb:exec("BEGIN EXCLUSIVE TRANSACTION");
1289 stmt = nanodb:prepare("UPDATE Boards SET MaxPostNumber = MaxPostNumber + 1 WHERE Name = ?");
1290 stmt:bind_values(boardname);
1291 stmt:step();
1292 stmt:finalize();
1293 stmt = nanodb:prepare("SELECT MaxPostNumber FROM Boards WHERE Name = ?");
1294 stmt:bind_values(boardname);
1295 stmt:step();
1296 local number = stmt:get_value(0);
1297 stmt:finalize();
1298 nanodb:exec("END TRANSACTION");
1299
1300 stmt = nanodb:prepare("INSERT INTO Posts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
1301 stmt:bind_values(boardname, number, parent, date, lastbumpdate, name, email, subject, comment, filename, 0, 0, autosage, 0);
1302 stmt:step();
1303 stmt:finalize()
1304
1305 -- Enable the captcha if too many posts were created, and it was not already enabled.
1306 if board_tbl["CaptchaTriggerPPH"] > 0 and
1307 board.pph(boardname, 1, false) > board_tbl["CaptchaTriggerPPH"] and
1308 board_tbl["RequireCaptcha"] == 0 then
1309 board_tbl["RequireCaptcha"] = 1;
1310 board.update(board_tbl);
1311 log.create(nil, boardname, "Automatically enabled captcha due to excessive PPH");
1312 end
1313
1314 -- Enable the captcha if MaxThreadsPerHour was hit, and it was not already enabled.
1315 if board_tbl["MaxThreadsPerHour"] > 0 and
1316 board.tph(boardname, 12, false) >= board_tbl["MaxThreadsPerHour"] and
1317 board_tbl["RequireCaptcha"] == 0 then
1318 global.setflag("ThreadCaptcha", true);
1319
1320 local boards = board.list();
1321 for i = 1, #boards do
1322 generate.catalog(boards[i]);
1323 end
1324
1325 log.create(nil, nil, "Automatically enabled sitewide thread captcha due to excessive TP12H in ", board.format(boardname));
1326 end
1327
1328 for i = 1, #references do
1329 stmt = nanodb:prepare("INSERT INTO Refs SELECT ?, ?, ? WHERE (SELECT COUNT(*) FROM Refs WHERE Board = ? AND Referee = ? AND Referrer = ?) = 0");
1330 stmt:bind_values(boardname, references[i], number, boardname, references[i], number);
1331 stmt:step();
1332 stmt:finalize();
1333 end
1334
1335 if parent ~= 0 then
1336 if not (string.lower(email) == "sage") and
1337 not (#post.threadreplies(boardname, parent) > board_tbl["BumpLimit"]) then
1338 post.bump(boardname, parent);
1339 end
1340 end
1341
1342 generate.thread(boardname, (parent ~= 0 and parent or number));
1343 generate.catalog(boardname);
1344
1345 if board_tbl["DisplayOverboard"] == 1 then
1346 generate.overboard();
1347 end
1348
1349 return number;
1350end
1351
1352--
1353-- Log access functions.
1354--
1355
1356function log.create(account, boardname, action, reason)
1357 account = account or "<i>System</i>";
1358 boardname = boardname and html.string.boardlink(boardname) or "<i>Global</i>";
1359 action = action or "Did nothing";
1360 local date = os.time();
1361 local desc = reason and action .. " for reason: " .. post.nano2html(string.escapehtml(reason)) or action;
1362
1363 local stmt = nanodb:prepare("INSERT INTO Logs VALUES (?,?,?,?)");
1364 stmt:bind_values(account, boardname, date, desc);
1365 stmt:step();
1366 stmt:finalize();
1367end
1368
1369function log.retrieve(limit, offset)
1370 limit = limit or 128;
1371 offset = offset or 0;
1372 local entries = {};
1373
1374 local stmt = nanodb:prepare("SELECT * FROM Logs ORDER BY Date DESC LIMIT ? OFFSET ?");
1375 stmt:bind_values(limit, offset);
1376
1377 for tbl in stmt:nrows() do
1378 entries[#entries + 1] = tbl;
1379 end
1380
1381 stmt:finalize();
1382 return entries;
1383end
1384
1385--
1386-- HTML output functions.
1387--
1388
1389function html.redirect(location)
1390 io.write("\n");
1391 io.write("<!DOCTYPE html>\n");
1392 io.write("<html>");
1393 io.write( "<head>");
1394 io.write( "<title>Redirecting...</title>");
1395 io.write( "<meta http-equiv='refresh' content='0;url=", location or "/", "' />");
1396 io.write( "</head>");
1397 io.write( "<body>");
1398 io.write( "Redirecting to <a href='", location,"'>", location, "</a>");
1399 io.write( "</body>");
1400 io.write("</html>");
1401end
1402
1403function html.begin(title, name, value)
1404 if title == nil then
1405 title = ""
1406 else
1407 title = title .. " - "
1408 end
1409
1410 if name and value then
1411-- io.write("<meta http-equiv='set-cookie' content='", name, "=", value, ";Path=/Nano' />");
1412 io.write("Set-Cookie: ", name, "=", value, ";Path=/Nano;Http-Only\n");
1413 end
1414
1415 io.write("\n");
1416 io.write("<!DOCTYPE html>\n");
1417 io.write("<html>");
1418 io.write( "<head>");
1419 io.write( "<title>", title, "nanochan</title>");
1420 io.write( "<link rel='stylesheet' type='text/css' href='/Static/nanochan.css' />");
1421 io.write( "<link rel='shortcut icon' type='image/png' href='/Static/favicon.png' />");
1422
1423 io.write( "<meta charset='utf-8' />");
1424 io.write( "<meta name='viewport' content='width=device-width, initial-scale=1.0' />");
1425 io.write( "</head>");
1426 io.write( "<body>");
1427 io.write( "<div id='topbar'>");
1428 io.write( "<nav id='topnav'>");
1429 io.write( "<ul>");
1430 io.write( "<li class='system'><a href='/index.html'>main</a></li>");
1431 io.write( "<li class='system'><a href='/Nano/mod'>mod</a></li>");
1432 io.write( "<li class='system'><a href='/Nano/log'>log</a></li>");
1433 io.write( "<li class='system'><a href='/Nano/stats'>stats</a></li>");
1434 io.write( "<li class='system'><a href='/Nano/recent'>recent</a></li>");
1435 io.write( "<li class='system'><a href='/overboard.html'>overboard</a></li>");
1436
1437 local boards = board.list();
1438 for i = 1, #boards do
1439 if boards[i] ~= "k" and boards[i] ~= "o" then -- remove when unneeded
1440 io.write("<li class='board'><a href='/", boards[i], "'>", board.format(boards[i]), "</a></li>");
1441 end
1442 end
1443
1444 io.write( "</ul>");
1445 io.write( "</nav>");
1446 io.write( "</div>");
1447 io.write( "<div id='content'>");
1448end
1449
1450function html.finish()
1451 io.write( "</div>");
1452 io.write( "</body>");
1453 io.write("</html>");
1454end
1455
1456function html.redheader(text)
1457 io.write("<h1 class='redheader'>", text, "</h1>");
1458end
1459
1460function html.announce()
1461 if global.retrieve("announce") then
1462 io.write("<div id='announce'>", global.retrieve("announce"), "</div>");
1463 end
1464end
1465
1466function html.pageswitcher(currentpage)
1467 io.write("<div class='page-switcher'>");
1468 io.write("<a class='page-switcher-prev' href='?page=", currentpage - 1, "'>[Prev]</a>");
1469 io.write("<a class='page-switcher-next' href='?page=", currentpage + 1, "'>[Next]</a>");
1470 io.write("</div>");
1471end
1472
1473function html.container.begin(type)
1474 io.write("<div class='container ", type or "narrow", "'>");
1475end
1476
1477function html.container.finish()
1478 io.write("</div>");
1479end
1480
1481function html.container.barheader(text)
1482 io.write("<h2 class='barheader'>", text, "</h2>");
1483end
1484
1485function html.table.begin(id, ...)
1486 local arg = {...};
1487 io.write("<table id='", id, "'>");
1488 io.write("<tr>");
1489
1490 for i = 1, #arg do
1491 io.write("<th>", arg[i], "</th>");
1492 end
1493
1494 io.write("</tr>");
1495end
1496
1497function html.table.entry(...)
1498 local arg = {...};
1499 io.write("<tr>");
1500
1501 for i = 1, #arg do
1502 io.write("<td>", tostring(arg[i]), "</td>");
1503 end
1504
1505 io.write("</tr>");
1506end
1507
1508function html.table.finish()
1509 io.write("</table>");
1510end
1511
1512function html.list.begin(type)
1513 io.write(type == "ordered" and "<ol>" or "<ul>");
1514end
1515
1516function html.list.entry(text, class)
1517 io.write("<li", class and (" class='" .. class .. "' ") or "", ">", text, "</li>");
1518end
1519
1520function html.list.finish(type)
1521 io.write(type == "ordered" and "</ol>" or "</ul>");
1522end
1523
1524-- Pre-defined pages.
1525function html.pdp.authorization_denied()
1526 html.begin("permission denied");
1527 html.redheader("Permission denied");
1528 html.container.begin();
1529 io.write("Your account class lacks authorization to perform this action. <a href='/Nano/mod'>Go back.</a>");
1530 html.container.finish();
1531 html.finish();
1532end
1533
1534function html.pdp.error(heading, explanation)
1535 html.begin("error");
1536 html.redheader(heading);
1537 html.container.begin();
1538 io.write(explanation);
1539 html.container.finish();
1540 html.finish();
1541end
1542
1543function html.pdp.notfound()
1544 html.begin("404");
1545 html.redheader("404 not found");
1546 html.container.begin();
1547 io.write("The resource which was requested does not appear to exist. Please check the URL");
1548 io.write(" and try again. Alternatively, if you believe this error message to in itself");
1549 io.write(" be an error, try contacting the nanochan administration.");
1550 html.container.finish();
1551 html.finish();
1552end
1553
1554function html.string.link(href, text, title)
1555 if not href then
1556 return nil;
1557 end
1558
1559 local result = "<a href='" .. href .. "'";
1560
1561 if href:sub(1, 1) ~= "/" then
1562 result = result .. " rel='noreferrer' target='_blank'";
1563 end
1564
1565 if title then
1566 result = result .. " title='" .. title .. "'";
1567 end
1568
1569 result = result .. ">" .. (text or href) .. "</a>";
1570 return result;
1571end
1572
1573function html.string.datetime(unixtime)
1574 local isotime = os.date("!%F %T", unixtime);
1575 return "<time datetime='" .. isotime .. "'>" .. isotime .. "</time>";
1576end
1577
1578function html.string.boardlink(boardname)
1579 return html.string.link(board.format(boardname));
1580end
1581
1582function html.string.threadlink(boardname, number)
1583 return html.string.link(post.format(boardname, number) .. ".html", post.format(boardname, number));
1584end
1585
1586function html.board.title(boardname)
1587 io.write("<h1 id='boardtitle'>", board.format(boardname), " - ", board.retrieve(boardname)["Title"], "</h1>");
1588end
1589
1590function html.board.subtitle(boardname)
1591 io.write("<h2 id='boardsubtitle'>", board.retrieve(boardname)["Subtitle"], "</h2>");
1592end
1593
1594function html.post.postform(boardname, parent)
1595 local board_tbl = board.retrieve(boardname);
1596
1597 if board_tbl["Lock"] == 1 and not username then
1598 return;
1599 end
1600
1601 io.write("<a id='new-post' href='#postform' accesskey='p'>", (parent == 0) and "[Start a New Thread]" or "[Make a Post]", "</a>");
1602 io.write("<fieldset><form id='postform' action='/Nano/post' method='post' enctype='multipart/form-data'>");
1603 io.write("<input type='hidden' name='board' value='", boardname, "' />");
1604 io.write("<input type='hidden' name='parent' value='", parent, "' />");
1605 io.write("<a href='##' class='close-button' accesskey='w'>[X]</a>");
1606 io.write("<label for='name'>Name</label><input type='text' id='name' name='name' maxlength='64' /><br />");
1607 io.write("<label for='email'>Email</label><input type='text' id='email' name='email' maxlength='64' /><br />");
1608 io.write("<label for='subject'>Subject</label><input type='text' id='subject' name='subject' autocomplete='off' maxlength='64' />");
1609 io.write("<input type='submit' value='Post' accesskey='s' /><br />");
1610 io.write("<label for='comment'>Comment</label><textarea id='comment' name='comment' form='postform' rows='5' cols='35' maxlength='32768'></textarea><br />");
1611 io.write("<label for='file'>File</label><input type='file' id='file' name='file' /><br />");
1612
1613 if board_tbl["RequireCaptcha"] == 1 or (parent == 0 and global.retrieveflag("ThreadCaptcha", false)) then
1614 io.write("<label for='captcha'>Captcha</label><input type='text' id='captcha' name='captcha' autocomplete='off' maxlength='6' /><br />");
1615 io.write("<img id='captcha-image' width='290' height='70' src='/Nano/captcha.jpg' />");
1616 end
1617
1618 io.write("</form></fieldset>");
1619end
1620
1621function html.post.modlinks(boardname, number)
1622 local post_tbl = post.retrieve(boardname, number);
1623
1624 io.write("<span class='thread-mod-links'>");
1625
1626 --io.write("<input type='checkbox' class='range-select' name='selected' value='", boardname, "-", number, "' />");
1627 io.write("<a href='/Nano/mod/post/delete/", boardname, "/", number, "' title='Delete'>[D]</a>");
1628
1629 if file.exists(post_tbl["File"]) then
1630 io.write("<a href='/Nano/mod/post/unlink/", boardname, "/", number, "' title='Unlink File'>[U]</a>");
1631 io.write("<a href='/Nano/mod/file/delete/", post_tbl["File"], "' title='Delete File'>[F]</a>");
1632 end
1633
1634 if post_tbl["Parent"] == 0 then
1635-- io.write("<a href='/Nano/mod/post/move/", boardname, "/", number, "' title='Move'>[M]</a>");
1636 io.write("<a href='/Nano/mod/post/sticky/", boardname, "/", number, "' title='Sticky'>[S]</a>");
1637 io.write("<a href='/Nano/mod/post/lock/", boardname, "/", number, "' title='Lock'>[L]</a>");
1638 io.write("<a href='/Nano/mod/post/autosage/", boardname, "/", number, "' title='Autosage'>[A]</a>");
1639 io.write("<a href='/Nano/mod/post/cycle/", boardname, "/", number, "' title='Cycle'>[C]</a>");
1640 end
1641
1642 io.write("</span>");
1643end
1644
1645function html.post.threadflags(boardname, number)
1646 local post_tbl = post.retrieve(boardname, number);
1647 io.write("<span class='thread-info-flags'>");
1648 if post_tbl["Sticky"] == 1 then io.write("(S)"); end;
1649 if post_tbl["Lock"] == 1 then io.write("(L)"); end;
1650 if post_tbl["Autosage"] == 1 then io.write("(A)"); end;
1651 if post_tbl["Cycle"] == 1 then io.write("(C)"); end;
1652 io.write("</span>");
1653end
1654
1655function html.post.render_catalog(boardname, number)
1656 local post_tbl = post.retrieve(boardname, number);
1657
1658 io.write("<div class='catalog-thread'>");
1659 io.write( "<div class='catalog-thread-link'><a href='/", boardname, "/", number, ".html'>");
1660
1661 if file.exists(post_tbl["File"]) then
1662 local file_ext = file.extension(post_tbl["File"]);
1663 local file_class = file.class(file_ext);
1664
1665 if file.has_thumbnails(file_ext) then
1666 io.write("<img src='/" .. file.icon(post_tbl["File"]) .. "' alt='***' />");
1667 else
1668 io.write("<img width='100' height='70' src='/Static/", file_class, ".png' />");
1669 end
1670 else
1671 io.write("***");
1672 end
1673
1674 io.write( "</a></div>");
1675 io.write( "<div class='thread-info'>");
1676 io.write( "<span class='thread-board-link'><a href='/", boardname, "'>", board.format(boardname), "</a></span> ");
1677 io.write( "<span class='thread-info-replies'>R:", #post.threadreplies(boardname, number), "</span>");
1678 html.post.threadflags(boardname, number);
1679 io.write( "</div>");
1680
1681 io.write( "<div class='catalog-thread-latest-post'>L: ");
1682-- html.string.datetime(post_tbl["LastBumpDate"]));
1683 io.write( "<time datetime='", os.date("!%F %T", post_tbl["LastBumpDate"]), "'>");
1684 io.write( os.date("!%F %H:%M", post_tbl["LastBumpDate"]));
1685 io.write( "</time>");
1686 io.write( "</div>");
1687
1688 html.post.modlinks(boardname, number);
1689
1690 io.write( "<div class='catalog-thread-subject'>");
1691 io.write( post_tbl["Subject"] or "");
1692 io.write( "</div>");
1693
1694 io.write( "<div class='catalog-thread-comment'>");
1695 io.write( post_tbl["Comment"]);
1696 io.write( "</div>");
1697 io.write("</div>");
1698end
1699
1700-- Omitting the 'boardname' value will turn the catalog into an overboard.
1701function html.post.catalog(boardname)
1702 io.write("<a href='' accesskey='r'>[Update]</a>");
1703 io.write("<hr />");
1704 io.write("<div class='catalog-container'>");
1705
1706 if boardname ~= nil then
1707 -- Catalog mode.
1708 local threadlist = post.listthreads(boardname);
1709 for i = 1, #threadlist do
1710 local number = threadlist[i];
1711 html.post.render_catalog(boardname, number);
1712 io.write("<hr class='invisible' />");
1713 end
1714 else
1715 -- Overboard mode.
1716 for post_tbl in nanodb:nrows("SELECT Board, Number FROM Posts WHERE Parent = 0 AND Autosage = 0 AND (SELECT DisplayOverboard FROM Boards WHERE Name = Board) = 1 ORDER BY LastBumpDate DESC LIMIT 100") do
1717 html.post.render_catalog(post_tbl["Board"], post_tbl["Number"]);
1718 io.write("<hr class='invisible' />");
1719 end
1720 end
1721
1722 io.write("</div>");
1723end
1724
1725function html.post.renderthumbnail(filename)
1726 local file_ext = file.extension(filename);
1727 local file_class = file.class(file_ext);
1728
1729 io.write("<div class='post-file-info'>");
1730 io.write("File: <a href='/Media/", filename, "' target='_blank'>", filename, "</a>");
1731 io.write(" (<a href='/Media/", filename, "' download>", "dl</a>)");
1732 io.write(" (", file.format_size(file.size(filename)), ")");
1733 io.write("</div>");
1734
1735 if file.has_thumbnails(file_ext) then
1736 local width, height = file.thumbnail_dimensions_get(filename);
1737 io.write("<a target='_blank' href='/Media/" .. filename .. "'>");
1738 io.write( "<img class='post-file-thumbnail' width='", width, "' height='", height, "' src='/", file.thumbnail(filename), "' />");
1739 io.write("</a>");
1740 elseif file_ext == "epub" then
1741 io.write("<a target='_blank' href='/Media/" .. filename .. "'>");
1742 io.write( "<img width='100' height='70' class='post-file-thumbnail' src='/Static/document.png' />");
1743 io.write("</a>");
1744 elseif file_class == "audio" then
1745 io.write("<audio class='post-audio' preload='none' controls loop>");
1746 io.write( "<source src='/Media/", filename, "' type='audio/", file_ext, "' />");
1747 io.write("</audio>");
1748 end
1749end
1750
1751function html.post.render(boardname, number, standalone)
1752 local post_tbl = post.retrieve(boardname, number);
1753 local parent = (post_tbl["Parent"] ~= 0) and post_tbl["Parent"] or nil;
1754
1755 io.write("<div class='post-container' id='post", number, "'>");
1756 io.write( "<div class='post'>");
1757 io.write( "<div class='post-header'>");
1758
1759 if standalone then
1760 io.write("<span class='boardname'><b>" .. board.format(boardname) .. (parent or "") .. "</b> -> </span>");
1761 end
1762
1763 io.write( "<span class='post-subject'>", post_tbl["Subject"] or "", "</span> ");
1764 io.write( "<span class='post-name'>");
1765
1766 if post_tbl["Email"] ~= "" then
1767 io.write( "<a class='post-email' href='mailto:", post_tbl["Email"], "'>");
1768 end
1769
1770 io.write( post_tbl["Name"]);
1771
1772 if post_tbl["Email"] ~= "" then
1773 io.write( "</a>");
1774 end
1775
1776 local hrefpre = standalone and board.format(boardname) .. (parent or number) .. ".html/" or "";
1777 io.write( "</span> ");
1778 io.write( "<span class='post-date'>", html.string.datetime(post_tbl["Date"]), "</span> ");
1779 io.write( "<span class='post-number'>");
1780 io.write( "<a href='", hrefpre, "#post", number, "'>No.</a>");
1781 io.write( "<a href='", hrefpre, "#postform'>", post_tbl["Number"], "</a>");
1782 io.write( "</span> ");
1783
1784 if post_tbl["Parent"] == 0 then
1785 html.post.threadflags(boardname, number);
1786 end
1787 html.post.modlinks(boardname, number);
1788
1789 local stmt = nanodb:prepare("SELECT Referrer FROM Refs WHERE Board = ? AND Referee = ? ORDER BY Referrer");
1790 stmt:bind_values(boardname, number);
1791
1792 for referee in stmt:nrows() do
1793 io.write( " <a class='referee' href='", standalone and board.format(boardname) .. (parent or number) .. ".html/" or "", "#post", referee["Referrer"], "'>>>", referee["Referrer"], "</a>");
1794 end
1795
1796 stmt:finalize();
1797
1798 io.write( "</div>");
1799
1800 if file.exists(post_tbl["File"]) then
1801 html.post.renderthumbnail(post_tbl["File"]);
1802 end
1803
1804 io.write( "<div class='post-comment'>");
1805 io.write( post_tbl["Comment"]);
1806 io.write( "</div>");
1807 io.write( "</div>");
1808
1809 io.write( "<br />");
1810 io.write("</div>");
1811end
1812
1813function html.post.renderthread(boardname, number)
1814 local replies = post.threadreplies(boardname, number);
1815 html.post.render(boardname, number, false);
1816
1817 for i = 1, #replies do
1818 io.write("<hr class='invisible' />");
1819 html.post.render(boardname, replies[i], false);
1820 end
1821end
1822
1823function generate.mainpage()
1824 io.output("index.html");
1825
1826 html.begin();
1827 html.redheader("Welcome to Nanochan");
1828 html.announce(global.retrieve("announce"));
1829 html.container.begin("narrow");
1830 io.write("<img id='front-page-logo' src='/Static/logo.png' alt='Nanochan logo' width=400 height=400 />");
1831 html.container.barheader("Boards");
1832
1833 local boards = board.list();
1834 html.list.begin("ordered");
1835 for i = 1, #boards do
1836 local board_tbl = board.retrieve(boards[i]);
1837 html.list.entry(html.string.boardlink(board_tbl["Name"]) .. " - " .. board_tbl["Title"]);
1838 end
1839 html.list.finish("ordered");
1840
1841 html.container.barheader("Rules");
1842 io.write("These rules apply to all boards on nanochan:");
1843 html.list.begin("ordered");
1844 html.list.entry("Child pornography is not permitted. Links to child pornography are not permitted either, " ..
1845 "and neither are links to websites which contain a significant number of direct links to CP.");
1846 html.list.entry("Flooding is not permitted. We define flooding as posting similar posts more " ..
1847 "than 3 times per hour, making a thread on a topic for which a thread already exists, " ..
1848 "or posting in such a way that it significantly " ..
1849 "changes the composition of a board. Common sense will be utilized.");
1850 html.list.finish("ordered");
1851 io.write("Individual boards may set their own rules which apply to that board. However, note");
1852 io.write(" that the nanochan rules stated above apply to everything done on the website.");
1853
1854 html.container.barheader("Miscellaneous");
1855 io.write("Source code for Nanochan can be found ", html.string.link("/source.lua", "here"), ".<br />");
1856-- io.write("To contact the administration, send an e-mail to ", html.string.link("mailto:37564N@memeware.net", "this address"), ".");
1857 io.write("The post deletion log can be found ", html.string.link("/audit.log", "here"), ".<br />");
1858 io.write("The SHA256 TLS fingerprint for the current v3 address is:<br />");
1859 io.write("4D:90:B6:FF:41:BF:1F:57:72:F1:D5:37:67:43:5C:D9:<br />");
1860 io.write("8A:5A:53:FE:C0:B3:3E:E3:C5:65:60:73:A6:46:C9:9A.");
1861
1862 html.container.finish();
1863 html.finish();
1864
1865 io.output(io.stdout);
1866end
1867
1868function generate.overboard()
1869 io.output("overboard.html");
1870 nanodb:exec("BEGIN TRANSACTION");
1871
1872 html.begin("overboard");
1873 html.redheader("Nanochan overboard");
1874 html.announce();
1875 html.post.catalog();
1876 html.finish();
1877
1878 nanodb:exec("END TRANSACTION");
1879 io.output(io.stdout);
1880end
1881
1882function generate.thread(boardname, number)
1883 local post_tbl = post.retrieve(boardname, number);
1884 if not post_tbl then
1885 return;
1886 elseif post_tbl["Parent"] ~= 0 then
1887 post_tbl = post.retrieve(boardname, post_tbl["Parent"]);
1888 number = post_tbl["Number"];
1889 end
1890
1891 io.output(boardname .. "/" .. number .. ".html");
1892 nanodb:exec("BEGIN TRANSACTION");
1893
1894 local desc = (#post_tbl["Subject"] > 0 and post_tbl["Subject"] or string.striphtml(post_tbl["Comment"]):sub(1, 64));
1895 html.begin(board.format(boardname) .. ((#desc > 0) and (" - " .. desc) or ""));
1896
1897 html.board.title(boardname);
1898 html.board.subtitle(boardname);
1899 html.announce();
1900
1901 html.post.postform(boardname, number);
1902 io.write("<hr />");
1903 html.post.renderthread(boardname, number);
1904 io.write("<hr />");
1905
1906 io.write("<div id='bottom-links' />");
1907 io.write("<a href='/", boardname, "/catalog.html'>[Catalog]</a>");
1908 io.write("<a href='/overboard.html'>[Overboard]</a>");
1909 io.write("<a href='' accesskey='r'>[Update]</a>");
1910 io.write("<div id='thread-reply'>");
1911 io.write( "<a href='#postform'>[Reply]</a>");
1912 io.write( #post.threadfiles(boardname, number, true), " files, ");
1913 io.write( #post.threadreplies(boardname, number), " replies");
1914 io.write("</div></div>");
1915
1916 html.finish();
1917 nanodb:exec("END TRANSACTION");
1918 io.output(io.stdout);
1919end
1920
1921function generate.catalog(boardname)
1922 io.output(boardname .. "/" .. "catalog.html");
1923 nanodb:exec("BEGIN TRANSACTION");
1924 html.begin(board.format(boardname));
1925
1926 html.board.title(boardname);
1927 html.board.subtitle(boardname);
1928 html.announce();
1929
1930 html.post.postform(boardname, 0);
1931 html.post.catalog(boardname);
1932
1933 html.finish();
1934 nanodb:exec("END TRANSACTION");
1935 io.output(io.stdout);
1936end
1937
1938-- Write HTTP headers.
1939io.write("Cache-Control: no-cache\n");
1940if cgi.pathinfo[1] == "captcha.jpg" then
1941 io.write("Content-Type: image/jpeg\n");
1942 io.write("\n");
1943else
1944 io.write("Content-Type: text/html; charset=utf-8\n");
1945end
1946-- io.write("\n"); -- signifies end of headers and beginning of html document
1947
1948--
1949-- This is the main part of Nanochan, where all the pages are defined.
1950--
1951
1952if cgi.pathinfo[1] == nil or cgi.pathinfo[1] == "" then
1953 -- /Nano
1954 html.redirect("/index.html");
1955elseif cgi.pathinfo[1] == "captcha.jpg" then
1956 local captcha_data;
1957 while not captcha_data do
1958 captcha_data = captcha.create();
1959 end
1960
1961 io.write(captcha_data);
1962elseif cgi.pathinfo[1] == "stats" then
1963 html.begin("stats");
1964 html.redheader("Nanochan statistics");
1965
1966 if not username and global.retrieveflag("NanoRequireCaptcha", false) and not captcha.valid(POST["captcha"]) then
1967 html.container.begin();
1968 if POST["captcha"] then
1969 io.write("The captcha you entered was incorrect.");
1970 end
1971 html.container.barheader("Regenerate stats");
1972 io.write("<fieldset><form method='post'>");
1973 io.write( "<img id='captcha-image' width='290' height='70' src='/Nano/captcha.jpg' /><br />");
1974 io.write( "<label for='captcha'>Captcha</label><input type='text' id='captcha' name='captcha' autocomplete='off' maxlength='6' /><br />");
1975 io.write( "<label for='submit'>Submit</label><input id='submit' type='submit' value='Continue' />");
1976 io.write("</form></fieldset>");
1977 html.container.finish();
1978 else
1979 html.container.begin("wide");
1980 html.table.begin("stats", "Board", "TPD (24h)", "TPW (7d)", "PPH (1h)", "PPH (24h)", "PPD (24h)", "PPD (7d)", "Total Posts");
1981
1982 local boards = board.list();
1983 local total = {};
1984 for i = 1, #boards do
1985 local rows = {board.format(boards[i]),
1986 board.tph(boards[i], 24, false), board.tph(boards[i], 168, false),
1987 board.pph(boards[i], 1, false), board.pph(boards[i], 24, true),
1988 board.pph(boards[i], 24, false), board.pph(boards[i], 168, true),
1989 board.retrieve(boards[i])["MaxPostNumber"]};
1990 html.table.entry(rows[1],
1991 string.format("%d", rows[2]), string.format("%d", rows[3]),
1992 string.format("%d", rows[4]), string.format("%.1f", rows[5]),
1993 string.format("%d", rows[6]), string.format("%.1f", rows[7] * 24),
1994 rows[8]);
1995 for j = 2, #rows do
1996 total[j] = (total[j] or 0) + rows[j];
1997 end
1998 end
1999 html.table.entry("total",
2000 string.format("%d", total[2]), string.format("%d", total[3]),
2001 string.format("%d", total[4]), string.format("%.1f", total[5]),
2002 string.format("%d", total[6]), string.format("%.1f", total[7] * 24),
2003 total[8]);
2004
2005 html.table.finish();
2006 html.container.finish();
2007
2008 local time = globalretrieve or nil;
2009 --[[if os.time() >= time + 100 then
2010 html.stats.generate();
2011 time = os.time();
2012 global.set("", time);
2013 end--]]
2014 io.write("<br /><div style='margin: auto; max-width: 1150px'>(Table generated on ");
2015 io.write( os.date("!%F %T", time), ", ", tostring(time and os.time() - time or 0));
2016 io.write(" seconds ago.)</div>");
2017 end
2018 html.finish();
2019elseif cgi.pathinfo[1] == "log" then
2020 -- /Nano/log
2021 html.begin("logs");
2022 html.redheader("Nanochan log");
2023 html.container.begin("wide");
2024
2025 local page = tonumber(GET["page"]);
2026 if page == nil or page <= 0 then
2027 page = 1;
2028 end
2029
2030 html.pageswitcher(page);
2031 html.table.begin("log", "Account", "Board", "Time", "Description");
2032
2033 local entries = log.retrieve(128, tonumber((page - 1) * 128));
2034 for i = 1, #entries do
2035 html.table.entry(entries[i]["Name"],
2036 entries[i]["Board"],
2037 html.string.datetime(entries[i]["Date"]),
2038 entries[i]["Description"]);
2039 end
2040
2041 html.table.finish();
2042 html.pageswitcher(page);
2043 html.container.finish();
2044 html.finish();
2045 os.exit();
2046elseif cgi.pathinfo[1] == "recent" then
2047 local reqcap = global.retrieveflag("NanoRequireCaptcha", false);
2048 local limit = (username or reqcap) and 128 or 30; -- posts per page
2049 -- /Nano/recent
2050 html.begin("recent posts");
2051 html.redheader("List of recent posts");
2052 html.container.begin("wide");
2053
2054 local page = tonumber(GET["page"]);
2055 if page == nil or page <= 0 then
2056 page = 1;
2057 end
2058 html.pageswitcher(page);
2059
2060 html.recentsfilter();
2061 io.write("<hr />");
2062
2063 if reqcap and not username and not captcha.valid(POST["captcha"]) then
2064 if POST["captcha"] then
2065 io.write("The captcha you entered was incorrect.");
2066 else
2067 io.write("No captcha provided.");
2068 end
2069 else
2070 local posts = misc.filterrecents(page, limit);
2071 for i = 1, #posts - 1 do
2072 html.post.render(posts[i]["Board"], posts[i]["Number"], true);
2073 io.write("<hr class='invisible' />");
2074 end
2075 html.post.render(posts[#posts]["Board"], posts[#posts]["Number"], true);
2076 end
2077
2078 io.write("<hr />");
2079
2080 html.pageswitcher(page);
2081 html.container.finish();
2082 html.finish();
2083 os.exit();
2084elseif cgi.pathinfo[1] == "mod" then
2085 -- /Nano/mod/...
2086 if cgi.pathinfo[2] == "login" then
2087 -- /Nano/mod/login
2088 -- This area is the only area in /Nano/mod which unauthenticated users are
2089 -- allowed to access.
2090 if POST["username"] and POST["password"] then
2091 if #identity.list() == 0 then
2092 -- Special case: if there are no mod accounts, use the first supplied credentials to
2093 -- establish an administration account (to allow for board creation and the like).
2094 if not identity.validname(POST["username"]) then
2095 errorstr = "Invalid account name. <br /><br />"
2096 elseif not identity.validpassword(POST["password"]) then
2097 errorstr = "Invalid password. <br /><br />"
2098 else
2099 identity.create("admin", POST["username"], POST["password"]);
2100 log.create(nil, nil, "Created a new admin account for board Global: " .. POST["username"]);
2101 html.redirect("/Nano/mod/login");
2102 os.exit();
2103 end
2104 else
2105 -- User has supplied a username and a password. Check if valid.
2106 if global.retrieveflag("NanoRequireCaptcha", false) and not captcha.valid(POST["captcha"]) then
2107 html.pdp.error("Invalid captcha", "The captcha you entered was incorrect. Go back, and refresh the page to get a new one.");
2108 os.exit();
2109 elseif identity.valid(POST["username"], POST["password"]) then
2110 -- Set authentication cookie.
2111 html.begin("successful login", "session_key", identity.session.create(POST["username"]));
2112 html.redheader("Login successful");
2113 html.container.begin();
2114 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.");
2115 html.container.finish();
2116 html.finish();
2117 else
2118 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.");
2119 end
2120 os.exit();
2121 end
2122 end
2123
2124 html.begin("moderation");
2125 html.redheader("Moderator login");
2126 html.container.begin();
2127 io.write(errorstr or "");
2128 io.write("The moderation tools require a login. Access to moderation tools is restricted");
2129 io.write(" to administrators, global volunteers, board owners and board volunteers.");
2130
2131 if #identity.list() == 0 then
2132 io.write("<br /><b>There are currently no moderator accounts. As such, the credentials you");
2133 io.write(" type in the box below will become those of the first administrator account.</b>");
2134 end
2135
2136 html.container.barheader("Login");
2137 io.write("<fieldset><form method='post'>");
2138 io.write( "<input type='hidden' name='referer' value='", cgi.referer or "/Nano/mod", "' />");
2139 io.write( "<label for='username'>Username</label><input type='text' id='username' name='username' /><br />");
2140 io.write( "<label for='password'>Password</label><input type='password' id='password' name='password' /><br />");
2141 if global.retrieveflag("NanoRequireCaptcha", false) then
2142 io.write("<img id='captcha-image' width='290' height='70' src='/Nano/captcha.jpg' /><br />");
2143 io.write("<label for='captcha'>Captcha</label><input type='text' id='captcha' name='captcha' autocomplete='off' maxlength='6' /><br />");
2144 end
2145 io.write( "<label for='submit'>Submit</label><input id='submit' type='submit' value='Continue' />");
2146 io.write("</form></fieldset>");
2147 html.container.finish();
2148 html.finish();
2149 os.exit();
2150 end
2151
2152 if username == nil then
2153 -- The user does not have a valid session key. User must log in.
2154 html.redirect("/Nano/mod/login");
2155 os.exit();
2156 end
2157
2158 if cgi.pathinfo[2] == nil or cgi.pathinfo[2] == "" then
2159 -- /Nano/mod
2160 html.begin("moderation");
2161 html.redheader("Moderation tools");
2162 html.container.begin();
2163 io.write("<a id='logout-button' href='/Nano/mod/logout'>[Logout]</a>");
2164 io.write("You are logged in as <b>", username, "</b>.");
2165 io.write(" Your account class is <b>", acctclass, "</b>.");
2166
2167 if acctclass == "bo" or acctclass == "lvol" then
2168 io.write("<br />You are assigned to <b>", html.string.boardlink(assignboard), "</b></a>.");
2169 end
2170
2171 if acctclass == "admin" then
2172 html.container.barheader("Global");
2173 html.list.begin("unordered");
2174 html.list.entry(html.string.link("/Nano/mod/global/announce", "Change top-bar announcement"));
2175 html.list.entry(html.string.link("/Nano/mod/global/config", "Change site-wide configs"));
2176 html.list.entry(html.string.link("/Nano/mod/global/regenerate", "Regenerate all HTML files"));
2177 html.list.finish("unordered");
2178 end
2179
2180 if acctclass == "admin" or acctclass == "bo" or acctclass == "gvol" then
2181 html.container.barheader("Boards");
2182 html.list.begin("unordered");
2183
2184 if acctclass == "admin" then
2185 html.list.entry(html.string.link("/Nano/mod/board/create", "Create a board"));
2186 html.list.entry(html.string.link("/Nano/mod/board/delete", "Delete a board"));
2187 end
2188
2189 if acctclass == "admin" then
2190 html.list.entry(html.string.link("/Nano/mod/board/config", "Configure a board"));
2191 elseif acctclass == "bo" then
2192 html.list.entry(html.string.link("/Nano/mod/board/config/" .. assignboard, "Configure your board"));
2193 end
2194
2195 if acctclass == "admin" or acctclass == "gvol" then
2196 html.list.entry(html.string.link("/Nano/mod/board/modifycaptcha", "Enable or disable the captcha for a board"));
2197 elseif acctclass == "bo" then
2198 html.list.entry(html.string.link("/Nano/mod/board/modifycaptcha", "Enable or disable the captcha for your board"));
2199 end
2200
2201 html.list.finish("unordered");
2202 end
2203
2204 html.container.barheader("Accounts");
2205 html.list.begin("unordered");
2206
2207 if acctclass == "admin" or acctclass == "bo" then
2208 html.list.entry(html.string.link("/Nano/mod/account/create", "Create an account"));
2209 html.list.entry(html.string.link("/Nano/mod/account/delete", "Delete an account"));
2210 html.list.entry(html.string.link("/Nano/mod/account/config", "Configure an account"));
2211 end
2212
2213 html.list.entry(html.string.link("/Nano/mod/account/config/" .. username, "Account settings"));
2214 html.list.finish("unordered");
2215 html.container.finish();
2216 html.finish();
2217 elseif cgi.pathinfo[2] == "logout" then
2218 identity.session.delete(username);
2219 html.redirect("/Nano/mod/login");
2220 elseif cgi.pathinfo[2] == "board" then
2221 -- /Nano/mod/board/...
2222 if cgi.pathinfo[3] == "create" then
2223 if acctclass ~= "admin" then
2224 html.pdp.authorization_denied();
2225 os.exit();
2226 end
2227
2228 -- /Nano/mod/board/create
2229 html.begin("create board");
2230 html.redheader("Create a board");
2231 html.container.begin();
2232
2233 if POST["board"] and POST["title"] then
2234 local boardname = POST["board"];
2235 local title = POST["title"];
2236 local subtitle = POST["subtitle"] and POST["subtitle"] or nil;
2237
2238 if board.exists(boardname) then
2239 io.write("That board already exists.");
2240 elseif not board.validname(boardname) then
2241 io.write("Invalid board name.");
2242 elseif not board.validtitle(title) then
2243 io.write("Invalid board title.");
2244 elseif subtitle and not board.validsubtitle(subtitle) then
2245 io.write("Invalid board subtitle.");
2246 else
2247 board.create(boardname, title, subtitle or "");
2248 log.create(username, nil,"Created a new board: " .. html.string.boardlink(boardname));
2249 io.write("Board created: ", html.string.boardlink(boardname), ".<br />");
2250 io.write("Configure board settings ", html.string.link("/Nano/mod/board/config/" .. boardname, "here"), ".");
2251 end
2252 end
2253
2254 html.container.barheader("Instructions");
2255 html.list.begin("unordered");
2256 html.list.entry("<b>Board names</b> must consist of only lowercase characters and" ..
2257 " numerals. They must be from one to eight characters long.");
2258 html.list.entry("<b>Board titles</b> must be from one to 32 characters long.");
2259 html.list.entry("<b>Board subtitles</b> must be from zero to 64 characters long.");
2260 html.list.finish("unordered");
2261
2262 html.container.barheader("Enter board information");
2263 io.write("<fieldset><form method='post'>");
2264 io.write( "<label for='board'>Name</label><input type='text' id='board' name='board' required /><br />");
2265 io.write( "<label for='title'>Title</label><input type='text' id='title' name='title' required /><br />");
2266 io.write( "<label for='subtitle'>Subtitle</label><input type='text' id='subtitle' name='subtitle' /><br />");
2267 io.write( "<label for='submit'>Submit</label><input type='submit' id='submit' value='Create' /><br />");
2268 io.write("</form></fieldset>");
2269
2270 html.container.finish();
2271 html.finish();
2272 elseif cgi.pathinfo[3] == "delete" then
2273 -- /Nano/mod/board/delete
2274 if acctclass ~= "admin" then
2275 html.pdp.authorization_denied();
2276 os.exit();
2277 end
2278
2279 html.begin("delete board");
2280 html.redheader("Delete a board");
2281 html.container.begin();
2282
2283 if POST["board"] then
2284 if not board.exists(POST["board"]) then
2285 io.write("The board you specified does not exist.");
2286 else
2287 board.delete(POST["board"]);
2288 log.create(username, nil, "Deleted board " .. board.format(POST["board"]), POST["reason"]);
2289 io.write("Board deleted.");
2290 end
2291 end
2292
2293 html.container.barheader("Instructions");
2294 io.write("Deleting a board removes the board itself, along with all posts on that board,");
2295 io.write(" and all accounts assigned to that board. Board deletion is irreversible.");
2296
2297 html.container.barheader("Enter information");
2298 io.write("<fieldset><form method='post'>");
2299 io.write( "<label for='board'>Board</label><input type='text' id='board' name='board' required /><br />");
2300 io.write( "<label for='reason'>Reason</label><input type='text' id='reason' name='reason' /><br />");
2301 io.write( "<label for='submit'>Submit</label><input type='submit' id='submit' value='Delete' /><br />");
2302 io.write("</form></fieldset>");
2303
2304 html.container.finish();
2305 html.finish();
2306 elseif cgi.pathinfo[3] == "config" then
2307 -- /Nano/mod/board/config
2308 if acctclass ~= "admin" and acctclass ~= "bo" then
2309 html.pdp.authorization_denied();
2310 os.exit();
2311 end
2312
2313 if POST["board"] then
2314 html.redirect("/Nano/mod/board/config/" .. POST["board"]);
2315 os.exit();
2316 end
2317
2318 if cgi.pathinfo[4] then
2319 -- /Nano/mod/board/config/...
2320 if not board.exists(cgi.pathinfo[4]) then
2321 html.pdp.error("Invalid board", "That board does not exist.");
2322 os.exit();
2323 elseif acctclass == "bo" and cgi.pathinfo[4] ~= assignboard then
2324 html.pdp.authorization_denied();
2325 os.exit();
2326 else
2327 html.begin("configure board");
2328 html.redheader("Configure " .. board.format(cgi.pathinfo[4]));
2329 html.container.begin();
2330
2331 if POST["action"] then
2332 if not board.validtitle(POST["title"]) then
2333 io.write("Invalid title. Settings not saved.");
2334 elseif POST["subtitle"] and not board.validsubtitle(POST["subtitle"]) then
2335 io.write("Invalid subtitle. Settings not saved.");
2336 else
2337 local new_settings = {
2338 Name = cgi.pathinfo[4],
2339 Title = string.escapehtml(POST["title"]) or cgi.pathinfo[4],
2340 Subtitle = string.escapehtml(POST["subtitle"]) or "",
2341 Lock = (POST["lock"] and 1 or 0),
2342 DisplayOverboard = (POST["displayoverboard"] and 1 or 0),
2343 RequireCaptcha = (POST["requirecaptcha"] and 1 or 0),
2344 CaptchaTriggerPPH = tonumber(POST["captchatrigger"]) or 0,
2345 MaxThreadsPerHour = tonumber(POST["mtph"]) or 0,
2346 MinThreadChars = tonumber(POST["mtc"]) or 0,
2347 BumpLimit = tonumber(POST["bumplimit"]) or 300,
2348 PostLimit = tonumber(POST["postlimit"]) or 350,
2349 ThreadLimit = tonumber(POST["threadlimit"]) or 300
2350 };
2351
2352 board.update(new_settings);
2353 log.create(username, cgi.pathinfo[4], "Edited board settings");
2354 io.write("Board settings modified.");
2355 end
2356 end
2357
2358 local existing = board.retrieve(cgi.pathinfo[4]);
2359
2360 io.write("<fieldset><form method='post'>");
2361 io.write( "<input type='hidden' name='action' value='yes' />");
2362 io.write( "<label for='name'>Name</label><input id='name' name='name' type='text' value='", existing["Name"], "' disabled /><br />");
2363 io.write( "<label for='title'>Title</label><input id='title' name='title' type='text' value='", existing["Title"], "' /><br />");
2364 io.write( "<label for='subtitle'>Subtitle</label><input id='subtitle' name='subtitle' type='text' value='", existing["Subtitle"], "' /><br />");
2365 io.write( "<label for='lock'>Lock</label><input id='lock' name='lock' type='checkbox' ", (existing["Lock"] == 0 and "" or "checked "), "/><br />");
2366 io.write( "<label for='displayoverboard'>Overboard</label><input id='displayoverboard' name='displayoverboard' type='checkbox' ",
2367 (existing["DisplayOverboard"] == 0 and "" or "checked "), "/><br />");
2368 io.write( "<label for='requirecaptcha'>Captcha</label><input id='requirecaptcha' name='requirecaptcha' type='checkbox' ",
2369 (existing["RequireCaptcha"] == 0 and "" or "checked "), "/><br />");
2370 io.write( "<label for='captchatrigger'>Captcha Trig</label><input id='captchatrigger' name='captchatrigger' type='number' value='", existing["CaptchaTriggerPPH"], "' /><br />");
2371 io.write( "<label for='mtph'>Max Thr./12h</label><input id='mtph' name='mtph' type='number' value='", existing["MaxThreadsPerHour"], "' /><br />");
2372 io.write( "<label for='mtc'>Min Thr. Len.</label><input id='mtc' name='mtc' type='number' value='", existing["MinThreadChars"], "' /><br />");
2373 io.write( "<label for='bumplimit'>Bump Limit</label><input id='bumplimit' name='bumplimit' type='number' value='", existing["BumpLimit"], "' /><br />");
2374 io.write( "<label for='postlimit'>Post Limit</label><input id='postlimit' name='postlimit' type='number' value='", existing["PostLimit"], "' /><br />");
2375 io.write( "<label for='threadliit'>Thread Limit</label><input id='threadlimit' name='threadlimit' type='number' value='", existing["ThreadLimit"], "' /><br />");
2376 io.write( "<label for='submit'>Submit</label><input id='submit' type='submit' value='Update' />");
2377 io.write("</form></fieldset>");
2378
2379 html.container.finish();
2380 html.finish();
2381 end
2382 else
2383 html.begin("configure board");
2384 html.redheader("Configure a board");
2385 html.container.begin();
2386
2387 html.container.barheader("Enter information");
2388 io.write("<fieldset><form method='post'>");
2389 io.write( "<label for='board'>Board</label><input type='text' id='board' name='board' required /><br />");
2390 io.write( "<label for='submit'>Submit</label><input type='submit' id='submit' value='Configure' /><br />");
2391 io.write("</form></fieldset>");
2392
2393 html.container.finish();
2394 html.finish();
2395 end
2396 elseif cgi.pathinfo[3] == "modifycaptcha" then
2397 -- /Nano/mod/board/modifycaptcha
2398 if acctclass ~= "admin" and acctclass ~= "bo" and acctclass ~= "gvol" then
2399 html.pdp.authorization_denied();
2400 os.exit();
2401 end
2402 boardname = POST["board"] and POST["board"] or nil;
2403 action = POST["action"] and POST["action"] or nil;
2404
2405 html.begin("enable/disable captcha for a board");
2406 html.redheader("Enable/Disable captcha for a board");
2407 html.container.begin();
2408
2409 if boardname and action then
2410 -- /Nano/mod/board/modifycaptcha/...
2411 if not board.exists(boardname) then
2412 io.write("That board does not exist.");
2413 elseif acctclass == "bo" and boardname ~= assignboard then
2414 io.write("You are not assigned to that board and are unable to configure it.");
2415 else
2416 local board_tbl = board.retrieve(boardname);
2417 if action == "disable" and board_tbl["RequireCaptcha"] == 0 then
2418 io.write("The captcha is already disabled on ", board.format(boardname), ".");
2419 elseif action == "enable" and board_tbl["RequireCaptcha"] == 1 then
2420 io.write("The captcha is already enabled on ", board.format(boardname), ".");
2421 elseif action ~= "enable" and action ~= "disable" then
2422 html.pdp.error("Invalid action", "There is no action associated with your request.");
2423 os.exit();
2424 else
2425 board_tbl["RequireCaptcha"] = (action == "enable") and 1 or 0;
2426 board.update(board_tbl);
2427 log.create(username, boardname, action:sub(1,1):upper()..action:sub(2) .. "d the captcha");
2428 io.write("Captcha ", action, "d on ", board.format(boardname), ".");
2429 end
2430 end
2431 end
2432
2433 html.container.barheader("Enter information");
2434 io.write("<fieldset><form id='captcha' method='post'>");
2435
2436 io.write("<label for='action'>Action</label>");
2437 io.write("<select id='action' name='action' form='captcha'>");
2438 io.write( "<option value='disable'>Disable</option>");
2439 io.write( "<option value='enable'>Enable</option>");
2440 io.write("</select><br />");
2441
2442 io.write( "<label for='board'>Board</label><input type='text' id='board' name='board' required /><br />");
2443 io.write( "<label for='submit'>Submit</label><input type='submit' id='submit' value='Configure' /><br />");
2444 io.write("</form></fieldset>");
2445 html.container.finish();
2446 html.finish();
2447 else
2448 html.pdp.notfound();
2449 end
2450 elseif cgi.pathinfo[2] == "global" then
2451 -- /Nano/mod/global
2452
2453 if acctclass ~= "admin" then
2454 html.pdp.authorization_denied();
2455 os.exit();
2456 end
2457
2458 if cgi.pathinfo[3] == "announce" then
2459 html.begin("edit global announcement");
2460 html.redheader("Edit global announcement");
2461 html.container.begin();
2462
2463 if POST["action"] then
2464 global.set("announce", POST["announce"] or "");
2465 log.create(username, nil, "Edited global announcement");
2466 io.write("Global announcement updated.");
2467 generate.mainpage();
2468 generate.overboard();
2469 end
2470
2471 io.write("<fieldset><form id='globalannounce' method='post'>");
2472 io.write( "<input type='hidden' name='action' value='yes' />");
2473 io.write( "<label for='announce'>Announcement</label><textarea form='globalannounce' rows=5 cols=35 id='announce' name='announce'>",
2474 string.escapehtml(global.retrieve("announce") or ""), "</textarea><br />");
2475 io.write( "<label for='submit'>Submit</label><input type='submit' id='submit' value='Update' />");
2476 io.write("</form></fieldset>");
2477
2478 html.container.finish();
2479 html.finish();
2480 elseif cgi.pathinfo[3] == "config" then
2481 html.begin("edit global configs");
2482 html.redheader("Edit global configs");
2483 html.container.begin();
2484
2485 if POST["action"] then
2486 local threadcap = POST["threadcap"] and true or false;
2487 if threadcap ~= global.retrieveflag("ThreadCaptcha") then
2488 global.setflag("ThreadCaptcha", threadcap);
2489 local boards = board.list();
2490 for i = 1, #boards do
2491 generate.catalog(boards[i]);
2492 end
2493 end
2494
2495 global.setflag("NanoRequireCaptcha", POST["nanoreqcap"]);
2496 log.create(username, nil, "Edited global settings");
2497 io.write("Global configs updated.");
2498 end
2499
2500 io.write("<fieldset><form id='globalannounce' method='post'>");
2501 io.write( "<input type='hidden' name='action' value='yes' />");
2502 io.write( "<label for='threadcap'>Thread Captcha</label><input id='threadcap' name='threadcap' type='checkbox' ",
2503 (global.retrieveflag("ThreadCaptcha") and "checked " or ""), "/><br />");
2504 io.write( "<label for='nanoreqcap'>/Nano Captcha</label><input id='nanoreqcap' name='nanoreqcap' type='checkbox' ",
2505 (global.retrieveflag("NanoRequireCaptcha") and "checked " or ""), "/><br />");
2506 io.write( "<label for='submit'>Submit</label><input type='submit' id='submit' value='Update' />");
2507 io.write("</form></fieldset>");
2508
2509 html.container.finish();
2510 html.finish();
2511 elseif cgi.pathinfo[3] == "regenerate" then
2512 generate.mainpage();
2513 generate.overboard();
2514 os.execute("mkdir Media Media/thumb Media/icon tmp_captchas/");
2515
2516 local boards = board.list();
2517 for j = 1, #boards do
2518 os.execute("mkdir " .. boards[j]);
2519 generate.catalog(boards[j]);
2520
2521 local threads = post.listthreads(boards[j]);
2522 for i = 1, #threads do
2523 generate.thread(boards[j], threads[i]);
2524 end
2525 end
2526
2527 html.begin("regenerate files");
2528 html.redheader("Regenerate all HTML files");
2529 html.container.begin();
2530
2531 io.write("All HTML files regenerated.");
2532
2533 html.container.finish();
2534 html.finish();
2535 else
2536 html.pdp.notfound();
2537 end
2538 elseif cgi.pathinfo[2] == "account" then
2539 -- /Nano/mod/account/...
2540 if cgi.pathinfo[3] == "create" then
2541 -- /Nano/mod/account/create
2542
2543 if acctclass ~= "admin" and acctclass ~= "bo" then
2544 html.pdp.authorization_denied();
2545 os.exit();
2546 end
2547
2548 html.begin("create account");
2549 html.redheader("Create an account");
2550 html.container.begin();
2551
2552 if POST["account"] and POST["password"] then
2553 if acctclass == "bo" then
2554 POST["class"] = "lvol";
2555 POST["board"] = assignboard;
2556 elseif POST["class"] == "gvol" or POST["class"] == "admin" then
2557 POST["board"] = nil;
2558 end
2559
2560 if identity.exists(POST["account"]) then
2561 io.write("That account already exists.");
2562 elseif not identity.validname(POST["account"]) then
2563 io.write("Invalid account name.");
2564 elseif not identity.validpassword(POST["password"]) then
2565 io.write("Invalid password.");
2566 elseif not identity.validclass(POST["class"]) then
2567 io.write("Invalid account class.");
2568 elseif POST["board"] and not board.exists(POST["board"]) then
2569 io.write("Board does not exist.");
2570 else
2571 identity.create(POST["class"],
2572 POST["account"],
2573 POST["password"],
2574 POST["board"]);
2575 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"]);
2576 io.write("Account created.");
2577 end
2578 end
2579
2580 html.container.barheader("Instructions");
2581 html.list.begin("unordered");
2582 html.list.entry("<b>Usernames</b> can only consist of alphanumerics. They must be from 1 to 16 characters long.");
2583 html.list.entry("<b>Passwords</b> must be from 13 to 64 characters long.");
2584 if acctclass == "admin" then
2585 html.list.entry("An account's <b>board</b> has no effect for Global Volunteers and " ..
2586 "Administrators. For Board Owners and Board Volunteers, the board " ..
2587 "parameter defines the board in which that account can operate.");
2588 end
2589 html.list.finish("unordered");
2590
2591 html.container.barheader("Enter account information");
2592 io.write("<fieldset><form id='acctinfo' method='post'>");
2593 if acctclass == "admin" then
2594 io.write("<label for='class'>Type</label>");
2595 io.write("<select id='class' name='class' form='acctinfo'>");
2596 io.write( "<option value='admin'>Administrator</option>");
2597 io.write( "<option value='gvol'>Global Volunteer</option>");
2598 io.write( "<option value='bo'>Board Owner</option>");
2599 io.write( "<option value='lvol'>Board Volunteer</option>");
2600 io.write("</select><br />");
2601 io.write("<label for='board'>Board</label><input type='text' id='board' name='board' /><br />");
2602 end
2603 io.write("<label for='account'>Username</label><input type='text' id='account' name='account' required /><br />");
2604 io.write("<label for='password'>Password</label><input type='password' id='password' name='password' required /><br />");
2605 io.write("<label for='submit'>Submit</label><input type='submit' id='submit' value='Create' /><br />");
2606 io.write("</form></fieldset>");
2607
2608 html.container.finish();
2609 html.finish();
2610 elseif cgi.pathinfo[3] == "delete" then
2611 -- /Nano/mod/account/delete
2612 if acctclass ~= "admin" and acctclass ~= "bo" then
2613 html.pdp.authorization_denied();
2614 os.exit();
2615 end
2616
2617 html.begin("delete account");
2618 html.redheader("Delete an account");
2619 html.container.begin();
2620
2621 -- only admins and bo's have the ability to delete their own account
2622 if POST["account"] then
2623 if not identity.exists(POST["account"]) then
2624 io.write("The account which you have specified does not exist.");
2625 elseif acctclass == "bo" and identity.retrieve(POST["account"])["Board"] ~= assignboard then
2626 io.write("You are not authorized to delete that account.");
2627 else
2628 identity.delete(POST["account"]);
2629 log.create(username, nil, "Deleted account " .. POST["account"], POST["reason"]);
2630 io.write("Account deleted.");
2631 end
2632 end
2633
2634 html.container.barheader("Instructions");
2635 html.list.begin("unordered");
2636 html.list.entry("Deleting an account will log the user out of all active sessions.");
2637 html.list.entry("Deleting an account will replace names all logs associated with that account with '<i>Deleted</i>'.");
2638 html.list.finish("unordered");
2639
2640 html.container.barheader("Enter information");
2641 io.write("<fieldset><form method='post'>");
2642 io.write("<label for='account'>Username</label><input type='text' id='account' name='account' /><br />");
2643 io.write("<label for='reason'>Reason</label><input type='text' id='reason' name='reason' /><br />");
2644 io.write("<label for='submit'>Submit</label><input type='submit' id='submit' value='Delete' /><br />");
2645 io.write("</form></fieldset>");
2646 html.container.finish();
2647 html.finish();
2648 elseif cgi.pathinfo[3] == "config" then
2649 -- /Nano/mod/account/config/...
2650 if POST["account"] then
2651 html.redirect("/Nano/mod/account/config/" .. POST["account"]);
2652 os.exit();
2653 end
2654
2655 if cgi.pathinfo[4] then
2656 if acctclass == "bo" and identity.retrieve(cgi.pathinfo[4])["Board"] ~= assignboard then
2657 html.pdp.authorization_denied();
2658 os.exit();
2659 elseif acctclass ~= "admin" and acctclass ~= "bo" and cgi.pathinfo[4] ~= username then
2660 html.pdp.authorization_denied();
2661 os.exit();
2662 elseif not identity.exists(cgi.pathinfo[4]) then
2663 html.pdp.error("Account not found", "The account that you specified does not exist.");
2664 os.exit();
2665 end
2666
2667 html.begin("configure account");
2668 html.redheader("Configure account " .. cgi.pathinfo[4]);
2669 html.container.begin();
2670
2671 if POST["password1"] and POST["password2"] then
2672 if POST["password1"] ~= POST["password2"] then
2673 io.write("The two passwords did not match.");
2674 elseif not identity.validpassword(POST["password1"]) then
2675 io.write("Invalid password.");
2676 else
2677 identity.changepassword(cgi.pathinfo[4], POST["password1"]);
2678 log.create(username, nil, "Changed password for account: " .. cgi.pathinfo[4]);
2679 io.write("Password changed.");
2680 end
2681 end
2682
2683 html.container.barheader("Instructions");
2684 html.list.begin();
2685 html.list.entry("<b>Passwords</b> must be from 13 to 64 characters long.");
2686 html.list.finish();
2687 html.container.barheader("Enter information");
2688 io.write("<fieldset><form method='post'>");
2689 io.write("<label for='password1'>New password</label><input type='password' id='password1' name='password1' /><br />");
2690 io.write("<label for='password2'>Repeat</label><input type='password' id='password2' name='password2' /><br />");
2691 io.write("<label for='submit'>Submit</label><input type='submit' id='submit' value='Change' /><br />");
2692 io.write("</form></fieldset>");
2693 html.container.finish();
2694 html.finish();
2695 else
2696 html.begin("configure account");
2697 html.redheader("Configure an account");
2698 html.container.begin();
2699 html.container.barheader("Enter information");
2700 io.write("<fieldset><form method='post'>");
2701 io.write("<label for='account'>Username</label><input type='text' id='account' name='account' /><br />");
2702 io.write("<label for='submit'>Submit</label><input type='submit' label='submit' value='Configure' /><br />");
2703 io.write("</form></fieldset>");
2704 html.container.finish();
2705 html.finish();
2706 end
2707 else
2708 html.pdp.notfound();
2709 end
2710 elseif cgi.pathinfo[2] == "file" then
2711 local action = cgi.pathinfo[3];
2712 local filename = cgi.pathinfo[4];
2713
2714 if acctclass ~= "admin" and acctclass ~= "gvol" then
2715 html.pdp.authorization_denied();
2716 os.exit();
2717 elseif not filename or filename == "" then
2718 html.pdp.error("No filename given", "There is no filename attached to your request.");
2719 os.exit();
2720 elseif not file.exists(filename) then
2721 html.pdp.error("Invalid file", "The file you are trying to modify does not exist.");
2722 os.exit();
2723 end
2724
2725 if not POST["action"] then
2726 html.begin();
2727 html.redheader("File modification/deletion");
2728 html.container.begin();
2729 io.write("This is the file you are trying to modify:<br />");
2730 io.write("<div class='wrapper'><div class='post'>");
2731 html.post.renderthumbnail(filename);
2732 io.write("</div></div>");
2733 io.write("The action is: <b>", action, "</b><br />");
2734 io.write("<fieldset><form action='' method='POST'>");
2735 io.write( "<input type='hidden' name='action' value='yes' />");
2736 io.write( "<input type='hidden' name='referer' value='", cgi.referer or "/overboard.html", "' />");
2737 io.write( "<label for='reason'>Reason</label><input type='text' id='reason' name='reason' required /><br />");
2738 io.write( "<label for='submit'>Submit</label><input type='submit' id='submit' value='Modify' />");
2739 io.write("</form></fieldset>");
2740 html.container.finish();
2741 html.finish();
2742 os.exit();
2743 end
2744
2745 if action == "delete" then
2746 log.create(username, nil, "Deleted file " .. filename .. " from all boards", POST["reason"]);
2747 file.unlink(filename);
2748 file.delete(filename);
2749 else
2750 html.pdp.error("Invalid action", "There is no action associated with your request.");
2751 os.exit();
2752 end
2753
2754 html.redirect(POST["referer"] or "/overboard.html");
2755 elseif cgi.pathinfo[2] == "post" then
2756 local action = cgi.pathinfo[3];
2757 local boardname = cgi.pathinfo[4];
2758 local number = tonumber(cgi.pathinfo[5]);
2759 local destboard = POST["destboard"] and POST["destboard"] or nil;
2760 local redirect = POST["referer"] and POST["referer"] or "/overboard.html"
2761
2762 if (acctclass == "bo" or acctclass == "lvol") and assignboard ~= boardname then
2763 html.pdp.authorization_denied();
2764 os.exit();
2765 elseif not action or action == "" or not boardname or not number then
2766 html.pdp.notfound();
2767 os.exit();
2768 elseif not post.exists(boardname, number) then
2769 html.pdp.error("Invalid post", "The post you are trying to modify does not exist.");
2770 os.exit();
2771 elseif (action == "move" or action == "sticky" or action == "lock" or action == "autosage" or action == "cycle") and post.retrieve(boardname, number)["Parent"] ~= 0 then
2772 html.pdp.error("Invalid thread", "The post you are trying to modify is not an opening post.");
2773 os.exit();
2774 elseif action == "move" and destboard and not board.exists(destboard) then
2775 html.pdp.error("Invalid board", "The destination board you provided does not exist.");
2776 os.exit();
2777 end
2778
2779 if not POST["action"] or (action == "move" and not destboard) then
2780 html.begin();
2781 html.redheader("Post modification/deletion");
2782 html.container.begin();
2783 io.write("This is the post you are trying to modify:<br />");
2784 html.post.render(boardname, number, true);
2785 io.write("The action is: <b>", action, "</b><br />");
2786 io.write("<fieldset><form action='' method='POST'>");
2787 io.write( "<input type='hidden' name='action' value='yes' />");
2788 io.write( "<input type='hidden' name='referer' value='", cgi.referer or "/overboard.html", "' />");
2789 io.write( "<label for='reason'>Reason</label><input type='text' id='reason' name='reason' required /><br />");
2790 if action == "move" then
2791 io.write("<label for='destboard'>Dest. Board</label><input type='text' id='destboard' name='destboard' required /><br />");
2792 end
2793 io.write( "<label for='submit'>Submit</label><input type='submit' id='submit' value='Modify' />");
2794 io.write("</form></fieldset>");
2795 html.container.finish();
2796 html.finish();
2797 os.exit();
2798 end
2799
2800 if action == "delete" then
2801 log.create(username, boardname, "Deleted post " .. post.format(boardname, number), POST["reason"]);
2802 misc.audit("delete", boardname, number, POST["reason"]);
2803 if post.retrieve(boardname, number)["Parent"] == 0 then
2804 redirect = "/overboard.html";
2805 end
2806 post.delete(boardname, number);
2807 elseif action == "unlink" then
2808 log.create(username, boardname, "Unlinked file from post " .. post.format(boardname, number), POST["reason"]);
2809 misc.audit("unlink", boardname, number, POST["reason"]);
2810 post.unlink(boardname, number);
2811 elseif action == "move" then
2812 local newnumber = post.move(boardname, number, destboard);
2813 log.create(username, boardname, "Moved thread " .. post.format(boardname, number) .. " to " .. html.string.threadlink(destboard, newnumber), POST["reason"]);
2814 redirect = html.string.threadlink(destboard, newnumber);
2815 elseif action == "sticky" then
2816 log.create(username, boardname, "Toggled sticky on thread " .. html.string.threadlink(boardname, number), POST["reason"]);
2817 post.toggle("Sticky", boardname, number);
2818 elseif action == "lock" then
2819 log.create(username, boardname, "Toggled lock on thread " .. html.string.threadlink(boardname, number), POST["reason"]);
2820 post.toggle("Lock", boardname, number);
2821 elseif action == "autosage" then
2822 log.create(username, boardname, "Toggled autosage on thread " .. html.string.threadlink(boardname, number), POST["reason"]);
2823 post.toggle("Autosage", boardname, number);
2824 elseif action == "cycle" then
2825 log.create(username, boardname, "Toggled cycle on thread " .. html.string.threadlink(boardname, number), POST["reason"]);
2826 post.toggle("Cycle", boardname, number);
2827 end
2828
2829 html.redirect(redirect);
2830 elseif cgi.pathinfo[2] == "index" then
2831 local regen = false;
2832 if regen then io.output("indexview.html"); end
2833
2834 local rpt = 2; -- replies shown per thread
2835 local tpp = 15; -- threads per page
2836
2837 local page = tonumber(GET["page"]);
2838 if page == nil or page <= 0 then
2839 page = 1;
2840 end
2841
2842 local post_tbls = {};
2843 local stmt = nanodb:prepare("SELECT Board, Number FROM Posts WHERE Parent = 0 AND Autosage = 0 AND (SELECT DisplayOverboard FROM Boards WHERE Name = Board) = 1 ORDER BY LastBumpDate DESC LIMIT ? OFFSET ?");
2844 stmt:bind_values(tpp, tonumber((page - 1) * tpp));
2845 for tbl in stmt:nrows() do
2846 post_tbls[#post_tbls + 1] = tbl;
2847 end
2848
2849 nanodb:exec("BEGIN TRANSACTION");
2850 html.begin("index");
2851 html.redheader("Nanochan index");
2852 html.announce();
2853
2854 io.write("<div class='index-container'>");
2855 html.pageswitcher(page);
2856 io.write("<a href='' accesskey='r' class='middle'>[Update]</a>");
2857 io.write(" <a href='/overboard.html' class='middle'>[Catalog]</a>");
2858 io.write("<hr />");
2859
2860 for i = 1, #post_tbls do
2861 local board = post_tbls[i]["Board"];
2862 local number = post_tbls[i]["Number"];
2863 local replies = post.threadreplies(board, number);
2864
2865 local replyomit = (#replies > rpt) and #replies - rpt or 0;
2866 local filelist = post.threadfiles(board, number, false);
2867 -- subtract (1, rpt) from #filelist for files not omitted
2868
2869 local fileplural = (filelist == 1) and " file" or " files";
2870 local replyplural = (replyomit == 1) and " reply" or " replies";
2871
2872 io.write("<div class='index-thread'>");
2873 html.post.render(board, number, true);
2874 io.write(replyomit, replyplural, " and ", #filelist, fileplural, " omitted. Click on the post number above to reply to this thread.<br /><br />");
2875
2876 io.write( "<div class='index-thread-children'>");
2877 for i = #replies - (rpt - 1), #replies do
2878 if replies[i] then
2879 html.post.render(board, replies[i], true);
2880 end
2881 end
2882 io.write( "</div>");
2883
2884 io.write("</div>");
2885 io.write("<hr />");
2886 end
2887
2888 html.pageswitcher(page);
2889 io.write("</div>");
2890 html.finish();
2891 nanodb:exec("END TRANSACTION");
2892
2893 if regen then io.output(io.stdout); html.redirect("/indexview.html"); end
2894 else
2895 html.pdp.notfound();
2896 end
2897elseif cgi.pathinfo[1] == "post" then
2898 -- /Nano/post
2899 local post_board = POST["board"];
2900 local post_parent = tonumber(POST["parent"]);
2901 local post_name = POST["name"];
2902 local post_email = POST["email"];
2903 local post_subject = POST["subject"];
2904 local post_comment = POST["comment"];
2905 local post_tmp_filepath = HASERL["file_path"];
2906 local post_tmp_filename = POST["file_name"];
2907 local post_captcha = POST["captcha"];
2908 local parent_tbl = post.retrieve(post_board, post_parent);
2909 local board_tbl = board.retrieve(post_board);
2910
2911 if POST["board"] and POST["parent"] then
2912 if not board_tbl then
2913 html.pdp.error("Invalid board", "The board you tried to post to does not exist.");
2914 os.exit();
2915 elseif post_parent ~= 0 and not post.exists(post_board, post_parent) then
2916 html.pdp.error("Invalid thread", "The thread you tried to post in does not exist. Perhaps it has been deleted.");
2917 os.exit();
2918 elseif parent_tbl ~= nil and parent_tbl["Lock"] == 1 and not username then
2919 html.pdp.error("Thread locked", "The thread you tried to post in is currently locked.");
2920 os.exit();
2921 elseif board_tbl["Lock"] == 1 and not username then
2922 html.pdp.error("Board locked", "The board you tried to post in is currently locked.");
2923 os.exit();
2924 elseif post_parent == 0 and (board_tbl["MaxThreadsPerHour"] > 0 and board.tph(post_board, 12, false) >= board_tbl["MaxThreadsPerHour"]) then
2925 html.pdp.error("Thread limit reached", "The board you tried to post in has reached its 12 hour thread limit.");
2926 os.exit();
2927 elseif post_parent ~= 0 and parent_tbl["Parent"] ~= 0 then
2928 html.pdp.error("Invalid thread", "The thread you tried to post in is not a thread. This is not supported.");
2929 os.exit();
2930 elseif post_parent == 0 and (board_tbl["MinThreadChars"] > 0 and #post_comment < board_tbl["MinThreadChars"]) then
2931 html.pdp.error("Post too short", "Your post text was too short. On this board, threads require at least " ..
2932 tonumber(board_tbl["MinThreadChars"]) .. " characters.");
2933 os.exit();
2934 elseif post_comment and #post_comment > 32768 then
2935 html.pdp.error("Post too long", "Your post text was over 32 KiB. Please reduce its length.");
2936 os.exit();
2937 elseif post_comment and select(2, post_comment:gsub("\n", "")) > 192 then
2938 html.pdp.error("Too many newlines", "Your post contained over 192 newlines. Please reduce its length.");
2939 os.exit();
2940 elseif post_name and #post_name > 64 then
2941 html.pdp.error("Name too long", "The text in the name field was over 64 bytes. Please reduce its length.");
2942 os.exit();
2943 elseif post_email and #post_email > 64 then
2944 html.pdp.error("Email too long", "The text in the email field was over 64 bytes. Please reduce its length.");
2945 os.exit();
2946 elseif post_subject and #post_subject > 64 then
2947 html.pdp.error("Subject too long", "The text in the subject field was over 64 bytes. Please reduce its length.");
2948 os.exit();
2949 elseif (#post_comment == 0) and (#post_tmp_filename == 0) then
2950 html.pdp.error("Blank post", "You must either upload a file or write something in the comment field.");
2951 os.exit();
2952 elseif post_parent ~= 0 and parent_tbl["Cycle"] == 0 and #post.threadreplies(post_board, post_parent) >= board_tbl["PostLimit"] then
2953 html.pdp.error("Thread full", "The thread you tried to post in is full. Please start a new thread instead.");
2954 os.exit();
2955 elseif (board_tbl["RequireCaptcha"] == 1 or (post_parent == 0 and global.retrieveflag("ThreadCaptcha", false))) and not captcha.valid(post_captcha) then
2956 html.pdp.error("Invalid captcha", "The captcha you entered was incorrect. Go back, and refresh the page to get a new one.");
2957 os.exit();
2958 end
2959
2960 local post_filename = "";
2961 if post_tmp_filename and post_tmp_filename ~= "" then
2962 post_filename = file.save(post_tmp_filepath, (post_parent == 0));
2963
2964 if not post_filename then
2965 html.pdp.error("File error", "There was a problem with the file you uploaded.");
2966 os.exit();
2967 end
2968 end
2969
2970 local post_number = post.create(post_board, post_parent, post_name, post_email, post_subject, post_comment, post_filename);
2971
2972 if post_parent == 0 then
2973 -- Redirect to the newly created thread.
2974 html.redirect("/" .. post_board .. "/" .. post_number .. ".html");
2975 else
2976 -- Redirect to the parent thread, but scroll down to the newly created post.
2977 html.redirect("/" .. post_board .. "/" .. post_parent .. ".html" .. "#post" .. post_number);
2978 end
2979 else
2980 html.pdp.error("No post attached", "There is no post attached to your request.");
2981 os.exit();
2982 end
2983else
2984 html.pdp.notfound();
2985end
2986%>