mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
refactor(trips): trip covers through storage
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user