"""SiteForge team accounts, RBAC, content history, audit, jobs, migration baseline and release governance. This module is deliberately database-backed and independent of the frontend. It extends the existing site factory without allowing editors to bypass permissions. """ import csv import hashlib import hmac import io import json import os import secrets import sqlite3 import uuid from pathlib import Path from urllib.parse import urlparse from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Body from fastapi.responses import Response from pydantic import BaseModel, Field from . import db, jobs from .core import route_path router = APIRouter(prefix='/api/enterprise', tags=['Enterprise']) ROLES = ('owner','editor','reviewer','viewer') ACCESS = {'viewer':0, 'reviewer':1, 'editor':2, 'owner':3, 'superadmin':4} def setup(): with db.connect() as c: c.executescript(''' CREATE TABLE IF NOT EXISTS team_users ( id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, display_name TEXT NOT NULL, disabled INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS team_memberships ( user_id TEXT NOT NULL REFERENCES team_users(id) ON DELETE CASCADE, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, role TEXT NOT NULL, PRIMARY KEY(user_id,site_id)); CREATE TABLE IF NOT EXISTS audit_events ( id TEXT PRIMARY KEY, actor TEXT NOT NULL, site_id TEXT, operation TEXT NOT NULL, path TEXT NOT NULL, status INTEGER NOT NULL, created_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_audit_time ON audit_events(created_at); CREATE TABLE IF NOT EXISTS content_revisions ( id TEXT PRIMARY KEY, site_id TEXT NOT NULL, entry_id TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL, snapshot_json TEXT NOT NULL, created_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_revision_entry ON content_revisions(site_id,entry_id,created_at); CREATE TABLE IF NOT EXISTS build_jobs ( id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, kind TEXT NOT NULL, state TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '', result_json TEXT NOT NULL DEFAULT '{}', created_by TEXT NOT NULL, created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT); CREATE INDEX IF NOT EXISTS idx_build_jobs_state ON build_jobs(state,created_at); CREATE TABLE IF NOT EXISTS migration_baseline ( site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, route TEXT NOT NULL, seo_title TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', canonical TEXT NOT NULL DEFAULT '', status INTEGER NOT NULL DEFAULT 200, PRIMARY KEY(site_id,route)); CREATE TABLE IF NOT EXISTS release_history ( id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, build_id TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL, created_at TEXT NOT NULL); ''') jobs.migrate() def uid(): return uuid.uuid4().hex[:16] def hash_password(password, salt=None): if len(password)<12: raise ValueError('密码至少12位') salt=salt or secrets.token_bytes(16) digest=hashlib.scrypt(password.encode(),salt=salt,n=2**14,r=8,p=1,dklen=32) return f'scrypt$16384$8$1${salt.hex()}${digest.hex()}' def check_password(password, encoded): try: algorithm,n,r,p,salt,expected=encoded.split('$') if algorithm!='scrypt':return False result=hashlib.scrypt(password.encode(),salt=bytes.fromhex(salt),n=int(n),r=int(r),p=int(p),dklen=32) return hmac.compare_digest(result,bytes.fromhex(expected)) except (ValueError,TypeError,MemoryError):return False def actor(request): return request.state.actor if hasattr(request.state,'actor') else 'system' def level(request, site_id=None): user=getattr(request.state,'account_id',None) if not user: return ACCESS['superadmin'] if getattr(request.state,'is_superadmin',False) else -1 if not site_id:return ACCESS['viewer'] item=db.one('SELECT role FROM team_memberships WHERE site_id=? AND user_id=?',(site_id,user)) return ACCESS.get(item['role'],-1) if item else -1 def require(request, site_id=None, role='viewer'): if level(request,site_id)20000:raise HTTPException(413,'URL 基线最多20000条') routes=set();rows=[] for item in payload: route=route_path(item.route) if route in routes:raise HTTPException(400,'存在重复 URL:'+route) routes.add(route) rows.append((site_id,route,item.seo_title,item.description,item.canonical,item.status)) with db.connect() as c: c.execute('DELETE FROM migration_baseline WHERE site_id=?',(site_id,)) c.executemany('INSERT INTO migration_baseline VALUES (?,?,?,?,?,?)',rows) return {'saved':len(rows)} @router.post('/sites/{site_id}/baseline/snapshot') def baseline_snapshot(request:Request,site_id:str): require(request,site_id,'editor') entries=db.rows('SELECT route,seo_title,seo_description,canonical FROM entries WHERE site_id=?',(site_id,)) with db.connect() as c: c.execute('DELETE FROM migration_baseline WHERE site_id=?',(site_id,)) c.executemany('INSERT INTO migration_baseline VALUES (?,?,?,?,?,?)', [(site_id,x['route'],x['seo_title'],x['seo_description'],x['canonical'],200) for x in entries]) return {'captured':len(entries)} def diff_baseline(site_id): baseline=db.rows('SELECT * FROM migration_baseline WHERE site_id=?',(site_id,)) entries={x['route']:x for x in db.rows('SELECT route,seo_title,seo_description,canonical FROM entries WHERE site_id=?',(site_id,))} redirects={x['old_path']:x['new_path'] for x in db.rows('SELECT old_path,new_path FROM redirects WHERE site_id=?',(site_id,))} problems=[] for old in baseline: new=entries.get(old['route']) if not new: problems.append({'route':old['route'],'field':'url','severity':'error','from':'200','to':f'redirect→{redirects[old["route"]]}' if old['route'] in redirects else 'missing'}) continue for oldcol,newcol in [('seo_title','seo_title'),('description','seo_description'),('canonical','canonical')]: if old[oldcol] and old[oldcol]!=new[newcol]: problems.append({'route':old['route'],'field':oldcol,'severity':'warning','from':old[oldcol],'to':new[newcol]}) return {'baseline_count':len(baseline),'current_count':len(entries),'issues':problems, 'errors':sum(x['severity']=='error' for x in problems),'warnings':sum(x['severity']=='warning' for x in problems), 'passed':not any(x['severity']=='error' for x in problems)} @router.get('/sites/{site_id}/baseline/report') def baseline_report(request:Request,site_id:str): require(request,site_id,'viewer') return diff_baseline(site_id) @router.get('/sites/{site_id}/baseline/export.csv') def baseline_export(request:Request,site_id:str): require(request,site_id,'viewer') records=db.rows('SELECT route,seo_title,description,canonical,status FROM migration_baseline WHERE site_id=? ORDER BY route',(site_id,)) dest=io.StringIO(); writer=csv.DictWriter(dest,fieldnames=['route','seo_title','description','canonical','status']);writer.writeheader();writer.writerows(records) return Response('\ufeff'+dest.getvalue(),media_type='text/csv',headers={'Content-Disposition':'attachment; filename=siteforge-seo-baseline.csv'}) @router.get('/sites/{site_id}/releases') def release_list(request:Request,site_id:str): require(request,site_id,'viewer') return db.rows('SELECT * FROM release_history WHERE site_id=? ORDER BY created_at DESC',(site_id,)) @router.post('/sites/{site_id}/releases/{build_id}/rollback') def release_rollback(request:Request,site_id:str,build_id:str): require(request,site_id,'owner') from .main import publish_local response=publish_local(site_id,build_id) db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',(uid(),site_id,build_id,actor(request),'rollback',db.now())) return response # Design tokens are site-specific and independent of the reusable theme package. DESIGN_DEFAULTS = {'font':'system','heading_scale':1.0,'radius':12,'section_spacing':72,'container_width':1200,'button_style':'rounded','header_style':'standard'} @router.get('/sites/{site_id}/design-tokens') def get_tokens(request:Request,site_id:str): require(request,site_id,'viewer') row=db.one('SELECT settings_json FROM sites WHERE id=?',(site_id,)) if not row:raise HTTPException(404) settings=json.loads(row['settings_json']) return {**DESIGN_DEFAULTS,**settings.get('design_tokens',{})} @router.put('/sites/{site_id}/design-tokens') def save_tokens(request:Request,site_id:str,payload:dict=Body(...)): from . import site_design_v87 site_design_v87.require_legacy_enabled() require(request,site_id,'editor') tokens={**DESIGN_DEFAULTS} if set(payload)-set(tokens):raise HTTPException(400,'存在不支持的设计变量') tokens.update(payload) if tokens['font'] not in ('system','serif','modern'):raise HTTPException(400,'不支持字体') if tokens['button_style'] not in ('rounded','square','pill'):raise HTTPException(400,'不支持按钮形状') if tokens['header_style'] not in ('standard','compact'):raise HTTPException(400,'不支持导航布局') for field,lo,hi in [('heading_scale',0.75,1.5),('radius',0,30),('section_spacing',32,140),('container_width',900,1600)]: if not isinstance(tokens[field],(float,int)) or not lo<=tokens[field]<=hi:raise HTTPException(400,'设计变量超出范围: '+field) record=db.one('SELECT settings_json FROM sites WHERE id=?',(site_id,)) if not record:raise HTTPException(404) settings=json.loads(record['settings_json']);settings['design_tokens']=tokens db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id)) return tokens @router.get('/sites/{site_id}/builds/{build_id}/quality') def quality_report(request:Request,site_id:str,build_id:str): require(request,site_id,'viewer') if not db.one('SELECT id FROM builds WHERE site_id=? AND id=?',(site_id,build_id)):raise HTTPException(404) from .quality import report return report(site_id,build_id) @router.post('/sites/{site_id}/analyze') def scan_source(request:Request,site_id:str,payload:dict=Body(...)): require(request,site_id,'editor') from .adapters import analyze try:return analyze(str(payload.get('url',''))) except Exception as exc:raise HTTPException(400,str(exc)[:300]) @router.post('/sites/{site_id}/wordpress-rest') def wordpress_rest(request:Request,site_id:str,payload:dict=Body(...)): require(request,site_id,'editor') from .adapters import import_wordpress_rest try:return import_wordpress_rest(site_id,str(payload.get('origin','')),int(payload.get('max_items',150)),str(payload.get('on_conflict','skip'))) except Exception as exc:raise HTTPException(400,str(exc)[:300])