streamflix-api / src /shared /modules /upload /box-storage /box-storage.service.ts
Akshar2325
feat(box-storage): add error illustration endpoint and caching for 404 page
7babbe1
Raw
History Blame
17.4 kB
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<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,
);
}
}
}