"""Pointer-aware, read-only static website gateway for SiteForge S3 releases. This process requires ONLY S3 read permissions. It never takes publishing credentials and never writes to the bucket. Deploy behind HTTPS reverse proxy. """ from __future__ import annotations import json,os,re from urllib.parse import unquote from fastapi import FastAPI, HTTPException from fastapi.responses import Response from .release_targets import SITE_RE, RELEASE_RE app=FastAPI(title='SiteForge S3 read-only site gateway',docs_url=None,redoc_url=None,openapi_url=None) def gateway_config(): bucket=os.getenv('SITEFORGE_S3_GATEWAY_BUCKET','').strip() region=os.getenv('SITEFORGE_S3_GATEWAY_REGION','').strip() prefix=os.getenv('SITEFORGE_S3_GATEWAY_PREFIX','siteforge').strip('/') if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{1,62}',bucket) or not region: raise HTTPException(503,'S3 gateway storage not configured') if not prefix or not re.fullmatch(r'[A-Za-z0-9/_-]+',prefix) or '..' in prefix.split('/'): raise HTTPException(503,'Invalid S3 gateway prefix') return bucket,region,prefix def client(region): try:import boto3 except ImportError as ex:raise HTTPException(503,'boto3 missing from release image') from ex endpoint=os.getenv('SITEFORGE_S3_GATEWAY_ENDPOINT_URL','').strip() if endpoint and not endpoint.startswith('https://'): raise HTTPException(503,'S3 gateway endpoint must use TLS') return boto3.client('s3',region_name=region,endpoint_url=endpoint or None) def _read(s3,bucket,key,limit=32*1024*1024): try: result=s3.get_object(Bucket=bucket,Key=key) body=result['Body'].read(limit+1) if len(body)>limit:raise HTTPException(413,'Static object exceeds gateway response limit') return body, result except HTTPException:raise except Exception as ex: code=str(getattr(ex,'response',{}).get('Error',{}).get('Code','')) if code in ('404','NoSuchKey','NotFound'):raise HTTPException(404,'Static resource not found') from ex raise HTTPException(502,'Storage unavailable') from ex def resolve_object(site,path,s3,bucket,prefix): if not SITE_RE.fullmatch(site):raise HTTPException(404,'Invalid site') path=unquote(path or '').lstrip('/') if '\\' in path or '%' in path or '..' in path.split('/') or len(path)>2048: raise HTTPException(404,'Invalid static path') if not path or path.endswith('/'):path+='index.html' pointer,_=_read(s3,bucket,f'{prefix}/sites/{site}/current.json',4096) try:release=json.loads(pointer)['release'] except (ValueError,TypeError,KeyError) as ex:raise HTTPException(502,'Invalid release pointer') from ex if not RELEASE_RE.fullmatch(release):raise HTTPException(502,'Invalid release pointer') objkey=f'{prefix}/sites/{site}/releases/{release}/{path}' body,meta=_read(s3,bucket,objkey) return body, meta.get('ContentType') or 'application/octet-stream', release @app.get('/health') def health():return {'healthy':True,'gateway':'read-only-s3'} @app.get('/{site}/{path:path}') def static_site(site:str,path:str=''): bucket,region,prefix=gateway_config() body,content_type,release=resolve_object(site,path,client(region),bucket,prefix) return Response(content=body,media_type=content_type,headers={ # URLs stay stable across releases, therefore cache must be revalidated. 'Cache-Control':'no-cache, must-revalidate', 'X-SiteForge-Release':release, 'X-Content-Type-Options':'nosniff', })