mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
Route native call_api through existing agent REST permissions. Preserve admitted credentials across restarts, protect sensitive replies and private snapshots, and compare saved completion with provider history through the same redaction projection. Add permission, privacy and recovery regressions and update the access map and run-log guide. Co-Authored-By: Paperclip <noreply@paperclip.ing>
381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
import { canActorReadAssetContent } from "../services/asset-content-access.js";
|
|
import { Router, type Request, type Response } from "express";
|
|
import multer from "multer";
|
|
import createDOMPurify from "dompurify";
|
|
import { JSDOM } from "jsdom";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { ASSET_NAMESPACE_RULE, createAssetImageMetadataSchema } from "@paperclipai/shared";
|
|
import type { StorageService } from "../storage/types.js";
|
|
import { assetService, logActivity } from "../services/index.js";
|
|
import {
|
|
formatAttachmentSize,
|
|
isAllowedContentType,
|
|
isInlineAttachmentContentType,
|
|
MAX_ATTACHMENT_BYTES,
|
|
} from "../attachment-types.js";
|
|
import { assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
|
|
const SVG_CONTENT_TYPE = "image/svg+xml";
|
|
const ALLOWED_COMPANY_LOGO_CONTENT_TYPES = new Set([
|
|
"image/png",
|
|
"image/jpeg",
|
|
"image/jpg",
|
|
"image/webp",
|
|
"image/gif",
|
|
SVG_CONTENT_TYPE,
|
|
]);
|
|
|
|
function sanitizeSvgBuffer(input: Buffer): Buffer | null {
|
|
const raw = input.toString("utf8").trim();
|
|
if (!raw) return null;
|
|
|
|
const baseDom = new JSDOM("");
|
|
const domPurify = createDOMPurify(
|
|
baseDom.window as unknown as Parameters<typeof createDOMPurify>[0],
|
|
);
|
|
domPurify.addHook("uponSanitizeAttribute", (_node, data) => {
|
|
const attrName = data.attrName.toLowerCase();
|
|
const attrValue = (data.attrValue ?? "").trim();
|
|
|
|
if (attrName.startsWith("on")) {
|
|
data.keepAttr = false;
|
|
return;
|
|
}
|
|
|
|
if ((attrName === "href" || attrName === "xlink:href") && attrValue && !attrValue.startsWith("#")) {
|
|
data.keepAttr = false;
|
|
}
|
|
});
|
|
|
|
let parsedDom: JSDOM | null = null;
|
|
try {
|
|
const sanitized = domPurify.sanitize(raw, {
|
|
USE_PROFILES: { svg: true, svgFilters: true, html: false },
|
|
FORBID_TAGS: ["script", "foreignObject"],
|
|
FORBID_CONTENTS: ["script", "foreignObject"],
|
|
RETURN_TRUSTED_TYPE: false,
|
|
});
|
|
|
|
parsedDom = new JSDOM(sanitized, { contentType: SVG_CONTENT_TYPE });
|
|
const document = parsedDom.window.document;
|
|
const root = document.documentElement;
|
|
if (!root || root.tagName.toLowerCase() !== "svg") return null;
|
|
|
|
for (const el of Array.from(root.querySelectorAll("script, foreignObject"))) {
|
|
el.remove();
|
|
}
|
|
for (const el of Array.from(root.querySelectorAll("*"))) {
|
|
for (const attr of Array.from(el.attributes)) {
|
|
const attrName = attr.name.toLowerCase();
|
|
const attrValue = attr.value.trim();
|
|
if (attrName.startsWith("on")) {
|
|
el.removeAttribute(attr.name);
|
|
continue;
|
|
}
|
|
if ((attrName === "href" || attrName === "xlink:href") && attrValue && !attrValue.startsWith("#")) {
|
|
el.removeAttribute(attr.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
const output = root.outerHTML.trim();
|
|
if (!output || !/^<svg[\s>]/i.test(output)) return null;
|
|
return Buffer.from(output, "utf8");
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
parsedDom?.window.close();
|
|
baseDom.window.close();
|
|
}
|
|
}
|
|
|
|
export function assetRoutes(db: Db, storage: StorageService) {
|
|
const router = Router();
|
|
const svc = assetService(db);
|
|
const assetUpload = multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
|
});
|
|
const companyLogoUpload = multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
|
});
|
|
|
|
async function runSingleFileUpload(
|
|
upload: ReturnType<typeof multer>,
|
|
req: Request,
|
|
res: Response,
|
|
) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
upload.single("file")(req, res, (err: unknown) => {
|
|
if (err) reject(err);
|
|
else resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
router.post("/companies/:companyId/assets/images", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
|
|
try {
|
|
await runSingleFileUpload(assetUpload, req, res);
|
|
} catch (err) {
|
|
if (err instanceof multer.MulterError) {
|
|
if (err.code === "LIMIT_FILE_SIZE") {
|
|
res.status(422).json({
|
|
error: `File is larger than the ${formatAttachmentSize(MAX_ATTACHMENT_BYTES)} limit`,
|
|
});
|
|
return;
|
|
}
|
|
res.status(400).json({ error: err.message });
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const file = (req as Request & { file?: { mimetype: string; buffer: Buffer; originalname: string } }).file;
|
|
if (!file) {
|
|
res.status(400).json({ error: "Missing file field 'file'" });
|
|
return;
|
|
}
|
|
|
|
const parsedMeta = createAssetImageMetadataSchema.safeParse(req.body ?? {});
|
|
if (!parsedMeta.success) {
|
|
res.status(400).json({
|
|
error: `Invalid image metadata: ${ASSET_NAMESPACE_RULE}`,
|
|
details: parsedMeta.error.issues,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const namespaceSuffix = parsedMeta.data.namespace ?? "general";
|
|
const contentType = (file.mimetype || "").toLowerCase();
|
|
if (contentType !== SVG_CONTENT_TYPE && !isAllowedContentType(contentType)) {
|
|
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
|
|
return;
|
|
}
|
|
let fileBody = file.buffer;
|
|
if (contentType === SVG_CONTENT_TYPE) {
|
|
const sanitized = sanitizeSvgBuffer(file.buffer);
|
|
if (!sanitized || sanitized.length <= 0) {
|
|
res.status(422).json({ error: "SVG could not be sanitized" });
|
|
return;
|
|
}
|
|
fileBody = sanitized;
|
|
}
|
|
if (fileBody.length <= 0) {
|
|
res.status(422).json({ error: "Image is empty" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
const stored = await storage.putFile({
|
|
companyId,
|
|
namespace: `assets/${namespaceSuffix}`,
|
|
originalFilename: file.originalname || null,
|
|
contentType,
|
|
body: fileBody,
|
|
});
|
|
|
|
const asset = await svc.create(companyId, {
|
|
provider: stored.provider,
|
|
objectKey: stored.objectKey,
|
|
contentType: stored.contentType,
|
|
byteSize: stored.byteSize,
|
|
sha256: stored.sha256,
|
|
originalFilename: stored.originalFilename,
|
|
createdByAgentId: actor.agentId,
|
|
createdByUserId: actor.actorType === "user" ? actor.actorId : req.actor.onBehalfOfUserId ?? null,
|
|
});
|
|
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "asset.created",
|
|
entityType: "asset",
|
|
entityId: asset.id,
|
|
details: {
|
|
...(namespaceSuffix.startsWith("issues/") ? {} : { originalFilename: asset.originalFilename }),
|
|
contentType: asset.contentType,
|
|
byteSize: asset.byteSize,
|
|
},
|
|
});
|
|
|
|
res.status(201).json({
|
|
assetId: asset.id,
|
|
companyId: asset.companyId,
|
|
provider: asset.provider,
|
|
objectKey: asset.objectKey,
|
|
contentType: asset.contentType,
|
|
byteSize: asset.byteSize,
|
|
sha256: asset.sha256,
|
|
originalFilename: asset.originalFilename,
|
|
createdByAgentId: asset.createdByAgentId,
|
|
createdByUserId: asset.createdByUserId,
|
|
createdAt: asset.createdAt,
|
|
updatedAt: asset.updatedAt,
|
|
contentPath: `/api/assets/${asset.id}/content`,
|
|
});
|
|
});
|
|
|
|
router.post("/companies/:companyId/logo", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
|
|
try {
|
|
await runSingleFileUpload(companyLogoUpload, req, res);
|
|
} catch (err) {
|
|
if (err instanceof multer.MulterError) {
|
|
if (err.code === "LIMIT_FILE_SIZE") {
|
|
res.status(422).json({
|
|
error: `Image is larger than the ${formatAttachmentSize(MAX_ATTACHMENT_BYTES)} limit`,
|
|
});
|
|
return;
|
|
}
|
|
res.status(400).json({ error: err.message });
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const file = (req as Request & { file?: { mimetype: string; buffer: Buffer; originalname: string } }).file;
|
|
if (!file) {
|
|
res.status(400).json({ error: "Missing file field 'file'" });
|
|
return;
|
|
}
|
|
|
|
const contentType = (file.mimetype || "").toLowerCase();
|
|
if (!ALLOWED_COMPANY_LOGO_CONTENT_TYPES.has(contentType)) {
|
|
res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` });
|
|
return;
|
|
}
|
|
|
|
let fileBody = file.buffer;
|
|
if (contentType === SVG_CONTENT_TYPE) {
|
|
const sanitized = sanitizeSvgBuffer(file.buffer);
|
|
if (!sanitized || sanitized.length <= 0) {
|
|
res.status(422).json({ error: "SVG could not be sanitized" });
|
|
return;
|
|
}
|
|
fileBody = sanitized;
|
|
}
|
|
|
|
if (fileBody.length <= 0) {
|
|
res.status(422).json({ error: "Image is empty" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
const stored = await storage.putFile({
|
|
companyId,
|
|
namespace: "assets/companies",
|
|
originalFilename: file.originalname || null,
|
|
contentType,
|
|
body: fileBody,
|
|
});
|
|
|
|
const asset = await svc.create(companyId, {
|
|
provider: stored.provider,
|
|
objectKey: stored.objectKey,
|
|
contentType: stored.contentType,
|
|
byteSize: stored.byteSize,
|
|
sha256: stored.sha256,
|
|
originalFilename: stored.originalFilename,
|
|
createdByAgentId: actor.agentId,
|
|
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
});
|
|
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "asset.created",
|
|
entityType: "asset",
|
|
entityId: asset.id,
|
|
details: {
|
|
originalFilename: asset.originalFilename,
|
|
contentType: asset.contentType,
|
|
byteSize: asset.byteSize,
|
|
namespace: "assets/companies",
|
|
},
|
|
});
|
|
|
|
res.status(201).json({
|
|
assetId: asset.id,
|
|
companyId: asset.companyId,
|
|
provider: asset.provider,
|
|
objectKey: asset.objectKey,
|
|
contentType: asset.contentType,
|
|
byteSize: asset.byteSize,
|
|
sha256: asset.sha256,
|
|
originalFilename: asset.originalFilename,
|
|
createdByAgentId: asset.createdByAgentId,
|
|
createdByUserId: asset.createdByUserId,
|
|
createdAt: asset.createdAt,
|
|
updatedAt: asset.updatedAt,
|
|
contentPath: `/api/assets/${asset.id}/content`,
|
|
});
|
|
});
|
|
|
|
router.get("/assets/:assetId/content", async (req, res, next) => {
|
|
const assetId = req.params.assetId as string;
|
|
const asset = await getAccessibleResource(req, res, svc.getById(assetId), "Asset not found");
|
|
if (!asset) return;
|
|
if (!(await canActorReadAssetContent(db, req.actor, asset))) {
|
|
res.status(404).json({ error: "Asset not found" }); return;
|
|
}
|
|
|
|
// Use the persisted size only after resource authorization. Single ranges
|
|
// keep saved API text pages bounded all the way to disk or object storage.
|
|
const rawRange = req.headers.range;
|
|
const rangeSyntax = rawRange && /^bytes=(\d*)-(\d*)$/i.exec(rawRange);
|
|
const emptyRead = asset.byteSize === 0 && rangeSyntax?.[1] === "0";
|
|
const ranges = rawRange && !emptyRead ? req.range(asset.byteSize) : undefined;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
if (/^[a-f0-9]{64}$/.test(asset.sha256)) res.setHeader("ETag", `"${asset.sha256}"`);
|
|
if (rawRange && (!rangeSyntax || (!rangeSyntax[1] && !rangeSyntax[2])
|
|
|| (!emptyRead && (!Array.isArray(ranges) || ranges.length !== 1)))) {
|
|
res.setHeader("Content-Range", `bytes */${asset.byteSize}`);
|
|
res.status(416).end();
|
|
return;
|
|
}
|
|
const range = Array.isArray(ranges) ? ranges[0] : undefined;
|
|
const object = await storage.getObject(asset.companyId, asset.objectKey, range ? { range } : undefined);
|
|
const responseContentType = asset.contentType || object.contentType || "application/octet-stream";
|
|
const mediaType = responseContentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
const inlineSafe = mediaType !== SVG_CONTENT_TYPE
|
|
&& isInlineAttachmentContentType(mediaType);
|
|
res.setHeader("Content-Type", responseContentType);
|
|
res.setHeader("Content-Length", String(range ? range.end - range.start + 1 : asset.byteSize || object.contentLength || 0));
|
|
if (range) {
|
|
res.status(206);
|
|
res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${asset.byteSize}`);
|
|
}
|
|
res.setHeader("Cache-Control", "private, no-store");
|
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
if (!inlineSafe) {
|
|
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'");
|
|
}
|
|
const filename = asset.originalFilename ?? "asset";
|
|
const disposition = inlineSafe
|
|
? "inline"
|
|
: "attachment";
|
|
res.setHeader("Content-Disposition", `${disposition}; filename=\"${filename.replaceAll("\"", "")}\"`);
|
|
|
|
object.stream.on("error", (err) => {
|
|
next(err);
|
|
});
|
|
res.on("close", () => object.stream.destroy());
|
|
object.stream.pipe(res);
|
|
});
|
|
|
|
return router;
|
|
}
|