Files
cms-SiteForge/app/main.py
T

841 lines
43 KiB
Python
Raw Normal View History

"""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 不会被自动修改'}