Files
cms-SiteForge/app/reusables.py
T

293 lines
15 KiB
Python
Raw Normal View History

"""Versioned, site-scoped reusable blocks with opt-in, draft-only propagation.
Published pages are NEVER rewritten by a shared-component update. A linked
instance is a validated, materialized block with shared_ref metadata. Updates
require a fresh preview token, exact page revision and an unmodified instance.
"""
from __future__ import annotations
import copy
import hashlib
import json
import re
import uuid
from fastapi import APIRouter, Body, HTTPException, Request
from . import db, enterprise, blueprints, studio_v86
from .core import uid
router=APIRouter(prefix='/api/sites',tags=['Reusable Components'])
def canonical(value):
return json.dumps(value,ensure_ascii=False,sort_keys=True,separators=(',',':'))
def _content(block):
"""Content fingerprint independent of generated instance IDs and provenance."""
def strip(item):
if isinstance(item,dict):return {k:strip(v) for k,v in item.items() if k not in ('id','shared_ref')}
if isinstance(item,list):return [strip(v) for v in item]
return item
return strip(block)
def fingerprint(block):
return hashlib.sha256(canonical(_content(block)).encode()).hexdigest()
def setup():
with db.connect() as c:
c.executescript('''CREATE TABLE IF NOT EXISTS reusable_components(
id TEXT PRIMARY KEY,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
name TEXT NOT NULL,version INTEGER NOT NULL DEFAULT 1,block_json TEXT NOT NULL,
updated_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_reuse_site ON reusable_components(site_id);
CREATE TABLE IF NOT EXISTS reusable_component_versions(
component_id TEXT NOT NULL REFERENCES reusable_components(id) ON DELETE CASCADE,
version INTEGER NOT NULL,block_json TEXT NOT NULL,snapshot_hash TEXT NOT NULL,
created_at TEXT NOT NULL,PRIMARY KEY(component_id,version));
''')
# Safe additive migration of old version-1 reusable blocks.
legacy=c.execute('SELECT id,version,block_json,updated_at FROM reusable_components').fetchall()
for row in legacy:
block=json.loads(row['block_json'])
c.execute('''INSERT INTO reusable_component_versions
(component_id,version,block_json,snapshot_hash,created_at)
VALUES(?,?,?,?,?) ON CONFLICT(component_id,version) DO NOTHING''',
(row['id'],row['version'],row['block_json'],fingerprint(block),row['updated_at']))
def _component(site_id,component_id,c=None):
sql='SELECT * FROM reusable_components WHERE site_id=? AND id=?'
row=c.execute(sql,(site_id,component_id)).fetchone() if c else db.one(sql,(site_id,component_id))
if not row:raise HTTPException(404,'本站组件不存在')
return dict(row)
def _validate_block(block):
try:
validated=blueprints.validate_blocks([block])[0]
except (ValueError,TypeError) as exc:raise HTTPException(400,str(exc))
# A library source is an independent snapshot. Embedded source links are
# removed recursively; only insertion as a linked instance adds provenance.
def detach(node):
node.pop('shared_ref',None)
if node.get('type')=='layout':
for col in node.get('columns',[]):
for sub in col.get('blocks',[]):detach(sub)
detach(validated)
return validated
def _name(value):
name=str(value or '').strip()
if not name or len(name)>100:raise HTTPException(400,'组件名称需要1–100个字符')
return name
def versions(site_id,component_id):
_component(site_id,component_id)
rows=db.rows('''SELECT version,snapshot_hash,created_at FROM reusable_component_versions
WHERE component_id=? ORDER BY version DESC''',(component_id,))
return {'items':rows}
@router.get('/{site_id}/components')
def all_components(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return [{**r,'block':json.loads(r['block_json']), 'snapshot_hash':fingerprint(json.loads(r['block_json']))}
for r in db.rows('SELECT * FROM reusable_components WHERE site_id=? ORDER BY updated_at DESC',(site_id,))]
@router.post('/{site_id}/components')
def save_component(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
name=_name(payload.get('name'))
block=_validate_block(payload.get('block'))
# Saved library snapshots must not retain a reference to a different component.
block.pop('shared_ref',None)
cid=uid();now=db.now();encoded=canonical(block);sha=fingerprint(block)
with db.connect() as c:
c.execute('INSERT INTO reusable_components VALUES (?,?,?,?,?,?)',(cid,site_id,name,1,encoded,now))
c.execute('INSERT INTO reusable_component_versions VALUES (?,?,?,?,?)',(cid,1,encoded,sha,now))
return {'id':cid,'name':name,'version':1,'block':block,'snapshot_hash':sha}
@router.put('/{site_id}/components/{component_id}')
def update_component(request:Request,site_id:str,component_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
block=_validate_block(payload.get('block'));block.pop('shared_ref',None)
with db.connect() as c:
row=_component(site_id,component_id,c)
rev=payload.get('expected_version')
if type(rev) is not int or rev!=row['version']:raise HTTPException(409,'组件版本已改变,请重新加载')
version=rev+1;name=_name(payload.get('name') if payload.get('name') is not None else row['name'])
encoded=canonical(block);now=db.now()
cur=c.execute('''UPDATE reusable_components SET name=?,block_json=?,version=?,updated_at=?
WHERE id=? AND site_id=? AND version=?''',(name,encoded,version,now,component_id,site_id,rev))
if cur.rowcount!=1:raise HTTPException(409,'组件已由其他用户修改')
c.execute('INSERT INTO reusable_component_versions VALUES (?,?,?,?,?)',
(component_id,version,encoded,fingerprint(block),now))
return {'id':component_id,'version':version,'snapshot_hash':fingerprint(block),'published_unchanged':True}
@router.delete('/{site_id}/components/{component_id}')
def delete_component(request:Request,site_id:str,component_id:str):
enterprise.require(request,site_id,'editor')
_component(site_id,component_id)
# Reject deletion while linked instances exist; preserve historical versions.
if _references(site_id,component_id):raise HTTPException(409,'组件仍被页面引用,请先解除引用')
db.execute('DELETE FROM reusable_components WHERE site_id=? AND id=?',(site_id,component_id))
return {'deleted':True}
@router.get('/{site_id}/components/{component_id}/versions')
def api_versions(request:Request,site_id:str,component_id:str):
enterprise.require(request,site_id,'viewer')
return versions(site_id,component_id)
@router.get('/{site_id}/components/{component_id}/versions/{version}')
def api_version(request:Request,site_id:str,component_id:str,version:int):
enterprise.require(request,site_id,'viewer')
_component(site_id,component_id)
row=db.one('SELECT * FROM reusable_component_versions WHERE component_id=? AND version=?',(component_id,version))
if not row:raise HTTPException(404,'版本不存在')
block=json.loads(row['block_json'])
if fingerprint(block)!=row['snapshot_hash']:raise HTTPException(409,'历史快照完整性校验失败')
return {'version':version,'block':block,'snapshot_hash':row['snapshot_hash']}
def _walk(rows):
for block in rows:
yield block
if block.get('type')=='layout':
for col in block.get('columns',[]):yield from _walk(col.get('blocks',[]))
def _replace_in_tree(rows,block_id,replacement):
for i,block in enumerate(rows):
if block.get('id')==block_id:
rows[i]=replacement;return True
if block.get('type')=='layout':
for col in block.get('columns',[]):
if _replace_in_tree(col['blocks'],block_id,replacement):return True
return False
def _references(site_id,component_id):
# Bounded scan; site ownership is enforced at query boundary.
all_pages=db.rows('SELECT * FROM entries WHERE site_id=?',(site_id,))
drafts={r['entry_id']:r for r in db.rows('SELECT * FROM studio_drafts WHERE site_id=?',(site_id,))}
found=[]
for page in all_pages:
draft=drafts.get(page['id'])
plan=json.loads(draft['blueprint_json']) if draft else json.loads(page['meta_json'] or '{}')
for block in _walk(plan.get('blocks',[])):
meta=block.get('shared_ref')
if isinstance(meta,dict) and meta.get('id')==component_id:
found.append({'entry_id':page['id'],'route':page['route'],'title':page['title'],
'block_id':block['id'],'current_version':meta.get('version'),
'local_modified':fingerprint(block)!=meta.get('hash'),
'draft_revision':draft['revision'] if draft else 0,
'base_hash':draft['base_hash'] if draft else studio_v86._hash(page),
'has_draft':bool(draft),'conflict':bool(draft and draft['base_hash']!=studio_v86._hash(page))})
return found
def _preview(site_id,component_id):
component=_component(site_id,component_id)
refs=_references(site_id,component_id)
for item in refs:
item['up_to_date']=item['current_version']==component['version']
item['eligible']=not item['local_modified'] and not item['conflict'] and not item['up_to_date']
item['reason']=('存在页面发布冲突' if item['conflict'] else '页面有本地修改' if item['local_modified']
else '已是最新版' if item['up_to_date'] else '')
# Token includes all page revisions and instance fingerprints; replay of stale
# preview after any page or component changes is rejected.
proof={'component':component_id,'version':component['version'],'references':refs}
token=hashlib.sha256(canonical(proof).encode()).hexdigest()
return {'component':{'id':component_id,'name':component['name'],'version':component['version']},
'references':refs,'plan_token':token,'warning':'同步仅写入新草稿,不自动发布或部署'}
@router.get('/{site_id}/components/{component_id}/usage')
def api_usage(request:Request,site_id:str,component_id:str):
enterprise.require(request,site_id,'viewer')
return _preview(site_id,component_id)
def _copy_into(snapshot,original,component_id,version):
block=copy.deepcopy(snapshot)
def fresh(node,root=False):
node['id']=original['id'] if root else uuid.uuid4().hex[:10]
node.pop('shared_ref',None)
if node.get('type')=='layout':
for col in node.get('columns',[]):
for sub in col.get('blocks',[]):fresh(sub)
fresh(block,True)
block['shared_ref']={'id':component_id,'version':version,'hash':fingerprint(block)}
return block
@router.post('/{site_id}/components/{component_id}/sync')
def api_sync(request:Request,site_id:str,component_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
selected=payload.get('entry_ids')
if not isinstance(selected,list) or not selected or len(selected)>100 or len(set(selected))!=len(selected) or any(not isinstance(s,str) for s in selected):
raise HTTPException(400,'选择1–100个不同页面')
preview=_preview(site_id,component_id)
if payload.get('plan_token')!=preview['plan_token']:raise HTTPException(409,'预检已过期,请重新查看引用页面')
chosen=set(selected)
available={r['entry_id'] for r in preview['references'] if r['eligible']}
if not chosen.issubset(available):raise HTTPException(409,'包含已修改、存在冲突或不可同步的页面')
component=_component(site_id,component_id)
current=blueprints.validate_blocks([json.loads(component['block_json'])])[0]
# Restrict to exactly one linked instance per page per request for now.
# When one page has multiple references, sync all eligible instances atomically.
updated=[]
expected_pages={r['entry_id']:(r['draft_revision'],r['base_hash']) for r in preview['references'] if r['entry_id'] in chosen}
with db.connect() as c:
# Lock the read/compare/write window. PostgreSQL uses an advisory
# transaction lock through the compatibility adapter.
c.execute('BEGIN IMMEDIATE')
newest=_component(site_id,component_id,c)
if newest['version']!=component['version']:
raise HTTPException(409,'组件源版本发生变化')
for entry_id in selected:
page=c.execute('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,entry_id)).fetchone()
if not page:raise HTTPException(409,'页面已删除,请重新预检')
draft=c.execute('SELECT * FROM studio_drafts WHERE site_id=? AND entry_id=?',(site_id,entry_id)).fetchone()
old_hash=studio_v86._hash(dict(page))
expected_page_revision,expected_base=expected_pages[entry_id]
if expected_base!=old_hash or (draft['revision'] if draft else 0)!=expected_page_revision:
raise HTTPException(409,'页面版本已变化,重新预检后同步')
if draft and draft['base_hash']!=old_hash:raise HTTPException(409,'页面内容已变化')
plan=json.loads(draft['blueprint_json']) if draft else {
'blocks':json.loads(page['meta_json'] or '{}').get('blocks',[]),
'page_template':json.loads(page['meta_json'] or '{}').get('page_template','home' if page['route']=='/' else 'standard'),
'layout_mode':json.loads(page['meta_json'] or '{}').get('layout_mode','append')}
did=0
for block in list(_walk(plan['blocks'])):
ref=block.get('shared_ref')
if isinstance(ref,dict) and ref.get('id')==component_id:
if fingerprint(block)!=ref.get('hash') or ref.get('version')==component['version']:
raise HTTPException(409,'引用发生变化,请重新预检')
if not _replace_in_tree(plan['blocks'],block['id'],_copy_into(current,block,component_id,component['version'])):
raise HTTPException(409,'组件位置发生变化')
did+=1
if not did:raise HTTPException(409,'页面已经没有组件引用')
plan=studio_v86._validate(plan)
expected=draft['revision'] if draft else 0
rev=expected+1
if draft:
cur=c.execute('''UPDATE studio_drafts SET revision=?,blueprint_json=?,updated_at=?
WHERE site_id=? AND entry_id=? AND revision=? AND base_hash=?''',
(rev,canonical(plan),db.now(),site_id,entry_id,expected,draft['base_hash']))
if cur.rowcount!=1:raise HTTPException(409,'草稿并发冲突')
else:
c.execute('INSERT INTO studio_drafts VALUES (?,?,?,?,?,?)',
(site_id,entry_id,rev,old_hash,canonical(plan),db.now()))
studio_v86._journal(c,site_id,entry_id,rev,'draft',plan,enterprise.actor(request))
updated.append({'entry_id':entry_id,'revision':rev,'instances':did})
return {'updated':updated,'published_unchanged':True,'build_required_after_publish':True}