Initial commit

This commit is contained in:
mid 2024-06-01 17:40:11 +03:00
commit f4b0a0b531
36 changed files with 4027 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
objd/
objkey
cfg.lua

7
404.html.l Normal file
View 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
View 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
View 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 &lt;ol&gt; 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
View 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
View 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
View 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
View 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
View 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("%&", "&amp;"):gsub("%<", "&lt;"):gsub("%>", "&gt;"):gsub("%\"", "&quot;"):gsub("%'", "&#x27;"))
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
View 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) })
}
}
ajax.send()
showalltags = function() {}
}
</script>
{% end %}
{# base.inc

297
install.lua Executable file
View File

@ -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."

39
invite.html.l Normal file
View File

@ -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 %}
<p>Invite sent.</p>
{% elseif verified then %}
<form action="/invite" method="GET">
<p>Use this form to invite anyone to the platform.</p>
<input type="hidden" name="csrf" value="{{ Escapes.htmlescape(DB.csrf(verified.id)) }}" />
<input type="email" name="z" placeholder="E-mail" autocomplete="off" />
<input type="submit" value="Invite" />
</form>
{% end %}
{% end %}
{# base.inc

45
login.html.l Normal file
View File

@ -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 %}
<p>In anarchy mode, anyone can be anything. Please wait..</p>
<p>If that doesn't work, <a href="{{ Escapes.htmlescape(zzz) }}">click here</a>.</p>
{% else %}
{% if worked then %}
<p>Link has been sent to {{ em and Escapes.htmlescape(em) }}. This page may be closed.</p>
{% else %}
<p>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 %}</p>
{% end %}
{% end %}
{% end %}
{# base.inc

82
lyre.lua Normal file
View File

@ -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
}

15
main.lua Normal file
View File

@ -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")

162
obj.html.l Normal file
View File

@ -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 %}
<div style="display:inline-block;width:40%;vertical-align:top;">
<h2 style="margin-bottom:0;">{{ Escapes.htmlescape(obj.name) }}{% if verified and verified.id == obj.owner then %} <a href="/obje/{{ DB.objhideid(obj.id) }}">(edit)</a>{% end %}</h2>
<h5 style="margin-top:0;"><sup><a href="/user/{{ obj.owner }}">{{ Escapes.htmlescape(owner.displayname) }}</a> <span class="dt">{{ obj.createtime }}</span></sup></h5>
<p>Object has {{#files-1}} files:</p>
<ul id="filelist">
{% for _,f in pairs(files) do %}
{% if f.name ~= ".thumb.jpg" then %}<li><a onmouseenter="hover(this);" download href="{{ Escapes.htmlescape(f.virt) }}">{{ Escapes.htmlescape(f.name) }}</a> ({{ (f.size + 1023) // 1024 }}kB)</li>{% end %}
{% end %}
</ul>
<div>
{% for _,tag in pairs(DB.getobjtags(obj.id)) do %}
<div class="tag tc{{ tag.category }}" data-tagid="{{ tag.id }}">{{ Escapes.htmlescape(tag.name) }}</div>
{% end %}
</div>
{% if verified and verified.privs >= DB.USER_PRIVS_APPROVED then %}
<p><a href="/reportuser/{{ obj.owner }}" class="reportlink">(report)</a></p>
{% end %}
</div>
<div style="display:inline-block;width:58%;height:0;padding-bottom:58%;vertical-align:top;text-align:center;">
<div id="previewThings">
<img id="previewImg" src="" style="display:none;width:80%;margin-left:5%;" />
<audio id="previewAudio" controls style="display:none;width:80%;margin-left:5%;margin-top: 5em;"/>
</div>
<p id="previewName"></p>
</div>
{% if verified and verified.privs >= DB.USER_PRIVS_MOD then %}
{% local csrfval = Escapes.htmlescape(DB.csrf(verified.id)) %}
{% if owner.privs < verified.privs then %}
<div style="padding:1em;display:inline-block;text-align:left;vertical-align:top;background-color:#FFE0E0;">
<form action="/apprq" method="GET">
<p>Delete object?</p>
<input type="hidden" name="csrf" value="{{ csrfval }}" />
<input type="hidden" name="oid" value="{{ DB.objhideid(obj.id) }}" />
<input type="checkbox" name="banuser" id="banuserparam" /><label for="banuserparam">And ban the user</label><br /><br />
<input type="checkbox" name="delfiles" id="delfilesparam" /><label for="delfilesparam">And delete the files</label><br /><br />
<input type="checkbox" name="deliamsure" id="deliamsureparam" /><label for="deliamsureparam">I am sure</label><br /><br />
<input type="submit" value="Go" />
</form>
</div>
{% end %}
{% if not obj.approved then %}
<div style="padding:1em;float:right;text-align:right;vertical-align:top;background-color:#E0FFE0;">
<form action="/apprq" method="GET">
<p>Approve object?</p>
<input type="hidden" name="csrf" value="{{ csrfval }}" />
<input type="hidden" name="oid" value="{{ DB.objhideid(obj.id) }}" />
<input type="checkbox" name="approveuser" id="approveuserparam" /><label for="approveuserparam">And approve the user</label><br /><br />
<input type="checkbox" name="approveiamsure" id="approveiamsureparam" /><label for="approveiamsureparam">I am sure</label><br /><br />
<input type="submit" value="Go" />
</form>
</div>
{% end %}
{% end %}
<div class="comments">
{% for _, c in pairs(DB.getcomments(obj.id)) do %}
<div>
<h5><a href="/user/{{ c.authorid }}">{{ Escapes.htmlescape(c.authordisplayname) }}</a> <sub class="dt">{{ c.createtime }}</sub></h5>
<div><p>{{ Escapes.htmlescape(c.content):gsub("\n\n", "</p><p>") }}</p></div>
<a href="/reportuser/{{ c.authorid }}" class="reportlink">(report)</a>
</div>
{% end %}
{% if verified then %}
<form action="#" method="POST" style="margin-top:2em;">
<input type="hidden" name="csrf" value="{{ Escapes.htmlescape(DB.csrf(verified.id)) }}" />
<textarea class="uf" placeholder="Comment (max {{BigGlobe.MAX_COMMENT_SIZE}})..." name="comment"></textarea>
<button>Post</button>
</form>
{% end %}
</div>
<script>
var lasthover = null
var autohov = document.querySelector("ul#filelist > *")
document.getElementById("previewAudio").onloadstart = function() {
document.getElementById("previewAudio").style.display = ""
document.getElementById("previewImg").style.display = "none"
}
function hover(el) {
if(lasthover == el) return;
lasthover = el
var xhr = new XMLHttpRequest()
xhr.open("HEAD", el.getAttribute("href"), true)
xhr.responseType = "blob"
xhr.onload = function() {
var typ = xhr.getResponseHeader("Content-Type")
if(typ.startsWith("image")) {
var img = new Image()
var name = el.innerText
img.onload = function() {
document.getElementById("previewImg").style.display = ""
document.getElementById("previewAudio").style.display = "none"
document.getElementById("previewImg").setAttribute("src", img.src)
document.getElementById("previewName").innerText = name
}
img.src = el.getAttribute("href")
} else if(typ.startsWith("audio")) {
document.getElementById("previewAudio").setAttribute("src", el.getAttribute("href"))
document.getElementById("previewAudio").load()
document.getElementById("previewName").innerText = el.innerText
}
}
xhr.send()
}
hover(autohov.querySelector("a"))
</script>
{% else %}
<p>Object was not found.</p>
{% end %}
{% end %}
{# base.inc

351
obje.html.l Normal file
View File

@ -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 %}
<form method="post" enctype="multipart/form-data" action="#" id="editform">
<div style="display:inline-block;vertical-align:top;width:49%;">
<h2>Editing object</h2>
<input type="hidden" name="csrf" value="{{ Escapes.htmlescape(DB.csrf(verified.id)) }}" />
<div><label>Name: </label><input type="text" name="objname" autocomplete="off" value="{{ Escapes.htmlescape(obj.name) }}" /></div>
<div><p>Once files are uploaded, the object will be considered published.</p></div>
<div><p>Object sized at <strong>{{ (usedSpace + 1023) // 1024 }}kB</strong> out of <strong>{{ (obj.sizequota + 1023) // 1024 }}kB</strong> maximum (<strong>{{math.floor(usedSpace / obj.sizequota * 1000 + 0.5) / 10}}%</strong>) </p></div>
<table style="margin-top:1em"><thead><tr><td>File</td><td>Size</td><td colspan="3">Actions</td></tr></thead><tbody>
{% for idx,f in pairs(files) do %}
<tr>
<td>{{ Escapes.htmlescape(f.name) }}</td>
<td>{{ (f.size + 1023) // 1024 }}kB</td>
<td><input type="checkbox" id="delcb{{ idx }}"><label for="delcb{{ idx }}">Delete</label></td>
</tr>
{% end %}
<tr style="{% if #files >= BigGlobe.cfg.maxfilesperobj then %}display:none;{% end %}"><td><input type="file" /><button type="button" style="margin:0;" onclick="clearfile(this)">Clear</button></td><td></td><td></td></tr>
</tbody></table>
<select name="newthumbnail" style="margin-top: 1em; margin-bottom: 1em; display: block;">
<option value="none" selected>Generate thumbnail from image file...</option>
{% for idx,f in pairs(files) do %}
<option value="{{idx}}">{{ Escapes.htmlescape(f.name) }}</option>
{% end %}
</select>
</div>
<div style="display:inline-block;vertical-align:top;width:49%;">
<h2>Tags</h2>
<div style="position:relative;width:80%;" class="tagbox" data-formid="editform" data-formparaminputid="objtagsparam">
<p{% if tagcount > 0 then %} style="visibility:hidden;"{% end %}>Enter tags...</p>
{% for _, tag in pairs(tags) do %}
<div class="tag tc{{ tag.category }}" data-tagid="{{ tag.id }}">{{ Escapes.htmlescape(tag.name) }}</div>
{% end %}
<span style="position:relative;min-width:4px;left:0;" contenteditable></span>
</div>
<div class="autocomplete" style="visibility:hidden;width:80%;"></div>
<input type="hidden" id="objtagsparam" name="objtags" value="" />
{% if BigGlobe.cfg.enable18plus then %}
<ul class="over18" style="width: 80%;">
<li>
<input type="radio" name="a" value="-1" id="o18h" onchange="upd(false)" />
<label for="o18h">Hide 18+</label>
</li><li>
<input type="radio" name="a" value="0" id="o18s" onchange="upd(true)" />
<label for="o18s">Show 18+</label>
</li>
</ul>
{% end %}
</div>
<div style="text-align:center;">
<input type="submit" value="Submit" />
<progress id="proggers" max="1" value="0" />
</div>
</form>
<script>
{% if BigGlobe.cfg.enable18plus then %}
function upd(over18) {
document.querySelector(".tagbox").setAttribute("data-over18", +over18 != -1)
}
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 %}
var totalfiles = {{ #files }}
function clearfile(el) {
var inp = el.parentElement.querySelector("input")
inp.value = ""
var allofem = Array.from(document.querySelectorAll("input[type='file']"))
var idx = allofem.indexOf(inp)
if(idx != allofem.length - 1) {
// remove row
el.parentElement.parentElement.parentElement.removeChild(el.parentElement.parentElement)
if(totalfiles == {{ BigGlobe.cfg.maxfilesperobj }}) {
allofem[allofem.length - 1].parentElement.parentElement.style.display = ""
}
totalfiles--
}
}
function rebind() {
var v = document.querySelectorAll("input[type='file']")
var last = v[v.length - 1]
last.onchange = function(ev) {
last.onchange = undefined
var tablerow = this.parentElement.parentElement
var clond = tablerow.cloneNode(true)
tablerow.parentElement.appendChild(clond)
clond.querySelector("input").value = ""
totalfiles++
if(totalfiles == {{ BigGlobe.cfg.maxfilesperobj }}) {
clond.style.display = "none"
}
rebind()
}
}
rebind()
var CHUNK_SIZE = 1024 * 512
var files
var totalsizetoupload
var totaluploaded
function send(fileidx, offset) {
var xhr = new XMLHttpRequest()
xhr.open("POST", "#", true)
var sz = Math.min(CHUNK_SIZE, files[fileidx].files[0].size - offset)
var formData = new FormData()
formData.append("csrf", document.querySelector("[name='csrf']").value)
formData.append("upchu", encodeURIComponent(files[fileidx].files[0].name))
formData.append("offset", offset)
formData.append("data", files[fileidx].files[0].slice(offset, offset + sz))
xhr.onreadystatechange = function() {
if(xhr.readyState == XMLHttpRequest.DONE) {
if(xhr.status == 200) {
document.getElementById("proggers").value = totaluploaded / totalsizetoupload
totaluploaded += sz
if(sz < CHUNK_SIZE) {
if(fileidx < files.length - 1) {
send(fileidx + 1, 0)
} else {
var daform = document.getElementById("editform")
for(var i = 0; i < files.length; i++) files[i].disabled = true
daform.submit()
}
} else {
send(fileidx, offset + sz)
}
} else {
send(fileidx, offset)
}
}
}
xhr.send(formData)
}
function testsizes() {
var xhr = new XMLHttpRequest()
xhr.open("POST", "#", true)
var formData = new FormData()
formData.append("csrf", document.querySelector("[name='csrf']").value)
formData.append("createfiles", files.map(function(x) {return [encodeURIComponent(x.files[0].name), x.files[0].size]}).flat().join(";"))
totalsizetoupload = files.map(function(x) {return x.files[0].size}).reduce(function(x,y){return x+y}, 0)
totaluploaded = 0
xhr.onreadystatechange = function() {
if(xhr.readyState == XMLHttpRequest.DONE) {
if(xhr.status == 200) {
send(0, 0)
} else {
alert("Too much space.")
window.location.href = window.location.href.split("#")[0]
}
}
}
xhr.send(formData)
}
function doremoves() {
var xhr = new XMLHttpRequest()
xhr.open("POST", "#", true)
var idxs = []
var remchecks = document.querySelectorAll("#editform input[type='checkbox']")
for(var i = 0; i < remchecks.length; i++) {
if(remchecks[i].checked) idxs.push(i)
}
var formData = new FormData()
formData.append("csrf", document.querySelector("[name='csrf']").value)
formData.append("remfiles", idxs.join(";"))
xhr.onreadystatechange = function() {
if(xhr.readyState == XMLHttpRequest.DONE) {
if(xhr.status == 200) {
if(files.length > 0) {
testsizes()
} else {
document.getElementById("editform").submit()
}
} else {
window.location.href = window.location.href.split("#")[0]
}
}
}
xhr.send(formData)
}
document.getElementById("editform").addEventListener("submit", function(ev) {
ev.preventDefault()
files = Array.from(document.querySelectorAll("input[type='file']"))
files.pop() // don't count empty input
doremoves()
})
</script>
<script src="/static/tagbox.js"></script>
{% else %}
<p>You are not eligible to edit this object.</p>
{% end %}
{% else %}
<p>Object was not found.</p>
{% end %}
{% end %}
{# base.inc

3
pegasus/compress.lua Normal file
View File

@ -0,0 +1,3 @@
-- compatibility shim for moving the plugin into a 'plugins' subfolder
-- without breaking anything
return require("pegasus.plugins.compress")

151
pegasus/handler.lua Normal file
View File

@ -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

63
pegasus/init.lua Normal file
View File

@ -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

View File

@ -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

277
pegasus/request.lua Normal file
View File

@ -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

238
pegasus/response.lua Normal file
View File

@ -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 = [[
<!DOCTYPE HTML PUBLIC '-//W3C//DTD HTML 4.01//EN'
'http://www.w3.org/TR/html4/strict.dtd'>
<html>
<head>
<meta http-equiv='Content-Type' content='text/html;charset=utf-8'>
<title>Error response</title>
</head>
<body>
<h1>Error response</h1>
<p>Error code: {{ STATUS_CODE }}</p>
<p>Message: {{ STATUS_TEXT }}</p>
</body>
</html>
]]
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

47
reg.html.l Normal file
View File

@ -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 %}
<p>This link has expired.</p>
{% else %}
<h2>Registration</h2>
<div>{{ BigGlobe.cfg.ruleset }}</div>
<p>{{ 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.</p>
<p>All items posted and their files shall be publicly available, along with all of your comments.</p>
<form action="#" method="POST">
<input type="hidden" name="iagree" value="" />
<input type="text" name="mydispname" value="" placeholder="Display name..." autocomplete="off" style="display:block;margin-bottom:1em;" />
<button style="margin-top:0;">I agree to the terms.</button>
</form>
{% end %}
{% end %}
{# base.inc

63
reports.html.l Normal file
View File

@ -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 %}
<p>View: <a href="/reports?status=0">Open</a> | <a href="/reports?status=2">Fixed</a> | <a href="/reports?status=1">Wontfix</a></p>
{% local reports = DB.getreports(queryStatus, offset) %}
{% local csrfval = DB.csrf(verified.id) %}
<table class="report">
{% if #reports == 0 then %}
<p>No results.</p>
{% end %}
{% for _, report in pairs(reports) do %}
<tr>
<td>
<p>
On <span class="dt">{{ report.createtime }}</span>,
<a href="/user/{{report.reportee}}">{{ Escapes.htmlescape(DB.getuserbyid(report.reportee).displayname) }}</a>
was reported by
<a href="/user/{{report.reporter}}">{{ Escapes.htmlescape(DB.getuserbyid(report.reporter).displayname) }}</a>
</p>
<p>Status: {{ ({[BigGlobe.REPORT_STATUS_OPEN] = "Open", [BigGlobe.REPORT_STATUS_CLOSED_WONTFIX] = "Wont Fix", [BigGlobe.REPORT_STATUS_CLOSED_FIXED] = "Fixed"})[report.status] }}</p>
<div class="content"><p>{{ Escapes.htmlescape(report.content):gsub("\n\n", "</p><p>") }}</p></div>
</td>
<td>
<p>Change Status?</p>
<form action="#" method="POST">
<input type="hidden" name="csrf" value="{{ csrfval }}" />
<input type="hidden" name="rid" value="{{ report.id }}" />
<div><input type="radio" name="newsta" value="{{ BigGlobe.REPORT_STATUS_OPEN }}" id="setopen{{ report.id }}" checked /><label for="setopenparam{{ report.id }}">Set to "Open"</label></div>
<div><input type="radio" name="newsta" value="{{ BigGlobe.REPORT_STATUS_CLOSED_FIXED }}" id="setfixedparam{{ report.id }}" /><label for="setfixedparam{{ report.id }}">Set to "Fixed"</label></div>
<div><input type="radio" name="newsta" value="{{ BigGlobe.REPORT_STATUS_CLOSED_WONTFIX }}" id="setwontfixparam{{ report.id }}" /><label for="setwontfixparam{{ report.id }}">Set to "Wont Fix"</label></div>
<button>Submit</button>
</form>
</td>
</tr>
{% end %}
</table>
{% if #reports == 50 then %}
<p><a href="/reports?status={{ queryStatus }}&o={{ reports[#reports].id }}">Next</a></p>
{% end %}
{% else %}
<p>Not authorized.</p>
{% end %}
{% end %}
{# base.inc

46
reportuser.html.l Normal file
View File

@ -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 %}
<form action="#" method="POST">
<input type="hidden" name="csrf" value="{{ DB.csrf(verified.id) }}" />
<h2>Reporting user &quot;{{ Escapes.htmlescape(reportee.displayname) }}&quot;</h2>
<div style="margin-top:1em;margin-bottom:1em;">{{ BigGlobe.cfg.ruleset }}</div>
<textarea class="uf" name="wtf" placeholder="Max {{ BigGlobe.MAX_COMMENT_SIZE }}..."></textarea>
<button>Submit</button>
</form>
{% else %}
<p>You must be an approved member to report users.</p>
{% end %}
{% elseif reported then %}
<p>Reported.</p>
{% else %}
<p>Failed to report.</p>
{% end %}
{% end %}
{# base.inc

45
search.html.l Normal file
View File

@ -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() %}
<div style="text-align:center;width:90%;margin-left:5%;margin-top:10%;">
{% local found = DB.searchobjs(tagids, namefilter, offset, adultcontent) %}
{% if #found == 0 then %}
<p>No results.</p>
{% else %}
{% for i=#found,1,-1 do local o = found[i] %}
{% local pub = DB.objhideid(o.id) %}
<a href="/obji/{{ pub }}" class="searchitem"><div>
<img loading="lazy" src="/objd/{{ pub }}/.thumb.jpg" />
<p>{{ Escapes.htmlescape(o.name) }}</p>
</div></a>
{% end %}
{% if #found >= 50 then %}
<form action="/search" method="GET" style="margin-top:2em;">
<input type="hidden" name="n" value="{{ Escapes.htmlescape(namefilter) }}" />
<input type="hidden" name="t" value="{{ Escapes.htmlescape(table.concat(tagids, ",")) }}" />
<input type="hidden" name="off" value="{{ DB.objhideid(found[#found].id) }}" />
<input type="submit" value="Next" />
</form>
{% end %}
{% end %}
</div>
{% end %}
{# base.inc

61
smtpauth.lua Normal file
View File

@ -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
<!DOCTYPE html>
<html><body style="font-family:sans-serif;"><p>Welcome back to %s. Click on the below link to access your account.</p><p>If you had not initiated a sign-in request, consider whether your e-mail account has been compromised.</p><a href="%s"><div style="display:inline-block;font-size:1.2em;padding:0.5em 1em 0.5em 1em;border:1px solid gray;color:#C0C0C0;border-radius:6px;"><span>Sign-in</span></div></a></body></html>]], 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
<!DOCTYPE html>
<html><body style="font-family:sans-serif;"><p>You have either registered or been invited to register at %s. Click on the below link to complete your registration.</p><p>If you have no idea what this is, consider whether your e-mail account has been compromised.</p><a href="%s"><div style="display:inline-block;font-size:1.2em;padding:0.5em 1em 0.5em 1em;border:1px solid gray;color:#C0C0C0;border-radius:6px;"><span>Complete</span></div></a></body></html>]], user.email, BigGlobe.cfg.sitename, BigGlobe.cfg.sitename, url))
return true
end
end
}

6
static/datetimes.js Normal file
View File

@ -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()
}

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

213
static/style.css Normal file
View File

@ -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;
}

100
static/tagbox.js Normal file
View File

@ -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
}
}

1
static/tagcats.css Normal file
View File

@ -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;}

43
uninstall.lua Normal file
View File

@ -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."

58
user.html.l Normal file
View File

@ -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 %}
<div style="display:inline-block;width:69%;vertical-align:top;">
<h2>{{ Escapes.htmlescape(user.displayname) }}'s uploads</h2>
<ul>
{% for k, obj in pairs(DB.getownedobjs(user.id)) do %}
{% local hidid = DB.objhideid(obj.id) %}
<li><a href="/obji/{{ hidid }}">{{ obj.name == "" and "<em>(Unnamed)</em>" or Escapes.htmlescape(obj.name) }}</a></li>
{% end %}
</ul>
{% if verified and verified.id == user.id then %}
<button id="newobjbtn">Create new object</button>
<script>
document.getElementById("newobjbtn").onclick = function() {
if(confirm("Are you sure? Objects cannot be deleted, and must be filled as soon as possible.")) {
window.location.href = "/addobj"
}
}
</script>
{% end %}
</div>
{% if verified and verified.id == user.id then %}
<div style="display:inline-block;width:29%;vertical-align:top;">
<form action="#" method="POST">
<input type="hidden" name="csrf" value="{{ Escapes.htmlescape(DB.csrf(verified.id)) }}" />
<h2>Profile Settings</h2>
<div>
<p style="margin-bottom:0;">Rename</p>
<input type="text" name="newname" autocomplete="off" value="{{ Escapes.htmlescape(user.displayname) }}" />
</div>
<div style="margin-top:1em;">
<input type="submit" value="Submit" />
</div>
</form>
</div>
{% end %}
{% else %}
<p>User not found.</p>
{% end %}
{% end %}
{# base.inc