"""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['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)