Files
浅念 601451f22b upgrade: adopt V8.21.3 (V8.13→8.21.3: P5 SFTP/S3, P6 multilingual, editor overhaul, enterprise hardening)
Overlay ~150 upstream items onto our V8.12 base, preserving trade-pro
theme, registry tests, and design docs. Re-applied our patch set
(discovery/env_for/blocks.css/type-mapping/theme_color/frontend entries,
test_theme_pro_v3 mods) — all anchors survived upstream evolution.

New platform enhancement (fixes upgrade-introduced regression):
multilingual.site_locale now honors settings_json.locale, plus
PUT /api/i18n/sites/{id}/locale — standalone English sites get
<html lang='en'> under the V8.15 locale-rewrite pipeline.

compose: adopt V8.21.3 (image 8.21.3-dev.1, /api/ready probes, MySQL/S3
env) with project name pinned to siteforge-v8-10 (data volumes).

Tests: 356 passed / 11 failed — all 11 confirmed pre-existing in
pristine V8.21.3 on this host (10 upstream Windows env + our build test
now green after locale fix). Patch registry updated in plan doc.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 12:31:03 +08:00

356 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)<ACCESS[role]:raise HTTPException(403,'没有访问权限')
def user_by_name(username):
return db.one('SELECT * FROM team_users WHERE username=?',(username,))
def account_from_session(session):
if session.get('sub')=='admin':return {'actor':'admin','id':None,'superadmin':True}
if session.get('sub')!='user':return None
account=db.one('SELECT id,username,disabled FROM team_users WHERE id=?',(session.get('user_id'),))
if not account or account['disabled']:return None
return {'actor':account['username'],'id':account['id'],'superadmin':False}
def log_event(actor_name,path,status,method,site_id=None):
try:
db.insert('INSERT INTO audit_events VALUES (?,?,?,?,?,?,?)',(uid(),actor_name,site_id,method,path,status,db.now()))
except (sqlite3.Error, OSError):pass
def save_revision(site_id,entry_id,actor_name,action):
row=db.one('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,entry_id))
if row:
db.insert('INSERT INTO content_revisions VALUES (?,?,?,?,?,?,?)',
(uid(),site_id,entry_id,actor_name,action,json.dumps(row,ensure_ascii=False),db.now()))
class UserCreate(BaseModel):
username:str=Field(min_length=3,max_length=70,pattern=r'^[a-zA-Z0-9._-]+$')
display_name:str=Field(min_length=1,max_length=90)
password:str=Field(min_length=12,max_length=200)
class AssignRole(BaseModel):
user_id:str
role:str
@router.get('/team/users')
def list_users(request:Request):
require(request,role='owner')
if not request.state.is_superadmin:raise HTTPException(403)
return db.rows('SELECT id,username,display_name,disabled,created_at FROM team_users ORDER BY created_at DESC')
@router.post('/team/users',status_code=201)
def create_user(request:Request,payload:UserCreate):
if not request.state.is_superadmin:raise HTTPException(403)
try:
person=uid()
db.insert('INSERT INTO team_users(id,username,password_hash,display_name,created_at) VALUES (?,?,?,?,?)',
(person,payload.username,hash_password(payload.password),payload.display_name,db.now()))
except Exception as exc:
# PostgreSQL uses psycopg.errors.UniqueViolation rather than sqlite3.IntegrityError.
if isinstance(exc,sqlite3.IntegrityError) or getattr(exc,'sqlstate',None)=='23505':
raise HTTPException(409,'用户名已存在') from exc
raise
return {'id':person,'username':payload.username,'display_name':payload.display_name}
@router.patch('/team/users/{user_id}/disable')
def disable_user(request:Request,user_id:str,payload:dict=Body(default_factory=dict)):
if not request.state.is_superadmin:raise HTTPException(403)
if not db.one('SELECT id FROM team_users WHERE id=?',(user_id,)):raise HTTPException(404)
db.execute('UPDATE team_users SET disabled=? WHERE id=?',(int(bool(payload.get('disabled',True))),user_id))
return {'ok':True}
@router.put('/sites/{site_id}/members')
def grant_site(request:Request,site_id:str,payload:AssignRole):
require(request,site_id,'owner')
if payload.role not in ROLES:raise HTTPException(400,'不支持该角色')
if not db.one('SELECT id FROM team_users WHERE id=?',(payload.user_id,)):raise HTTPException(404,'用户不存在')
db.insert('''INSERT INTO team_memberships(user_id,site_id,role) VALUES (?,?,?)
ON CONFLICT(user_id,site_id) DO UPDATE SET role=excluded.role''',(payload.user_id,site_id,payload.role))
return {'assigned':True}
@router.get('/sites/{site_id}/members')
def site_members(request:Request,site_id:str):
require(request,site_id,'owner')
return db.rows('''SELECT u.id,u.username,u.display_name,m.role FROM team_memberships m
JOIN team_users u ON u.id=m.user_id WHERE m.site_id=?''',(site_id,))
@router.get('/audit')
def audits(request:Request,site_id:str='',limit:int=100):
if site_id:require(request,site_id,'owner')
elif not request.state.is_superadmin:raise HTTPException(403)
limit=min(max(limit,1),250)
if site_id:return db.rows('SELECT * FROM audit_events WHERE site_id=? ORDER BY created_at DESC,id DESC LIMIT ?',(site_id,limit))
return db.rows('SELECT * FROM audit_events ORDER BY created_at DESC,id DESC LIMIT ?',(limit,))
@router.get('/sites/{site_id}/entries/{entry_id}/revisions')
def revisions(request:Request,site_id:str,entry_id:str):
require(request,site_id,'viewer')
return db.rows('SELECT id,actor,action,created_at FROM content_revisions WHERE site_id=? AND entry_id=? ORDER BY created_at DESC,id DESC LIMIT 100',(site_id,entry_id))
@router.get('/sites/{site_id}/revisions/{revision_id}')
def revision_detail(request:Request,site_id:str,revision_id:str):
require(request,site_id,'viewer')
row=db.one('SELECT * FROM content_revisions WHERE site_id=? AND id=?',(site_id,revision_id))
if not row:raise HTTPException(404)
return {'id':revision_id,'entry':json.loads(row['snapshot_json']),'actor':row['actor']}
@router.post('/sites/{site_id}/revisions/{revision_id}/restore')
def revision_restore(request:Request,site_id:str,revision_id:str):
require(request,site_id,'editor')
row=db.one('SELECT * FROM content_revisions WHERE site_id=? AND id=?',(site_id,revision_id))
if not row:raise HTTPException(404)
payload=json.loads(row['snapshot_json'])
entry_id=payload['id']
save_revision(site_id,entry_id,actor(request),'pre_restore')
columns=['type','title','route','slug','summary','body','cover','seo_title','seo_description','canonical','robots','meta_json','status']
with db.connect() as c:
c.execute(f'''UPDATE entries SET {','.join(f'{col}=?' for col in columns)},updated_at=? WHERE site_id=? AND id=?''',
[payload[k] for k in columns]+[db.now(),site_id,entry_id])
if c.total_changes==0:raise HTTPException(404,'原页面已删除,请先创建')
return {'restored':True,'entry_id':entry_id}
@router.post('/sites/{site_id}/jobs',status_code=202)
def enqueue(request:Request,site_id:str,payload:dict=Body(default_factory=dict)):
require(request,site_id,'editor')
kind=payload.get('kind','build')
if kind!='build':raise HTTPException(400,'当前任务队列支持构建任务')
try:
return jobs.submit(site_id, {'mirror_images':bool(payload.get('mirror_images',False))},
actor(request),payload.get('idempotency_key'))
except jobs.JobCapacityError as exc:
raise HTTPException(429, str(exc))
except jobs.JobConflict as exc:
raise HTTPException(409,str(exc))
@router.get('/sites/{site_id}/jobs')
def list_jobs(request:Request,site_id:str):
require(request,site_id,'viewer')
return db.rows('SELECT * FROM build_jobs WHERE site_id=? ORDER BY created_at DESC LIMIT 50',(site_id,))
@router.get('/sites/{site_id}/jobs/{job_id}')
def job_detail(request:Request,site_id:str,job_id:str):
require(request,site_id,'viewer')
job=db.one('SELECT * FROM build_jobs WHERE site_id=? AND id=?',(site_id,job_id))
if not job: raise HTTPException(404,'任务不存在')
return job
@router.post('/sites/{site_id}/jobs/{job_id}/cancel')
def job_cancel(request:Request,site_id:str,job_id:str):
require(request,site_id,'editor')
return jobs.cancel(site_id,job_id)
class BaselineItem(BaseModel):
route:str
seo_title:str=''
description:str=''
canonical:str=''
status:int=200
@router.put('/sites/{site_id}/baseline')
def set_baseline(request:Request,site_id:str,payload:list[BaselineItem]):
require(request,site_id,'editor')
if len(payload)>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])