mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
Allow configurable issue attachment limits
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
559a843a25
commit
e3869bda4c
17 files changed
+16020
-14
No files matched your search
@@ -10,6 +10,9 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
createAttachment: vi.fn(),
|
||||
getAttachmentById: vi.fn(),
|
||||
}));
|
||||
const mockCompanyService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
@@ -39,6 +42,7 @@ function registerRouteMocks() {
|
||||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
companyService: () => mockCompanyService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
@@ -180,6 +184,10 @@ describe("issue attachment routes", () => {
|
||||
registerRouteMocks();
|
||||
vi.clearAllMocks();
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockCompanyService.getById.mockResolvedValue({
|
||||
id: "company-1",
|
||||
attachmentMaxBytes: 1024 * 1024 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts zip uploads for issue attachments", async () => {
|
||||
@@ -215,6 +223,49 @@ describe("issue attachment routes", () => {
|
||||
expect(res.body.contentType).toBe("application/zip");
|
||||
});
|
||||
|
||||
it("allows issue attachments larger than the legacy 10 MiB process default when company limit allows it", async () => {
|
||||
const storage = createStorageService();
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-1",
|
||||
});
|
||||
mockIssueService.createAttachment.mockResolvedValue(makeAttachment("application/octet-stream", "large.bin"));
|
||||
|
||||
const app = await createApp(storage);
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
|
||||
.attach("file", Buffer.alloc(10 * 1024 * 1024 + 1), {
|
||||
filename: "large.bin",
|
||||
contentType: "application/octet-stream",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(storage.__calls.putFile?.body.length).toBe(10 * 1024 * 1024 + 1);
|
||||
});
|
||||
|
||||
it("enforces the configured per-company issue attachment limit", async () => {
|
||||
const storage = createStorageService();
|
||||
mockCompanyService.getById.mockResolvedValue({
|
||||
id: "company-1",
|
||||
attachmentMaxBytes: 4,
|
||||
});
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-1",
|
||||
});
|
||||
|
||||
const app = await createApp(storage);
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
|
||||
.attach("file", Buffer.from("large"), { filename: "large.txt", contentType: "text/plain" });
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("Attachment exceeds 4 bytes");
|
||||
expect(mockIssueService.createAttachment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves html attachments as downloads with nosniff", async () => {
|
||||
const storage = createStorageService();
|
||||
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("text/html", "report.html"));
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
* - Exact types: "application/pdf"
|
||||
* - Wildcards: "image/*" or "application/vnd.openxmlformats-officedocument.*"
|
||||
*/
|
||||
import {
|
||||
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
|
||||
"image/png",
|
||||
@@ -92,3 +96,10 @@ export function isAllowedContentType(contentType: string): boolean {
|
||||
|
||||
export const MAX_ATTACHMENT_BYTES =
|
||||
Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
||||
|
||||
export function normalizeIssueAttachmentMaxBytes(value: number | null | undefined): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES;
|
||||
}
|
||||
return Math.min(Math.floor(value), MAX_COMPANY_ATTACHMENT_MAX_BYTES);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import * as serviceIndex from "../services/index.js";
|
||||
import {
|
||||
accessService,
|
||||
agentService,
|
||||
companyService,
|
||||
executionWorkspaceService,
|
||||
goalService,
|
||||
heartbeatService,
|
||||
@@ -65,7 +66,7 @@ import {
|
||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||
import {
|
||||
isInlineAttachmentContentType,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
normalizeIssueAttachmentMaxBytes,
|
||||
normalizeContentType,
|
||||
SVG_CONTENT_TYPE,
|
||||
} from "../attachment-types.js";
|
||||
@@ -404,6 +405,7 @@ export function issueRoutes(
|
||||
pluginWorkerManager: opts.pluginWorkerManager,
|
||||
});
|
||||
const feedback = feedbackService(db);
|
||||
const companiesSvc = companyService(db);
|
||||
const instanceSettings = instanceSettingsService(db);
|
||||
const agentsSvc = agentService(db);
|
||||
const projectsSvc = projectService(db);
|
||||
@@ -427,11 +429,6 @@ export function issueRoutes(
|
||||
};
|
||||
const feedbackExportService = opts?.feedbackExportService;
|
||||
const environmentsSvc = environmentService(db);
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||
});
|
||||
|
||||
function withContentPath<T extends { id: string }>(attachment: T) {
|
||||
return {
|
||||
...attachment,
|
||||
@@ -493,7 +490,11 @@ export function issueRoutes(
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function runSingleFileUpload(req: Request, res: Response) {
|
||||
async function runSingleFileUpload(req: Request, res: Response, fileSizeLimit: number) {
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: fileSizeLimit, files: 1 },
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upload.single("file")(req, res, (err: unknown) => {
|
||||
if (err) reject(err);
|
||||
@@ -3692,12 +3693,15 @@ export function issueRoutes(
|
||||
}
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
|
||||
const company = await companiesSvc.getById(companyId);
|
||||
const attachmentMaxBytes = normalizeIssueAttachmentMaxBytes(company?.attachmentMaxBytes);
|
||||
|
||||
try {
|
||||
await runSingleFileUpload(req, res);
|
||||
await runSingleFileUpload(req, res, attachmentMaxBytes);
|
||||
} catch (err) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
res.status(422).json({ error: `Attachment exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
|
||||
res.status(422).json({ error: `Attachment exceeds ${attachmentMaxBytes} bytes` });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ error: err.message });
|
||||
|
||||
@@ -45,6 +45,7 @@ export function companyService(db: Db) {
|
||||
issueCounter: companies.issueCounter,
|
||||
budgetMonthlyCents: companies.budgetMonthlyCents,
|
||||
spentMonthlyCents: companies.spentMonthlyCents,
|
||||
attachmentMaxBytes: companies.attachmentMaxBytes,
|
||||
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
|
||||
feedbackDataSharingEnabled: companies.feedbackDataSharingEnabled,
|
||||
feedbackDataSharingConsentAt: companies.feedbackDataSharingConsentAt,
|
||||
|
||||
@@ -2428,6 +2428,10 @@ function buildManifestFromPackageFiles(
|
||||
description: asString(companyFrontmatter.description),
|
||||
brandColor: asString(paperclipCompany.brandColor),
|
||||
logoPath: asString(paperclipCompany.logoPath) ?? asString(paperclipCompany.logo),
|
||||
attachmentMaxBytes:
|
||||
typeof paperclipCompany.attachmentMaxBytes === "number" && Number.isFinite(paperclipCompany.attachmentMaxBytes)
|
||||
? Math.max(1, Math.floor(paperclipCompany.attachmentMaxBytes))
|
||||
: null,
|
||||
requireBoardApprovalForNewAgents:
|
||||
typeof paperclipCompany.requireBoardApprovalForNewAgents === "boolean"
|
||||
? paperclipCompany.requireBoardApprovalForNewAgents
|
||||
@@ -3465,6 +3469,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
company: stripEmptyValues({
|
||||
brandColor: company.brandColor ?? null,
|
||||
logoPath: companyLogoPath,
|
||||
attachmentMaxBytes: company.attachmentMaxBytes,
|
||||
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? true : undefined,
|
||||
feedbackDataSharingEnabled: company.feedbackDataSharingEnabled ? true : undefined,
|
||||
feedbackDataSharingConsentAt: company.feedbackDataSharingConsentAt?.toISOString() ?? null,
|
||||
@@ -3963,6 +3968,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
id: string;
|
||||
name: string;
|
||||
requireBoardApprovalForNewAgents?: boolean | null;
|
||||
attachmentMaxBytes?: number | null;
|
||||
} | null = null;
|
||||
let companyAction: "created" | "updated" | "unchanged" = "unchanged";
|
||||
|
||||
@@ -3985,6 +3991,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
name: companyName,
|
||||
description: include.company ? (sourceManifest.company?.description ?? null) : null,
|
||||
brandColor: include.company ? (sourceManifest.company?.brandColor ?? null) : null,
|
||||
attachmentMaxBytes: include.company
|
||||
? (sourceManifest.company?.attachmentMaxBytes ?? undefined)
|
||||
: undefined,
|
||||
requireBoardApprovalForNewAgents: include.company
|
||||
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? false)
|
||||
: false,
|
||||
@@ -4016,6 +4025,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
name: sourceManifest.company.name,
|
||||
description: sourceManifest.company.description,
|
||||
brandColor: sourceManifest.company.brandColor,
|
||||
attachmentMaxBytes: sourceManifest.company.attachmentMaxBytes ?? undefined,
|
||||
requireBoardApprovalForNewAgents: sourceManifest.company.requireBoardApprovalForNewAgents,
|
||||
feedbackDataSharingEnabled: sourceManifest.company.feedbackDataSharingEnabled,
|
||||
feedbackDataSharingConsentAt: sourceManifest.company.feedbackDataSharingConsentAt
|
||||
|
||||
Reference in new issue
Block a user