"""Whole-site header/footer/navigation/responsive drafts and publications. Only site-design `publish` changes the source of static builds. A draft is versioned and fenced against edits to the underlying site settings. """ from __future__ import annotations import hashlib import json import os import re import uuid from fastapi import APIRouter, Body, HTTPException, Request from . import db, enterprise from .core import route_path router=APIRouter(prefix='/api/sites',tags=['V8.7 site design']) DEFAULTS={ 'header':{'announcement':'','show_announcement':False,'show_search':True, 'show_cta':True,'cta_text':'联系我们','cta_href':'/contact/'}, 'footer':{'title':'','description':'','email':'','show_nav':True}, 'nav':[], 'tokens':{'font':'system','heading_scale':1.0,'radius':12,'section_spacing':72, 'container_width':1200,'button_style':'rounded','header_style':'standard'}, 'responsive':{'tablet_gap':64,'mobile_gap':40,'mobile_hide_announcement':True, 'mobile_hide_footer_links':False,'mobile_container_padding':20}, } def require_legacy_enabled(): """Block historical direct-to-live design writes unless explicitly enabled. Operators migrating an old automation may temporarily enable this escape hatch; the V8.7 editor never uses it. """ if os.environ.get('SITEFORGE_ALLOW_LEGACY_DIRECT_DESIGN_WRITE') != '1': raise HTTPException(409,'旧版直接修改正式设计的接口已停用;请使用 site-design 或 studio-draft 草稿发布流程') def setup(): with db.connect() as c: c.execute('''CREATE TABLE IF NOT EXISTS site_design_drafts ( site_id TEXT PRIMARY KEY, revision INTEGER NOT NULL, base_hash TEXT NOT NULL, config_json TEXT NOT NULL, updated_at TEXT NOT NULL)''') c.execute('''CREATE TABLE IF NOT EXISTS site_design_publications ( id TEXT PRIMARY KEY, site_id TEXT NOT NULL, actor TEXT NOT NULL, revision INTEGER NOT NULL, old_config_json TEXT NOT NULL, new_config_json TEXT NOT NULL, published_at TEXT NOT NULL)''') def _site(site_id): row=db.one('SELECT * FROM sites WHERE id=?',(site_id,)) if not row:raise HTTPException(404,'网站不存在') return row def _settings(row): return json.loads(row['settings_json'] or '{}') def _hash(settings): return hashlib.sha256(json.dumps(settings,sort_keys=True,ensure_ascii=False).encode()).hexdigest() def _url(value): if not isinstance(value,str) or len(value)>350 or any(x in value for x in ('<','>','"',"'",'\\','\n','\r','\t')): raise HTTPException(400,'不安全的网址') if value.startswith('/') and not value.startswith('//'): try:return route_path(value) except ValueError:raise HTTPException(400,'不合法的站内链接') # External links are intentionally not allowed inside site chrome; the # enterprise's external CTA can be built with a vetted form-link block. raise HTTPException(400,'全站导航只允许以 / 开头的站内路径') def _nav(rows,depth=0,counter=None): if counter is None:counter=[0] if not isinstance(rows,list) or len(rows)>18:raise HTTPException(400,'导航项过多') result=[] for x in rows: counter[0]+=1 if counter[0]>48 or not isinstance(x,dict):raise HTTPException(400,'导航结构不合法') if set(x)-{'title','route','children'}:raise HTTPException(400,'导航出现未支持的字段') title=x.get('title') if not isinstance(title,str) or not title.strip() or len(title)>48:raise HTTPException(400,'导航名称不合法') children=x.get('children',[]) if depth==2 and children:raise HTTPException(400,'最多支持三级导航') result.append({'title':title.strip(),'route':_url(x.get('route','/')), 'children':_nav(children,depth+1,counter) if depth<2 else []}) return result def validate(raw): if not isinstance(raw,dict) or set(raw)-set(DEFAULTS):raise HTTPException(400,'未知的全站设计设置') normalized={} for group in ('header','footer','responsive'): val=raw.get(group,{}) if not isinstance(val,dict) or set(val)-set(DEFAULTS[group]):raise HTTPException(400,'无效的全站设置组: '+group) normalized[group]={**DEFAULTS[group],**val} header=normalized['header'];footer=normalized['footer'];mobile=normalized['responsive'] for key in ('show_announcement','show_search','show_cta'): if type(header[key]) is not bool:raise HTTPException(400,'无效的页眉开关') for key in ('show_nav',): if type(footer[key]) is not bool:raise HTTPException(400,'无效的页脚开关') for key in ('mobile_hide_announcement','mobile_hide_footer_links'): if type(mobile[key]) is not bool:raise HTTPException(400,'无效的移动端开关') for field,cap in [('announcement',140),('cta_text',40)]: if not isinstance(header[field],str) or len(header[field])>cap:raise HTTPException(400,'页眉文字超限') for field,cap in [('title',120),('description',650),('email',150)]: if not isinstance(footer[field],str) or len(footer[field])>cap:raise HTTPException(400,'页脚文字超限') header['cta_href']=_url(header['cta_href']) for field,lo,hi in [('tablet_gap',20,120),('mobile_gap',16,100),('mobile_container_padding',8,48)]: n=mobile[field] if type(n) is not int or not lo<=n<=hi:raise HTTPException(400,'设备间距超出范围: '+field) tokens=raw.get('tokens',{}) if not isinstance(tokens,dict) or set(tokens)-set(DEFAULTS['tokens']): raise HTTPException(400,'未知设计规范字段') tokens={**DEFAULTS['tokens'],**tokens} for field,options in [('font',('system','serif','modern')), ('button_style',('rounded','square','pill')),('header_style',('standard','compact'))]: if tokens[field] not in options:raise HTTPException(400,'不支持的设计规范: '+field) for field,lo,hi in [('heading_scale',.75,1.5),('radius',0,30), ('section_spacing',32,140),('container_width',900,1600)]: v=tokens[field] if type(v) not in (float,int) or not lo<=v<=hi:raise HTTPException(400,'设计变量超出范围: '+field) normalized['tokens']=tokens normalized['nav']=_nav(raw.get('nav',[])) if len(json.dumps(normalized,ensure_ascii=False))>35_000:raise HTTPException(400,'全站设计过大') return normalized def published(row_or_settings): settings=_settings(row_or_settings) if 'settings_json' in row_or_settings else row_or_settings merged=dict(settings.get('site_design') or {}) if 'tokens' not in merged:merged['tokens']=settings.get('design_tokens') or {} if 'nav' not in merged:merged['nav']=settings.get('nav') or [] if 'footer' not in merged:merged['footer']={'description':settings.get('footer_text') or '', 'email':settings.get('email') or ''} return validate(merged) def read(site_id): site=_site(site_id) config=published(site) draft=db.one('SELECT * FROM site_design_drafts WHERE site_id=?',(site_id,)) return {'config':json.loads(draft['config_json']) if draft else config, 'revision':int(draft['revision']) if draft else 0, 'state':'draft' if draft else 'published', 'conflict':bool(draft and draft['base_hash']!=_hash(_settings(site)))} def write(site_id,payload): plan=validate(payload.get('config')) site=_site(site_id) with db.connect() as c: old=c.execute('SELECT revision,base_hash FROM site_design_drafts WHERE site_id=?',(site_id,)).fetchone() revision=old[0] if old else 0 if type(payload.get('expected_revision')) is not int or payload['expected_revision']!=revision: raise HTTPException(409,'全站草稿已更新,请重新加载') if old and old[1]!=_hash(_settings(site)): raise HTTPException(409,'网站信息已在其他窗口修改') base=old[1] if old else _hash(_settings(site)) c.execute('''INSERT INTO site_design_drafts VALUES(?,?,?,?,?) ON CONFLICT(site_id) DO UPDATE SET revision=excluded.revision, config_json=excluded.config_json, updated_at=excluded.updated_at''', (site_id,revision+1,base,json.dumps(plan,ensure_ascii=False),db.now())) return {'saved':True,'state':'draft','revision':revision+1,'build_unchanged':True} def publish(site_id,expected_revision,actor): site=_site(site_id) with db.connect() as c: draft=c.execute('SELECT * FROM site_design_drafts WHERE site_id=?',(site_id,)).fetchone() if not draft:raise HTTPException(409,'尚无全站设计草稿') if type(expected_revision) is not int or expected_revision!=draft['revision']: raise HTTPException(409,'草稿版本不匹配') live=c.execute('SELECT settings_json FROM sites WHERE id=?',(site_id,)).fetchone() settings=json.loads(live['settings_json']) if draft['base_hash']!=_hash(settings):raise HTTPException(409,'网站信息已变更,请重新审核') before=published(settings) config=validate(json.loads(draft['config_json'])) settings['site_design']=config settings['design_tokens']=config['tokens'] # Keep old nav/settings for non-professional legacy templates too. settings['nav']=config['nav'] c.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?', (json.dumps(settings,ensure_ascii=False),db.now(),site_id)) publication_id=uuid.uuid4().hex c.execute('INSERT INTO site_design_publications VALUES(?,?,?,?,?,?,?)', (publication_id,site_id,actor,expected_revision,json.dumps(before,ensure_ascii=False), json.dumps(config,ensure_ascii=False),db.now())) c.execute('DELETE FROM site_design_drafts WHERE site_id=?',(site_id,)) return {'published':True,'publication_id':publication_id,'build_required':True} def responsive_css(config): r=validate(config)['responsive'] tablet=r['tablet_gap'];mobile=r['mobile_gap'];padding=r['mobile_container_padding'] rules=(f'@media(max-width:1023px){{.sf-canvas .sf-section{{padding-block:{tablet}px}}}}' f'@media(max-width:767px){{.sf-canvas .sf-section{{padding-block:{mobile}px}}' f'.pro-wrap,.sf-container{{padding-left:{padding}px;padding-right:{padding}px}}') if r['mobile_hide_announcement']:rules+='body .pro-utility{display:none!important}' if r['mobile_hide_footer_links']:rules+='body .pro-footer-links{display:none!important}' return 'body .pro-utility[hidden]{display:none!important}'+rules+'}' def token_css(tokens): safe=validate({'tokens':tokens})['tokens'] fonts={'system':'system-ui,-apple-system,BlinkMacSystemFont,sans-serif', 'serif':'Georgia,Times New Roman,serif', 'modern':'Arial,Helvetica,sans-serif'} radius=safe['radius'];width=safe['container_width'];space=safe['section_spacing'] scale=safe['heading_scale'] button={'rounded':f'{radius}px','square':'0px','pill':'999px'}[safe['button_style']] return (f':root{{--sf-font:{fonts[safe["font"]]};--sf-radius:{radius}px;' f'--sf-space:{space}px;--sf-width:{width}px;--sf-heading-scale:{scale};}}' f'body{{font-family:var(--sf-font)}}' f'.sf-canvas .sf-section,.sf-canvas .section{{padding-block:var(--sf-space)}}' f'.container,.sf-container{{max-width:var(--sf-width)}}' f'.btn,.primary,.request,.button,.nav-btn{{border-radius:{button}}}') @router.get('/{site_id}/site-design') def api_read(request:Request,site_id:str): enterprise.require(request,site_id,'viewer') return read(site_id) @router.put('/{site_id}/site-design') def api_write(request:Request,site_id:str,payload:dict=Body(...)): enterprise.require(request,site_id,'editor') return write(site_id,payload) @router.post('/{site_id}/site-design/publish') def api_publish(request:Request,site_id:str,payload:dict=Body(...)): enterprise.require(request,site_id,'owner') return publish(site_id,payload.get('expected_revision'),enterprise.actor(request)) @router.delete('/{site_id}/site-design') def api_discard(request:Request,site_id:str): enterprise.require(request,site_id,'editor') _site(site_id) db.execute('DELETE FROM site_design_drafts WHERE site_id=?',(site_id,)) return {'discarded':True} @router.get('/{site_id}/site-design/publications') def api_history(request:Request,site_id:str): enterprise.require(request,site_id,'viewer') _site(site_id) return db.rows('''SELECT id,site_id,actor,revision,published_at FROM site_design_publications WHERE site_id=? ORDER BY published_at DESC LIMIT 50''',(site_id,))