import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; import FormData = require('form-data'); import * as fs from 'fs'; import * as path from 'path'; import { generate404ErrorPage } from '../../../utils/error-pages/error-404.util'; interface BoxTokenResponse { access_token: string; expires_in: number; token_type: string; refresh_token?: string; } interface BoxTokensFile { accessToken: string; refreshToken: string; expiresAt: number; updatedAt: string; } @Injectable() export class BoxStorageService { private readonly logger = new Logger(BoxStorageService.name); private accessToken: string; private refreshToken: string; private tokenExpiresAt: number = 0; private readonly BOX_API_URL = 'https://api.box.com/2.0'; private readonly BOX_UPLOAD_URL = 'https://upload.box.com/api/2.0'; private readonly tokensFilePath = path.join(process.cwd(), 'box-tokens.json'); private errorIllustrationCache?: { contentType: string; body: Buffer; fetchedAt: number; }; private folders: { streamflix: string; images: string; videos: string; documents: string; }; constructor(private configService: ConfigService) { this.folders = { streamflix: this.configService.get('boxStorage.folders.streamflix') || '0', images: this.configService.get('boxStorage.folders.images') || '0', videos: this.configService.get('boxStorage.folders.videos') || '0', documents: this.configService.get('boxStorage.folders.documents') || '0', }; // Load tokens from file on startup this.loadTokensFromFile(); this.logger.log('Box Storage Service initialized with OAuth 2.0'); } /** * Load tokens from file on startup */ private async loadTokensFromFile(): Promise { try { if (fs.existsSync(this.tokensFilePath)) { const data = fs.readFileSync(this.tokensFilePath, 'utf8'); const tokens: BoxTokensFile = JSON.parse(data); this.accessToken = tokens.accessToken; this.refreshToken = tokens.refreshToken; this.tokenExpiresAt = tokens.expiresAt; this.logger.log('✅ Loaded tokens from box-tokens.json'); this.logger.log( `Token expires at: ${new Date(this.tokenExpiresAt).toLocaleString()}`, ); } else { // No tokens found - user must authorize this.logger.warn('⚠️ No tokens found! Please authorize via OAuth:'); this.logger.warn( `Visit: http://localhost:${this.configService.get('PORT') || 5119}/box/oauth/authorize`, ); } } catch (error) { this.logger.error('Error loading tokens from file:', error.message); throw new HttpException( 'Failed to load Box tokens', HttpStatus.INTERNAL_SERVER_ERROR, ); } } /** * Save tokens to file */ private async saveTokensToFile( accessToken: string, refreshToken: string, expiresIn: number, ) { try { const tokens: BoxTokensFile = { accessToken, refreshToken, expiresAt: Date.now() + expiresIn * 1000, updatedAt: new Date().toISOString(), }; fs.writeFileSync( this.tokensFilePath, JSON.stringify(tokens, null, 2), 'utf8', ); this.logger.log('✅ Tokens saved to box-tokens.json'); } catch (error) { this.logger.error('Failed to save tokens to file:', error.message); } } /** * Save tokens to file (public method for OAuth callback) */ async saveInitialTokens( accessToken: string, refreshToken: string, expiresIn: number, ) { this.accessToken = accessToken; this.refreshToken = refreshToken; this.tokenExpiresAt = Date.now() + (expiresIn - 300) * 1000; await this.saveTokensToFile(accessToken, refreshToken, expiresIn); this.logger.log('✅ Initial OAuth tokens saved successfully'); } /** * Get folder ID based on file type */ private getFolderId(fileType?: string): string { if (!fileType) { return this.folders.streamflix; } const folderMap = { IMAGE: this.folders.images, VIDEO: this.folders.videos, DOCUMENT: this.folders.documents, }; return folderMap[fileType] || this.folders.streamflix; } /** * Get or refresh access token */ private async getAccessToken(): Promise { // If token is still valid, return it if (this.accessToken && Date.now() < this.tokenExpiresAt) { return this.accessToken; } // Get credentials const clientId = this.configService.get('boxStorage.clientId'); const clientSecret = this.configService.get( 'boxStorage.clientSecret', ); if (!this.refreshToken || !clientId || !clientSecret) { throw new HttpException( 'Box OAuth credentials not configured. Run authorization flow first.', HttpStatus.INTERNAL_SERVER_ERROR, ); } try { const response = await axios.post( 'https://api.box.com/oauth2/token', new URLSearchParams({ grant_type: 'refresh_token', refresh_token: this.refreshToken, client_id: clientId, client_secret: clientSecret, }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }, ); this.accessToken = response.data.access_token; this.tokenExpiresAt = Date.now() + (response.data.expires_in - 300) * 1000; // Box may issue a new refresh token const newRefreshToken = response.data.refresh_token || this.refreshToken; if (newRefreshToken !== this.refreshToken) { this.logger.log( '🔄 Box issued a new refresh token - updating stored token', ); this.refreshToken = newRefreshToken; } // Save tokens to file for persistence await this.saveTokensToFile( this.accessToken, this.refreshToken, response.data.expires_in, ); this.logger.log('✅ Access token refreshed successfully'); return this.accessToken; } catch (error) { this.logger.error( 'Failed to refresh access token:', error.response?.data, ); // If refresh token expired, user needs to re-authorize if (error.response?.data?.error === 'invalid_grant') { this.logger.error( '❌ Refresh token expired! Please run OAuth authorization again:', ); this.logger.error( `Visit: http://localhost:${this.configService.get('PORT') || 5119}/box/oauth/authorize`, ); } throw new HttpException( 'Failed to authenticate with Box. Please re-authorize.', HttpStatus.UNAUTHORIZED, ); } } /** * Upload file to Box.com */ async uploadFile(params: { fileBuffer: Buffer; filename: string; fileType?: string; customFilename?: string; }) { const { fileBuffer, filename, customFilename, fileType } = params; const folderId = this.getFolderId(fileType); const timestamp = Date.now(); const finalFilename = customFilename || filename; const sanitizedFilename = finalFilename.replace(/[^a-zA-Z0-9.-]/g, '_'); const boxFilename = `${timestamp}-${sanitizedFilename}`; try { const token = await this.getAccessToken(); // Create form data for upload const form = new FormData(); form.append( 'attributes', JSON.stringify({ name: boxFilename, parent: { id: folderId }, }), ); form.append('file', fileBuffer, boxFilename); // Upload file const uploadResponse = await axios.post( `${this.BOX_UPLOAD_URL}/files/content`, form, { headers: { ...form.getHeaders(), Authorization: `Bearer ${token}`, }, }, ); const file = uploadResponse.data.entries[0]; this.logger.log(`File uploaded: ${file.name} (ID: ${file.id})`); // Create shared link const sharedLinkResponse = await axios.put( `${this.BOX_API_URL}/files/${file.id}`, { shared_link: { access: 'open', permissions: { can_download: true, }, }, }, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, }, ); const sharedLink = sharedLinkResponse.data.shared_link; return { success: true, name: file.name, boxFileId: file.id, size: file.size, folderId: folderId || '0', publicUrl: sharedLink.url, // Note: Box direct links don't work for regular user accounts // Use the /box/file/:fileId endpoint to stream content through your server contentUrl: `${this.configService.get('APP_URL')}/box/file/${file.id}`, extraData: { sha1: file.sha1, createdAt: file.created_at, modifiedAt: file.modified_at, sharedLink: sharedLink, }, }; } catch (error) { this.logger.error( 'Upload failed:', error.response?.data || error.message, ); throw new HttpException( error.response?.data?.message || 'Failed to upload file to Box', error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, ); } } /** * Get file content as stream (use this to serve files through your server) */ async getFileContent(fileId: string): Promise { try { const token = await this.getAccessToken(); const response = await axios.get( `${this.BOX_API_URL}/files/${fileId}/content`, { headers: { Authorization: `Bearer ${token}`, }, responseType: 'stream', }, ); return response.data; } catch (error) { this.logger.error( 'Failed to get file content:', error.response?.data || error.message, ); throw new HttpException( 'Failed to get file content from Box', error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, ); } } /** * Detect MIME type from file extension */ private detectMimeType(filename: string, boxContentType?: string): string { // Use Box's content type if available and valid if (boxContentType && boxContentType !== 'application/octet-stream') { return boxContentType; } const ext = filename.split('.').pop()?.toLowerCase(); const mimeTypes: Record = { // Images jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon', tiff: 'image/tiff', tif: 'image/tiff', // Videos mp4: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', avi: 'video/x-msvideo', mov: 'video/quicktime', wmv: 'video/x-ms-wmv', flv: 'video/x-flv', mkv: 'video/x-matroska', m4v: 'video/x-m4v', '3gp': 'video/3gpp', // Audio mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', oga: 'audio/ogg', m4a: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac', weba: 'audio/webm', // Documents pdf: 'application/pdf', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // Text txt: 'text/plain', html: 'text/html', htm: 'text/html', css: 'text/css', js: 'application/javascript', json: 'application/json', xml: 'application/xml', csv: 'text/csv', md: 'text/markdown', // Archives zip: 'application/zip', rar: 'application/x-rar-compressed', '7z': 'application/x-7z-compressed', tar: 'application/x-tar', gz: 'application/gzip', }; return mimeTypes[ext || ''] || 'application/octet-stream'; } /** * Get file with metadata and stream (for controller use) */ async getFileWithStream(fileId: string): Promise<{ metadata: any; stream: any; contentType: string; }> { const metadata = await this.getFileMetadata(fileId); const stream = await this.getFileContent(fileId); const contentType = this.detectMimeType( metadata.name, metadata.content_type, ); return { metadata, stream, contentType, }; } /** * Generate beautiful 404 HTML page (Shadcn-inspired design) */ generate404Page(fileId: string): string { return generate404ErrorPage({ fileId, title: 'Whoops!', message: 'File Not Found', description: "The file you're looking for isn't found. It may have been deleted or moved to another location.", illustrationSrc: '/box/error-illustration', }); } /** * Same-origin illustration for the 404 page. * * Why: Some hosts/proxies (and some browser CSP/COEP setups) block loading images from * third-party domains. Serving the image from our own origin makes it work everywhere. */ async get404Illustration(): Promise<{ contentType: string; body: Buffer }> { const cacheTtlMs = 24 * 60 * 60 * 1000; const cached = this.errorIllustrationCache; if (cached && Date.now() - cached.fetchedAt < cacheTtlMs) { return { contentType: cached.contentType, body: cached.body }; } const remoteUrl = 'https://cdn.shadcnstudio.com/ss-assets/blocks/marketing/error/image-1.png'; try { const response = await axios.get(remoteUrl, { responseType: 'arraybuffer', timeout: 7000, headers: { // Some CDNs behave differently based on Accept. Accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8', }, // Avoid following unexpected redirects forever. maxRedirects: 3, }); const contentType = (response.headers?.['content-type'] as string) || 'image/png'; const body = Buffer.from(response.data); this.errorIllustrationCache = { contentType, body, fetchedAt: Date.now(), }; return { contentType, body }; } catch { // Don't spam logs for a non-critical decorative asset. const svg = ` 404 `; return { contentType: 'image/svg+xml; charset=utf-8', body: Buffer.from(svg, 'utf8'), }; } } /** * Get file metadata */ async getFileMetadata(fileId: string): Promise { try { const token = await this.getAccessToken(); const response = await axios.get(`${this.BOX_API_URL}/files/${fileId}`, { headers: { Authorization: `Bearer ${token}`, }, }); return response.data; } catch (error) { this.logger.error( 'Failed to get file metadata:', error.response?.data || error.message, ); throw new HttpException( 'Failed to get file metadata from Box', error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, ); } } /** * Delete file from Box */ async deleteFile(fileId: string) { try { const token = await this.getAccessToken(); await axios.delete(`${this.BOX_API_URL}/files/${fileId}`, { headers: { Authorization: `Bearer ${token}`, }, }); this.logger.log(`File deleted: ${fileId}`); return { success: true, deletedFileId: fileId, }; } catch (error) { this.logger.error( 'Delete failed:', error.response?.data || error.message, ); throw new HttpException( error.response?.data?.message || 'Failed to delete file from Box', error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, ); } } }