"""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}