72 lines
3.5 KiB
Python
72 lines
3.5 KiB
Python
|
|
"""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',
|
||
|
|
})
|