Initial commit

This commit is contained in:
Shy 2024-08-22 18:20:02 +02:00
commit 14602222e7
3 changed files with 603 additions and 0 deletions

121
LICENSE Normal file
View file

@ -0,0 +1,121 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.

29
README.md Normal file
View file

@ -0,0 +1,29 @@
# c4mate - command line client for the C4 mukas instance (aka Mate-Pad)
## Requirements
* lua
* curl
* sed
* date
## Usage
c4mate [-u <user>] [ <query> | -b <id> | -l | +/-<amount> | -g <amount> <user> [<reason>]]
Display current balance:
c4mate
Find and buy item (interactive):
c4mate <query>
Buy by item id (non-interactive):
c4mate -b|--buy <id>
Show log:
c4mate -l|--log
Add or subtract credits:
c4mate <difference>
Give credits:
c4mate -g <amount> <user> [<reason>]
## License
CC0

453
c4mate Executable file
View file

@ -0,0 +1,453 @@
#!/bin/lua
--
-- c4mate: command line client for the C4 mukas instance (aka Mate-Pad).
--
-- Author: Shy
-- License: CC0
-- Enter your user name here:
USER = nil
HOST = "http://mate.labor.koeln.ccc.de"
function utf8_decode(str)
-- Replace JSON unicode escape sequences with chars.
if str == nil then return "" end
return string.gsub(str, "\\u(%x%x%x%x)",
function (s) return utf8.char(tonumber(s, 16)) end)
end
function url_encode(str)
-- URL-encode string. Used only for usernames.
-- Todo: make this more sophisticated.
return string.gsub(str, " ", "%%20")
end
MatePad = {
CURL_FLAGS = "--silent --show-error --fail --user-agent 'c4mate' --max-redirs 0 --max-filesize 65536",
}
function MatePad:new(HOST, USER)
local o = {
HOST = HOST,
USER = USER,
COOKIE_JAR,
balance
}
-- Metatable. Make garbage collector delete our temporary cookie jar.
local m = {
__index = self,
__gc = function (f)
if f.COOKIE_JAR ~= nil then os.remove(f.COOKIE_JAR) end
end
}
return setmetatable(o, m)
end
-- Fetch response from given api endpoint.
function MatePad:fetch(api, data)
local responce, curl, ok, exit, signal
-- Set up cookie jar if not yet present.
if self.COOKIE_JAR == nil then self.COOKIE_JAR = os.tmpname() end
if data ~= nil then
local flags = ""
for k, v in pairs(data) do
flags = string.format("%s --data-urlencode \"%s=%s\"", flags, k, v)
end
curl = io.popen(string.format(
"curl %s --cookie \"%s\" --cookie-jar \"%s\" %s \"%s/%s\"",
self.CURL_FLAGS, self.COOKIE_JAR, self.COOKIE_JAR, flags, self.HOST, api))
else
curl = io.popen(string.format(
"curl %s --cookie \"%s\" --cookie-jar \"%s\" \"%s/%s\"",
self.CURL_FLAGS, self.COOKIE_JAR, self.COOKIE_JAR, self.HOST, api))
end
responce = curl:read("a")
ok, exit, signal = curl:close()
if not ok then
if exit == "signal" then
io.stderr:write("Error: 'curl' received signal "..signal..".\n")
else
io.stderr:write("Error: 'curl' returned exit code "..signal..". ")
if signal == 22 then
io.stderr:write("Maybe incorrect username?\n")
else
io.stderr:write("\n")
end
end
end
return responce
end
-- Initialize session.
function MatePad:init()
self.token = self:fetch("api/csrf_token")
-- Todo: check token.
end
-- Update user balance.
function MatePad:update_balance()
self.balance = self:fetch("api/user/"..url_encode(self.USER).."/balance?_csrf_token="..self.token)
return self.balance ~= ""
end
-- Parse history.
function MatePad:parse_log()
local pipe, ok, exit, signal
local datefile = os.tmpname()
local log = {}
-- sed #1: Insert newlines between log entries.
-- sed #2: Extract time and write it into datefile. Then extract and print
-- oldbalance, newbalance, parameter and method. Depending on method, print
-- either reason or item_name. If any extraction fails we fail with exit
-- code 1.
pipe = io.popen(string.format([=[
curl %s --header "Accept: application/json" "%s/api/user/%s/log?type=json" | \
sed -n --sandbox '
/.*\[\([^]]*\)\].*/{
s//\1/
s/} *,/&\n/gp
}' | \
sed -n '
h
s/.*"time" *: *"\([^"]\+\)".*/\1UTC/
T fail
w %s
g
s/.*"oldbalance" *: *\(-\?[0-9]\+\).*/\1/p
T fail
g
s/.*"newbalance" *: *\(-\?[0-9]\+\).*/\1/p
T fail
g
s/.*"parameter" *: *\([0-9]\+\).*/\1/p
T fail
g
s/.*"method" *: *"\([^"]\+\)".*/\1/p
T fail
/set_balance\|transfer/{
g
s/.*"reason" *: *\(null\|"\([^"]*\)"\).*/\2/p
T fail
b
}
g
s/.*"item_name" *: *\(null\|"\([^"]*\)"\).*/\2/p
T fail
b
:fail
q 1
']=], self.CURL_FLAGS, self.HOST, url_encode(self.USER), datefile))
while true do
local entry = {}
local oldbalance = tonumber(pipe:read("l"))
if oldbalance == nil then break end
entry.oldbalance = oldbalance
entry.newbalance = tonumber(pipe:read("l"))
entry.parameter = tonumber(pipe:read("l"))
entry.method = pipe:read("l")
if entry.method == "buy" or entry.method == "recharge" then
entry.name = utf8_decode(pipe:read("l"))
else
-- set_balance, transferTo, transferFrom
entry.reason = utf8_decode(pipe:read("l"))
end
table.insert(log, entry)
end
-- Check pipe exit status.
ok, exit, signal = pipe:close()
if not ok then -- sed error
io.stderr:write("Sorry, there was an error while parsing the API response.\n")
-- An empty log will throw no further exceptions.
log = {}
elseif log == {} then -- curl error
io.stderr:write("Sorry, there was an error while connecting the API.\n")
end
-- Convert timestamps to local time.
local timestamps = io.popen("date -f "..datefile.." +'%_c'")
for i = 1, #log do
log[i].time = timestamps:read("l")
end
-- Check date exit code.
ok, exit, signal = timestamps:close()
os.remove(datefile)
if not ok then
io.stderr:write(string.format(
"Warning: 'date' exited with non-zero exit code (%s/%s).\n",
exit, signal))
end
return log
end
-- Print given history.
function MatePad:print_log(log)
local event
for i = #log, 1, -1 do
event = log[i]
if event.method == "buy" or event.method == "recharge" then
print(string.format(
"[%s] Bought %s",
event.time, event.name
))
elseif event.method == "set_balance" then
print(string.format(
"[%s] Set balance from %.2f € to %.2f €",
event.time, event.oldbalance/100, event.newbalance/100
))
elseif event.method == "transferTo" then
print(string.format(
"[%s] Transferred %.2f € to #%d (%s)",
event.time, (event.oldbalance - event.newbalance)/100,
event.parameter, event.reason
))
elseif event.method == "transferFrom" then
print(string.format(
"[%s] Received %.2f € from #%d (%s)",
event.time, (event.newbalance - event.oldbalance)/100,
event.parameter, event.reason
))
else
print("Warning: unknown event \""..event.method.."\".")
end
end
end
-- Get availably items.
function MatePad:get_roster()
local pipe, ok, exit, signal
local roster = {}
-- sed #1: Extract items section and insert newlines between entries.
-- sed #2: Extract and print id, name and price. If any extraction fails we
-- fail with exit code 1.
pipe = io.popen(string.format([=[
curl %s --header "Accept: application/json" "%s/api/items" | \
sed -n --sandbox '
/.*"items": *\[\( *{.*}\)*\].*/{
s//\1/
s/} *,/&\n/gp
}' | \
sed -n --sandbox '
h
s/.*"id" *: *\([0-9]\+\).*/\1/p
T fail
g
s/.*"name" *: *"\([^"]*\)".*/\1/p
T fail
g
s/.*"price" *: *\(-\?[0-9]\+\).*/\1/p
T fail
b
:fail
q 1
']=], self.CURL_FLAGS, self.HOST))
while true do
local item = {}
item.id = tonumber(pipe:read("l"))
if item.id == nil then break end
item.name = utf8_decode(pipe:read("l"))
item.price = tonumber(pipe:read("l"))
table.insert(roster, item)
end
ok, exit, signal = pipe:close()
if not ok then -- sed error
io.stderr:write("Sorry, there was an error while parsing the API response.\n")
-- Empty table will throw no exceptions.
return {}
elseif roster == nil then -- curl error
io.stderr:write("Sorry, there was an error while connecting the API.\n")
return {}
end
return roster
end
-- Buy given item.
function MatePad:buy(id)
return self:fetch(string.format(
"api/user/%s/buy/%d?_csrf_token=%s",
url_encode(self.USER), id, self.token))
end
-- Set new balance (in cents).
function MatePad:set_balance(amount)
return self:fetch(string.format(
"api/user/%s/balance?_csrf_token=%s",
url_encode(self.USER), self.token),
{newbalance = amount})
end
-- Transfer credits to given user.
function MatePad:give(amount, recipient, reason)
return self:fetch(string.format(
"api/user/%s/transfer?_csrf_token=%s",
url_encode(self.USER), self.token),
{
recipient = recipient,
amount = amount,
reason = reason or ""
})
end
-- Process help and username options.
if arg[1] == "-h" or
arg[1] == "--help" then
-- Usage.
print([=[
Usage: c4mate [-u <user>] [ <query> | -b <id> | -l | +/-<amount> | -g <amount> <user> [<reason>]]
Display current balance:
c4mate
Find and buy item (interactive):
c4mate <query>
Buy by item id (non-interactive):
c4mate -b|--buy <id>
Show log:
c4mate -l|--log
Add or subtract credits:
c4mate <difference>
Give credits:
c4mate -g <amount> <user> [<reason>]]=])
return
-- Parse -u flag.
elseif arg[1] == "-u" then
table.remove(arg, 1)
USER = arg[1]
table.remove(arg, 1)
end
-- Set up username.
if USER == nil then
USER = os.getenv("USER")
if USER == nil then
io.stderr:write("Error: no username given and $USER environment variable empty!\n")
os.exit(1)
end
end
pad = MatePad:new(HOST, USER)
if arg[1] == nil then
-- Print balance.
pad:init()
if pad:update_balance() then
print(string.format(
"Balance of user %s: %.2f €", pad.USER, pad.balance/100))
end
elseif arg[1] == "-l" or
arg[1] == "--log" then
-- Print log.
pad:print_log(pad:parse_log())
elseif arg[1] == "-g" or
arg[1] == "--give" then
-- Give.
local amount = tonumber(arg[2])
local recipient = arg[3]
local reason = table.concat(arg, " ", 4)
if amount == nil or recipient == nil then
print("Error: could not parse amount and/or recipient.")
print("Usage: c4mate -g <amount> <user> [<reason>]")
return
end
pad:init()
local ok = pad:give(amount, recipient, reason)
if ok == "OK" then
pad:update_balance()
print(string.format(
"%.2f € transferred from %s to %s.\nNew balance of user %s: %.2f €",
amount, pad.USER, recipient, pad.USER, pad.balance/100))
else
print("Sorry, there was an error. ("..ok..")")
end
elseif arg[1] == "-b" or arg[1] == "--buy" then
local id = tonumber(arg[2])
if id == nil then
print("Usage: c4mate --buy <id>")
return
end
pad:init()
if pad:buy(id) == "OK" then
pad:update_balance()
print(string.format(
"Bought #%s. New balance: %.2f €.",
id, pad.balance/100))
else
print("Error while attempting to buy product #"..id..".")
end
elseif tonumber(arg[1]) ~= nil then
-- Add to (or subtract from) balance.
pad:init()
if pad:update_balance() then
new = math.tointeger(pad.balance + tonumber(arg[1])*100)
if pad:set_balance(new) == "OK" then
pad:update_balance()
print(string.format(
"New balance of user %s: %.2f €", pad.USER, pad.balance/100))
else
print("Sorry, something went wrong. (Unexpected reply from API.)")
end
end
else
-- Find and buy item.
local roster, query, input, ok
roster = pad:get_roster()
query = string.lower(table.concat(arg, " "))
local menu = {}
for i = 1, #roster do
local item = roster[i]
if string.match(string.lower(item.name), query) then
table.insert(menu, item)
end
end
if #menu == 0 then
print("Sorry, no matching item found.")
else
print("Press [y] to accept, [n] for next or anything else to cancel:")
end
-- Sort menu by id.
table.sort(menu, function (e1, e2) return e1.id < e2.id end)
-- Check if we can invoke stty.
is_tty = os.execute("tty -s") == true
for i, item in ipairs(menu) do
io.stdout:write(string.format("[%d/%d] Buy %s (#%d) for %.2f €? ",
i, #menu, item.name, item.id, item.price/100))
-- Turn off canonical mode, so we can read a char without the user
-- having to press enter.
if is_tty then os.execute("stty -icanon") end
ok, input = pcall(io.read, 1)
if is_tty then os.execute("stty icanon") end
if not ok then break end -- ^C
if input == "y" then
pad:init()
if pad:buy(item.id) == "OK" then
pad:update_balance()
print(string.format(
"\nBought %s. New balance: %.2f €.",
item.name, pad.balance/100))
end
break
elseif input == "n" then print() -- force newline
elseif input ~= "\n" then
print("\nCancelled.")
break
end
end
end