refactor(trips): trip covers through storage

This commit is contained in:
jubnl
2026-08-17 05:37:52 +02:00
parent 965566369e
commit 8a3327c5f6
11 changed files with 87 additions and 76 deletions
+3 -4
View File
@@ -40,7 +40,6 @@ import { TripReadModelService } from '../trip-read-model/trip-read-model.service
export const MAX_COVER_SIZE = 20 * 1024 * 1024;
// Still needed by the Unsplash cover download (a raw-fs writer until the
// caches/downloads slice); the multer destination moved to the storage spool.
const coversDir = path.join(__dirname, '../../../uploads/covers');
// Consumed by trips.module.ts's MulterModule factory. Quirk preserved on
// purpose: a plain Error without statusCode maps to 500, not 400 (parity).
export const TRIP_COVER_FILE_FILTER: Options['fileFilter'] = (_req, file, cb) => {
@@ -154,7 +153,7 @@ export class TripsController {
// it into uploads/covers so the cover survives offline + CDN link-rot (#1277).
if (this.unsplash.isUnsplashCoverUrl(body.cover_image)) {
try {
const filename = await this.unsplash.saveUnsplashCover(body.cover_image, coversDir);
const filename = await this.unsplash.saveUnsplashCover(body.cover_image);
body.cover_image = `/uploads/covers/${filename}`;
} catch (e) {
console.error('Unsplash cover download failed:', e);
@@ -167,7 +166,7 @@ export class TripsController {
try {
const result = await this.trips.update(id, user.id, body, user.role);
if (body.cover_image !== undefined && body.cover_image !== oldCover) {
this.trips.deleteOldCover(oldCover);
await this.trips.deleteOldCover(oldCover);
}
if (Object.keys(result.changes).length > 0) {
this.audit.writeAudit({ userId: user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(id), trip: result.newTitle, ...(result.ownerEmail ? { owner: result.ownerEmail } : {}), ...result.changes } });
@@ -209,7 +208,7 @@ export class TripsController {
// Commit the spooled upload to its final storage location (atomic
// same-volume rename) before anything references the final path.
await this.storage.put('covers', file.filename, { tmpPath: file.path });
this.trips.deleteOldCover(trip.cover_image);
await this.trips.deleteOldCover(trip.cover_image);
const coverUrl = `/uploads/covers/${file.filename}`;
this.trips.updateCoverImage(id, coverUrl);
return { cover_image: coverUrl };
+10 -11
View File
@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import path from 'path';
import fs from 'fs';
import { DatabaseService } from '../database/database.service';
import type { ActiveTrip, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
@@ -11,6 +10,7 @@ import { BudgetService } from '../budget/budget.service';
import { ReservationsService } from '../reservations/reservations.service';
import { VacayService } from '../vacay/vacay.service';
import { UnsplashService } from '../unsplash/unsplash.service';
import { StorageService } from '../storage/storage.service';
import { NotFoundError, ValidationError } from '../common/domain-errors';
export const MS_PER_DAY = 86400000;
@@ -146,6 +146,7 @@ export class TripsService {
private readonly vacay: VacayService,
private readonly realtime: RealtimeService,
private readonly unsplash: UnsplashService,
private readonly storage: StorageService,
) {}
private get db() {
@@ -499,17 +500,15 @@ export class TripsService {
// ── Cover image ───────────────────────────────────────────────────────────
deleteOldCover(coverImage: string | null | undefined): void {
async deleteOldCover(coverImage: string | null | undefined): Promise<void> {
if (!coverImage) return;
// cover_image is client-supplied, so treat it as untrusted: covers live in
// uploads/covers as a flat filename — use basename() and confine the unlink
// to that directory. (dist/nest/trips is one level deeper than the legacy
// dist/services home, hence the extra '..' — same resolved directory.)
const coversDir = path.resolve(__dirname, '../../../uploads/covers');
const resolvedPath = path.resolve(path.join(coversDir, path.basename(coverImage)));
if (resolvedPath.startsWith(coversDir + path.sep) && fs.existsSync(resolvedPath)) {
fs.unlinkSync(resolvedPath);
}
// cover_image is client-supplied, so treat it as untrusted: covers are flat
// filenames in the 'covers' category — basename() confines the delete to
// it, and central key validation rejects anything hostile (swallowed like
// the old containment guard; an external https URL is likewise a no-op).
await this.storage.delete('covers', path.basename(coverImage)).catch(() => {
/* external URL or already gone */
});
}
updateCoverImage(tripId: string | number, coverUrl: string): void {
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { UnsplashService } from './unsplash.service';
import { AppConfigModule } from '../app-config/app-config.module';
import { StorageModule } from '../storage/storage.module';
/** Unsplash cover search and download. No controller of its own — trips and
* places both reach it. Deliberately NOT @Global, matching PermissionsModule:
@@ -8,7 +9,7 @@ import { AppConfigModule } from '../app-config/app-config.module';
* AppConfigModule is imported explicitly because @Global only applies to
* modules that are in the graph, which a single-domain TestingModule is not. */
@Module({
imports: [AppConfigModule],
imports: [AppConfigModule, StorageModule],
providers: [UnsplashService],
exports: [UnsplashService],
})
+11 -10
View File
@@ -1,11 +1,11 @@
import path from 'path';
import fs from 'fs';
import { Readable } from 'node:stream';
import { v4 as uuidv4 } from 'uuid';
import { Injectable } from '@nestjs/common';
import { safeFetch } from '../../utils/ssrfGuard';
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { DatabaseService } from '../database/database.service';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { StorageService } from '../storage/storage.service';
interface UnsplashSearchResponse {
results?: {
@@ -50,6 +50,7 @@ export class UnsplashService {
constructor(
private readonly db: DatabaseService,
private readonly env: RuntimeEnvService,
private readonly storage: StorageService,
) {}
/**
@@ -138,13 +139,14 @@ export class UnsplashService {
}
/**
* Download a chosen Unsplash cover from its CDN into destDir so the cover is
* stored locally (offline + CDN link-rot safe) instead of hot-linked. Only the
* Unsplash image CDN host is accepted, and the request goes through the SSRF
* guard. Returns the saved filename. Throws on a non-Unsplash host, a failed
* download, an unsupported content type, or an oversized image.
* Download a chosen Unsplash cover from its CDN into the 'covers' storage
* category so the cover is stored locally (offline + CDN link-rot safe)
* instead of hot-linked. Only the Unsplash image CDN host is accepted, and the
* request goes through the SSRF guard. Returns the saved filename. Throws on a
* non-Unsplash host, a failed download, an unsupported content type, or an
* oversized image — validation runs before any write.
*/
async saveUnsplashCover(url: string, destDir: string): Promise<string> {
async saveUnsplashCover(url: string): Promise<string> {
if (!this.isUnsplashCoverUrl(url)) throw new Error('Not an Unsplash image URL');
const res = await safeFetch(url);
if (!res.ok) throw new Error(`Unsplash image download failed (HTTP ${res.status})`);
@@ -153,9 +155,8 @@ export class UnsplashService {
if (!ext) throw new Error(`Unsupported cover image type: ${type || 'unknown'}`);
const buf = Buffer.from(await res.arrayBuffer());
if (buf.byteLength > MAX_COVER_BYTES) throw new Error('Cover image too large');
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
const filename = `${uuidv4()}${ext}`;
fs.writeFileSync(path.join(destDir, filename), buf);
await this.storage.put('covers', filename, Readable.from(buf));
return filename;
}
}