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>
220 lines
10 KiB
Python
220 lines
10 KiB
Python
"""Minimal isolated, signed release receiver, not a shell/SSH command runner.
|
|
|
|
Run behind TLS reverse proxy. The receiver has no database credentials and may
|
|
only write under SITEFORGE_DEPLOY_ROOT. Never expose without TLS/firewall.
|
|
"""
|
|
from __future__ import annotations
|
|
import hashlib,hmac,io,json,os,re,shutil,tempfile,time,zipfile
|
|
from pathlib import Path,PurePosixPath
|
|
from fastapi import FastAPI,HTTPException,Request
|
|
from fastapi.responses import FileResponse
|
|
|
|
app=FastAPI(title='SiteForge Release Agent',docs_url=None,redoc_url=None,openapi_url=None)
|
|
ROOT=Path(os.environ.get('SITEFORGE_DEPLOY_ROOT','/srv/releases')).resolve()
|
|
SITE=re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$')
|
|
MAX_ZIP=120*1024*1024
|
|
MAX_EXPANDED=1024*1024*1024
|
|
|
|
def signature(secret,site,timestamp,body,method,path):
|
|
# Bind each release signature to the exact HTTP action and target path;
|
|
# otherwise an inventory signature could authorize a rollback endpoint.
|
|
envelope=(f'{timestamp}\n{site}\n{method.upper()}\n{path}\n'
|
|
f'{hashlib.sha256(body).hexdigest()}').encode('utf-8')
|
|
return hmac.new(secret.encode(),envelope,hashlib.sha256).hexdigest()
|
|
|
|
def authorize(site,request,body):
|
|
ref=os.environ.get('SITEFORGE_DEPLOY_SECRET_REF','')
|
|
if ref:
|
|
from .secret_refs import resolve,SecretReferenceError
|
|
try:secret=resolve(ref)
|
|
except SecretReferenceError:raise HTTPException(503,'Release Agent secret unavailable')
|
|
else:secret=os.environ.get('SITEFORGE_DEPLOY_SECRET','')
|
|
if len(secret)<32:raise HTTPException(503,'Release agent secret not configured')
|
|
stamp=request.headers.get('x-siteforge-timestamp','')
|
|
try:
|
|
if abs(int(time.time())-int(stamp))>300:raise ValueError
|
|
except ValueError:raise HTTPException(401,'Invalid release timestamp')
|
|
actual=request.headers.get('x-siteforge-signature','')
|
|
if not hmac.compare_digest(signature(secret,site,stamp,body,request.method,request.url.path),actual):raise HTTPException(401,'Invalid release signature')
|
|
|
|
|
|
def validate_archive(raw):
|
|
if len(raw)>MAX_ZIP:raise ValueError('Archive too large')
|
|
with zipfile.ZipFile(io.BytesIO(raw)) as z:
|
|
if len(z.infolist())>20000:raise ValueError('Too many files')
|
|
total=0;seen=set()
|
|
for info in z.infolist():
|
|
name=info.filename
|
|
path=PurePosixPath(name)
|
|
if name.startswith('/') or '\\' in name or '..' in path.parts or not name or ':' in name or name.startswith('./'):
|
|
raise ValueError('Unsafe archive path')
|
|
key=name.lower().rstrip('/')
|
|
if key in seen or key=='.siteforge-manifest.json':
|
|
raise ValueError('Duplicate or reserved file in archive')
|
|
seen.add(key)
|
|
# Unix symlink type is 0o120000.
|
|
if ((info.external_attr>>16)&0o170000)==0o120000:raise ValueError('Symlinks forbidden')
|
|
total+=info.file_size
|
|
if total>MAX_EXPANDED:raise ValueError('Expanded archive too large')
|
|
if info.file_size>2000000 and info.compress_size and info.file_size/info.compress_size>200:
|
|
raise ValueError('Suspicious compression ratio')
|
|
if 'index.html' not in z.namelist():raise ValueError('Missing site index.html')
|
|
return True
|
|
|
|
|
|
def _pointer(site):
|
|
parent=ROOT/site
|
|
current=parent/'current'
|
|
version=current.read_text(encoding='ascii').strip() if current.is_file() else None
|
|
return version if version and re.fullmatch('[0-9a-f]{20}',version) else None
|
|
|
|
|
|
def _manifest(root):
|
|
"""Hash all files of an immutable release. Called on a limited archive."""
|
|
hashes={}
|
|
for path in sorted(root.rglob('*')):
|
|
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
|
|
raise ValueError('Release contains unsafe link')
|
|
if path.is_file():hashes[path.relative_to(root).as_posix()]=hashlib.sha256(path.read_bytes()).hexdigest()
|
|
return hashes
|
|
|
|
|
|
def stage(site,raw):
|
|
"""Stage an immutable release without changing current traffic."""
|
|
if not SITE.fullmatch(site):raise ValueError('Bad site ID')
|
|
validate_archive(raw)
|
|
digest=hashlib.sha256(raw).hexdigest()[:20]
|
|
parent=ROOT/site
|
|
releases=parent/'releases'
|
|
releases.mkdir(parents=True,exist_ok=True)
|
|
target=releases/digest
|
|
if target.is_symlink():raise ValueError('Invalid immutable release directory')
|
|
if not target.is_dir():
|
|
stage_dir=Path(tempfile.mkdtemp(prefix='.unpacked-',dir=str(parent)))
|
|
try:
|
|
with zipfile.ZipFile(io.BytesIO(raw)) as z:z.extractall(stage_dir)
|
|
if not (stage_dir/'index.html').is_file():raise ValueError('Invalid site')
|
|
hashes=_manifest(stage_dir)
|
|
(stage_dir/'.siteforge-manifest.json').write_text(json.dumps({'sha256':hashlib.sha256(raw).hexdigest(),
|
|
'files':hashes},sort_keys=True),encoding='utf-8')
|
|
try:stage_dir.rename(target)
|
|
except FileExistsError:pass
|
|
finally:
|
|
if stage_dir.exists():shutil.rmtree(stage_dir)
|
|
return {'site':site,'release':digest,'sha256':hashlib.sha256(raw).hexdigest(),
|
|
'staged':True,'current':_pointer(site)}
|
|
|
|
|
|
def verify_release(site,release):
|
|
if not SITE.fullmatch(site) or not re.fullmatch('[0-9a-f]{20}',release):raise ValueError('Invalid release')
|
|
folder=ROOT/site/'releases'/release
|
|
meta=folder/'.siteforge-manifest.json'
|
|
if not meta.is_file():raise ValueError('Release manifest missing')
|
|
manifest=json.loads(meta.read_text(encoding='utf-8'))
|
|
expected=manifest.get('files',{})
|
|
current=_manifest(folder)
|
|
current.pop('.siteforge-manifest.json',None)
|
|
if expected != current or 'index.html' not in current:raise ValueError('Release file integrity failure')
|
|
return {'site':site,'release':release,'sha256':manifest['sha256'],
|
|
'files':len(current),'verified':True,'active':_pointer(site)==release,
|
|
'current':_pointer(site)}
|
|
|
|
|
|
def activate(site,release):
|
|
verified=verify_release(site,release)
|
|
parent=ROOT/site
|
|
previous=_pointer(site)
|
|
tmp=parent/('.current-'+re.sub('[^0-9a-f]','',release)+'.tmp')
|
|
tmp.write_text(release,encoding='ascii')
|
|
os.replace(tmp,parent/'current')
|
|
return {**verified,'active':True,'previous':previous,'current':release}
|
|
|
|
|
|
def install(site,raw):
|
|
"""Compatibility endpoint used by existing clients; new Worker uses stage/verify/activate."""
|
|
staged=stage(site,raw)
|
|
active=activate(site,staged['release'])
|
|
return {**active,'published':True}
|
|
|
|
|
|
@app.get('/health')
|
|
def health():return {'healthy':True}
|
|
|
|
@app.post('/deploy/{site}')
|
|
async def deploy(site:str,request:Request):
|
|
if not SITE.fullmatch(site):raise HTTPException(400,'Invalid site')
|
|
if int(request.headers.get('content-length','0') or 0)>MAX_ZIP:raise HTTPException(413)
|
|
parts=[];size=0
|
|
async for chunk in request.stream():
|
|
size+=len(chunk)
|
|
if size>MAX_ZIP:raise HTTPException(413)
|
|
parts.append(chunk)
|
|
data=b''.join(parts)
|
|
authorize(site,request,data)
|
|
try:return install(site,data)
|
|
except (ValueError,zipfile.BadZipFile) as e:raise HTTPException(400,str(e))
|
|
|
|
@app.post('/staging/{site}')
|
|
async def stage_upload(site:str,request:Request):
|
|
if not SITE.fullmatch(site):raise HTTPException(400)
|
|
parts=[];size=0
|
|
async for chunk in request.stream():
|
|
size+=len(chunk)
|
|
if size>MAX_ZIP:raise HTTPException(413)
|
|
parts.append(chunk)
|
|
raw=b''.join(parts)
|
|
authorize(site,request,raw)
|
|
try:return stage(site,raw)
|
|
except (ValueError,zipfile.BadZipFile) as exc:raise HTTPException(400,str(exc))
|
|
|
|
@app.get('/sites/{site}/verify/{release}')
|
|
def verify_route(site:str,release:str,request:Request):
|
|
if not SITE.fullmatch(site) or not re.fullmatch('[0-9a-f]{20}',release):raise HTTPException(400)
|
|
authorize(site,request,b'')
|
|
try:return verify_release(site,release)
|
|
except (ValueError,OSError,KeyError) as exc:raise HTTPException(409,str(exc))
|
|
|
|
@app.post('/sites/{site}/activate/{release}')
|
|
def activate_route(site:str,release:str,request:Request):
|
|
if not SITE.fullmatch(site) or not re.fullmatch('[0-9a-f]{20}',release):raise HTTPException(400)
|
|
authorize(site,request,b'')
|
|
try:return activate(site,release)
|
|
except (ValueError,OSError,KeyError) as exc:raise HTTPException(409,str(exc))
|
|
|
|
@app.get('/sites/{site}/releases')
|
|
def versions(site:str,request:Request):
|
|
# No public access to release inventory. Require same signed request semantics.
|
|
if not SITE.fullmatch(site):raise HTTPException(400)
|
|
authorize(site,request,b'')
|
|
parent=ROOT/site
|
|
return {'current':(parent/'current').read_text().strip() if (parent/'current').exists() else None,
|
|
'releases':sorted(p.name for p in (parent/'releases').iterdir() if p.is_dir()) if (parent/'releases').is_dir() else []}
|
|
|
|
@app.post('/sites/{site}/rollback/{release}')
|
|
async def rollback(site:str,release:str,request:Request):
|
|
if not SITE.fullmatch(site) or not re.fullmatch('[0-9a-f]{20}',release):raise HTTPException(400)
|
|
authorize(site,request,b'')
|
|
parent=ROOT/site
|
|
if not (parent/'releases'/release/'index.html').is_file():raise HTTPException(404)
|
|
if (parent/'releases'/release/'.siteforge-manifest.json').exists():
|
|
try:verify_release(site,release)
|
|
except (ValueError,OSError,KeyError):raise HTTPException(409,'Historic release failed integrity verification')
|
|
tmp=parent/'.current.tmp';tmp.write_text(release);os.replace(tmp,parent/'current')
|
|
return {'site':site,'release':release,'rolled_back':True}
|
|
|
|
@app.get('/public/{site}/{path:path}')
|
|
def serve(site:str,path:str=''):
|
|
if not SITE.fullmatch(site):raise HTTPException(404)
|
|
parent=ROOT/site
|
|
pointer=parent/'current'
|
|
if not pointer.is_file():raise HTTPException(404)
|
|
version=pointer.read_text().strip()
|
|
if not re.fullmatch('[0-9a-f]{20}',version):raise HTTPException(404)
|
|
root=(parent/'releases'/version).resolve()
|
|
rel=path or 'index.html'
|
|
candidate=(root/rel).resolve()
|
|
if not candidate.is_relative_to(root):raise HTTPException(404)
|
|
if candidate.is_dir() or not candidate.suffix:candidate=candidate/'index.html'
|
|
if not candidate.is_file():raise HTTPException(404)
|
|
return FileResponse(candidate)
|