abrechenbarkeit/abrechenbarkeit.lua

507 lines
18 KiB
Lua
Raw Normal View History

2024-10-30 01:00:27 +00:00
#!/usr/bin/env luajit
local function escape(s)
return s:gsub("<", "&lt;"):gsub("<", "&lt;")
end
2024-10-31 01:00:42 +00:00
2024-10-30 12:50:36 +00:00
local function urldecode(s)
if s == nil then return nil end
return s:gsub("+", " "):gsub("%%20", " ")
end
2024-10-31 01:00:42 +00:00
2024-10-30 12:50:36 +00:00
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
2024-10-30 22:09:58 +00:00
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()
2024-10-30 12:50:36 +00:00
local path = os.getenv("PATH_INFO")
local method = os.getenv("REQUEST_METHOD")
local query = parse_query(os.getenv("QUERY_STRING"))
2024-11-03 17:38:21 +00:00
local stylesheet = io.open("style.css"):read("a")
local script = io.open("script.js"):read("a")
2024-10-30 12:50:36 +00:00
2024-10-30 01:00:27 +00:00
local function respond(status, title, body)
print(string.format("Status: %d", status))
print("Content-Type: text/html")
print("")
print(string.format([[
2024-10-30 21:00:15 +00:00
<!DOCTYPE html>
2024-10-30 01:00:27 +00:00
<html><head>
<title>%s</title>
<meta charset="utf-8" />
2024-10-30 12:50:36 +00:00
<style>%s</style>
<script>%s</script>
2024-10-30 22:09:58 +00:00
%s
2024-10-30 12:50:36 +00:00
</head>
<body>
<nav>
2024-11-03 17:31:13 +00:00
<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>
2024-10-30 12:50:36 +00:00
</nav>
2024-10-30 22:09:58 +00:00
]], escape(title), stylesheet, script, config.head_extra or ""))
2024-10-30 01:00:27 +00:00
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
2024-10-30 11:20:03 +00:00
local function redirect(path)
print("Status: 307")
print(string.format("Location: %s", path))
print()
end
2024-10-30 01:00:27 +00:00
local function form_data()
2024-10-30 12:50:36 +00:00
return parse_query(io.read())
2024-10-30 01:00:27 +00:00
end
2024-10-30 21:00:15 +00:00
local function format_duration(t)
2024-10-31 00:44:12 +00:00
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
2024-10-30 21:00:15 +00:00
return string.format("%d seconds", t)
end
2024-10-30 01:00:27 +00:00
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()
2024-10-30 12:50:36 +00:00
if l == "" or l == nil then
return nil
end
2024-11-03 18:38:24 +00:00
local time, username, amount, pcode, pcount, comment = string.match(l,
"(%d+),([%w_ -]+),(-?%d+),([%w_-]*),(-?%d*),([%w_ -]*)")
return tonumber(time), username, tonumber(amount), pcode, tonumber(pcount), comment
2024-10-30 01:00:27 +00:00
end
end
2024-10-31 01:00:42 +00:00
2024-10-30 12:50:36 +00:00
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
2024-11-03 01:20:52 +00:00
local barcode, price, name = string.match(l, "([%w_-]+),(-?%d+),([%w_ -]*)")
return barcode, tonumber(price), name
2024-10-30 12:50:36 +00:00
end
end
2024-10-30 01:00:27 +00:00
2024-10-30 01:11:09 +00:00
local function balances()
local users = {}
2024-11-03 18:38:24 +00:00
for _, username, amount, _, _, _ in read_log() do
2024-10-30 01:11:09 +00:00
users[username] = (users[username] or 0) + amount
end
return users
end
2024-10-31 01:00:42 +00:00
2024-11-03 18:38:24 +00:00
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
2024-10-30 21:00:15 +00:00
local function last_txns()
local users = {}
2024-11-03 18:38:24 +00:00
for time, username, _, _, _, _ in read_log() do
2024-10-30 21:00:15 +00:00
users[username] = time
2024-10-30 01:00:27 +00:00
end
2024-10-30 21:00:15 +00:00
return users
end
local function get_active_users()
2024-11-03 21:34:04 +00:00
local user_balances = {}
for time, username, amount, _, _, _ in read_log() do
user_balances[username] = {
2024-11-03 21:34:04 +00:00
time = time,
username = username,
balance = (user_balances[username] or { balance = 0 }).balance + amount
}
end
2024-11-03 21:34:04 +00:00
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 end)
return users
end
2024-10-30 21:00:15 +00:00
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()
2024-11-03 20:21:35 +00:00
local amount = tonumber(data.amount)
local comment = data.comment
2024-11-03 18:38:24 +00:00
local pcode = nil
local pcount = nil
2024-11-03 20:21:35 +00:00
if data.pcode then
2024-10-30 21:00:15 +00:00
for p_barcode, p_amount, p_name in read_products() do
2024-11-03 20:21:35 +00:00
if p_barcode == data.pcode then
pcount = (tonumber(data.pcount) or 1) * (data.negate_pcount ~= nil and -1 or 1)
2024-11-03 18:38:24 +00:00
pcode = p_barcode
2024-11-03 20:21:35 +00:00
if amount == nil then amount = pcount * p_amount end
2024-11-03 21:34:04 +00:00
if comment == nil then comment = string.format("%s %d %s", pcount < 0 and "Buy" or "Restock",
math.abs(pcount), p_name) end
2024-10-30 12:50:36 +00:00
end
end
2024-10-30 01:00:27 +00:00
if amount == nil then
2024-10-30 21:00:15 +00:00
return error_box("unknown product")
2024-10-30 01:00:27 +00:00
end
end
2024-10-30 21:00:15 +00:00
if amount == nil then
return error_box("amount invalid")
end
2024-11-03 20:21:35 +00:00
if comment == nil or comment:match("^[%w_ -]*$") == nil then
2024-10-30 21:00:15 +00:00
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()
2024-11-03 18:38:24 +00:00
log:write(string.format("%d,%s,%d,%s,%s,%s\n", time, username, amount, pcode or "", pcount or "", comment))
2024-10-30 21:00:15 +00:00
log:flush()
log:close()
2024-10-30 22:09:58 +00:00
return string.format([[
2024-10-30 23:19:10 +00:00
<div class="notif"><p>Transaction successful: <strong class="amount-%s">%.02f</strong> (%s)</p></div>
<audio src="%s" autoplay></audio>
]],
2024-10-30 22:09:58 +00:00
amount >= 0 and "pos" or "neg", amount / 100,
escape(comment),
config.transaction_sound or ""
2024-10-30 21:00:15 +00:00
)
end
2024-10-30 01:00:27 +00:00
2024-10-30 21:00:15 +00:00
local function r_user(username)
local notif = nil
if method == "POST" then
notif = r_user_post(username)
end
2024-11-03 17:31:13 +00:00
return respond(200, string.format("Abrechenbarheit: %s", username), function()
2024-10-30 01:00:27 +00:00
print(string.format("<h1>%s</h1>", username))
2024-10-30 12:50:36 +00:00
local balance = balances()[username]
2024-10-30 21:00:15 +00:00
local last_txn = last_txns()[username]
2024-10-30 12:50:36 +00:00
local new_user = balance == nil
balance = balance or 0
2024-10-30 22:09:58 +00:00
if notif then print(notif) end
2024-10-30 12:50:36 +00:00
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>
]])
2024-10-30 22:09:58 +00:00
else
2024-11-03 21:31:20 +00:00
print([[<div class="backgroundbox userinfo">]])
2024-10-30 22:09:58 +00:00
print(string.format([[
2024-11-03 21:31:20 +00:00
Current balance:<br><span class="amount-%s balance-value">%.02f</span><br>
2024-10-30 22:09:58 +00:00
]], balance >= 0 and "pos" or "neg", balance / 100))
print(string.format([[
2024-11-03 21:31:20 +00:00
Last transaction added %s ago. <a href="/%s?log">View user log</a>
2024-10-30 22:09:58 +00:00
]], format_duration(os.time() - last_txn), username))
2024-11-03 21:31:20 +00:00
print([[</div>]])
2024-10-30 12:50:36 +00:00
end
2024-11-03 18:38:22 +00:00
print([[<div class="transactions container firstchildlarge">]])
2024-11-03 20:21:35 +00:00
print([[<div class="amount-presets backgroundbox">]])
2024-11-03 17:31:13 +00:00
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 button" />
2024-11-03 17:31:13 +00:00
</form>
]], amount * type, ({ [-1] = "-", [1] = "+" })[type], amount / 100,
({ [-1] = "neg", [1] = "pos" })[type]))
end
end
print("</div>")
2024-10-30 01:00:27 +00:00
print([[
2024-11-03 18:38:22 +00:00
<form class="transaction box backgroundbox" action="" method="POST">
2024-11-03 20:29:52 +00:00
<h3>Create Transaction</h3>
<label for="amount">Amount (ct): </label>
2024-11-03 17:31:13 +00:00
<input type="number" name="amount" id="amount" />
2024-10-30 01:00:27 +00:00
<label for="comment">Comment: </label>
2024-11-03 17:31:13 +00:00
<input type="text" name="comment" id="comment" />
<input type="submit" value="Update" class="amount-ntr button" />
2024-10-30 01:00:27 +00:00
</form>
2024-11-03 18:38:22 +00:00
<form class="transaction box backgroundbox" action="" method="POST" id="buy_product">
2024-10-30 21:00:15 +00:00
<h3>Buy Product</h3>
2024-11-03 20:21:35 +00:00
<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">Product Barcode: </label>
<input type="text" name="pcode" id="pcode" />
<input class="amount-neg button" type="submit" value="Buy" />
2024-10-30 21:00:15 +00:00
</form>
2024-11-03 20:21:35 +00:00
<form class="transaction box backgroundbox" action="" method="POST" id="buy_product">
<h3>Restock Product</h3>
<label for="pcount">Count: </label>
<input type="number" name="pcount" id="pcount" value="1" />
<label for="amount">Upstream price: </label>
<input type="number" name="amount" id="amount" />
<label for="pcode">Product Barcode: </label>
<input type="text" name="pcode" id="pcode" />
2024-11-03 20:29:52 +00:00
<input type="submit" value="Restock" class="button amount-pos" />
2024-11-03 20:21:35 +00:00
</form>
2024-10-30 01:00:27 +00:00
]])
2024-10-30 12:50:36 +00:00
print("</div>")
2024-10-30 01:00:27 +00:00
end)
end
2024-10-30 11:20:03 +00:00
2024-10-30 21:00:15 +00:00
local function r_log(filter)
2024-11-03 17:31:13 +00:00
return respond(200, "Abrechnungen", function()
print([[<table class="log">]])
2024-11-03 20:21:35 +00:00
print([[<tr>
<th>Time</th>
<th>Username</th>
<th>Amount</th>
<th>P.-Barcode</th>
<th>P.-Count</th>
<th>Comment</th>
<th>Actions</th>
</tr>]])
2024-11-03 18:38:24 +00:00
for time, username, amount, pcode, pcount, comment in read_log() do
2024-10-30 21:00:15 +00:00
if filter == nil or filter == username then
2024-10-30 23:19:10 +00:00
print(string.format([[
<tr>
2024-11-03 21:34:04 +00:00
<td>%s (%s ago)</td>
2024-10-30 23:19:10 +00:00
<td>%s</td>
2024-10-31 00:44:12 +00:00
<td class="amount-%s">%.02f</td>
2024-10-30 23:19:10 +00:00
<td>%s</td>
2024-11-03 20:21:35 +00:00
<td>%s %s</td>
2024-11-03 18:38:24 +00:00
<td>%s</td>
2024-10-30 23:19:10 +00:00
<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" class="amount-ntr button" value="Revert" />
2024-10-30 23:19:10 +00:00
</form>
</td>
</tr>
]],
2024-11-03 21:34:04 +00:00
os.date("!%Y-%m-%dT%H:%M:%SZ", time), format_duration(os.time() - time),
2024-10-30 21:00:15 +00:00
escape(username),
amount >= 0 and "pos" or "neg", amount / 100,
2024-11-03 18:38:24 +00:00
escape(pcode) or "",
2024-11-03 20:21:35 +00:00
pcount and (pcount < 0 and "buy" or "stock") or "", pcount and tostring(math.abs(pcount)) or "",
2024-10-30 23:19:10 +00:00
escape(comment),
escape(username),
-amount,
2024-10-30 21:00:15 +00:00
escape(comment)
))
end
2024-10-30 11:20:03 +00:00
end
print("</table>")
end)
end
local function r_index()
2024-11-03 17:31:13 +00:00
return respond(200, "Abrechenbarkeit", function()
2024-10-30 11:20:03 +00:00
print([[
2024-11-03 17:31:13 +00:00
<form action="/" method="GET" class="creation">
2024-10-30 13:15:18 +00:00
<h3>User Creation</h3>
2024-10-30 11:20:03 +00:00
<label for="username">Username: </label>
2024-11-03 17:31:13 +00:00
<input type="text" name="create_user" id="username" />
<input type="submit" value="Continue" class="button amount-ntr" />
2024-10-30 11:20:03 +00:00
</form>
]])
print("<ul>")
2024-11-03 21:34:04 +00:00
for _, user in ipairs(get_active_users()) do
2024-10-31 00:44:12 +00:00
print(string.format([[
2024-11-03 17:31:13 +00:00
<li><a href="/%s"><span class="name">%s</span> <span class="amount amount-%s">%.02f</span></a></li>
2024-10-31 00:44:12 +00:00
]],
urlencode(user.username),
escape(user.username),
user.balance >= 0 and "pos" or "neg", user.balance / 100
2024-10-31 00:44:12 +00:00
))
2024-10-30 11:20:03 +00:00
end
print("</ul>")
end)
end
local function r_create_user()
2024-10-30 12:50:36 +00:00
local username = query.create_user
if username:match("^([%w_ -]+)$") == nil then
return respond_error("invalid username " .. username)
2024-10-30 11:20:03 +00:00
end
2024-10-30 12:50:36 +00:00
return redirect(string.format("/%s", urlencode(username)))
2024-10-30 11:20:03 +00:00
end
2024-11-03 01:20:52 +00:00
local function r_products_post()
local data = form_data()
local barcode = data.barcode
if barcode == nil then
return error_box("barcode unset")
end
if barcode:match("^[%w_-]*$") == nil then
return error_box("barcode invalid")
end
if data.delete then
local new_products = io.open("products.new", "w+")
if new_products == nil then
return error_box("failed to open new products")
end
for a_barcode, price, name in read_products() do
if barcode ~= a_barcode then
new_products:write(string.format("%s,%d,%s\n", a_barcode, price, name))
end
end
new_products:flush()
new_products:close()
os.rename("products.new", "products")
else
local price = tonumber(data.price)
local name = data.name
if price == nil then
return error_box("price invalid")
end
if name:match("^[%w_ -]*$") == nil then
return error_box("name invalid")
end
local products = io.open("products", "a+")
if products == nil then
return error_box("failed to open products")
end
products:write(string.format("%s,%d,%s\n", barcode, price, name))
products:flush()
products:close()
end
end
2024-10-31 01:00:42 +00:00
local function r_products()
2024-11-03 01:20:52 +00:00
local notif = nil
if method == "POST" then
notif = r_products_post()
end
respond(200, "Abrechenbare Product List", function()
2024-10-31 01:00:42 +00:00
print("<h1>Product List</h1>")
2024-11-03 01:20:52 +00:00
if notif then print(notif) end
print([[
2024-11-03 18:38:22 +00:00
<div class="container">
<form action="/?products" method="POST" class="box backgroundbox">
2024-11-03 01:20:52 +00:00
<h3>Add Product</h3>
<label for="name">Name: </label>
2024-11-03 18:38:22 +00:00
<input type="text" name="name" id="name" />
<label for="price">Price (ct): </label>
2024-11-03 20:05:03 +00:00
<input type="number" name="price" id="price" />
2024-11-03 20:21:35 +00:00
<label for="barcode">Barcode: </label>
<input type="text" name="barcode" id="barcode" />
2024-11-03 20:05:03 +00:00
<input type="submit" value="Add" class="amount-ntr button" />
2024-11-03 01:20:52 +00:00
</form>
2024-11-03 18:38:22 +00:00
<form action="/?products" method="POST" class="box backgroundbox">
2024-11-03 01:20:52 +00:00
<h3>Remove Product</h3>
<input type="text" name="delete" value="1" hidden />
<label for="barcode">Barcode: </label>
2024-11-03 18:38:22 +00:00
<input type="text" name="barcode" id="barcode" />
2024-11-03 20:05:03 +00:00
<input type="submit" value="Remove"class="amount-ntr button" />
2024-11-03 01:20:52 +00:00
</form>
2024-11-03 18:38:22 +00:00
</div>
2024-11-03 01:20:52 +00:00
]])
print([[<table class="productlist"><tr><th>Name</th><th>Price</th><th>Barcode</th><th>Count</th></tr>]])
2024-11-03 18:38:24 +00:00
local pbals = product_balances()
2024-10-31 01:00:42 +00:00
for barcode, price, name in read_products() do
print(string.format([[
2024-11-03 18:38:24 +00:00
<tr><td>%s</td><td class="amount-%s">%.02f</td><td>%s</td><td>%s</td></tr>
2024-10-31 01:00:42 +00:00
]],
name,
2024-11-03 20:21:35 +00:00
-price >= 0 and "pos" or "neg", -price / 100,
2024-11-03 18:38:24 +00:00
barcode,
pbals[barcode] or "0"
2024-10-31 01:00:42 +00:00
))
end
print("</table>")
end)
end
2024-10-30 21:00:15 +00:00
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
2024-10-30 11:20:03 +00:00
if path == "/" then
2024-10-31 01:00:42 +00:00
if query.products then
return r_products()
elseif query.log then
2024-10-30 11:20:03 +00:00
return r_log()
2024-10-30 12:50:36 +00:00
elseif query.create_user then
2024-10-30 11:20:03 +00:00
return r_create_user()
else
return r_index()
end
else
2024-10-30 21:00:15 +00:00
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
2024-10-30 11:20:03 +00:00
end