修复报销附件上传与中文文件名(07-28-travel-reimbursement-guide)
这个提交包含在:
@@ -4,6 +4,7 @@ const { AppError } = require("../../utils/AppError");
|
||||
const { sendData } = require("../../utils/reply");
|
||||
const { buildAuditLogData } = require("../../utils/audit");
|
||||
const { signPayload, verifyToken } = require("../../utils/signedUrl");
|
||||
const { buildAttachmentContentDisposition } = require("../../utils/contentDisposition");
|
||||
const { openReadStream, StorageNotFoundError } = require("../../services/storage");
|
||||
const { toMoneyString } = require("../../utils/workflow");
|
||||
const { resolveInvoiceAvailability } = require("../../services/invoiceGovernance");
|
||||
@@ -31,11 +32,6 @@ const invoiceStatusQuerySchema = z.object({
|
||||
ids: z.string().trim().min(1).max(2000),
|
||||
});
|
||||
|
||||
function sanitizeFilename(fileName) {
|
||||
if (!fileName) return "file";
|
||||
return fileName.replace(/[\r\n"]/g, "_");
|
||||
}
|
||||
|
||||
function parseInvoiceListQuery(query) {
|
||||
const parsed = invoiceListQuerySchema.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
@@ -634,7 +630,7 @@ function registerInvoicePoolReadRoutes(fastify) {
|
||||
reply.type(invoice.mime_type || "application/octet-stream");
|
||||
reply.header(
|
||||
"content-disposition",
|
||||
`inline; filename=\"${sanitizeFilename(invoice.original_name)}\"`,
|
||||
buildAttachmentContentDisposition(invoice.original_name, { disposition: "inline" }),
|
||||
);
|
||||
return reply.send(stream);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sha256Hex } = require("../utils/crypto");
|
||||
const { buildAttachmentContentDisposition } = require("../utils/contentDisposition");
|
||||
const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
const { findActiveDelegationForDelegate, listActiveDelegationPrincipals } = require("../utils/delegation");
|
||||
const { hasPermission, resolveEntityPermission } = require("../constants/permissions");
|
||||
@@ -293,11 +294,6 @@ function ensureApplicantOrAdmin(request, reimbursement) {
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeAttachmentFilename(fileName) {
|
||||
if (!fileName) return "file";
|
||||
return fileName.replace(/[\r\n"]/g, "_");
|
||||
}
|
||||
|
||||
function mapReimbursementAttachment(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -2481,7 +2477,12 @@ async function reimbursementRoutes(fastify) {
|
||||
const files = [];
|
||||
let governanceFields = {};
|
||||
try {
|
||||
for await (const part of request.parts()) {
|
||||
for await (const part of request.parts({
|
||||
limits: {
|
||||
files: 5,
|
||||
fileSize: fastify.config.maxUploadSizeBytes,
|
||||
},
|
||||
})) {
|
||||
if (part.type === "field") {
|
||||
governanceFields = parseMultipartGovernanceField(part, governanceFields);
|
||||
continue;
|
||||
@@ -2533,6 +2534,14 @@ async function reimbursementRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === "FST_FILES_LIMIT") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { file: "too_many_files", maxFiles: 5 },
|
||||
});
|
||||
}
|
||||
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
@@ -2809,7 +2818,7 @@ async function reimbursementRoutes(fastify) {
|
||||
reply.type(attachment.mime_type || "application/octet-stream");
|
||||
reply.header(
|
||||
"content-disposition",
|
||||
`inline; filename=\"${sanitizeAttachmentFilename(attachment.original_name)}\"`,
|
||||
buildAttachmentContentDisposition(attachment.original_name, { disposition: "inline" }),
|
||||
);
|
||||
return reply.send(stream);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const sharp = require("sharp");
|
||||
const { buildAttachmentContentDisposition: buildContentDisposition } = require("../utils/contentDisposition");
|
||||
|
||||
const PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS = 40_000_000;
|
||||
const PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS = 180;
|
||||
@@ -393,50 +394,11 @@ function buildAttachmentStorageKey(purchaseRequestId, extension) {
|
||||
return `purchase-requests/${purchaseRequestId.toLowerCase()}/${crypto.randomUUID()}${extension}`;
|
||||
}
|
||||
|
||||
function sanitizeHeaderFileName(fileName) {
|
||||
const source = typeof fileName === "string" && fileName.trim()
|
||||
? fileName.normalize("NFC").trim()
|
||||
: "attachment.bin";
|
||||
const sanitized = truncateCodePoints(
|
||||
source
|
||||
.replace(/[\\/]/gu, "_")
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "_")
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/gu, "_")
|
||||
.replace(/"+/gu, "_")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim(),
|
||||
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS,
|
||||
);
|
||||
return sanitized && sanitized !== "." && sanitized !== ".."
|
||||
? sanitized
|
||||
: "attachment.bin";
|
||||
}
|
||||
|
||||
function buildAsciiFallback(fileName) {
|
||||
const lastDotIndex = fileName.lastIndexOf(".");
|
||||
const rawExtension = lastDotIndex > 0 ? fileName.slice(lastDotIndex).toLowerCase() : ".bin";
|
||||
const extension = /^\.[a-z0-9]{1,10}$/u.test(rawExtension) ? rawExtension : ".bin";
|
||||
const rawStem = lastDotIndex > 0 ? fileName.slice(0, lastDotIndex) : fileName;
|
||||
const asciiStem = rawStem
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/gu, "")
|
||||
.replace(/[^a-zA-Z0-9._-]+/gu, "_")
|
||||
.replace(/_+/gu, "_")
|
||||
.replace(/^[._-]+|[._-]+$/gu, "")
|
||||
.slice(0, 100);
|
||||
return `${asciiStem || "attachment"}${extension}`;
|
||||
}
|
||||
|
||||
function encodeRfc5987Value(value) {
|
||||
return encodeURIComponent(value).replace(/[\u0027\u0028\u0029\u002a]/gu, (character) =>
|
||||
`%${character.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildAttachmentContentDisposition(fileName) {
|
||||
const safeFileName = sanitizeHeaderFileName(fileName);
|
||||
const asciiFallback = buildAsciiFallback(safeFileName);
|
||||
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeRfc5987Value(safeFileName)}`;
|
||||
function buildAttachmentContentDisposition(fileName, options = {}) {
|
||||
return buildContentDisposition(fileName, {
|
||||
...options,
|
||||
maxCodePoints: PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
const DEFAULT_FILE_NAME = "attachment.bin";
|
||||
const DEFAULT_MAX_FILE_NAME_CODE_POINTS = 180;
|
||||
const ALLOWED_DISPOSITIONS = new Set(["attachment", "inline"]);
|
||||
|
||||
function truncateCodePoints(value, maxCodePoints) {
|
||||
const codePoints = Array.from(value);
|
||||
return codePoints.length <= maxCodePoints
|
||||
? value
|
||||
: codePoints.slice(0, maxCodePoints).join("");
|
||||
}
|
||||
|
||||
function sanitizeHeaderFileName(fileName, { maxCodePoints = DEFAULT_MAX_FILE_NAME_CODE_POINTS } = {}) {
|
||||
const source = typeof fileName === "string" && fileName.trim()
|
||||
? fileName.normalize("NFC").trim()
|
||||
: DEFAULT_FILE_NAME;
|
||||
const sanitized = truncateCodePoints(
|
||||
source
|
||||
.replace(/[\\/]/gu, "_")
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "_")
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/gu, "_")
|
||||
.replace(/"+/gu, "_")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim(),
|
||||
maxCodePoints,
|
||||
);
|
||||
return sanitized && sanitized !== "." && sanitized !== ".."
|
||||
? sanitized
|
||||
: DEFAULT_FILE_NAME;
|
||||
}
|
||||
|
||||
function buildAsciiFallback(fileName) {
|
||||
const lastDotIndex = fileName.lastIndexOf(".");
|
||||
const rawExtension = lastDotIndex > 0 ? fileName.slice(lastDotIndex).toLowerCase() : ".bin";
|
||||
const extension = /^\.[a-z0-9]{1,10}$/u.test(rawExtension) ? rawExtension : ".bin";
|
||||
const rawStem = lastDotIndex > 0 ? fileName.slice(0, lastDotIndex) : fileName;
|
||||
const asciiStem = rawStem
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/gu, "")
|
||||
.replace(/[^a-zA-Z0-9._-]+/gu, "_")
|
||||
.replace(/_+/gu, "_")
|
||||
.replace(/^[._-]+|[._-]+$/gu, "")
|
||||
.slice(0, 100);
|
||||
return `${asciiStem || "attachment"}${extension}`;
|
||||
}
|
||||
|
||||
function encodeRfc5987Value(value) {
|
||||
return encodeURIComponent(value).replace(/[\u0027\u0028\u0029\u002a]/gu, (character) =>
|
||||
`%${character.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildAttachmentContentDisposition(
|
||||
fileName,
|
||||
{ disposition = "attachment", maxCodePoints = DEFAULT_MAX_FILE_NAME_CODE_POINTS } = {},
|
||||
) {
|
||||
if (!ALLOWED_DISPOSITIONS.has(disposition)) {
|
||||
throw new TypeError("disposition must be attachment or inline");
|
||||
}
|
||||
const safeFileName = sanitizeHeaderFileName(fileName, { maxCodePoints });
|
||||
const asciiFallback = buildAsciiFallback(safeFileName);
|
||||
return `${disposition}; filename="${asciiFallback}"; filename*=UTF-8''${encodeRfc5987Value(safeFileName)}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_MAX_FILE_NAME_CODE_POINTS,
|
||||
buildAttachmentContentDisposition,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户