Spaces:
Runtime error
Runtime error
| 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; | |
| } | |
| () | |
| 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<string>('boxStorage.folders.streamflix') || '0', | |
| images: | |
| this.configService.get<string>('boxStorage.folders.images') || '0', | |
| videos: | |
| this.configService.get<string>('boxStorage.folders.videos') || '0', | |
| documents: | |
| this.configService.get<string>('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<void> { | |
| 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<string> { | |
| // If token is still valid, return it | |
| if (this.accessToken && Date.now() < this.tokenExpiresAt) { | |
| return this.accessToken; | |
| } | |
| // Get credentials | |
| const clientId = this.configService.get<string>('boxStorage.clientId'); | |
| const clientSecret = this.configService.get<string>( | |
| '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<BoxTokenResponse>( | |
| '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<any> { | |
| 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<string, string> = { | |
| // 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 = `<?xml version="1.0" encoding="UTF-8"?> | |
| <svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640"> | |
| <rect width="640" height="640" fill="#000"/> | |
| <g fill="#fff" opacity="0.18" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial" font-weight="700" font-size="260"> | |
| <text x="80" y="410">404</text> | |
| </g> | |
| <g transform="translate(320 320)"> | |
| <circle r="86" fill="#fff" opacity="0.10"/> | |
| <circle r="54" fill="#fff" opacity="0.12"/> | |
| <path d="M-26 -20 h52 a22 22 0 0 1 22 22 v30 a22 22 0 0 1-22 22 h-52 a22 22 0 0 1-22-22 v-30 a22 22 0 0 1 22-22z" fill="#fff" opacity="0.75"/> | |
| <path d="M-12 -8 h24 a12 12 0 0 1 12 12 v12 a12 12 0 0 1-12 12 h-24 a12 12 0 0 1-12-12 v-12 a12 12 0 0 1 12-12z" fill="#000" opacity="0.55"/> | |
| <path d="M-42 54 c18 26 38 40 62 40 26 0 46-14 62-40" fill="none" stroke="#fff" stroke-width="14" opacity="0.35" stroke-linecap="round"/> | |
| </g> | |
| </svg>`; | |
| return { | |
| contentType: 'image/svg+xml; charset=utf-8', | |
| body: Buffer.from(svg, 'utf8'), | |
| }; | |
| } | |
| } | |
| /** | |
| * Get file metadata | |
| */ | |
| async getFileMetadata(fileId: string): Promise<any> { | |
| 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, | |
| ); | |
| } | |
| } | |
| } | |