Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .cursor/skills/signbank-frontend/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ Uploaded videos return relative paths from API. Display via nginx:

Example: API returns `gloss-videos/abc.mp4` → src `/lscassets/gloss-videos/abc.mp4`.

Google Drive URLs stored in the DB are rewritten by `getVideoUrl()` to:

```
/api/videos/drive/{fileId}
```

The backend streams them with `GOOGLE_DRIVE_API_KEY`. Files must be shared as "Anyone with the link".

Validation helper: `src/utils/videoValidation.ts`.

## Search UI
Expand Down
1 change: 1 addition & 0 deletions .cursor/skills/signbank-infrastructure/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Local config: `nginx/default.conf`. Production/test variants under `nginx/nginx.
https://host/ → frontend:443
https://host/ws → frontend (WebSocket/HMR)
https://host/api/* → backend:443 (strip /api prefix)
https://host/api/videos/drive/* → backend Drive proxy (no buffering)
https://host/lscassets/* → dufs:5000 (video files)
```

Expand Down
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ TYPESENSE_PORT=8108
FILE_SERVER_PATH=/production/FileServer
DUFS_URL=http://dufs:5000

# --- Google Drive videos (optional) ---
# Required to play videos stored as drive.google.com URLs in the DB.
# Create a Google Cloud API key with the Drive API enabled. Files must be
# shared as "Anyone with the link" (an API key cannot impersonate your account).
GOOGLE_DRIVE_API_KEY=

# --- JWT authentication ---
JWT_SECRET=your-jwt-secret-here-min-32-chars-long
JWT_EXPIRATION=24h
Expand Down
1 change: 1 addition & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ The following diagram illustrates the main structure of the database used by the
---

## Videos (`/videos`)
- `GET /videos/drive/:fileId` — Stream a Google Drive video (public; requires `GOOGLE_DRIVE_API_KEY`)
- `POST /videos/upload` — Upload a video (JWT required)
- `DELETE /videos/:videoUrl(*)` — Delete a video (JWT required)

Expand Down
48 changes: 48 additions & 0 deletions backend/src/videos/google-drive.util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
extractGoogleDriveFileId,
extractGoogleDriveResourceKey,
isDriveFileId,
isGoogleDriveUrl,
} from './google-drive.util';

describe('google-drive.util', () => {
const fileId = '1f8YqJU_Lne5Az0o8gyAR1CAPKE3CbA8x';

it('accepts typical Drive file ids', () => {
expect(isDriveFileId(fileId)).toBe(true);
expect(isDriveFileId('short')).toBe(false);
expect(isDriveFileId('../etc/passwd')).toBe(false);
});

it('extracts ids from sharing links', () => {
expect(
extractGoogleDriveFileId(
`https://drive.google.com/file/d/${fileId}/view?usp=sharing`,
),
).toBe(fileId);
});

it('extracts ids from open and uc links', () => {
expect(
extractGoogleDriveFileId(`https://drive.google.com/open?id=${fileId}`),
).toBe(fileId);
expect(
extractGoogleDriveFileId(
`https://drive.google.com/uc?export=download&id=${fileId}`,
),
).toBe(fileId);
});

it('reads resource keys', () => {
expect(
extractGoogleDriveResourceKey(
`https://drive.google.com/file/d/${fileId}/view?usp=sharing&resourcekey=abc-123`,
),
).toBe('abc-123');
});

it('ignores non-Drive paths', () => {
expect(extractGoogleDriveFileId('gloss-videos/example.mp4')).toBeNull();
expect(isGoogleDriveUrl('https://example.com/video.mp4')).toBe(false);
});
});
50 changes: 50 additions & 0 deletions backend/src/videos/google-drive.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const DRIVE_FILE_ID_PATTERN = /^[a-zA-Z0-9_-]{10,128}$/;

export function isDriveFileId(fileId: string): boolean {
return DRIVE_FILE_ID_PATTERN.test(fileId);
}

function hostLooksLikeGoogleDrive(hostname: string): boolean {
const host = hostname.toLowerCase();
return host.includes('google.com') || host.includes('googleusercontent.com');
}

export function extractGoogleDriveFileId(value: string): string | null {
if (!value?.trim()) {
return null;
}

try {
const parsed = new URL(value.trim());
if (!hostLooksLikeGoogleDrive(parsed.hostname)) {
return null;
}

const fromQuery = parsed.searchParams.get('id');
if (fromQuery && isDriveFileId(fromQuery)) {
return fromQuery;
}

const fromPath = parsed.pathname.match(/\/(?:file\/)?d\/([^/]+)/);
if (fromPath?.[1] && isDriveFileId(fromPath[1])) {
return fromPath[1];
}
} catch {
return null;
}

return null;
}

export function extractGoogleDriveResourceKey(value: string): string | null {
try {
const parsed = new URL(value.trim());
return parsed.searchParams.get('resourcekey') || parsed.searchParams.get('resourceKey');
} catch {
return null;
}
}

export function isGoogleDriveUrl(value: string): boolean {
return extractGoogleDriveFileId(value) !== null;
}
16 changes: 14 additions & 2 deletions backend/src/videos/videos.controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Controller, Post, Delete, UploadedFile, UseInterceptors, Body, Param } from '@nestjs/common';
import { Controller, Post, Delete, UploadedFile, UseInterceptors, Body, Param, Get, Query, Req, Res, UseGuards } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { VideosService } from './videos.service';
import { diskStorage } from 'multer';
import { extname, join } from 'path';
import { UseGuards } from '@nestjs/common';
import { JwtGuard } from '../auth/guard/jwt.guard';
import { RolesGuard } from '../auth/guard/roles.guard';
import { Public } from '../auth/decorator/public.decorator';
import { promises as fs } from 'fs';
import type { Request, Response } from 'express';

@Controller('videos')
@UseGuards(JwtGuard, RolesGuard)
Expand All @@ -33,6 +34,17 @@ export class VideosController {
}
}

@Public()
@Get('drive/:fileId')
async streamDriveVideo(
@Param('fileId') fileId: string,
@Query('resourceKey') resourceKey: string | undefined,
@Req() req: Request,
@Res() res: Response,
) {
return this.videosService.streamDriveVideo(fileId, req, res, resourceKey);
}

@Post('upload')
@UseInterceptors(
FileInterceptor('video', {
Expand Down
58 changes: 58 additions & 0 deletions backend/src/videos/videos.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { HttpException } from '@nestjs/common';
import axios from 'axios';
import { VideosService } from './videos.service';

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

describe('VideosService Drive streaming', () => {
const originalApiKey = process.env.GOOGLE_DRIVE_API_KEY;
let service: VideosService;

beforeEach(() => {
jest.resetAllMocks();
mockedAxios.isAxiosError = axios.isAxiosError;
process.env.GOOGLE_DRIVE_API_KEY = 'test-key';
service = new VideosService();
});

afterAll(() => {
process.env.GOOGLE_DRIVE_API_KEY = originalApiKey;
});

it('does not delete Google Drive files from Dufs', async () => {
await expect(
service.deleteVideo(
'https://drive.google.com/file/d/1f8YqJU_Lne5Az0o8gyAR1CAPKE3CbA8x/view?usp=sharing',
),
).resolves.toBeUndefined();
expect(mockedAxios.delete).not.toHaveBeenCalled();
});

it('rejects invalid Drive file ids', async () => {
await expect(
service.streamDriveVideo(
'../nope',
{ headers: {}, on: jest.fn() } as never,
{} as never,
),
).rejects.toBeInstanceOf(HttpException);
});

it('fails closed when the API key is missing', async () => {
delete process.env.GOOGLE_DRIVE_API_KEY;
service = new VideosService();

try {
await service.streamDriveVideo(
'1f8YqJU_Lne5Az0o8gyAR1CAPKE3CbA8x',
{ headers: {}, on: jest.fn() } as never,
{} as never,
);
throw new Error('expected streamDriveVideo to reject');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect((error as HttpException).getStatus()).toBe(503);
}
});
});
122 changes: 119 additions & 3 deletions backend/src/videos/videos.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,22 @@ import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { createReadStream } from 'fs';
import { stat, unlink } from 'fs/promises';
import axios from 'axios';
import { basename, join } from 'path';
import { basename } from 'path';
import type { Request, Response } from 'express';
import { isDriveFileId, isGoogleDriveUrl } from './google-drive.util';

@Injectable()
export class VideosService {
private readonly dufsUrl = process.env.DUFS_URL || 'http://localhost:5000';

constructor() {
if (!process.env.GOOGLE_DRIVE_API_KEY?.trim()) {
console.warn(
'GOOGLE_DRIVE_API_KEY is not set; Google Drive videos will not play',
);
}
}

async uploadVideo(file: any, type: 'gloss' | 'example' | 'definition' = 'gloss'): Promise<{ url: string }> {
try {
const fileStream = createReadStream(file.path);
Expand Down Expand Up @@ -62,16 +72,122 @@ export class VideosService {

async deleteVideo(videoUrl: string): Promise<void> {
console.log('Deleting video', videoUrl);

if (
isGoogleDriveUrl(videoUrl) ||
videoUrl.startsWith('http://') ||
videoUrl.startsWith('https://')
) {
return;
}

try {
// The videoUrl is already in the format "baseDir/filename"
const deleteUrl = `${this.dufsUrl}/${videoUrl}`;

await axios.delete(deleteUrl);
} catch (error) {
} catch {
throw new HttpException(
'Failed to delete video',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}

async streamDriveVideo(
fileId: string,
req: Request,
res: Response,
resourceKey?: string,
): Promise<void> {
if (!isDriveFileId(fileId)) {
throw new HttpException('Invalid Google Drive file id', HttpStatus.BAD_REQUEST);
}

const apiKey = process.env.GOOGLE_DRIVE_API_KEY?.trim();
if (!apiKey) {
throw new HttpException(
'GOOGLE_DRIVE_API_KEY is not configured',
HttpStatus.SERVICE_UNAVAILABLE,
);
}

const driveUrl = new URL(
`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}`,
);
driveUrl.searchParams.set('alt', 'media');
driveUrl.searchParams.set('supportsAllDrives', 'true');
driveUrl.searchParams.set('key', apiKey);

const headers: Record<string, string> = {};
const range = req.headers.range;
if (typeof range === 'string' && range.length > 0) {
headers.Range = range;
}
if (resourceKey) {
headers['X-Goog-Drive-Resource-Keys'] = `${fileId}/${resourceKey}`;
}

try {
const driveResponse = await axios.get(driveUrl.toString(), {
headers,
responseType: 'stream',
timeout: 0,
maxContentLength: Infinity,
maxBodyLength: Infinity,
validateStatus: (status) => status === 200 || status === 206,
});

res.status(driveResponse.status);
res.setHeader(
'Content-Type',
driveResponse.headers['content-type'] || 'video/mp4',
);
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Cache-Control', 'public, max-age=3600');

const contentLength = driveResponse.headers['content-length'];
if (contentLength) {
res.setHeader('Content-Length', contentLength);
}
const contentRange = driveResponse.headers['content-range'];
if (contentRange) {
res.setHeader('Content-Range', contentRange);
}

const stream = driveResponse.data as NodeJS.ReadableStream;
const abort = () => {
if (typeof (stream as { destroy?: () => void }).destroy === 'function') {
(stream as { destroy: () => void }).destroy();
}
};
req.on('close', abort);
stream.on('error', abort);
stream.pipe(res);
} catch (error) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const data = error.response?.data as { destroy?: () => void } | undefined;
data?.destroy?.();

if (status === 401 || status === 403) {
throw new HttpException(
'Google Drive denied access. Share the file with "Anyone with the link" and check GOOGLE_DRIVE_API_KEY.',
HttpStatus.FORBIDDEN,
);
}
if (status === 404) {
throw new HttpException(
'Google Drive file not found. Confirm the file id and that it is shared with "Anyone with the link".',
HttpStatus.NOT_FOUND,
);
}
}

throw new HttpException(
'Failed to fetch video from Google Drive',
HttpStatus.BAD_GATEWAY,
);
}
}
}
1 change: 1 addition & 0 deletions docker-compose-local.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ services:
- TYPESENSE_PORT=${TYPESENSE_PORT}
- JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRATION=${JWT_EXPIRATION:-24h}
- GOOGLE_DRIVE_API_KEY=${GOOGLE_DRIVE_API_KEY:-}
- SEED_DB=${SEED_DB:-false}
ports:
- 9229:9229
Expand Down
Loading
Loading