refactor(share): stream shared place-photo bytes through storage

This commit is contained in:
jubnl
2026-08-16 11:07:04 +02:00
parent 786b1f5c07
commit 1bf6517b75
5 changed files with 91 additions and 59 deletions
+20 -6
View File
@@ -1,8 +1,9 @@
import { Body, Controller, Delete, Get, HttpException, Param, Post, Res, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
import { createReadStream } from 'node:fs';
import type { Readable } from 'node:stream';
import type { User } from '../../types';
import { ShareService } from './share.service';
import { StorageService } from '../storage/storage.service';
import { ShareLinkDto } from './share.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
@@ -78,7 +79,10 @@ export class TripShareController {
@Public('share-token validated: a shared trip link has to work for somebody without an account')
@Controller('api/shared')
export class SharedController {
constructor(private readonly share: ShareService) {}
constructor(
private readonly share: ShareService,
private readonly storage: StorageService,
) {}
/**
* Public, token-scoped place-photo proxy. The shared payload rewrites place
@@ -89,15 +93,25 @@ export class SharedController {
* MapsController.placePhotoBytes (cached photos are always JPEG).
*/
@Get(':token/place-photo/:placeId/bytes')
placePhotoBytes(@Param('token') token: string, @Param('placeId') placeId: string, @Res() res: Response): void {
const fp = this.share.getSharedPlacePhotoPath(token, placeId);
if (!fp) {
async placePhotoBytes(@Param('token') token: string, @Param('placeId') placeId: string, @Res() res: Response): Promise<void> {
const key = this.share.getSharedPlacePhotoKey(token, placeId);
if (!key) {
res.status(404).json({ error: 'Photo not cached' });
return;
}
// Bytes are stream-piped like the maps handler; headers go on before the
// stream attempt so an early failure overrides them exactly as the old
// createReadStream error event did.
res.set('Cache-Control', 'public, max-age=2592000, immutable');
res.type('image/jpeg');
const stream = createReadStream(fp);
let stream: Readable;
try {
({ stream } = await this.storage.getStream('photos-google', key));
} catch {
// Cache-delete race — same terminal state as the old stream error path.
if (!res.headersSent) res.status(404).json({ error: 'Photo not cached' });
return;
}
stream.on('error', () => {
if (!res.headersSent) res.status(404).json({ error: 'Photo not cached' });
});
+2 -1
View File
@@ -6,11 +6,12 @@ import { SettingsModule } from '../settings/settings.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { QueryHelpersModule } from '../query-helpers/query-helpers.module';
import { PlacePhotosModule } from '../place-photos/place-photos.module';
import { StorageModule } from '../storage/storage.module';
import { AuthModule } from '../auth/auth.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
@Module({
imports: [McpSharedModule, SettingsModule, PermissionsModule, QueryHelpersModule, AuthModule, PlacePhotosModule],
imports: [McpSharedModule, SettingsModule, PermissionsModule, QueryHelpersModule, AuthModule, PlacePhotosModule, StorageModule],
controllers: [TripShareController, SharedController],
providers: [ShareService, ShareMcp],
})
+9 -8
View File
@@ -297,14 +297,15 @@ export class ShareService {
}
/**
* Resolves the on-disk path for a cached place photo requested through a public
* share link. Validates that the token is valid + unexpired and that the place
* actually belongs to that token's trip (matched via the stored proxy URL, which
* covers both Google `placeId` and Wikimedia `coords:` pseudo-IDs without
* depending on google_place_id). Returns null — never throws — so the caller
* answers a plain 404, mirroring the authenticated bytes endpoint.
* Resolves the storage name (category 'photos-google') for a cached place
* photo requested through a public share link. Validates that the token is
* valid + unexpired and that the place actually belongs to that token's trip
* (matched via the stored proxy URL, which covers both Google `placeId` and
* Wikimedia `coords:` pseudo-IDs without depending on google_place_id).
* Returns null — never throws — so the caller answers a plain miss,
* mirroring the authenticated bytes endpoint.
*/
getSharedPlacePhotoPath(token: string, placeId: string): string | null {
getSharedPlacePhotoKey(token: string, placeId: string): string | null {
const shareRow = this.dbs.get<{ trip_id: string; share_map: number }>(
"SELECT trip_id, share_map FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))",
token,
@@ -319,6 +320,6 @@ export class ShareService {
const place = this.dbs.get('SELECT 1 FROM places WHERE trip_id = ? AND image_url = ?', shareRow.trip_id, expectedUrl);
if (!place) return null;
return this.photoCache.serveFilePath(placeId);
return this.photoCache.serveKey(placeId);
}
}