Files
cms-SiteForge/app/connectors_v84.py
T

352 lines
19 KiB
Python
Raw Normal View History

"""V8.4 bounded XML and public JSON API connectors.
All resulting content enters the existing staged, auditable V8 import batches.
Public API paging runs in a subprocess under the common Worker lease/timeout.
Authenticated API sources and database connectors are intentionally not enabled.
"""
from __future__ import annotations
import json
import ipaddress
import re
import uuid
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from fastapi import APIRouter, Body, File, Form, HTTPException, Request, UploadFile
from defusedxml import ElementTree as XML
from defusedxml.common import DefusedXmlException
from . import db, enterprise, jobs, safe_http, v8_workflows, secret_refs
router = APIRouter(prefix='/api/connectors/sites',tags=['V8.4 data connectors'])
MAX_XML_BYTES = 8_000_000
MAX_XML_NODES = 20_000
MAX_XML_DEPTH = 9
MAX_VALUE_LEN = 20_000
MAX_RECORDS = 1000
MAX_JSON_BYTES = 2_000_000
TAG = re.compile(r'^[A-Za-z_][\w.-]{0,70}$')
JSON_PATH = re.compile(r'^[A-Za-z_][\w-]{0,60}(?:\.[A-Za-z_][\w-]{0,60}){0,4}$')
SENSITIVE_KEYS = {'key','api_key','apikey','token','access_token','secret','password','authorization','auth','signature','client_secret'}
def setup():
with db.connect() as c:
c.executescript('''
CREATE TABLE IF NOT EXISTS v84_api_sources (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
url TEXT NOT NULL, items_path TEXT NOT NULL, page_param TEXT NOT NULL,
per_page_param TEXT NOT NULL, per_page INTEGER NOT NULL,
max_pages INTEGER NOT NULL, next_page INTEGER NOT NULL DEFAULT 1,
scanned_pages INTEGER NOT NULL DEFAULT 0, staged_count INTEGER NOT NULL DEFAULT 0,
kind TEXT NOT NULL, mapping_json TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'ready',
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, error TEXT NOT NULL DEFAULT '');
CREATE INDEX IF NOT EXISTS idx_v84_sources_site ON v84_api_sources(site_id,created_at);
CREATE TABLE IF NOT EXISTS v84_api_pages (
source_id TEXT NOT NULL REFERENCES v84_api_sources(id) ON DELETE CASCADE,
page INTEGER NOT NULL, batch_id TEXT REFERENCES v8_import_batches(id),
input_count INTEGER NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY(source_id,page));
''')
# Preserve V8.4 sources when upgrading an existing database.
existing={r['name'] for r in c.execute('PRAGMA table_info(v84_api_sources)')}
for col,ddl in {
'auth_type':"TEXT NOT NULL DEFAULT 'none'",
'auth_header':"TEXT NOT NULL DEFAULT ''",
'secret_ref':"TEXT NOT NULL DEFAULT ''",
}.items():
if col not in existing:c.execute(f'ALTER TABLE v84_api_sources ADD COLUMN {col} {ddl}')
def _local_name(tag):
return tag.rsplit('}',1)[-1] if isinstance(tag,str) else ''
def parse_xml_records(data:bytes, record_tag:str):
"""Parse a bounded, non-DTD, flat/two-level XML dataset without network access."""
if not isinstance(data,bytes) or not 0 < len(data) <= MAX_XML_BYTES:
raise ValueError('XML 文件大小必须在 1 字节到 8 MB 之间')
if not isinstance(record_tag,str) or not TAG.fullmatch(record_tag):
raise ValueError('记录节点名称不合法')
# defusedxml is the security gate against DTD entity expansion and XXE.
try:root=XML.fromstring(data,forbid_dtd=True)
except (DefusedXmlException, XML.ParseError) as exc:
raise ValueError('XML 包含不允许的实体或格式错误') from exc
stack=[(root,1)];nodes=0;records=[]
while stack:
elem,depth=stack.pop();nodes+=1
if nodes>MAX_XML_NODES or depth>MAX_XML_DEPTH:
raise ValueError('XML 节点数或嵌套层级超限')
if _local_name(elem.tag)==record_tag:
records.append(elem)
if len(records)>MAX_RECORDS:raise ValueError('XML 每次最多导入1000条记录')
for child in reversed(list(elem)):stack.append((child,depth+1))
if not records:raise ValueError('未找到指定的 XML 记录节点')
rows=[]
for record in records:
item={}
for child in record:
field=_local_name(child.tag)
if not TAG.fullmatch(field):raise ValueError('XML 字段名不合法')
if len(child):
for sub in child:
if len(sub):raise ValueError('仅支持扁平或两层结构的记录')
key=field+'.'+_local_name(sub.tag)
if key in item:raise ValueError('XML 记录存在重复字段:'+key)
item[key]=(sub.text or '').strip()[:MAX_VALUE_LEN]
else:
if field in item:raise ValueError('XML 记录存在重复字段:'+field)
item[field]=(child.text or '').strip()[:MAX_VALUE_LEN]
if item:rows.append(item)
if not rows:raise ValueError('XML 记录没有可导入字段')
if len(json.dumps(rows,ensure_ascii=False).encode('utf-8'))>v8_workflows.MAX_UPLOAD:
raise ValueError('XML 解码后数据超过导入限制')
return rows
def _validate_url(url):
if not isinstance(url,str) or len(url)>1600:raise ValueError('API 地址不合法')
p=urlsplit(url)
if p.scheme!='https' or not p.hostname or p.username or p.password or p.fragment:
raise ValueError('API 连接器只接受公开 HTTPS 地址,不能包含账号、口令或片段')
try:port=p.port
except ValueError as exc:raise ValueError('API 端口不合法') from exc
if port not in (None,443):raise ValueError('仅支持默认 HTTPS 端口')
try:
ip=ipaddress.ip_address(p.hostname)
if not ip.is_global:raise ValueError('禁止使用非公网 IP API 地址')
except ValueError as exc:
if str(exc)=='禁止使用非公网 IP API 地址':raise
if any(k.casefold() in SENSITIVE_KEYS or 'token' in k.casefold() or 'secret' in k.casefold()
for k,_ in parse_qsl(p.query,keep_blank_values=True)):
raise ValueError('请勿在 API URL 中传入凭据或令牌')
if len(parse_qsl(p.query,keep_blank_values=True))>30:raise ValueError('URL 查询参数过多')
# DNS->socket pinning and non-public IP protection are enforced on fetch.
return urlunsplit(p)
def create_api_source(site_id,payload):
v8_workflows._require_site(site_id)
if not isinstance(payload,dict):raise ValueError('API 参数必须是对象')
url=_validate_url(payload.get('url'))
kind=str(payload.get('content_type','product'))
if kind not in ('product','vehicle','page','post','brand','case','service','other'):
raise ValueError('内容类型不受支持')
items_path=str(payload.get('items_path','items'))
if not JSON_PATH.fullmatch(items_path):raise ValueError('JSON 路径不合法')
page_param=str(payload.get('page_param','page'))
per_page_param=str(payload.get('per_page_param','per_page'))
if not TAG.fullmatch(page_param) or not TAG.fullmatch(per_page_param) or page_param==per_page_param:
raise ValueError('分页参数不合法')
if any(k in (page_param,per_page_param) for k,_ in parse_qsl(urlsplit(url).query)):
raise ValueError('源 URL 不应携带分页参数')
try:page_size=int(payload.get('page_size',50));max_pages=int(payload.get('max_pages',20))
except (TypeError,ValueError):raise ValueError('分页数量不合法')
if not 1<=page_size<=100 or not 1<=max_pages<=50 or page_size*max_pages>5000:
raise ValueError('上限为每页100条、最多50页、合计5000条')
mapping=payload.get('mapping') or {}
if not isinstance(mapping,dict) or len(mapping)>100 or any(not isinstance(k,str) or not isinstance(v,str) or len(v)>100 for k,v in mapping.items()):
raise ValueError('字段映射规则不合法')
auth_type=str(payload.get('auth_type','none'))
if auth_type not in ('none','bearer','api_key','custom'):
raise ValueError('不支持的 API 认证方式')
secret_ref='';auth_header=''
if auth_type!='none':
secret_ref=secret_refs.validate_reference(payload.get('secret_ref'))
auth_header='Authorization' if auth_type=='bearer' else 'X-API-Key'
if auth_type=='custom':
auth_header=str(payload.get('auth_header',''))
if not re.fullmatch(r'X-[A-Za-z0-9-]{2,64}',auth_header,re.I):
raise ValueError('自定义凭据请求头必须以 X- 开头')
elif payload.get('secret_ref'):
raise ValueError('公开 API 不应保存密钥引用')
source_id=uuid.uuid4().hex
db.insert('''INSERT INTO v84_api_sources(id,site_id,url,items_path,page_param,per_page_param,
per_page,max_pages,kind,mapping_json,auth_type,auth_header,secret_ref,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
(source_id,site_id,url,items_path,page_param,per_page_param,page_size,max_pages,kind,
json.dumps(mapping,ensure_ascii=False),auth_type,auth_header,secret_ref,db.now(),db.now()))
return status(site_id,source_id)
def _source(site_id,source_id):
r=db.one('SELECT * FROM v84_api_sources WHERE id=? AND site_id=?',(source_id,site_id))
if not r:raise HTTPException(404,'API 数据源不存在')
return r
def status(site_id,source_id):
s=_source(site_id,source_id)
# Secret references are never returned by management APIs.
return {k:s[k] for k in ('id','site_id','url','items_path','state','next_page',
'scanned_pages','staged_count','max_pages','per_page','kind','error','auth_type','auth_header')} | {
'batches':db.rows('SELECT page,batch_id,input_count FROM v84_api_pages WHERE source_id=? ORDER BY page',(source_id,))}
def _data_items(content,items_path):
try:result=json.loads(content.decode('utf-8-sig'))
except (ValueError,UnicodeError) as exc:raise ValueError('API 响应不是有效 UTF-8 JSON') from exc
if isinstance(result,list):items=result
else:
for part in items_path.split('.'):
if not isinstance(result,dict) or part not in result:raise ValueError('API JSON 路径不存在:'+items_path)
result=result[part]
items=result
if not isinstance(items,list) or len(items)>100 or any(not isinstance(x,dict) for x in items):
raise ValueError('API 当前页必须包含最多100条对象记录')
if any(len(row)>150 for row in items):raise ValueError('API 单条字段数量超过限制')
return items
def page_url(source,page):
p=urlsplit(source['url']);params=parse_qsl(p.query,keep_blank_values=True)
params.extend(((source['page_param'],str(page)),(source['per_page_param'],str(source['per_page']))))
return urlunsplit((p.scheme,p.netloc,p.path,urlencode(params),''))
def scan_page(site_id,source_id):
source=_source(site_id,source_id)
if source['state'] in ('cancelled','completed','limit_reached'):return {'state':source['state'],'next_page':source['next_page']}
if source['next_page']>source['max_pages']:
db.execute("UPDATE v84_api_sources SET state='completed',updated_at=? WHERE id=?",(db.now(),source_id))
return {'state':'completed','next_page':source['next_page']}
page=int(source['next_page'])
# A process crash after stage but before the checkpoint must not stage twice.
existing=db.one('SELECT * FROM v84_api_pages WHERE source_id=? AND page=?',(source_id,page))
origin=f'api:{source_id}:page:{page}'
if not existing:
pending=db.one('SELECT id,input_count FROM v8_import_batches WHERE site_id=? AND origin=?',(site_id,origin))
if pending:
batch_id=pending['id'];count=pending['input_count']
else:
extra={}
if source.get('auth_type','none')!='none':
token=secret_refs.resolve(source['secret_ref'])
extra[source['auth_header']]=('Bearer '+token if source['auth_type']=='bearer' else token)
opts={'headers':{'Accept':'application/json'},'redirects':0,'timeout':20,'max_bytes':MAX_JSON_BYTES}
if extra:opts['credential_headers']=extra
resp=safe_http.fetch(page_url(source,page),**opts)
resp.raise_for_status()
mime=resp.headers.get('content-type','').split(';')[0].strip().lower()
if mime!='application/json' and not mime.endswith('+json'):
raise ValueError('API 响应 Content-Type 必须为 JSON')
rows=_data_items(resp.content,source['items_path'])
count=len(rows);batch_id=None
if rows:
mapping=json.loads(source['mapping_json'] or '{}') or None
keys=[f'{source_id}:{page}:{i}:{str(r.get("id",i))[:60]}' for i,r in enumerate(rows)]
staged=v8_workflows.stage(site_id,rows,mapping,source['kind'],origin,keys)
batch_id=staged['batch_id']
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
# The exclusive one-page checkpoint is idempotent across retries.
c.execute('''INSERT INTO v84_api_pages(source_id,page,batch_id,input_count,created_at)
VALUES(?,?,?,?,?) ON CONFLICT(source_id,page) DO NOTHING''',
(source_id,page,batch_id,count,db.now()))
else:
count=existing['input_count'];batch_id=existing['batch_id']
finished=count<source['per_page'] or page>=source['max_pages']
final_state=('limit_reached' if page>=source['max_pages'] and count>=source['per_page'] else 'completed') if finished else 'ready'
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
cur=c.execute('SELECT next_page,state FROM v84_api_sources WHERE id=? AND site_id=?',(source_id,site_id)).fetchone()
if cur and cur['state']=='cancelled':
return {'state':'cancelled','page':page,'count':count,'batch_id':batch_id,'next_page':page}
if cur and cur['next_page']==page:
c.execute('''UPDATE v84_api_sources SET next_page=?,scanned_pages=scanned_pages+1,
staged_count=staged_count+?,state=?,updated_at=?,error='' WHERE id=?''',
(page+1,count,final_state,db.now(),source_id))
return {'state':final_state,'page':page,'count':count,'batch_id':batch_id,
'next_page':page+1}
def run_scan(site_id,source_id,job_id=None):
source=_source(site_id,source_id)
if source['state']=='cancelled':return status(site_id,source_id)
# Worker retry always resumes from the durable checkpoint.
while True:
one=scan_page(site_id,source_id)
if one['state'] in ('completed','limit_reached'):break
if one['state']=='cancelled':break
return status(site_id,source_id)
def submit_scan(site_id,source_id,actor,key=None):
source=_source(site_id,source_id)
if source['state']=='cancelled':raise HTTPException(409,'数据源已取消')
if key is not None and not (8<=len(key)<=128 and all(c.isalnum() or c in '_-:.' for c in key)):
raise ValueError('幂等键不合法')
payload=json.dumps({'source_id':source_id},sort_keys=True,separators=(',',':'))
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
if key:
prior=c.execute('SELECT id,state,kind,payload_json FROM build_jobs WHERE site_id=? AND idempotency_key=?',(site_id,key)).fetchone()
if prior:
if prior['kind']!='api_scan' or prior['payload_json']!=payload:raise ValueError('幂等键已关联其他任务')
return {'id':prior['id'],'state':prior['state'],'existing':True}
prior=c.execute('''SELECT id,state FROM build_jobs WHERE site_id=? AND kind='api_scan'
AND state IN ('queued','running','retrying') AND payload_json=? LIMIT 1''',(site_id,payload)).fetchone()
if prior:return {'id':prior['id'],'state':prior['state'],'existing':True}
if source['state'] in ('completed','limit_reached'):return {'state':source['state'],'existing':True,'source_id':source_id}
if c.execute("SELECT COUNT(*) FROM build_jobs WHERE state IN ('queued','running','retrying')").fetchone()[0]>=jobs._limit('SITEFORGE_MAX_PENDING_JOBS',100):
raise jobs.JobCapacityError('全局任务容量已满')
if c.execute("SELECT COUNT(*) FROM build_jobs WHERE site_id=? AND state IN ('queued','running','retrying')",(site_id,)).fetchone()[0]>=jobs._limit('SITEFORGE_MAX_PENDING_JOBS_PER_SITE',5):
raise jobs.JobCapacityError('当前网站任务容量已满')
jid=uuid.uuid4().hex[:16]
c.execute('''INSERT INTO build_jobs(id,site_id,kind,state,created_by,created_at,
payload_json,idempotency_key,max_attempts,timeout_seconds,available_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)''',
(jid,site_id,'api_scan','queued',actor,db.now(),payload,key,3,900,db.now()))
from . import redis_signal
redis_signal.notify()
return {'id':jid,'state':'queued','existing':False}
@router.post('/{site_id}/xml/staging')
async def api_xml(request:Request,site_id:str,file:UploadFile=File(...),record_tag:str=Form('item'),
content_type:str=Form('product'),mapping_json:str=Form('{}')):
enterprise.require(request,site_id,'editor')
blob=await file.read(MAX_XML_BYTES+1)
try:
mapping=json.loads(mapping_json)
rows=parse_xml_records(blob,record_tag)
result=v8_workflows.stage(site_id,rows,mapping or None,content_type,'xml:'+str(file.filename or 'upload.xml')[:100])
return {**result,'columns':list(rows[0]),'record_tag':record_tag}
except (ValueError,TypeError,json.JSONDecodeError) as exc:raise HTTPException(400,str(exc))
@router.post('/{site_id}/api-sources')
def api_create(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
try:return create_api_source(site_id,payload)
except ValueError as exc:raise HTTPException(400,str(exc))
@router.get('/{site_id}/api-sources')
def api_list(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
v8_workflows._require_site(site_id)
return {'items':db.rows('SELECT id,url,state,next_page,staged_count FROM v84_api_sources WHERE site_id=? ORDER BY created_at DESC LIMIT 100',(site_id,))}
@router.get('/{site_id}/api-sources/{source_id}')
def api_status(request:Request,site_id:str,source_id:str):
enterprise.require(request,site_id,'viewer')
return status(site_id,source_id)
@router.post('/{site_id}/api-sources/{source_id}/scan',status_code=202)
def api_scan(request:Request,site_id:str,source_id:str,payload:dict=Body(default_factory=dict)):
enterprise.require(request,site_id,'editor')
try:return submit_scan(site_id,source_id,request.state.actor,payload.get('idempotency_key'))
except (ValueError,jobs.JobCapacityError) as exc:raise HTTPException(409,str(exc))
@router.post('/{site_id}/api-sources/{source_id}/cancel')
def api_cancel(request:Request,site_id:str,source_id:str):
enterprise.require(request,site_id,'editor')
s=_source(site_id,source_id)
if s['state']!='completed':db.execute("UPDATE v84_api_sources SET state='cancelled',updated_at=? WHERE id=?",(db.now(),source_id))
for row in db.rows("SELECT id FROM build_jobs WHERE site_id=? AND kind='api_scan' AND state IN ('queued','running','retrying')",(site_id,)):
job=db.one('SELECT payload_json FROM build_jobs WHERE id=?',(row['id'],))
if job and json.loads(job['payload_json']).get('source_id')==source_id:jobs.cancel(site_id,row['id'])
return status(site_id,source_id)