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