File size: 7,780 Bytes
c5b7daf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b357ab7
c5b7daf
 
 
 
 
 
 
 
 
7babbe1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c5b7daf
cfe53d1
b357ab7
c5b7daf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cfe53d1
b357ab7
c5b7daf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cfe53d1
c5b7daf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cfe53d1
c5b7daf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f195c2
c5b7daf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
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<string>('boxStorage.clientId');
    const redirectUri =
      this.configService.get<string>('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<string>('boxStorage.clientId');
      const clientSecret = this.configService.get<string>(
        'boxStorage.clientSecret',
      );
      const redirectUri =
        this.configService.get<string>('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<string, string>),
        {
          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',
        });
      }
    }
  }
}