#!/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 (`.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 (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 = ('' 'Család').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![kép]({url})\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:])