Initial commit
This commit is contained in:
commit
f4b0a0b531
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
objd/
|
||||
objkey
|
||||
cfg.lua
|
7
404.html.l
Normal file
7
404.html.l
Normal file
@ -0,0 +1,7 @@
|
||||
{% title = BigGlobe.cfg.sitename .. " - 404" %}
|
||||
|
||||
{% function content() %}
|
||||
<p>This page doesn't exist.</p>
|
||||
{% end %}
|
||||
|
||||
{# base.inc
|
56
README
Normal file
56
README
Normal file
@ -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.
|
157
admin.html.l
Normal file
157
admin.html.l
Normal file
@ -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 %}
|
||||
<form method="POST" action="/admin" id="adminform">
|
||||
<input type="hidden" name="csrf" value="{{ Escapes.htmlescape(DB.csrf(verified.id)) }}" />
|
||||
|
||||
<h1>{{ title }} <input type="submit" value="Submit" style="vertical-align:middle;" /></h1>
|
||||
<div style="display:inline-block;width:60%;vertical-align:top;">
|
||||
<h2>Basic Site Settings</h2>
|
||||
<p style="margin-bottom:0;">Site Name</p>
|
||||
<input type="text" name="sitename" autocomplete="off" value="{{ Escapes.htmlescape(BigGlobe.cfg.sitename) }}" style="display:block;margin-bottom:1em;" />
|
||||
|
||||
<input id="enable18plusparam" type="checkbox" name="enable18plus" {% if BigGlobe.cfg.enable18plus then %}checked {% end %}/>
|
||||
<label for="enable18plusparam">Enable 18+</label>
|
||||
|
||||
<h2>New Tag</h2>
|
||||
<p style="margin-bottom:0;">Set category ID</p>
|
||||
<input type="number" name="ntc" min="1" max="{{ BigGlobe.cfg.tc.n }}" />
|
||||
<p style="margin-bottom:0;">Set name</p>
|
||||
<input type="text" name="ntn" autocomplete="off" />
|
||||
|
||||
<div style="margin-top:1em;"><input type="checkbox" name="nta" id="ntaparam" /><label for="ntaparam">18+</label></div>
|
||||
|
||||
<h2>Edit Tag</h2>
|
||||
<div data-over18="1" style="position:relative;width:30%;" class="tagbox" data-formid="adminform" data-formparaminputid="ettagsparam">
|
||||
<p>Tags to edit...</p>
|
||||
<span style="position:relative;min-width:4px;left:0;" contenteditable></span>
|
||||
</div>
|
||||
<div class="autocomplete" style="visibility:hidden;width:30%;"></div>
|
||||
<input type="hidden" id="ettagsparam" name="ettags" value="" />
|
||||
<input type="number" name="etcat" min="1" max="{{ BigGlobe.cfg.tc.n }}" placeholder="Set category..." style="display:block;margin-bottom:1em;" />
|
||||
<input type="text" name="etname" autocomplete="off" placeholder="Set name..." style="display:block;margin-bottom:1em;" />
|
||||
<div style="margin-bottom:1em;"><input type="checkbox" name="eta" id="etaparam" /><label for="etaparam">18+</label></div>
|
||||
<input type="checkbox" name="etdel" id="etdelbox" /><label for="etdelbox">Delete</label>
|
||||
|
||||
<h2>Moderators</h2>
|
||||
<p>Enter a comma-separated list of e-mails that shall be granted moderator permissions.</p>
|
||||
{% local mods = DB.getmoderators() %}
|
||||
<textarea name="mods" style="width:80%;height:10em;">{% for _, m in pairs(mods) do %}{{ Escapes.htmlescape(m.email) }},{% end %}</textarea>
|
||||
|
||||
<h2>Ruleset</h2>
|
||||
<p>The following HTML <strong>(!)</strong> 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.</p>
|
||||
<textarea name="ruleset" class="uf" style="width:80%;"></textarea>
|
||||
|
||||
<h2>File download acceleration</h2>
|
||||
<p>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).</p>
|
||||
<input type="text" name="sendfilehdr" autocomplete="off" placeholder="HTTP Header" value="{{ Escapes.htmlescape(BigGlobe.cfg.sendfile) }}" style="display:block;margin-bottom:1em;" />
|
||||
<input type="text" name="sendfileprefix" autocomplete="off" placeholder="Path Prefix" value="{{ Escapes.htmlescape(BigGlobe.cfg.sendfileprefix) }}" style="display:block;margin-bottom:1em;" />
|
||||
</div>
|
||||
<div style="display:inline-block;width:30%">
|
||||
<h2>Tag Categories</h2>
|
||||
<p>These exist for ease within searching.</p>
|
||||
<table>
|
||||
<tr><td>Id</td><td>Name</td><td>Color</td></tr>
|
||||
{% for i=1,BigGlobe.cfg.tc.n do %}
|
||||
<tr>
|
||||
<td>{{ i }}</td>
|
||||
<td><input type="text" name="tcn{{ i }}" value="{{ Escapes.htmlescape(BigGlobe.cfg.tc[i].name) }}" autocomplete="off" /></td>
|
||||
<td><input type="color" name="tcc{{ i }}" value="#{{ string.format("%06x", BigGlobe.cfg.tc[i].col) }}" /></td>
|
||||
</tr>
|
||||
{% end %}
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script src="/static/tagbox.js"></script>
|
||||
{% else %}
|
||||
<p>You are not authorized to view this page.</p>
|
||||
{% end %}
|
||||
{% end %}
|
||||
|
||||
{# base.inc
|
56
base.inc
Normal file
56
base.inc
Normal file
@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="notranslate" translate="no" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="google" content="notranslate" />
|
||||
<title>{{ Escapes.htmlescape(title) }}</title>
|
||||
<link rel="stylesheet" href="/static/style.css" type="text/css" />
|
||||
<link rel="stylesheet" href="/static/tagcats.css" type="text/css" />
|
||||
<link rel="stylesheet" href="/static/user.css" type="text/css" />
|
||||
<link rel="icon" type="image/png" href="{{ Escapes.htmlescape(BigGlobe.cfg.domain .. "/static/favicon.png") }}" />
|
||||
<meta property="og:title" content="{{ Escapes.htmlescape(title) }}" />
|
||||
<meta property="og:image" content="{{ Escapes.htmlescape(ogImage and ogImage or (BigGlobe.cfg.domain .. "/static/logo.png")) }}" />
|
||||
<meta property="og:type" content="website" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<p style="position:absolute;left:1em;margin:0;"><a href="/">{{ Escapes.htmlescape(BigGlobe.cfg.sitename) }}</a></p>
|
||||
{% if verified and verified.privs >= DB.USER_PRIVS_ADMIN then %}
|
||||
<p style="position:absolute;left:1em;top:1em;margin:0;"><a href="/admin">Admin Settings</a></p>
|
||||
{% end %}
|
||||
{% if verified and verified.privs >= DB.USER_PRIVS_MOD then %}
|
||||
<p style="position:absolute;left:1em;top:2em;margin:0;"><a href="/apprq">Approval Queue</a></p>
|
||||
<p style="position:absolute;left:1em;top:3em;margin:0;"><a href="/reports">Reports</a></p>
|
||||
{% end %}
|
||||
<div style="position:absolute;right:1em;margin:0;">
|
||||
{% if verified then %}
|
||||
<label style="margin:0;">{{ Escapes.htmlescape(verified.displayname) }}</label>
|
||||
<form method="GET" action="/verif" style="display:inline-block;">
|
||||
<input type="hidden" name="q" value="" />
|
||||
<input type="submit" value="Log out" />
|
||||
</form>
|
||||
<div><a href="/user/{{ verified.id }}">My profile</a></div>
|
||||
{% 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 %}
|
||||
|
||||
<div><a href="/invite">Invite</a></div>
|
||||
{% end %}
|
||||
{% else %}
|
||||
<form method="GET" action="/login">
|
||||
<input type="{{BigGlobe.cfg.anarchy == "ANARCHY" and "text" or "email"}}" name="z" required style="margin-bottom:0.5em;" />
|
||||
<input type="submit" value="Log in" /><br />
|
||||
<input type="radio" name="type" id="logemail" value="email" checked style="margin-left:0;" /><label for="logemail">E-mail</label>
|
||||
<!--<input type="radio" name="type" id="logxmpp" value="xmpp" required /><label for="logxmpp">Jabber</label>-->
|
||||
</form>
|
||||
{% end %}
|
||||
</div>
|
||||
</header>
|
||||
<main>{% content() %}</main>
|
||||
<footer>
|
||||
<p>Running Ikibooru v0.1</p>
|
||||
</footer>
|
||||
<script defer src="/static/datetimes.js"></script>
|
||||
</body>
|
||||
</html>
|
70
bigglobe.lua
Normal file
70
bigglobe.lua
Normal file
@ -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
|
||||
}
|
186
core.lua
Normal file
186
core.lua
Normal file
@ -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
|
822
db.lua
Normal file
822
db.lua
Normal file
@ -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,
|
||||
}
|
35
html.lua
Normal file
35
html.lua
Normal file
@ -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,
|
||||
}
|
84
index.html.l
Normal file
84
index.html.l
Normal file
@ -0,0 +1,84 @@
|
||||
{% title = BigGlobe.cfg.sitename .. " - generic object database inspired by imageboards" %}
|
||||
|
||||
{% function content() %}
|
||||
<div style="text-align:center;height:50%;margin-top:25%;">
|
||||
<div style="max-width:15cm;display:inline-block;">
|
||||
<h1>{{ Escapes.htmlescape(BigGlobe.cfg.sitename) }}</h1>
|
||||
<form action="/search" method="GET" id="searchform">
|
||||
<input type="text" name="n" placeholder="Filter by name..." style="position:relative;width:100%;" />
|
||||
|
||||
{% if BigGlobe.cfg.enable18plus then %}
|
||||
<ul class="over18">
|
||||
<li>
|
||||
<input type="radio" name="a" value="-1" id="o18h" onchange="upd(-1)" checked />
|
||||
<label for="o18h">Hide 18+</label>
|
||||
</li><li>
|
||||
<input type="radio" name="a" value="0" id="o18s" onchange="upd(0)" />
|
||||
<label for="o18s">Show 18+</label>
|
||||
</li><li>
|
||||
<input type="radio" name="a" value="1" id="o18o" onchange="upd(1)" />
|
||||
<label for="o18o">Only 18+</label>
|
||||
</li>
|
||||
</ul>
|
||||
{% end %}
|
||||
|
||||
<input type="hidden" id="tparam" name="t" value="" />
|
||||
<div data-formid="searchform" data-formparaminputid="tparam" style="position:relative;width:100%;margin-top:1em;" class="tagbox">
|
||||
<p>Filter by tags...</p>
|
||||
<span style="position:relative;min-width:4px;left:0;" contenteditable></span>
|
||||
</div>
|
||||
<div class="autocomplete" style="visibility:hidden;width:100%;"></div>
|
||||
|
||||
<input type="submit" value="Search" style="margin-top:1em;" />
|
||||
|
||||
<p>Hosting <a href="/search?n=&a=0&t=&hpl">{{DB.getobjcount()}} objects</a> and <a href="#" onclick="showalltags()">{{DB.gettagcount()}} tags</a>.</p>
|
||||
|
||||
<div id="alltags"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script defer src="/static/tagbox.js"></script>
|
||||
<script defer>
|
||||
{% if BigGlobe.cfg.enable18plus then %}
|
||||
function upd(over18) {
|
||||
document.querySelector(".tagbox").setAttribute("data-over18", +over18 != -1)
|
||||
window.localStorage.setItem("o18", over18)
|
||||
}
|
||||
var activeo18 = "-1"
|
||||
if(window.localStorage.getItem("o18")) {
|
||||
activeo18 = window.localStorage.getItem("o18")
|
||||
}
|
||||
upd(activeo18)
|
||||
document.querySelector("ul.over18 input[type=\"radio\"][value=\"" + activeo18 + "\"]").checked = true
|
||||
{% end %}
|
||||
|
||||
function showalltags() {
|
||||
var ajax = new XMLHttpRequest()
|
||||
ajax.open("GET", "/autocomp?q=" + (document.querySelector("div.tagbox").getAttribute("data-over18") == "true" ? "&a=" : ""), true)
|
||||
ajax.onreadystatechange = function() {
|
||||
if(ajax.readyState == 4 && ajax.status == 200) {
|
||||
var box = document.getElementById("alltags")
|
||||
var datags = []
|
||||
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.setAttribute("data-tc", line.split(",")[2])
|
||||
newtag.innerText = line.split(",")[1]
|
||||
datags.push(newtag)
|
||||
})
|
||||
datags.sort(function(a, b) {return a.getAttribute("data-tc") - b.getAttribute("data-tc")})
|
||||
datags.forEach(function(x) { box.insertBefore(x, null) })
|
||||
}
|
||||