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/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.python-version
|
|
||||||
dist/
|
dist/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.env
|
.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