import { Controller, Post, Delete, Get, Query, Param, UseInterceptors, UploadedFile, HttpCode, HttpStatus, Res, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiTags, ApiOperation, ApiConsumes, ApiQuery, ApiBody, } from '@nestjs/swagger'; import { BoxStorageService } from './box-storage.service'; import { LogToDiscord } from 'src/shared/decorators/log-to-discord.decorator'; import { ConfigService } from '@nestjs/config'; import type { Response } from 'express'; import axios from 'axios'; import { FileType } from './enums/file-type.enum'; import { SkipApiToken } from 'src/shared/decorators/skip-api-token.decorator'; @ApiTags('Box Storage') @Controller('box') export class BoxStorageController { constructor( private readonly boxStorageService: BoxStorageService, private readonly configService: ConfigService, ) {} @Get('error-illustration') @SkipApiToken() @ApiOperation({ summary: '404 illustration asset (same-origin)', description: 'Serves the illustration used on the HTML 404 page. Proxies remote CDN and caches in memory with a safe SVG fallback.', }) async errorIllustration(@Res() res: Response) { const { contentType, body } = await this.boxStorageService.get404Illustration(); res.setHeader('Content-Type', contentType); res.setHeader('Cache-Control', 'public, max-age=86400'); return res.status(200).send(body); } @Get('oauth/authorize') @LogToDiscord() @SkipApiToken() @ApiOperation({ summary: 'Start OAuth 2.0 authorization flow', description: 'Redirects to Box.com authorization page to get user consent', }) authorize(@Res() res: Response) { const clientId = this.configService.get('boxStorage.clientId'); const redirectUri = this.configService.get('boxStorage.redirectUri') || 'http://localhost:3000/box/oauth/callback'; if (!clientId) { return res.status(500).json({ error: 'Box Client ID not configured' }); } const authUrl = `https://account.box.com/api/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}`; return res.redirect(authUrl); } @Get('oauth/callback') @LogToDiscord() @SkipApiToken() @ApiOperation({ summary: 'OAuth 2.0 callback endpoint', description: 'Receives authorization code from Box and exchanges it for access and refresh tokens', }) async oauthCallback( @Query('code') code: string, @Query('error') error: string, ) { if (error) { return { success: false, error: error, message: 'Authorization failed', }; } if (!code) { return { success: false, message: 'No authorization code received', }; } try { const clientId = this.configService.get('boxStorage.clientId'); const clientSecret = this.configService.get( 'boxStorage.clientSecret', ); const redirectUri = this.configService.get('boxStorage.redirectUri') || 'http://localhost:3000/box/oauth/callback'; if (!clientId || !clientSecret) { return { success: false, message: 'Box OAuth credentials not configured', }; } const response = await axios.post( 'https://api.box.com/oauth2/token', new URLSearchParams({ grant_type: 'authorization_code', code: code, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri, } as Record), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }, ); // Save tokens to file automatically await this.boxStorageService.saveInitialTokens( response.data.access_token, response.data.refresh_token, response.data.expires_in, ); return { success: true, message: '✅ OAuth authorization successful! Tokens saved to box-tokens.json', tokens: { access_token: response.data.access_token, refresh_token: response.data.refresh_token, expires_in: response.data.expires_in, }, instructions: [ '✅ Tokens have been automatically saved to box-tokens.json', '✅ No need to update .env file manually!', '✅ Your app will now work automatically', 'ℹ️ The tokens will be auto-refreshed in the background', ], }; } catch (err) { return { success: false, error: err.response?.data || err.message, message: 'Failed to exchange authorization code for tokens', }; } } @Post('upload') @LogToDiscord() @ApiOperation({ summary: 'Upload file to Box.com' }) @ApiConsumes('multipart/form-data') @ApiQuery({ name: 'fileType', enum: FileType, required: false, description: 'Type of file to determine upload folder (IMAGE, VIDEO, DOCUMENT). Defaults to main folder if not provided.', }) @ApiQuery({ name: 'customFilename', required: false, description: 'Custom filename (optional)', }) @ApiBody({ schema: { type: 'object', properties: { file: { type: 'string', format: 'binary', }, }, }, }) @UseInterceptors(FileInterceptor('file')) async uploadFile( @UploadedFile() file: any, @Query('fileType') fileType?: FileType, @Query('customFilename') customFilename?: string, ) { return this.boxStorageService.uploadFile({ fileBuffer: file.buffer, filename: file.originalname, fileType: fileType, customFilename: customFilename, }); } @Delete('by-id') @LogToDiscord() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Delete file by Box file ID' }) @ApiQuery({ name: 'fileId', required: true, description: 'Box file ID', type: String, }) async deleteFileById(@Query('fileId') fileId: string) { const result = await this.boxStorageService.deleteFile(fileId); return { message: 'File deleted successfully', ...result, }; } @Get('file/:fileId') @SkipApiToken() @ApiOperation({ summary: 'Stream file content from Box', description: 'Streams file content through your server. Supports images, videos, audio, PDFs, and more.', }) async streamFile(@Res() res: Response, @Param('fileId') fileId: string) { try { // Get file metadata, stream, and detected content type from service const { metadata, stream, contentType } = await this.boxStorageService.getFileWithStream(fileId); // Set appropriate headers for inline display res.setHeader('Content-Type', contentType); res.setHeader('Content-Length', metadata.size); res.setHeader('Cache-Control', 'public, max-age=31536000'); res.setHeader('Accept-Ranges', 'bytes'); // Enable video seeking res.setHeader( 'Content-Disposition', `inline; filename="${metadata.name}"`, ); // Stream file content stream.pipe(res); } catch (error) { // Check if it's a 404 error (file not found) const isNotFound = error.status === 404 || error.status === HttpStatus.NOT_FOUND; if (isNotFound) { // Send beautiful retro TV 404 page res.status(404).send(this.boxStorageService.generate404Page(fileId)); } else { // Other errors - send JSON res.status(error.status || 500).json({ success: false, message: error.message || 'Failed to stream file', }); } } } }