2026-09-19 15:44:00 +03:30
|
|
|
import { existsSync, statSync } from 'node:fs';
|
|
|
|
|
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
2026-09-23 02:27:30 +03:30
|
|
|
import { envOr } from '../../common/env';
|
2026-09-19 15:44:00 +03:30
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Directory files are written to and served from.
|
|
|
|
|
*
|
|
|
|
|
* Default matches docker-compose's `api_uploads:/app/storage/uploads` volume.
|
|
|
|
|
* A nested leftover (`storage/uploads/uploads/`) is also checked when serving,
|
|
|
|
|
* because copying a folder named `uploads` into the volume creates that extra
|
|
|
|
|
* directory and the previous static root then 404'd every file.
|
|
|
|
|
*/
|
|
|
|
|
export function getUploadDir(): string {
|
2026-09-23 02:27:30 +03:30
|
|
|
return resolve(envOr('UPLOAD_DIR', join(process.cwd(), 'storage', 'uploads')));
|
2026-09-19 15:44:00 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const UPLOAD_MIME: Record<string, string> = {
|
|
|
|
|
'.png': 'image/png',
|
|
|
|
|
'.jpg': 'image/jpeg',
|
|
|
|
|
'.jpeg': 'image/jpeg',
|
|
|
|
|
'.gif': 'image/gif',
|
|
|
|
|
'.webp': 'image/webp',
|
|
|
|
|
'.avif': 'image/avif',
|
|
|
|
|
'.svg': 'image/svg+xml',
|
|
|
|
|
'.pdf': 'application/pdf',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function mimeForUpload(filePath: string): string | undefined {
|
|
|
|
|
const dot = filePath.lastIndexOf('.');
|
|
|
|
|
const ext = dot >= 0 ? filePath.slice(dot).toLowerCase() : '';
|
|
|
|
|
return UPLOAD_MIME[ext];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Resolve a public filename to an on-disk file, or null if it is missing/unsafe. */
|
|
|
|
|
export function resolveStoredUpload(rawName: string): string | null {
|
|
|
|
|
const safe = basename(rawName);
|
|
|
|
|
if (!safe || safe === '.' || safe === '..') return null;
|
|
|
|
|
|
|
|
|
|
const dir = getUploadDir();
|
|
|
|
|
const candidates = [join(dir, safe), join(dir, 'uploads', safe)];
|
|
|
|
|
|
|
|
|
|
for (const candidate of candidates) {
|
|
|
|
|
const resolved = resolve(candidate);
|
|
|
|
|
const rel = relative(dir, resolved);
|
|
|
|
|
if (!rel || rel.startsWith('..') || isAbsolute(rel)) continue;
|
|
|
|
|
if (existsSync(resolved) && statSync(resolved).isFile()) return resolved;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|