mirror of
https://codeberg.org/metamuffin/abrechenbarkeit.git
synced 2024-12-28 07:54:35 +00:00
838 lines
30 KiB
Lua
Executable file
838 lines
30 KiB
Lua
Executable file
#!/usr/bin/env lua
|
|
--[[
|
|
Abrechenbarkeit - A simple trust-based ledger
|
|
Copyright 2024 metamuffin
|
|
Copyright 2024 dasriley
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as published by
|
|
the Free Software Foundation, version 3 of the License only.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
]] --
|
|
|
|
-- TODO: allow unicode
|
|
-- TODO: somehow remove _opt variants
|
|
local matchers = {
|
|
time = "(%d+)",
|
|
user = "([%w_@ -]+)",
|
|
user_opt = "([%w_@ -]*)",
|
|
amount = "(-?%d+)",
|
|
amount_opt = "(-?%d*)",
|
|
comment = "([%w_ -]+)",
|
|
comment_opt = "([%w_ -]*)",
|
|
barcode = "([%w_-]+)",
|
|
barcode_opt = "([%w_-]*)",
|
|
name = "([%w_ -]+)",
|
|
}
|
|
local matchers_global = (function()
|
|
local s = {}
|
|
for k, v in pairs(matchers) do s[k] = ("^%s$"):format(v) end
|
|
return s
|
|
end)()
|
|
|
|
local function escape(s)
|
|
return s:gsub("<", "<"):gsub("<", "<")
|
|
end
|
|
|
|
local function urldecode(s)
|
|
if s == nil then return nil end
|
|
local t, _ = s:gsub("+", " "):gsub("%%(%x%x)",
|
|
function(cap) return string.char(tonumber(cap, 16)) end)
|
|
return t
|
|
end
|
|
|
|
local function urlencode(s)
|
|
if s == nil then return nil end
|
|
return s:gsub("[^%w]",
|
|
function(cap) return string.format("%%%02x", string.byte(cap, 1)) end)
|
|
end
|
|
|
|
local function parse_query(q)
|
|
if q == nil then return {} end
|
|
local data = {}
|
|
for pair in string.gmatch(q, "([^&]+)") do
|
|
local flag = string.match(pair, "^([^=]+)$")
|
|
if flag ~= nil then
|
|
data[flag] = "1"
|
|
else
|
|
local key, value = string.match(pair, "^([^=]+)=([^=]*)$")
|
|
if key ~= nil and value ~= nil then
|
|
data[key] = urldecode(value)
|
|
end
|
|
end
|
|
end
|
|
return data
|
|
end
|
|
|
|
local function load_config()
|
|
local log = io.open("config", "r")
|
|
if log == nil then return {} end
|
|
local config = {}
|
|
for l in log:lines("l") do
|
|
if l ~= "" and l[0] ~= "#" then
|
|
local key, value = string.match(l, "^([^=]+)=([^=]*)")
|
|
if key ~= nil and value ~= nil then
|
|
config[key] = value
|
|
end
|
|
end
|
|
end
|
|
return config
|
|
end
|
|
|
|
local function load_translations(langs)
|
|
local t = {}
|
|
for _, lcode in ipairs(langs) do
|
|
local file = io.open(string.format("locale/%s.ini", lcode), "r")
|
|
if file ~= nil then
|
|
for l in file:lines("l") do
|
|
if l ~= "" then
|
|
local key, value = string.match(l, "^([^=%s]+)%s?=%s?([^=]*)")
|
|
if key ~= nil and value ~= nil then
|
|
t["+" .. key] = value
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return t
|
|
end
|
|
|
|
local config = load_config()
|
|
local path = os.getenv("PATH_INFO")
|
|
local method = os.getenv("REQUEST_METHOD")
|
|
local query = parse_query(os.getenv("QUERY_STRING"))
|
|
local translations = load_translations({ "en", config.language })
|
|
|
|
local stylesheet = io.open("style.css"):read("a")
|
|
local script = io.open("script.js"):read("a")
|
|
|
|
local function format(template, params)
|
|
params = params or {}
|
|
if template == nil then return "NIL TEMPLATE" end
|
|
local s, _ = string.gsub(template, "{([%w\\+_\\.!]+)}", function(n)
|
|
local esc = n:sub(1, 1) == "!"
|
|
if esc then n = n:sub(2) end
|
|
local s = params[n] or translations[n] or "NIL PARAM"
|
|
if not esc then s = format(s, params) end
|
|
return esc and escape(s) or s
|
|
end)
|
|
return s
|
|
end
|
|
|
|
local function format_amount(amount, tag, classes)
|
|
local s = format("{+price.amount}", {
|
|
sign = amount >= 0 and "+" or "-",
|
|
amount = string.format("%.2f", math.abs(amount / 100)),
|
|
unit = config.unit or "€"
|
|
})
|
|
if tag == nil then return s end
|
|
return format(
|
|
[[<{tag} class="amount-{sign} {classes}">{content}</{tag}>]], {
|
|
tag = tag,
|
|
sign = amount >= 0 and "pos" or "neg",
|
|
classes = classes or "",
|
|
content = s
|
|
})
|
|
end
|
|
|
|
local function get_user_theme(username)
|
|
local c = ""
|
|
if username == "_jeb" then
|
|
c = "html { animation: 2s jeb infinite; }"
|
|
c = c .. "@keyframes jeb {\n"
|
|
for i = 0, 100 do
|
|
c = c .. string.format("%.02f%% { --hue: %.02f; } \n", i, i / 100 * 360)
|
|
end
|
|
c = c .. "\n}"
|
|
elseif username == "Dinnerbone" then
|
|
c = "html { transform: scale(-1); } "
|
|
end
|
|
return c
|
|
end
|
|
|
|
local function format_duration(t)
|
|
local unit = nil
|
|
local n = nil
|
|
if t > 86400 then
|
|
n = math.floor(t / 86400)
|
|
unit = "day"
|
|
elseif t > 3600 then
|
|
n = math.floor(t / 3600)
|
|
unit = "hour"
|
|
elseif t > 60 then
|
|
n = math.floor(t / 60)
|
|
unit = "minute"
|
|
else
|
|
n = t
|
|
unit = "second"
|
|
end
|
|
return format("{+time.delta_past}",
|
|
{ n = n, unit = translations["+time." .. unit .. (n ~= 1 and "s" or "")] })
|
|
end
|
|
|
|
local function respond(status, title, body)
|
|
print(string.format("Status: %d", status))
|
|
print("Content-Type: text/html")
|
|
print("")
|
|
print(format([[
|
|
<!DOCTYPE html>
|
|
<html><head>
|
|
<title>{title}</title>
|
|
<meta charset="utf-8" />
|
|
<style>{style}</style>
|
|
<style>{user_style}</style>
|
|
<script>{script}</script>
|
|
{head_extra}
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<a class="logo" href="/">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-receipt-cutoff" viewBox="0 0 16 16">
|
|
<path d="M3 4.5a.5.5 0 0 1 .5-.5h6a.5.5 0 1 1 0 1h-6a.5.5 0 0 1-.5-.5m0 2a.5.5 0 0 1 .5-.5h6a.5.5 0 1 1 0 1h-6a.5.5 0 0 1-.5-.5m0 2a.5.5 0 0 1 .5-.5h6a.5.5 0 1 1 0 1h-6a.5.5 0 0 1-.5-.5m0 2a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5m0 2a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5M11.5 4a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1zm0 2a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1zm0 2a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1zm0 2a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1zm0 2a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1z"/>
|
|
<path d="M2.354.646a.5.5 0 0 0-.801.13l-.5 1A.5.5 0 0 0 1 2v13H.5a.5.5 0 0 0 0 1h15a.5.5 0 0 0 0-1H15V2a.5.5 0 0 0-.053-.224l-.5-1a.5.5 0 0 0-.8-.13L13 1.293l-.646-.647a.5.5 0 0 0-.708 0L11 1.293l-.646-.647a.5.5 0 0 0-.708 0L9 1.293 8.354.646a.5.5 0 0 0-.708 0L7 1.293 6.354.646a.5.5 0 0 0-.708 0L5 1.293 4.354.646a.5.5 0 0 0-.708 0L3 1.293zm-.217 1.198.51.51a.5.5 0 0 0 .707 0L4 1.707l.646.647a.5.5 0 0 0 .708 0L6 1.707l.646.647a.5.5 0 0 0 .708 0L8 1.707l.646.647a.5.5 0 0 0 .708 0L10 1.707l.646.647a.5.5 0 0 0 .708 0L12 1.707l.646.647a.5.5 0 0 0 .708 0l.509-.51.137.274V15H2V2.118z"/>
|
|
</svg>
|
|
{+appname}
|
|
</a>
|
|
<a href="/?users">{+users}</a>
|
|
<a href="/?spus">{+spus}</a>
|
|
<a href="/?products">{+products}</a>
|
|
<a href="/?log">{+log}</a>
|
|
<a href="/?about">{+about}</a>
|
|
</nav>
|
|
]], {
|
|
title = escape(title),
|
|
style = stylesheet,
|
|
user_style = get_user_theme(path and path:sub(2)),
|
|
script = script,
|
|
head_extra = config.head_extra or ""
|
|
}))
|
|
if config.header ~= nil then
|
|
print(config.header)
|
|
end
|
|
body()
|
|
if config.footer ~= nil then
|
|
print(config.footer)
|
|
end
|
|
print("</body></html>")
|
|
end
|
|
|
|
local function error_box(message, params)
|
|
return string.format([[<div class="notif error"><p>Error: %s</p></div>]], escape(format(message, params)))
|
|
end
|
|
|
|
local function respond_error(message)
|
|
respond(400, "Error", function()
|
|
print(error_box("{!x}", { x = message }))
|
|
end)
|
|
end
|
|
|
|
local function redirect(path)
|
|
print("Status: 307")
|
|
print(string.format("Location: %s", path))
|
|
print()
|
|
end
|
|
|
|
local function form_data()
|
|
return parse_query(io.read())
|
|
end
|
|
|
|
local function read_log()
|
|
local log = io.open("log", "r")
|
|
if log == nil then
|
|
return function() return nil end
|
|
end
|
|
local lines = log:lines("l")
|
|
return function()
|
|
local l = lines()
|
|
if l == "" or l == nil then
|
|
return nil
|
|
end
|
|
local time, user_src, user_dst, amount, pcode, pcount, comment = string.match(l,
|
|
format("^{time},{user},{user},{amount},{barcode_opt},{amount_opt},{comment_opt}$", matchers))
|
|
return tonumber(time), user_src, user_dst, tonumber(amount), pcode, tonumber(pcount), comment
|
|
end
|
|
end
|
|
|
|
local function read_products()
|
|
local log = io.open("products", "r")
|
|
if log == nil then
|
|
return function() return nil end
|
|
end
|
|
local lines = log:lines("l")
|
|
return function()
|
|
local l = lines()
|
|
if l == "" or l == nil then
|
|
return nil
|
|
end
|
|
local barcode, price, user, name = string.match(l, format("^{barcode},{amount},{user_opt},{name}$", matchers))
|
|
return barcode, tonumber(price), user, name
|
|
end
|
|
end
|
|
|
|
local function balances()
|
|
local users = {}
|
|
for _, user_src, user_dst, amount, _, _, _ in read_log() do
|
|
users[user_src] = (users[user_src] or 0) - amount
|
|
users[user_dst] = (users[user_dst] or 0) + amount
|
|
end
|
|
return users
|
|
end
|
|
|
|
local function product_balances()
|
|
local products = {}
|
|
for _, _, _, _, pcode, pcount, _ in read_log() do
|
|
if pcode ~= nil and pcount ~= nil then
|
|
products[pcode] = (products[pcode] or 0) + pcount
|
|
end
|
|
end
|
|
return products
|
|
end
|
|
|
|
local function last_txns()
|
|
local users = {}
|
|
for time, user_src, user_dst, _, _, _, _ in read_log() do
|
|
users[user_src] = time
|
|
users[user_dst] = time
|
|
end
|
|
return users
|
|
end
|
|
|
|
local function get_active_users()
|
|
local user_balances = {}
|
|
for time, user_src, user_dst, amount, _, _, _ in read_log() do
|
|
user_balances[user_src] = {
|
|
time = time,
|
|
name = user_src,
|
|
balance = (user_balances[user_src] or { balance = 0 }).balance - amount
|
|
}
|
|
user_balances[user_dst] = {
|
|
time = time,
|
|
name = user_dst,
|
|
balance = (user_balances[user_dst] or { balance = 0 }).balance + amount
|
|
}
|
|
end
|
|
|
|
local users = {}
|
|
for _, user in pairs(user_balances) do
|
|
table.insert(users, user)
|
|
end
|
|
|
|
table.sort(users, function(a, b) return ((a.time == b.time) and a.name > b.name) or (a.time > b.time) end)
|
|
return users
|
|
end
|
|
|
|
local function r_transaction_post()
|
|
local data = form_data()
|
|
local user_src = data.user_src
|
|
local user_dst = data.user_dst
|
|
local amount = tonumber(data.amount)
|
|
local pcode = data.pcode
|
|
local pcount = tonumber(data.pcount)
|
|
local comment = data.comment
|
|
if pcode ~= nil and pcode ~= "" then
|
|
local exists = false
|
|
for p_barcode, p_amount, p_user, p_name in read_products() do
|
|
if p_barcode == pcode then
|
|
pcount = (tonumber(data.pcount) or 1) * (data.negate_pcount ~= nil and -1 or 1)
|
|
amount = amount or pcount * p_amount
|
|
user_src = user_src or p_user
|
|
comment = comment or
|
|
string.format("%s %d %s", pcount < 0 and "Buy" or "Restock", math.abs(pcount or 0), p_name)
|
|
exists = true
|
|
end
|
|
end
|
|
if not exists then
|
|
return error_box("{+error.unknown_product}")
|
|
end
|
|
end
|
|
user_src = user_src or "@Potential"
|
|
if amount == nil then
|
|
return error_box("{+error.invalid_amount}")
|
|
end
|
|
if comment == nil or comment:match(matchers_global.comment_opt) == nil then
|
|
return error_box("{+error.invalid_comment}")
|
|
end
|
|
if user_src == nil or user_src:match(matchers_global.user) == nil then
|
|
return error_box("{+error.invalid_user_src}")
|
|
end
|
|
if user_dst == nil or user_dst:match(matchers_global.user) == nil then
|
|
return error_box("{+error.invalid_user_dst}")
|
|
end
|
|
local log = io.open("log", "a+")
|
|
if log == nil then
|
|
return error_box("{+error.open_log}")
|
|
end
|
|
local time = os.time()
|
|
log:write(string.format("%d,%s,%s,%d,%s,%s,%s\n",
|
|
time, user_src, user_dst, amount, pcode or "", pcount or "", comment))
|
|
log:flush()
|
|
log:close()
|
|
return format([[
|
|
<div class="notif"><p>{+user.form.transaction.success}: {amount} ({!comment})</p></div>
|
|
<audio src="{sound}" autoplay></audio>
|
|
]], {
|
|
sign = amount >= 0 and "pos" or "neg",
|
|
amount = format_amount(amount, "strong"),
|
|
comment = comment,
|
|
sound = config.transaction_sound or ""
|
|
})
|
|
end
|
|
|
|
local function r_user(username)
|
|
local notif = nil
|
|
if method == "POST" then
|
|
notif = r_transaction_post()
|
|
end
|
|
return respond(200, string.format("Abrechenbarheit: %s", username), function()
|
|
local is_special = username:sub(1, 1) == "@"
|
|
local username_display = username:gsub("@", "")
|
|
print(format(is_special and "<h1><i>{!username}</i></h1>" or "<h1>{!username}</h1>",
|
|
{ username = username_display }))
|
|
local balance = balances()[username]
|
|
local last_txn = last_txns()[username]
|
|
local new_user = balance == nil
|
|
balance = balance or 0
|
|
if notif then print(notif) end
|
|
if is_special then
|
|
print(format([[<div class="notif"><p><i>{+user.special}</i></p></div>]]))
|
|
end
|
|
if new_user then
|
|
print(format([[<div class="notif"><p><i>{+user.lazy_creation}</i></p></div>]]))
|
|
else
|
|
print([[<div class="backgroundbox userinfo">]])
|
|
print(format([[{+user.balance}: <br>{amount}<br>]],
|
|
{ amount = format_amount(balance, "span", "balance-value") }))
|
|
print(format([[{+user.last_txn} <a href="/{username}?log">{+user.view_log}</a>]],
|
|
{ time = format_duration(os.time() - last_txn), username = urlencode(username) }))
|
|
print([[</div>]])
|
|
end
|
|
print([[<div class="transactions container firstchildlarge">]])
|
|
print([[<div class="amount-presets backgroundbox">]])
|
|
for _, type in ipairs({ 1, -1 }) do
|
|
for _, amount in ipairs({ 50, 100, 150, 200, 500, 1000 }) do
|
|
local a = amount * type
|
|
print(format([[<form action="" method="POST" class="{disable_class}">
|
|
<input type="text" name="user_dst" value="{!username}" hidden />
|
|
<input type="number" name="amount" value="{a_raw}" hidden />
|
|
<input type="text" name="comment" value="" hidden />
|
|
<input type="submit" value="{amount}" class="amount-{sign} button" />
|
|
</form>]], {
|
|
username = username,
|
|
a_raw = a,
|
|
amount = format_amount(a),
|
|
sign = a < 0 and "neg" or "pos",
|
|
disable_class = is_special and "disabled" or ""
|
|
}))
|
|
end
|
|
end
|
|
print("</div>")
|
|
print(format([[
|
|
<form class="transaction box backgroundbox {disable_class}" action="" method="POST">
|
|
<h3>{+user.form.transaction}</h3>
|
|
<input type="text" name="user_dst" value="{!username}" hidden />
|
|
<label for="amount">Amount (ct): </label>
|
|
<input type="number" name="amount" id="amount" />
|
|
<label for="comment">Comment: </label>
|
|
<input type="text" name="comment" id="comment" />
|
|
<input type="submit" value="{+user.form.transaction.submit}" class="amount-ntr button" />
|
|
</form>
|
|
<form class="transaction box backgroundbox {disable_class}" action="" method="POST" id="buy_product">
|
|
<h3>{+user.form.buy}</h3>
|
|
<input type="text" name="user_dst" value="{!username}" hidden />
|
|
<input type="text" name="negate_pcount" value="1" hidden />
|
|
<label for="pcount">Count: </label>
|
|
<input type="number" name="pcount" id="pcount" value="1" />
|
|
<label for="pcode">{+field.barcode}: </label>
|
|
<input type="text" name="pcode" id="pcode" />
|
|
<input class="amount-neg button" type="submit" value="{+user.form.buy.submit}" />
|
|
</form>
|
|
<form class="transaction box backgroundbox {disable_class}" action="" method="POST" id="buy_product">
|
|
<h3>{+user.form.restock}</h3>
|
|
<input type="text" name="user_dst" value="{!username}" hidden />
|
|
<label for="pcount">{+field.count}: </label>
|
|
<input type="number" name="pcount" id="pcount" value="1" />
|
|
<label for="amount">{+field.upstream_price}: </label>
|
|
<input type="number" name="amount" id="amount" />
|
|
<label for="pcode">{+field.barcode}: </label>
|
|
<input type="text" name="pcode" id="pcode" />
|
|
<input type="submit" value="{+user.form.restock.submit}" class="button amount-pos" />
|
|
</form>
|
|
]], { username = username, disable_class = is_special and "disabled" or "" }))
|
|
print("</div>")
|
|
end)
|
|
end
|
|
|
|
local function r_log(filter)
|
|
local notif = nil
|
|
if method == "POST" then
|
|
notif = r_transaction_post()
|
|
end
|
|
return respond(200, "Abrechnungen", function()
|
|
if notif then print(notif) end
|
|
print([[<table class="log"]])
|
|
print(format([[<thead><tr>
|
|
<th>{+field.time}</th>
|
|
<th>{+field.username}</th>
|
|
<th>{+field.amount}</th>
|
|
<th>{+field.barcode}</th>
|
|
<th>{+field.count}</th>
|
|
<th>{+field.comment}</th>
|
|
<th>{+log.actions}</th>
|
|
</tr></thead>]]))
|
|
print("<tbody>")
|
|
for time, user_src, user_dst, amount, pcode, pcount, comment in read_log() do
|
|
if filter == nil or filter == user_src or filter == user_dst then
|
|
print(format([[
|
|
<tr>
|
|
<td>{time} ({time_delta})</td>
|
|
<td><a href="/{user_src_url}">{user_src}</a> → <a href="/{user_dst_url}">{user_dst}</a></td>
|
|
{amount}
|
|
<td>{pcode}</td>
|
|
<td>{pcount}</td>
|
|
<td>{comment}</td>
|
|
<td>
|
|
<form action="/?log" method="POST">
|
|
<input type="text" name="user_src" value="{user_src}" hidden />
|
|
<input type="text" name="user_dst" value="{user_dst}" hidden />
|
|
<input type="number" name="amount" value="{revert_amount}" hidden />
|
|
<input type="text" name="pcode" value="{pcode}" hidden />
|
|
<input type="number" name="pcount" value="{revert_pcount}" hidden />
|
|
<input type="text" name="comment" value="Revert {comment}" hidden />
|
|
<input type="submit" class="amount-ntr button" value="{+log.actions.revert}" />
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
]], {
|
|
time = os.date("!%Y-%m-%dT%H:%M:%SZ", time),
|
|
time_delta = format_duration(os.time() - time),
|
|
user_src = escape(user_src),
|
|
user_dst = escape(user_dst),
|
|
user_src_url = urlencode(user_src),
|
|
user_dst_url = urlencode(user_dst),
|
|
amount = format_amount(amount, "td"),
|
|
pcode = escape(pcode),
|
|
pcount = pcount and tostring(math.abs(pcount)) or "",
|
|
comment = escape(comment),
|
|
revert_amount = -amount,
|
|
revert_pcount = -(pcount or 0),
|
|
}))
|
|
end
|
|
end
|
|
print("</tbody>")
|
|
print("</table>")
|
|
end)
|
|
end
|
|
|
|
local function r_users(show_special, filter_negative)
|
|
if filter_negative ~= nil then
|
|
filter_negative = tonumber(filter_negative) or 0
|
|
end
|
|
|
|
return respond(200, "Abrechenbarkeit", function()
|
|
local users = get_active_users()
|
|
|
|
if not show_special then
|
|
print(format([[
|
|
<form class="section" action="/" method="GET" id="user_creation">
|
|
<h3>{+index.form.create_user}</h3>
|
|
<label for="username">{+field.username}: </label>
|
|
<input type="text" name="create_user" id="username" />
|
|
<input type="submit" value="{+index.form.create_user.submit}" class="button amount-ntr" />
|
|
</form>
|
|
]]))
|
|
|
|
-- get first letters
|
|
local firstletters = {}
|
|
local function contains(char)
|
|
for _, firstchar in ipairs(firstletters) do
|
|
if firstchar == char then
|
|
return true
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
for _, user in ipairs(users) do
|
|
user.firstchar = user.name:sub(1,1):lower()
|
|
if user.firstchar ~= "@" and not contains(user.firstchar) then
|
|
table.insert(firstletters, user.firstchar)
|
|
end
|
|
end
|
|
|
|
table.sort(firstletters)
|
|
|
|
print(format([[<section class="firstletterlist"><h3>{+users.filter}</h3><ul>]]))
|
|
for k, letter in ipairs(firstletters) do
|
|
print(format([[<li><a href="?users&prefix={letter}">{letter}</a></li>]], {
|
|
letter = letter,
|
|
}))
|
|
end
|
|
print([[</ul></section>]])
|
|
end
|
|
print([[<div class="userlist"></div>]]) -- for printing
|
|
print([[<ul class="userlist">]])
|
|
|
|
local show_user = function(user)
|
|
local is_spu = user.name:sub(1, 1) == "@"
|
|
local filter_out = query.prefix ~= nil and user.name:sub(1, 1):lower() ~= query.prefix
|
|
if is_spu == show_special and
|
|
((filter_negative ~= nil and user.balance < filter_negative)
|
|
or (filter_negative == nil and (not filter_out))) then
|
|
print(format([[<li>
|
|
<a href="/{username_url}">
|
|
<span class="name">{!username}</span>
|
|
{balance}
|
|
</a>
|
|
</li>]], {
|
|
username_url = urlencode(user.name),
|
|
username = user.name:gsub("@", ""),
|
|
balance = format_amount(user.balance, "span")
|
|
}))
|
|
end
|
|
end
|
|
|
|
if filter_negative ~= nil then
|
|
table.sort(users, function(a, b)
|
|
return a.balance < b.balance
|
|
end)
|
|
end
|
|
|
|
local inactive_cutoff = os.time() - (tonumber(config.inactive_cutoff) or (30 * 24 * 60 * 60))
|
|
local embezzlement = 0
|
|
for _, user in ipairs(users) do
|
|
if filter_negative or user.time > inactive_cutoff then
|
|
show_user(user)
|
|
|
|
if user.name:sub(1,1) ~= "@" then
|
|
if user.balance < 0 then embezzlement = embezzlement - user.balance end
|
|
end
|
|
end
|
|
end
|
|
print("</ul>")
|
|
if filter_negative ~= nil then
|
|
print(format([[<section class="section" style="margin-top: 0">{+users.embezzlement}</section>]], {
|
|
amount = format_amount(embezzlement)
|
|
}))
|
|
|
|
return
|
|
end
|
|
print(format([[<details><summary>{+users.inactive_list}</summary><ul class="userlist">]]))
|
|
for _, user in ipairs(users) do
|
|
if user.time <= inactive_cutoff then show_user(user) end
|
|
end
|
|
print([[</ul></details>]])
|
|
end)
|
|
end
|
|
|
|
local function r_create_user()
|
|
local username = query.create_user
|
|
if username:match(matchers_global.user) == nil then
|
|
return respond_error(format("{+error.invalid_user}"))
|
|
end
|
|
return redirect(string.format("/%s", urlencode(username)))
|
|
end
|
|
|
|
local function r_products_post()
|
|
local data = form_data()
|
|
local barcode = data.barcode
|
|
if barcode == nil or barcode:match("^[%w_-]*$") == nil then
|
|
return error_box("{+error.invalid_barcode}")
|
|
end
|
|
if data.delete then
|
|
local new_products = io.open("products.new", "w+")
|
|
if new_products == nil then
|
|
return error_box("{+error.open_new_products}")
|
|
end
|
|
for a_barcode, price, user, name in read_products() do
|
|
if barcode ~= a_barcode then
|
|
new_products:write(string.format("%s,%d,%s,%s\n", a_barcode, price, user, name))
|
|
end
|
|
end
|
|
new_products:flush()
|
|
new_products:close()
|
|
os.rename("products.new", "products")
|
|
else
|
|
local price = tonumber(data.price)
|
|
local name = data.name
|
|
local user = data.user
|
|
if price == nil then
|
|
return error_box("{+error.invalid_price}")
|
|
end
|
|
if name == nil or name:match(matchers_global.name) == nil then
|
|
return error_box("{+error.invalid_price}")
|
|
end
|
|
if user == nil or user:match(matchers_global.user) == nil then
|
|
return error_box("{+error.invalid_user}")
|
|
end
|
|
local products = io.open("products", "a+")
|
|
if products == nil then
|
|
return error_box("{+error.open_products}")
|
|
end
|
|
products:write(string.format("%s,%d,%s,%s\n", barcode, price, user, name))
|
|
products:flush()
|
|
products:close()
|
|
end
|
|
end
|
|
|
|
local function r_products()
|
|
local notif = nil
|
|
if method == "POST" then
|
|
notif = r_products_post()
|
|
end
|
|
respond(200, "Abrechenbare Product List", function()
|
|
print(format("<h1>{+products.title}</h1>"))
|
|
if notif then print(notif) end
|
|
print(format([[
|
|
<div class="container">
|
|
<form action="/?products" method="POST" class="box backgroundbox">
|
|
<h3>{+products.form.add}</h3>
|
|
<label for="name">{+field.name}: </label>
|
|
<input type="text" name="name" id="name" />
|
|
<label for="price">{+field.price}: </label>
|
|
<input type="number" name="price" id="price" />
|
|
<label for="user">{+field.user}: </label>
|
|
<input type="text" name="user" id="user" />
|
|
<label for="barcode">{+field.barcode}: </label>
|
|
<input type="text" name="barcode" id="barcode" />
|
|
<input type="submit" value="{+products.form.add.submit}" class="amount-ntr button" />
|
|
</form>
|
|
<form action="/?products" method="POST" class="box backgroundbox">
|
|
<h3>{+products.form.remove}</h3>
|
|
<input type="text" name="delete" value="1" hidden />
|
|
<label for="barcode">{+field.barcode}: </label>
|
|
<input type="text" name="barcode" id="barcode" />
|
|
<input type="submit" value="{+products.form.remove.submit}" class="amount-ntr button" />
|
|
</form>
|
|
</div>
|
|
]], {
|
|
currency = config.unit or "€",
|
|
}))
|
|
print(format([[<table class="productlist"><tr>
|
|
<th>{+field.name}</th>
|
|
<th>{+field.price}</th>
|
|
<th>{+field.barcode}</th>
|
|
<th>{+field.count}</th>
|
|
<th>{+field.user}</th>
|
|
</tr>]]))
|
|
local pbals = product_balances()
|
|
for barcode, price, user, name in read_products() do
|
|
print(format([[<tr>
|
|
<td>{!name}</td>
|
|
{price}
|
|
<td>{!barcode}</td>
|
|
<td>{!count}</td>
|
|
<td>{!user}</td>
|
|
</tr>]], {
|
|
name = name,
|
|
price = format_amount(-price, "td"),
|
|
barcode = barcode,
|
|
count = tostring(pbals[barcode] or 0),
|
|
user = user,
|
|
}))
|
|
end
|
|
print("</table>")
|
|
end)
|
|
end
|
|
|
|
local function r_about()
|
|
respond(200, "About Abrechenbarkeit", function()
|
|
print(format([[
|
|
<h1>{+about.title}</h1>
|
|
<p>{+about.desc}</p>
|
|
<p>{+about.license}<p>
|
|
<p>{+about.source}</p>
|
|
<p>{+about.thanks}</p>
|
|
]], {
|
|
issues = [[<a href="https://codeberg.org/metamuffin/strichliste/issues">]],
|
|
codeberg = [[<a href="https://codeberg.org/metamuffin/strichliste">]],
|
|
ae = [[</a>]],
|
|
}))
|
|
end)
|
|
end
|
|
|
|
local function extract_username()
|
|
if path == nil then
|
|
return respond_error(format("{+error.no_path}"))
|
|
end
|
|
local username = urldecode(path:sub(2))
|
|
if username == nil or username:match(matchers_global.user) == nil then
|
|
return nil
|
|
end
|
|
return username
|
|
end
|
|
|
|
local function r_export_log()
|
|
local log = io.open("log", "r")
|
|
if log == nil then
|
|
return function() return nil end
|
|
end
|
|
print("Status: 200")
|
|
print("Content-Type: text/csv")
|
|
print("")
|
|
for l in log:lines("l") do
|
|
print(l)
|
|
end
|
|
end
|
|
local function r_export_products()
|
|
local log = io.open("products", "r")
|
|
if log == nil then
|
|
return function() return nil end
|
|
end
|
|
print("Status: 200")
|
|
print("Content-Type: text/csv")
|
|
print("")
|
|
for l in log:lines("l") do
|
|
print(l)
|
|
end
|
|
end
|
|
local function r_export_balances()
|
|
print("Status: 200")
|
|
print("Content-Type: text/csv")
|
|
print("")
|
|
for user, balance in pairs(balances()) do
|
|
print(string.format("%s,%d", user, balance))
|
|
end
|
|
end
|
|
|
|
if path == "/" then
|
|
if query.about then
|
|
return r_about()
|
|
elseif query.products then
|
|
if query.export then
|
|
return r_export_products()
|
|
else
|
|
return r_products()
|
|
end
|
|
elseif query.log then
|
|
if query.export then
|
|
return r_export_log()
|
|
else
|
|
return r_log()
|
|
end
|
|
elseif query.create_user then
|
|
return r_create_user()
|
|
elseif query.spus then
|
|
return r_users(true, nil)
|
|
elseif query.users and query.export then
|
|
return r_export_balances()
|
|
else
|
|
return r_users(false, query.negative and (query.maximum or 0))
|
|
end
|
|
else
|
|
local username = extract_username()
|
|
if username == nil then
|
|
return respond_error(format("{+error.invalid_user}"))
|
|
elseif query.log then
|
|
return r_log(username)
|
|
else
|
|
return r_user(username)
|
|
end
|
|
end
|