upgrade: adopt V8.21.3 (V8.13→8.21.3: P5 SFTP/S3, P6 multilingual, editor overhaul, enterprise hardening)

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>
This commit is contained in:
浅念
2026-10-23 12:31:03 +08:00
parent 257c89e159
commit 601451f22b
218 changed files with 10441 additions and 628 deletions
+71
View File
@@ -0,0 +1,71 @@
"""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',
})