227 خطوط
12 KiB
Python
227 خطوط
12 KiB
Python
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import re
|
|
import secrets
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from typing import Annotated, Literal
|
|
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse, JSONResponse, Response
|
|
from sqlalchemy import func, select, text
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
from starlette.concurrency import run_in_threadpool
|
|
from .config import settings
|
|
from .db import Job, initialize, sessions
|
|
from .storage import storage
|
|
from .fonts import fonts, stylesheet
|
|
from .models.api import UploadResponse, RenderStatus, ErrorEnvelope
|
|
from .models.schematic import Document
|
|
from .uploads import read_schematic
|
|
|
|
FRONTEND = Path(__file__).resolve().parents[2] / 'frontend'
|
|
ID = re.compile(r'^[a-f0-9]{48}$')
|
|
|
|
def digest(token): return hashlib.sha256(token.encode()).hexdigest()
|
|
|
|
def error(code, message, status=400):
|
|
raise HTTPException(status, {'code': code, 'message': message})
|
|
|
|
def authenticated(key):
|
|
expected = settings().api_key
|
|
return bool(expected and key and hmac.compare_digest(expected, key))
|
|
|
|
def require_api(key):
|
|
if settings().api_key and not authenticated(key):
|
|
error('unauthorized', 'Provide a valid X-API-Key header', 401)
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app):
|
|
initialize()
|
|
fonts() # Validate configured font allowlist before accepting requests.
|
|
yield
|
|
|
|
app = FastAPI(docs_url=None, redoc_url=None, responses={code: {'model': ErrorEnvelope} for code in (401,404,409,410,413,415,422,503)}, title='Native Altium Schematic Rendering API', version='1.0.0', lifespan=lifespan,
|
|
description='Upload native SchDoc, poll durable jobs, retrieve versioned geometry and SVG. Partial compatibility is explicitly reported.')
|
|
|
|
# Count the complete request body before Starlette spools multipart uploads.
|
|
# Content-Length alone is insufficient for chunked requests.
|
|
class UploadLimit:
|
|
def __init__(self, app): self.app = app
|
|
async def __call__(self, scope, receive, send):
|
|
if scope['type'] != 'http': return await self.app(scope, receive, send)
|
|
file_count = 2 if scope['path'] in ('/api/compare','/api/v1/comparisons') else 1
|
|
limit = file_count * settings().max_upload_bytes + 1024 * 1024
|
|
received = 0
|
|
async def bounded_receive():
|
|
nonlocal received
|
|
message = await receive()
|
|
received += len(message.get('body', b''))
|
|
if received > limit:
|
|
raise HTTPException(413, {'code': 'upload_too_large', 'message': 'Multipart body exceeds configured size limit'})
|
|
return message
|
|
await self.app(scope, bounded_receive, send)
|
|
|
|
app.add_middleware(UploadLimit)
|
|
|
|
@app.middleware('http')
|
|
async def security(request, call_next):
|
|
# Reject unauthorized POST bodies before the multipart parser reads them.
|
|
if request.method == 'POST' and request.url.path in ('/api/v1/schematics/render','/api/compare','/api/v1/comparisons'):
|
|
if settings().api_key and not authenticated(request.headers.get('x-api-key')):
|
|
return JSONResponse({'success': False, 'error': {'code': 'unauthorized', 'message': 'Provide a valid X-API-Key header'}}, 401)
|
|
response = await call_next(request)
|
|
is_viewer = request.url.path.startswith('/view/')
|
|
frame_ancestors = "'self' https://git.sadentel.net" if is_viewer else "'none'"
|
|
response.headers.update({
|
|
'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
|
|
'Content-Security-Policy': f"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'none'; frame-ancestors {frame_ancestors}; form-action 'self'",
|
|
'Cache-Control': 'no-store', 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
|
|
})
|
|
if is_viewer and 'X-Frame-Options' in response.headers:
|
|
del response.headers['X-Frame-Options']
|
|
return response
|
|
|
|
# Register after security so CORS also decorates its early authentication errors.
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=['https://git.sadentel.net'],
|
|
allow_methods=['GET', 'POST', 'OPTIONS'],
|
|
allow_headers=['X-API-Key', 'X-Viewer-Token', 'Content-Type'],
|
|
)
|
|
|
|
@app.exception_handler(HTTPException)
|
|
async def http_error(request, exc):
|
|
detail = exc.detail if isinstance(exc.detail, dict) else {'code': 'http_error', 'message': str(exc.detail)}
|
|
return JSONResponse({'success': False, 'error': detail}, status_code=exc.status_code, headers=exc.headers)
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_error(request, exc):
|
|
return JSONResponse({'success': False, 'error': {'code': 'invalid_request', 'message': 'Invalid or missing request fields',
|
|
'fields': ['.'.join(map(str, e['loc'])) for e in exc.errors()]}}, status_code=422)
|
|
|
|
@app.get('/healthz', include_in_schema=False)
|
|
def health():
|
|
with sessions()() as db: db.execute(text('SELECT 1'))
|
|
return {'status': 'ok'}
|
|
|
|
def authorize(identifier, key, token, *, comparison=False):
|
|
if not ID.fullmatch(identifier): error('not_found', 'Schematic not found', 404)
|
|
with sessions()() as db: job = db.get(Job, identifier)
|
|
if job is None or job.input_key.endswith('/comparison.json') != comparison:
|
|
error('not_found', 'Schematic not found', 404)
|
|
# Private existence and expiry are not disclosed to unauthenticated callers.
|
|
if job.access == 'private' and not (authenticated(key) or (token and hmac.compare_digest(job.token_hash, digest(token)))):
|
|
error('not_found', 'Schematic not found', 404)
|
|
if job.expires_at <= time.time(): error('link_expired', 'This schematic link has expired', 410)
|
|
return job
|
|
|
|
def public_status(job):
|
|
result = {'success': job.status != 'failed', 'id': job.id, 'status': job.status,
|
|
'viewer_url': settings().public_url + '/view/' + job.id,
|
|
'expires_at': job.expires_at, 'access': job.access}
|
|
if job.summary: result.update(json.loads(job.summary))
|
|
if job.error_code: result['error'] = {'code': job.error_code, 'message': job.error_message}
|
|
return result
|
|
|
|
@app.post('/api/v1/schematics/render', status_code=202, response_model=UploadResponse, response_model_exclude_none=True,
|
|
responses={202: {'description': 'Queued; poll status_url'}, 401: {'description': 'API key required'},
|
|
413: {'description': 'Upload too large'}, 422: {'description': 'Invalid file or fields'},
|
|
503: {'description': 'Queue or storage unavailable'}})
|
|
async def upload(file: Annotated[UploadFile, File(description='Native binary OLE or ASCII .SchDoc')],
|
|
access: Annotated[Literal['private', 'unlisted'], Form()] = 'private',
|
|
expires_in: Annotated[int | None, Form(gt=0, description='Link lifetime in seconds, up to configured maximum')] = None,
|
|
x_api_key: Annotated[str | None, Header()] = None):
|
|
require_api(x_api_key)
|
|
ttl = expires_in or settings().link_ttl
|
|
if ttl > settings().link_ttl: error('invalid_expiry', 'expires_in exceeds the configured link lifetime', 422)
|
|
name, body = await read_schematic(file)
|
|
identifier = secrets.token_hex(24)
|
|
token = secrets.token_urlsafe(32)
|
|
now = time.time()
|
|
job = Job(id=identifier, filename=name, status='queued', access=access, token_hash=digest(token),
|
|
created_at=now, updated_at=now, expires_at=now+ttl, attempt=0, input_key=f'{identifier}/input.SchDoc')
|
|
def persist():
|
|
with sessions().begin() as db:
|
|
# Serialize admission checks across API replicas on PostgreSQL.
|
|
if db.bind.dialect.name == 'postgresql': db.execute(text('SELECT pg_advisory_xact_lock(84721309)'))
|
|
pending = db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']), Job.expires_at > now))
|
|
if pending >= settings().max_pending: error('queue_full', 'Rendering queue is full; try again later', 503)
|
|
try: storage().put(job.input_key, bytes(body))
|
|
except Exception: error('storage_unavailable', 'Upload storage unavailable', 503)
|
|
try:
|
|
db.add(job)
|
|
db.flush()
|
|
except Exception:
|
|
storage().delete(job.input_key)
|
|
raise
|
|
await run_in_threadpool(persist)
|
|
result = public_status(job)
|
|
if access == 'private':
|
|
# Fragments are never sent to servers/referrers; viewer exchanges this only in a header.
|
|
result['viewer_url'] += '#token=' + token
|
|
result['viewer_token'] = token
|
|
result['status_url'] = settings().public_url + f'/api/v1/schematics/{identifier}/status'
|
|
return JSONResponse(result, status_code=202, headers={'Location': result['status_url'], 'Retry-After': '1'})
|
|
|
|
@app.get('/api/v1/schematics/{identifier}/status', response_model=RenderStatus, response_model_exclude_none=True)
|
|
def status(identifier: str, x_api_key: Annotated[str | None, Header()] = None,
|
|
x_viewer_token: Annotated[str | None, Header()] = None):
|
|
return public_status(authorize(identifier, x_api_key, x_viewer_token))
|
|
|
|
def artifact(identifier, kind, key, token):
|
|
job = authorize(identifier, key, token)
|
|
if job.status == 'failed': error(job.error_code, job.error_message, 422)
|
|
if job.status != 'completed': error('not_ready', 'Rendering is still in progress', 409)
|
|
try: data = storage().get(job.output_prefix + '/' + kind)
|
|
except Exception: error('storage_unavailable', 'Rendering artifact unavailable', 503)
|
|
return Response(data, media_type='application/json' if kind.endswith('.json') else 'image/svg+xml')
|
|
|
|
@app.get('/api/v1/schematics/{identifier}/data', response_model=Document)
|
|
def data(identifier: str, x_api_key: Annotated[str | None, Header()] = None,
|
|
x_viewer_token: Annotated[str | None, Header()] = None):
|
|
return artifact(identifier, 'data.json', x_api_key, x_viewer_token)
|
|
|
|
@app.get('/api/v1/schematics/{identifier}/svg', response_class=Response, responses={200:{'content':{'image/svg+xml':{'schema':{'type':'string'}}}}})
|
|
def svg(identifier: str, x_api_key: Annotated[str | None, Header()] = None,
|
|
x_viewer_token: Annotated[str | None, Header()] = None):
|
|
return artifact(identifier, 'drawing.svg', x_api_key, x_viewer_token)
|
|
|
|
@app.get('/view/{identifier}', include_in_schema=False)
|
|
def viewer(identifier: str):
|
|
if not ID.fullmatch(identifier): error('not_found', 'Schematic not found', 404)
|
|
return FileResponse(FRONTEND / 'viewer.html')
|
|
|
|
@app.get('/', include_in_schema=False)
|
|
def index(): return FileResponse(FRONTEND / 'index.html')
|
|
|
|
@app.get('/assets/{name}', include_in_schema=False)
|
|
def asset(name: str):
|
|
if name not in {'viewer.js','viewer.css','upload.js','upload.css','camera.js','compare.js','compare.css','compare-upload.js','interactions.js'}: error('not_found', 'Asset not found', 404)
|
|
return FileResponse(FRONTEND / name)
|
|
|
|
@app.get('/docs', include_in_schema=False)
|
|
def api_docs(): return FileResponse(FRONTEND / 'docs.html')
|
|
|
|
@app.get('/fonts.css', include_in_schema=False)
|
|
def font_css(): return Response(stylesheet(),media_type='text/css')
|
|
|
|
@app.get('/fonts/{name}', include_in_schema=False)
|
|
def font_file(name: str):
|
|
font=fonts().get(name)
|
|
if font is None: error('not_found','Font not found',404)
|
|
return FileResponse(font['file'])
|
|
|
|
@app.exception_handler(SQLAlchemyError)
|
|
async def database_error(request, exc):
|
|
logging.getLogger(__name__).error('Database request failed', exc_info=exc)
|
|
return JSONResponse({'success':False,'error':{'code':'database_unavailable','message':'Database service unavailable'}},status_code=503,headers={'Cache-Control':'no-store'})
|
|
|
|
# Comparison jobs share the existing queue, access policy and error envelope.
|
|
from .compare_api import register_comparison_routes
|
|
register_comparison_routes(app, authorize=authorize, require_api=require_api, error=error)
|