207 lines
11 KiB
Python
207 lines
11 KiB
Python
#!/usr/bin/python3
|
|
"""apps.py — the pretend household fills its apps through each app's OWN front door (rehearsal 2026-10-20, B4).
|
|
|
|
Runs in the browser LXC. Every app is reached by its real name (`<sub>.tester4.test`, mapped to the box's LAN address in
|
|
the LXC's /etc/hosts — the household's DNS would do the same) over HTTPS; the box's certificate is not trusted on a
|
|
LAN name, so TLS verification is off (recorded as a harness substitution). Credentials come from env vars whose NAMES
|
|
are passed in (brun.sh hands them in on stdin). Nothing secret is printed. Each step prints what it sent and what the
|
|
app answered, and the files it sent are named by their manifest path so B7/B8 can compare by sha256.
|
|
|
|
usage: apps.py <app> <args…> (see the functions below)
|
|
"""
|
|
import base64, hashlib, json, os, ssl, subprocess, sys, time, urllib.error, urllib.parse, urllib.request
|
|
from email.utils import formatdate
|
|
|
|
DATA = "/root/data"
|
|
DOMAIN = os.environ.get("DOMAIN", "tester4.test")
|
|
CTX = ssl.create_default_context()
|
|
CTX.check_hostname = False
|
|
CTX.verify_mode = ssl.CERT_NONE
|
|
|
|
|
|
def say(*a):
|
|
print(time.strftime("%H:%M:%SZ", time.gmtime()), *a, flush=True)
|
|
|
|
|
|
def http(method, url, body=None, headers=None, timeout=300):
|
|
h = dict(headers or {})
|
|
data = body
|
|
if isinstance(body, (dict, list)):
|
|
data = json.dumps(body).encode()
|
|
h.setdefault("Content-Type", "application/json")
|
|
req = urllib.request.Request(url, data=data, method=method, headers=h)
|
|
try:
|
|
with urllib.request.urlopen(req, context=CTX, timeout=timeout) as r:
|
|
return r.status, r.read(), dict(r.headers)
|
|
except urllib.error.HTTPError as e:
|
|
return e.code, e.read(), dict(e.headers)
|
|
|
|
|
|
def basic(user, pw):
|
|
return "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
|
|
|
|
|
|
def manifest(app=None):
|
|
rows = []
|
|
with open(os.path.join(DATA, "MANIFEST.tsv"), encoding="utf-8") as fh:
|
|
next(fh)
|
|
for line in fh:
|
|
p, size, sha, a, cls, note = line.rstrip("\n").split("\t")
|
|
if app is None or a == app:
|
|
rows.append(dict(path=p, size=size, sha=sha, app=a, cls=cls, note=note))
|
|
return rows
|
|
|
|
|
|
def multipart(fields, files):
|
|
"""fields: {name: str}; files: [(field, filename, bytes, ctype)] → (body, content-type)"""
|
|
b = "----felhomrehearsal" + hashlib.sha1(str(time.time()).encode()).hexdigest()[:16]
|
|
out = []
|
|
for k, v in fields.items():
|
|
out += [f"--{b}\r\n".encode(), f'Content-Disposition: form-data; name="{k}"\r\n\r\n'.encode(), str(v).encode(), b"\r\n"]
|
|
for field, fname, content, ctype in files:
|
|
q = urllib.parse.quote(fname)
|
|
out += [f"--{b}\r\n".encode(),
|
|
f'Content-Disposition: form-data; name="{field}"; filename="{q}"; filename*=UTF-8\'\'{q}\r\n'.encode(),
|
|
f"Content-Type: {ctype}\r\n\r\n".encode(), content, b"\r\n"]
|
|
out.append(f"--{b}--\r\n".encode())
|
|
return b"".join(out), f"multipart/form-data; boundary={b}"
|
|
|
|
|
|
# ---------------------------------------------------------------- Paperless-ngx (documents, class A)
|
|
def paperless(sub, user_env, pw_env):
|
|
base, auth = f"https://{sub}.{DOMAIN}", basic(os.environ[user_env], os.environ[pw_env])
|
|
ok = bad = 0
|
|
for r in manifest("paperless-ngx" if False else "paperless"):
|
|
content = open(os.path.join(DATA, r["path"]), "rb").read()
|
|
body, ct = multipart({"title": os.path.basename(r["path"])[:-4]},
|
|
[("document", os.path.basename(r["path"]), content, "application/pdf")])
|
|
st, out, _ = http("POST", base + "/api/documents/post_document/", body, {"Authorization": auth, "Content-Type": ct})
|
|
ok += st == 200
|
|
bad += st != 200
|
|
if st != 200:
|
|
say("paperless upload", r["path"], st, out[:200])
|
|
say(f"paperless: {ok} accepted, {bad} refused")
|
|
|
|
|
|
# ---------------------------------------------------------------- Nextcloud (files, class A) — WebDAV
|
|
def nextcloud(sub, user_env, pw_env):
|
|
"""rclone over WebDAV, as a desktop sync client would. Symlinks are not followed (a sync client skips them)."""
|
|
user, pw = os.environ[user_env], os.environ[pw_env]
|
|
obscured = subprocess.run(["rclone", "obscure", "-"], input=pw, capture_output=True, text=True).stdout.strip()
|
|
env = dict(os.environ, RCLONE_CONFIG_NC_TYPE="webdav", RCLONE_CONFIG_NC_URL=f"https://{sub}.{DOMAIN}/remote.php/dav/files/{user}",
|
|
RCLONE_CONFIG_NC_VENDOR="nextcloud", RCLONE_CONFIG_NC_USER=user, RCLONE_CONFIG_NC_PASS=obscured)
|
|
r = subprocess.run(["rclone", "copy", "--no-check-certificate", "--transfers", os.environ.get("TRANSFERS", "1"), "--stats", "60s", "--stats-one-line",
|
|
"-v", os.path.join(DATA, "nextcloud"), "nc:/"], env=env, capture_output=True, text=True)
|
|
tail = [l for l in (r.stdout + r.stderr).splitlines() if "ERROR" in l or "Transferred" in l or "Skipping" in l or "NOTICE" in l]
|
|
say(f"nextcloud rclone rc={r.returncode}")
|
|
for l in tail[-25:]:
|
|
print(" ", l)
|
|
|
|
|
|
# ---------------------------------------------------------------- Radicale (calendar, class B) — CalDAV
|
|
def radicale(sub, user_env, pw_env):
|
|
base, user = f"https://{sub}.{DOMAIN}", os.environ[user_env]
|
|
auth = basic(user, os.environ[pw_env])
|
|
cal = f"{base}/{urllib.parse.quote(user)}/csalad/"
|
|
body = ('<?xml version="1.0" encoding="utf-8"?><C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
|
|
'<D:set><D:prop><D:displayname>Család</D:displayname></D:prop></D:set></C:mkcalendar>').encode()
|
|
st, out, _ = http("MKCALENDAR", cal, body, {"Authorization": auth, "Content-Type": "application/xml"})
|
|
say("radicale MKCALENDAR", st)
|
|
ics = open(os.path.join(DATA, "radicale", "csalad.ics"), "rb").read().decode()
|
|
# one PUT per VEVENT (CalDAV stores one component per resource)
|
|
head, rest = ics.split("BEGIN:VEVENT", 1)
|
|
tz = head[head.index("BEGIN:VTIMEZONE"):]
|
|
for i, ev in enumerate(("BEGIN:VEVENT" + rest).split("BEGIN:VEVENT")[1:]):
|
|
one = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//felhom rehearsal//EN\r\n" + tz + "BEGIN:VEVENT" + ev.split("END:VCALENDAR")[0] + "END:VCALENDAR\r\n"
|
|
st, out, _ = http("PUT", cal + f"event{i}.ics", one.encode(), {"Authorization": auth, "Content-Type": "text/calendar"})
|
|
say("radicale PUT event", i, st)
|
|
|
|
|
|
# ---------------------------------------------------------------- Mealie (recipes, class B)
|
|
def mealie(sub, user_env, pw_env):
|
|
base = f"https://{sub}.{DOMAIN}"
|
|
form = urllib.parse.urlencode({"username": os.environ[user_env], "password": os.environ[pw_env]}).encode()
|
|
st, out, _ = http("POST", base + "/api/auth/token", form, {"Content-Type": "application/x-www-form-urlencoded"})
|
|
if st != 200:
|
|
say("mealie login", st, out[:200]); return
|
|
tok = {"Authorization": "Bearer " + json.loads(out)["access_token"]}
|
|
for r in manifest("mealie"):
|
|
if not r["path"].endswith(".json"):
|
|
continue
|
|
rec = json.load(open(os.path.join(DATA, r["path"]), encoding="utf-8"))
|
|
st, out, _ = http("POST", base + "/api/recipes", {"name": rec["name"]}, tok)
|
|
slug = json.loads(out) if st in (200, 201) else None
|
|
say("mealie create", rec["name"], st, slug)
|
|
if not slug:
|
|
continue
|
|
st, out, _ = http("GET", base + f"/api/recipes/{slug}", None, tok)
|
|
full = json.loads(out)
|
|
full["recipeIngredient"] = [{"note": x, "display": x} for x in rec["ingredients"]]
|
|
full["recipeInstructions"] = [{"text": x} for x in rec["steps"]]
|
|
st, out, _ = http("PUT", base + f"/api/recipes/{slug}", full, tok)
|
|
say("mealie update", slug, st)
|
|
img = open(os.path.join(DATA, r["path"][:-5] + ".jpg"), "rb").read()
|
|
body, ct = multipart({"extension": "jpg"}, [("image", "kep.jpg", img, "image/jpeg")])
|
|
st, out, _ = http("PUT", base + f"/api/recipes/{slug}/image", body, dict(tok, **{"Content-Type": ct}))
|
|
say("mealie image", slug, st)
|
|
|
|
|
|
# ---------------------------------------------------------------- Immich (photos, class A)
|
|
def immich(sub, email_env, pw_env, name="Kovács Ödön"):
|
|
base = f"https://{sub}.{DOMAIN}"
|
|
st, out, _ = http("POST", base + "/api/auth/admin-sign-up",
|
|
{"email": os.environ[email_env], "password": os.environ[pw_env], "name": name})
|
|
say("immich admin-sign-up", st, out[:120])
|
|
st, out, _ = http("POST", base + "/api/auth/login", {"email": os.environ[email_env], "password": os.environ[pw_env]})
|
|
if st not in (200, 201):
|
|
say("immich login", st, out[:200]); return
|
|
tok = {"Authorization": "Bearer " + json.loads(out)["accessToken"]}
|
|
ok = dup = bad = 0
|
|
for r in manifest("immich"):
|
|
p = os.path.join(DATA, r["path"])
|
|
content = open(p, "rb").read()
|
|
mt = formatdate(os.path.getmtime(p), usegmt=True)
|
|
iso = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime(os.path.getmtime(p)))
|
|
ctype = "video/mp4" if p.endswith(".mp4") else "image/png" if p.endswith(".png") else "image/jpeg"
|
|
body, ct = multipart({"deviceAssetId": r["path"], "deviceId": "rehearsal-phone", "fileCreatedAt": iso,
|
|
"fileModifiedAt": iso}, [("assetData", os.path.basename(p), content, ctype)])
|
|
st, out, _ = http("POST", base + "/api/assets", body, dict(tok, **{"Content-Type": ct}))
|
|
res = json.loads(out) if out[:1] == b"{" else {}
|
|
if st in (200, 201) and res.get("status") == "duplicate":
|
|
dup += 1
|
|
elif st in (200, 201):
|
|
ok += 1
|
|
else:
|
|
bad += 1
|
|
say("immich refused", r["path"], st, out[:160])
|
|
say(f"immich: {ok} created, {dup} duplicates, {bad} refused")
|
|
|
|
|
|
# ---------------------------------------------------------------- BookStack (wiki, class B) — its own REST API
|
|
def bookstack(sub, tok_env):
|
|
base = f"https://{sub}.{DOMAIN}"
|
|
tid, tsec = os.environ[tok_env].split(":", 1)
|
|
H = {"Authorization": f"Token {tid}:{tsec}"}
|
|
st, out, _ = http("POST", base + "/api/books", {"name": "Családi wiki", "description": "A ház dolgai (szintetikus)"}, H)
|
|
book = json.loads(out)["id"] if st == 200 else None
|
|
say("bookstack book", st, book)
|
|
for r in manifest("bookstack"):
|
|
if not r["path"].endswith(".md"):
|
|
continue
|
|
md = open(os.path.join(DATA, r["path"]), encoding="utf-8").read()
|
|
name = md.splitlines()[0].lstrip("# ").strip()
|
|
st, out, _ = http("POST", base + "/api/pages", {"book_id": book, "name": name, "markdown": md}, H)
|
|
page = json.loads(out)["id"] if st == 200 else None
|
|
img = open(os.path.join(DATA, r["path"].replace("oldal_", "kep_").replace(".md", ".png")), "rb").read()
|
|
body, ct = multipart({"type": "gallery", "uploaded_to": page, "name": "kep.png"}, [("image", "kep.png", img, "image/png")])
|
|
st2, out2, _ = http("POST", base + "/api/image-gallery", body, dict(H, **{"Content-Type": ct}))
|
|
url = json.loads(out2).get("url") if st2 == 200 else None
|
|
if url:
|
|
st3, _, _ = http("PUT", base + f"/api/pages/{page}", {"markdown": md + f"\n\n\n"}, H)
|
|
say("bookstack page", name, st, "image", st2, "linked", st3 if url else "-")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
fn = globals()[sys.argv[1]]
|
|
fn(*sys.argv[2:])
|