aboutsummaryrefslogtreecommitdiff
path: root/abrechenbarkeit.lua
diff options
context:
space:
mode:
Diffstat (limited to 'abrechenbarkeit.lua')
-rwxr-xr-xabrechenbarkeit.lua400
1 files changed, 400 insertions, 0 deletions
diff --git a/abrechenbarkeit.lua b/abrechenbarkeit.lua
new file mode 100755
index 0000000..dc3685b
--- /dev/null
+++ b/abrechenbarkeit.lua
@@ -0,0 +1,400 @@
+#!/usr/bin/env luajit
+
+local function escape(s)
+ return s:gsub("<", "&lt;"):gsub("<", "&lt;")
+end
+
+local function urldecode(s)
+ if s == nil then return nil end
+ return s:gsub("+", " "):gsub("%%20", " ")
+end
+
+local function urlencode(s)
+ if s == nil then return nil end
+ return s:gsub(" ", "%%20")
+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 config = load_config()
+local path = os.getenv("PATH_INFO")
+local method = os.getenv("REQUEST_METHOD")
+local query = parse_query(os.getenv("QUERY_STRING"))
+
+local stylesheet = io.open("a.css"):read("a")
+-- local stylesheet = [[
+-- /* body { background-color: #161616; }
+-- h1, h2, h3, h4, h5, h6, p, label, a { color: #e2e2e2; } */
+-- .amount-presets form { display: inline-block; width: 60px }
+-- .amount-pos { color: green; }
+-- .amount-neg { color: red; }
+-- nav h2 { display: inline-block }
+-- .notif { padding: 0.5em; margin: 0.5em; background-color: #ddd; }
+-- .notif.error { background-color: #faa; }
+-- .notif p { margin: 5px; }
+-- form.box { border: 2px solid grey; padding: 0.5em; margin: 0.5em; display: inline-block; }
+-- form h3 { margin: 5px; }
+-- ]]
+
+local script = [[
+ document.addEventListener("keypress", ev => {
+ if (!(document.activeElement instanceof HTMLInputElement)) {
+ if (ev.code.startsWith("Digit"))
+ document.forms.buy_product.product.value += ev.code.substring(5)
+ if (ev.code == "Enter")
+ document.forms.buy_product.submit()
+ }
+ })
+]]
+
+local function respond(status, title, body)
+ print(string.format("Status: %d", status))
+ print("Content-Type: text/html")
+ print("")
+ print(string.format([[
+ <!DOCTYPE html>
+ <html><head>
+ <title>%s</title>
+ <meta charset="utf-8" />
+ <style>%s</style>
+ <script>%s</script>
+ %s
+ </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>
+ Abrechenbarkeit</a>
+ <a href="/?log">Log</a>
+ <a href="/?products">Products</a>
+ <a href="https://codeberg.org/metamuffin/strichliste">Source</a>
+ </nav>
+ ]], escape(title), stylesheet, script, config.head_extra or ""))
+ body()
+ print("</body></html>")
+end
+
+local function respond_error(message)
+ respond(400, "Error", function()
+ print(string.format("<p>Error: %s</p>", escape(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 format_duration(t)
+ if t > 86400 then return string.format("%d day%s", t / 86400, math.floor(t / 86400) ~= 1 and "s" or "") end
+ if t > 3600 then return string.format("%d hour%s", t / 3600, math.floor(t / 3600) ~= 1 and "s" or "") end
+ if t > 60 then return string.format("%d minute%s", t / 60, math.floor(t / 60) ~= 1 and "s" or "") end
+ return string.format("%d seconds", t)
+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, username, amount, comment = string.match(l, "(%d+),([%w_ -]+),(-?%d+),([%w_ -]*)")
+ return tonumber(time), username, tonumber(amount), 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, amount, name = string.match(l, "([%w_-]+),(-?%d+),([%w_ -]*)")
+ return barcode, tonumber(amount), name
+ end
+end
+
+local function balances()
+ local users = {}
+ for _, username, amount, _ in read_log() do
+ users[username] = (users[username] or 0) + amount
+ end
+ return users
+end
+
+local function last_txns()
+ local users = {}
+ for time, username, _, _ in read_log() do
+ users[username] = time
+ end
+ return users
+end
+
+local function error_box(message)
+ return string.format([[<div class="notif error"><p>Error: %s</p></div>]], message)
+end
+
+local function r_user_post(username)
+ local data = form_data()
+ local amount = nil
+ local comment = ""
+ if data.product then
+ for p_barcode, p_amount, p_name in read_products() do
+ if p_barcode == data.product then
+ amount = p_amount
+ comment = p_name
+ end
+ end
+ if amount == nil then
+ return error_box("unknown product")
+ end
+ else
+ amount = tonumber(data.amount)
+ comment = data.comment or ""
+ end
+ if amount == nil then
+ return error_box("amount invalid")
+ end
+ if comment:match("^[%w_ -]*$") == nil then
+ return error_box("comment invalid")
+ end
+ local log = io.open("log", "a+")
+ if log == nil then
+ return error_box("failed to open log")
+ end
+ local time = os.time()
+ log:write(string.format("%d,%s,%d,%s\n", time, username, amount, comment))
+ log:flush()
+ log:close()
+ return string.format([[
+ <div class="notif"><p>Transaction successful: <strong class="amount-%s">%.02f€</strong> (%s)</p></div>
+ <audio src="%s" autoplay></audio>
+ ]],
+ amount >= 0 and "pos" or "neg", amount / 100,
+ escape(comment),
+ config.transaction_sound or ""
+ )
+end
+
+local function r_user(username)
+ local notif = nil
+ if method == "POST" then
+ notif = r_user_post(username)
+ end
+ return respond(200, string.format("Abrechenbarheit: %s", username), function()
+ print(string.format("<h1>%s</h1>", username))
+ 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 new_user then
+ print([[
+ <div class="notif"><p><i>This user account does not exist yet. It will only be created after the first transaction.</i></p></div>
+ ]])
+ else
+ print(string.format([[
+ <p><span class="balance">Current balance:</span><span class="amount-%s balance-value">%.02f€</p>
+ ]], balance >= 0 and "pos" or "neg", balance / 100))
+ print(string.format([[
+ <p>Last transaction added %s ago. <a href="/%s?log">View user log</a>
+ ]], format_duration(os.time() - last_txn), username))
+ end
+ print([[<div class="transactions container">]])
+ print([[<div class="amount-presets box">]])
+ for _, type in ipairs({ 1, -1 }) do
+ for _, amount in ipairs({ 50, 100, 150, 200, 500, 1000 }) do
+ print(string.format([[
+ <form action="" method="POST">
+ <input type="number" name="amount" id="amount" value="%d" hidden />
+ <input type="text" name="comment" id="comment" value="" hidden />
+ <input type="submit" value="%s%.02f€" class="amount-%s" />
+ </form>
+ ]], amount * type, ({ [-1] = "-", [1] = "+" })[type], amount / 100,
+ ({ [-1] = "neg", [1] = "pos" })[type]))
+ end
+ end
+ print("</div>")
+ print([[
+ <form class="transaction box" action="" method="POST">
+ <h3>Create Transaction</h3>
+ <label for="amount">Amount: </label>
+ <input type="number" name="amount" id="amount" />
+ <label for="comment">Comment: </label>
+ <input type="text" name="comment" id="comment" />
+ <input type="submit" value="Update" />
+ </form>
+ <form class="transaction box" action="" method="POST" id="buy_product">
+ <h3>Buy Product</h3>
+ <label for="product">Product: </label>
+ <input type="text" name="product" id="product" />
+ <input type="submit" value="Buy" />
+ </form>
+ ]])
+ print("</div>")
+ end)
+end
+
+local function r_log(filter)
+ return respond(200, "Abrechnungen", function()
+ print("<table>")
+ print("<tr><th>Time</th><th>Username</th><th>Amount</th><th>Comment</th></tr>")
+ for time, username, amount, comment in read_log() do
+ if filter == nil or filter == username then
+ print(string.format([[
+ <tr>
+ <td>%d (%s ago)</td>
+ <td>%s</td>
+ <td class="amount-%s">%.02f€</td>
+ <td>%s</td>
+ <td>
+ <form action="/%s" method="POST">
+ <input type="number" name="amount" id="amount" value="%d" hidden />
+ <input type="text" name="comment" id="comment" value="Revert %s" hidden />
+ <input type="submit" value="Revert" />
+ </form>
+ </td>
+ </tr>
+ ]],
+ time, format_duration(os.time() - time),
+ escape(username),
+ amount >= 0 and "pos" or "neg", amount / 100,
+ escape(comment),
+ escape(username),
+ -amount,
+ escape(comment)
+ ))
+ end
+ end
+ print("</table>")
+ end)
+end
+
+local function r_index()
+ return respond(200, "Abrechenbarkeit", function()
+ print([[
+ <form action="/" method="GET" class="creation">
+ <h3>User Creation</h3>
+ <label for="username">Username: </label>
+ <input type="text" name="create_user" id="username" />
+ <input type="submit" value="Continue" />
+ </form>
+ ]])
+ print("<ul>")
+ for username, balance in pairs(balances()) do
+ print(string.format([[
+ <li><a href="/%s"><span class="name">%s</span> <span class="amount amount-%s">%.02f€</span></a></li>
+ ]],
+ urlencode(username),
+ escape(username),
+ balance >= 0 and "pos" or "neg", balance / 100
+ ))
+ end
+ print("</ul>")
+ end)
+end
+
+local function r_create_user()
+ local username = query.create_user
+ if username:match("^([%w_ -]+)$") == nil then
+ return respond_error("invalid username " .. username)
+ end
+ return redirect(string.format("/%s", urlencode(username)))
+end
+
+local function r_products()
+ respond(200, "Abrechenbare Products", function()
+ print("<h1>Product List</h1>")
+ print("<table><tr><th>Name</th><th>Price</th><th>Barcode</th></tr>")
+ for barcode, price, name in read_products() do
+ print(string.format([[
+ <tr><td>%s</td><td class="amount-%s">%.02f€</td><td>%s</td></tr>
+ ]],
+ name,
+ price >= 0 and "pos" or "neg", price / 100,
+ barcode
+ ))
+ end
+ print("</table>")
+ end)
+end
+
+local function extract_username()
+ if path == nil then
+ return respond_error("no path")
+ end
+ local username = urldecode(path:sub(2))
+ if username == nil or username:match("^([%w_ -]+)$") == nil then
+ return nil
+ end
+ return username
+end
+
+if path == "/" then
+ if query.products then
+ return r_products()
+ elseif query.log then
+ return r_log()
+ elseif query.create_user then
+ return r_create_user()
+ else
+ return r_index()
+ end
+else
+ local username = extract_username()
+ if username == nil then
+ return respond_error("username invalid")
+ elseif query.log then
+ return r_log(username)
+ else
+ return r_user(username)
+ end
+end