97 lines
3.1 KiB
JavaScript
97 lines
3.1 KiB
JavaScript
const path = require('path');
|
||
|
||
// Control chars incl. NUL. Keep it strict and cross-platform.
|
||
const CONTROL_CHARS_RE = /[\u0000-\u001F\u007F]/g;
|
||
|
||
function toSafeBasename(input, fallback = 'file', opts = {}) {
|
||
const maxLength = Number.isInteger(opts.maxLength) ? opts.maxLength : 255;
|
||
|
||
let name = '';
|
||
if (typeof input === 'string') name = input;
|
||
else if (input == null) name = '';
|
||
else name = String(input);
|
||
|
||
// Remove NUL/control chars early (these can truncate paths in some tooling).
|
||
name = name.replace(CONTROL_CHARS_RE, '');
|
||
|
||
// Normalize separators to POSIX then force basename (flat archives).
|
||
name = name.replace(/\\/g, '/');
|
||
name = path.posix.basename(name);
|
||
|
||
// Trim to avoid whitespace-only names; keep the caller’s original if they need strict equality.
|
||
name = name.trim();
|
||
|
||
// Disallow dot/dotdot and empty.
|
||
if (!name || name === '.' || name === '..') name = '';
|
||
|
||
// Length cap.
|
||
if (name.length > maxLength) name = name.slice(0, maxLength);
|
||
|
||
if (!name) {
|
||
const fb = (fallback == null ? '' : String(fallback)).replace(CONTROL_CHARS_RE, '').trim();
|
||
return fb ? fb.slice(0, maxLength) : 'file';
|
||
}
|
||
|
||
return name;
|
||
}
|
||
|
||
function isSafeBasename(name, opts = {}) {
|
||
if (typeof name !== 'string') return false;
|
||
return toSafeBasename(name, '', opts) === name;
|
||
}
|
||
|
||
// Node randomUUID() v4 shape (case-insensitive).
|
||
const UUID_V4_RE =
|
||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||
|
||
/**
|
||
* Tus resume URL id: exactly one "++", safe bucket sid, UUID key.
|
||
*/
|
||
function isSafeTusUploadId(uploadId) {
|
||
if (typeof uploadId !== 'string' || uploadId.includes('\0')) return false;
|
||
const parts = uploadId.split('++');
|
||
if (parts.length !== 2) return false;
|
||
const [sid, key] = parts;
|
||
if (!sid || !key) return false;
|
||
return isSafeBasename(sid) && UUID_V4_RE.test(key);
|
||
}
|
||
|
||
/**
|
||
* Bucket-only id (lock PATCH, middleware checks without "++").
|
||
*/
|
||
function isSafeBucketFid(fid) {
|
||
if (typeof fid !== 'string' || fid.includes('++') || fid.includes('\0')) return false;
|
||
return isSafeBasename(fid);
|
||
}
|
||
|
||
function toAsciiFallbackFilename(name, fallback = 'file') {
|
||
const safe = toSafeBasename(name, fallback);
|
||
const normalized = safe.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
|
||
const ascii = normalized.replace(/[^\x20-\x7E]+/g, '').trim();
|
||
const cleaned = ascii.replace(/["\\]/g, '_');
|
||
if (cleaned) return cleaned;
|
||
const fallbackSafe = toSafeBasename(fallback, 'file');
|
||
return fallbackSafe.replace(/[^\x20-\x7E]+/g, '').trim() || 'file';
|
||
}
|
||
|
||
/** Content-Disposition value with an ASCII fallback and the UTF-8 name (RFC 5987). */
|
||
function contentDisposition(name, fallback = 'file', type = 'attachment') {
|
||
const safe = toSafeBasename(name, fallback);
|
||
const asciiFallback = toAsciiFallbackFilename(safe, fallback);
|
||
const encoded = encodeURIComponent(safe)
|
||
.replace(/['()]/g, c => `%${ c.charCodeAt(0).toString(16).toUpperCase() }`)
|
||
.replace(/\*/g, '%2A');
|
||
return `${ type }; filename="${ asciiFallback }"; filename*=UTF-8''${ encoded }`;
|
||
}
|
||
|
||
module.exports = {
|
||
contentDisposition,
|
||
toSafeBasename,
|
||
isSafeBasename,
|
||
isSafeTusUploadId,
|
||
isSafeBucketFid,
|
||
UUID_V4_RE,
|
||
};
|
||
|
||
|