Finish Persims consolidation

This commit is contained in:
matcha committed 2026-10-04 02:57:23 +00:00
1 parent 69dda53f2d
commit 8f4a8026fb
1 file changed
+280
+280
View File
@@ -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<?', (now,))
db.execute('DELETE FROM sessions WHERE expires<?', (now,))
for label, maximum in [('global', 120), ('ip:' + address, 20), ('name:' + name, 10)]:
key = hashlib.sha256(label.encode()).hexdigest()
row = db.execute('SELECT count FROM attempts WHERE key=?', (key,)).fetchone()
if row and row[0] >= 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)