#!/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 .
]] --
-- 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([[
{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 ""
}))
if config.header ~= nil then
print(config.header)
end
body()
if config.footer ~= nil then
print(config.footer)
end
print("")
end
local function error_box(message, params)
return string.format([[
Error: %s
]], 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([[
]], {
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 "
{!username}
" or "
{!username}
",
{ 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([[
]])
for _, type in ipairs({ 1, -1 }) do
for _, amount in ipairs({ 50, 100, 150, 200, 500, 1000 }) do
local a = amount * type
print(format([[]], {
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("
")
print(format([[
]], { username = username, disable_class = is_special and "disabled" or "" }))
print("
")
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([[
{+field.time}
{+field.username}
{+field.amount}
{+field.barcode}
{+field.count}
{+field.comment}
{+log.actions}
]]))
print("")
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([[
]], {
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("")
print("
")
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([[
]]))
-- 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([[
{+users.filter}
]]))
for k, letter in ipairs(firstletters) do
print(format([[
]])
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 (filter_negative ~= nil and user.balance < filter_negative)
or (filter_negative == nil and is_spu == show_special and not filter_out) then
print(format([[
]], {
username_url = urlencode(user.name),
username = user.name:gsub("@", ""),
balance = format_amount(user.balance, "span")
}))
end
end
local inactive_cutoff = os.time() - (tonumber(config.inactive_cutoff) or (30 * 24 * 60 * 60))
for _, user in ipairs(get_active_users()) do
if user.time > inactive_cutoff then show_user(user) end
end
print("
")
print(format([[{+users.inactive_list}
]]))
for _, user in ipairs(users) do
if user.time <= inactive_cutoff then show_user(user) end
end
print([[
]])
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("
{+products.title}
"))
if notif then print(notif) end
print(format([[
]], {
currency = config.unit or "€",
}))
print(format([[
{+field.name}
{+field.price}
{+field.barcode}
{+field.count}
{+field.user}
]]))
local pbals = product_balances()
for barcode, price, user, name in read_products() do
print(format([[
{!name}
{price}
{!barcode}
{!count}
{!user}
]], {
name = name,
price = format_amount(-price, "td"),
barcode = barcode,
count = tostring(pbals[barcode] or 0),
user = user,
}))
end
print("
")
end)
end
local function r_about()
respond(200, "About Abrechenbarkeit", function()
print(format([[
{+about.title}
{+about.desc}
{+about.license}
{+about.source}
{+about.thanks}
]], {
issues = [[]],
codeberg = [[]],
ae = [[]],
}))
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)
-- tonumber(query.negative or 0) or nil)
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