e21dbb6518
V8.33.0 absorbed our entire platform patch set as native features (discovery/env_for macro override/blocks.css priority/type-mapping fix/ frontend entries/locale override) and synced trade-pro from our remote; V8.33.1 adds HESU PIPE industrial-theme home fixes (1.3.1). Zero patch re-application this round — all verified native. Theme suites 35/35; full run 428 passed + same 10 known Windows env fails (FAILED set unchanged). Live: container 8.33.1-dev.1 healthy, hesupipe 192 entries intact, trade-pro build 203 pages 0 errors. Patch registry retired to archive — future upgrades are plain overlay. Co-Authored-By: Claude Code <noreply@anthropic.com>
841 lines
43 KiB
Python
841 lines
43 KiB
Python
"""SiteForge Docker-first website factory API and Web console."""
|
||
import io
|
||
from contextlib import asynccontextmanager
|
||
import json
|
||
import re
|
||
import shutil
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Body, Request
|
||
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, Response, JSONResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from pydantic import BaseModel, Field
|
||
from . import console_v819, wp_complex_v820, db, importers, builder, security, blueprints, enterprise, theme_store, factory_api, migration_audit, reusables, jobs, release_api, cms_pipeline, v8_workflows, v8_templates, wp_migration_v81, wp_migration_v82, wp_migration_v83, connectors_v84, connectors_v85, studio_v86, site_design_v87, multilingual
|
||
from .version import VERSION, BUILD_SHA
|
||
from .request_trace import TraceMiddleware
|
||
from .preview_rewrite import rewrite_preview_html
|
||
from .core import uid, THEMES, TYPES, clean_hex, save_entry, route_path, entry_dict
|
||
|
||
ROOT=Path(__file__).resolve().parent.parent
|
||
@asynccontextmanager
|
||
async def lifespan(app):
|
||
security.configure()
|
||
db.init()
|
||
enterprise.setup()
|
||
factory_api.setup()
|
||
migration_audit.setup()
|
||
reusables.setup()
|
||
release_api.setup()
|
||
cms_pipeline.setup()
|
||
v8_workflows.setup()
|
||
v8_templates.setup()
|
||
wp_migration_v81.setup()
|
||
wp_migration_v82.setup()
|
||
wp_migration_v83.setup()
|
||
connectors_v84.setup()
|
||
connectors_v85.setup()
|
||
studio_v86.setup()
|
||
site_design_v87.setup()
|
||
multilingual.setup()
|
||
yield
|
||
|
||
app=FastAPI(title='SiteForge 网站工厂',description='Docker-first visual website factory',version=VERSION,lifespan=lifespan)
|
||
app.add_middleware(TraceMiddleware)
|
||
app.mount('/ui',StaticFiles(directory=str(ROOT/'app'/'static')),name='static')
|
||
|
||
@app.get('/favicon.ico',include_in_schema=False)
|
||
def favicon():
|
||
return FileResponse(ROOT/'app'/'static'/'favicon.ico',media_type='image/x-icon')
|
||
app.include_router(enterprise.router)
|
||
app.include_router(factory_api.router)
|
||
app.include_router(migration_audit.router)
|
||
app.include_router(reusables.router)
|
||
app.include_router(release_api.router)
|
||
app.include_router(cms_pipeline.router)
|
||
app.include_router(v8_workflows.router)
|
||
app.include_router(v8_templates.router)
|
||
app.include_router(wp_migration_v81.router)
|
||
app.include_router(wp_migration_v82.router)
|
||
app.include_router(wp_migration_v83.router)
|
||
app.include_router(wp_complex_v820.router)
|
||
app.include_router(connectors_v84.router)
|
||
app.include_router(connectors_v85.router)
|
||
app.include_router(studio_v86.router)
|
||
app.include_router(site_design_v87.router)
|
||
app.include_router(multilingual.router)
|
||
app.include_router(console_v819.router)
|
||
|
||
@app.middleware('http')
|
||
async def require_login(request:Request, call_next):
|
||
path = request.url.path
|
||
public = path in ('/login', '/api/auth/login', '/api/health', '/api/ready', '/favicon.ico') or path.startswith('/ui/')
|
||
if not public:
|
||
session = security.read_session(request.cookies.get(security.COOKIE))
|
||
identity = enterprise.account_from_session(session) if session else None
|
||
if not identity:
|
||
if path.startswith('/api/') or path in ('/openapi.json',):
|
||
return JSONResponse({'detail':'请先登录 SiteForge'},status_code=401)
|
||
return RedirectResponse('/login',status_code=303)
|
||
request.state.account_id=identity['id']
|
||
request.state.is_superadmin=identity['superadmin']
|
||
request.state.actor=identity['actor']
|
||
if request.method not in ('GET','HEAD','OPTIONS'):
|
||
if not security.verify_csrf(session,request.headers.get('x-siteforge-csrf','')):
|
||
return JSONResponse({'detail':'安全验证失败,请刷新页面'},status_code=403)
|
||
origin=request.headers.get('origin')
|
||
if origin:
|
||
from urllib.parse import urlparse
|
||
parsed=urlparse(origin)
|
||
forwarded=request.headers.get('x-forwarded-host') or request.headers.get('host') or ''
|
||
if parsed.netloc.lower()!=forwarded.lower():
|
||
return JSONResponse({'detail':'不允许跨站请求'},status_code=403)
|
||
if not identity['superadmin']:
|
||
import re
|
||
segments=re.match(r'^/api/sites/([^/]+)(?:/|$)',path)
|
||
enterprise_segments=re.match(r'^/api/(?:enterprise|factory|audit|production|migrations|connectors)/sites/([^/]+)(?:/|$)',path)
|
||
asset_segments=re.match(r'^/(?:editor|preview|local-media)/([^/]+)(?:/|$)',path)
|
||
site_id=(segments or enterprise_segments or asset_segments)
|
||
if site_id:
|
||
site_id=site_id.group(1)
|
||
role=('viewer' if request.method in ('GET','HEAD') else 'editor')
|
||
if path.startswith('/api/factory/sites/') and '/release-requests/' in path and path.endswith('/review'):
|
||
role='reviewer'
|
||
if any(t in path for t in ('/publish-local','/rollback','/members','/deploy')) or request.method=='DELETE' and segments and segments.group(0).rstrip('/').endswith(site_id):
|
||
role='owner'
|
||
if path.endswith('/baseline') and request.method=='GET':role='viewer'
|
||
try:enterprise.require(request,site_id,role)
|
||
except HTTPException:
|
||
return JSONResponse({'detail':'没有访问该网站的权限'},status_code=403)
|
||
elif path in ('/api/sites','/api/presets') and request.method not in ('GET','HEAD'):
|
||
return JSONResponse({'detail':'只有系统管理员可创建站点/全局模板'},status_code=403)
|
||
elif path.startswith('/api/enterprise/team/') or path in ('/docs','/openapi.json'):
|
||
return JSONResponse({'detail':'没有全局管理权限'},status_code=403)
|
||
if request.method in ('PATCH','PUT','DELETE') and path.startswith('/api/sites/') and '/entries/' in path:
|
||
pieces=path.strip('/').split('/')
|
||
if len(pieces)>=5:
|
||
enterprise.save_revision(pieces[2],pieces[4],identity['actor'],'before_'+request.method.lower())
|
||
response=await call_next(request)
|
||
if not public:
|
||
if request.method in ('POST','PUT','PATCH','DELETE'):
|
||
site_match=__import__('re').match(r'^/api/(?:(?:enterprise|factory|production|migrations|connectors)/)?sites/([^/]+)',path)
|
||
enterprise.log_event(request.state.actor,path,response.status_code,request.method,
|
||
site_match.group(1) if site_match else None)
|
||
if request.method=='GET' and path=='/api/sites' and not request.state.is_superadmin and response.status_code==200:
|
||
# Apply the same site-scope filter at the top-level list boundary.
|
||
import json as _json
|
||
permitted=enterprise.db.rows('SELECT site_id FROM team_memberships WHERE user_id=?',(request.state.account_id,))
|
||
ids={r['site_id'] for r in permitted}
|
||
from starlette.responses import StreamingResponse
|
||
raw=b''
|
||
async for part in response.body_iterator:raw+=part
|
||
data=_json.loads(raw)
|
||
response=JSONResponse([x for x in data if x['id'] in ids])
|
||
response.headers['X-Content-Type-Options']='nosniff'
|
||
# Only read-only theme previews (and the existing editor) may be framed by this origin.
|
||
# All other application pages stay protected from clickjacking.
|
||
is_theme_preview=bool(__import__('re').fullmatch(r'/api/factory/themes/[A-Za-z0-9_-]+/preview',path))
|
||
response.headers['X-Frame-Options']='SAMEORIGIN' if (path.startswith('/editor/') or is_theme_preview) else 'DENY'
|
||
if is_theme_preview:
|
||
response.headers['Content-Security-Policy']="frame-ancestors 'self'"
|
||
response.headers['Cache-Control']='no-store'
|
||
response.headers['Referrer-Policy']='strict-origin-when-cross-origin'
|
||
response.headers['Cache-Control']='no-store' if path.startswith('/api/auth/') else response.headers.get('Cache-Control','no-cache')
|
||
return response
|
||
|
||
|
||
@app.get('/production-v8',response_class=HTMLResponse)
|
||
def production_v8():
|
||
return (ROOT/'app'/'templates'/'production-v8.html').read_text(encoding='utf-8')
|
||
|
||
@app.get('/theme-gallery',response_class=HTMLResponse)
|
||
def theme_gallery():
|
||
return (ROOT/'app'/'templates'/'theme-gallery.html').read_text(encoding='utf-8')
|
||
|
||
@app.get('/operations',response_class=HTMLResponse)
|
||
def operations():
|
||
return (ROOT/'app'/'templates'/'operations.html').read_text(encoding='utf-8')
|
||
|
||
@app.get('/factory-studio',response_class=HTMLResponse)
|
||
def factory_studio():
|
||
return (ROOT/'app'/'templates'/'factory-v2.html').read_text(encoding='utf-8')
|
||
|
||
@app.get('/login',response_class=HTMLResponse)
|
||
def login_page():
|
||
return (ROOT/'app'/'templates'/'login.html').read_text(encoding='utf-8')
|
||
|
||
|
||
class LoginIn(BaseModel):
|
||
password: str
|
||
username: str = "admin"
|
||
|
||
|
||
@app.post('/api/auth/login')
|
||
def login(payload:LoginIn, request:Request):
|
||
remote = request.client.host if request.client else 'unknown'
|
||
security.check_login_rate(remote)
|
||
if payload.username=='admin':
|
||
authenticated=security.check_password(payload.password)
|
||
token=security.sign_session() if authenticated else None
|
||
else:
|
||
user=enterprise.user_by_name(payload.username)
|
||
authenticated=bool(user and not user['disabled'] and enterprise.check_password(payload.password,user['password_hash']))
|
||
token=security.sign_user_session(user['id']) if authenticated else None
|
||
if not authenticated:
|
||
security.note_login_failure(remote)
|
||
raise HTTPException(401, '账号或密码不正确')
|
||
security.note_login_success(remote)
|
||
response = JSONResponse({'ok':True})
|
||
response.set_cookie(security.COOKIE, token, max_age=security.MAX_AGE,
|
||
httponly=True, secure=security.COOKIE_SECURE, samesite='strict', path='/')
|
||
response.headers['Cache-Control'] = 'no-store'
|
||
return response
|
||
|
||
|
||
@app.get('/api/auth/me')
|
||
def whoami(request:Request):
|
||
session=security.read_session(request.cookies.get(security.COOKIE))
|
||
return {'authenticated':True,'user':request.state.actor,'superadmin':request.state.is_superadmin,'csrf':security.csrf(session),'version':VERSION}
|
||
|
||
|
||
@app.post('/api/auth/logout')
|
||
def logout():
|
||
response=JSONResponse({'ok':True})
|
||
response.delete_cookie(security.COOKIE,path='/')
|
||
return response
|
||
|
||
|
||
class SiteIn(BaseModel):
|
||
name:str = Field(min_length=1,max_length=150)
|
||
domain:str = ''
|
||
tagline:str = ''
|
||
theme:str = 'corporate'
|
||
accent:str = '#2563eb'
|
||
logo:str = ''
|
||
mode:str = 'new'
|
||
origin:str = ''
|
||
preset_id:str = ''
|
||
|
||
class SitePatch(BaseModel):
|
||
name:str|None=None
|
||
domain:str|None=None
|
||
tagline:str|None=None
|
||
theme:str|None=None
|
||
accent:str|None=None
|
||
logo:str|None=None
|
||
mode:str|None=None
|
||
origin:str|None=None
|
||
|
||
class CrawlIn(BaseModel):
|
||
url:str
|
||
max_pages:int=20
|
||
on_conflict:str='skip'
|
||
|
||
class BuildIn(BaseModel):
|
||
mirror_images:bool=False
|
||
|
||
class RedirectIn(BaseModel):
|
||
old_path:str
|
||
new_path:str
|
||
status_code:int=301
|
||
|
||
def get_site(site_id):
|
||
site=db.one('SELECT * FROM sites WHERE id=?',(site_id,))
|
||
if not site:raise HTTPException(404,'找不到网站')
|
||
site['settings']=json.loads(site.pop('settings_json'))
|
||
return site
|
||
|
||
def get_entry(site_id,eid):
|
||
val=db.one('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,eid))
|
||
if not val:raise HTTPException(404,'找不到内容')
|
||
return entry_dict(val)
|
||
|
||
def fail(exc):
|
||
if isinstance(exc,HTTPException):raise exc
|
||
raise HTTPException(400,detail=str(exc)[:500])
|
||
|
||
@app.get('/',response_class=HTMLResponse)
|
||
def home():
|
||
return (ROOT/'app'/'templates'/'workspace.html').read_text(encoding='utf-8')
|
||
|
||
@app.get('/classic',response_class=HTMLResponse)
|
||
def classic_home():
|
||
return (ROOT/'app'/'templates'/'admin.html').read_text(encoding='utf-8')
|
||
|
||
@app.get('/editor/{site_id}/{entry_id}',response_class=HTMLResponse)
|
||
def visual_editor(site_id:str,entry_id:str):
|
||
get_entry(site_id,entry_id)
|
||
return (ROOT/'app'/'templates'/'editor.html').read_text(encoding='utf-8')
|
||
|
||
@app.get('/api/health')
|
||
def health():return {'status':'ok','name':'SiteForge','version':VERSION,'commit':BUILD_SHA}
|
||
|
||
@app.get('/api/ready')
|
||
def ready():
|
||
from .healthchecks import readiness
|
||
ok=readiness()
|
||
return JSONResponse({'status':'ready' if ok else 'unavailable'},status_code=200 if ok else 503)
|
||
|
||
@app.get('/api/themes')
|
||
def themes():
|
||
manifests=[]
|
||
for theme in THEMES:
|
||
meta=json.loads((ROOT/'themes'/theme/'manifest.json').read_text(encoding='utf-8'))
|
||
manifests.append(meta)
|
||
return manifests
|
||
|
||
@app.get('/api/editor/blocks')
|
||
def editor_blocks():
|
||
return {'blocks':blueprints.BLOCKS,'bindings':blueprints.BINDABLE,'page_templates':blueprints.PAGE_TEMPLATES,'property_schemas':blueprints.BLOCK_PROPERTY_SCHEMA}
|
||
|
||
@app.get('/api/sites')
|
||
def list_sites():
|
||
arr=db.rows('SELECT * FROM sites ORDER BY updated_at DESC')
|
||
for s in arr:
|
||
s.pop('settings_json',None)
|
||
s['entries_count']=db.one('SELECT count(*) n FROM entries WHERE site_id=?',(s['id'],))['n']
|
||
s['build_count']=db.one('SELECT count(*) n FROM builds WHERE site_id=?',(s['id'],))['n']
|
||
return arr
|
||
|
||
@app.post('/api/sites',status_code=201)
|
||
def create_site(payload:SiteIn):
|
||
if payload.theme not in theme_store.ids():raise HTTPException(400,'没有该模板')
|
||
try:clean_hex(payload.accent)
|
||
except Exception as exc:fail(exc)
|
||
site_id=uid()
|
||
settings={'origin':payload.origin,'nav':[],'email':'','footer_text':''}
|
||
homeblocks=blueprints.theme_blocks(payload.theme,'home')
|
||
if payload.preset_id:
|
||
p=db.one('SELECT * FROM presets WHERE id=?',(payload.preset_id,))
|
||
if not p:raise HTTPException(404,'模板预设不存在')
|
||
payload.theme=p['theme'];settings.update(json.loads(p['settings_json']))
|
||
homeblocks=json.loads(p['home_blocks_json'])
|
||
with db.connect() as c:
|
||
c.execute('''INSERT INTO sites(id,name,domain,tagline,theme,accent,logo,mode,settings_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)''',
|
||
(site_id,payload.name,payload.domain,payload.tagline,payload.theme,payload.accent,payload.logo,payload.mode,json.dumps(settings,ensure_ascii=False),db.now(),db.now()))
|
||
save_entry(site_id,{'type':'page','title':'首页','route':'/','summary':payload.tagline,
|
||
'seo_title':payload.name,'meta':{'blocks':homeblocks}},source='starter')
|
||
return get_site(site_id)
|
||
|
||
@app.get('/api/sites/{site_id}')
|
||
def site_detail(site_id:str):
|
||
site=get_site(site_id)
|
||
site['counts']={r['type']:r['n'] for r in db.rows('SELECT type,count(*) n FROM entries WHERE site_id=? GROUP BY type',(site_id,))}
|
||
site['builds']=db.rows('SELECT id,created_at,total_pages,status FROM builds WHERE site_id=? ORDER BY created_at DESC LIMIT 10',(site_id,))
|
||
return site
|
||
|
||
@app.patch('/api/sites/{site_id}')
|
||
def patch_site(site_id:str,patch:SitePatch):
|
||
old=get_site(site_id)
|
||
data=patch.model_dump(exclude_unset=True)
|
||
if 'theme' in data and data['theme'] not in theme_store.ids():raise HTTPException(400,'没有该模板')
|
||
if 'accent' in data:
|
||
try:clean_hex(data['accent'])
|
||
except Exception as exc:fail(exc)
|
||
settings=old['settings']
|
||
if 'origin' in data:settings['origin']=data.pop('origin') or ''
|
||
valid=['name','domain','tagline','theme','accent','logo','mode']
|
||
fields=[k for k in data if k in valid]
|
||
with db.connect() as c:
|
||
for k in fields:c.execute(f'UPDATE sites SET {k}=? WHERE id=?',(data[k],site_id))
|
||
c.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
|
||
return get_site(site_id)
|
||
|
||
@app.delete('/api/sites/{site_id}')
|
||
def delete_site(site_id:str):
|
||
get_site(site_id)
|
||
db.execute('DELETE FROM sites WHERE id=?',(site_id,))
|
||
for relative in (db.DATA/'media'/site_id,db.DATA/'builds'/site_id):
|
||
if relative.exists():shutil.rmtree(relative)
|
||
for z in (db.DATA/'archives').glob(site_id+'-*.zip') if (db.DATA/'archives').exists() else []:
|
||
z.unlink()
|
||
return {'deleted':True}
|
||
|
||
@app.post('/api/sites/{site_id}/clone',status_code=201)
|
||
def clone_site(site_id:str,settings:dict[str,Any]=Body(default_factory=dict)):
|
||
original=get_site(site_id)
|
||
clone=create_site(SiteIn(name=settings.get('name') or original['name']+' 副本',theme=original['theme'],
|
||
domain=settings.get('domain',''),tagline=original['tagline'],accent=original['accent'],logo=original['logo']))
|
||
entries=db.rows('SELECT * FROM entries WHERE site_id=?',(site_id,))
|
||
for old in entries:
|
||
e=entry_dict(old)
|
||
try:save_entry(clone['id'],e,source='clone',replace_route=True)
|
||
except Exception:pass
|
||
return get_site(clone['id'])
|
||
|
||
@app.get('/api/sites/{site_id}/entries')
|
||
def list_entries(site_id:str,typ:str='',q:str=''):
|
||
get_site(site_id)
|
||
sql='SELECT * FROM entries WHERE site_id=?';params=[site_id]
|
||
if typ:sql+=' AND type=?';params.append(typ)
|
||
if q:sql+=' AND (title LIKE ? OR route LIKE ?)';params.extend(['%'+q+'%','%'+q+'%'])
|
||
sql+=' ORDER BY CASE WHEN route="/" THEN 0 ELSE 1 END, type, updated_at DESC'
|
||
return [entry_dict(x) for x in db.rows(sql,params)]
|
||
|
||
@app.post('/api/sites/{site_id}/entries',status_code=201)
|
||
def create_entry(site_id:str,payload:dict[str,Any]):
|
||
try:
|
||
eid=save_entry(site_id,payload)
|
||
return get_entry(site_id,eid)
|
||
except Exception as exc:fail(exc)
|
||
|
||
@app.patch('/api/sites/{site_id}/entries/{entry_id}')
|
||
def update_entry(site_id:str,entry_id:str,payload:dict[str,Any]):
|
||
previous=get_entry(site_id,entry_id)
|
||
if isinstance(payload.get('meta'),dict) and any(k in payload['meta'] for k in ('blocks','layout_mode','page_template')):
|
||
site_design_v87.require_legacy_enabled()
|
||
data={**previous,**payload}
|
||
try:
|
||
save_entry(site_id,data,existing_id=entry_id)
|
||
return get_entry(site_id,entry_id)
|
||
except Exception as exc:fail(exc)
|
||
|
||
@app.delete('/api/sites/{site_id}/entries/{entry_id}')
|
||
def delete_entry(site_id:str,entry_id:str):
|
||
get_entry(site_id,entry_id)
|
||
db.execute('DELETE FROM entries WHERE site_id=? AND id=?',(site_id,entry_id))
|
||
return {'deleted':True}
|
||
|
||
def infer_page_template(entry):
|
||
route=str(entry.get('route','')).lower()
|
||
typ=entry.get('type')
|
||
if route=='/':return 'home'
|
||
if typ in ('product','vehicle'):return 'product'
|
||
if typ=='post':return 'article'
|
||
if typ=='brand':return 'brand'
|
||
if any(k in route for k in ('/about', '/company', '/our-story')):return 'about'
|
||
if any(k in route for k in ('/contact', '/inquiry')):return 'contact'
|
||
return 'standard'
|
||
|
||
@app.post('/api/sites/{site_id}/scaffold')
|
||
def scaffold_standard_pages(site_id:str):
|
||
"""Add commonly needed enterprise pages while never overwriting existing URLs."""
|
||
site=get_site(site_id)
|
||
defaults=[('about','关于我们','/about/','企业介绍、经营理念与核心服务能力'),
|
||
('contact','联系我们','/contact/','联系方式、业务咨询与合作对接'),
|
||
('standard','服务与解决方案','/services/','适合不同客户需求的业务与服务')]
|
||
created=[];skipped=[]
|
||
for kind,title,route,summary in defaults:
|
||
if db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(site_id,route)):
|
||
skipped.append(route);continue
|
||
meta={'page_template':kind,'blocks':blueprints.theme_blocks(site['theme'],kind,(theme_store.pinned(site_id) or {}).get('version'))}
|
||
save_entry(site_id,{'type':'page','title':title,'route':route,'summary':summary,'meta':meta},source='scaffold')
|
||
created.append(route)
|
||
return {'created':created,'skipped':skipped,'note':'不覆盖已有页面或修改任何原始 URL'}
|
||
|
||
@app.post('/api/sites/{site_id}/apply-theme')
|
||
def apply_theme_templates(site_id:str,payload:dict=Body(default_factory=dict)):
|
||
"""Re-assemble existing content with the selected theme without modifying URLs/SEO."""
|
||
site=get_site(site_id)
|
||
mode=payload.get('mode','missing')
|
||
if mode not in ('missing','overwrite'):raise HTTPException(400,'无效的套版模式')
|
||
entries=[entry_dict(x) for x in db.rows('SELECT * FROM entries WHERE site_id=?',(site_id,))]
|
||
changed=0;skipped=0
|
||
for entry in entries:
|
||
if mode=='missing' and entry['meta'].get('blocks'):
|
||
skipped+=1;continue
|
||
kind=infer_page_template(entry)
|
||
entry['meta']={**entry['meta'],'page_template':kind,'blocks':blueprints.theme_blocks(site['theme'],kind,(theme_store.pinned(site_id) or {}).get('version'))}
|
||
save_entry(site_id,entry,existing_id=entry['id'])
|
||
changed+=1
|
||
return {'updated':changed,'skipped':skipped,'total':len(entries),'theme':site['theme'],'mode':mode,
|
||
'note':'只修改页面模板/区块,不改变正文原数据、URL、SEO 和 Canonical'}
|
||
|
||
@app.get('/api/sites/{site_id}/schemas')
|
||
def site_schemas(site_id:str):
|
||
get_site(site_id)
|
||
rows=db.rows('SELECT content_type,fields_json FROM content_schemas WHERE site_id=?',(site_id,))
|
||
result={k:v for k,v in blueprints.CONTENT_MODELS.items()}
|
||
for row in rows:result[row['content_type']]=json.loads(row['fields_json'])
|
||
return result
|
||
|
||
@app.put('/api/sites/{site_id}/schemas/{kind}')
|
||
def update_schema(site_id:str,kind:str,payload:list[dict]=Body(...)):
|
||
get_site(site_id)
|
||
if kind not in TYPES:raise HTTPException(400,'未知内容类型')
|
||
if len(payload)>40:raise HTTPException(400,'最多允许40个自定义字段')
|
||
clean=[];names=set()
|
||
for field in payload:
|
||
name=str(field.get('name') or '')
|
||
if not re.fullmatch('[a-zA-Z_][a-zA-Z0-9_]{0,50}',name) or name in names:raise HTTPException(400,'字段名称不合法或重复')
|
||
typ=field.get('type','text')
|
||
if typ not in ('text','textarea','number','date','url','boolean'):raise HTTPException(400,'不支持该字段类型')
|
||
names.add(name);clean.append({'name':name,'label':str(field.get('label') or name)[:80],'type':typ})
|
||
with db.connect() as conn:
|
||
conn.execute('INSERT INTO content_schemas(site_id,content_type,fields_json) VALUES (?,?,?) ON CONFLICT(site_id,content_type) DO UPDATE SET fields_json=excluded.fields_json',(site_id,kind,json.dumps(clean,ensure_ascii=False)))
|
||
return {'content_type':kind,'fields':clean}
|
||
|
||
@app.get('/api/sites/{site_id}/pages/{entry_id}/blueprint')
|
||
def read_blueprint(site_id:str,entry_id:str):
|
||
entry=get_entry(site_id,entry_id)
|
||
kind=entry['meta'].get('page_template') or ('home' if entry['route']=='/' else 'product' if entry['type'] in ('product','vehicle') else 'article' if entry['type']=='post' else 'standard')
|
||
return {'entry_id':entry_id,'page_template':kind,'layout_mode':entry['meta'].get('layout_mode','append'),'blocks':entry['meta'].get('blocks',[]),'entry':entry}
|
||
|
||
class BlueprintPayload(BaseModel):
|
||
blocks:list[dict]
|
||
page_template:str='standard'
|
||
layout_mode:str='append'
|
||
site_design:dict|None=None
|
||
|
||
@app.put('/api/sites/{site_id}/pages/{entry_id}/blueprint')
|
||
def save_blueprint(site_id:str,entry_id:str,payload:BlueprintPayload):
|
||
site_design_v87.require_legacy_enabled()
|
||
entry=get_entry(site_id,entry_id)
|
||
if payload.page_template not in blueprints.PAGE_TEMPLATES:raise HTTPException(400,'不支持的页面模板')
|
||
try:validated=blueprints.validate_blocks(payload.blocks)
|
||
except ValueError as exc:raise HTTPException(400,str(exc))
|
||
if payload.layout_mode not in ('append','replace'):raise HTTPException(400,'不支持的页面设计模式')
|
||
meta={**entry['meta'],'page_template':payload.page_template,'layout_mode':payload.layout_mode,'blocks':validated}
|
||
save_entry(site_id,{**entry,'meta':meta},existing_id=entry_id)
|
||
return {'saved':True,'page_template':payload.page_template,'layout_mode':payload.layout_mode,'blocks':validated}
|
||
|
||
@app.post('/api/sites/{site_id}/pages/{entry_id}/blueprint/preset')
|
||
def reset_blueprint(site_id:str,entry_id:str,payload:dict=Body(default_factory=dict)):
|
||
site_design_v87.require_legacy_enabled()
|
||
entry=get_entry(site_id,entry_id)
|
||
kind=payload.get('page_template','home' if entry['route']=='/' else 'standard')
|
||
if kind not in blueprints.PAGE_TEMPLATES:raise HTTPException(400,'未知的页面模板')
|
||
site=get_site(site_id)
|
||
blocks=blueprints.theme_blocks(site['theme'],kind,(theme_store.pinned(site_id) or {}).get('version'))
|
||
meta={**entry['meta'],'blocks':blocks,'page_template':kind}
|
||
save_entry(site_id,{**entry,'meta':meta},existing_id=entry_id)
|
||
return {'page_template':kind,'blocks':blocks}
|
||
|
||
@app.post('/api/sites/{site_id}/pages/{entry_id}/editor-preview',response_class=HTMLResponse)
|
||
def editor_preview(site_id:str,entry_id:str,payload:BlueprintPayload):
|
||
site=get_site(site_id);page=get_entry(site_id,entry_id)
|
||
try:blocks=blueprints.validate_blocks(payload.blocks)
|
||
except (ValueError,TypeError) as exc:raise HTTPException(400,str(exc))
|
||
site['schemas']={k:v for k,v in blueprints.CONTENT_MODELS.items()}
|
||
for row in db.rows('SELECT content_type,fields_json FROM content_schemas WHERE site_id=?',(site_id,)):
|
||
site['schemas'][row['content_type']]=json.loads(row['fields_json'])
|
||
content=blueprints.render_blocks(blocks,site,page,[entry_dict(x) for x in db.rows("SELECT * FROM entries WHERE site_id=? AND status='published'",(site_id,))])
|
||
# The editor now uses the same trusted theme renderer as production builds.
|
||
# CSS assets are inlined because srcdoc is sandboxed and has no /assets mount.
|
||
from . import theme_pro, theme_store, responsive
|
||
import re as _re
|
||
from markupsafe import Markup
|
||
from jinja2 import Environment,FileSystemLoader,select_autoescape
|
||
settings=site.get('settings') or {}
|
||
if payload.site_design is not None:
|
||
settings={**settings,'site_design':site_design_v87.validate(payload.site_design)}
|
||
if payload.site_design is not None:
|
||
settings['design_tokens']=settings['site_design']['tokens']
|
||
site['settings_json']=json.dumps(settings,ensure_ascii=False)
|
||
records=db.rows("SELECT * FROM entries WHERE site_id=? AND status='published'",(site_id,))
|
||
page['meta_json']=json.dumps({**page['meta'],'layout_mode':payload.layout_mode},ensure_ascii=False)
|
||
data=theme_store.pinned(site_id)
|
||
theme_dir=theme_store.resolve(site['theme'],data['version'] if data else None)
|
||
if site['theme'] in theme_pro.THEMES and theme_dir.resolve()==(ROOT/'themes'/site['theme']).resolve():
|
||
html=theme_pro.render(site['theme'],site,page,records,blocks_html=content)
|
||
elif (theme_dir/'pro.html').exists():
|
||
env=Environment(loader=FileSystemLoader([str(theme_dir),str(ROOT/'themes')]),autoescape=select_autoescape(['html']))
|
||
ctx=theme_pro.make_context(site,page,records)
|
||
ctx.update(custom_blocks=Markup(content),has_custom=bool(content),noindex=False,
|
||
designer_replace=payload.layout_mode=='replace',theme_overrides=True)
|
||
html=env.get_template('pro.html').render(**ctx)
|
||
else:
|
||
html='<html><head><meta charset="utf-8"></head><body>'+content+'</body></html>'
|
||
styles='\n'.join([
|
||
(ROOT/'app'/'static'/'pro-base.css').read_text(encoding='utf-8'),
|
||
(ROOT/'app'/'static'/'commercial.css').read_text(encoding='utf-8'),
|
||
((theme_dir/'blocks.css') if (theme_dir/'blocks.css').is_file() else (ROOT/'app'/'static'/'blocks.css')).read_text(encoding='utf-8'),
|
||
(ROOT/'app'/'static'/'factory.css').read_text(encoding='utf-8'),
|
||
(theme_dir/'pro.css').read_text(encoding='utf-8') if (theme_dir/'pro.css').exists() else '',
|
||
(theme_dir/'theme.css').read_text(encoding='utf-8'),
|
||
responsive.editor_css(blocks),
|
||
site_design_v87.responsive_css(site_design_v87.published(settings)),
|
||
site_design_v87.token_css(site_design_v87.published(settings)['tokens'])])
|
||
html=_re.sub(r'<link\s+rel="stylesheet"[^>]*>', '', html)
|
||
html=_re.sub(r'<script\s+defer\s+src="assets/[^"]+"[^>]*></script>', '', html)
|
||
html=html.replace('</head>','<style>'+styles+'</style></head>')
|
||
# External trusted bridge is injected only into the sandboxed editor preview;
|
||
# production/static builds never include authoring controls.
|
||
bridge_source=(ROOT/'app'/'static'/'studio-canvas-bridge.js').read_text(encoding='utf-8')
|
||
bridge='<script>'+bridge_source+'</script>'
|
||
# The static site's /media/ assets are hosted by the admin at
|
||
# /local-media/{site}/. Rewrite only the editor response, never builds.
|
||
html=html.replace('src="/media/',f'src="/local-media/{site_id}/')
|
||
html=html.replace('poster="/media/',f'poster="/local-media/{site_id}/')
|
||
return HTMLResponse(html.replace('</body>',bridge+'</body>'))
|
||
|
||
@app.put('/api/sites/{site_id}/contact-settings')
|
||
def update_contact_settings(request:Request,site_id:str,payload:dict=Body(...)):
|
||
enterprise.require(request,site_id,'owner')
|
||
site=get_site(site_id)
|
||
email=str(payload.get('email') or '').strip()[:180]
|
||
endpoint=str(payload.get('form_endpoint') or '').strip()
|
||
if email and not re.fullmatch(r'[^@\s]+@[^@\s]+\.[^@\s]+',email):
|
||
raise HTTPException(400,'联系邮箱格式不正确')
|
||
if endpoint and (not endpoint.startswith('https://') or any(c in endpoint for c in ('\"','<','>','\n','\r')) or len(endpoint)>500):
|
||
raise HTTPException(400,'表单服务地址必须是有效的 HTTPS URL')
|
||
settings={**site['settings'],'email':email,'form_endpoint':endpoint,
|
||
'footer_text':str(payload.get('footer_text') or '')[:600]}
|
||
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
|
||
return {'saved':True,'email':email,'form_endpoint':endpoint,'footer_text':settings['footer_text'],
|
||
'note':'外部表单服务必须另外开通、配置隐私与防垃圾验证,SiteForge不保存表单数据'}
|
||
|
||
@app.put('/api/sites/{site_id}/navigation')
|
||
def update_navigation(site_id:str,items:list[dict]=Body(...)):
|
||
site_design_v87.require_legacy_enabled()
|
||
site=get_site(site_id)
|
||
if len(items)>25:raise HTTPException(400,'最多25个导航项目')
|
||
nav=[]
|
||
for item in items:
|
||
label=str(item.get('title') or '').strip()[:60]
|
||
path=route_path(item.get('route') or '/')
|
||
if not label:raise HTTPException(400,'导航标题不能为空')
|
||
nav.append({'title':label,'route':path})
|
||
settings={**site['settings'],'nav':nav}
|
||
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
|
||
return {'nav':nav}
|
||
|
||
@app.post('/api/sites/{site_id}/import')
|
||
async def import_file(site_id:str,file:UploadFile=File(...),on_conflict:str=Form('skip')):
|
||
get_site(site_id)
|
||
content=await file.read(15_000_001)
|
||
if len(content)>15_000_000:raise HTTPException(413,'导入文件不能超过 15MB')
|
||
try:result=importers.import_data_file(site_id,file.filename or '',content,on_conflict)
|
||
except Exception as exc:fail(exc)
|
||
db.insert('INSERT INTO imports(id,site_id,created_at,source,result_json) VALUES (?,?,?,?,?)',
|
||
(uid(),site_id,db.now(),file.filename or 'upload',json.dumps(result,ensure_ascii=False)))
|
||
return result
|
||
|
||
@app.post('/api/sites/{site_id}/crawl')
|
||
def crawl(site_id:str,payload:CrawlIn):
|
||
get_site(site_id)
|
||
try:result=importers.crawl_site(site_id,payload.url,payload.max_pages,payload.on_conflict)
|
||
except Exception as exc:fail(exc)
|
||
db.insert('INSERT INTO imports(id,site_id,created_at,source,result_json) VALUES (?,?,?,?,?)',
|
||
(uid(),site_id,db.now(),'crawler: '+payload.url,json.dumps(result,ensure_ascii=False)))
|
||
old=get_site(site_id);settings=old['settings'];settings['origin']=payload.url
|
||
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings),db.now(),site_id))
|
||
return result
|
||
|
||
@app.get('/api/sites/{site_id}/imports')
|
||
def imports(site_id:str):
|
||
get_site(site_id)
|
||
return db.rows('SELECT * FROM imports WHERE site_id=? ORDER BY created_at DESC LIMIT 25',(site_id,))
|
||
|
||
@app.post('/api/sites/{site_id}/media')
|
||
async def upload_image(site_id:str,file:UploadFile=File(...)):
|
||
get_site(site_id)
|
||
allowed={'.png','image/png','.jpg','image/jpeg','.jpeg','.webp','image/webp','.gif','image/gif','.avif','image/avif'}
|
||
ext=Path(file.filename or '').suffix.lower()
|
||
if ext not in ('.png','.jpg','.jpeg','.webp','.gif','.avif'):
|
||
raise HTTPException(400,'仅支持 PNG/JPG/WebP/GIF/AVIF 图片')
|
||
data=await file.read(8_000_001)
|
||
if len(data)>8_000_000:raise HTTPException(413,'图片不能超过 8 MB')
|
||
if not (data.startswith(b'\x89PNG') or data.startswith(b'\xff\xd8') or data.startswith(b'GIF8') or data[4:8]==b'ftyp' or (len(data)>12 and data[8:12]==b'WEBP')):
|
||
raise HTTPException(400,'不是有效图片格式')
|
||
folder=db.DATA/'media'/site_id;folder.mkdir(parents=True,exist_ok=True)
|
||
dest=folder/(uid()+ext);dest.write_bytes(data)
|
||
return {'url':'media/'+dest.name,'size':len(data)}
|
||
|
||
@app.get('/api/sites/{site_id}/media')
|
||
def media_list(site_id:str):
|
||
get_site(site_id)
|
||
folder=db.DATA/'media'/site_id
|
||
return [{'url':'media/'+p.name,'name':p.name,'bytes':p.stat().st_size} for p in sorted(folder.glob('*')) if p.is_file()] if folder.exists() else []
|
||
|
||
@app.get('/local-media/{site_id}/{filename}')
|
||
def media_view(site_id:str,filename:str):
|
||
get_site(site_id)
|
||
if not re.fullmatch(r'(?:[0-9a-f]{12}|wp-[0-9a-f]{24})\.(png|jpg|jpeg|webp|gif|avif)',filename):raise HTTPException(404)
|
||
path=db.DATA/'media'/site_id/filename
|
||
if not path.is_file():raise HTTPException(404)
|
||
return FileResponse(path)
|
||
|
||
@app.post('/api/sites/{site_id}/redirects')
|
||
def add_redirect(site_id:str,payload:RedirectIn):
|
||
get_site(site_id)
|
||
if payload.status_code not in (301,302,307,308):raise HTTPException(400,'无效的 HTTP 重定向状态')
|
||
old=route_path(payload.old_path)
|
||
if payload.new_path==old:raise HTTPException(400,'无法重定向到自身')
|
||
if not (payload.new_path.startswith('/') or payload.new_path.startswith('https://')):raise HTTPException(400,'无效目标')
|
||
try:
|
||
db.insert('INSERT INTO redirects(id,site_id,old_path,new_path,status_code) VALUES (?,?,?,?,?)',
|
||
(uid(),site_id,old,payload.new_path,payload.status_code))
|
||
except Exception as exc:fail(exc)
|
||
return {'created':True}
|
||
|
||
@app.get('/api/sites/{site_id}/redirects')
|
||
def list_redirects(site_id:str):
|
||
get_site(site_id)
|
||
return db.rows('SELECT * FROM redirects WHERE site_id=? ORDER BY old_path',(site_id,))
|
||
|
||
@app.delete('/api/sites/{site_id}/redirects/{redirect_id}')
|
||
def remove_redirect(site_id:str,redirect_id:str):
|
||
get_site(site_id)
|
||
db.execute('DELETE FROM redirects WHERE site_id=? AND id=?',(site_id,redirect_id))
|
||
return {'deleted':True}
|
||
|
||
@app.post('/api/sites/{site_id}/builds', status_code=202)
|
||
def new_build(site_id:str,payload:BuildIn,request:Request):
|
||
get_site(site_id)
|
||
# All builds are submitted to the same durable worker queue. No build()
|
||
# calls are allowed from a request handler.
|
||
key=request.headers.get('Idempotency-Key')
|
||
try:
|
||
return jobs.submit(site_id, {'mirror_images':payload.mirror_images}, enterprise.actor(request), key)
|
||
except jobs.JobCapacityError as exc:
|
||
raise HTTPException(429, str(exc))
|
||
except jobs.JobConflict as exc:
|
||
raise HTTPException(409, str(exc))
|
||
|
||
|
||
@app.get('/api/sites/{site_id}/builds')
|
||
def list_builds(site_id:str):
|
||
get_site(site_id)
|
||
return db.rows('SELECT id,created_at,status,total_pages,report_json FROM builds WHERE site_id=? ORDER BY created_at DESC',(site_id,))
|
||
|
||
@app.get('/api/sites/{site_id}/builds/{build_id}/report')
|
||
def build_report(site_id:str,build_id:str):
|
||
record=db.one('SELECT * FROM builds WHERE site_id=? AND id=?',(site_id,build_id))
|
||
if not record:raise HTTPException(404)
|
||
return json.loads(record['report_json'])
|
||
|
||
@app.get('/api/sites/{site_id}/builds/{build_id}/download')
|
||
def download_build(site_id:str,build_id:str):
|
||
record=db.one('SELECT * FROM builds WHERE site_id=? AND id=?',(site_id,build_id))
|
||
if not record:raise HTTPException(404)
|
||
archive=Path(record['zip_path'])
|
||
if not archive.is_file():raise HTTPException(404,'构建压缩包丢失')
|
||
return FileResponse(archive,filename=f'siteforge-{site_id}-{build_id}.zip',media_type='application/zip')
|
||
|
||
@app.get('/preview/{site_id}/{build_id}/{path:path}')
|
||
def preview_path(site_id:str,build_id:str,path:str):
|
||
b=db.one('SELECT id FROM builds WHERE site_id=? AND id=?',(site_id,build_id))
|
||
if not b:raise HTTPException(404)
|
||
dist=(db.DATA/'builds'/site_id/build_id/'dist').resolve()
|
||
target=dist/(path or 'index.html')
|
||
if target.is_dir():target=target/'index.html'
|
||
if not target.exists() and not Path(path).suffix:target=dist/path/'index.html'
|
||
if not target.resolve().is_relative_to(dist):raise HTTPException(404)
|
||
if not target.is_file():raise HTTPException(404)
|
||
if target.suffix.lower() in ('.html','.htm'):
|
||
html=target.read_text(encoding='utf-8')
|
||
return HTMLResponse(rewrite_preview_html(html,site_id,build_id))
|
||
return FileResponse(target)
|
||
|
||
@app.get('/preview/{site_id}/{build_id}')
|
||
def preview_root(site_id:str,build_id:str):
|
||
return RedirectResponse(f'/preview/{site_id}/{build_id}/')
|
||
|
||
@app.get('/api/presets')
|
||
def get_presets():
|
||
return db.rows('SELECT id,name,theme,created_at FROM presets ORDER BY created_at DESC')
|
||
|
||
@app.post('/api/sites/{site_id}/save-preset')
|
||
def save_preset(site_id:str,payload:dict=Body(default_factory=dict)):
|
||
s=get_site(site_id)
|
||
homepage=db.one('SELECT meta_json FROM entries WHERE site_id=? AND route="/"',(site_id,))
|
||
meta=json.loads(homepage['meta_json'] or '{}') if homepage else {}
|
||
blocks=meta.get('blocks',[])
|
||
pid=uid()
|
||
name=str(payload.get('name') or s['name']+' 模板')[:150]
|
||
db.insert('INSERT INTO presets(id,name,theme,settings_json,home_blocks_json,created_at) VALUES (?,?,?,?,?,?)',
|
||
(pid,name,s['theme'],json.dumps(s['settings'],ensure_ascii=False),json.dumps(blocks,ensure_ascii=False),db.now()))
|
||
return {'id':pid,'name':name,'theme':s['theme']}
|
||
|
||
|
||
@app.post('/api/sites/{site_id}/media-archive')
|
||
async def import_media_archive(site_id:str,file:UploadFile=File(...)):
|
||
"""Upload a WordPress wp-content/uploads ZIP. Reject executable formats and unsafe paths."""
|
||
import zipfile
|
||
import stat
|
||
get_site(site_id)
|
||
if not (file.filename or '').lower().endswith('.zip'):
|
||
raise HTTPException(400,'请上传 .zip 媒体压缩包')
|
||
data=await file.read(80_000_001)
|
||
if len(data)>80_000_000:raise HTTPException(413,'压缩包最大 80MB')
|
||
count=0;skipped=0;size=0
|
||
allowed={'.png','.jpg','.jpeg','.gif','.webp','.avif','.pdf','.woff','.woff2','.ttf','.ico'}
|
||
target=db.DATA/'legacy'/site_id
|
||
try:
|
||
with zipfile.ZipFile(io.BytesIO(data)) as z:
|
||
infos=z.infolist()
|
||
if len(infos)>4000:raise ValueError('压缩包文件数过多')
|
||
for member in infos:
|
||
if member.file_size>100*max(member.compress_size,1) and member.file_size>1_000_000:
|
||
raise ValueError('拒绝异常高压缩比的媒体文件')
|
||
size+=member.file_size
|
||
if size>250_000_000:raise ValueError('解压后的数据总量超过 250MB')
|
||
for member in infos:
|
||
name=member.filename.replace('\\','/').lstrip('/')
|
||
parts=name.split('/')
|
||
if member.is_dir():continue
|
||
if any(p in ('.','..') for p in parts) or ':' in name or name.startswith('~'):
|
||
skipped+=1;continue
|
||
if stat.S_ISLNK(member.external_attr>>16):skipped+=1;continue
|
||
if Path(name).suffix.lower() not in allowed or member.file_size>25_000_000:
|
||
skipped+=1;continue
|
||
if 'wp-content' in parts:
|
||
name='/'.join(parts[parts.index('wp-content'):])
|
||
elif 'uploads' in parts:
|
||
name='wp-content/'+'/'.join(parts[parts.index('uploads'):])
|
||
else:
|
||
name='media/imported/'+name
|
||
dst=target/name
|
||
if not dst.resolve().is_relative_to(target.resolve()):skipped+=1;continue
|
||
dst.parent.mkdir(parents=True,exist_ok=True)
|
||
with z.open(member) as source, open(dst,'wb') as out:
|
||
shutil.copyfileobj(source,out)
|
||
count+=1
|
||
except Exception as exc:fail(exc)
|
||
return {'imported':count,'skipped':skipped,'bytes_uncompressed':size,
|
||
'message':'旧媒体将按原路径打包进下次构建;单独的源站页面和 URL 仍需核验'}
|
||
|
||
@app.post('/api/sites/{site_id}/builds/{build_id}/publish-local')
|
||
def publish_local(site_id:str,build_id:str):
|
||
"""Opt-in atomic local release; requires SITEFORGE_PUBLISH_ROOT env."""
|
||
import os
|
||
root=os.environ.get('SITEFORGE_PUBLISH_ROOT','').strip()
|
||
if not root:raise HTTPException(403,'本地部署未启用,请设置 SITEFORGE_PUBLISH_ROOT')
|
||
get_site(site_id)
|
||
build_record=db.one('SELECT * FROM builds WHERE site_id=? AND id=?',(site_id,build_id))
|
||
if not build_record:raise HTTPException(404,'构建版本不存在')
|
||
if build_record['status']!='complete':
|
||
raise HTTPException(409,'尚未构建完成,不能发布')
|
||
from . import quality
|
||
try:
|
||
q=quality.report(site_id,build_id)
|
||
except (FileNotFoundError,OSError):
|
||
raise HTTPException(409,'构建文件缺失,请重新构建')
|
||
if not q['passed']:
|
||
raise HTTPException(409,'静态网站质量检查存在阻断错误')
|
||
site=get_site(site_id)
|
||
if site['mode']=='migration' and cms_pipeline.open_reviews(site_id):
|
||
raise HTTPException(409,'复杂 CMS 数据仍有未完成的人工复核')
|
||
if site['mode']=='migration':
|
||
report=enterprise.diff_baseline(site_id)
|
||
if report['baseline_count']==0:raise HTTPException(409,'迁移网站尚未导入旧 URL 基线,禁止直接发布')
|
||
if report['errors']:raise HTTPException(409,f'旧站 URL 基线检查未通过:缺失 {report["errors"]} 项')
|
||
# Four-eyes rule is enabled by default in the V2 Docker configuration.
|
||
if __import__('os').environ.get('SITEFORGE_REQUIRE_RELEASE_APPROVAL','false').lower() in ('1','true','yes'):
|
||
approval=db.one('SELECT id FROM release_requests WHERE site_id=? AND build_id=? AND state=? ORDER BY reviewed_at DESC LIMIT 1',
|
||
(site_id,build_id,'approved'))
|
||
if not approval:raise HTTPException(409,'当前构建版本尚未获得独立审批,禁止发布')
|
||
# Migration gate is re-evaluated on publish, not only at approval time.
|
||
if site['mode']=='migration' and enterprise.diff_baseline(site_id)['errors']:
|
||
raise HTTPException(409,'迁移基线再次检测未通过')
|
||
src=db.DATA/'builds'/site_id/build_id/'dist'
|
||
if not src.is_dir():raise HTTPException(404,'构建文件缺失')
|
||
parent=Path(root).resolve()/site_id
|
||
releases=parent/'releases';releases.mkdir(parents=True,exist_ok=True)
|
||
dest=releases/build_id
|
||
if not dest.exists():shutil.copytree(src,dest)
|
||
current=parent/'current'
|
||
if os.name=='nt':
|
||
previous=parent/'previous'
|
||
if previous.exists():shutil.rmtree(previous)
|
||
if current.exists():current.rename(previous)
|
||
shutil.copytree(dest,current)
|
||
else:
|
||
tmp=parent/('current-tmp-'+uid())
|
||
tmp.symlink_to(dest,target_is_directory=True)
|
||
os.replace(tmp,current)
|
||
enterprise.db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',(enterprise.uid(),site_id,build_id,'system','publish',db.now()))
|
||
return {'published':True,'root':str(current),'release':build_id,'message':'请确认 Nginx/Caddy 的站点根目录指向 current;DNS/CDN 不会被自动修改'}
|