Finish Persims consolidation
This commit is contained in:
1 parent
69dda53f2d
commit
8f4a8026fb
1 file changed
+280
@@ -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)
|
||||||
Reference in new issue
Block a user