21 lines
6.4 KiB
TypeScript
21 lines
6.4 KiB
TypeScript
import { Router, type Request, type Response } from 'express';
|
|
import { getActivePublishedBySlug, listActivePublishedRows, type PublishedAppRow } from '../db/publishedApps';
|
|
import { executeRequest, ALLOWED_METHODS, type RestActionInput } from './proxy';
|
|
import { AuthenticationError } from '../lib/authentication';
|
|
import { ProxyPolicyError, sanitizeUrl } from '../lib/proxyPolicy';
|
|
import { recordExecution, type ExecutionOutcome } from '../db/executions';
|
|
|
|
const router=Router();
|
|
type SnapshotPage={id:string;components:Array<{name:string}>};
|
|
type Snapshot={schemaVersion:string;project:{id:string;name:string;description?:string;pages:SnapshotPage[];actions:RestActionInput[];bindings:unknown[];variables:Record<string,{type:string;defaultValue?:unknown;scope?:'global'|'page';pageId?:string}>;settings?:Record<string,unknown>}};
|
|
const canAccess=(row:PublishedAppRow,req:Request)=>row.visibility==='public'||!!req.principal;
|
|
function publicDocument(snapshot:Snapshot):Snapshot { return {...snapshot,project:{...snapshot.project,actions:snapshot.project.actions.map((action)=>{const refs=referencedNames(action);return{id:action.id,name:action.name,description:action.description,method:action.method,url:'published://server-owned',headers:{},queryParameters:{},pathParameters:{},bodyTemplate:'',authenticationType:'anonymous',runtimeInputComponents:[...refs.components],runtimeInputVariables:[...refs.variables]};})}}; }
|
|
function runtimeDto(row:PublishedAppRow){const snapshot=JSON.parse(row.snapshot_json) as Snapshot;return {slug:row.slug,displayName:row.display_name,description:row.description,visibility:row.visibility,version:row.version,document:publicDocument(snapshot)};}
|
|
function referencedNames(action:RestActionInput){const components=new Set<string>(),variables=new Set<string>();const visit=(value:unknown)=>{if(typeof value==='string')for(const match of value.matchAll(/\{\{(components|variables)\.([^.}]+)(?:\.value)?\}\}/g))(match[1]==='components'?components:variables).add(match[2]);else if(value&&typeof value==='object')Object.values(value).forEach(visit);};visit(action);return{components,variables};}
|
|
function renderAction(action:RestActionInput,body:unknown):RestActionInput {const input=body&&typeof body==='object'?body as Record<string,unknown>:{};const componentValues=input.componentValues&&typeof input.componentValues==='object'?input.componentValues as Record<string,unknown>:{};const variableValues=input.variableValues&&typeof input.variableValues==='object'?input.variableValues as Record<string,unknown>:{};const refs=referencedNames(action);for(const key of Object.keys(componentValues))if(!refs.components.has(key))throw new ProxyPolicyError('PUBLISHED_INPUT_FORBIDDEN',`Runtime component value "${key}" is not referenced by this action.`,400);for(const key of Object.keys(variableValues))if(!refs.variables.has(key))throw new ProxyPolicyError('PUBLISHED_INPUT_FORBIDDEN',`Runtime variable "${key}" is not referenced by this action.`,400);const valueText=(value:unknown)=>typeof value==='string'?value:value===undefined?'':JSON.stringify(value);const replace=(value:string)=>value.replace(/\{\{components\.([^.}]+)\.value\}\}/g,(_m,key)=>valueText(componentValues[key])).replace(/\{\{variables\.([^.}]+)\}\}/g,(_m,key)=>valueText(variableValues[key]));const map=(value:Record<string,string>|undefined)=>Object.fromEntries(Object.entries(value??{}).map(([key,val])=>[key,replace(val)]));return{...action,url:replace(action.url??''),headers:map(action.headers),queryParameters:map(action.queryParameters),pathParameters:map(action.pathParameters),bodyTemplate:replace(action.bodyTemplate??'')};}
|
|
|
|
router.get('/',(req,res)=>{const rows=listActivePublishedRows().filter((row)=>canAccess(row,req));res.json(rows.map((row)=>({slug:row.slug,displayName:row.display_name,description:row.description,visibility:row.visibility,version:row.version})));});
|
|
router.post('/:slug/actions/:actionId/execute',async(req:Request,res:Response)=>{const row=getActivePublishedBySlug(req.params.slug);if(!row){res.status(404).json({code:'PUBLISHED_APP_NOT_FOUND',error:'Published application not found.'});return;}if(!canAccess(row,req)){res.status(401).json({code:'AUTH_REQUIRED',error:'Authentication is required.'});return;}const snapshot=JSON.parse(row.snapshot_json) as Snapshot;const action=snapshot.project.actions.find((item)=>item.id===req.params.actionId);if(!action){res.status(404).json({code:'PUBLISHED_ACTION_NOT_FOUND',error:'Published action not found.'});return;}const method=(action.method??'').toUpperCase();if(!ALLOWED_METHODS.has(method)){res.status(400).json({code:'PUBLISHED_ACTION_INVALID',error:'Published action method is invalid.'});return;}const started=Date.now();const base={projectId:snapshot.project.id,projectName:snapshot.project.name,publishedAppId:row.id,publishedVersion:row.version,userId:req.principal?.userId,actionId:action.id,actionName:action.name,method,url:sanitizeUrl(action.url??'')};try{const rendered=renderAction(action,req.body);const result=await executeRequest({...rendered,method});const previewText=typeof result.body==='string'?result.body:JSON.stringify(result.body);const preview=previewText.slice(0,2048);const bytes=Buffer.byteLength(previewText,'utf8');recordExecution({...base,status:result.status,durationMs:result.durationMs,outcome:result.ok?'success':'upstream_error',preview,responseBytes:bytes,previewTruncated:bytes>Buffer.byteLength(preview,'utf8'),contentType:result.headers['content-type']});res.json(result);}catch(error){const auth=error instanceof AuthenticationError;const policy=error instanceof ProxyPolicyError?error:new ProxyPolicyError('PROXY_NETWORK_ERROR','The upstream destination could not be reached.',502);const status=auth?error.status:policy.status;const code=auth?error.code:policy.code;const message=auth?error.message:policy.message;let outcome:ExecutionOutcome='network_error';if(code==='PROXY_TIMEOUT')outcome='timeout';else if(code.includes('TOO_LARGE'))outcome='size_limited';else if(status===400||code.includes('FORBIDDEN')||auth)outcome='policy_rejected';recordExecution({...base,durationMs:Date.now()-started,outcome,errorCode:code,errorMessage:message});res.status(status).json({code,error:message});}});
|
|
router.get('/:slug',(req,res)=>{const row=getActivePublishedBySlug(req.params.slug);if(!row){res.status(404).json({code:'PUBLISHED_APP_NOT_FOUND',error:'Published application not found.'});return;}if(!canAccess(row,req)){res.status(401).json({code:'AUTH_REQUIRED',error:'Authentication is required.'});return;}res.json(runtimeDto(row));});
|
|
export default router;
|