54 lines
4.2 KiB
TypeScript
54 lines
4.2 KiB
TypeScript
import {z} from 'zod';
|
|
import {PDFDocument} from 'pdf-lib';
|
|
import type {RecordModel} from 'pocketbase';
|
|
import type {DocumentReference,PurchasingContext} from './types';
|
|
import {PurchasingError} from './errors';
|
|
export const RecordId=z.string().regex(/^[a-z0-9]{15}$/);
|
|
export const DocumentReferenceSchema=z.object({kind:z.enum(['po','receipt','source','page','candidate']),id:RecordId}).strict();
|
|
export function parseDocumentReference(input:unknown):DocumentReference{return DocumentReferenceSchema.parse(input);}
|
|
export function safeAttachmentName(filename:string):string{return filename.split(/[\\/]/).pop()!.replace(/[\x00-\x1f\x7f"<>]/g,'').slice(0,200)||'document';}
|
|
export function documentDownloadUrl(reference:DocumentReference):string {
|
|
parseDocumentReference(reference);
|
|
const base=new URL(process.env.MCP_PUBLIC_URL||'');
|
|
return new URL(`/api/mcp/documents/${reference.kind}/${reference.id}`,base).toString();
|
|
}
|
|
const MAX_BYTES=20*1024*1024;
|
|
async function boundedBytes(response:Response):Promise<Uint8Array>{
|
|
if(!response.ok)throw new PurchasingError('document_unavailable',502);
|
|
if(Number(response.headers.get('content-length'))>MAX_BYTES)throw new PurchasingError('document_too_large',413);
|
|
if(!response.body)throw new PurchasingError('document_empty',502);
|
|
const reader=response.body.getReader(),parts:Uint8Array[]=[];let size=0;
|
|
try{for(;;){const next=await reader.read();if(next.done)break;size+=next.value.byteLength;if(size>MAX_BYTES){await reader.cancel();throw new PurchasingError('document_too_large',413);}parts.push(next.value);}}finally{reader.releaseLock();}
|
|
return new Uint8Array(Buffer.concat(parts));
|
|
}
|
|
export async function loadDocument(ctx:PurchasingContext,reference:DocumentReference):Promise<{bytes:Uint8Array;mime:string;filename:string}>{
|
|
const ref=parseDocumentReference(reference);
|
|
if(ref.kind==='candidate'){
|
|
const note=await ctx.pb.collection('dn_batch_notes').getOne(ref.id,{fields:'id,batch,pages'});
|
|
const saved:Array<number>=Array.isArray(note.pages)?note.pages:[];
|
|
if(!saved.length||saved.length>100||new Set(saved).size!==saved.length)throw new PurchasingError('invalid_candidate_pages');
|
|
const rows=await ctx.pb.collection('dn_batch_pages').getFullList({filter:ctx.pb.filter('batch={:batch}',{batch:note.batch}),fields:'id,batch,global_page,image'});
|
|
const pdf=await PDFDocument.create();let total=0;
|
|
for(const page of saved){
|
|
const matches=rows.filter(row=>row.batch===note.batch&&row.global_page===page);
|
|
if(matches.length!==1)throw new PurchasingError('candidate_source_page_missing',404);
|
|
const source=await loadDocument(ctx,{kind:'page',id:matches[0].id});total+=source.bytes.length;
|
|
if(total>MAX_BYTES)throw new PurchasingError('document_too_large',413);
|
|
const image=source.mime==='image/jpeg'?await pdf.embedJpg(source.bytes):source.mime==='image/png'?await pdf.embedPng(source.bytes):null;
|
|
if(!image)throw new PurchasingError('source_page_type_unsupported');
|
|
const sheet=pdf.addPage([image.width,image.height]);sheet.drawImage(image,{x:0,y:0,width:image.width,height:image.height});
|
|
}
|
|
const bytes=await pdf.save();if(bytes.length>MAX_BYTES)throw new PurchasingError('document_too_large',413);
|
|
return {bytes,mime:'application/pdf',filename:`delivery-${ref.id}.pdf`};
|
|
}
|
|
const mapping={po:['purchase_orders','purchase_order_file'],receipt:['delivery_notes','slip_image'],source:['dn_batch_sources','file'],page:['dn_batch_pages','image']} as const;
|
|
const [collection,field]=mapping[ref.kind];
|
|
const record=await ctx.pb.collection(collection).getOne(ref.id,{fields:`id,collectionId,${field}`});
|
|
const filename=record[field];if(typeof filename!=='string'||!filename)throw new PurchasingError('document_not_found',404);
|
|
const url=ctx.pb.files.getURL({...record,collectionName:collection} as RecordModel,filename,{token:await ctx.pb.files.getToken()});
|
|
const upstream=await fetch(url,{headers:{Authorization:ctx.pb.authStore.token},signal:AbortSignal.timeout(60000)});
|
|
const mime=(upstream.headers.get('content-type')||'application/octet-stream').split(';')[0].toLowerCase();
|
|
const allowed=new Set(['application/pdf','image/png','image/jpeg','image/webp']);
|
|
const bytes=await boundedBytes(upstream);
|
|
return {bytes,mime:allowed.has(mime)?mime:'application/octet-stream',filename:safeAttachmentName(filename)};
|
|
}
|