#!/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_ -]+)", name_opt = "([%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 format_list(list) if list == nil then return nil end local not_first = false local out = "" for _, barcode in pairs(list) do if not_first then out = out .. ", " .. barcode else out = barcode end not_first = true end return out 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, pname, pcount, comment = string.match(l, format("^{time},{user},{user},{amount},{name_opt},{amount_opt},{comment_opt}$", matchers)) return tonumber(time), user_src, user_dst, tonumber(amount), pname, 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 price, user, name = string.match(l, format("^{amount},{user_opt},{name}$", matchers)) return tonumber(price), user, name end end local function read_barcodes() local log = io.open("barcodes", "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 name, barcode = string.match(l, format("^{name},{barcode}$", matchers)) return name, barcode 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 local barcode_name = nil if pcode ~= nil and pcode ~= "" then -- check if barcode exists local exists = false for p_name, p_barcode in read_barcodes() do if p_barcode == pcode then exists = true barcode_name = p_name end end if not exists then return error_box("{+error.unknown_barcode}") end -- check if product exists local exists = false for p_amount, p_user, p_name in read_products() do if barcode_name == p_name 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}"..barcode_name) end end -- for outside money 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, barcode_name 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.name}</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="name" 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 {oclass}><summary>{+users.inactive_list}</summary><ul class="userlist"> ]], { oclass = query.prefix and "open" or "" })) 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() if data.deletebarcode then local barcode = data.barcode -- remove barcode local new_barcodes = io.open("barcodes.new", "w+") if new_barcodes == nil then return error_box("{+error.open_new_barcodes}") end for name, a_barcode in read_barcodes() do if barcode ~= a_barcode then new_barcodes:write(string.format("%s,%s\n", name, a_barcode)) end end new_barcodes:flush() new_barcodes:close() os.rename("barcodes.new", "barcodes") return end local name = data.name if name == nil or name:match("^[%w_-]*$") == nil then return error_box("{+error.invalid_name}") 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 price, user, a_name in read_products() do if name ~= a_name then new_products:write(string.format("%d,%s,%s\n", price, user, a_name)) end end new_products:flush() new_products:close() os.rename("products.new", "products") elseif data.addbarcode then -- add barcode local name = data.name local barcode = data.barcode if name == nil or name:match(matchers_global.name) == nil then return error_box("{+error.invalid_name}") end if barcode == nil or barcode:match(matchers_global.barcode) == nil then return error_box("{+error.invalid_barcode}") end local exists = false for _, a_barcode in read_barcodes() do if a_barcode == barcode then exists = true end end if exists then return error_box("{+error.invalid_barcode}") end local barcodes = io.open("barcodes", "a+") if barcodes == nil then return error_box("{+error.open_barcodes}") end barcodes:write(string.format("%s,%s\n", name, barcode)) barcodes:flush() barcodes:close() 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_name}") 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("%d,%s,%s\n", 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" /> <input type="submit" value="{+products.form.add.submit}" class="amount-ntr button" /> </form> <form action="/?products" method="POST" class="box backgroundbox"> <h3>{+products.form.addbarcode}</h3> <input type="text" name="addbarcode" value="1" hidden /> <label for="name">{+field.name}: </label> <select type="text" name="name" id="name"> ]], { currency = config.unit or "€", })) for _, _, name in read_products() do print(format([[<option value="{name}">{name}</option>]], { name = name, })) end print(format([[</select> <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="name">{+field.name}: </label> <select type="text" name="name" id="name"> ]], { currency = config.unit or "€", })) for _, _, name in read_products() do print(format([[<option value="{name}">{!name}</option>]], { name = name, })) end print(format([[</select> <input type="submit" value="{+products.form.remove.submit}" class="amount-ntr button" /> </form> <!-- remove barcode --> <form action="/?products" method="POST" class="box backgroundbox"> <h3>{+products.form.removebarcode}</h3> <input type="text" name="deletebarcode" value="1" hidden /> <label for="barcode">{+field.barcode}: </label> <select type="text" name="barcode" id="barcode"> ]], { currency = config.unit or "€", })) local barcodes = {} for name, barcode in read_barcodes() do print(format([[<option value="{barcode}">{!barcode} ({!name})</option>]], { name = name, barcode = barcode, })) if barcodes[name] ~= nil then table.insert(barcodes[name], barcode) else barcodes[name] = {} table.insert(barcodes[name], barcode) end end print(format([[</select> <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.count}</th> <th>{+field.user}</th> <th>{+field.barcode}</th> </tr>]])) local pbals = product_balances() for price, user, name in read_products() do print(format([[<tr> <td>{!name}</td> {price} <td>{!count}</td> <td>{!user}</td> <td>{barcodes}</td> </tr>]], { name = name, price = format_amount(-price, "td"), count = tostring(pbals[barcode] or 0), user = user, barcodes = format_list(barcodes[name]) or "{+field.not_set}", })) 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