Browse Source

onboarding

master
Silberengel 2 weeks ago
parent
commit
b5596e6d57
  1. 2
      README.md
  2. 4
      pyproject.toml
  3. 48
      src/imwald/core/forest_avatar.py
  4. 57
      src/imwald/core/nature_username.py
  5. 23
      src/imwald/core/nostr_engine.py
  6. 136
      src/imwald/core/nostr_nip96_upload.py
  7. 17
      src/imwald/ui/onboarding_wizard.py
  8. 16
      tests/test_nature_username.py
  9. 16
      tests/test_nip96_imeta.py

2
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. - **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). - **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. - **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-<unix_timestamp>`** (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). - **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) ## Dependencies (runtime)

4
pyproject.toml

@ -18,6 +18,7 @@ dependencies = [
"quickjs-ng>=0.13", "quickjs-ng>=0.13",
"nh3>=0.2", "nh3>=0.2",
"markdown>=3.5", "markdown>=3.5",
"pillow>=10",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@ -43,3 +44,6 @@ pythonpath = ["src"]
include = ["src", "tests"] include = ["src", "tests"]
extraPaths = ["src"] extraPaths = ["src"]
pythonVersion = "3.11" pythonVersion = "3.11"
# So third-party stubs (e.g. Pillow → ``PIL``) resolve when using ``.venv`` at the repo root.
venvPath = "."
venv = ".venv"

48
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()

57
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)}"

23
src/imwald/core/nostr_engine.py

@ -137,19 +137,36 @@ class NostrEngine(QObject):
account: StoredAccount, account: StoredAccount,
password: str | None, password: str | None,
*, *,
name: str, username: str,
about: str, about: str,
interest_tags: list[str], interest_tags: list[str],
languages: list[str], languages: list[str],
) -> None: ) -> None:
sec = unlock_secret(account, password) sec = unlock_secret(account, password)
now = int(time.time()) now = int(time.time())
# NIP-01 metadata: unique handle ``imwald-anon-<created_at>``; 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( kind0 = build_signed_event(
sec, sec,
created_at=now, created_at=now,
kind=0, kind=0,
tags=[["client", "imwald"]], tags=tags0,
content=json.dumps({"name": name, "about": about}, ensure_ascii=False), content=json.dumps(profile, ensure_ascii=False),
) )
tags_10002: list[list[str]] = [["client", "imwald"]] tags_10002: list[list[str]] = [["client", "imwald"]]
for r in DEFAULT_READ_RELAYS: for r in DEFAULT_READ_RELAYS:

136
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

17
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.accounts_store import StoredAccount, add_account_nsec_hex, generate_new_identity, save_accounts
from imwald.core.database import Database from imwald.core.database import Database
from imwald.core.nature_username import random_nature_username
from imwald.core.nostr_engine import NostrEngine from imwald.core.nostr_engine import NostrEngine
POPULAR_TAGS = ( POPULAR_TAGS = (
@ -74,11 +75,12 @@ class PageProfile(QWizardPage):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.setTitle("Profile") 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) form = QFormLayout(self)
self._name = QLineEdit()
self._about = QPlainTextEdit() self._about = QPlainTextEdit()
form.addRow("Name", self._name)
form.addRow("About", self._about) form.addRow("About", self._about)
def nextId(self) -> int: def nextId(self) -> int:
@ -157,9 +159,9 @@ class PagePassword(QWizardPage):
self.setSubTitle("Matches Jumble-style ncryptsec storage when a password is set.") self.setSubTitle("Matches Jumble-style ncryptsec storage when a password is set.")
form = QFormLayout(self) form = QFormLayout(self)
self._pw = QLineEdit() self._pw = QLineEdit()
self._pw.setEchoMode(QLineEdit.Password) self._pw.setEchoMode(QLineEdit.EchoMode.Password)
self._pw2 = QLineEdit() self._pw2 = QLineEdit()
self._pw2.setEchoMode(QLineEdit.Password) self._pw2.setEchoMode(QLineEdit.EchoMode.Password)
form.addRow("Password", self._pw) form.addRow("Password", self._pw)
form.addRow("Repeat", self._pw2) form.addRow("Repeat", self._pw2)
@ -204,7 +206,8 @@ def run_onboarding_wizard(
sec, _pub = generate_new_identity() sec, _pub = generate_new_identity()
acc = add_account_nsec_hex(sec.hex(), password) 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) existing_accounts.append(acc)
save_accounts(existing_accounts) save_accounts(existing_accounts)
@ -218,7 +221,7 @@ def run_onboarding_wizard(
engine.publish_kind0_and_lists( engine.publish_kind0_and_lists(
acc, acc,
password, password,
name=p1._name.text().strip() or "imwald user", # noqa: SLF001 username=nature_label,
about=p1._about.toPlainText().strip(), about=p1._about.toPlainText().strip(),
interest_tags=interests, interest_tags=interests,
languages=langs, languages=langs,

16
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

16
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)
Loading…
Cancel
Save