Implement all modules

- store: SQLite whitelist + per-pubkey daily rate limiting
- auth: NIP-98 kind:27235 event verification via pynostr
- r2: boto3 presigned PUT URL generation for Cloudflare R2
- nostr: websocket relay subscription for kind:5392 registration events
- main: FastAPI app with NIP-96 info endpoint and upload request endpoint

Also moves package to src/ layout (required by uv_build) and pins
Python to 3.12 for coincurve wheel availability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-06-07 17:33:40 +03:00
parent 5f35041ab4
commit be93d74d68
13 changed files with 319 additions and 101 deletions

1
.gitignore vendored
View file

@ -1,7 +1,6 @@
.venv/
__pycache__/
*.pyc
.python-version
dist/
*.egg-info/
.env

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.12

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

44
src/jirorian/auth.py Normal file
View file

@ -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"]

86
src/jirorian/main.py Normal file
View file

@ -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,
)

62
src/jirorian/nostr.py Normal file
View file

@ -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)

48
src/jirorian/r2.py Normal file
View file

@ -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}"

78
src/jirorian/store.py Normal file
View file

@ -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),
)