diff --git a/src/backlog.ts b/src/backlog.ts index 2c723be..fbd2435 100644 --- a/src/backlog.ts +++ b/src/backlog.ts @@ -3,6 +3,43 @@ import * as Entity from "./entity"; import Request from "./request"; import type { Fetch } from "./types"; +/** + * Extracts the filename from a `Content-Disposition` header, or `""` when it + * carries none. + * + * Per RFC 6266 the `filename*` extended notation wins over plain `filename`; + * its `''` prefix is dropped and the rest percent-decoded. + * The value is the server's, so sanitise it before using it as a path. + */ +const parseContentDispositionFilename = (disposition: string | null): string => { + if (!disposition) { + return ""; + } + + const extended = /(?:^|;)\s*filename\*\s*=\s*([^;]+)/i.exec(disposition); + if (extended) { + // Servers send a quoted ext-value even though RFC 5987 forbids it. + const value = extended[1].trim().replace(/^"(.*)"$/, "$1"); + const encoded = /^[^']*'[^']*'(.*)$/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1]); + } catch { + return encoded[1]; + } + } + } + + // Before the unquoted form: a quoted value may contain the `;` that ends one. + const quoted = /(?:^|;)\s*filename\s*=\s*"((?:[^"\\]|\\.)*)"/i.exec(disposition); + if (quoted) { + return quoted[1].replace(/\\(.)/g, "$1"); + } + + const plain = /(?:^|;)\s*filename\s*=\s*([^;]*)/i.exec(disposition); + return plain ? plain[1].trim() : ""; +}; + export default class Backlog extends Request { constructor(configure: { host: string; @@ -1408,12 +1445,10 @@ export default class Backlog extends Request { blob: () => response.blob(), }); } else { - const disposition = response.headers.get("Content-Disposition"); - const filename = disposition ? disposition.substring(disposition.indexOf("''") + 2) : ""; resolve({ body: (response).body, url: response.url, - filename: filename, + filename: parseContentDispositionFilename(response.headers.get("Content-Disposition")), }); } }); diff --git a/test/test.ts b/test/test.ts index 6ff4bf3..0758ef6 100644 --- a/test/test.ts +++ b/test/test.ts @@ -494,6 +494,80 @@ describe("Backlog API", () => { expect(data).toHaveProperty("filename", "test.png"); }); + // Measured against a live Backlog space: it always uses the extended notation + // and percent-encodes anything that needs it, so `%20`, `%3B` and `%2530` are + // what an ordinary filename arrives as. + it.each([ + ["attachment; filename*=UTF-8''shot.png", "shot.png"], + ["attachment; filename*=UTF-8''%E5%9B%B3%E9%9D%A2.png", "図面.png"], + ["attachment; filename*=UTF-8''a%20b.csv", "a b.csv"], + ["attachment; filename*=UTF-8''q1%3Bsummary.csv", "q1;summary.csv"], + ["attachment; filename*=UTF-8''20%2530report.csv", "20%30report.csv"], + ])("should read the filename Backlog sends in %j", async (disposition, expected) => { + const issueIdOrKey = "BLG-1"; + const attachmentId = 3; + mockRequest({ + method: "GET", + path: `/api/v2/issues/${issueIdOrKey}/attachments/${attachmentId}`, + reqHeaders: { "Backlog-API-Key": apiKey }, + status: 200, + data: "dummy", + headers: { "Content-Disposition": disposition }, + times: 1, + }); + const data = await backlog.getIssueAttachment(issueIdOrKey, attachmentId); + expect(data).toHaveProperty("filename", expected); + }); + + // Shapes RFC 6266 permits that Backlog does not send, so the client does not + // depend on one server's habits. + it.each([ + ['attachment; filename="report.png"', "report.png"], + ["attachment; filename=report.png", "report.png"], + ["attachment; filename*=UTF-8''%E5%9B%B3%E9%9D%A2.png", "図面.png"], + // RFC 6266: `filename*` wins when a header carries both. + ["attachment; filename=\"fallback.png\"; filename*=UTF-8''%E5%9B%B3.png", "図.png"], + // A quoted value may contain the delimiter. + ['attachment; filename="quarter;summary.pdf"', "quarter;summary.pdf"], + ['attachment; filename="20%30report.pdf"', "20%30report.pdf"], + // RFC 5987 does not allow a quoted ext-value, but servers send one. + ["attachment; filename*=\"UTF-8''%E5%9B%B3.png\"", "図.png"], + ['attachment; filename=" spaced.png "', " spaced.png "], + ["attachment; filename= spaced.png ", "spaced.png"], + ['attachment; filename="a\\"b.png"', 'a"b.png'], + ["attachment;filename=nospace.png", "nospace.png"], + ["attachment", ""], + ])("should read the filename from %j", async (disposition, expected) => { + const issueIdOrKey = "BLG-1"; + const attachmentId = 1; + mockRequest({ + method: "GET", + path: `/api/v2/issues/${issueIdOrKey}/attachments/${attachmentId}`, + reqHeaders: { "Backlog-API-Key": apiKey }, + status: 200, + data: "dummy", + headers: { "Content-Disposition": disposition }, + times: 1, + }); + const data = await backlog.getIssueAttachment(issueIdOrKey, attachmentId); + expect(data).toHaveProperty("filename", expected); + }); + + it("should return an empty filename when the header is absent.", async () => { + const issueIdOrKey = "BLG-1"; + const attachmentId = 2; + mockRequest({ + method: "GET", + path: `/api/v2/issues/${issueIdOrKey}/attachments/${attachmentId}`, + reqHeaders: { "Backlog-API-Key": apiKey }, + status: 200, + data: "dummy", + times: 1, + }); + const data = await backlog.getIssueAttachment(issueIdOrKey, attachmentId); + expect(data).toHaveProperty("filename", ""); + }); + it("should add a document.", async () => { mockRequest({ method: "POST",