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:
parent
5f35041ab4
commit
be93d74d68
13 changed files with 319 additions and 101 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,7 +1,6 @@
|
|||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.python-version
|
||||
dist/
|
||||
*.egg-info/
|
||||
.env
|
||||
|
|
|
|||
1
.python-version
Normal file
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.12
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
44
src/jirorian/auth.py
Normal 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
86
src/jirorian/main.py
Normal 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
62
src/jirorian/nostr.py
Normal 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
48
src/jirorian/r2.py
Normal 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
78
src/jirorian/store.py
Normal 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),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue