diff --git a/apps/backend/src/application/controllers/blog.controller.ts b/apps/backend/src/application/controllers/blog.controller.ts index 3a36562..f2d7afe 100644 --- a/apps/backend/src/application/controllers/blog.controller.ts +++ b/apps/backend/src/application/controllers/blog.controller.ts @@ -117,7 +117,7 @@ export class BlogController { slug: post.slug, excerpt: post.excerpt, content: post.content, - coverImageUrl: post.coverImageUrl, + coverImageUrl: post.coverImageUrl ?? undefined, category: post.category, tags: post.tags, authorName: post.authorName, diff --git a/apps/backend/src/application/controllers/csv-booking-actions.controller.ts b/apps/backend/src/application/controllers/csv-booking-actions.controller.ts index 1ef266a..b97efdb 100644 --- a/apps/backend/src/application/controllers/csv-booking-actions.controller.ts +++ b/apps/backend/src/application/controllers/csv-booking-actions.controller.ts @@ -1,4 +1,14 @@ -import { Controller, Get, Post, Param, Query, Body } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Param, + Query, + Body, + Res, + StreamableFile, +} from '@nestjs/common'; +import { Response } from 'express'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger'; import { Public } from '../decorators/public.decorator'; import { CsvBookingService } from '../services/csv-booking.service'; @@ -173,4 +183,78 @@ export class CsvBookingActionsController { async getBookingDocuments(@Param('token') token: string): Promise { return this.csvBookingService.getDocumentsForCarrier(token); } + + /** + * Download a single booking document, streamed through the API (PUBLIC - token-based). + * + * Password-protected bookings must use POST with the password in the body. + * + * POST /api/v1/csv-booking-actions/documents/:token/:documentId/download + */ + @Public() + @Post('documents/:token/:documentId/download') + @ApiOperation({ + summary: 'Download a booking document with password (public)', + description: + 'Streams a single booking document to the carrier. Applies the same access rules as the documents list (booking accepted + password when protected).', + }) + @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) + @ApiParam({ name: 'documentId', description: 'Document ID' }) + @ApiBody({ type: VerifyDocumentAccessDto }) + @ApiResponse({ status: 200, description: 'Document streamed successfully.' }) + @ApiResponse({ status: 401, description: 'Invalid or missing password' }) + @ApiResponse({ status: 404, description: 'Booking or document not found' }) + async downloadBookingDocumentWithPassword( + @Param('token') token: string, + @Param('documentId') documentId: string, + @Body() dto: VerifyDocumentAccessDto, + @Res({ passthrough: true }) res: Response + ): Promise { + return this.streamDocument(token, documentId, dto?.password, res); + } + + /** + * Download a single booking document (PUBLIC - token-based) - Legacy without password. + * + * GET /api/v1/csv-booking-actions/documents/:token/:documentId/download + */ + @Public() + @Get('documents/:token/:documentId/download') + @ApiOperation({ + summary: 'Download a booking document (public) - Legacy', + description: + 'Streams a single booking document for bookings without password protection. Protected bookings must use the POST variant.', + }) + @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) + @ApiParam({ name: 'documentId', description: 'Document ID' }) + @ApiResponse({ status: 200, description: 'Document streamed successfully.' }) + @ApiResponse({ status: 401, description: 'Password required for this booking' }) + @ApiResponse({ status: 404, description: 'Booking or document not found' }) + async downloadBookingDocument( + @Param('token') token: string, + @Param('documentId') documentId: string, + @Res({ passthrough: true }) res: Response + ): Promise { + return this.streamDocument(token, documentId, undefined, res); + } + + private async streamDocument( + token: string, + documentId: string, + password: string | undefined, + res: Response + ): Promise { + const { buffer, fileName, mimeType } = await this.csvBookingService.streamDocumentForCarrier( + token, + documentId, + password + ); + + res.setHeader('Content-Type', mimeType); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${encodeURIComponent(fileName)}"` + ); + return new StreamableFile(buffer); + } } diff --git a/apps/backend/src/application/dto/blog-post.dto.ts b/apps/backend/src/application/dto/blog-post.dto.ts index 129db6c..eda2d62 100644 --- a/apps/backend/src/application/dto/blog-post.dto.ts +++ b/apps/backend/src/application/dto/blog-post.dto.ts @@ -10,6 +10,7 @@ import { MaxLength, MinLength, Matches, + ValidateIf, } from 'class-validator'; import { BlogPostStatus, type BlogPostCategory } from '@domain/entities/blog-post.entity'; @@ -121,11 +122,15 @@ export class UpdateBlogPostDto { @IsString() content?: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ + nullable: true, + description: 'Cover image URL. Pass null to remove the current cover image.', + }) @IsOptional() + @ValidateIf((_, value) => value !== null) @IsString() @MaxLength(500) - coverImageUrl?: string; + coverImageUrl?: string | null; @ApiPropertyOptional({ enum: CATEGORIES }) @IsOptional() diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index ed8eadc..d62e544 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -735,20 +735,17 @@ export class CsvBookingService { throw new NotFoundException('Réservation introuvable'); } - // Generate signed URLs for all documents - const documentsWithUrls = await Promise.all( - booking.documents.map(async doc => { - const signedUrl = await this.generateSignedUrlForDocument(doc.filePath); - return { - id: doc.id, - type: doc.type, - fileName: doc.fileName, - mimeType: doc.mimeType, - size: doc.size, - downloadUrl: signedUrl, - }; - }) - ); + // Build proxy download URLs. The file is streamed through the API rather than + // served via a presigned storage URL, whose internal host is unreachable from + // the carrier's browser. The frontend prepends the public API base URL. + const documentsWithUrls = booking.documents.map(doc => ({ + id: doc.id, + type: doc.type, + fileName: doc.fileName, + mimeType: doc.mimeType, + size: doc.size, + downloadUrl: `/api/v1/csv-booking-actions/documents/${token}/${doc.id}/download`, + })); const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR'; @@ -795,12 +792,10 @@ export class CsvBookingService { } /** - * Generate signed URL for a document file path + * Extract the storage object key from a stored document file path. + * Handles raw keys, "bucket/key" paths and full URLs. */ - private async generateSignedUrlForDocument(filePath: string): Promise { - const bucket = 'xpeditis-documents'; - - // Extract key from the file path + private extractDocumentKey(filePath: string): string { let key = filePath; if (filePath.includes('xpeditis-documents/')) { key = filePath.split('xpeditis-documents/')[1]; @@ -811,10 +806,65 @@ export class CsvBookingService { key = key.replace('xpeditis-documents/', ''); } } + return key; + } - // Generate signed URL with 1 hour expiration - const signedUrl = await this.storageAdapter.getSignedUrl({ bucket, key }, 3600); - return signedUrl; + /** + * Stream a booking document to the carrier through the API (public, token-based). + * + * Downloads the file from storage and returns it as a buffer so the API can + * proxy it to the carrier's browser. This avoids handing out presigned storage + * URLs whose host (internal MinIO/S3 endpoint) is not reachable from outside. + * + * Applies the same access rules as {@link getDocumentsForCarrier}: the booking + * must be ACCEPTED and, when protected, the correct password must be supplied. + */ + async streamDocumentForCarrier( + token: string, + documentId: string, + password?: string + ): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> { + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { confirmationToken: token }, + }); + + if (!ormBooking) { + throw new NotFoundException('Réservation introuvable'); + } + + if (ormBooking.status !== 'ACCEPTED') { + throw new BadRequestException("Cette réservation n'a pas encore été acceptée"); + } + + if (ormBooking.passwordHash) { + if (!password) { + throw new UnauthorizedException('Mot de passe requis pour accéder aux documents'); + } + const isPasswordValid = await argon2.verify(ormBooking.passwordHash, password); + if (!isPasswordValid) { + throw new UnauthorizedException('Mot de passe incorrect'); + } + } + + const booking = await this.csvBookingRepository.findByToken(token); + if (!booking) { + throw new NotFoundException('Réservation introuvable'); + } + + const document = booking.documents.find(doc => doc.id === documentId); + if (!document) { + throw new NotFoundException('Document introuvable'); + } + + const bucket = 'xpeditis-documents'; + const key = this.extractDocumentKey(document.filePath); + const buffer = await this.storageAdapter.download({ bucket, key }); + + return { + buffer, + fileName: document.fileName, + mimeType: document.mimeType || 'application/octet-stream', + }; } /** diff --git a/apps/backend/src/domain/entities/blog-post.entity.ts b/apps/backend/src/domain/entities/blog-post.entity.ts index 74a0f56..3684794 100644 --- a/apps/backend/src/domain/entities/blog-post.entity.ts +++ b/apps/backend/src/domain/entities/blog-post.entity.ts @@ -13,7 +13,7 @@ interface BlogPostProps { slug: string; excerpt: string; content: string; - coverImageUrl?: string; + coverImageUrl?: string | null; category: BlogPostCategory; tags: string[]; authorName: string; @@ -64,7 +64,7 @@ export class BlogPost { get content(): string { return this.props.content; } - get coverImageUrl(): string | undefined { + get coverImageUrl(): string | null | undefined { return this.props.coverImageUrl; } get category(): BlogPostCategory { diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/blog-post.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/blog-post.orm-entity.ts index 36cb24d..cffd061 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/blog-post.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/blog-post.orm-entity.ts @@ -20,7 +20,7 @@ export class BlogPostOrmEntity { content: string; @Column('varchar', { length: 500, nullable: true }) - cover_image_url?: string; + cover_image_url?: string | null; @Column('varchar', { length: 50 }) category: string; diff --git a/apps/frontend/app/[locale]/admin/blog/page.tsx b/apps/frontend/app/[locale]/admin/blog/page.tsx index 0e40772..57a8862 100644 --- a/apps/frontend/app/[locale]/admin/blog/page.tsx +++ b/apps/frontend/app/[locale]/admin/blog/page.tsx @@ -29,6 +29,7 @@ import { Clock, ChevronDown, ChevronUp, + ArrowLeft, } from 'lucide-react'; import { Link } from '@/i18n/navigation'; @@ -181,7 +182,9 @@ export default function AdminBlogPage() { slug: formData.slug, excerpt: formData.excerpt, content: formData.content, - coverImageUrl: formData.coverImageUrl || undefined, + // Send explicit null (not undefined) when the cover was removed, so the + // backend clears the column instead of leaving the previous value. + coverImageUrl: formData.coverImageUrl?.trim() ? formData.coverImageUrl : null, category: formData.category, tags, authorName: formData.authorName, @@ -318,7 +321,9 @@ export default function AdminBlogPage() { setShowEditModal(true); }; - const closeModal = () => { + // Pure UI reset — does not touch browser history. Used by the popstate + // handler (the browser already popped our entry at that point). + const resetEditorState = () => { setShowCreateModal(false); setShowEditModal(false); setSelectedPost(null); @@ -328,6 +333,28 @@ export default function AdminBlogPage() { setSeoOpen(false); }; + // Explicit close (X / Retour / after save): reset UI and pop the history + // entry we pushed when opening, so the browser Back button stays on /admin/blog. + const closeModal = () => { + resetEditorState(); + if (typeof window !== 'undefined' && window.history.state?.xpBlogEditor) { + window.history.back(); + } + }; + + // Keep the browser Back button inside the blog editor: pushing a history entry + // when the editor opens means Back closes the editor instead of navigating away + // (e.g. to the System Logs tab). + useEffect(() => { + const editorOpen = showCreateModal || showEditModal; + if (!editorOpen) return; + window.history.pushState({ xpBlogEditor: true }, ''); + const handlePop = () => resetEditorState(); + window.addEventListener('popstate', handlePop); + return () => window.removeEventListener('popstate', handlePop); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showCreateModal, showEditModal]); + const handleTitleChange = (title: string) => { setFormData(prev => ({ ...prev, @@ -535,9 +562,19 @@ export default function AdminBlogPage() {
{/* Header */}
-

- {showCreateModal ? 'Nouvel article' : `Modifier — ${selectedPost?.title}`} -

+
+ +

+ {showCreateModal ? 'Nouvel article' : `Modifier — ${selectedPost?.title}`} +

+
{showEditModal && (