601451f22b
Overlay ~150 upstream items onto our V8.12 base, preserving trade-pro
theme, registry tests, and design docs. Re-applied our patch set
(discovery/env_for/blocks.css/type-mapping/theme_color/frontend entries,
test_theme_pro_v3 mods) — all anchors survived upstream evolution.
New platform enhancement (fixes upgrade-introduced regression):
multilingual.site_locale now honors settings_json.locale, plus
PUT /api/i18n/sites/{id}/locale — standalone English sites get
<html lang='en'> under the V8.15 locale-rewrite pipeline.
compose: adopt V8.21.3 (image 8.21.3-dev.1, /api/ready probes, MySQL/S3
env) with project name pinned to siteforge-v8-10 (data volumes).
Tests: 356 passed / 11 failed — all 11 confirmed pre-existing in
pristine V8.21.3 on this host (10 upstream Windows env + our build test
now green after locale fix). Patch registry updated in plan doc.
Co-Authored-By: Claude Code <noreply@anthropic.com>
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',
|
|
})
|