mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
feat(trips): download chosen Unsplash covers into uploads (#1277)
Previously a selected Unsplash photo was stored as a remote images.unsplash.com hot-link, so covers broke offline and on link rot. The trip PUT handler now fetches the picked image through the SSRF guard and saves it under uploads/covers, rewriting cover_image to the local path (502 if the download fails). Also debounces the cover search so a slow earlier request can no longer overwrite newer results, drops a dead userId parameter, and reverts an unrelated vite proxy change.
This commit is contained in:
@@ -34,6 +34,7 @@ interface CoverSearchPhoto {
|
||||
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }: TripFormModalProps) {
|
||||
const isEditing = !!trip
|
||||
const fileRef = useRef(null)
|
||||
const coverSearchSeq = useRef(0)
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const currentUser = useAuthStore(s => s.user)
|
||||
@@ -216,17 +217,22 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
setCoverSearchError(t('dashboard.unsplashQueryRequired'))
|
||||
return
|
||||
}
|
||||
// Guard against out-of-order responses: only the latest search applies its
|
||||
// results, so a slow earlier query can't overwrite a newer one. #1277 review
|
||||
const seq = ++coverSearchSeq.current
|
||||
setSearchingCover(true)
|
||||
setCoverSearchError('')
|
||||
try {
|
||||
const data = await tripsApi.searchCoverImages(query)
|
||||
if (seq !== coverSearchSeq.current) return
|
||||
const photos = data.photos || []
|
||||
setCoverSearchResults(photos)
|
||||
if (photos.length === 0) setCoverSearchError(t('dashboard.unsplashNoResults'))
|
||||
} catch (err: unknown) {
|
||||
if (seq !== coverSearchSeq.current) return
|
||||
setCoverSearchError(getApiErrorMessage(err, t('dashboard.coverSearchError')))
|
||||
} finally {
|
||||
setSearchingCover(false)
|
||||
if (seq === coverSearchSeq.current) setSearchingCover(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-11
@@ -2,8 +2,6 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
const backendTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3001'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
@@ -128,42 +126,42 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: backendTarget,
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: backendTarget,
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: backendTarget,
|
||||
target: 'http://localhost:3001',
|
||||
ws: true,
|
||||
},
|
||||
'/mcp': {
|
||||
target: backendTarget,
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
// OAuth 2.1 endpoints handled by backend (SDK authorize handler + token/revoke)
|
||||
// /oauth/authorize goes to backend so the SDK can redirect to /oauth/consent
|
||||
// /oauth/consent is served by Vite as a SPA route (no proxy entry needed)
|
||||
'/oauth/authorize': {
|
||||
target: backendTarget,
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/oauth/token': {
|
||||
target: backendTarget,
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/oauth/register': {
|
||||
target: backendTarget,
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/oauth/revoke': {
|
||||
target: backendTarget,
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/.well-known': {
|
||||
target: backendTarget,
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { writeAudit, getClientIp, logInfo } from '../../services/auditLog';
|
||||
import { isDemoEmail } from '../../services/demo';
|
||||
import { NotFoundError, ValidationError } from '../../services/tripService';
|
||||
import { saveUnsplashCover, isUnsplashCoverUrl } from '../../services/unsplashService';
|
||||
|
||||
const MAX_COVER_SIZE = 20 * 1024 * 1024;
|
||||
const coversDir = path.join(__dirname, '../../../uploads/covers');
|
||||
@@ -72,9 +73,9 @@ export class TripsController {
|
||||
}
|
||||
|
||||
@Get('cover-images/search')
|
||||
async coverImages(@CurrentUser() user: User, @Query('query') query?: string) {
|
||||
async coverImages(@Query('query') query?: string) {
|
||||
try {
|
||||
const result = await this.trips.searchCoverImages(user.id, query || '');
|
||||
const result = await this.trips.searchCoverImages(query || '');
|
||||
if ('error' in result) {
|
||||
throw new HttpException({ error: result.error }, result.status);
|
||||
}
|
||||
@@ -120,7 +121,7 @@ export class TripsController {
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(@CurrentUser() user: User, @Param('id') id: string, @Body() body: Record<string, unknown>, @Req() req: Request, @Headers('x-socket-id') socketId?: string) {
|
||||
async update(@CurrentUser() user: User, @Param('id') id: string, @Body() body: Record<string, unknown>, @Req() req: Request, @Headers('x-socket-id') socketId?: string) {
|
||||
const access = this.trips.canAccessTrip(id, user.id);
|
||||
if (!access) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
@@ -137,6 +138,17 @@ export class TripsController {
|
||||
if (editFields.some((f) => body[f] !== undefined) && !this.trips.can('trip_edit', user.role, ownerId, user.id, isMember)) {
|
||||
throw new HttpException({ error: 'No permission to edit this trip' }, 403);
|
||||
}
|
||||
// A chosen Unsplash cover arrives as an images.unsplash.com hot-link; download
|
||||
// it into uploads/covers so the cover survives offline + CDN link-rot (#1277).
|
||||
if (isUnsplashCoverUrl(body.cover_image)) {
|
||||
try {
|
||||
const filename = await saveUnsplashCover(body.cover_image, coversDir);
|
||||
body.cover_image = `/uploads/covers/${filename}`;
|
||||
} catch (e) {
|
||||
console.error('Unsplash cover download failed:', e);
|
||||
throw new HttpException({ error: 'Could not save the selected cover image' }, 502);
|
||||
}
|
||||
}
|
||||
const oldCover = body.cover_image !== undefined
|
||||
? (this.trips.getRaw(id) as { cover_image: string | null } | undefined)?.cover_image
|
||||
: undefined;
|
||||
|
||||
@@ -50,8 +50,8 @@ export class TripsService {
|
||||
return tripSvc.getTripRaw(tripId);
|
||||
}
|
||||
|
||||
searchCoverImages(userId: number, query: string) {
|
||||
return searchUnsplashPhotos(userId, query, 9);
|
||||
searchCoverImages(query: string) {
|
||||
return searchUnsplashPhotos(query, 9);
|
||||
}
|
||||
|
||||
getOwner(tripId: string) {
|
||||
|
||||
@@ -939,9 +939,9 @@ export async function importNaverList(
|
||||
// Search place image (Unsplash)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function searchPlaceImage(tripId: string, placeId: string, userId: number) {
|
||||
export async function searchPlaceImage(tripId: string, placeId: string, _userId: number) {
|
||||
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Place | undefined;
|
||||
if (!place) return { error: 'Place not found', status: 404 };
|
||||
|
||||
return searchUnsplashPhotos(userId, place.name + (place.address ? ' ' + place.address : ''), 5);
|
||||
return searchUnsplashPhotos(place.name + (place.address ? ' ' + place.address : ''), 5);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { safeFetch } from '../utils/ssrfGuard';
|
||||
|
||||
interface UnsplashSearchResponse {
|
||||
results?: {
|
||||
id: string;
|
||||
@@ -20,7 +25,7 @@ export interface UnsplashPhoto {
|
||||
link: string | null;
|
||||
}
|
||||
|
||||
export async function searchUnsplashPhotos(_userId: number, query: string, perPage = 9) {
|
||||
export async function searchUnsplashPhotos(query: string, perPage = 9) {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
return { error: 'Search query is required', status: 400 };
|
||||
@@ -67,3 +72,44 @@ export async function searchUnsplashPhotos(_userId: number, query: string, perPa
|
||||
|
||||
return { photos };
|
||||
}
|
||||
|
||||
const UNSPLASH_IMAGE_HOST = 'images.unsplash.com';
|
||||
const MAX_COVER_BYTES = 15 * 1024 * 1024;
|
||||
const COVER_EXT_BY_TYPE: Record<string, string> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
'image/gif': '.gif',
|
||||
};
|
||||
|
||||
/** True when a cover_image value is an Unsplash CDN hot-link we should internalise. */
|
||||
export function isUnsplashCoverUrl(value: unknown): value is string {
|
||||
if (typeof value !== 'string') return false;
|
||||
try {
|
||||
return new URL(value).hostname.toLowerCase() === UNSPLASH_IMAGE_HOST;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function saveUnsplashCover(url: string, destDir: string): Promise<string> {
|
||||
if (!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})`);
|
||||
const type = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
||||
const ext = COVER_EXT_BY_TYPE[type];
|
||||
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);
|
||||
return filename;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,13 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.coverSaved': 'Omslagsbild sparad',
|
||||
'dashboard.coverUploadError': 'Det gick inte att ladda upp',
|
||||
'dashboard.coverRemoveError': 'Det gick inte att ta bort',
|
||||
'dashboard.coverSaveError': 'Det gick inte att spara omslagsbilden',
|
||||
'dashboard.searchUnsplash': 'Sök på Unsplash',
|
||||
'dashboard.unsplashSearchPlaceholder': 'Sök efter resmålsbilder',
|
||||
'dashboard.unsplashQueryRequired': 'Ange en sökterm',
|
||||
'dashboard.unsplashNoResults': 'Inga bilder hittades',
|
||||
'dashboard.coverSearchError': 'Det gick inte att söka på Unsplash',
|
||||
'dashboard.useUnsplashPhoto': 'Använd Unsplash-foto av {photographer}',
|
||||
'dashboard.titleRequired': 'Titel är obligatoriskt',
|
||||
'dashboard.endDateError': 'Slutdatumet måste ligga efter startdatumet',
|
||||
'dashboard.greeting.morning': 'God morgon,',
|
||||
|
||||
Reference in New Issue
Block a user