mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-25 00:13:00 +00:00
fix(server): unicode-safe content-disposition for gpx, ics, feed and wallet downloads (#2165)
This commit is contained in:
@@ -689,7 +689,9 @@ export class CalendarService {
|
||||
const timezones = new Map<string, string>();
|
||||
for (const [zone, yyyymmdd] of usedZones) timezones.set(zone, buildVTimezone(zone, yyyymmdd));
|
||||
|
||||
const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w\s.-]/g, '_');
|
||||
// \w + space/tab, not \s: JS \s admits U+3000 and friends — codepoints
|
||||
// Node's header validation refuses, so they 500'd the export (#2165).
|
||||
const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w \t.-]/g, '_');
|
||||
return {
|
||||
calName: esc(trip.title || 'TREK Trip'),
|
||||
filename: `${safeFilename}.ics`,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Content-Disposition values per RFC 6266. Node validates header values against
|
||||
* Latin-1 and throws ERR_INVALID_CHAR on any codepoint above 0xFF, so quoting a
|
||||
* trip title like 沖縄 4泊5日 straight into the header 500'd every export
|
||||
* (#2165). The quoted filename is folded to printable ASCII here, and when that
|
||||
* folding loses anything the real name rides along RFC 5987-encoded as
|
||||
* filename*, which browsers prefer. The result is printable ASCII by
|
||||
* construction: setHeader cannot throw on it, and CR/LF or quotes in a name
|
||||
* cannot break out of the quoted-string. A name that is already plain ASCII
|
||||
* produces byte-for-byte the header the call sites used to build by hand.
|
||||
*/
|
||||
export function contentDisposition(filename: string, type: 'attachment' | 'inline'): string {
|
||||
const name = filename
|
||||
// Control characters (tab included) have no business in a filename on
|
||||
// either side of the header, and an unpaired surrogate — a name cut
|
||||
// mid-emoji — would make encodeURIComponent throw below.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
||||
.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\uFFFD');
|
||||
const ascii = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
||||
if (ascii === filename) return `${type}; filename="${filename}"`;
|
||||
|
||||
// A name the folding erased entirely still needs a usable quoted fallback;
|
||||
// the extension survives, the readable name is in filename*.
|
||||
const dot = ascii.lastIndexOf('.');
|
||||
const stem = dot > 0 ? ascii.slice(0, dot) : ascii;
|
||||
const fallback = /[0-9A-Za-z]/.test(stem) ? ascii : `download${dot > 0 ? ascii.slice(dot) : ''}`;
|
||||
|
||||
// RFC 5987 attr-char: encodeURIComponent is close but leaves ' ( ) * bare.
|
||||
const encoded = encodeURIComponent(name).replace(
|
||||
/['()*]/g,
|
||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
return `${type}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { readEnv } from '../../app-config';
|
||||
import { contentDisposition } from '../common/content-disposition';
|
||||
import { FeedsService } from './feeds.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
@@ -48,7 +49,7 @@ export class FeedsPublicController {
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${result.filename}"`);
|
||||
res.setHeader('Content-Disposition', contentDisposition(result.filename, 'inline'));
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store');
|
||||
res.setHeader('X-Published-TTL', 'PT1H');
|
||||
res.send(result.ics);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Controller, Get, HttpException, Param, Req, Res } from '@nestjs/common'
|
||||
import type { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import { FilesService } from './files.service';
|
||||
import { contentDisposition } from '../common/content-disposition';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
|
||||
@@ -68,7 +69,7 @@ export class FilesDownloadController {
|
||||
name,
|
||||
res,
|
||||
walletMime
|
||||
? { contentType: walletMime, disposition: `inline; filename="${path.basename(file.original_name || name)}"` }
|
||||
? { contentType: walletMime, disposition: contentDisposition(path.basename(file.original_name || name), 'inline') }
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -18,6 +18,7 @@ import {
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write';
|
||||
import { contentDisposition } from '../common/content-disposition';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { hexColorSchema, placeImageUrlSchema, placeWebsiteSchema } from '@trek/shared';
|
||||
@@ -214,7 +215,7 @@ export class PlacesController {
|
||||
throw new HttpException({ error: 'Nothing to export' }, 404);
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/gpx+xml; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
|
||||
res.setHeader('Content-Disposition', contentDisposition(result.filename, 'attachment'));
|
||||
res.send(result.gpx);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write';
|
||||
import { contentDisposition } from '../common/content-disposition';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Options } from 'multer';
|
||||
@@ -273,7 +274,7 @@ export class TripsController {
|
||||
try {
|
||||
const { ics, filename } = this.calendar.exportICS(id);
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Content-Disposition', contentDisposition(filename, 'attachment'));
|
||||
res.send(ics);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof NotFoundError) throw new HttpException({ error: e.message }, 404);
|
||||
|
||||
Reference in New Issue
Block a user