Merge pull request #495 from subhendukundu/feat/configurable-attachment-types
feat: make attachment content types configurable via env var
This commit is contained in:
97
server/src/__tests__/attachment-types.test.ts
Normal file
97
server/src/__tests__/attachment-types.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
parseAllowedTypes,
|
||||||
|
matchesContentType,
|
||||||
|
DEFAULT_ALLOWED_TYPES,
|
||||||
|
} from "../attachment-types.js";
|
||||||
|
|
||||||
|
describe("parseAllowedTypes", () => {
|
||||||
|
it("returns default image types when input is undefined", () => {
|
||||||
|
expect(parseAllowedTypes(undefined)).toEqual([...DEFAULT_ALLOWED_TYPES]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns default image types when input is empty string", () => {
|
||||||
|
expect(parseAllowedTypes("")).toEqual([...DEFAULT_ALLOWED_TYPES]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses comma-separated types", () => {
|
||||||
|
expect(parseAllowedTypes("image/*,application/pdf")).toEqual([
|
||||||
|
"image/*",
|
||||||
|
"application/pdf",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace", () => {
|
||||||
|
expect(parseAllowedTypes(" image/png , application/pdf ")).toEqual([
|
||||||
|
"image/png",
|
||||||
|
"application/pdf",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lowercases entries", () => {
|
||||||
|
expect(parseAllowedTypes("Application/PDF")).toEqual(["application/pdf"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters empty segments", () => {
|
||||||
|
expect(parseAllowedTypes("image/png,,application/pdf,")).toEqual([
|
||||||
|
"image/png",
|
||||||
|
"application/pdf",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("matchesContentType", () => {
|
||||||
|
it("matches exact types", () => {
|
||||||
|
const patterns = ["application/pdf", "image/png"];
|
||||||
|
expect(matchesContentType("application/pdf", patterns)).toBe(true);
|
||||||
|
expect(matchesContentType("image/png", patterns)).toBe(true);
|
||||||
|
expect(matchesContentType("text/plain", patterns)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches /* wildcard patterns", () => {
|
||||||
|
const patterns = ["image/*"];
|
||||||
|
expect(matchesContentType("image/png", patterns)).toBe(true);
|
||||||
|
expect(matchesContentType("image/jpeg", patterns)).toBe(true);
|
||||||
|
expect(matchesContentType("image/svg+xml", patterns)).toBe(true);
|
||||||
|
expect(matchesContentType("application/pdf", patterns)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches .* wildcard patterns", () => {
|
||||||
|
const patterns = ["application/vnd.openxmlformats-officedocument.*"];
|
||||||
|
expect(
|
||||||
|
matchesContentType(
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
patterns,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
matchesContentType(
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
patterns,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(matchesContentType("application/pdf", patterns)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive", () => {
|
||||||
|
const patterns = ["application/pdf"];
|
||||||
|
expect(matchesContentType("APPLICATION/PDF", patterns)).toBe(true);
|
||||||
|
expect(matchesContentType("Application/Pdf", patterns)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines exact and wildcard patterns", () => {
|
||||||
|
const patterns = ["image/*", "application/pdf", "text/*"];
|
||||||
|
expect(matchesContentType("image/webp", patterns)).toBe(true);
|
||||||
|
expect(matchesContentType("application/pdf", patterns)).toBe(true);
|
||||||
|
expect(matchesContentType("text/csv", patterns)).toBe(true);
|
||||||
|
expect(matchesContentType("application/zip", patterns)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles plain * as allow-all wildcard", () => {
|
||||||
|
const patterns = ["*"];
|
||||||
|
expect(matchesContentType("image/png", patterns)).toBe(true);
|
||||||
|
expect(matchesContentType("application/pdf", patterns)).toBe(true);
|
||||||
|
expect(matchesContentType("text/plain", patterns)).toBe(true);
|
||||||
|
expect(matchesContentType("application/zip", patterns)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
server/src/attachment-types.ts
Normal file
68
server/src/attachment-types.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Shared attachment content-type configuration.
|
||||||
|
*
|
||||||
|
* By default only image types are allowed. Set the
|
||||||
|
* `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a
|
||||||
|
* comma-separated list of MIME types or wildcard patterns to expand the
|
||||||
|
* allowed set.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf
|
||||||
|
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf,text/*
|
||||||
|
*
|
||||||
|
* Supported pattern syntax:
|
||||||
|
* - Exact types: "application/pdf"
|
||||||
|
* - Wildcards: "image/*" or "application/vnd.openxmlformats-officedocument.*"
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/webp",
|
||||||
|
"image/gif",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a comma-separated list of MIME type patterns into a normalised array.
|
||||||
|
* Returns the default image-only list when the input is empty or undefined.
|
||||||
|
*/
|
||||||
|
export function parseAllowedTypes(raw: string | undefined): string[] {
|
||||||
|
if (!raw) return [...DEFAULT_ALLOWED_TYPES];
|
||||||
|
const parsed = raw
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
return parsed.length > 0 ? parsed : [...DEFAULT_ALLOWED_TYPES];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether `contentType` matches any entry in `allowedPatterns`.
|
||||||
|
*
|
||||||
|
* Supports exact matches ("application/pdf") and wildcard / prefix
|
||||||
|
* patterns ("image/*", "application/vnd.openxmlformats-officedocument.*").
|
||||||
|
*/
|
||||||
|
export function matchesContentType(contentType: string, allowedPatterns: string[]): boolean {
|
||||||
|
const ct = contentType.toLowerCase();
|
||||||
|
return allowedPatterns.some((pattern) => {
|
||||||
|
if (pattern === "*") return true;
|
||||||
|
if (pattern.endsWith("/*") || pattern.endsWith(".*")) {
|
||||||
|
return ct.startsWith(pattern.slice(0, -1));
|
||||||
|
}
|
||||||
|
return ct === pattern;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Module-level singletons read once at startup ----------
|
||||||
|
|
||||||
|
const allowedPatterns: string[] = parseAllowedTypes(
|
||||||
|
process.env.PAPERCLIP_ALLOWED_ATTACHMENT_TYPES,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Convenience wrapper using the process-level allowed list. */
|
||||||
|
export function isAllowedContentType(contentType: string): boolean {
|
||||||
|
return matchesContentType(contentType, allowedPatterns);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_ATTACHMENT_BYTES =
|
||||||
|
Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
||||||
@@ -5,22 +5,14 @@ import { createAssetImageMetadataSchema } from "@paperclipai/shared";
|
|||||||
import type { StorageService } from "../storage/types.js";
|
import type { StorageService } from "../storage/types.js";
|
||||||
import { assetService, logActivity } from "../services/index.js";
|
import { assetService, logActivity } from "../services/index.js";
|
||||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
|
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||||
const MAX_ASSET_IMAGE_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
|
||||||
const ALLOWED_IMAGE_CONTENT_TYPES = new Set([
|
|
||||||
"image/png",
|
|
||||||
"image/jpeg",
|
|
||||||
"image/jpg",
|
|
||||||
"image/webp",
|
|
||||||
"image/gif",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function assetRoutes(db: Db, storage: StorageService) {
|
export function assetRoutes(db: Db, storage: StorageService) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = assetService(db);
|
const svc = assetService(db);
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: { fileSize: MAX_ASSET_IMAGE_BYTES, files: 1 },
|
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
async function runSingleFileUpload(req: Request, res: Response) {
|
async function runSingleFileUpload(req: Request, res: Response) {
|
||||||
@@ -41,7 +33,7 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof multer.MulterError) {
|
if (err instanceof multer.MulterError) {
|
||||||
if (err.code === "LIMIT_FILE_SIZE") {
|
if (err.code === "LIMIT_FILE_SIZE") {
|
||||||
res.status(422).json({ error: `Image exceeds ${MAX_ASSET_IMAGE_BYTES} bytes` });
|
res.status(422).json({ error: `File exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.status(400).json({ error: err.message });
|
res.status(400).json({ error: err.message });
|
||||||
@@ -57,8 +49,8 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contentType = (file.mimetype || "").toLowerCase();
|
const contentType = (file.mimetype || "").toLowerCase();
|
||||||
if (!ALLOWED_IMAGE_CONTENT_TYPES.has(contentType)) {
|
if (!isAllowedContentType(contentType)) {
|
||||||
res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` });
|
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.buffer.length <= 0) {
|
if (file.buffer.length <= 0) {
|
||||||
|
|||||||
@@ -26,15 +26,7 @@ import { logger } from "../middleware/logger.js";
|
|||||||
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
||||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||||
|
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||||
const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
|
||||||
const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([
|
|
||||||
"image/png",
|
|
||||||
"image/jpeg",
|
|
||||||
"image/jpg",
|
|
||||||
"image/webp",
|
|
||||||
"image/gif",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function issueRoutes(db: Db, storage: StorageService) {
|
export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -1067,7 +1059,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const contentType = (file.mimetype || "").toLowerCase();
|
const contentType = (file.mimetype || "").toLowerCase();
|
||||||
if (!ALLOWED_ATTACHMENT_CONTENT_TYPES.has(contentType)) {
|
if (!isAllowedContentType(contentType)) {
|
||||||
res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
|
res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user