"""Refresh one source live, build a safe snapshot, and activate it. Runs only inside the ordinary governance worker. It reads restricted credentials, uses allowlisted read-only APIs, writes temporary files under /tmp, and never downloads AList content or invokes source write/delete APIs. """ import argparse,csv,io,json,os,pathlib,re,subprocess,sys,tempfile from collections import deque sys.path.insert(0,'/app/scripts') from source_catalog_import import build_alist_source,build_dingtalk_sources,build_pp_live_source,catalog_sql,validate_source class RefreshBlocked(RuntimeError):pass _source_name='';_last_directory='' def emit(stage,progress,message,processed=0,total=0,current_directory='',scanned_directories=0,discovered_directories=0,scanned_files=0,estimated_total_files=0,total_is_estimate=False): global _last_directory if current_directory:_last_directory=current_directory payload={'stage':stage,'progress':progress,'message':message,'processed':processed,'total':total} if _source_name:payload['source_name']=_source_name if _last_directory:payload['current_directory']=_last_directory if scanned_directories:payload['scanned_directories']=scanned_directories if discovered_directories:payload['discovered_directories']=discovered_directories if scanned_files:payload['scanned_files']=scanned_files if estimated_total_files:payload['estimated_total_files']=estimated_total_files if total_is_estimate:payload['total_is_estimate']=True print(json.dumps(payload,ensure_ascii=False),flush=True) def sanitize_directory(path): if not path:return '' cleaned=''.join('␉' if char=='\t' else char for char in str(path) if char.isprintable() or char=='\t') for forbidden in ('http://','https://','token','secret','password','authorization','bearer','cookie','/run/source-secrets','api-key','apikey'): if forbidden in cleaned.lower():return '' return cleaned[:1024] def run_psql(sql): url=os.environ.get('DATABASE_URL',''); if not url:raise RuntimeError('database unavailable') result=subprocess.run(['psql','-X','-v','ON_ERROR_STOP=1','-d',url,'-At'],input=sql,text=True,capture_output=True,timeout=180) if result.returncode:raise RuntimeError('catalog activation failed') def refresh_pp(): sys.path.insert(0,'/sources/pp');from download_scope_a import login,fetch_inventory emit('connecting',10,'正在登录PP资料下载中心') try:token=login('/run/source-secrets/pp_credentials.json') except Exception as error:raise RefreshBlocked('PP登录失败,请在来源链接更新凭据') from error emit('scanning',35,'正在实时读取PP公开资料清单') inventory=fetch_inventory(token) emit('building',75,'正在构建PP八类目录快照',len(inventory.get('items',[])),len(inventory.get('items',[]))) manifest={} try: from pathlib import Path as _P manifest=json.loads(_P('/sources/pp/data/public-documents/manifest.json').read_text()) except Exception: emit('building',78,'本地内容清单暂不可用,将记录为新资料待校验') return build_pp_live_source(inventory,manifest) def dingtalk_root_key(connection_id, space_id): from source_catalog_import import stable_connection_id key='space-'+space_id if stable_connection_id('dingtalk-'+key)==connection_id:return key # Legacy identities remain authoritative for existing directory annotations. inventory=json.loads(pathlib.Path('/sources/dingtalk/data/dingtalk-internal/inventory-state.json').read_text()) for key in inventory.get('roots',{}): if stable_connection_id('dingtalk-'+key)==connection_id:return key raise RuntimeError('DingTalk source identity cannot be resolved safely') def refresh_dingtalk(name,connection_id=''): from dws_dingtalk_scan import walk if not re.fullmatch(r'[A-Za-z0-9_.:-]{1,36}',connection_id):raise RuntimeError('invalid source identity') rows=subprocess.run(['psql','-X','-At','-v','ON_ERROR_STOP=1','-d',os.environ.get('DATABASE_URL',''),'-c',"SELECT base_url FROM source_connections WHERE id='"+connection_id+"'"],capture_output=True,text=True,timeout=30) if rows.returncode:raise RuntimeError('DingTalk source lookup failed') match=re.fullmatch(r'spaces/([A-Za-z0-9_-]+)\?space=([A-Za-z0-9_-]+)',rows.stdout.strip()) if not match:raise RuntimeError('DingTalk source root missing') root_id,space_id=match.groups() root_key=dingtalk_root_key(connection_id,space_id) kind='drive' if space_id.isdigit() else 'wiki' emit('connecting',8,'正在用 dws 读取来源目录') def report(done,pending,files): total=done+pending emit('scanning',10+(65*done//total if total else 65),f"正在扫描目录:已完成 {done} 个文件夹,待处理 {pending} 个",done,total,scanned_directories=done,discovered_directories=total,scanned_files=files,estimated_total_files=files,total_is_estimate=True) try:nodes=walk(space_id,kind,root_id,report) except Exception as error:raise RefreshBlocked('dws 目录读取失败,请检查共享授权及来源读取权限') from error sources=build_dingtalk_sources({'roots':{root_key:{'configured_root_id':root_id,'live_space_name':name,'nodes':nodes}}}) if len(sources)!=1:raise RuntimeError('DingTalk refreshed source missing') emit('building',80,'正在校验钉钉父子目录和文件数量') return sources[0] def refresh_alist(name): sys.path.insert(0,'/sources/alist');from alist_readonly_inventory import AListReadOnlyClient,join_path,read_connections connections=[item for item in read_connections(pathlib.Path('/run/source-secrets/alist_connections.xlsx'),require_token=False) if item['category']==name] if len(connections)!=1:raise RuntimeError('AList source configuration missing') token_path=pathlib.Path('/run/source-secrets/alist_api_token') try: token=token_path.read_text(encoding='utf-8').strip() if not token:raise ValueError('empty token') except (OSError,ValueError) as error:raise RefreshBlocked('AList API Token 未配置,请联系管理员更新来源凭据') from error connection=connections[0];client=AListReadOnlyClient(connection['base_url'],token);queue=deque(['/']);visited=set();directories=[];files=[];last=10 emit('connecting',8,'正在验证AList直连只读凭据') while queue: path=queue.popleft() if path in visited:continue visited.add(path) try:items=client.list_directory(path) except Exception as error:raise RefreshBlocked('AList直连API访问失败,请检查只读Token或来源可用性') from error direct_dirs=direct_files=direct_bytes=0 for item in items: item_name=str(item.get('name','')).strip() if not item_name or item_name in ('.','..'):continue child=join_path(path,item_name) if bool(item.get('is_dir')): direct_dirs+=1 if child not in visited:queue.append(child) else: size=max(0,int(item.get('size') or 0));direct_files+=1;direct_bytes+=size files.append({'path':child,'parent_path':path,'name':item_name,'size':size,'modified':str(item.get('modified') or ''),'extension':pathlib.PurePosixPath(item_name).suffix.lower()}) directories.append({'path':path,'parent_path':'' if path=='/' else str(pathlib.PurePosixPath(path).parent) or '/','depth':0 if path=='/' else path.count('/'),'status':'done','direct_directories':direct_dirs,'direct_files':direct_files,'direct_bytes':direct_bytes,'subtree_files':0,'subtree_bytes':0,'last_error':''}) total=len(visited)+len(queue);progress=10+(65*len(visited)//total if total else 65);last=max(last,progress) if len(visited)%25==0 or not queue:emit('scanning',last,f"正在扫描目录:已完成 {len(visited)} 个文件夹,待处理 {len(queue)} 个",len(visited),total,current_directory=sanitize_directory(path),scanned_directories=len(visited),discovered_directories=total,scanned_files=len(files),estimated_total_files=len(files),total_is_estimate=True) dir_buf=io.StringIO();file_buf=io.StringIO();dw=csv.DictWriter(dir_buf,fieldnames=list(directories[0]));dw.writeheader();dw.writerows(directories);fw=csv.DictWriter(file_buf,fieldnames=['path','parent_path','name','size','modified','extension']);fw.writeheader();fw.writerows(files);dir_buf.seek(0);file_buf.seek(0) emit('building',80,'正在校验AList父子目录和文件数量',len(visited),len(visited)) slug={'国内工业':'domestic-industrial','海外工业':'global-industrial','国内教育':'domestic-education','海外教育':'global-education'}[name] return build_alist_source(slug,name,dir_buf,file_buf) def main(): parser=argparse.ArgumentParser();parser.add_argument('--connection-id',required=True);parser.add_argument('--source-type',choices=('pp','dingtalk','alist'),required=True);parser.add_argument('--source-name',required=True);parser.add_argument('--source-space-id',default='');args=parser.parse_args() global _source_name;_source_name=args.source_name try: if args.source_type=='pp':source=refresh_pp() elif args.source_type=='dingtalk':source=refresh_dingtalk(args.source_name,args.connection_id) else:source=refresh_alist(args.source_name) validate_source(source);emit('activating',92,'正在写入不可变快照并切换当前版本',source['node_count'],source['node_count']) run_psql(catalog_sql(source,args.connection_id,apply=True));emit('completed',100,f"实时快照完成:{source['folder_count']-1} 个目录,{source['file_count']} 份文件",source['node_count'],source['node_count']) except RefreshBlocked as error: emit('failed',0,f"{error};旧快照仍然保留") return 1 except Exception: emit('failed',0,'实时刷新发生内部错误;旧快照仍然保留') return 1 return 0 if __name__=='__main__':raise SystemExit(main())