diff --git a/backend/server.py b/backend/server.py new file mode 100644 index 0000000..4f2d0dd --- /dev/null +++ b/backend/server.py @@ -0,0 +1,280 @@ +"""Independent Persimmons taste-test host. No MCP or Tea runtime imports.""" +import asyncio +import hashlib +import json +import os +from pathlib import Path +import re +import secrets +import sqlite3 +import time +from contextlib import closing + +from starlette.applications import Starlette +from starlette.concurrency import run_in_threadpool +from starlette.middleware.trustedhost import TrustedHostMiddleware +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import FileResponse, JSONResponse +from starlette.requests import Request +from starlette.routing import Route +from starlette.staticfiles import StaticFiles + +from . import human_vault, gitea, juice + +ORIGIN = 'https://persimmons.app' +COOKIE = '__Host-persimmons-session' +ROOT = Path(__file__).parent +DATA = Path(os.environ.get('PERSIMMONS_DATA', '/data')) +SESSION_SECONDS = 12 * 3600 +AUTH_GATE = asyncio.Semaphore(1) + + +def database(): + db = sqlite3.connect(DATA / 'sessions.sqlite', timeout=5) + db.execute('PRAGMA secure_delete=ON') + version = db.execute('PRAGMA user_version').fetchone()[0] + if version not in (0, 2): + db.close() + raise sqlite3.DatabaseError('Unsupported Persimmons schema') + if version == 0: + # One-time authorized reset, restricted to this app's disposable stores. + vault = DATA / 'human-vault.sqlite' + db.execute('ATTACH DATABASE ? AS human', (str(vault),)) + with db: + db.execute('BEGIN IMMEDIATE') + if db.execute('PRAGMA user_version').fetchone()[0] != 2: + for table in ('users', 'sessions', 'attempts'): + db.execute('DROP TABLE IF EXISTS ' + table) + db.execute('DROP TABLE IF EXISTS human.human_items') + db.execute('CREATE TABLE sessions (digest TEXT PRIMARY KEY, owner TEXT NOT NULL, gitea_id INTEGER NOT NULL, login TEXT NOT NULL, expires INTEGER NOT NULL)') + db.execute('CREATE TABLE attempts (key TEXT PRIMARY KEY, count INTEGER, expires INTEGER)') + db.execute('CREATE TABLE named_vaults (owner TEXT NOT NULL, repo_id INTEGER NOT NULL UNIQUE, name TEXT NOT NULL, PRIMARY KEY(owner,name))') + db.execute('PRAGMA user_version=2') + db.execute('DETACH DATABASE human') + return db + + +def resolve(request): + token = request.cookies.get(COOKIE, '') + if not re.fullmatch(r'[A-Za-z0-9_-]{43}', token): + return None + with closing(database()) as db: + row = db.execute('SELECT owner,gitea_id,login FROM sessions WHERE digest=? AND expires>?', + (hashlib.sha256(token.encode()).hexdigest(), int(time.time()))).fetchone() + return {'owner': row[0], 'id': row[1], 'login': row[2]} if row else None + + +async def actor(request): + user = await run_in_threadpool(resolve, request) + return user['owner'] if user else None + + +def reply(data, code=200): + return JSONResponse(data, code, headers={'Cache-Control': 'no-store'}) + + +def credentials(name, password, register, address): + now = int(time.time()) + with closing(database()) as db, db: + db.execute('DELETE FROM attempts WHERE expires= maximum: + return None, 429 + db.execute('INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(key) DO UPDATE SET count=count+1', (key, now + 600)) + try: + user = gitea.authenticate(name, password, register) + except gitea.Failure as error: + return None, error.code if error.code in (400, 401, 403, 409, 429) else 503 + with closing(database()) as db, db: + token = secrets.token_urlsafe(32) + db.execute('INSERT INTO sessions VALUES (?,?,?,?,?)', + (hashlib.sha256(token.encode()).hexdigest(), user['owner'], user['id'], user['login'], now + SESSION_SECONDS)) + return token, 200 + + +async def auth(request): + if request.url.scheme != 'https' or request.headers.getlist('origin') != [ORIGIN] or request.url.query: + return reply({'error': 'forbidden'}, 403) + if request.headers.get('content-type') != 'application/json': + return reply({'error': 'JSON required'}, 415) + raw = bytearray() + async for chunk in request.stream(): + raw.extend(chunk) + if len(raw) > 4096: + return reply({'error': 'invalid request'}, 400) + try: + data = json.loads(raw) + name = data['name'].strip().lower() + password = data['password'] + if not re.fullmatch(r'[a-z0-9][a-z0-9_.-]{2,63}', name) or not isinstance(password, str) or not 12 <= len(password) <= 1024: + raise ValueError() + except (ValueError, KeyError, TypeError, AttributeError): + return reply({'error': 'Use a 3–64 character username and a 12–1024 character password.'}, 400) + if AUTH_GATE.locked(): + return reply({'error': 'Sign-in busy; retry shortly.'}, 429) + async with AUTH_GATE: + token, code = await run_in_threadpool(credentials, name, password, request.url.path.endswith('/register'), request.headers.get('fly-client-ip', request.client.host if request.client else 'local')) + if not token: + return reply({'error': 'Sign-in unavailable. Check details or wait before retrying.'}, code) + response = reply({'ok': True}) + response.set_cookie(COOKIE, token, max_age=SESSION_SECONDS, secure=True, httponly=True, samesite='none', path='/') + return response + + +async def session(request): + user = await run_in_threadpool(resolve, request) + if not user: + return reply({'error': 'unauthorized'}, 401) + return reply({'owner': user['owner'], 'name': user['login'], 'gitea_id': user['id']}) + + +async def named_vaults(request): + if request.url.scheme != 'https' or request.url.query or request.headers.get('authorization'): + return reply({'error': 'forbidden'}, 403) + if request.method == 'POST' and (request.headers.getlist('origin') != [ORIGIN] or + request.headers.get('x-requested-with') != 'Persimmons'): + return reply({'error': 'forbidden'}, 403) + user = await run_in_threadpool(resolve, request) + if not user: + return reply({'error': 'unauthorized'}, 401) + try: + if request.method == 'GET': + with closing(database()) as db: + rows = db.execute('SELECT repo_id,name FROM named_vaults WHERE owner=? ORDER BY name', (user['owner'],)).fetchall() + vaults = await run_in_threadpool(gitea.list_repos, user, rows) + return reply({'vaults': vaults}) + if request.headers.get('content-type') != 'application/json': + return reply({'error': 'JSON required'}, 415) + raw = bytearray() + async for chunk in request.stream(): + raw.extend(chunk) + if len(raw) > 1024: + raise ValueError() + data = json.loads(raw) + if not isinstance(data, dict) or set(data) != {'name'}: + raise ValueError() + name = gitea.repo_name(data['name']) + with closing(database()) as db: + if db.execute('SELECT 1 FROM named_vaults WHERE owner=? AND name=?', (user['owner'], name)).fetchone(): + return reply({'error': 'A repository with this name already exists.'}, 409) + if db.execute('SELECT count(*) FROM named_vaults WHERE owner=?', (user['owner'],)).fetchone()[0] >= 100: + return reply({'error': 'Vault limit reached.'}, 409) + repo = await run_in_threadpool(gitea.create_repo, user, name) + with closing(database()) as db, db: + db.execute('INSERT INTO named_vaults VALUES (?,?,?)', (user['owner'], repo['id'], repo['name'])) + return reply({'vault': repo}, 201) + except (ValueError, TypeError): + return reply({'error': 'Use a vault name with letters, numbers, spaces, dots, underscores or hyphens (100 characters maximum).'}, 400) + except gitea.Failure as error: + if error.code == 409: + return reply({'error': 'A repository with this name already exists.'}, 409) + return reply({'error': 'Vault service unavailable.'}, 503) + except (sqlite3.Error, OSError): + return reply({'error': 'Vault service unavailable.'}, 503) + + +async def logout(request): + if request.url.scheme != 'https' or request.headers.getlist('origin') != [ORIGIN]: + return reply({'error': 'forbidden'}, 403) + digest = hashlib.sha256(request.cookies.get(COOKIE, '').encode()).hexdigest() + with closing(database()) as db, db: + db.execute('DELETE FROM sessions WHERE digest=?', (digest,)) + response = reply({'ok': True}) + response.delete_cookie(COOKIE, path='/', secure=True, httponly=True, samesite='none') + return response + + +async def vault_index(request): + owner = await actor(request) + if not owner: + return reply({'error': 'unauthorized'}, 401) + path = os.environ.get('PERSIMMONS_HUMAN_VAULT_DB') + if not path: + return reply({'error': 'vault not configured'}, 503) + try: + payload, code = await run_in_threadpool(human_vault.Store(path).operate, owner.lower(), 'GET') + except (sqlite3.Error, OSError): + return reply({'error': 'vault unavailable'}, 503) + return reply(payload, code) + + +async def delete_vault_item(request): + if (request.url.scheme != 'https' or request.headers.getlist('origin') != [ORIGIN] or + request.headers.get('x-requested-with') != 'Persimmons' or request.url.query): + return reply({'error': 'forbidden'}, 403) + owner = await actor(request) + if not owner: + return reply({'error': 'unauthorized'}, 401) + item_id = request.path_params['item_id'] + if not human_vault.ITEM_ID.fullmatch(item_id): + return reply({'error': 'not found'}, 404) + path = os.environ.get('PERSIMMONS_HUMAN_VAULT_DB') + if not path: + return reply({'error': 'vault not configured'}, 503) + try: + payload, code = await run_in_threadpool( + human_vault.Store(path).operate, owner.lower(), 'DELETE', item_id) + except (sqlite3.Error, OSError): + return reply({'error': 'vault unavailable'}, 503) + return reply(payload, code) + + +async def health(request): + try: + with closing(database()) as db: + db.execute('SELECT 1').fetchone() + return reply({'ok': True, 'service': 'persimmons', 'source_sha': os.environ.get('SOURCE_SHA', 'local')}) + except sqlite3.Error: + return reply({'ok': False, 'service': 'persimmons'}, 503) + + +async def home(request): + return FileResponse(ROOT / 'web/index.html', headers={'Cache-Control': 'no-store'}) + + +async def signin(request): + return FileResponse(ROOT / 'web/signin.html', headers={'Cache-Control': 'no-store'}) + + +# Only the path changes; encryption validation, ownership, and exact extension +# origin enforcement are copied byte-for-byte from the accepted implementation. +vault_routes = [Route(route.path.replace('/chat/persimmons', '/api'), route.endpoint, methods=route.methods) + for route in human_vault.routes(actor)] +async def read_vault(request): + # Chromium sends its genuine extension Origin on POST, but omits it on + # permitted cross-origin GET. Adapt only empty read requests; the accepted + # handler still checks HTTPS, exact Origin, cookie session and owner itself. + if request.method == 'OPTIONS': + return await vault_routes[0].endpoint(request) + async for chunk in request.stream(): + if chunk: + return reply({'error': 'read body must be empty'}, 400) + read_request = Request({**request.scope, 'method': 'GET'}, receive=request.receive) + return await vault_routes[0].endpoint(read_request) + + +app = Starlette() +app.routes.extend([Route('/', home), Route('/signin', signin), Route('/healthz', health), + Route('/api/juice/{operation}', juice.endpoint(resolve, database), methods=['POST', 'OPTIONS']), + Route('/api/session', session), Route('/api/vaults', named_vaults, methods=['GET', 'POST']), Route('/api/vault-index', vault_index), + Route('/api/vault-index/{item_id}', delete_vault_item, methods=['DELETE']), + Route('/api/logout', logout, methods=['POST']), + Route('/api/auth/register', auth, methods=['POST'], name='register'), + Route('/api/auth/login', auth, methods=['POST'], name='login'), + Route('/api/human-vault/items/read', read_vault, methods=['POST', 'OPTIONS']), + Route('/api/human-vault/items/{item_id}/read', read_vault, methods=['POST', 'OPTIONS']), *vault_routes]) +app.mount('/assets', StaticFiles(directory=ROOT / 'web'), name='assets') +app.add_middleware(TrustedHostMiddleware, allowed_hosts=['persimmons.app', 'persimmons-app.fly.dev', 'localhost', '127.0.0.1']) + + +async def response_headers(request, call_next): + response = await call_next(request) + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['Referrer-Policy'] = 'no-referrer' + response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'" + return response + +app.add_middleware(BaseHTTPMiddleware, dispatch=response_headers)