From b5596e6d574a50843268f0f4341f3a8220c1e64b Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 19 Apr 2026 01:11:01 +0200 Subject: [PATCH] onboarding --- README.md | 2 +- pyproject.toml | 4 + src/imwald/core/forest_avatar.py | 48 +++++++++ src/imwald/core/nature_username.py | 57 +++++++++++ src/imwald/core/nostr_engine.py | 23 ++++- src/imwald/core/nostr_nip96_upload.py | 136 ++++++++++++++++++++++++++ src/imwald/ui/onboarding_wizard.py | 17 ++-- tests/test_nature_username.py | 16 +++ tests/test_nip96_imeta.py | 16 +++ 9 files changed, 308 insertions(+), 11 deletions(-) create mode 100644 src/imwald/core/forest_avatar.py create mode 100644 src/imwald/core/nature_username.py create mode 100644 src/imwald/core/nostr_nip96_upload.py create mode 100644 tests/test_nature_username.py create mode 100644 tests/test_nip96_imeta.py diff --git a/README.md b/README.md index 6e3161a..462c9c3 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The package uses a `src/` layout; **editable install** is enough (no `PYTHONPATH - **Ingest:** NIP-01 id check + **BIP-340** signature verification before SQLite upsert; kind **5** tombstones applied on valid deletion events. - **Feed:** Ranked queue (follows, kind **30000** list authors, scores), per-viewer “seen” tracking, OP body rendered as **Markdown** (offline **marked** via QuickJS, **nh3** sanitize, Python `markdown` fallback). Thread list, search hits, notifications rows, and “your events” use the same pipeline for **plain summaries** (no raw `#` / `**` noise in one-liners). - **Composer:** Split **Markdown source** + **live preview**; publish to resolved write relays. -- **Accounts:** Onboarding wizard, **nsec** / **ncryptsec** (NIP-49) storage, composer and NIP-09 publish. +- **Accounts:** Onboarding wizard, **nsec** / **ncryptsec** (NIP-49) storage, composer and NIP-09 publish. New identities get kind **0** metadata with **`name`** **`imwald-anon-`** (same value as the event’s `created_at`, unique per publish), a random nature **`username`** phrase (e.g. romping chipmunks) for the local account label, and a deterministic **forest-themed PNG** uploaded to **nostr.build** (NIP-96 + NIP-98); **`picture`** plus a full **NIP-92 `imeta`** tag when upload succeeds (otherwise no picture). - **Other pages:** Search (local `LIKE`), notifications list (DB table + joined event text when present), local **DB admin** (safe SELECT, purge, NIP-09 hook). ## Dependencies (runtime) diff --git a/pyproject.toml b/pyproject.toml index 1225c5d..3d151f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "quickjs-ng>=0.13", "nh3>=0.2", "markdown>=3.5", + "pillow>=10", ] [project.optional-dependencies] @@ -43,3 +44,6 @@ pythonpath = ["src"] include = ["src", "tests"] extraPaths = ["src"] pythonVersion = "3.11" +# So third-party stubs (e.g. Pillow → ``PIL``) resolve when using ``.venv`` at the repo root. +venvPath = "." +venv = ".venv" diff --git a/src/imwald/core/forest_avatar.py b/src/imwald/core/forest_avatar.py new file mode 100644 index 0000000..2e3178a --- /dev/null +++ b/src/imwald/core/forest_avatar.py @@ -0,0 +1,48 @@ +"""Deterministic PNG avatar: green forest / leaf motif (Imwald — „im Wald“).""" + +from __future__ import annotations + +import io +import random + + +def build_forest_avatar_png(*, size: int = 192) -> bytes: + """Return small PNG bytes: low resolution, palette, max zlib — keeps nostr.build uploads tiny.""" + from PIL import Image, ImageDraw # type: ignore[import-not-found, import-untyped] + + rng = random.Random(42) + w = h = max(64, min(size, 256)) + img = Image.new("RGB", (w, h), (22, 68, 42)) + dr = ImageDraw.Draw(img) + # Soft vertical light (forest floor → canopy) + for y in range(h): + t = y / (h - 1) if h > 1 else 0.0 + r = int(22 + 35 * (1 - t)) + g = int(68 + 55 * (1 - t)) + b = int(42 + 25 * (1 - t)) + dr.line([(0, y), (w, y)], fill=(min(r, 120), min(g, 200), min(b, 110))) + # Fewer, smaller blobs → smaller compressed PNG than a busy 512² canvas + scale = w / 192.0 + for _ in range(95): + x = rng.randint(0, w - 1) + y = rng.randint(0, h - 1) + rx = max(2, int(rng.randint(3, 14) * scale)) + ry = max(2, int(rng.randint(2, 9) * scale)) + fill = ( + rng.randint(45, 110), + rng.randint(120, 200), + rng.randint(35, 95), + ) + dr.ellipse((x - rx, y - ry, x + rx, y + ry), fill=fill) + for _ in range(6): + x = rng.randint(max(8, w // 8), w - max(8, w // 8)) + y0 = rng.randint(h // 3, h - max(8, h // 12)) + dr.rectangle( + (x, y0, x + max(2, int(rng.randint(3, 7) * scale)), h - max(4, h // 16)), + fill=(40, 62, 38), + ) + # Palette image compresses much better than full RGB for this art style + paletted = img.quantize(colors=48, method=Image.Quantize.MEDIANCUT) + buf = io.BytesIO() + paletted.save(buf, format="PNG", compress_level=9, optimize=True) + return buf.getvalue() diff --git a/src/imwald/core/nature_username.py b/src/imwald/core/nature_username.py new file mode 100644 index 0000000..0718ada --- /dev/null +++ b/src/imwald/core/nature_username.py @@ -0,0 +1,57 @@ +"""Random nature-themed display phrases (adjective + noun), e.g. \"romping chipmunk\".""" + +from __future__ import annotations + +import random + +_ADJECTIVES = ( + "romping", + "galloping", + "pretty", + "gentle", + "wild", + "sleepy", + "busy", + "tiny", + "bright", + "mossy", + "drifting", + "rustling", + "singing", + "shy", + "nimble", + "curious", + "golden", + "misty", + "crisp", + "dappled", +) + +_NOUNS = ( + "chipmunks", + "elephants", + "acorns", + "ferns", + "sparrows", + "deer", + "streams", + "leaves", + "pinecones", + "foxes", + "otters", + "badgers", + "thrushes", + "mushrooms", + "brambles", + "dragonflies", + "woodpeckers", + "creekbeds", + "sunbeams", + "seedpods", +) + + +def random_nature_username(*, rng: random.Random | None = None) -> str: + """Return a lowercase phrase like ``romping chipmunks`` (two words, nature / forest vibe).""" + r = rng or random.Random() + return f"{r.choice(_ADJECTIVES)} {r.choice(_NOUNS)}" diff --git a/src/imwald/core/nostr_engine.py b/src/imwald/core/nostr_engine.py index 36c864e..4f2d4bc 100644 --- a/src/imwald/core/nostr_engine.py +++ b/src/imwald/core/nostr_engine.py @@ -137,19 +137,36 @@ class NostrEngine(QObject): account: StoredAccount, password: str | None, *, - name: str, + username: str, about: str, interest_tags: list[str], languages: list[str], ) -> None: sec = unlock_secret(account, password) now = int(time.time()) + # NIP-01 metadata: unique handle ``imwald-anon-``; friendly phrase in ``username``. + profile: dict[str, object] = { + "name": f"imwald-anon-{now}", + "username": username.strip() or "gentle ferns", + "about": about, + } + tags0: list[list[str]] = [["client", "imwald"]] + try: + from imwald.core.forest_avatar import build_forest_avatar_png + from imwald.core.nostr_nip96_upload import nip94_tags_to_imeta, upload_image_nip96_nostr_build + + png = build_forest_avatar_png() + url, nip94_tags = upload_image_nip96_nostr_build(sec, png) + profile["picture"] = url + tags0.append(nip94_tags_to_imeta(nip94_tags)) + except Exception as e: # noqa: BLE001 + log.warning("Default forest avatar upload skipped: %s", e) kind0 = build_signed_event( sec, created_at=now, kind=0, - tags=[["client", "imwald"]], - content=json.dumps({"name": name, "about": about}, ensure_ascii=False), + tags=tags0, + content=json.dumps(profile, ensure_ascii=False), ) tags_10002: list[list[str]] = [["client", "imwald"]] for r in DEFAULT_READ_RELAYS: diff --git a/src/imwald/core/nostr_nip96_upload.py b/src/imwald/core/nostr_nip96_upload.py new file mode 100644 index 0000000..fd42f7f --- /dev/null +++ b/src/imwald/core/nostr_nip96_upload.py @@ -0,0 +1,136 @@ +"""NIP-96 upload to nostr.build with NIP-98 (kind 27235) authorization.""" + +from __future__ import annotations + +import base64 +import json +import logging +import secrets +import time +import urllib.error +import urllib.request +from hashlib import sha256 +from typing import Any + +from imwald.core.nostr_crypto import build_signed_event + +log = logging.getLogger(__name__) + +NOSTR_BUILD_NIP96_UPLOAD = "https://nostr.build/api/v2/nip96/upload" + + +def _multipart_body( + *, + boundary: str, + file_bytes: bytes, + filename: str, + mime: str, + fields: dict[str, str], +) -> bytes: + crlf = b"\r\n" + parts: list[bytes] = [] + for name, value in fields.items(): + parts.append(f"--{boundary}".encode() + crlf) + parts.append(f'Content-Disposition: form-data; name="{name}"'.encode() + crlf + crlf) + parts.append(value.encode("utf-8") + crlf) + parts.append(f"--{boundary}".encode() + crlf) + disp = f'Content-Disposition: form-data; name="file"; filename="{filename}"' + parts.append(disp.encode() + crlf) + parts.append(f"Content-Type: {mime}".encode() + crlf + crlf) + parts.append(file_bytes + crlf) + parts.append(f"--{boundary}--".encode() + crlf) + return b"".join(parts) + + +def nip94_tags_to_imeta(tags: list[list[str]]) -> list[str]: + """ + NIP-92: one ``imeta`` tag with ``url`` plus space-delimited ``key value`` entries from NIP-94 tags. + """ + allowed = frozenset({"url", "m", "dim", "blurhash", "alt", "x", "ox", "size", "thumb", "image", "summary"}) + parts: list[str] = ["imeta"] + for t in tags: + if not t or len(t) < 2: + continue + name = str(t[0]) + if name not in allowed: + continue + val = str(t[1]).strip() + if not val: + continue + parts.append(f"{name} {val}") + if len(parts) < 3: + raise ValueError("imeta requires url plus at least one other NIP-94 field") + return parts + + +def upload_image_nip96_nostr_build( + secret: bytes, + file_bytes: bytes, + *, + filename: str = "imwald-forest-avatar.png", + mime: str = "image/png", + caption: str = "imwald default forest avatar", + alt: str = "Green forest leaf pattern — imwald (in the forest)", +) -> tuple[str, list[list[str]]]: + """ + POST ``file`` to nostr.build NIP-96 endpoint with NIP-98 auth (payload = SHA-256 of multipart body). + + Returns ``(download_url, nip94_tags)`` where ``nip94_tags`` is the server's ``nip94_event.tags`` list. + """ + api_url = NOSTR_BUILD_NIP96_UPLOAD + boundary = "ImwaldBoundary" + secrets.token_hex(16) + fields = { + "caption": caption, + "alt": alt, + "media_type": "avatar", + "content_type": mime, + "size": str(len(file_bytes)), + } + body = _multipart_body( + boundary=boundary, + file_bytes=file_bytes, + filename=filename, + mime=mime, + fields=fields, + ) + payload_hex = sha256(body).hexdigest() + created_at = int(time.time()) + nip98 = build_signed_event( + secret, + created_at=created_at, + kind=27235, + tags=[ + ["u", api_url], + ["method", "POST"], + ["payload", payload_hex], + ], + content="", + ) + auth_b64 = base64.b64encode( + json.dumps(nip98, ensure_ascii=False, separators=(",", ":")).encode("utf-8"), + ).decode("ascii") + req = urllib.request.Request(api_url, data=body, method="POST") + req.add_header("Authorization", f"Nostr {auth_b64}") + req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") + try: + with urllib.request.urlopen(req, timeout=90) as resp: + raw = resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as e: + err_body = e.read().decode("utf-8", errors="replace") if e.fp else "" + log.warning("nip96 upload HTTP %s: %s", e.code, err_body[:500]) + raise RuntimeError(f"nostr.build upload failed: HTTP {e.code}") from e + except urllib.error.URLError as e: + log.warning("nip96 upload URL error: %s", e) + raise RuntimeError("nostr.build upload failed: network error") from e + + data: dict[str, Any] = json.loads(raw) + if data.get("status") != "success": + raise RuntimeError(data.get("message") or "nostr.build upload unsuccessful") + nip94 = data.get("nip94_event") or {} + tags = nip94.get("tags") or [] + if not isinstance(tags, list): + raise RuntimeError("invalid nip94_event.tags in upload response") + url = next((str(t[1]) for t in tags if isinstance(t, list) and len(t) >= 2 and str(t[0]) == "url"), None) + if not url: + raise RuntimeError("no url tag in nip94_event response") + return url, tags diff --git a/src/imwald/ui/onboarding_wizard.py b/src/imwald/ui/onboarding_wizard.py index 5e72539..2db19bb 100644 --- a/src/imwald/ui/onboarding_wizard.py +++ b/src/imwald/ui/onboarding_wizard.py @@ -19,6 +19,7 @@ from PySide6.QtWidgets import ( from imwald.core.accounts_store import StoredAccount, add_account_nsec_hex, generate_new_identity, save_accounts from imwald.core.database import Database +from imwald.core.nature_username import random_nature_username from imwald.core.nostr_engine import NostrEngine POPULAR_TAGS = ( @@ -74,11 +75,12 @@ class PageProfile(QWizardPage): def __init__(self) -> None: super().__init__() self.setTitle("Profile") - self.setSubTitle("Shown on your kind 0 metadata.") + self.setSubTitle( + "On the network your profile name will be imwald-anon- plus a Unix timestamp (unique); " + "this device gets a random nature nickname (e.g. romping chipmunks) for the account list." + ) form = QFormLayout(self) - self._name = QLineEdit() self._about = QPlainTextEdit() - form.addRow("Name", self._name) form.addRow("About", self._about) def nextId(self) -> int: @@ -157,9 +159,9 @@ class PagePassword(QWizardPage): self.setSubTitle("Matches Jumble-style ncryptsec storage when a password is set.") form = QFormLayout(self) self._pw = QLineEdit() - self._pw.setEchoMode(QLineEdit.Password) + self._pw.setEchoMode(QLineEdit.EchoMode.Password) self._pw2 = QLineEdit() - self._pw2.setEchoMode(QLineEdit.Password) + self._pw2.setEchoMode(QLineEdit.EchoMode.Password) form.addRow("Password", self._pw) form.addRow("Repeat", self._pw2) @@ -204,7 +206,8 @@ def run_onboarding_wizard( sec, _pub = generate_new_identity() acc = add_account_nsec_hex(sec.hex(), password) - acc.label = p1._name.text().strip() or None # noqa: SLF001 + nature_label = random_nature_username() + acc.label = nature_label existing_accounts.append(acc) save_accounts(existing_accounts) @@ -218,7 +221,7 @@ def run_onboarding_wizard( engine.publish_kind0_and_lists( acc, password, - name=p1._name.text().strip() or "imwald user", # noqa: SLF001 + username=nature_label, about=p1._about.toPlainText().strip(), interest_tags=interests, languages=langs, diff --git a/tests/test_nature_username.py b/tests/test_nature_username.py new file mode 100644 index 0000000..bab6f13 --- /dev/null +++ b/tests/test_nature_username.py @@ -0,0 +1,16 @@ +import random + +from imwald.core.nature_username import random_nature_username + + +def test_random_nature_username_format() -> None: + s = random_nature_username(rng=random.Random(0)) + parts = s.split() + assert len(parts) == 2 + assert s == s.lower() + + +def test_random_nature_username_deterministic() -> None: + a = random_nature_username(rng=random.Random(12345)) + b = random_nature_username(rng=random.Random(12345)) + assert a == b diff --git a/tests/test_nip96_imeta.py b/tests/test_nip96_imeta.py new file mode 100644 index 0000000..541c61f --- /dev/null +++ b/tests/test_nip96_imeta.py @@ -0,0 +1,16 @@ +from imwald.core.nostr_nip96_upload import nip94_tags_to_imeta + + +def test_nip94_tags_to_imeta_shape() -> None: + tags = [ + ["url", "https://media.nostr.build/example.png"], + ["m", "image/png"], + ["dim", "512x512"], + ["alt", "Forest pattern"], + ["ox", "a" * 64], + ] + im = nip94_tags_to_imeta(tags) + assert im[0] == "imeta" + assert "url https://media.nostr.build/example.png" in im + assert any(x.startswith("m image/png") for x in im) + assert any(x.startswith("dim 512x512") for x in im)