Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -535,11 +535,89 @@ export class VoidApiResponse {
export class BlobApiResponse {
constructor(public raw: Response) {}

async value(): Promise<Blob> {
return await this.raw.blob();
/**
* The body as a File named after the Content-Disposition header, so that a download keeps the
* name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name
* is empty when the server did not send one. Runtimes without a global File (Node.js before 20)
* keep receiving the bare Blob.
*/
async value(): Promise<File> {
const blob = await this.raw.blob();
if (typeof File === 'undefined') {
return blob as File;
}
return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type });
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
};
}

/**
* The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded
* `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters
* are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory
* part is dropped so that the name is safe to write to disk as is; undefined when no usable name
* is advertised.
*/
export function parseContentDispositionFilename(headers: Headers): string | undefined {
const value = headers.get('Content-Disposition');
if (!value) {
return undefined;
}
const params = parseHeaderParameters(value);
const encoded = params.get('filename*');
if (encoded !== undefined) {
const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded);
if (extValue) {
try {
return basename(decodeURIComponent(extValue[1]));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When filename* decodes to an unusable name, parseContentDispositionFilename returns immediately instead of falling back to filename, so responses with a path-only or empty encoded name lose a usable plain fallback. Return the encoded name only when basename is defined, then continue to the plain parameter.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache, line 571:

<comment>When `filename*` decodes to an unusable name, `parseContentDispositionFilename` returns immediately instead of falling back to `filename`, so responses with a path-only or empty encoded name lose a usable plain fallback. Return the encoded name only when `basename` is defined, then continue to the plain parameter.</comment>

<file context>
@@ -552,27 +552,70 @@ export class BlobApiResponse {
+        const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded);
+        if (extValue) {
+            try {
+                return basename(decodeURIComponent(extValue[1]));
+            } catch {
+                // malformed percent-encoding: fall through to the plain form
</file context>
Suggested change
return basename(decodeURIComponent(extValue[1]));
const decoded = basename(decodeURIComponent(extValue[1]));
if (decoded !== undefined) {
return decoded;
}

} catch {
// malformed percent-encoding: fall through to the plain form
}
}
}
const plain = params.get('filename');
return plain === undefined ? undefined : basename(plain);
}

/**
* The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are
* lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the
* first occurrence of a name wins.
*/
function parseHeaderParameters(value: string): Map<string, string> {
const params = new Map<string, string>();
let start = 0;
let quoted = false;
for (let i = 0; i <= value.length; i++) {
const c = value[i];
if (i === value.length || (c === ';' && !quoted)) {
const part = value.slice(start, i);
const eq = part.indexOf('=');
if (eq !== -1) {
const name = part.slice(0, eq).trim().toLowerCase();
let raw = part.slice(eq + 1).trim();
if (raw.startsWith('"')) {
raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1');
}
if (name && !params.has(name)) {
params.set(name, raw);
}
}
start = i + 1;
} else if (c === '"') {
quoted = !quoted;
} else if (c === '\\' && quoted) {
i++;
}
}
return params;
}

/** The last path segment of a file name, or undefined when nothing usable is left. */
function basename(name: string): string | undefined {
const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim();
return base === '' || base === '.' || base === '..' ? undefined : base;
}

export class TextApiResponse {
constructor(public raw: Response) {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -491,11 +491,89 @@ export class VoidApiResponse {
export class BlobApiResponse {
constructor(public raw: Response) {}

async value(): Promise<Blob> {
return await this.raw.blob();
/**
* The body as a File named after the Content-Disposition header, so that a download keeps the
* name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name
* is empty when the server did not send one. Runtimes without a global File (Node.js before 20)
* keep receiving the bare Blob.
*/
async value(): Promise<File> {
const blob = await this.raw.blob();
if (typeof File === 'undefined') {
return blob as File;
}
return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type });
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
};
}

/**
* The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded
* `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters
* are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory
* part is dropped so that the name is safe to write to disk as is; undefined when no usable name
* is advertised.
*/
export function parseContentDispositionFilename(headers: Headers): string | undefined {
const value = headers.get('Content-Disposition');
if (!value) {
return undefined;
}
const params = parseHeaderParameters(value);
const encoded = params.get('filename*');
if (encoded !== undefined) {
const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded);
if (extValue) {
try {
return basename(decodeURIComponent(extValue[1]));
} catch {
// malformed percent-encoding: fall through to the plain form
}
}
}
const plain = params.get('filename');
return plain === undefined ? undefined : basename(plain);
}

/**
* The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are
* lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the
* first occurrence of a name wins.
*/
function parseHeaderParameters(value: string): Map<string, string> {
const params = new Map<string, string>();
let start = 0;
let quoted = false;
for (let i = 0; i <= value.length; i++) {
const c = value[i];
if (i === value.length || (c === ';' && !quoted)) {
const part = value.slice(start, i);
const eq = part.indexOf('=');
if (eq !== -1) {
const name = part.slice(0, eq).trim().toLowerCase();
let raw = part.slice(eq + 1).trim();
if (raw.startsWith('"')) {
raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1');
}
if (name && !params.has(name)) {
params.set(name, raw);
}
}
start = i + 1;
} else if (c === '"') {
quoted = !quoted;
} else if (c === '\\' && quoted) {
i++;
}
}
return params;
}

/** The last path segment of a file name, or undefined when nothing usable is left. */
function basename(name: string): string | undefined {
const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim();
return base === '' || base === '.' || base === '..' ? undefined : base;
}

export class TextApiResponse {
constructor(public raw: Response) {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -491,11 +491,89 @@ export class VoidApiResponse {
export class BlobApiResponse {
constructor(public raw: Response) {}

async value(): Promise<Blob> {
return await this.raw.blob();
/**
* The body as a File named after the Content-Disposition header, so that a download keeps the
* name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name
* is empty when the server did not send one. Runtimes without a global File (Node.js before 20)
* keep receiving the bare Blob.
*/
async value(): Promise<File> {
const blob = await this.raw.blob();
if (typeof File === 'undefined') {
return blob as File;
}
return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type });
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
};
}

/**
* The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded
* `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters
* are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory
* part is dropped so that the name is safe to write to disk as is; undefined when no usable name
* is advertised.
*/
export function parseContentDispositionFilename(headers: Headers): string | undefined {
const value = headers.get('Content-Disposition');
if (!value) {
return undefined;
}
const params = parseHeaderParameters(value);
const encoded = params.get('filename*');
if (encoded !== undefined) {
const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded);
if (extValue) {
try {
return basename(decodeURIComponent(extValue[1]));
} catch {
// malformed percent-encoding: fall through to the plain form
}
}
}
const plain = params.get('filename');
return plain === undefined ? undefined : basename(plain);
}

/**
* The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are
* lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the
* first occurrence of a name wins.
*/
function parseHeaderParameters(value: string): Map<string, string> {
const params = new Map<string, string>();
let start = 0;
let quoted = false;
for (let i = 0; i <= value.length; i++) {
const c = value[i];
if (i === value.length || (c === ';' && !quoted)) {
const part = value.slice(start, i);
const eq = part.indexOf('=');
if (eq !== -1) {
const name = part.slice(0, eq).trim().toLowerCase();
let raw = part.slice(eq + 1).trim();
if (raw.startsWith('"')) {
raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1');
}
if (name && !params.has(name)) {
params.set(name, raw);
}
}
start = i + 1;
} else if (c === '"') {
quoted = !quoted;
} else if (c === '\\' && quoted) {
i++;
}
}
return params;
}

/** The last path segment of a file name, or undefined when nothing usable is left. */
function basename(name: string): string | undefined {
const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim();
return base === '' || base === '.' || base === '..' ? undefined : base;
}

export class TextApiResponse {
constructor(public raw: Response) {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -491,11 +491,89 @@ export class VoidApiResponse {
export class BlobApiResponse {
constructor(public raw: Response) {}

async value(): Promise<Blob> {
return await this.raw.blob();
/**
* The body as a File named after the Content-Disposition header, so that a download keeps the
* name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name
* is empty when the server did not send one. Runtimes without a global File (Node.js before 20)
* keep receiving the bare Blob.
*/
async value(): Promise<File> {
const blob = await this.raw.blob();
if (typeof File === 'undefined') {
return blob as File;
}
return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type });
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
};
}

/**
* The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded
* `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters
* are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory
* part is dropped so that the name is safe to write to disk as is; undefined when no usable name
* is advertised.
*/
export function parseContentDispositionFilename(headers: Headers): string | undefined {
const value = headers.get('Content-Disposition');
if (!value) {
return undefined;
}
const params = parseHeaderParameters(value);
const encoded = params.get('filename*');
if (encoded !== undefined) {
const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded);
if (extValue) {
try {
return basename(decodeURIComponent(extValue[1]));
} catch {
// malformed percent-encoding: fall through to the plain form
}
}
}
const plain = params.get('filename');
return plain === undefined ? undefined : basename(plain);
}

/**
* The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are
* lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the
* first occurrence of a name wins.
*/
function parseHeaderParameters(value: string): Map<string, string> {
const params = new Map<string, string>();
let start = 0;
let quoted = false;
for (let i = 0; i <= value.length; i++) {
const c = value[i];
if (i === value.length || (c === ';' && !quoted)) {
const part = value.slice(start, i);
const eq = part.indexOf('=');
if (eq !== -1) {
const name = part.slice(0, eq).trim().toLowerCase();
let raw = part.slice(eq + 1).trim();
if (raw.startsWith('"')) {
raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1');
}
if (name && !params.has(name)) {
params.set(name, raw);
}
}
start = i + 1;
} else if (c === '"') {
quoted = !quoted;
} else if (c === '\\' && quoted) {
i++;
}
}
return params;
}

/** The last path segment of a file name, or undefined when nothing usable is left. */
function basename(name: string): string | undefined {
const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim();
return base === '' || base === '.' || base === '..' ? undefined : base;
}

export class TextApiResponse {
constructor(public raw: Response) {}

Expand Down
Loading
Loading