commit f4b0a0b531520610abc0408d909a2d12580aa23e Author: mid <> Date: Sat Jun 1 17:40:11 2024 +0300 Initial commit 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 %} +
+ + +

{{ title }}

+
+

Basic Site Settings

+

Site Name

+ + + + + +

New Tag

+

Set category ID

+ +

Set name

+ + +
+ +

Edit Tag

+
+

Tags to edit...

+ +
+ + + + +
+ + +

Moderators

+

Enter a comma-separated list of e-mails that shall be granted moderator permissions.

+ {% local mods = DB.getmoderators() %} + + +

Ruleset

+

The following HTML (!) will be displayed upon registration and user reporting. Use this to define a ruleset for your community. Tip: place an <ol> element to establish a clear numbered list.

+ + +

File download acceleration

+

Using this feature is highly recommended as it lets Ikibooru outsource IO-intensive file downloads onto the reverse proxy, which has ways of speeding up the process. Otherwise large file downloads could freeze the entire website. Said proxy must also be configured to support the feature (it is typically called X-Sendfile or X-Accel-Redirect).

+ + +
+
+

Tag Categories

+

These exist for ease within searching.

+ + + {% for i=1,BigGlobe.cfg.tc.n do %} + + + + + + {% end %} +
IdNameColor
{{ i }}
+
+
+ + + {% 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(title) }} + + + + + + + + + +
+

{{ Escapes.htmlescape(BigGlobe.cfg.sitename) }}

+ {% if verified and verified.privs >= DB.USER_PRIVS_ADMIN then %} +

Admin Settings

+ {% end %} + {% if verified and verified.privs >= DB.USER_PRIVS_MOD then %} +

Approval Queue

+

Reports

+ {% end %} +
+ {% if verified then %} + +
+ + +
+
My profile
+ {% if + (BigGlobe.cfg.membexcl == BigGlobe.MEMBEXCL_MEMBERS_INVITE and verified.privs >= DB.USER_PRIVS_APPROVED) + or (BigGlobe.cfg.membexcl == BigGlobe.MEMBEXCL_MODS_INVITE and verified.privs >= DB.USER_PRIVS_MOD) + or (BigGlobe.cfg.membexcl == BigGlobe.MEMBEXCL_ADMIN_INVITES and verified.privs >= DB.USER_PRIVS_ADMIN) then %} + +
Invite
+ {% end %} + {% else %} +
+ +
+ + +
+ {% end %} +
+
+
{% content() %}
+ + + + \ No newline at end of file diff --git a/bigglobe.lua b/bigglobe.lua new file mode 100644 index 0000000..22f35cf --- /dev/null +++ b/bigglobe.lua @@ -0,0 +1,70 @@ +local Escapes=require"html" + +local cfg + +local function ld() + cfg = loadfile("cfg.lua", "t") + + if cfg then cfg = cfg() end + + if not cfg then + error("Configuration file is missing.") + end +end + +local function serialize(t) + if type(t) == "table" then + local r = {"{"} + + for k, v in pairs(t) do + table.insert(r, "[" .. string.format("%q", k) .. "]=" .. serialize(v) .. ",") + end + + table.insert(r, "}") + return table.concat(r) + else + return string.format("%q", t) + end +end + +local function sv() + local out = io.open("cfg.lua", "wb") + out:write"-- THIS FILE IS AUTO-GENERATED. USE THE WEB ADMIN MENU!!\nreturn " + out:write(serialize(cfg)) + out:close() + + out = io.open("static/tagcats.css", "wb") + for i=1,cfg.tc.n do + out:write("div.tag.tc" .. (i - 1) .. "::before{content:" .. Escapes.cssescape(cfg.tc[i].name:sub(1, 1)) .. ";}") + out:write("div.tag.tc" .. (i - 1) .. ":hover::before{content:" .. Escapes.cssescape(cfg.tc[i].name) .. ";}") + + local r = cfg.tc[i].col >> 16 + local g = (cfg.tc[i].col >> 8) & 0xFF + local b = cfg.tc[i].col & 0xFF + local off = 150 + local darker = math.floor(math.min(b + off, 255)) | (math.floor(math.min(g + off, 255)) << 8) | (math.floor(math.min(r + off, 255)) << 16) + out:write("div.tag.tc" .. (i - 1) .. "{border-color:#" .. string.format("%06x", cfg.tc[i].col) .. ";background-color:#"..string.format("%06x",darker)..";}") + end + out:close() +end + +ld() +sv() + +return { + MEMBEXCL_ADMIN_INVITES = 1, + MEMBEXCL_MODS_INVITE = 2, + MEMBEXCL_MEMBERS_INVITE = 3, + MEMBEXCL_PUBLIC_WITHAPPROVAL = 4, + MEMBEXCL_PUBLIC_NOAPPROVAL = 5, + + MAX_COMMENT_SIZE = 1024, + + REPORT_STATUS_OPEN = 0, + REPORT_STATUS_CLOSED_WONTFIX = 1, + REPORT_STATUS_CLOSED_FIXED = 2, + + cfg = cfg, + ld = ld, + sv = sv +} \ No newline at end of file diff --git a/core.lua b/core.lua new file mode 100644 index 0000000..d6aa00e --- /dev/null +++ b/core.lua @@ -0,0 +1,186 @@ +local Lyre = require"lyre" +local Mime = require"mimetypes" + +local BigGlobe = require"bigglobe" + +local DB = require"db" +local Escapes = require"html" +local LFS = require"lfs" + +local SMTPAuth = require"smtpauth" + +-- Add template inclusions to Lyre +local function compiel(fn, sub) + local f = io.open(fn, "rb") + local buf = {} + + for l in f:lines() do + local includie = l:match("^{#%s*(.*)$") + if includie then + table.insert(buf, compiel(includie, true)) + else + table.insert(buf, l) + end + end + + f:close() + + return sub and table.concat(buf, "\n") or Lyre.compile(table.concat(buf, "\n"), fn) +end + +local Static = {} +local Templates = { + ["/"] = compiel("index.html.l"), + ["/search"] = compiel("search.html.l"), + ["/obji"] = compiel("obj.html.l"), + ["/obje"] = compiel("obje.html.l"), + ["/login"] = compiel("login.html.l"), + ["/admin"] = compiel("admin.html.l"), + ["/user"] = compiel("user.html.l"), + ["/invite"] = compiel("invite.html.l"), + ["/reportuser"] = compiel("reportuser.html.l"), + ["/reg"] = compiel("reg.html.l"), + ["/reports"] = compiel("reports.html.l"), + ["404"] = compiel("404.html.l"), +} + +local handler = function(req, res) + local verified = req:cookie"sesh" and DB.userverify(Escapes.urlunescape(req:cookie"sesh")) + + if verified and verified.privs == DB.USER_PRIVS_BANNED then + verified = nil + end + + if req:path() == "/autocomp" then + res:contentType("text/plain;charset=UTF-8") + res:write(DB.autocomplete(Escapes.urlunescape(req.querystring.q), req.querystring.a ~= nil)) + elseif req:path():match"^/addobj/?$" then + if verified then + local oid = DB.regnewobj(verified.id, verified.privs >= DB.USER_PRIVS_APPROVED, false) + if oid then + res:addHeader("Location", "/obje/" .. DB.objhideid(oid)) + res:statusCode(303) + end + else + res:statusCode(403) + end + res:write"" + elseif req:path():match"^/verif/?$" then + res:addHeader("Set-Cookie", "sesh=" .. (req.querystring.q or "") .. "; SameSite=Lax; Secure; HttpOnly") + res:addHeader("Location", "/") + res:statusCode(303) + res:write"" + elseif req:path():match"^/apprq/?$" then + if verified and verified.privs >= DB.USER_PRIVS_MOD then + -- Has decided on something already? + if req.querystring.csrf and DB.csrfverify(verified.id, Escapes.urlunescape(req.querystring.csrf)) then + local oid = DB.objshowid(Escapes.urlunescape(req.querystring.oid)) + + if req.querystring.deliamsure and DB.getuserbyid(DB.getobj(oid).owner).privs < verified.privs then + if req.querystring.banuser then + DB.banuser(DB.getobj(oid).owner) + end + + DB.delobj(oid, req.querystring.delfiles ~= nil) + elseif req.querystring.approveiamsure then + if req.querystring.approveuser then + DB.approveuser(DB.getobj(oid).owner) + end + + DB.approveobj(oid) + end + end + + local oid = DB.nexttoapproveobj() + res:addHeader("Location", oid and ("/obji/" .. DB.objhideid(oid)) or "/") + res:statusCode(303) + else + res:statusCode(403) + end + res:write"" + elseif req:path():match"^/static/" or req:path():match"^/objd/" or req:path() == "/favicon.ico" then + local p = req:path() + + res:contentType(Mime.guess(p:sub(2):lower()) or "text/plain") + + -- Force download + if p:match"^/objd/" then + res:addHeader("Content-Disposition", "attachment;filename=\"" .. p:match"^/objd/[^/]+/(.*)$" .. "\"") + end + + res:addHeader("Cache-Control", "max-age=604800") + + if req:method() == "HEAD" then + res:write"" + return + end + + local phys = DB.urltophysical(p) + + if not BigGlobe.cfg.sendfile:match"^%s*$" then + res:addHeader(BigGlobe.cfg.sendfile, BigGlobe.cfg.sendfileprefix .. "/" .. phys) + res:write"" + else + local f = io.open(phys, "rb") + if f then + local sz = f:seek("end") + if req:headers()["range"] then + local starto, endo = req:headers()["range"]:match("^bytes%=(%d+)%-(%d*)$") + starto = tonumber(starto) + endo = tonumber(endo) + if not endo then + endo = sz - 1 + elseif endo >= sz then + res:statusCode(416) + res:write"" + return + end + if starto and endo and starto <= endo then + res:statusCode(206) + res:addHeader("Accept-Ranges", "bytes") + res:addHeader("Content-Length", endo - starto + 1) + res:addHeader("Content-Range", "bytes " .. starto .. "-" .. endo .. "/" .. sz) + + f:seek("set", starto) + res:write(f:read(endo - starto + 1)) + end + else + f:seek("set") + res:write(f:read"*a") + end + f:close() + else + res:statusCode(404) + res:write"" + end + end + else + local tmpl = Templates[req:path():sub(1, req:path():sub(2):find("/") or -1)] + if not tmpl then + res:statusCode(404) + tmpl = Templates["404"] + end + + res:statusCode(200) + + local env = {BigGlobe = BigGlobe, SMTPAuth = SMTPAuth, DB = DB, Escapes = Escapes, LFS = LFS, request = req, response = res, print = print, verified = verified} + + local succ, val + if _ENV then + succ, val = xpcall(Lyre.render, debug.traceback, tmpl, env) + else + succ, val = pcall(Lyre.render, tmpl, env) + end + + if succ then + res:write(val) + else + print("Error!") + print(val) + res:statusCode(500) + res:write"Internal server error. Try not doing that." + end + end +end + +return handler \ No newline at end of file diff --git a/db.lua b/db.lua new file mode 100644 index 0000000..b7a8758 --- /dev/null +++ b/db.lua @@ -0,0 +1,822 @@ +local SQL = require"luasql.mysql" + +local BigGlobe = require"bigglobe" + +local SQLEnv = SQL.mysql() +local SQLConn + +local Rand = require"openssl.rand" +assert(Rand.ready()) + +local SessionsKey = Rand.bytes(64) + +local ObjHideKey = io.open("objkey", "rb"):read"*a" +local AES128ECB = require"openssl.cipher".new("AES-128-ECB") -- Can be ECB without reprecussions + +local Escapes = require"html" + +local LFS = require"lfs" + +local unistd = require"posix.unistd" +local stdio = require"posix.stdio" + +local USER_PRIVS_BANNED = 0 +local USER_PRIVS_UNAPPROVED = 1 +local USER_PRIVS_APPROVED = 2 +local USER_PRIVS_MOD = 128 +local USER_PRIVS_ADMIN = 255 + +local function pingdb() + if not SQLConn or not SQLConn:ping() then + SQLConn = SQLEnv:connect("ikibooru", BigGlobe.cfg.sqlu, BigGlobe.cfg.sqlp, BigGlobe.cfg.sqlh) + end + return SQLConn ~= nil +end + +-- UTC to store in database +local function getnow() + return "'" .. SQLConn:escape(os.date("!%Y-%m-%d %H:%M:%S")) .. "'" +end + +local function autocomplete(liek, over18) + pingdb() + + local ret = {} + local cursor = SQLConn:execute("SELECT * FROM tags WHERE name LIKE '" .. SQLConn:escape("%" .. liek .. "%") .. "'" .. (over18 and "" or " AND adultonly = 0") .. ";") + if not cursor then return "" end + local row = cursor:fetch({}) + while row do + ret[#ret + 1] = table.concat(row, ",") .. "\n" + + row = cursor:fetch(row) + end + cursor:close() + return table.concat(ret) +end + +local function searchobjs(taglist, namefilter, offset, adultstuff) + -- Security check + for k,v in pairs(taglist) do + if type(taglist[k]) ~= "number" then + return nil + end + end + + if not adultstuff then + adultstuff = -1 + end + + pingdb() + + offset = tonumber(offset) + + local sqlq + if #taglist > 0 and namefilter then + sqlq = string.format( + "SELECT * FROM objects WHERE %s id IN (SELECT objid FROM objtag WHERE tagid IN (%s) GROUP BY objid HAVING COUNT(tagid) >= %s) AND MATCH(name) AGAINST('%s') AND id > %s AND published = 1 ORDER BY id ASC LIMIT 50;", + + ({[-1] = "id NOT IN (SELECT objid FROM objtag WHERE tagid IN (SELECT id FROM tags WHERE adultonly = 1)) AND", [0] = "", [1] = "id IN (SELECT objid FROM objtag WHERE tagid IN (SELECT id FROM tags WHERE adultonly = 1)) AND"})[adultstuff], + table.concat(taglist, ","), + #taglist, + SQLConn:escape(namefilter), + offset + ) + elseif #taglist > 0 and not namefilter then + sqlq = string.format( + "SELECT * FROM objects WHERE %s id IN (SELECT objid FROM objtag WHERE tagid IN (%s) GROUP BY objid HAVING COUNT(tagid) >= %s) AND id > %s AND published = 1 ORDER BY id ASC LIMIT 50;", + + ({[-1] = "id NOT IN (SELECT objid FROM objtag WHERE tagid IN (SELECT id FROM tags WHERE adultonly = 1)) AND", [0] = "", [1] = "id IN (SELECT objid FROM objtag WHERE tagid IN (SELECT id FROM tags WHERE adultonly = 1)) AND"})[adultstuff], + table.concat(taglist, ","), + #taglist, + offset + ) + elseif #taglist == 0 and namefilter then + sqlq = string.format( + "SELECT * FROM objects WHERE %s MATCH(name) AGAINST('%s') AND id > %s AND published = 1 ORDER BY id ASC LIMIT 50;", + + ({[-1] = "id NOT IN (SELECT objid FROM objtag WHERE tagid IN (SELECT id FROM tags WHERE adultonly = 1)) AND", [0] = "", [1] = "id IN (SELECT objid FROM objtag WHERE tagid IN (SELECT id FROM tags WHERE adultonly = 1)) AND"})[adultstuff], + SQLConn:escape(namefilter), + offset + ) + else + sqlq = string.format( + "SELECT * FROM objects WHERE %s id > %s AND published = 1 ORDER BY id ASC LIMIT 50;", + + ({[-1] = "id NOT IN (SELECT objid FROM objtag WHERE tagid IN (SELECT id FROM tags WHERE adultonly = 1)) AND", [0] = "", [1] = "id IN (SELECT objid FROM objtag WHERE tagid IN (SELECT id FROM tags WHERE adultonly = 1)) AND"})[adultstuff], + offset + ) + end + + local cursor = SQLConn:execute(sqlq) + if not cursor then return {} end + + local ret = {} + while true do + local row = cursor:fetch({}, "a") + if not row then break end + table.insert(ret, row) + end + + return ret +end + +local function getobj(objid) + if type(objid) ~= "number" then return nil end + + pingdb() + + local cursor = SQLConn:execute("SELECT * FROM objects WHERE id = " .. objid .. ";") + if not cursor then return nil end + + local ret = cursor:fetch({}, "a") + if not ret then return nil end + + ret.id = tonumber(ret.id) --for some reason.. + ret.owner = tonumber(ret.owner) + ret.published = ret.published:byte(1) ~= 0 + ret.approved = ret.approved:byte(1) ~= 0 + return ret +end + +local hextointandinttohex = {} +for i=0,255 do local s = string.format("%02x", i); hextointandinttohex[s] = i; hextointandinttohex[i] = s; end +local function b256toreadable(src) + local r = {} + src:gsub(".", function(b) table.insert(r, hextointandinttohex[b:byte()]) end) + return table.concat(r) +end +local function readabletob256(src) + if #src % 2 == 1 then error("gotta be even length, man") end + local r = {} + for i=1,#src,2 do + table.insert(r, string.char(hextointandinttohex[src:sub(i, i + 1)])) + end + return table.concat(r) +end + +local function isobjhexvalid(objhex) + return #objhex == 32 and not objhex:find("[^0-9a-f]") +end + +local function objhideid(objid) + local t = {} + for i=1,16 do + table.insert(t, objid % 256) + objid = objid // 256 + end + + local c = AES128ECB:encrypt(ObjHideKey, nil, false):final(string.char(table.unpack(t))) + + return b256toreadable(c) +end + +local function objshowid(objhex) + if not isobjhexvalid(objhex) then return nil end + + local c = AES128ECB:decrypt(ObjHideKey, nil, false):final(readabletob256(objhex)) + + local r = 0 + for i=16,1,-1 do + r = r * 256 + c:byte(i) + end + + return r +end + +local function testobjfilename(fname) + if fname:match"^%s+.*$" or fname:match"^%.+$" or fname:match".*%s+$" or fname:match".*%.+$" then return false end + + for k, v in pairs{"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"} do + if fname == v or fname:match("^" .. v .. "%..+") then + return false + end + end + + return not fname:find"[/\\\0-\31%<%>%:%\"%|%?%*]" and #fname <= 56 +end + +local function urltophysical(p) + if p:sub(1, 6) == "/objd/" then + if isobjhexvalid(p:sub(7, 7 + 31)) and testobjfilename(p:sub(7 + 33)) then + return "objd/" .. p:sub(7, 7 + 31):gsub("..", "%0/") .. p:sub(7 + 32) + end + return nil + else + return p:sub(2) + end +end + +-- PERFORMS NO QUOTA CHECKING; THATS DONE IN obje.html.l +local function createfile(objid, fname, fsz) + if not testobjfilename(fname) or fname == ".thumb.jpg" then return false end + if type(fsz) ~= "number" then return false end + + local f = io.open(urltophysical("/objd/" .. objhideid(objid) .. "/" .. fname), "wb") + if not f then return false end + if unistd.ftruncate(stdio.fileno(f), fsz) ~= 0 then return false end + + f:close() + + return true +end + +local function updatefile(objid, fname, offset, data) + if not testobjfilename(fname) or fname == ".thumb.jpg" then return false end + + local fn = urltophysical("/objd/" .. objhideid(objid) .. "/" .. fname) + + if offset + #data > lfs.attributes(fn).size then + return false + end + + local f = io.open(fn, "r+b") + if not f then + return false + end + + if not f:seek("set", offset) then return false end + if not f:write(data) then return false end + + f:close() + + return true +end + +local function remfilefromobj(objid, fname) + if not testobjfilename(fname) then return false end + + if not os.remove(urltophysical("/objd/" .. objhideid(objid) .. "/" .. fname)) then return false end + + return true +end + +local function genobjthumbnail(objid, fname, maxsize) + if not testobjfilename(fname) then return false end + + local objhex = objhideid(objid) + + local srcfn = urltophysical("/objd/" .. objhex .. "/" .. fname) + local tempfn = "/tmp/ikibooruthumb" .. b256toreadable(Rand.bytes(16)) .. ".jpg" + local destfn = urltophysical("/objd/" .. objhex .. "/.thumb.jpg") + + local ret, killa, exitcode = os.execute("magick convert " .. Escapes.shellescape(srcfn) .. " -background white -strip -interlace Plane -sampling-factor 4:2:0 -flatten -define jpeg:extent=100kb " .. Escapes.shellescape(tempfn)) + if not ret or killa ~= "exit" or exitcode ~= 0 then return false end + + if LFS.attributes(tempfn).size > maxsize then + os.remove(tempfn) + return false + end + + local inn = io.open(tempfn, "rb") + local outt = io.open(destfn, "wb") + + if not inn or not outt then + os.remove(tempfn) + return false + end + + outt:write(inn:read"*a") + + outt:close() + inn:close() + + os.remove(tempfn) + + return true +end + +local function isdisplaynamevalid(str) + return not str:find"[\0-\31]" and #str <= 32 +end + +local function isemailvalid(str) + return not str:match"^%s*$" and not str:find"[\0-\31]" and #str <= 64 +end + +local function getuserbyemail(email) + if type(email) ~= "string" then return nil end + + pingdb() + + local cursor = SQLConn:execute("SELECT * FROM users WHERE email = '" .. SQLConn:escape(email) .. "';") + if not cursor then return nil end + + local ret = cursor:fetch({}, "a") + if not ret then return nil end + + ret.id = tonumber(ret.id) + ret.privs = tonumber(ret.privs) + + return ret +end + +local function getuserbyid(userid) + userid = tonumber(userid) + if not userid then return nil end + + pingdb() + + local cursor = SQLConn:execute("SELECT * FROM users WHERE id = " .. userid .. ";") + if not cursor then return nil end + + local ret = cursor:fetch({}, "a") + if not ret then return nil end + + ret.id = tonumber(ret.id) + ret.privs = tonumber(ret.privs) + + return ret +end + +local function reguser(email, displayname, privs) + if not isdisplaynamevalid(displayname) then return false, "displayname" end + + privs = tonumber(privs) + if not privs then return false, "privs" end + + pingdb() + + local cursor = SQLConn:execute("INSERT INTO users (displayname, email, createtime, privs) VALUES ('" .. SQLConn:escape(displayname) .. "', '" .. SQLConn:escape(email) .. "', " .. getnow() .. ", " .. privs ..");") + if not cursor then return false, "db" end + + return tonumber(SQLConn:getlastautoid()) +end + +local function userregverify(str) + local start, sep = str:find"^[^%;]+;" + + -- Malformity check + if not start then return nil end + + local digest = str:sub(1, sep - 1) + local msg = str:sub(sep + 1) + + local thetime = msg:match"t%=(%d+)%;" + + if not thetime then return nil end + + if os.difftime(thetime, os.time()) < 0 then return false end + + if b256toreadable(require"openssl.hmac".new(SessionsKey, "sha256"):final(msg)) ~= digest then + return nil + end + + if not msg:match"^reg%;" then return nil end + + return { em = readabletob256(msg:match"em=([^%;]*)") } +end + +local function userregcode(em) + local t = os.date"*t" + t.day = t.day + 7 --7 day expiry + + local str = "reg;em="..b256toreadable(em) .. ";t=" .. os.time(t) .. ";" + return b256toreadable(require"openssl.hmac".new(SessionsKey, "sha256"):final(str)) .. ";" .. str +end + +local function userauth(user) + local t = os.date"*t" + t.day = t.day + 7 --7 day expiry + + local str = "id=" .. user.id .. ";t=" .. os.time(t) .. ";" + + return b256toreadable(require"openssl.hmac".new(SessionsKey, "sha256"):final(str)) .. ";" .. str +end + +local function userverify(str) + local start, sep = str:find"^[^%;]+;"; + + -- Malformity check + if not start then return nil end + + local digest = str:sub(1, sep - 1) + local msg = str:sub(sep + 1) + + local thetime = msg:match("t%=(%d+)%;") + + -- Malformity check + if not thetime then return nil end + + -- Expiry check + if os.difftime(thetime, os.time()) < 0 then return false end + + if b256toreadable(require"openssl.hmac".new(SessionsKey, "sha256"):final(msg)) ~= digest then + return nil + end + + if not msg:match"^id%=" then return nil end + + return getuserbyid(msg:match"^id%=(%d+)") +end + +local function tagadd(name, category, adultonly) + category = tonumber(category) + if not category then return false end + category = category - 1 + + pingdb() + + return not not SQLConn:execute("INSERT INTO tags (name, category, adultonly) VALUES ('" .. SQLConn:escape(name) .. "', " .. category .. ", " .. (adultonly and 1 or 0) .. ");") +end + +local function regnewobj(ownerid, approved, published) + ownerid = tonumber(ownerid) + if not ownerid then return nil end + + if type(approved) ~= "boolean" then return nil end + if type(published) ~= "boolean" then return nil end + + pingdb() + + local cur = SQLConn:execute("INSERT INTO objects (name, owner, approved, published, createtime) VALUES ('', " .. ownerid .. ", " .. (approved and 1 or 0) .. ", " .. (published and 1 or 0) .. ", " .. getnow() .. ");") + + if not cur then return nil end + + local objid = tonumber(SQLConn:getlastautoid()) + + local objpath = "objd/" + for objpathpart in objhideid(objid):gmatch("..") do + objpath = objpath .. objpathpart .. "/" + LFS.mkdir(objpath) + end + + return objid +end + +local function getownedobjs(ownerid) + ownerid = tonumber(ownerid) + if not ownerid then return nil end + + pingdb() + + local cur = SQLConn:execute("SELECT * FROM objects WHERE owner = " .. ownerid .. " ORDER BY id DESC;") + + if not cur then return {} end + + local ret = {} + while true do + local row = cur:fetch({}, "a") + if not row then break end + row.id = tonumber(row.id) + row.owner = tonumber(row.owner) + table.insert(ret, row) + end + + return ret +end + +local function objupdate(objid, newname, newpublished) + if not isdisplaynamevalid(newname) then return false end + objid = tonumber(objid) + if not objid then return false end + if type(newpublished) ~= "boolean" then return false end + pingdb() + if not SQLConn:execute("UPDATE objects SET name = '" .. SQLConn:escape(newname) .. "', published = " .. (newpublished and 1 or 0) .. " WHERE id = " .. objid .. ";") then return false end + return true +end + +local function updatetags(tagarr, newname, newcategory, newadultonly, del) + for k,v in pairs(tagarr) do + if type(v) ~= "number" then + return false + end + end + + if #tagarr == 0 then return false end + + if newcategory and type(newcategory) ~= "number" then + return false + end + + pingdb() + + if del then + return not not SQLConn:execute("DELETE FROM tags WHERE id IN (" .. table.concat(tagarr, ",") .. ");") + end + + local cols = {"adultonly = " .. (newadultonly and 1 or 0)} + if newname then + table.insert(cols, newname and ("name = '" .. SQLConn:escape(newname) .. "'") or "") + end + if newcategory then + newcategory = newcategory - 1 + table.insert(cols, newcategory and ("category = " .. newcategory) or "") + end + + return not not SQLConn:execute("UPDATE tags SET " .. table.concat(cols, ", ") .. " WHERE id IN (" .. table.concat(tagarr, ",") .. ");") +end + +local function csrf(userid) + local str = table.concat({os.time(), tonumber(userid)}, ";") + return b256toreadable(require"openssl.hmac".new(SessionsKey, "sha256"):final(str)) .. ";" .. str +end + +local function csrfverify(userid, booboo) + if not booboo:find";" then return false end + + local digest = booboo:sub(1, booboo:find";" - 1) + local msg = booboo:sub(booboo:find";" + 1) + + if b256toreadable(require"openssl.hmac".new(SessionsKey, "sha256"):final(msg)) ~= digest then + return false + end + + return tonumber(msg:match";(%d+)$") == userid +end + +local function nexttoapproveobj() + pingdb() + + local cur = SQLConn:execute("SELECT id FROM objects WHERE published = 1 AND approved = 0 ORDER BY id ASC LIMIT 1;") + if not cur then return nil end + return tonumber(cur:fetch()) +end + +local function banuser(uid) + if type(uid) ~= "number" then + return false + end + pingdb() + return not not SQLConn:execute("UPDATE users SET privs = " .. USER_PRIVS_BANNED .. " WHERE id = " .. uid .. ";") +end + +local function approveuser(uid) + if type(uid) ~= "number" then + return false + end + pingdb() + return not not SQLConn:execute("UPDATE users SET privs = " .. USER_PRIVS_APPROVED .. " WHERE id = " .. uid .. " AND privs = " .. USER_PRIVS_UNAPPROVED .. ";") +end + +local function delobj(oid, delfiles) + if type(oid) ~= "number" then + return false + end + + pingdb() + if not SQLConn:execute("DELETE FROM objects WHERE id = " .. oid .. ";") then + return false + end + + if delfiles then + local d = urltophysical("/objd/" .. objhideid(oid)) + for f in LFS.dir(d) do + if f ~= "." and f ~= ".." then + os.remove(d .. f) + end + end + end + + return true +end + +local function approveobj(oid) + if type(oid) ~= "number" then + return false + end + pingdb() + return not not SQLConn:execute("UPDATE objects SET approved = 1 WHERE id = " .. oid .. ";") +end + +local function userinfoupdate(userid, displayname) + if type(userid) ~= "number" then return false end + + if not isdisplaynamevalid(displayname) then return false end + + pingdb() + return not not SQLConn:execute("UPDATE users SET displayname = '" .. SQLConn:escape(displayname) .. "' WHERE id = " .. userid .. ";") +end + +local function getobjtags(objid) + if type(objid) ~= "number" then return nil end + + pingdb() + local cur = SQLConn:execute("SELECT * FROM tags WHERE id IN (SELECT tagid FROM objtag WHERE objid = " .. objid .. ");") + if not cur then return nil end + + local ret = {} + local n = 0 + while true do + local t = cur:fetch({}, "*a") + if not t then break end + + t.id = tonumber(t.id) + t.category = tonumber(t.category) + ret[t.id] = t + n = n + 1 + end + + return ret, n +end + +local function addtagstoobj(objid, taglist) + if type(objid) ~= "number" then return false end + + local temp = {} + for k, v in pairs(taglist) do + if type(v) ~= "number" then return false end + + table.insert(temp, "(" .. objid .. "," .. v .. ")") + end + + pingdb() + return not not SQLConn:execute("INSERT INTO objtag (objid, tagid) VALUES " .. table.concat(temp, ",") .. ";") +end + +local function remtagsfromobj(objid, taglist) + if type(objid) ~= "number" then return false end + + local temp = {} + for k, v in pairs(taglist) do + if type(v) ~= "number" then return false end + + table.insert(temp, "tagid = " .. v) + end + + pingdb() + return not not SQLConn:execute("DELETE FROM objtag WHERE objid = " .. objid .. " AND (" .. table.concat(temp, " OR ") .. ");") +end + +local function getmoderators() + pingdb() + + local cur = SQLConn:execute("SELECT * from users WHERE privs = " .. USER_PRIVS_MOD .. ";") + if not cur then return {} end + + local ret = {} + while true do + local t = cur:fetch({}, "a") + if not t then break end + t.id = tonumber(t.id) + t.privs = tonumber(t.privs) + table.insert(ret, t) + end + + return ret +end + +local function setmodsviaemails(emails) + pingdb() + + if not SQLConn:execute("UPDATE users SET privs = " .. USER_PRIVS_APPROVED .. " WHERE privs = " .. USER_PRIVS_MOD .. ";") then + return false + end + + for k,v in pairs(emails) do + emails[k] = "'" .. SQLConn:escape(v) .. "'" + end + + return not not SQLConn:execute("UPDATE users SET privs = " .. USER_PRIVS_MOD .. " WHERE privs < " .. USER_PRIVS_MOD .. " AND email IN (" .. table.concat(emails, ",") .. ");") +end + +local function getcomments(objid) + if type(objid) ~= "number" then return nil end + + pingdb() + + local cur = SQLConn:execute("SELECT comment.id AS id, comment.content AS content, comment.authorid AS authorid, user.displayname AS authordisplayname, comment.createtime AS createtime FROM comments comment INNER JOIN users user ON comment.authorid = user.id WHERE comment.objid = " .. objid .. " ORDER BY id DESC;") + if not cur then return {} end + + local ret = {} + while true do + local t = cur:fetch({}, "a") + if not t then break end + t.id = tonumber(t.id) + t.authorid = tonumber(t.authorid) + table.insert(ret, t) + end + + return ret +end + +local function postcomment(objid, userid, content) + if #content > BigGlobe.MAX_COMMENT_SIZE then + return false + end + + pingdb() + + return not not SQLConn:execute("INSERT INTO comments (objid, authorid, content, createtime) VALUES (" .. objid .. ", " .. userid .. ", '" .. SQLConn:escape(content) .. "', " .. getnow() .. ")") +end + +local function addreport(reporterid, reporteeid, content) + if type(reporterid) ~= "number" then return false end + if type(reporteeid) ~= "number" then return false end + + pingdb() + + return not not SQLConn:execute("INSERT INTO reports (reporter, reportee, content, createtime, status) VALUES (" .. reporterid .. ", " .. reporteeid .. ", '" .. SQLConn:escape(content) .. "', " .. getnow() .. ", " .. BigGlobe.REPORT_STATUS_OPEN .. ")") +end + +local function getreportcount() + pingdb() + + local cur = SQLConn:execute("SELECT COUNT(*) FROM reports WHERE status = 0;") + if not cur then return nil end + + return cur:fetch() +end + +local function getreports(status, offset) + if type(status) ~= "number" then return nil end + + if type(offset) ~= "number" then offset = 0 end + + pingdb() + + local cur = SQLConn:execute("SELECT * FROM reports WHERE status = " .. status .. " AND id > " .. offset .. " ORDER BY id ASC LIMIT 50;") + if not cur then return {} end + + local ret = {} + while true do + local t = cur:fetch({}, "a") + if not t then break end + t.id = tonumber(t.id) + t.reporter = tonumber(t.reporter) + t.reportee = tonumber(t.reportee) + t.status = tonumber(t.status) + table.insert(ret, t) + end + + return ret +end + +local function setreportstatus(rid, newstatus) + if type(rid) ~= "number" then return false end + if type(newstatus) ~= "number" then return false end + + pingdb() + + return not not SQLConn:execute("UPDATE reports SET status = " .. newstatus .. " WHERE id = " .. rid .. ";") +end + +local function getobjcount() + pingdb() + local cur = SQLConn:execute("SELECT COUNT(*) FROM objects;") + if not cur then return nil end + return cur:fetch() +end + +local function gettagcount() + pingdb() + local cur = SQLConn:execute("SELECT COUNT(*) FROM tags;") + if not cur then return nil end + return cur:fetch() +end + +return { + USER_PRIVS_BANNED = USER_PRIVS_BANNED, + USER_PRIVS_UNAPPROVED = USER_PRIVS_UNAPPROVED, + USER_PRIVS_APPROVED = USER_PRIVS_APPROVED, + USER_PRIVS_MOD = USER_PRIVS_MOD, + USER_PRIVS_ADMIN = USER_PRIVS_ADMIN, + + autocomplete = autocomplete, + searchobjs = searchobjs, + getobj = getobj, + objhideid = objhideid, + objshowid = objshowid, + isobjhexvalid = isobjhexvalid, + urltophysical = urltophysical, + createfile = createfile, + updatefile = updatefile, + remfilefromobj = remfilefromobj, + genobjthumbnail = genobjthumbnail, + isdisplaynamevalid = isdisplaynamevalid, + isemailvalid = isemailvalid, + getuserbyemail = getuserbyemail, + getuserbyid = getuserbyid, + userauth = userauth, + userverify = userverify, + userregcode = userregcode, + userregverify = userregverify, + reguser = reguser, + tagadd = tagadd, + regnewobj = regnewobj, + getownedobjs = getownedobjs, + objupdate = objupdate, + updatetags = updatetags, + b256toreadable = b256toreadable, + readabletob256 = readabletob256, + csrf = csrf, + csrfverify = csrfverify, + nexttoapproveobj = nexttoapproveobj, + banuser = banuser, + approveuser = approveuser, + delobj = delobj, + approveobj = approveobj, + userinfoupdate = userinfoupdate, + getobjtags = getobjtags, + addtagstoobj = addtagstoobj, + remtagsfromobj = remtagsfromobj, + getmoderators = getmoderators, + setmodsviaemails = setmodsviaemails, + getcomments = getcomments, + postcomment = postcomment, + addreport = addreport, + getreportcount = getreportcount, + getreports = getreports, + setreportstatus = setreportstatus, + getobjcount = getobjcount, + gettagcount = gettagcount, +} diff --git a/html.lua b/html.lua new file mode 100644 index 0000000..79e4858 --- /dev/null +++ b/html.lua @@ -0,0 +1,35 @@ +local SocketUrl = require"socket.url" + +local function urlescape(str) + return SocketUrl.escape(str) +end + +local function urlunescape(str) + return SocketUrl.unescape(str) +end + +local function urlspunescape(str) + return urlunescape(str:gsub("%+", "%%20")) +end + +local function htmlescape(str) + return (str:gsub("%&", "&"):gsub("%<", "<"):gsub("%>", ">"):gsub("%\"", """):gsub("%'", "'")) +end + +local function shellescape(str) + return "'" .. str:gsub("'", "'\"'\"'") .. "'" +end + +local function cssescape(str) + return '"' .. str:gsub('"', '\\"'):gsub('\\', '\\\\') .. '"' +end + +return { + urlescape = urlescape, + urlunescape = urlunescape, + urlspunescape = urlspunescape, + htmlescape = htmlescape, + shellescape = shellescape, + patternescape = patternescape, + cssescape = cssescape, +} \ No newline at end of file diff --git a/index.html.l b/index.html.l new file mode 100644 index 0000000..1766d46 --- /dev/null +++ b/index.html.l @@ -0,0 +1,84 @@ +{% title = BigGlobe.cfg.sitename .. " - generic object database inspired by imageboards" %} + +{% function content() %} +
+
+

{{ Escapes.htmlescape(BigGlobe.cfg.sitename) }}

+
+ + + {% if BigGlobe.cfg.enable18plus then %} +
    +
  • + + +
  • + + +
  • + + +
  • +
+ {% end %} + + +
+

Filter by tags...

+ +
+ + + + +

Hosting {{DB.getobjcount()}} objects and {{DB.gettagcount()}} tags.

+ +
+
+
+
+ + + + +{% end %} + +{# base.inc \ No newline at end of file diff --git a/install.lua b/install.lua new file mode 100755 index 0000000..50eea12 --- /dev/null +++ b/install.lua @@ -0,0 +1,297 @@ +#!/usr/bin/env lua5.3 + +print"Ikibooru Installer\n" + +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 installer at this moment." + return + end +end + +if not os.getenv"SUDO_COMMAND" then + print"Please run under sudo or as root." + return +end + +local baad = false + +if not pcall(require, "lfs") then + print"LuaFileSystem not installed." + print"Install manually or using LuaRocks (package name 'luafilesystem')" + print"" + baad = true +end +if not pcall(require, "zlib") or not require"zlib"._COPYRIGHT or not (require"zlib"._COPYRIGHT:match"Maher" or require"zlib"._COPYRIGHT:match"Dionizio") then + print"lua-zlib not installed." + print"Install manually or using LuaRocks (package name 'lua-zlib')" + print"" + baad = true +end +if not pcall(require, "openssl") or require"openssl".VERSION_NUMBER == nil then + print"luaossl not installed." + print"Install manually or using LuaRocks (package name 'luaossl')" + print"" + baad = true +end +if not pcall(require, "posix.fcntl") then + print"LuaPOSIX not installed." + print"Install manually or using LuaRocks (package name 'luaposix')" + print"" + baad = true +end +if not pcall(require, "socket.url") then + print"LuaSocket not installed." + print"Install manually or using LuaRocks (package name 'luasocket')" + print"" + baad = true +end +if not pcall(require, "luasql.mysql") or not require"luasql.mysql".mysql() then + print"luasql-mysql not installed." + print"Install manually or using LuaRocks (package name 'luasql-mysql')" + print"" + baad = true +end +if not pcall(require, "mimetypes") then + print"mimetypes not installed." + print"Install manually or using LuaRocks (package name 'mimetypes')" + print"" + baad = true +end +local magickprefix +if #io.popen"magick convert":read"*a" > 0 then + magickprefix = "magick " +elseif #io.popen"convert":read"*a" > 0 then + magickprefix = "" +else + print"ImageMagick not installed." + print"Install manually or via your system's package manager." + print"" + baad = true +end + +os.execute"adduser --shell /bin/sh --disabled-password ikibooru" + +io.stdout:write"MySQL host: " +local mysqladdr = io.read"*l" +io.stdout:write"MySQL user: " +local mysqluser = io.read"*l" +io.stdout:write"MySQL password: " +local mysqlpass = io.read"*l" + +local SQLConn = require"luasql.mysql".mysql():connect("", mysqluser, mysqlpass, mysqladdr) + +if not SQLConn then + print"Could not connect to MySQL database." + baad = true; +end + +if baad then + return +end + +print"" + +local port +repeat + io.stdout:write"Port to run on (external connections to which must be blocked): " + port = tonumber(io.read"*l") +until tonumber(port) ~= nil +port = tostring(port) + +io.stdout:write"Public URI prefix (e.g. https://foo.bar): " +local domname = io.read"*l" + +print"" + +print"The following parameters are required for the administrator account (user id 0):" +io.stdout:write"E-mail address (must be real): " +local adminusereml = io.read"*l" +io.stdout:write"Display name: " +local adminuserdispname = io.read"*l" + +print"" + +print"Enter member exclusivity:" +print"1. Only admin can invite" +print"2. Mods can invite" +print"3. Any member can invite" +print"4. Public registration; mods must approve" +print"5. Public registration without approval (not recommended)" + +local membexcl +while true do + membexcl = tonumber(io.read"*l") + if membexcl and membexcl % 1 == 0 then + if membexcl >= 1 and membexcl <= 5 then + break + end + end + print"Try again." +end + +print"" + +io.stdout:write"Max number of files per object: " +local maxfilesperobj +while true do + maxfilesperobj = tonumber(io.read"*l") + if maxfilesperobj and maxfilesperobj % 1 == 0 and maxfilesperobj > 0 then + break + end + print"Try again." +end + +io.stdout:write"Max total object size (in kB): " +local maxtotalobjsize +while true do + maxtotalobjsize = tonumber(io.read"*l") + if maxtotalobjsize and maxtotalobjsize % 1 == 0 and maxtotalobjsize > 0 then + break + end + print"Try again." +end + +os.execute"mkdir /home/ikibooru/ikibooru" +os.execute"cp -r ./* /home/ikibooru/ikibooru" +os.execute"mkdir -p /home/ikibooru/ikibooru/objd" +os.execute"rm -r /home/ikibooru/ikibooru/objd/*" +os.execute"rm /home/ikibooru/ikibooru/install.lua" +-- Not /dev/urandom. System might be new and unseeded. +os.execute"dd if=/dev/random of=/home/ikibooru/ikibooru/objkey bs=16 count=1" +os.execute"chown -R ikibooru:ikibooru /home/ikibooru/*" +os.execute"find /home/ikibooru/ -type f -exec chmod 444 {} \;" +os.execute"find /home/ikibooru/ -type d -exec chmod 555 {} \;" +os.execute"chmod 440 /home/ikibooru/ikibooru/objkey" +-- # These files need writability +os.execute"find /home/ikibooru/ikibooru/objd/ -type d -exec chmod 755 {} \;" +os.execute"chmod 644 /home/ikibooru/ikibooru/static/tagcats.css" + +local cfg = io.open("/home/ikibooru/ikibooru/cfg.lua", "wb") +cfg:write(string.format([[-- THIS FILE IS AUTO-GENERATED. EDIT WITH CAUTION. +return {["domain"]=%q,["sqlh"]=%q,["sqlu"]=%q,["sqlp"]=%q,["port"]=%q,["maxfilesperobj"]=%q,["membexcl"]=%q,["maxtotalobjsize"]=%q,["magickprefix"]=%q,["tc"]={[1]={["name"]="",["col"]=14096010,},[2]={["name"]="",["col"]=6616647,},[3]={["name"]="",["col"]=13138224,},[4]={["name"]="",["col"]=13395600,},[5]={["name"]="",["col"]=15294904,},[6]={["name"]="",["col"]=3314361,},[7]={["name"]="",["col"]=5624104,},[8]={["name"]="",["col"]=12888753,},[9]={["name"]="",["col"]=4660286,},[10]={["name"]="",["col"]=9294073,},[11]={["name"]="",["col"]=8009393,},[12]={["name"]="",["col"]=10550703,},[13]={["name"]="",["col"]=6120067,},[14]={["name"]="",["col"]=8613437,},[15]={["name"]="",["col"]=15975763,},[16]={["name"]="",["col"]=15371202,},[17]={["name"]="",["col"]=10665472,},[18]={["name"]="",["col"]=12034245,},[19]={["name"]="",["col"]=2375696,},[20]={["name"]="",["col"]=10183247,},[21]={["name"]="",["col"]=273478,},[22]={["name"]="",["col"]=4074963,},[23]={["name"]="",["col"]=2302363,},[24]={["name"]="",["col"]=13491847,},[25]={["name"]="",["col"]=2628638,},[26]={["name"]="",["col"]=6726730,},[27]={["name"]="",["col"]=2177522,},[28]={["name"]="",["col"]=1825508,},[29]={["name"]="",["col"]=16759172,},[30]={["name"]="",["col"]=3661743,},[31]={["name"]="",["col"]=8605577,},[32]={["name"]="",["col"]=14077967,},["n"]=32,},["smtpauth"]=true,["sitename"]="Ikibooru",["codever"]=0,["dbver"]=0,["ruleset"]="",["sendfile"]="",["sendfileprefix"]="",["enable18plus"]=false}]], domname, mysqladdr, mysqluser, mysqlpass, port, maxfilesperobj, membexcl, maxtotalobjsize, magickprefix)) +cfg:close() cfg = nil + +os.execute"chmod 640 /home/ikibooru/ikibooru/cfg.lua" + +os.execute"chown -R root:root /home/ikibooru/ikibooru/uninstall.lua" + +SQLConn:execute[[CREATE DATABASE ikibooru CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;]] +SQLConn:execute[[USE ikibooru;]] +SQLConn:execute[[CREATE TABLE `tags` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `category` tinyint(3) unsigned NOT NULL, + `adultonly` BIT NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;]] +SQLConn:execute[[CREATE TABLE `users` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `displayname` varchar(255) NOT NULL, + `email` varchar(255) NOT NULL, + `createtime` datetime NOT NULL, + `privs` tinyint(4) unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;]] +SQLConn:execute[[CREATE TABLE `objects` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `owner` bigint(20) unsigned NULL, + `approved` bit(1) NOT NULL, + `published` bit(1) NOT NULL, + `createtime` datetime NOT NULL, + PRIMARY KEY (`id`), + FULLTEXT KEY `objects_name_IDX` (`name`), + KEY `objects_FK_owner` (`owner`), + CONSTRAINT `objects_FK_owner` FOREIGN KEY (`owner`) REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;]] +SQLConn:execute[[CREATE TABLE `comments` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `objid` bigint(20) unsigned NOT NULL, + `authorid` bigint(20) unsigned NOT NULL, + `createtime` datetime NOT NULL, + `content` text NOT NULL, + PRIMARY KEY (`id`), + KEY `comments_FK_objid` (`objid`), + KEY `comments_FK_authorid` (`authorid`), + CONSTRAINT `comments_FK_objid` FOREIGN KEY (`objid`) REFERENCES `objects` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `comments_FK_authorid` FOREIGN KEY (`authorid`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;]] +SQLConn:execute[[CREATE TABLE `objtag` ( + `objid` bigint(20) unsigned NOT NULL, + `tagid` bigint(20) unsigned NOT NULL, + PRIMARY KEY (`objid`,`tagid`), + KEY `objtag_FK_objid` (`objid`), + KEY `objtag_FK_tagid` (`tagid`), + CONSTRAINT `objtag_FK_objid` FOREIGN KEY (`objid`) REFERENCES `objects` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `objtag_FK_tagid` FOREIGN KEY (`tagid`) REFERENCES `tags` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;]] +SQLConn:execute[[CREATE TABLE `reports` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `reporter` bigint(20) unsigned NOT NULL, + `reportee` bigint(20) unsigned NOT NULL, + `createtime` datetime NOT NULL, + `content` text DEFAULT NULL, + `status` tinyint(4) NOT NULL, + PRIMARY KEY (`id`), + KEY `reports_FK_reporter` (`reporter`), + KEY `reports_FK_reportee` (`reportee`), + CONSTRAINT `reports_FK_reportee` FOREIGN KEY (`reportee`) REFERENCES `users` (`id`), + CONSTRAINT `reports_FK_reporter` FOREIGN KEY (`reporter`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;]] + +SQLConn:execute(string.format([[INSERT INTO ikibooru.users (displayname, email, createtime, privs) VALUES ("%s", "%s", "%s", 255);]], SQLConn:escape(adminuserdispname), SQLConn:escape(adminusereml), os.date("!%Y-%m-%d %H:%M:%S"))) + +if pcall(io.popen, "systemd --version") then + local systemd + while true do + io.stdout:write"Register systemd service (y/n): " + local o = io.read"*l":lower() + if o == "y" then + systemd = true + break + elseif o == "n" then + systemd = false + break + end + print"Try again." + end + + if systemd then + local external = false + while true do + io.stdout:write"Is MySQL server external (y/n): " + local o = io.read"*l":lower() + if o == "y" then + external = true + break + elseif o == "n" then + external = false + break + end + print"Try again." + end + + local service = io.open("/etc/systemd/system/ikibooru.service", "wb") + service:write(string.format([[[Unit] +Description=Ikibooru fileboard +%s + +[Service] +Type=simple +Restart=on-failure +ExecStart=lua5.3 main.lua +WorkingDirectory=/home/ikibooru/ikibooru +User=ikibooru +Group=ikibooru]], external and "" or "After=mysql.target")) + service:close() + + os.execute"systemctl daemon-reload" + print"Done. Service can be started via 'systemctl start ikibooru'." + end +end + +print"" + +print"Installation complete. Remember: Ikibooru is only an HTTP server. It must be used together with a relay or reverse proxy." \ No newline at end of file diff --git a/invite.html.l b/invite.html.l new file mode 100644 index 0000000..1c3d6da --- /dev/null +++ b/invite.html.l @@ -0,0 +1,39 @@ +{% + local worked = false + + if verified and request.querystring.csrf and DB.csrfverify(verified.id, Escapes.urlunescape(request.querystring.csrf)) and request.querystring.z and DB.isemailvalid(Escapes.urlunescape(request.querystring.z)) then + local em = Escapes.urlspunescape(request.querystring.z) + local u = DB.getuserbyemail(em) + + -- When done this way, a member can't tell if another is already registered. + worked = true + + if not u and ( + (BigGlobe.cfg.membexcl == BigGlobe.MEMBEXCL_MEMBERS_INVITE and verified.privs >= DB.USER_PRIVS_APPROVED) + or (BigGlobe.cfg.membexcl == BigGlobe.MEMBEXCL_MODS_INVITE and verified.privs >= DB.USER_PRIVS_MOD) + or (BigGlobe.cfg.membexcl == BigGlobe.MEMBEXCL_ADMIN_INVITES and verified.privs >= DB.USER_PRIVS_ADMIN)) then + + -- Invitation + SMTPAuth.sendregisterinfo(em) + end + end + + title = BigGlobe.cfg.sitename .. " - Invitation" +%} + +{% function content() %} + {% if worked then %} +

Invite sent.

+ {% elseif verified then %} +
+

Use this form to invite anyone to the platform.

+ + + + + +
+ {% 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 %} +
+

{{ Escapes.htmlescape(obj.name) }}{% if verified and verified.id == obj.owner then %} (edit){% end %}

+
{{ Escapes.htmlescape(owner.displayname) }} {{ obj.createtime }}
+

Object has {{#files-1}} files:

+ +
+ {% for _,tag in pairs(DB.getobjtags(obj.id)) do %} +
{{ Escapes.htmlescape(tag.name) }}
+ {% end %} +
+ {% if verified and verified.privs >= DB.USER_PRIVS_APPROVED then %} +

(report)

+ {% end %} +
+
+
+ +
+

+
+ + {% if verified and verified.privs >= DB.USER_PRIVS_MOD then %} + {% local csrfval = Escapes.htmlescape(DB.csrf(verified.id)) %} + + {% if owner.privs < verified.privs then %} +
+
+

Delete object?

+ + +

+

+

+ +
+
+ {% end %} + + {% if not obj.approved then %} +
+
+

Approve object?

+ + +

+

+ +
+
+ {% end %} + {% end %} + +
+ {% for _, c in pairs(DB.getcomments(obj.id)) do %} +
+
{{ Escapes.htmlescape(c.authordisplayname) }} {{ c.createtime }}
+

{{ Escapes.htmlescape(c.content):gsub("\n\n", "

") }}

+ (report) +
+ {% end %} + + {% if verified then %} +
+ + + +
+ {% end %} +
+ + + {% else %} +

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 %} +
+
+

Editing object

+ + +
+ +

Once files are uploaded, the object will be considered published.

+ +

Object sized at {{ (usedSpace + 1023) // 1024 }}kB out of {{ (obj.sizequota + 1023) // 1024 }}kB maximum ({{math.floor(usedSpace / obj.sizequota * 1000 + 0.5) / 10}}%)

+ + + {% for idx,f in pairs(files) do %} + + + + + + {% end %} + +
FileSizeActions
{{ Escapes.htmlescape(f.name) }}{{ (f.size + 1023) // 1024 }}kB
+ + +
+
+

Tags

+
+ 0 then %} style="visibility:hidden;"{% end %}>Enter tags...

+ {% for _, tag in pairs(tags) do %} +
{{ Escapes.htmlescape(tag.name) }}
+ {% end %} + +
+ + + + {% if BigGlobe.cfg.enable18plus then %} + + {% end %} +
+
+ + +
+
+ + + + {% 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 response + + +

Error response

+

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 %} +

Registration

+ +
{{ BigGlobe.cfg.ruleset }}
+ +

{{ 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 %} +

View: Open | Fixed | Wontfix

+ {% local reports = DB.getreports(queryStatus, offset) %} + {% local csrfval = DB.csrf(verified.id) %} + + {% if #reports == 0 then %} +

No results.

+ {% end %} + + {% for _, report in pairs(reports) do %} + + + + + {% end %} +
+

+ 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?

+
+ + +
+
+
+ +
+
+ {% if #reports == 50 then %} +

Next

+ {% end %} + {% else %} +

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 %} +
+ + +

Reporting user "{{ Escapes.htmlescape(reportee.displayname) }}"

+
{{ BigGlobe.cfg.ruleset }}
+ + + +
+ {% 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() %} +
+ {% local found = DB.searchobjs(tagids, namefilter, offset, adultcontent) %} + {% if #found == 0 then %} +

No results.

+ {% else %} + {% for i=#found,1,-1 do local o = found[i] %} + {% local pub = DB.objhideid(o.id) %} +
+ +

{{ Escapes.htmlescape(o.name) }}

+
+ {% end %} + + {% if #found >= 50 then %} +
+ + + + +
+ {% end %} + {% end %} +
+{% end %} + +{# base.inc diff --git a/smtpauth.lua b/smtpauth.lua new file mode 100644 index 0000000..d77983f --- /dev/null +++ b/smtpauth.lua @@ -0,0 +1,61 @@ +local DB = require"db" +local Escapes = require"html" + +local BigGlobe = require"bigglobe" + +local Rand = require"openssl.rand" +assert(Rand.ready()) + +local DB = require"db" + +local function sendeml(raw) + --[[local fn = "/tmp/ikibooru" .. DB.b256toreadable(Rand.bytes(16)) .. ".eml" + local f = io.open(fn, "wb") + f:write(raw) + f:close() + + -- Send e-mail. Yes, this is crude. + io.popen("{ sendmail -t < " .. fn .. "; rm " .. fn .. "; } &", "r")]] +end + +return { + sendauthinfo = function(user) + local url = BigGlobe.cfg.domain .."/verif?q=" .. Escapes.urlescape(DB.userauth(user)) + +-- print(url) + + if BigGlobe.cfg.anarchy == "ANARCHY" then + return url + else + sendeml(string.format([[To: %s +Subject: %s sign-in via e-mail +Content-Type: text/html; charset=UTF-8 +MIME-Version: 1.0 + + +

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.

Sign-in
]], user.email, BigGlobe.cfg.sitename, BigGlobe.cfg.sitename, url)) + + return true + end + end, + + sendregisterinfo = function(user) + local url = BigGlobe.cfg.domain .."/reg?q=" .. Escapes.urlescape(DB.userregcode(user)) + + print(url) + + if BigGlobe.cfg.anarchy == "ANARCHY" then + return url + else + sendeml(string.format([[To: %s +Subject: %s registration via e-mail +Content-Type: text/html; charset=UTF-8 +MIME-Version: 1.0 + + +

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.

Complete
]], user.email, BigGlobe.cfg.sitename, BigGlobe.cfg.sitename, url)) + + return true + end + end +} \ No newline at end of file diff --git a/static/datetimes.js b/static/datetimes.js new file mode 100644 index 0000000..9153db8 --- /dev/null +++ b/static/datetimes.js @@ -0,0 +1,6 @@ +var z = document.querySelectorAll(".dt") +var off = new Date().getTimezoneOffset() +for(var i = 0, m = z.length; i < m; i++) { + var t = z[i].innerText.match(/\d+/g) + z[i].innerText = new Date(Date.UTC(+t[0], +t[1] - 1, +t[2], +t[3], +t[4], +t[5])).toLocaleString() +} \ No newline at end of file diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000..ca72c08 Binary files /dev/null and b/static/favicon.png differ diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000..e704351 Binary files /dev/null and b/static/logo.png differ 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

+ + {% if verified and verified.id == user.id then %} + + + {% end %} +
+ {% if verified and verified.id == user.id then %} +
+
+ + +

Profile Settings

+
+

Rename

+ +
+
+ +
+
+
+ {% end %} + {% else %} +

User not found.

+ {% end %} +{% end %} + +{# base.inc \ No newline at end of file