{{ Escapes.htmlescape(report.content):gsub("\n\n", "
") }}
From f4b0a0b531520610abc0408d909a2d12580aa23e Mon Sep 17 00:00:00 2001 From: mid <> Date: Sat, 1 Jun 2024 17:40:11 +0300 Subject: [PATCH] Initial commit --- .gitignore | 3 + 404.html.l | 7 + README | 56 +++ admin.html.l | 157 +++++++ base.inc | 56 +++ bigglobe.lua | 70 +++ core.lua | 186 ++++++++ db.lua | 822 +++++++++++++++++++++++++++++++++++ html.lua | 35 ++ index.html.l | 84 ++++ install.lua | 297 +++++++++++++ invite.html.l | 39 ++ login.html.l | 45 ++ lyre.lua | 82 ++++ main.lua | 15 + obj.html.l | 162 +++++++ obje.html.l | 351 +++++++++++++++ pegasus/compress.lua | 3 + pegasus/handler.lua | 151 +++++++ pegasus/init.lua | 63 +++ pegasus/plugins/compress.lua | 145 ++++++ pegasus/request.lua | 277 ++++++++++++ pegasus/response.lua | 238 ++++++++++ reg.html.l | 47 ++ reports.html.l | 63 +++ reportuser.html.l | 46 ++ search.html.l | 45 ++ smtpauth.lua | 61 +++ static/datetimes.js | 6 + static/favicon.png | Bin 0 -> 1642 bytes static/logo.png | Bin 0 -> 8448 bytes static/style.css | 213 +++++++++ static/tagbox.js | 100 +++++ static/tagcats.css | 1 + uninstall.lua | 43 ++ user.html.l | 58 +++ 36 files changed, 4027 insertions(+) create mode 100644 .gitignore create mode 100644 404.html.l create mode 100644 README create mode 100644 admin.html.l create mode 100644 base.inc create mode 100644 bigglobe.lua create mode 100644 core.lua create mode 100644 db.lua create mode 100644 html.lua create mode 100644 index.html.l create mode 100755 install.lua create mode 100644 invite.html.l create mode 100644 login.html.l create mode 100644 lyre.lua create mode 100644 main.lua create mode 100644 obj.html.l create mode 100644 obje.html.l create mode 100644 pegasus/compress.lua create mode 100644 pegasus/handler.lua create mode 100644 pegasus/init.lua create mode 100644 pegasus/plugins/compress.lua create mode 100644 pegasus/request.lua create mode 100644 pegasus/response.lua create mode 100644 reg.html.l create mode 100644 reports.html.l create mode 100644 reportuser.html.l create mode 100644 search.html.l create mode 100644 smtpauth.lua create mode 100644 static/datetimes.js create mode 100644 static/favicon.png create mode 100644 static/logo.png create mode 100644 static/style.css create mode 100644 static/tagbox.js create mode 100644 static/tagcats.css create mode 100644 uninstall.lua create mode 100644 user.html.l diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..677ee4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +objd/ +objkey +cfg.lua \ No newline at end of file diff --git a/404.html.l b/404.html.l new file mode 100644 index 0000000..dea02bf --- /dev/null +++ b/404.html.l @@ -0,0 +1,7 @@ +{% title = BigGlobe.cfg.sitename .. " - 404" %} + +{% function content() %} +
This page doesn't exist.
+{% end %} + +{# base.inc \ No newline at end of file diff --git a/README b/README new file mode 100644 index 0000000..7be8779 --- /dev/null +++ b/README @@ -0,0 +1,56 @@ +This document is intended for people looking to contribute to Ikibooru. For Ikibooru users, check the official webpage: https://mid.net.ua/ikibooru.html + +Ikibooru uses Pegasus.lua as a barebones HTTP library, despite the latter's intention to be larger in scope. This is because it fails at this task. As such, Ikibooru's version of Pegasus.lua is also modified to include additional features: + +1. Support for multipart/form-data requests (parseMultipartFormData @ pegasus/request.lua); +2. set FD_CLOEXEC on each socket using luaposix; +3. more specific logging; +4. removed a really stupid feature where it tries to write to a response already written, if the status code is 404. +(UNDONE) 5. Request handlers are placed in coroutines and run in a round-robin fashion, so as to make sure one request or response wouldn't block other users + +The first is necessary for file uploads to be possible. + +The second is necessary as Ikibooru calls upon the shell for a few things, such as generating a thumbnail or sending an e-mail. Otherwise child processes inherit open file descriptors, which causes clients to wait until any internal processes end. + +Yes, it calls upon the shell. This quirk is why Ikibooru currently supports only Unices running Bourne-like shells. The offending lines appear in smtpauth.lua and db.lua. Protection against command injection is done by wrapping potentially untrusted input with single quotes, the contents of which the shell should interpret literally. Single quotes are beforehand turned into '"'"'. Behaviour should be equivalent to shlex.quote from the Python standard library. This on paper should achieve perfect command injection prevention, but more testing is required. More on this later. + +The aptly named html.lua handles formatting such as escaping or unescaping, and so must also be tested for security. The exception is SQL escaping which is done by LuaSQL (which wraps mysql_real_escape_string). + +db.lua handles all SQL access and filesystem access for storing individual object files. + +db.lua also handles valid formats for usernames, object names, emails, etc. For example, object names are not permitted to be CON, PRN, AUX, LPT1...LPT9, etc., due to these names being reserved by Windows (testobjfilename @ db.lua). It also makes sure a filename does not contain slashes, backslashes, unprintable ASCII characters, any other reserved filenames, may not consist only of dots, etc. These should make it more difficult to perform any command injection. + +Maybe valid names should be handled by html.lua? + +Ikibooru encodes object IDs using AES-128-ECB so as to hide their chronology. To do this it generates a random 16-byte string during installation, which it will use as a key forever. The security of object IDs is minor and its disclosure isn't as catastrophic as, say, the bypassing of user verification. The use of ECB is deliberate as it does not require an IV, and having unique plaintext-ciphertext pairs is useful. In other words, AES is not used in such a way that the choice of ECB will be detrimental to security. However, it is extremely important this key is not lost or changed, otherwise all external links to objects will be suddenly invalidated. + +db.lua makes the distinction of virtual and physical filenames (terminology borrowed from the virtual and physical memory of computer architectures). Virtual filenames are those shown to the end user (/objd/ABCD1234..ABCD1234), whereas physical filenames are those used internally (objd/AB/CD/12/34/../AB/CD/12/34). Ikibooru is designed to organize the filesystem in such a manner so as to minimize any penalties from having too many entries in any one directory. + +main.lua is where the server begins running. All requests caught by Pegasus.lua are passed to the callback function. Within it, a few special paths are passed to specific codepaths, and the rest go to the template system. Make no mistake, the templates do more than formatting a page. They may also handle and call to perform serious operations. + +Note, that the generic template path is wrapped with a pcall to catch errors. This lets the server keep running, should anything occur. The same is not done for the special paths, such as /verif, /addobj, etc. Therefore, making those parts extra error-resistant is more important. + +Ikibooru uses Lyre to compile templates, and its done once before Pegasus.lua is first initialized. Prior to passing to Lyre, Ikibooru preprocesses lines starting with {# to implement a basic "inclusion" system. Currently, all templates at the end include base.inc, which grants them a standard layout with a header, sign-in/out button, etc. + +BigGlobe handles the main configuration. The name is composed of Big (which means "great" or "powerful") and Globe (which means global). The name has nothing to do with flat-earthers nor globe-earthers. + +Sessions are done client-side, similarly to how JWT works. A verification link is sent to the e-mail address, which contains the session token. The token is composed of a few pieces of data concatenated with their HMAC-SHA256 digest. The session key used for the HMAC is 64 bytes long and is generated per each server launch. It is good practice to restart the server every now and then to invalidate all sessions, also rendering nil any efforts to bruteforce the session key. Assuming perfect security, "every now and then" means every few thousand years, but let us say every few months just in case. + +Notice that only an HMAC is used. There is no encryption, which means no secrecy, only integrity. So far this has been good enough. + +While Ikibooru can run on just about any Unix-like system, an automatic installer exists only for Linux. More would be welcome. + +obje.html.l is arguably the ugliest part of the entire program, in part due to the unreadable JS code within. The problem is because Pegasus.lua deals with only one request at a time. Should a large file be uploaded (say 50MB), it will effectively block the server from doing anything else. This is unacceptable, so JS is used to upload files in chunks of 512KB. Requests from other users inbetween can then be serviced. Now obje does the following upon submitting a form: +1. Send a POST request to delete appropriately marked files +2. If there are new files, send a POST request to create empty files of specific sizes (to make sure such sizes are allowed in the first place) +2.5. Send a POST request for each 512KB chunk of every file, which the server then places at the appropriate offset +3. Allow the rest of the form to be submitted naturally +What makes the code unreadable (IMO) is the asynchronous nature. It's hard to tell the overall control flow what with all of the onreadystatuschange events. + +Later on I realized that large responses (file downloads) block the server, also. Don't know why it took so long, but that's what lead to change no. 5 of Pegasus.lua. I still kept the uploading JS code in obje.html.l, because the page needed JS before that, anyway, and it provided better UX. + +Unfortunately, mod no. 5 cut down processing speed to around 66% of what it was without, and considering that Ikibooru is already meant to be used together with a reverse proxy, since that proxy likely supports accelerated static file serving (X-Sendfile or X-Accel-Redirect) the mod was ultimately undone. Perhaps it should be made optional? + +Times are stored in the database as DATETIMEs in the UTC time zone. I don't remember why this was done. I suppose because the TIMESTAMP datatype (which uses UTC automatically) breaks after 2038, whereas DATETIME can go upto the year 9999. The server passes to browsers times in UTC (strings directly from the DB engine), and then clientside JS code proceeds to shift them into the the user's appropriate local timezone (static/datetimes.js). This means that should JS be disabled, all times will be displayed as UTC. + +main.lua and core.lua are split to later separate Ikibooru the "service", from Ikibooru the "server". Some reverse proxies can create Lua environments of their own and have Ikibooru attached to them directly (skipping any inter-server communication overhead), That way, Ikibooru wouldn't have to run as an HTTP server at all, but this isn't supported yet. diff --git a/admin.html.l b/admin.html.l new file mode 100644 index 0000000..c18caf0 --- /dev/null +++ b/admin.html.l @@ -0,0 +1,157 @@ +{% + -- ADMIN ONLY + if verified and verified.privs ~= 255 then verified = nil end + + if verified then + local pohst = request:post() + if pohst and pohst.csrf and DB.csrfverify(verified.id, Escapes.urlunescape(tostring(pohst.csrf))) then + -- Basic settings + if pohst["sitename"] and not Escapes.urlunescape(pohst["sitename"]):match"^%s*$" then + BigGlobe.cfg.sitename = Escapes.urlspunescape(pohst["sitename"]) + end + + BigGlobe.cfg.enable18plus = pohst["enable18plus"] ~= nil + + -- Tag categories + for i=1,BigGlobe.cfg.tc.n do + if pohst["tcn" .. i] then + BigGlobe.cfg.tc[i].name = Escapes.urlspunescape(pohst["tcn" .. i]) + end + if pohst["tcc" .. i] then + BigGlobe.cfg.tc[i].col = tonumber(Escapes.urlunescape(pohst["tcc" .. i]):sub(2), 16) or 0 + end + end + + -- New tag + if pohst["ntn"] and tonumber(pohst["ntc"]) then + local cat = tonumber(pohst["ntc"]) + if cat >= 1 and cat <= BigGlobe.cfg.tc.n then + DB.tagadd(Escapes.urlspunescape(pohst["ntn"]), cat, pohst["nta"] ~= nil) + end + end + + -- Tag editing + if pohst["ettags"] and (#pohst["ettags"] > 0) then + local tagarr = {} + for tagstr in pohst["ettags"]:gmatch("%d+") do + table.insert(tagarr, tonumber(tagstr)) + end + + local newname = pohst["etname"] and Escapes.urlspunescape(pohst["etname"]) + if newname and newname:match"^%s*$" then newname = nil end + + local newcategory = tonumber(pohst["etcat"]) + + local newadultonly = pohst["eta"] ~= nil + + local del = pohst["etdel"] + if del and del:match"^%s*$" then del = nil end + + DB.updatetags(tagarr, newname, newcategory, newadultonly, del) + end + + -- Mods + if pohst["mods"] then + local newmods = {} + for m in Escapes.urlspunescape(pohst["mods"]):gmatch"[^,]+" do + if not m:match("^%s*$") then + table.insert(newmods, m) + end + end + + DB.setmodsviaemails(newmods) + end + + -- Ruleset + if pohst["ruleset"] then + BigGlobe.cfg.ruleset = Escapes.urlspunescape(pohst["ruleset"]) + end + + -- X-Sendfile + if pohst["sendfilehdr"] then + BigGlobe.cfg.sendfile = Escapes.urlunescape(pohst["sendfilehdr"]) + end + if pohst["sendfileprefix"] then + BigGlobe.cfg.sendfileprefix = Escapes.urlunescape(pohst["sendfileprefix"]) + end + + BigGlobe.sv() + end + else + response:statusCode(403) + end +%} + +{% title = "Administrative Settings" %} + +{% function content() %} + {% if verified then %} + + + + {% else %} +You are not authorized to view this page.
+ {% end %} +{% end %} + +{# base.inc \ No newline at end of file diff --git a/base.inc b/base.inc new file mode 100644 index 0000000..5823df6 --- /dev/null +++ b/base.inc @@ -0,0 +1,56 @@ + + + + + +{{ Escapes.htmlescape(BigGlobe.cfg.sitename) }}
+ {% if verified and verified.privs >= DB.USER_PRIVS_ADMIN then %} + + {% end %} + {% if verified and verified.privs >= DB.USER_PRIVS_MOD then %} + + + {% end %} +Invite sent.
+ {% elseif verified then %} + + {% end %} +{% end %} + +{# base.inc \ No newline at end of file diff --git a/login.html.l b/login.html.l new file mode 100644 index 0000000..8aa7e7d --- /dev/null +++ b/login.html.l @@ -0,0 +1,45 @@ +{% + local em = request.querystring.z and Escapes.urlspunescape(request.querystring.z) + + local u + local worked = false + local zzz + + if em and not verified then + worked = true + + u = DB.getuserbyemail(em) + if u and u.privs ~= DB.USER_PRIVS_BANNED then + -- Signing in + zzz = SMTPAuth.sendauthinfo(u) + elseif not u and (BigGlobe.cfg.membexcl == BigGlobe.MEMBEXCL_PUBLIC_WITHAPPROVAL or BigGlobe.cfg.membexcl == BigGlobe.MEMBEXCL_PUBLIC_NOAPPROVAL) then + -- Public registration + zzz = SMTPAuth.sendregisterinfo(em) + else + worked = false + end + + if worked and BigGlobe.cfg.anarchy == "ANARCHY" then + response:addHeader("Refresh", "3;url=" .. zzz) + response:statusCode(303) + end + end + + title = BigGlobe.cfg.sitename .. " - Sign-in sent" +%} + +{% function content() %} + {% if BigGlobe.cfg.anarchy == "ANARCHY" then %} +In anarchy mode, anyone can be anything. Please wait..
+ +If that doesn't work, click here.
+ {% else %} + {% if worked then %} +Link has been sent to {{ em and Escapes.htmlescape(em) }}. This page may be closed.
+ {% else %} +You are not authorized to register this account. {% if u and u.privs == DB.USER_PRIVS_BANNED then %}You have been banned.{% elseif BigGlobe.cfg.membexcl == BigGlobe.MEMBEXCL_MEMBERS_INVITE then %}This website is invite-only.{% elseif BigGlobe.cfg.membexcl == BigGlobe.MEMBEXCL_MODS_INVITE then %}Only moderators may invite.{% elseif BigGlobe.cfg.membexcl == BigGlobe.MEMBEXCL_ADMIN_INVITES then %}Only administrators may invite.{% end %}
+ {% end %} + {% end %} +{% end %} + +{# base.inc \ No newline at end of file diff --git a/lyre.lua b/lyre.lua new file mode 100644 index 0000000..56f9390 --- /dev/null +++ b/lyre.lua @@ -0,0 +1,82 @@ +--[[ + Copyright (C) 2023 + Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE +]] + +local function read(fn) + local fd = io.open(fn, "r") + + if not fd then return nil end + + local ret = fd:read("*all") + fd:close() + return ret +end + +local function compile(src, name) + local code = {"RETURN_STRINGS={} "} + + name = name or "Lyre Render" + + while true do + local startI = select(2, src:find("{[{%%]", 1, false)) + local endI + local isstmt = false + + if startI then + isstmt = src:byte(startI) == string.byte("%") + endI = select(2, src:find(isstmt and "%%}" or "}}", 1, false)) + end + + if startI and endI then + if endI < startI then + table.insert(code, ";RETURN_STRINGS[#RETURN_STRINGS+1]="..string.format("%q", src:sub(1, startI - 2))) + src = src:sub(startI - 1) + else + table.insert(code, ";RETURN_STRINGS[#RETURN_STRINGS+1]="..string.format("%q", src:sub(1, startI - 2))) + + if isstmt then + table.insert(code, src:sub(startI + 1, endI - 2)) + else + table.insert(code, "RETURN_STRINGS[#RETURN_STRINGS+1]=tostring("..src:sub(startI + 1, endI - 2)..")") + end + + table.insert(code, ret) + + src = src:sub(endI + 1) + end + else + table.insert(code, "RETURN_STRINGS[#RETURN_STRINGS+1]="..string.format("%q", src)..";return table.concat(RETURN_STRINGS)") + break + end + end + + local env = setmetatable({table = table, tostring = tostring, tonumber = tonumber, pairs = pairs, ipairs = ipairs, string = string, math = math, RETURN_STRINGS = {}, type = type}, {}) + + local chu, err + if _ENV then + chu, err = load(table.concat(code), name, "t", env) + if err then error(err) end + else + chu, err = loadstring(table.concat(code), name) + if err then error(err) end + setfenv(chu, env) + end + + return {chu = chu, env = env} +end + +local function render(tmpl, envOverride, strongEnv) + local mt = getmetatable(tmpl.env) + mt.__index = envOverride + if strongEnv then mt.__newindex = envOverride end + return tmpl.chu() +end + +return { + render = render, + compile = compile, + compilef = function(fn) return compile(read(fn), fn) end +} diff --git a/main.lua b/main.lua new file mode 100644 index 0000000..2b5c7b3 --- /dev/null +++ b/main.lua @@ -0,0 +1,15 @@ +local st, res = pcall(io.popen, "echo $SHELL", "r") +shelltype = st and res:read"*l" or "" +if not st or (shelltype ~= "/bin/bash" and shelltype ~= "/bin/sh" and shelltype ~= "/bin/ksh") then + error("Ikibooru currently runs only on Unix platforms + Bourne-like shell.") +end + +local Pegasus = require"pegasus" +local Compress = require"pegasus.plugins.compress" + +local BigGlobe = require"bigglobe" + +Pegasus:new({ + port = BigGlobe.cfg.port, + plugins = {} +}):start(require"core") \ No newline at end of file diff --git a/obj.html.l b/obj.html.l new file mode 100644 index 0000000..e0c088c --- /dev/null +++ b/obj.html.l @@ -0,0 +1,162 @@ +{% + local obj, files, owner + if DB.isobjhexvalid(request:path():sub(7, 7 + 31)) then + obj = DB.getobj(DB.objshowid(request:path():sub(7, 7 + 31))) + end + + if obj then + local virt = "/objd/" .. DB.objhideid(obj.id) .. "/" + local phys = DB.urltophysical(virt) + + files = {} + for f in LFS.dir(phys) do + if f ~= "." and f ~= ".." then + local attribs = LFS.attributes(phys .. f) + table.insert(files, {name = f, size = attribs.size, modtime = attribs.modification, phys = phys .. f, virt = virt .. f}) + end + end + + table.sort(files, function(a,b) + if a.modtime == b.modtime then + return a.name < b.name + else + return a.modtime < b.modtime + end + end) + + if verified and request:post() and request:post().csrf and DB.csrfverify(verified.id, Escapes.urlunescape(request:post().csrf)) then + if request:post().comment then + DB.postcomment(obj.id, verified.id, Escapes.urlspunescape(request:post().comment)) + end + end + + owner = DB.getuserbyid(obj.owner) + end + + title = BigGlobe.cfg.sitename .. " - " .. (obj and obj.name or "Object not found") +%} + +{% function content() %} + {% if obj then %} +Object has {{#files-1}} files:
+Object was not found.
+ {% end %} +{% end %} + +{# base.inc \ No newline at end of file diff --git a/obje.html.l b/obje.html.l new file mode 100644 index 0000000..8d23781 --- /dev/null +++ b/obje.html.l @@ -0,0 +1,351 @@ +{% + local obj, files, usedSpace, tags, tagcount + if DB.isobjhexvalid(request:path():sub(7, 7 + 31)) then + obj = DB.getobj(DB.objshowid(request:path():sub(7, 7 + 31))) + end + + function recalcfiles() + local virt = "/objd/" .. DB.objhideid(obj.id) .. "/" + local phys = DB.urltophysical(virt) + + files = {} + usedSpace = 0 + for f in LFS.dir(phys) do + if f ~= "." and f ~= ".." then + local attribs = LFS.attributes(phys .. f) + table.insert(files, {name = f, size = attribs.size, modtime = attribs.modification, phys = phys .. f, virt = virt .. f}) + usedSpace = usedSpace + attribs.size + end + end + table.sort(files, function(a,b) + if a.modtime == b.modtime then + return a.name < b.name + else + return a.modtime < b.modtime + end + end) + end + + if obj then + -- !!!CHECK IF OWNER HAPPENS HERE!!! + if verified and (verified.id ~= obj.owner) then + verified = nil + end + + recalcfiles() + + tags, tagcount = DB.getobjtags(obj.id) + + if verified then + obj.sizequota = BigGlobe.cfg.maxtotalobjsize * 1024 -- Temporary + + local post = request:post() + if post and post.csrf and DB.csrfverify(verified.id, Escapes.urlunescape(tostring(post.csrf))) then + if post["createfiles"] then + local sztoadd = 0 + for fn, sz in post["createfiles"]:gmatch"([^;]+);(%d+)" do + sztoadd = sztoadd + sz + end + if usedSpace + sztoadd > obj.sizequota then + response:statusCode(409) + else + for fn, sz in post["createfiles"]:gmatch"([^;]+);(%d+)" do + DB.createfile(obj.id, Escapes.urlunescape(fn), tonumber(sz)) + end + end + return + elseif post["upchu"] then + DB.updatefile(obj.id, post["upchu"], tonumber(post["offset"]), post["data"].data) + return + elseif post["remfiles"] then + for d in post["remfiles"]:gmatch"(%d+)" do + DB.remfilefromobj(obj.id, files[tonumber(d) + 1].name) + end + return + else + recalcfiles() + + if tonumber(post.newthumbnail) and files[tonumber(post.newthumbnail)] then + DB.genobjthumbnail(obj.id, files[tonumber(post.newthumbnail)].name, obj.sizequota - usedSpace) + recalcfiles() + end + + if post["objname"] then + local newname = Escapes.urlunescape(post["objname"]) + local newpublished = usedSpace > 0 + if newname ~= obj.name or obj.published ~= newpublished then + if DB.objupdate(obj.id, newname, newpublished) then + obj.name = newname + obj.published = newpublished + end + end + end + + if post["objtags"] then + local newtagset = {} + for m in post["objtags"]:gmatch"%d+" do + newtagset[tonumber(m)] = true + end + + local tagstoremove = {} + for k,_ in pairs(tags) do + if not newtagset[k] then + table.insert(tagstoremove, k) + end + end + + local tagstoadd = {} + for k,_ in pairs(newtagset) do + if not tags[k] then + table.insert(tagstoadd, k) + end + end + + DB.remtagsfromobj(obj.id, tagstoremove) + DB.addtagstoobj(obj.id, tagstoadd) + + tags, tagcount = DB.getobjtags(obj.id) + end + end + end + end + end + + title = BigGlobe.cfg.sitename .. " - Editing " .. (obj and obj.name or "Object not found") + + function content() %} + {% if obj then %} + {% if verified then %} + + + + + {% else %} +You are not eligible to edit this object.
+ {% end %} + {% else %} +Object was not found.
+ {% end %} +{% end %} + +{# base.inc \ No newline at end of file diff --git a/pegasus/compress.lua b/pegasus/compress.lua new file mode 100644 index 0000000..24cd3fe --- /dev/null +++ b/pegasus/compress.lua @@ -0,0 +1,3 @@ +-- compatibility shim for moving the plugin into a 'plugins' subfolder +-- without breaking anything +return require("pegasus.plugins.compress") diff --git a/pegasus/handler.lua b/pegasus/handler.lua new file mode 100644 index 0000000..5e8c70f --- /dev/null +++ b/pegasus/handler.lua @@ -0,0 +1,151 @@ +local Request = require 'pegasus.request' +local Response = require 'pegasus.response' +local mimetypes = require 'mimetypes' +local lfs = require 'lfs' + +local function ternary(condition, t, f) + if condition then return t else return f end +end + +local Handler = {} +Handler.__index = Handler + +function Handler:new(callback, location, plugins) + local handler = {} + handler.callback = callback + handler.location = location or '' + handler.plugins = plugins or {} + + local result = setmetatable(handler, self) + result:pluginsAlterRequestResponseMetatable() + + return result +end + +function Handler:pluginsAlterRequestResponseMetatable() + for _, plugin in ipairs(self.plugins) do + if plugin.alterRequestResponseMetaTable then + local stop = plugin:alterRequestResponseMetaTable(Request, Response) + if stop then + return stop + end + end + end +end + +function Handler:pluginsNewRequestResponse(request, response) + for _, plugin in ipairs(self.plugins) do + if plugin.newRequestResponse then + local stop = plugin:newRequestResponse(request, response) + if stop then + return stop + end + end + end +end + +function Handler:pluginsBeforeProcess(request, response) + for _, plugin in ipairs(self.plugins) do + if plugin.beforeProcess then + local stop = plugin:beforeProcess(request, response) + if stop then + return stop + end + end + end +end + +function Handler:pluginsAfterProcess(request, response) + for _, plugin in ipairs(self.plugins) do + if plugin.afterProcess then + local stop = plugin:afterProcess(request, response) + if stop then + return stop + end + end + end +end + +function Handler:pluginsProcessFile(request, response, filename) + for _, plugin in ipairs(self.plugins) do + if plugin.processFile then + local stop = plugin:processFile(request, response, filename) + + if stop then + return stop + end + end + end +end + +function Handler:processBodyData(data, stayOpen, response) + local localData = data + + for _, plugin in ipairs(self.plugins or {}) do + if plugin.processBodyData then + localData = plugin:processBodyData( + localData, + stayOpen, + response.request, + response + ) + end + end + + return localData +end + +function Handler:processRequest(port, client, server) + local request = Request:new(port, client, server) + + if not request:method() then + client:close() + return + end + + local response = Response:new(client, self) + response.request = request + local stop = self:pluginsNewRequestResponse(request, response) + + if stop then + return + end + + if request:path() and self.location ~= '' then + local path = ternary(request:path() == '/' or request:path() == '', 'index.html', request:path()) + local filename = '.' .. self.location .. path + + if not lfs.attributes(filename) then + response:statusCode(404) + end + + stop = self:pluginsProcessFile(request, response, filename) + + if stop then + return + end + + local file = io.open(filename, 'rb') + + if file then + response:writeFile(file, mimetypes.guess(filename or '') or 'text/html') + else + response:statusCode(404) + end + end + + if self.callback then + response:statusCode(200) + response.headers = {} + response:addHeader('Content-Type', 'text/html') + + self.callback(request, response) + end + + if response.status == 404 and not response._isClosed then + response:writeDefaultErrorMessage(404) + end +end + + +return Handler diff --git a/pegasus/init.lua b/pegasus/init.lua new file mode 100644 index 0000000..477dcbc --- /dev/null +++ b/pegasus/init.lua @@ -0,0 +1,63 @@ +local socket = require 'socket' +local Handler = require 'pegasus.handler' + +local FCNTL = require 'posix.fcntl' +local time = require 'posix.time' + +local Pegasus = {} +Pegasus.__index = Pegasus + +function Pegasus:new(params) + params = params or {} + local server = {} + + server.host = params.host or '*' + server.port = params.port or '9090' + server.location = params.location or '' + server.plugins = params.plugins or {} + server.timeout = params.timeout or 1 + server.threads = {} + + return setmetatable(server, self) +end + +function Pegasus:start(callback) + local handler = Handler:new(callback, self.location, self.plugins) + local server = assert(socket.bind(self.host, self.port)) + local ip, port = server:getsockname() + print('Pegasus is up on ' .. ip .. ":".. port) + + FCNTL.fcntl(server:getfd(), FCNTL.F_SETFD, FCNTL.FD_CLOEXEC) + --server:settimeout(0) + + local i = 1 + while 1 do + local client, errmsg = server:accept() + + if client then + FCNTL.fcntl(client:getfd(), FCNTL.F_SETFD, FCNTL.FD_CLOEXEC) + client:settimeout(self.timeout, 'b') + --local coro = coroutine.create(handler.processRequest) + --coroutine.resume(coro, handler, self.port, client, server) + --if coroutine.status(coro) ~= "dead" then + -- self.threads[#self.threads + 1] = coro + --end + handler:processRequest(self.port, client, server) + elseif errmsg ~= 'timeout' then + io.stderr:write('Failed to accept connection:' .. errmsg .. '\n') + end + + --[[if #self.threads > 0 then + coroutine.resume(self.threads[i]) + if coroutine.status(self.threads[i]) == "dead" then + table.remove(self.threads, i) + else + i = i % #self.threads + 1 + end + else + time.nanosleep({tv_sec = 0, tv_nsec = 500000}) + end]] + end +end + +return Pegasus diff --git a/pegasus/plugins/compress.lua b/pegasus/plugins/compress.lua new file mode 100644 index 0000000..40d8ca2 --- /dev/null +++ b/pegasus/plugins/compress.lua @@ -0,0 +1,145 @@ +local zlib = require "zlib" + +local function zlib_name(lib) + if lib._VERSION and string.find(lib._VERSION, 'lua-zlib', nil, true) then + return 'lua-zlib' + end + + if lib._VERSION and string.find(lib._VERSION, 'lzlib', nil, true) then + return 'lzlib' + end +end + +local z_lib_name = assert(zlib_name(zlib), 'Unsupported zlib Lua binding') + +local ZlibStream = {} do + ZlibStream.__index = ZlibStream + + ZlibStream.NO_COMPRESSION = zlib.NO_COMPRESSION or 0 + ZlibStream.BEST_SPEED = zlib.BEST_SPEED or 1 + ZlibStream.BEST_COMPRESSION = zlib.BEST_COMPRESSION or 9 + ZlibStream.DEFAULT_COMPRESSION = zlib.DEFAULT_COMPRESSION or -1 + ZlibStream.STORE = 0 + ZlibStream.DEFLATE = 8 + + if z_lib_name == 'lzlib' then + function ZlibStream:new(writer, level, method, windowBits) + level = level or ZlibStream.DEFAULT_COMPRESSION + method = method or ZlibStream.DEFLATE + + local o = setmetatable({ + zd = assert(zlib.deflate(writer, level, method, windowBits)); + }, self) + + return o + end + + function ZlibStream:write(chunk) + assert(self.zd:write(chunk)) + end + + function ZlibStream:close() + self.zd:close() + end + + elseif z_lib_name == 'lua-zlib' then + function ZlibStream:new(writer, level, method, windowBits) + level = level or ZlibStream.DEFAULT_COMPRESSION + method = method or ZlibStream.DEFLATE + + assert(method == ZlibStream.DEFLATE, 'lua-zlib support only deflated method') + + local o = setmetatable({ + zd = assert(zlib.deflate(level, windowBits)); + writer = writer; + }, self) + + return o + end + + function ZlibStream:write(chunk) + chunk = assert(self.zd(chunk)) + self.writer(chunk) + end + + function ZlibStream:close() + local chunk = self.zd('', 'finish') + if chunk and #chunk > 0 then self.writer(chunk) end + end + end +end + +local Compress = {} do + Compress.__index = Compress + + Compress.NO_COMPRESSION = ZlibStream.NO_COMPRESSION + Compress.BEST_SPEED = ZlibStream.BEST_SPEED + Compress.BEST_COMPRESSION = ZlibStream.BEST_COMPRESSION + Compress.DEFAULT_COMPRESSION = ZlibStream.DEFAULT_COMPRESSION + + function Compress:new(options) + local compress = {} + compress.options = options or {} + + return setmetatable(compress, self) + end + + function Compress:processBodyData(data, stayOpen, request, response) + local accept_encoding + + if response.headersSended then + accept_encoding = response.headers['Content-Encoding'] or '' + else + local headers = request:headers() + accept_encoding = headers and headers['Accept-Encoding'] or '' + end + + local accept_gzip = not not accept_encoding:find('gzip', nil, true) + + if accept_gzip and self.options.level ~= ZlibStream.NO_COMPRESSION then + local stream = response.compress_stream + local buffer = response.compress_buffer + + if not stream then + local writer = function (zdata) buffer[#buffer + 1] = zdata end + stream, buffer = ZlibStream:new(writer, self.options.level, nil, 31), {} + end + + if stayOpen then + if data == nil then + stream:close() + response.compress_stream = nil + response.compress_buffer = nil + else + stream:write(data) + response.compress_stream = stream + response.compress_buffer = buffer + end + + local compressed = table.concat(buffer) + for i = 1, #buffer do buffer[i] = nil end + if not response.headersSended then + response:addHeader('Content-Encoding', 'gzip') + end + + return compressed + end + + stream:write(data) + stream:close() + local compressed = table.concat(buffer) + + if #compressed < #data then + if not response.headersSended then + response:addHeader('Content-Encoding', 'gzip') + end + + return compressed + end + end + + return data + end +end + +return Compress diff --git a/pegasus/request.lua b/pegasus/request.lua new file mode 100644 index 0000000..aba78db --- /dev/null +++ b/pegasus/request.lua @@ -0,0 +1,277 @@ +local function normalizePath(path) + local value = string.gsub(path ,'\\', '/') + value = string.gsub(value, '^/*', '/') + value = string.gsub(value, '(/%.%.?)$', '%1/') + value = string.gsub(value, '/%./', '/') + value = string.gsub(value, '/+', '/') + + while true do + local first, last = string.find(value, '/[^/]+/%.%./') + if not first then break end + value = string.sub(value, 1, first) .. string.sub(value, last + 1) + end + + while true do + local n + value, n = string.gsub(value, '^/%.%.?/', '/') + if n == 0 then break end + end + + while true do + local n + value, n = string.gsub(value, '/%.%.?$', '/') + if n == 0 then break end + end + + return value +end + +local Request = {} +Request.__index = Request +Request.PATTERN_METHOD = '^(.-)%s' +Request.PATTERN_PATH = '(%S+)%s*' +Request.PATTERN_PROTOCOL = '(HTTP%/%d%.%d)' +Request.PATTERN_REQUEST = (Request.PATTERN_METHOD .. +Request.PATTERN_PATH ..Request.PATTERN_PROTOCOL) +Request.PATTERN_QUERY_STRING = '([^=]*)=([^&]*)&?' +Request.PATTERN_HEADER = '([%w-]+): ([%w %p]+=?)' + +function Request:new(port, client, server) + local obj = {} + obj.client = client + obj.server = server + obj.port = port + obj.ip = client:getpeername() + obj.querystring = {} + obj._firstLine = nil + obj._method = nil + obj._path = nil + obj._params = {} + obj._headerParsed = false + obj._headers = {} + obj._contentDone = 0 + obj._contentLength = nil + + return setmetatable(obj, self) +end + +function Request:parseFirstLine() + if (self._firstLine ~= nil) then + return + end + + local status, partial + self._firstLine, status, partial = self.client:receive() + + if (self._firstLine == nil or status == 'timeout' or partial == '' or status == 'closed') then + return + end + + -- Parse firstline http: METHOD PATH + -- GET Makefile HTTP/1.1 + local method, path = string.match(self._firstLine, Request.PATTERN_REQUEST) + + if not method then + self.client:close() + return + end + + print(os.date() .. ";" .. self.client:getpeername() .. ';' .. method .. ";" .. path) + + local filename = '' + local querystring = '' + + if #path then + filename, querystring = string.match(path, '^([^#?]+)[#|?]?(.*)') + filename = normalizePath(filename) + end + + self._path = filename + self._method = method + self.querystring = self:parseUrlEncoded(querystring) +end + +function Request:parseUrlEncoded(data) + local output = {} + + if data then + for key, value in string.gmatch(data, Request.PATTERN_QUERY_STRING) do + if key and value then + local v = output[key] + if not v then + output[key] = value + elseif type(v) == "string" then + output[key] = { v, value } + else -- v is a table + v[#v + 1] = value + end + end + end + end + + return output +end + +function Request:parseMultipartFormData(data) + local boundary = self:headers()["content-type"]:match("multipart/form%-data;%s+boundary=([^,]+)") + + local output = {} + + while true do + data = data:sub(data:find("--" .. boundary) + 2 + #boundary) + + if data:match("^--%s+$") then + break + end + + local name, filename, filetype + + local metadataHeaders = data:sub(1, data:find("\r\n\r\n", 5) + 1) + for line in metadataHeaders:gmatch("([^\r]+)\r\n") do + local key, value = line:match(Request.PATTERN_HEADER) + if not key then + break + elseif key:lower() == "content-disposition" then + -- TODO: secure? + name = value:match("; name=\"([^\"]+)\"") + filename = value:match("; filename=\"([^\"]+)\"") + elseif key:lower() == "content-type" then + filetype = value + end + end + + local itemdata = data:sub(data:find"\r\n\r\n" + 4, data:find("\r\n--" .. boundary) - 1) + + if name then + if filename then + -- Files *must* have a type, else this ignores them + if filetype then + output[name] = { + data = itemdata, + filename = filename, + type = filetype + } + end + else + output[name] = itemdata + end + end + end + + return output +end + +function Request:post() + if self:method() ~= 'POST' then return nil end + if self._postdata then return self._postdata end + + local ct = self:headers()["content-type"] + local data = self:receiveBody() + if ct:sub(1, ct:find";") == "multipart/form-data;" then + self._postdata = self:parseMultipartFormData(data) + else + self._postdata = self:parseUrlEncoded(data) + end + + return self._postdata +end + +function Request:path() + self:parseFirstLine() + return self._path +end + +function Request:method() + self:parseFirstLine() + return self._method +end + +function Request:headers() + if self._headerParsed then + return self._headers + end + + self:parseFirstLine() + + local data = self.client:receive() + + local headers = setmetatable({},{ -- add metatable to do case-insensitive lookup + __index = function(self, key) + if type(key) == "string" then + key = key:lower() + return rawget(self, key) + end + end + }) + + while (data ~= nil) and (data:len() > 0) do + local key, value = string.match(data, Request.PATTERN_HEADER) + + if key and value then + key = key:lower() + local v = headers[key] + if not v then + headers[key] = value + elseif type(v) == "string" then + headers[key] = { v, value } + else -- t == "table", v is a table + v[#v + 1] = value + end + end + + data = self.client:receive() + end + + self._headerParsed = true + self._contentLength = tonumber(headers["content-length"] or 0) + self._headers = headers + + return headers +end + +function Request:cookie(name) + if not self:headers()["cookie"] then + return nil + end + + return self:headers()["cookie"]:match(name:gsub("([^%w])", "%%%1") .. "=([^%;]*)") +end + +function Request:receiveBody(size) + -- do we have content? + if (self._contentLength == nil) or (self._contentDone >= self._contentLength) then + return false + end + + --[[local leftToLoad = self._contentLength + local ret = {} + + local maxEmpties = 15 + + while leftToLoad > 0 do + local chunkSz = math.min(512 * 1024, leftToLoad) + local data, err, partial = self.client:receive(chunkSz) + if err == 'timeout' then + data = partial + end + table.insert(ret, data) + leftToLoad = leftToLoad - #data + + if leftToLoad > 0 then + coroutine.yield() + end + end + + return table.concat(ret)]] + + local data, err, partial = self.client:receive(self._contentLength) + if err == 'timeout' then + data = partial + end + + self._contentDone = self._contentLength + + return data +end + +return Request diff --git a/pegasus/response.lua b/pegasus/response.lua new file mode 100644 index 0000000..4d4ff7d --- /dev/null +++ b/pegasus/response.lua @@ -0,0 +1,238 @@ +local function toHex(dec) + local charset = { '0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f' } + local tmp = {} + + repeat + table.insert(tmp, 1, charset[dec % 16 + 1]) + dec = math.floor(dec / 16) + until dec == 0 + + return table.concat(tmp) +end + +local STATUS_TEXT = setmetatable({ + [100] = 'Continue', + [101] = 'Switching Protocols', + [200] = 'OK', + [201] = 'Created', + [202] = 'Accepted', + [203] = 'Non-Authoritative Information', + [204] = 'No Content', + [205] = 'Reset Content', + [206] = 'Partial Content', + [300] = 'Multiple Choices', + [301] = 'Moved Permanently', + [302] = 'Found', + [303] = 'See Other', + [304] = 'Not Modified', + [305] = 'Use Proxy', + [307] = 'Temporary Redirect', + [400] = 'Bad Request', + [401] = 'Unauthorized', + [402] = 'Payment Required', + [403] = 'Forbidden', + [404] = 'Not Found', + [405] = 'Method Not Allowed', + [406] = 'Not Acceptable', + [407] = 'Proxy Authentication Required', + [408] = 'Request Time-out', + [409] = 'Conflict', + [410] = 'Gone', + [411] = 'Length Required', + [412] = 'Precondition Failed', + [413] = 'Request Entity Too Large', + [414] = 'Request-URI Too Large', + [415] = 'Unsupported Media Type', + [416] = 'Requested range not satisfiable', + [417] = 'Expectation Failed', + [500] = 'Internal Server Error', + [501] = 'Not Implemented', + [502] = 'Bad Gateway', + [503] = 'Service Unavailable', + [504] = 'Gateway Time-out', + [505] = 'HTTP Version not supported', +}, { + __index = function(self, statusCode) + -- if the lookup failed, try coerce to a number and try again + if type(statusCode) == "string" then + local result = rawget(self, tonumber(statusCode) or -1) + if result then + return result + end + end + error("http status code '"..tostring(statusCode).."' is unknown", 2) + end, +}) + +local DEFAULT_ERROR_MESSAGE = [[ + + + + +Error code: {{ STATUS_CODE }}
+Message: {{ STATUS_TEXT }}
+ + +]] + +local Response = {} +Response.__index = Response + +function Response:new(client, writeHandler) + local newObj = {} + newObj._headersSended = false + newObj._templateFirstLine = 'HTTP/1.1 {{ STATUS_CODE }} {{ STATUS_TEXT }}\r\n' + newObj._headFirstLine = '' + newObj._headers = {} + newObj._isClosed = false + newObj._client = client + newObj._writeHandler = writeHandler + newObj.status = 200 + + return setmetatable(newObj, self) +end + +function Response:addHeader(key, value) + assert(not self._headersSended, "can't add header, they were already sent") + self._headers[key] = value + return self +end + +function Response:addHeaders(params) + for key, value in pairs(params) do + self:addHeader(key, value) + end + + return self +end + +function Response:contentType(value) + return self:addHeader('Content-Type', value) +end + +function Response:statusCode(statusCode, statusText) + assert(not self._headersSended, "can't set status code, it was already sent") + self.status = statusCode + self._headFirstLine = string.gsub(self._templateFirstLine, '{{ STATUS_CODE }}', tostring(statusCode)) + self._headFirstLine = string.gsub(self._headFirstLine, '{{ STATUS_TEXT }}', statusText or STATUS_TEXT[statusCode]) + + return self +end + +function Response:_getHeaders() + local headers = {} + + for header_name, header_value in pairs(self._headers) do + if type(header_value) == "table" and #header_value > 0 then + for _, sub_value in ipairs(header_value) do + headers[#headers + 1] = header_name .. ': ' .. sub_value .. '\r\n' + end + else + headers[#headers + 1] = header_name .. ': ' .. header_value .. '\r\n' + end + end + + return table.concat(headers) +end + +function Response:writeDefaultErrorMessage(statusCode) + self:statusCode(statusCode) + local content = string.gsub(DEFAULT_ERROR_MESSAGE, '{{ STATUS_CODE }}', statusCode) + self:write(string.gsub(content, '{{ STATUS_TEXT }}', STATUS_TEXT[statusCode]), false) + + return self +end + +function Response:close() + local body = self._writeHandler:processBodyData(nil, true, self) + + if body and #body > 0 then + self._client:send(toHex(#body) .. '\r\n' .. body .. '\r\n') + end + + self._client:send('0\r\n\r\n') + self.close = true -- TODO: this seems unused?? + + return self +end + +function Response:sendOnlyHeaders() + self:sendHeaders(false, '') + self:write('\r\n') + + return self +end + +function Response:sendHeaders(stayOpen, body) + if self._headersSended then + return self + end + + if stayOpen then + self:addHeader('Transfer-Encoding', 'chunked') + elseif type(body) == 'string' then + self:addHeader('Content-Length', body:len()) + end + + self:addHeader('Date', os.date('!%a, %d %b %Y %H:%M:%S GMT', os.time())) + + if not self._headers['Content-Type'] then + self:addHeader('Content-Type', 'text/html') + end + + self._headersSended = true + self._client:send(self._headFirstLine .. self:_getHeaders() .. '\r\n') + self._chunked = stayOpen + + return self +end + +function Response:sendneck(data) + --if #data <= 512*1024 then + self._client:send(data) + --[[else + for i = 1, #data, 512*1024 do + self._client:send(data:sub(i, math.min(i + 512*1024 - 1, #data))) + coroutine.yield() + end + end]] +end + +function Response:write(body, stayOpen) + body = self._writeHandler:processBodyData(body or '', stayOpen, self) + self:sendHeaders(stayOpen, body) + + self._isClosed = not stayOpen + + if self._isClosed then + self:sendneck(body) + elseif #body > 0 then + self:sendneck(toHex(#body) .. '\r\n' .. body .. '\r\n') + end + + if self._isClosed then + self._client:close() -- TODO: remove this, a non-chunked body can also be sent in multiple pieces + if self.status // 100 > 3 then + print("Not OK: " .. self.status) + end + end + + return self +end + +function Response:writeFile(file, contentType) + self:contentType(contentType) + self:statusCode(200) + local value = file:read('*a') + file:close() + self:write(value) + + return self +end + +return Response diff --git a/reg.html.l b/reg.html.l new file mode 100644 index 0000000..6f1d98e --- /dev/null +++ b/reg.html.l @@ -0,0 +1,47 @@ +{% + title = "Registration" + + local expired = false + + if request:post() and request:post().iagree and request.querystring.q then + local q = Escapes.urlunescape(request.querystring.q) + local ver = DB.userregverify(q) + -- Second condition prevents double registration + if ver and not DB.getuserbyemail(ver.em) then + local name = Escapes.urlspunescape(request:post().mydispname) + if not name or name:match"^%s*$" then + name = "Empty Space" + end + + local userid = DB.reguser(ver.em, name, BigGlobe.cfg.membexcl == BigGlobe.MEMBEXCL_PUBLIC_WITHAPPROVAL and DB.USER_PRIVS_UNAPPROVED or DB.USER_PRIVS_APPROVED) + response:addHeader("Set-Cookie", "sesh=" .. Escapes.urlescape(DB.userauth(DB.getuserbyid(userid))) .. "; SameSite=Lax; Secure; HttpOnly") + response:addHeader("Location", "/") + response:statusCode(303) + else + response:statusCode(400) + expired = true + end + end +%} + +{% function content() %} + {% if expired then %} +This link has expired.
+ {% else %} +{{ Escapes.htmlescape(BigGlobe.cfg.sitename) }} uses 1 cookie to handle user sessions, and requires a user-chosen name to be publicly displayed and your e-mail address, both for as long as the account is registered. These are strictly necessary for {{ Escapes.htmlescape(BigGlobe.cfg.sitename) }} to function.
+ +All items posted and their files shall be publicly available, along with all of your comments.
+ + + {% end %} +{% end %} + +{# base.inc \ No newline at end of file diff --git a/reports.html.l b/reports.html.l new file mode 100644 index 0000000..82a115b --- /dev/null +++ b/reports.html.l @@ -0,0 +1,63 @@ +{% + local queryStatus = tonumber(request.querystring.status) or BigGlobe.REPORT_STATUS_OPEN + local offset = tonumber(request.querystring.o) + + if verified and verified.privs >= DB.USER_PRIVS_MOD then + if request:post() and request:post().csrf and DB.csrfverify(verified.id, Escapes.urlunescape(request:post().csrf)) and request:post().rid and tonumber(request:post().rid) then + local rid = tonumber(request:post().rid) + local selected = tonumber(request:post().newsta) + DB.setreportstatus(rid, selected) + end + + title = "Reports" + else + title = "Not authorized" + end +%} + +{% function content() %} + {% if verified and verified.privs >= DB.USER_PRIVS_MOD then %} + + {% local reports = DB.getreports(queryStatus, offset) %} + {% local csrfval = DB.csrf(verified.id) %} +
+ + On {{ report.createtime }}, + {{ Escapes.htmlescape(DB.getuserbyid(report.reportee).displayname) }} + was reported by + {{ Escapes.htmlescape(DB.getuserbyid(report.reporter).displayname) }} + +Status: {{ ({[BigGlobe.REPORT_STATUS_OPEN] = "Open", [BigGlobe.REPORT_STATUS_CLOSED_WONTFIX] = "Wont Fix", [BigGlobe.REPORT_STATUS_CLOSED_FIXED] = "Fixed"})[report.status] }} + +{{ Escapes.htmlescape(report.content):gsub("\n\n", " ") }} |
+
+ Change Status? + + |
+
Not authorized.
+ {% end %} +{% end %} + +{# base.inc \ No newline at end of file diff --git a/reportuser.html.l b/reportuser.html.l new file mode 100644 index 0000000..ceeafbb --- /dev/null +++ b/reportuser.html.l @@ -0,0 +1,46 @@ +{% + local reportee + + local reported = nil + + if verified and verified.privs >= DB.USER_PRIVS_APPROVED then + local reporteeid = tonumber(request:path():match"/reportuser/(%d+)") + reportee = reporteeid and DB.getuserbyid(reporteeid) + + if reportee then + title = "Report user " .. Escapes.htmlescape(reportee.displayname) + + if request:post() and request:post().csrf and DB.csrfverify(verified.id, Escapes.urlunescape(request:post().csrf)) and request:post().wtf and not request:post().wtf:match"^%s*$" and #request:post().wtf <= BigGlobe.MAX_COMMENT_SIZE then + reported = DB.addreport(verified.id, reporteeid, Escapes.urlspunescape(request:post().wtf)) + end + else + title = "User not found" + end + else + title = "Unverified" + end +%} + +{% function content() %} + {% if reported == nil then %} + {% if verified and verified.privs >= DB.USER_PRIVS_APPROVED then %} + + {% else %} +You must be an approved member to report users.
+ {% end %} + {% elseif reported then %} +Reported.
+ {% else %} +Failed to report.
+ {% end %} +{% end %} + +{# base.inc \ No newline at end of file diff --git a/search.html.l b/search.html.l new file mode 100644 index 0000000..5c2b58d --- /dev/null +++ b/search.html.l @@ -0,0 +1,45 @@ +{% + local tagids = {} + if request.querystring.t then + for tagid in string.gmatch(Escapes.urlunescape(request.querystring.t), "%d+") do + table.insert(tagids, tonumber(tagid)) + end + end + + local namefilter = request.querystring.n and Escapes.urlspunescape(request.querystring.n) or "" + if namefilter and namefilter:match"^%s*$" then namefilter = nil end + + local offset = request.querystring.off and DB.objshowid(Escapes.urlunescape(request.querystring.off)) or 0 + + local adultcontent = BigGlobe.cfg.enable18plus and tonumber(request.querystring.a) or -1 + + title = BigGlobe.cfg.sitename .. " - search" +%} + +{% function content() %} +No results.
+ {% else %} + {% for i=#found,1,-1 do local o = found[i] %} + {% local pub = DB.objhideid(o.id) %} +{{ Escapes.htmlescape(o.name) }}
+Welcome back to %s. Click on the below link to access your account.
If you had not initiated a sign-in request, consider whether your e-mail account has been compromised.
You have either registered or been invited to register at %s. Click on the below link to complete your registration.
If you have no idea what this is, consider whether your e-mail account has been compromised.
G^wHR{5jm4)SHP)R0|wh7b_07~hwc
zqd{TEFK%FDh6#;NUtsGKy User not found.=tUY9By^Hws2C{T@1>OEUAj
w#&9l0hMx81#sAuWIrxu(|1TMMPh*_ZESJ)s?oK-Uvw==u*F>jG%lYsB1A3ZN{{R30
literal 0
HcmV?d00001
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..44d3196
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,213 @@
+html, body {
+ width: 100%;
+ min-height: 100%;
+ margin: 0;
+}
+
+div.tagbox {
+ cursor: text;
+ border: 1px solid gray;
+ text-align: left;
+ user-select: contain;
+ padding: 0.4em;
+ border-radius: 3px;
+}
+div.tagbox::after {
+ content: "penis";
+ visibility: hidden;
+}
+div.tag {
+ display: inline-block;
+ border: 1px solid blue;
+ padding: 0.1em;
+ margin: 0.1em;
+}
+div.tag.selected {
+ border-width: 2px;
+}
+div.tag::before {
+ font-weight: lighter;
+ text-shadow: 0px 2px 3px gray;
+ padding-left: 0.1em;
+ padding-right: 0.1em;
+ border-right: 1px solid black;
+ margin-right: 0.1em;
+}
+div.tagbox span {
+ border: 0;
+ outline: none;
+}
+div.tagbox > p {
+ position: absolute;
+ left: 0.5em;
+ top: 0;
+ transform: translateY(-50%);
+ color: #C0C0C0;
+}
+
+div.autocomplete {
+ text-align: left;
+ border: 1px solid gray;
+ background-color: white;
+ padding: 0.4em;
+}
+
+a.searchitem > div {
+ position: relative;
+ border: 1px solid #808080;
+ max-width: 12.5em;
+ display: inline-block;
+ padding: 0.5em;
+ vertical-align: middle;
+ margin: 0.5em 0 0.5em 0;
+}
+a.searchitem img {
+ width: 100%;
+}
+a.searchitem {
+ text-decoration-line: none;
+ color: black;
+}
+a.searchitem p {
+ width: 100%;
+ margin: 0.3em 0 0.3em 0;
+}
+
+button {
+ margin-top: 1em;
+}
+
+h1, h2, h3, h4, h5, h6, p, li, span, td, label, div {
+ font-family: sans-serif;
+}
+
+table {
+ border-collapse: collapse;
+}
+td {
+ border: 1px solid black;
+ padding: 0.5em;
+}
+
+header {
+ position: absolute;
+ top: 1em;
+ width: 100%;
+}
+
+main {
+ margin-top: 10%;
+ margin-left: 10%;
+ width: 80%;
+}
+
+li {
+ margin: 0.2em 0 0.2em 0;
+}
+
+input, input:hover {
+ outline: none;
+}
+input[type='text'], input[type='number'] {
+ padding: 0.4em;
+ border: 1px solid gray;
+ border-radius: 3px;
+ font-size: 1em;
+}
+input::placeholder {
+ color: #C0C0C0;
+ opacity: 1;
+}
+
+ul.over18 {
+ margin: 1em 0 1em 0;
+ list-style-type: none;
+ padding: 0 0.4em 0 0.4em;
+ width: 100%;
+ text-align: center;
+}
+ul.over18 label {
+ cursor: pointer;
+ font-size: 0.9em;
+}
+ul.over18 > li {
+ width: 32%;
+ height: 1em;
+ position: relative;
+ display: inline-block;
+ border: 1px solid gray;
+ padding: 0.4em 0 0.4em 0;
+}
+ul.over18 > li:nth-child(1) {
+ border-top-left-radius: 3px;
+ border-bottom-left-radius: 3px;
+}
+ul.over18 > li:nth-child(3) {
+ border-top-right-radius: 3px;
+ border-bottom-right-radius: 3px;
+}
+ul.over18 > li > * {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 50%;
+ transform: translateY(50%);
+}
+ul.over18 input:checked + label {
+ text-shadow: 0px 2px 3px gray;
+ transform: translateY(60%);
+}
+ul.over18 input {
+ opacity: 0.01;
+ z-index: 100;
+}
+
+div.comments {
+ width: 60%;
+}
+div.comments h5 {
+ margin-bottom: 0;
+}
+div.comments p {
+ margin: 0.2em 0 0.2em 0;
+}
+div.comments p:last-child {
+ margin-bottom: 0;
+}
+textarea.uf {
+ font-family: inherit;
+ width: 100%;
+ height: 8em;
+}
+
+.reportlink {
+ font-size: 0.5em;
+ text-decoration: none;
+}
+
+.report {
+ width: 100%;
+ border-collapse: separate;
+ border-spacing: 0 1em;
+}
+.report td {
+ border: 0;
+ border-top: 1px solid gray;
+ border-bottom: 1px solid gray;
+}
+.report td:first-child {
+ border-left: 1px solid gray;
+}
+.report td:last-child {
+ border-right: 1px solid gray;
+ background-color: #E0E0E0;
+}
+.report .content {
+ margin-left: 1em;
+}
+
+footer {
+ margin-top: 2em;
+ width: 100%;
+ text-align: center;
+}
\ No newline at end of file
diff --git a/static/tagbox.js b/static/tagbox.js
new file mode 100644
index 0000000..ec9e4c7
--- /dev/null
+++ b/static/tagbox.js
@@ -0,0 +1,100 @@
+var acTimer;
+
+document.getElementById(document.querySelector("div.tagbox").getAttribute("data-formid")).addEventListener("submit", function(ev) {
+ document.getElementById(document.querySelector("div.tagbox").getAttribute("data-formparaminputid")).value = Array.from(document.querySelectorAll("div.tagbox div.tag")).map(function(a) {return a.getAttribute("data-tagid")}).join(",")
+})
+
+document.querySelector("div.tagbox").onclick = function(ev) {
+ this.querySelector('span').focus()
+}
+
+document.querySelector("div.tagbox span").onkeydown = function(ev) {
+ if(ev.keyCode == 13) {
+ ev.preventDefault()
+
+ if(document.querySelector("div.tagbox span").innerText.length) {
+ var from = document.querySelector("div.autocomplete > div.tag.selected") || document.querySelectorAll("div.autocomplete > div.tag")[0]
+
+ if(!document.querySelector("div.tagbox div.tag[data-tagid='"+from.getAttribute("data-tagid")+"']")) {
+ from.classList.remove("selected")
+
+ document.querySelector("div.tagbox span").innerText = ""
+ document.querySelector("div.tagbox").insertBefore(from, document.querySelector("div.tagbox span"))
+ document.querySelector("div.tagbox").insertBefore(document.createTextNode("\n"), document.querySelector("div.tagbox span"))
+
+ var ac = document.querySelector(".autocomplete")
+ ac.style.visibility = "hidden"
+ }
+ } else {
+ document.getElementById(document.querySelector("div.tagbox").getAttribute("data-formid")).querySelector("input[type='submit']").click()
+ }
+ } else if(ev.keyCode == 40) {
+ ev.preventDefault()
+ var sel = document.querySelector("div.autocomplete > div.tag.selected")
+ if(sel) {
+ sel.classList.toggle("selected")
+ sel = sel.nextElementSibling
+ } else {
+ sel = document.querySelectorAll("div.autocomplete > div.tag")[0]
+ }
+ if(sel) sel.classList.toggle("selected")
+ } else if(ev.keyCode == 38) {
+ ev.preventDefault()
+ var sel = document.querySelector("div.autocomplete > div.tag.selected")
+ if(sel) {
+ sel.classList.toggle("selected")
+ sel = sel.previousElementSibling
+ } else {
+ var asdf = document.querySelectorAll("div.autocomplete > div.tag")
+ sel = asdf[asdf.length - 1]
+ }
+ if(sel) sel.classList.toggle("selected")
+ } else if(ev.keyCode == 8 && !document.querySelector("div.tagbox span").innerText.length) {
+ ev.preventDefault()
+
+ var asdf = document.querySelectorAll("div.tagbox div.tag")
+ if(asdf.length > 0) {
+ asdf = asdf[asdf.length - 1]
+ if(asdf) asdf.parentNode.removeChild(asdf)
+ }
+ }
+}
+
+document.querySelector("div.tagbox span").oninput = function(ev) {
+ if(document.querySelector("div.tagbox span").innerText.length) {
+ document.querySelector("div.tagbox > p").style.visibility = "hidden"
+
+ if(acTimer) {
+ clearTimeout(acTimer)
+ }
+ acTimer = setTimeout(function() {
+ var ajax = new XMLHttpRequest()
+ ajax.open("GET", "/autocomp?q=" + encodeURIComponent(document.querySelector("div.tagbox span").innerText) + (document.querySelector("div.tagbox").getAttribute("data-over18") == "true" ? "&a=" : ""), true)
+ ajax.onreadystatechange = function() {
+ if(ajax.readyState == 4 && ajax.status == 200) {
+ var ac = document.querySelector(".autocomplete")
+ ac.style.visibility = ""
+ while(ac.firstChild) ac.removeChild(ac.firstChild)
+ if(ajax.responseText == "") {
+ ac.innerText = "No such tags found"
+ } else ajax.responseText.split("\n").slice(0, -1).forEach(function(line) {
+ var newtag = document.createElement("div")
+ newtag.classList.toggle("tag")
+ newtag.classList.toggle("tc" + line.split(",")[2])
+ newtag.setAttribute("data-tagid", line.split(",")[0])
+ newtag.innerText = line.split(",")[1]
+ ac.insertBefore(newtag, null)
+ })
+ }
+ }
+ ajax.send()
+ }, 250)
+ } else {
+ if(!document.querySelectorAll(".tagbox > div.tag").length) document.querySelector("div.tagbox > p").style.color = ""
+
+ document.querySelector(".autocomplete").style.visibility = "hidden"
+
+ clearTimeout(acTimer)
+ acTimer = undefined
+ }
+}
\ No newline at end of file
diff --git a/static/tagcats.css b/static/tagcats.css
new file mode 100644
index 0000000..e0e4bca
--- /dev/null
+++ b/static/tagcats.css
@@ -0,0 +1 @@
+div.tag.tc0::before{content:"G";}div.tag.tc0:hover::before{content:"Gamemode";}div.tag.tc0{border-color:#d7168a;background-color:#ffacff;}div.tag.tc1::before{content:"T";}div.tag.tc1:hover::before{content:"Theme";}div.tag.tc1{border-color:#64f647;background-color:#faffdd;}div.tag.tc2::before{content:"S";}div.tag.tc2:hover::before{content:"Strategy";}div.tag.tc2{border-color:#c87930;background-color:#ffffc6;}div.tag.tc3::before{content:"M";}div.tag.tc3:hover::before{content:"Meta";}div.tag.tc3{border-color:#cc6690;background-color:#fffcff;}div.tag.tc4::before{content:"";}div.tag.tc4:hover::before{content:"";}div.tag.tc4{border-color:#e961b8;background-color:#fff7ff;}div.tag.tc5::before{content:"";}div.tag.tc5:hover::before{content:"";}div.tag.tc5{border-color:#3292b9;background-color:#c8ffff;}div.tag.tc6::before{content:"";}div.tag.tc6:hover::before{content:"";}div.tag.tc6{border-color:#55d128;background-color:#ebffbe;}div.tag.tc7::before{content:"";}div.tag.tc7:hover::before{content:"";}div.tag.tc7{border-color:#c4aab1;background-color:#ffffff;}div.tag.tc8::before{content:"";}div.tag.tc8:hover::before{content:"";}div.tag.tc8{border-color:#471c3e;background-color:#ddb2d4;}div.tag.tc9::before{content:"";}div.tag.tc9:hover::before{content:"";}div.tag.tc9{border-color:#8dd0f9;background-color:#ffffff;}div.tag.tc10::before{content:"";}div.tag.tc10:hover::before{content:"";}div.tag.tc10{border-color:#7a36b1;background-color:#ffccff;}div.tag.tc11::before{content:"";}div.tag.tc11:hover::before{content:"";}div.tag.tc11{border-color:#a0fdaf;background-color:#ffffff;}div.tag.tc12::before{content:"";}div.tag.tc12:hover::before{content:"";}div.tag.tc12{border-color:#5d6283;background-color:#f3f8ff;}div.tag.tc13::before{content:"";}div.tag.tc13:hover::before{content:"";}div.tag.tc13{border-color:#836e3d;background-color:#ffffd3;}div.tag.tc14::before{content:"";}div.tag.tc14:hover::before{content:"";}div.tag.tc14{border-color:#f3c553;background-color:#ffffe9;}div.tag.tc15::before{content:"";}div.tag.tc15:hover::before{content:"";}div.tag.tc15{border-color:#ea8bc2;background-color:#ffffff;}div.tag.tc16::before{content:"";}div.tag.tc16:hover::before{content:"";}div.tag.tc16{border-color:#a2be00;background-color:#ffff96;}div.tag.tc17::before{content:"";}div.tag.tc17:hover::before{content:"";}div.tag.tc17{border-color:#b7a0c5;background-color:#ffffff;}div.tag.tc18::before{content:"";}div.tag.tc18:hover::before{content:"";}div.tag.tc18{border-color:#244010;background-color:#bad6a6;}div.tag.tc19::before{content:"";}div.tag.tc19:hover::before{content:"";}div.tag.tc19{border-color:#9b624f;background-color:#fff8e5;}div.tag.tc20::before{content:"";}div.tag.tc20:hover::before{content:"";}div.tag.tc20{border-color:#042c46;background-color:#9ac2dc;}div.tag.tc21::before{content:"";}div.tag.tc21:hover::before{content:"";}div.tag.tc21{border-color:#3e2dd3;background-color:#d4c3ff;}div.tag.tc22::before{content:"";}div.tag.tc22:hover::before{content:"";}div.tag.tc22{border-color:#23219b;background-color:#b9b7ff;}div.tag.tc23::before{content:"";}div.tag.tc23:hover::before{content:"";}div.tag.tc23{border-color:#cdde87;background-color:#ffffff;}div.tag.tc24::before{content:"";}div.tag.tc24:hover::before{content:"";}div.tag.tc24{border-color:#281c1e;background-color:#beb2b4;}div.tag.tc25::before{content:"";}div.tag.tc25:hover::before{content:"";}div.tag.tc25{border-color:#66a44a;background-color:#fcffe0;}div.tag.tc26::before{content:"";}div.tag.tc26:hover::before{content:"";}div.tag.tc26{border-color:#2139f2;background-color:#b7cfff;}div.tag.tc27::before{content:"";}div.tag.tc27:hover::before{content:"";}div.tag.tc27{border-color:#1bdae4;background-color:#b1ffff;}div.tag.tc28::before{content:"";}div.tag.tc28:hover::before{content:"";}div.tag.tc28{border-color:#ffb984;background-color:#ffffff;}div.tag.tc29::before{content:"";}div.tag.tc29:hover::before{content:"";}div.tag.tc29{border-color:#37dfaf;background-color:#cdffff;}div.tag.tc30::before{content:"";}div.tag.tc30:hover::before{content:"";}div.tag.tc30{border-color:#834f89;background-color:#ffe5ff;}div.tag.tc31::before{content:"";}div.tag.tc31:hover::before{content:"";}div.tag.tc31{border-color:#d6d00f;background-color:#ffffa5;}
\ No newline at end of file
diff --git a/uninstall.lua b/uninstall.lua
new file mode 100644
index 0000000..c8ae76f
--- /dev/null
+++ b/uninstall.lua
@@ -0,0 +1,43 @@
+#!/usr/bin/env lua5.3
+
+local SQL = require"luasql.mysql"
+
+do
+ local st, res = pcall(io.popen, "uname -s", "r")
+ if not st or res:read"*l" ~= "Linux" then
+ print"Only Linux is supported by the uninstaller at this moment."
+ return
+ end
+end
+
+local code = [[YES, I AM REALLY SURE I REALLY WANT TO DELETE EVERYTHING ON IKIBOORU]]
+
+print"Are you sure you wish to uninstall Ikibooru? Perhaps you want to only disable the service? This script will not do that.
+print"This script will: remove the user; its directory, which includes all object data and ID-hiding key; delete the database (that means ALL ACCOUNTS, COMMENTS AND POSTS!); remove as a service."
+print"ARE YOU SURE YOU WANT THIS?"
+print("If you are sure, type \""..code.."\" exactly.")
+
+if io.read"*l" ~= code then return end
+
+if not os.getenv"SUDO_COMMAND" then
+ print"Please run under sudo or as root."
+ return
+end
+
+if pcall(io.popen, "systemd --version") then
+ os.execute"systemctl stop ikibooru"
+ os.execute"systemctl disable ikibooru"
+ os.execute"rm /etc/systemd/system/ikibooru.service"
+ os.execute"systemctl daemon-reload"
+end
+
+local cfg = loadfile("/home/ikibooru/ikibooru/cfg.lua", "t", {})
+
+local SQLConn = SQL.mysql():connect("", cfg.sqlu, cfg.sqlp, cfg.sqlh)
+SQLConn:execute[[DROP DATABASE `ikibooru`;]]
+
+os.execute"deluser ikibooru"
+os.execute"rm -r /home/ikibooru"
+
+print""
+print"It was pleasure doing business."
\ No newline at end of file
diff --git a/user.html.l b/user.html.l
new file mode 100644
index 0000000..21fe7f7
--- /dev/null
+++ b/user.html.l
@@ -0,0 +1,58 @@
+{%
+ local urlid = tonumber(request:path():match"^/user/(%d+)")
+
+ local user = urlid and DB.getuserbyid(urlid)
+
+ if verified and user and verified.id == user.id and request:post() and request:post().csrf and DB.csrfverify(verified.id, Escapes.urlunescape(request:post().csrf)) then
+ local newname = Escapes.urlspunescape(request:post().newname)
+ if DB.userinfoupdate(user.id, newname) then
+ user.displayname = newname
+ end
+ end
+
+ title = BigGlobe.cfg.sitename .. " - " .. (user and user.displayname or "User not found")
+%}
+
+{% function content() %}
+ {% if user then %}
+
{{ Escapes.htmlescape(user.displayname) }}'s uploads
+
+ {% for k, obj in pairs(DB.getownedobjs(user.id)) do %}
+ {% local hidid = DB.objhideid(obj.id) %}
+
+ {% if verified and verified.id == user.id then %}
+
+
+ {% end %}
+
{{ Escapes.htmlescape(c.authordisplayname) }} {{ c.createtime }}
+{{ Escapes.htmlescape(c.content):gsub("\n\n", "
") }}