#!/usr/bin/env luajit
--[[
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 .
]] --
local function escape(s)
return s:gsub("<", "<"):gsub("<", "<")
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 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, "^([^=]+)=([^=]*)")
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
return string.gsub(template, "{([%w_\\.!]+)}", function(n)
local raw = n:sub(1, 1) ~= "!"
if not raw then n = n:sub(2) end
local s = format(params[n] or translations[n] or "NIL PARAM", params)
return raw and s or escape(s)
end)
end
local function format_amount(amount, tag, classes)
local s = string.format("%.02f%s", amount / 100, config.unit or "€")
if tag == nil then return s end
return string.format([[<%s class="amount-%s %s">%s%s>"]], tag, amount >= 0 and "pos" or "neg", s, tag, classes or "")
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)
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 respond(status, title, body)
print(string.format("Status: %d", status))
print("Content-Type: text/html")
print("")
print(format([[
{title}
{head_extra}
]], {
title = escape(title),
style = stylesheet,
user_style = get_user_theme(path and path:sub(2)),
script = script,
head_extra = config.head_extra or ""
}))
body()
print("")
end
local function error_box(message)
return string.format([[
Error: %s
]], message)
end
local function respond_error(message)
respond(400, "Error", function()
print(error_box(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, username, amount, pcode, pcount, comment = string.match(l,
"(%d+),([%w_ -]+),(-?%d+),([%w_-]*),(-?%d*),([%w_ -]*)")
return tonumber(time), username, 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, name, owner = string.match(l, "([%w_-]+),(-?%d+),([%w_ -]*),([%w_ -]*)")
return barcode, tonumber(price), name, owner
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 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, username, _, _, _, _ in read_log() do
users[username] = time
end
return users
end
local function get_active_users()
local user_balances = {}
for time, username, amount, _, _, _ in read_log() do
user_balances[username] = {
time = time,
username = username,
balance = (user_balances[username] 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 end)
return users
end
local function r_user_post(username)
local data = form_data()
local amount = tonumber(data.amount)
local comment = data.comment
local pcode = nil
local pcount = nil
local powner = nil
local powner_comment = nil
if data.pcode then
for p_barcode, p_amount, p_name, p_owner in read_products() do
if p_barcode == data.pcode then
powner = p_owner
pcount = (tonumber(data.pcount) or 1) * (data.negate_pcount ~= nil and -1 or 1)
pcode = p_barcode
if amount == nil then amount = pcount * p_amount end
if comment == nil then
comment = string.format("%s %d %s", pcount < 0 and "Buy" or "Restock",
math.abs(pcount), p_name)
powner_comment = string.format("%s %d %s %s %s",
pcount < 0 and "Sell" or "Restock",
math.abs(pcount), p_name,
pcount < 0 and "to" or "by",
username)
end
end
end
if amount == nil then
return error_box("unknown product")
end
end
if amount == nil then
return error_box("amount invalid")
end
if comment == nil or 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()
-- subtract from buyer
log:write(string.format("%d,%s,%d,%s,%s,%s\n", time, username, amount, pcode or "", pcount or "", comment))
-- add to owner
if powner then
-- count is always zero as doesn't affect stock
log:write(string.format("%d,%s,%d,%s,%s,%s\n", time, powner, -amount, pcode or "", "", powner_comment))
end
log:flush()
log:close()
return format([[
Transaction successful: {amount} ({!comment})
]], {
sign = amount >= 0 and "pos" or "neg",
amount = format_amount(amount, "strong"),
comment = escape(comment),
sound = 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(format("
{username}
", { username = 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([[
]])
for _, type in ipairs({ 1, -1 }) do
for _, amount in ipairs({ 50, 100, 150, 200, 500, 1000 }) do
print(string.format([[
]], amount * type, ({ [-1] = "-", [1] = "+" })[type], amount / 100,
({ [-1] = "neg", [1] = "pos" })[type]))
end
end
print("
")
print(format([[
]]))
print("
")
end)
end
local function r_log(filter)
return respond(200, "Abrechnungen", function()
print([[
Time
Username
Amount
P.-Barcode
P.-Count
Comment
Actions
]])
print("")
for time, username, amount, pcode, pcount, comment in read_log() do
if filter == nil or filter == username then
print(string.format([[
%s (%s ago)
%s
%.02f€
%s
%s %s
%s
]],
os.date("!%Y-%m-%dT%H:%M:%SZ", time), format_duration(os.time() - time),
escape(username),
amount >= 0 and "pos" or "neg", amount / 100,
escape(pcode) or "",
pcount and (pcount < 0 and "buy" or "stock") or "", pcount and tostring(math.abs(pcount)) or "",
escape(comment),
escape(username),
-amount,
escape(comment)
))
end
end
print("")
print("
")
end)
end
local function r_index()
return respond(200, "Abrechenbarkeit", function()
print([[
]])
print([[]]) -- for printing
print([[
]])
for _, user in ipairs(get_active_users()) do
print(string.format([[
]],
urlencode(user.username),
escape(user.username),
user.balance >= 0 and "pos" or "neg", user.balance / 100
))
end
print("
")
end)
end
local function validate_username(username)
-- disallow leading or traling whitespace
return username ~= nil
and username:match("^([%w_ -]+)$") ~= nil
and username:match("^%s") == nil
and username:match("%s$") == nil
end
local function r_create_user()
local username = query.create_user
if not validate_username(username) then
return respond_error("invalid username " .. username)
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 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, owner in read_products() do
if barcode ~= a_barcode then
new_products:write(string.format("%s,%d,%s,%s\n", a_barcode, price, name, owner))
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
local owner = data.owner or ""
if name:match("^[%w_ -]*$") == nil then
return error_box("owner invalid")
end
products:write(string.format("%s,%d,%s,%s\n", barcode, price, name, owner))
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("
Product List
")
if notif then print(notif) end
print([[
]])
print([[
Name
Price
Barcode
Count
Owner
]])
local pbals = product_balances()
for barcode, price, name, owner in read_products() do
print(string.format([[
%s
%.02f€
%s
%s
%s
]],
name,
-price >= 0 and "pos" or "neg", -price / 100,
barcode,
pbals[barcode] or "0",
owner
))
end
print("
")
end)
end
local function r_about()
respond(200, "About Abrechenbarkeit", function()
print([[
About Abrechenbarkeit
Abrechenbarkeit is a simple trust-based ledger for keeping track of money spent on product.
Abrechenbarkeit is free software. It is licensed exclusively GNU Affero General Public License Version 3 only.
]])
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.about then
return r_about()
elseif 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 or not validate_username(username) then
return respond_error("username invalid")
elseif query.log then
return r_log(username)
else
return r_user(username)
end
end