diff --git a/.gitignore b/.gitignore index c3a9886..c334897 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ .venv/ __pycache__/ *.pyc -.python-version dist/ *.egg-info/ .env diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/jirorian/auth.py b/jirorian/auth.py deleted file mode 100644 index 92cb2ca..0000000 --- a/jirorian/auth.py +++ /dev/null @@ -1,12 +0,0 @@ -# NIP-98 HTTP Auth verification -# Verifies kind:27235 Nostr events used as Bearer tokens. -# Rejects events older than MAX_AGE_SECONDS to prevent replay attacks. - -MAX_AGE_SECONDS = 60 - - -async def verify_nip98(authorization: str, expected_url: str, expected_method: str) -> str: - # TODO: base64-decode the event, verify secp256k1 signature via pynostr, - # check created_at freshness, url and method tags match request. - # Returns the verified pubkey (hex) on success, raises HTTPException on failure. - raise NotImplementedError diff --git a/jirorian/main.py b/jirorian/main.py deleted file mode 100644 index fa94565..0000000 --- a/jirorian/main.py +++ /dev/null @@ -1,20 +0,0 @@ -from fastapi import FastAPI - -app = FastAPI(title="jirorian") - - -@app.get("/.well-known/nostr/nip96.json") -async def nip96_info(): - # TODO: return NIP-96 server info document - pass - - -@app.post("/upload/request") -async def upload_request(): - # TODO: verify NIP-98 auth, check whitelist, return presigned R2 URL - pass - - -def main(): - import uvicorn - uvicorn.run("jirorian.main:app", host="127.0.0.1", port=8390, reload=False) diff --git a/jirorian/nostr.py b/jirorian/nostr.py deleted file mode 100644 index db2e006..0000000 --- a/jirorian/nostr.py +++ /dev/null @@ -1,9 +0,0 @@ -# Nostr relay subscription for kind:5392 registration events. -# Runs as a background task alongside the FastAPI app. -# Auto-whitelists sender pubkeys and optionally sends kind:4 DM confirmations. - - -async def listen_for_registrations(): - # TODO: connect to relay via websockets, subscribe to kind:5392 events - # addressed to SERVICE_NOSTR_PUBKEY, call store.whitelist_pubkey() on receipt. - raise NotImplementedError diff --git a/jirorian/r2.py b/jirorian/r2.py deleted file mode 100644 index 6f7c7e8..0000000 --- a/jirorian/r2.py +++ /dev/null @@ -1,26 +0,0 @@ -# Cloudflare R2 presigned URL generation via boto3 (S3-compatible API). -# Credentials are loaded from environment variables at startup. - -import boto3 -from botocore.config import Config - -_client = None - - -def get_client(): - global _client - if _client is None: - import os - _client = boto3.client( - "s3", - endpoint_url=os.environ["R2_ENDPOINT"], - aws_access_key_id=os.environ["R2_ACCESS_KEY_ID"], - aws_secret_access_key=os.environ["R2_SECRET_ACCESS_KEY"], - config=Config(signature_version="s3v4"), - ) - return _client - - -def generate_presigned_upload(app: str, pubkey: str, ext: str, content_type: str, ttl: int = 300) -> tuple[str, str]: - # TODO: generate a presigned PUT URL and return (upload_url, cdn_url) - raise NotImplementedError diff --git a/jirorian/store.py b/jirorian/store.py deleted file mode 100644 index f9d0f26..0000000 --- a/jirorian/store.py +++ /dev/null @@ -1,33 +0,0 @@ -# SQLite-backed whitelist and per-pubkey rate limiting. -# DB path is configurable via JIRORIAN_DB env var (default: jirorian.db). - -import sqlite3 -import os - -DB_PATH = os.environ.get("JIRORIAN_DB", "jirorian.db") - - -def init_db(): - # TODO: create tables: pubkeys(pubkey, app, registered_at, status), - # uploads(pubkey, uploaded_at) - raise NotImplementedError - - -def is_whitelisted(pubkey: str) -> bool: - raise NotImplementedError - - -def is_banned(pubkey: str) -> bool: - raise NotImplementedError - - -def whitelist_pubkey(pubkey: str, app: str): - raise NotImplementedError - - -def count_uploads_today(pubkey: str) -> int: - raise NotImplementedError - - -def record_upload(pubkey: str): - raise NotImplementedError diff --git a/jirorian/__init__.py b/src/jirorian/__init__.py similarity index 100% rename from jirorian/__init__.py rename to src/jirorian/__init__.py diff --git a/src/jirorian/auth.py b/src/jirorian/auth.py new file mode 100644 index 0000000..4d20ef3 --- /dev/null +++ b/src/jirorian/auth.py @@ -0,0 +1,44 @@ +import base64 +import json +import time + +from fastapi import HTTPException +from pynostr.event import Event + +MAX_AGE_SECONDS = 60 + + +async def verify_nip98(authorization: str, expected_url: str, expected_method: str) -> str: + """Verify a NIP-98 Authorization header. Returns the pubkey hex on success.""" + if not authorization.startswith("Nostr "): + raise HTTPException(status_code=401, detail="Authorization scheme must be 'Nostr'") + + try: + data = json.loads(base64.b64decode(authorization[6:])) + except Exception: + raise HTTPException(status_code=401, detail="Invalid authorization token") + + if data.get("kind") != 27235: + raise HTTPException(status_code=401, detail="Event kind must be 27235") + + if abs(time.time() - data.get("created_at", 0)) > MAX_AGE_SECONDS: + raise HTTPException(status_code=401, detail="Event timestamp out of range") + + tags = {t[0]: t[1] for t in data.get("tags", []) if len(t) >= 2} + + if tags.get("u") != expected_url: + raise HTTPException(status_code=401, detail="URL mismatch") + + if tags.get("method", "").upper() != expected_method.upper(): + raise HTTPException(status_code=401, detail="Method mismatch") + + try: + event = Event.from_dict(data) + if not event.verify(): + raise HTTPException(status_code=401, detail="Invalid signature") + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=401, detail="Signature verification failed") + + return data["pubkey"] diff --git a/src/jirorian/main.py b/src/jirorian/main.py new file mode 100644 index 0000000..74f6682 --- /dev/null +++ b/src/jirorian/main.py @@ -0,0 +1,86 @@ +import asyncio +import os +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Header, HTTPException, Request +from pydantic import BaseModel + +from . import auth, nostr, r2, store + + +@asynccontextmanager +async def lifespan(_: FastAPI): + store.init_db() + task = asyncio.create_task(nostr.listen_for_registrations()) + yield + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +app = FastAPI(title="jirorian", lifespan=lifespan) + + +class UploadRequest(BaseModel): + app: str + content_type: str + + +class UploadResponse(BaseModel): + upload_url: str + cdn_url: str + + +@app.get("/.well-known/nostr/nip96.json") +async def nip96_info(): + base_url = os.environ.get("JIRORIAN_BASE_URL", "") + return { + "api_url": f"{base_url}/upload/request", + "download_url": os.environ.get("R2_CDN_URL", ""), + "supported_nips": [96, 98], + "tos_url": "", + "content_types": list(r2.ALLOWED_CONTENT_TYPES), + "plans": { + "free": { + "name": "Free", + "max_byte_size": r2.MAX_FILE_SIZE, + "file_expiry": [0, 0], + "media_transformations": {}, + } + }, + } + + +@app.post("/upload/request", response_model=UploadResponse) +async def upload_request( + body: UploadRequest, + request: Request, + authorization: str = Header(), +): + pubkey = await auth.verify_nip98(authorization, str(request.url), "POST") + + if store.is_banned(pubkey): + raise HTTPException(status_code=403, detail="Forbidden") + + if store.count_uploads_today(pubkey) >= store.daily_limit(pubkey): + raise HTTPException(status_code=429, detail="Daily upload limit reached") + + if body.content_type not in r2.ALLOWED_CONTENT_TYPES: + raise HTTPException(status_code=415, detail="Unsupported media type") + + upload_url, cdn_url = r2.generate_presigned_upload(body.app, pubkey, body.content_type) + store.record_upload(pubkey) + + return UploadResponse(upload_url=upload_url, cdn_url=cdn_url) + + +def main(): + import uvicorn + uvicorn.run( + "jirorian.main:app", + host="127.0.0.1", + port=int(os.environ.get("JIRORIAN_PORT", "8390")), + reload=False, + ) diff --git a/src/jirorian/nostr.py b/src/jirorian/nostr.py new file mode 100644 index 0000000..9c9044f --- /dev/null +++ b/src/jirorian/nostr.py @@ -0,0 +1,62 @@ +import asyncio +import json +import logging +import os + +import coincurve +from websockets.asyncio.client import connect + +from . import store + +logger = logging.getLogger(__name__) + +REGISTRATION_KIND = 5392 +RECONNECT_DELAY = 10 + + +def _service_pubkey() -> str: + privkey_bytes = bytes.fromhex(os.environ["SERVICE_NOSTR_PRIVKEY"]) + return coincurve.PublicKey.from_valid_secret(privkey_bytes).format(compressed=True)[1:].hex() + + +def _get_tag(event: dict, name: str) -> str | None: + for tag in event.get("tags", []): + if len(tag) >= 2 and tag[0] == name: + return tag[1] + return None + + +async def _handle_event(event: dict): + pubkey = event.get("pubkey") + if not pubkey: + return + app = _get_tag(event, "app") or "unknown" + store.whitelist_pubkey(pubkey, app) + logger.info("Whitelisted %s... (app: %s)", pubkey[:16], app) + + +async def listen_for_registrations(): + relay_url = os.environ["SERVICE_RELAY_URL"] + service_pubkey = _service_pubkey() + subscription = json.dumps([ + "REQ", "jirorian-reg", + {"kinds": [REGISTRATION_KIND], "#p": [service_pubkey]}, + ]) + + while True: + try: + async with connect(relay_url) as ws: + await ws.send(subscription) + logger.info("Subscribed to %s for kind:%d", relay_url, REGISTRATION_KIND) + async for raw in ws: + try: + msg = json.loads(raw) + except json.JSONDecodeError: + continue + if isinstance(msg, list) and msg[0] == "EVENT" and len(msg) >= 3: + await _handle_event(msg[2]) + except asyncio.CancelledError: + return + except Exception as exc: + logger.warning("Relay disconnected (%s), retrying in %ds", exc, RECONNECT_DELAY) + await asyncio.sleep(RECONNECT_DELAY) diff --git a/src/jirorian/r2.py b/src/jirorian/r2.py new file mode 100644 index 0000000..b586259 --- /dev/null +++ b/src/jirorian/r2.py @@ -0,0 +1,48 @@ +import os +import uuid + +import boto3 +from botocore.config import Config + +ALLOWED_CONTENT_TYPES: dict[str, str] = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", +} + +MAX_FILE_SIZE = int(os.environ.get("JIRORIAN_MAX_FILE_SIZE", str(10 * 1024 * 1024))) +PRESIGNED_URL_TTL = int(os.environ.get("JIRORIAN_PRESIGNED_TTL", "300")) + +_client = None + + +def _get_client(): + global _client + if _client is None: + _client = boto3.client( + "s3", + endpoint_url=os.environ["R2_ENDPOINT"], + aws_access_key_id=os.environ["R2_ACCESS_KEY_ID"], + aws_secret_access_key=os.environ["R2_SECRET_ACCESS_KEY"], + region_name="auto", + config=Config(signature_version="s3v4"), + ) + return _client + + +def generate_presigned_upload(app: str, pubkey: str, content_type: str) -> tuple[str, str]: + """Return (presigned PUT URL, public CDN URL) for a new upload.""" + ext = ALLOWED_CONTENT_TYPES[content_type] + key = f"{app}/{pubkey}/{uuid.uuid4()}.{ext}" + bucket = os.environ["R2_BUCKET_NAME"] + cdn_base = os.environ["R2_CDN_URL"].rstrip("/") + + upload_url = _get_client().generate_presigned_url( + "put_object", + Params={"Bucket": bucket, "Key": key, "ContentType": content_type}, + ExpiresIn=PRESIGNED_URL_TTL, + HttpMethod="PUT", + ) + + return upload_url, f"{cdn_base}/{key}" diff --git a/src/jirorian/store.py b/src/jirorian/store.py new file mode 100644 index 0000000..dc23528 --- /dev/null +++ b/src/jirorian/store.py @@ -0,0 +1,78 @@ +import os +import sqlite3 +from datetime import datetime, timezone + +DB_PATH = os.environ.get("JIRORIAN_DB", "jirorian.db") +UNREGISTERED_DAILY_LIMIT = int(os.environ.get("JIRORIAN_UNREGISTERED_LIMIT", "5")) +WHITELISTED_DAILY_LIMIT = int(os.environ.get("JIRORIAN_WHITELISTED_LIMIT", "100")) + + +def init_db(): + with sqlite3.connect(DB_PATH) as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS pubkeys ( + pubkey TEXT PRIMARY KEY, + app TEXT NOT NULL, + registered_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'whitelisted' + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS uploads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pubkey TEXT NOT NULL, + uploaded_at TEXT NOT NULL + ) + """) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_uploads_pubkey_date ON uploads(pubkey, uploaded_at)" + ) + + +def _status(pubkey: str) -> str | None: + with sqlite3.connect(DB_PATH) as conn: + row = conn.execute( + "SELECT status FROM pubkeys WHERE pubkey = ?", (pubkey,) + ).fetchone() + return row[0] if row else None + + +def is_whitelisted(pubkey: str) -> bool: + return _status(pubkey) == "whitelisted" + + +def is_banned(pubkey: str) -> bool: + return _status(pubkey) == "banned" + + +def daily_limit(pubkey: str) -> int: + return WHITELISTED_DAILY_LIMIT if is_whitelisted(pubkey) else UNREGISTERED_DAILY_LIMIT + + +def whitelist_pubkey(pubkey: str, app: str): + now = datetime.now(timezone.utc).isoformat() + with sqlite3.connect(DB_PATH) as conn: + conn.execute(""" + INSERT INTO pubkeys (pubkey, app, registered_at, status) + VALUES (?, ?, ?, 'whitelisted') + ON CONFLICT(pubkey) DO UPDATE SET status = 'whitelisted', app = excluded.app + """, (pubkey, app, now)) + + +def count_uploads_today(pubkey: str) -> int: + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + with sqlite3.connect(DB_PATH) as conn: + row = conn.execute( + "SELECT COUNT(*) FROM uploads WHERE pubkey = ? AND uploaded_at >= ?", + (pubkey, today), + ).fetchone() + return row[0] if row else 0 + + +def record_upload(pubkey: str): + now = datetime.now(timezone.utc).isoformat() + with sqlite3.connect(DB_PATH) as conn: + conn.execute( + "INSERT INTO uploads (pubkey, uploaded_at) VALUES (?, ?)", + (pubkey, now), + )