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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ MCP_LOG_LEVEL=INFO
# Docs Service
DOCS_PORT=3000

# =============================================================================
# Optional: Error Tracking (Self-hosted Sentry)
# =============================================================================
SENTRY_DSN= # Backend (apps/api)
VITE_SENTRY_DSN= # Frontend (apps/web, apps/sites)

# Frontend Build Variables (set during Docker build, not runtime)
VITE_API_BASE_URL=/api
VITE_VERIFY_PASSWORD=
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/build-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ jobs:
build-args: |
VITE_API_BASE_URL=/api
VITE_APP_ENV=production
VITE_SENTRY_DSN=${{ vars.VITE_SENTRY_DSN }}

build-api:
needs: detect-changes
Expand Down
19 changes: 19 additions & 0 deletions apps/api/lib/sentry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as Sentry from '@sentry/node';

export function initSentry(): void {
const dsn = process.env.SENTRY_DSN;

if (!dsn) {
console.info('Sentry DSN not configured. Error tracking disabled.');
return;
}

Sentry.init({
dsn,
environment: process.env.NODE_ENV || 'development',
enabled: process.env.NODE_ENV === 'production',
tracesSampleRate: 0,
});
}

export { Sentry };
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.71.0",
"@sentry/node": "^9.27.0",
"@gruenerator/shared": "workspace:*",
"@hocuspocus/extension-logger": "^3.4.3",
"@hocuspocus/server": "^3.4.3",
Expand Down
85 changes: 53 additions & 32 deletions apps/api/routes/subtitler/processingController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,42 @@
* Handles video processing, transcription, and export routes.
*/

import express, { Response, Router } from 'express';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import path from 'path';
import fs from 'fs';
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';

import express, { type Response, type Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { getVideoMetadata, cleanupFiles } from '../../services/subtitler/videoUploadService.js';
import { transcribeVideo } from '../../services/subtitler/transcriptionService.js';
import {
getFilePathFromUploadId,
checkFileExists,
markUploadAsProcessed,
scheduleImmediateCleanup,
getOriginalFilename,
} from '../../services/subtitler/tusService.js';
import { redisClient } from '../../utils/redis/index.js';

import AssSubtitleService from '../../services/subtitler/assSubtitleService.js';
import { processVideoAutomatically } from '../../services/subtitler/autoProcessingService.js';
import { getCompressionStatus } from '../../services/subtitler/backgroundCompressionService.js';
import {
processVideoExportInBackground,
setRedisStatus,
} from '../../services/subtitler/backgroundExportService.js';
import {
generateDownloadToken,
processDirectDownload,
processChunkedDownload,
processSubtitleSegments,
} from '../../services/subtitler/downloadUtils.js';
import { calculateFontSizing } from '../../services/subtitler/subtitleSizingService.js';
import { autoSaveProject } from '../../services/subtitler/projectSavingService.js';
import { getCompressionStatus } from '../../services/subtitler/backgroundCompressionService.js';
import { createLogger } from '../../utils/logger.js';
import { correctSubtitlesViaAI } from '../../services/subtitler/subtitleCorrectionService.js';
import { processRemotionExport } from '../../services/subtitler/remotionExportService.js';
import { processVideoAutomatically } from '../../services/subtitler/autoProcessingService.js';
import { correctSubtitlesViaAI } from '../../services/subtitler/subtitleCorrectionService.js';
import { calculateFontSizing } from '../../services/subtitler/subtitleSizingService.js';
import { transcribeVideo } from '../../services/subtitler/transcriptionService.js';
import {
processVideoExportInBackground,
setRedisStatus,
} from '../../services/subtitler/backgroundExportService.js';
getFilePathFromUploadId,
checkFileExists,
markUploadAsProcessed,
scheduleImmediateCleanup,
getOriginalFilename,
} from '../../services/subtitler/tusService.js';
import { getVideoMetadata, cleanupFiles } from '../../services/subtitler/videoUploadService.js';
import { createLogger } from '../../utils/logger.js';
import { redisClient } from '../../utils/redis/index.js';

import type { AuthenticatedRequest } from '../../middleware/types.js';

const __filename = fileURLToPath(import.meta.url);
Expand Down Expand Up @@ -231,48 +233,46 @@
// GET /export-download/:exportToken
router.get(
'/export-download/:exportToken',
async (req: SubtitlerRequest, res: Response): Promise<void> => {
const { exportToken } = req.params;
try {
const data = (await redisClient.get(`export:${exportToken}`)) as string | null;
if (!data) {
res.status(404).json({ error: 'Export not found' });
return;
}
const exportData = JSON.parse(data);
if (exportData.status !== 'complete') {
res.status(400).json({ error: 'Export not complete', status: exportData.status });
return;
}
if (!exportData.outputPath || !(await checkFileExists(exportData.outputPath))) {
res.status(404).json({ error: 'File not found' });
return;
}

const stats = await fsPromises.stat(exportData.outputPath);
const filename =
path
.basename(
exportData.originalFilename || 'video',
path.extname(exportData.originalFilename || '')
)
.replace(/[^a-zA-Z0-9_-]/g, '_') + '_gruenerator.mp4';
res.setHeader('Content-Type', 'video/mp4');
res.setHeader('Content-Length', stats.size);
res.setHeader('Content-Disposition', `attachment; filename=${filename}`);

const stream = fs.createReadStream(exportData.outputPath);
stream.pipe(res);
stream.on('end', () =>
setTimeout(async () => {
await cleanupFiles(null, exportData.outputPath).catch(() => {});
await redisClient.del(`export:${exportToken}`).catch(() => {});
}, 2000)
);
stream.on('error', (err) => {
log.error(`Stream error for export ${exportToken}: ${err.message}`);
if (!res.headersSent) res.status(500).end();
});
} catch (e: any) {
if (!res.headersSent) res.status(500).json({ error: e.message });
}
}

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a database access
, but is not rate-limited.
This route handler performs
a file system access
, but is not rate-limited.
This route handler performs
a file system access
, but is not rate-limited.
This route handler performs
a file system access
, but is not rate-limited.
This route handler performs
a file system access
, but is not rate-limited.
);

// GET /internal-video/:uploadId - Internal video streaming for Remotion
Expand Down Expand Up @@ -351,7 +351,9 @@
inputPath = ps.getVideoPath(proj.video_path);
originalFilename = proj.video_filename || 'video.mp4';
}
} catch {}
} catch {
/* ignored */
}
}
if (!inputPath && uploadId) {
inputPath = getFilePathFromUploadId(uploadId);
Expand All @@ -375,7 +377,22 @@
outputDir,
`${path.basename(originalFilename, path.extname(originalFilename))}_${Date.now()}.mp4`
);
const segments = processSubtitleSegments(subtitles);
let segments: { startTime: number; endTime: number; text: string }[];
if (Array.isArray(subtitles)) {
segments = subtitles
.map((s: Record<string, unknown>) => ({
startTime: Number(s.start ?? s.startTime ?? 0),
endTime: Number(s.end ?? s.endTime ?? 0),
text: String(s.text ?? ''),
}))
.filter((s) => s.text && s.endTime > s.startTime)
.sort((a, b) => a.startTime - b.startTime);
if (segments.length === 0) {
throw new Error('Keine gültigen Untertitel-Segmente gefunden');
}
} else {
segments = processSubtitleSegments(subtitles);
}
const { finalFontSize } = calculateFontSizing(metadata, segments);

// Generate ASS
Expand Down Expand Up @@ -424,7 +441,9 @@
await fsPromises.copyFile(srcFont, tempFontPath).catch(() => {
tempFontPath = null;
});
} catch {}
} catch {
/* ignored */
}

await redisClient.set(
`export:${exportToken}`,
Expand Down Expand Up @@ -617,7 +636,9 @@
exportToken: result.autoProcessToken,
});
projectId = r.projectId;
} catch {}
} catch {
/* ignored */
}
}
await redisClient.set(
`auto:${uploadId}`,
Expand Down
3 changes: 3 additions & 0 deletions apps/api/scrape-schleswig-holstein.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ async function main() {
// Step 6: Store in Qdrant
console.log('6. Storing in Qdrant...');
const qdrant = getQdrantInstance();
if (!qdrant.client) {
throw new Error('Qdrant client not initialized');
}
for (let i = 0; i < points.length; i += BATCH_SIZE) {
const batch = points.slice(i, i + BATCH_SIZE);
await batchUpsert(qdrant.client, COLLECTION_NAME, batch);
Expand Down
26 changes: 18 additions & 8 deletions apps/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,46 @@

// Load environment variables FIRST before any other imports
import 'dotenv/config';
import { initSentry, Sentry } from './lib/sentry.js';
initSentry();

import express, { type Express, type Request, type Response, type NextFunction } from 'express';

import express, { Express, Request, Response, NextFunction } from 'express';
import cluster from 'cluster';
import os from 'os';

import compression from 'compression';
import cors from 'cors';
import morgan from 'morgan';

import fs from 'fs';
import http from 'http';
import path from 'path';
import { fileURLToPath } from 'url';

import helmet from 'helmet';
import multer from 'multer';
import session from 'express-session';
import { RedisStore } from 'connect-redis';

// Local imports
import { createLogger } from './utils/logger.js';
import { shouldSkipBodyParser, TUS_UPLOAD_PATHS } from './middleware/bodyParserConfig.js';
import { createCacheMiddleware } from './middleware/cacheMiddleware.js';
import { setupRoutes } from './routes.js';
import { startCleanupScheduler as startExportCleanup } from './services/subtitler/exportCleanupService.js';
import { tusServer } from './services/subtitler/tusService.js';
import { getCorsOrigins, PRIMARY_DOMAIN } from './utils/domainUtils.js';
import { createLogger } from './utils/logger.js';
import { createCorsOptions } from './config/cors.js';
import { getServerConfig } from './config/serverConfig.js';
import { createCacheMiddleware } from './middleware/cacheMiddleware.js';
import { shouldSkipBodyParser, TUS_UPLOAD_PATHS } from './middleware/bodyParserConfig.js';
import redisClient, { ensureConnected, checkRedisHealth } from './utils/redis/client.js';
import {
createMasterShutdownHandler,
createWorkerShutdownHandler,
} from './utils/shutdown/index.js';
import passport from './config/passportSetup.js';
import { setupRoutes } from './routes.js';
import AIWorkerPool from './workers/aiWorkerPool.js';
import redisClient, { ensureConnected, checkRedisHealth } from './utils/redis/client.js';
import { tusServer } from './services/subtitler/tusService.js';
import { startCleanupScheduler as startExportCleanup } from './services/subtitler/exportCleanupService.js';

import { spawn } from 'child_process';

const __filename = fileURLToPath(import.meta.url);
Expand Down Expand Up @@ -509,6 +516,9 @@ async function startWorker(): Promise<void> {
next();
});

// Sentry error handler (must be before custom error handler)
Sentry.setupExpressErrorHandler(app);

// Error handler
app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
// Prevent "Cannot set headers after they are sent" errors
Expand Down
3 changes: 3 additions & 0 deletions apps/api/services/subtitler/gladiaService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

import fs from 'fs';
import https from 'https';

import FormData from 'form-data';

import { createLogger } from '../../utils/logger.js';
import { redisClient } from '../../utils/redis/index.js';

Expand Down Expand Up @@ -318,6 +320,7 @@ function transformToOpenAIFormat(
requestWordTimestamps: boolean
): TranscriptionResult {
if (!gladiaResponse?.result?.transcription?.full_transcript) {
log.error(`Unexpected Gladia response structure: ${JSON.stringify(gladiaResponse, null, 2)}`);
throw new Error('Invalid Gladia response: missing transcription');
}

Expand Down
2 changes: 1 addition & 1 deletion apps/api/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"esModuleInterop": true,
"allowJs": true,
"checkJs": false,
"strict": false,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
Expand Down
7 changes: 3 additions & 4 deletions apps/sites/.env.local.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Highlight.io Error Tracking
# Sign up at https://app.highlight.io and create a project
# Get your project ID from: https://app.highlight.io/setup
VITE_HIGHLIGHT_PROJECT_ID=your_project_id_here
# Sentry Error Tracking
# Get your DSN from your self-hosted Sentry instance
VITE_SENTRY_DSN=https://examplePublicKey@sentry.gruenerator.de/1

# API Configuration (optional, defaults to /api)
VITE_API_BASE_URL=/api
Expand Down
4 changes: 4 additions & 0 deletions apps/sites/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
COPY packages/shared ./packages/shared
COPY apps/sites ./apps/sites

# Build environment variables (baked at build time by Vite)
ARG VITE_SENTRY_DSN
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN

# Build the app
WORKDIR /app/apps/sites
RUN pnpm build
Expand Down
2 changes: 1 addition & 1 deletion apps/sites/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
},
"dependencies": {
"@gruenerator/shared": "workspace:*",
"@highlight-run/react": "^21.0.0",
"@sentry/react": "^9.27.0",
"@mdxeditor/editor": "^3.52.3",
"@tanstack/react-query": "^5.90.16",
"axios": "^1.13.2",
Expand Down
16 changes: 12 additions & 4 deletions apps/sites/src/lib/errorTracking.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import * as Sentry from '@sentry/react';

let errorTrackingInitialized = false;

export function initErrorTracking(): void {
const projectId = import.meta.env.VITE_HIGHLIGHT_PROJECT_ID;
const dsn = import.meta.env.VITE_SENTRY_DSN;

if (!projectId) {
if (!dsn) {
console.info(

Check warning on line 9 in apps/sites/src/lib/errorTracking.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
'Highlight.io project ID not configured. Error tracking disabled. ' +
'Set VITE_HIGHLIGHT_PROJECT_ID environment variable to enable error tracking.'
'Sentry DSN not configured. Error tracking disabled. ' +
'Set VITE_SENTRY_DSN environment variable to enable error tracking.'
);
return;
}
Expand All @@ -17,7 +19,13 @@
}

try {
Sentry.init({
dsn,
environment: import.meta.env.MODE,
enabled: import.meta.env.PROD,
tracesSampleRate: 0,
});
console.info('Error tracking initialized successfully');

Check warning on line 28 in apps/sites/src/lib/errorTracking.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
errorTrackingInitialized = true;
} catch (error) {
console.error('Failed to initialize error tracking:', error);
Expand Down
14 changes: 11 additions & 3 deletions apps/sites/src/utils/errorReporter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import * as Sentry from '@sentry/react';

import { isErrorTrackingEnabled } from '../lib/errorTracking';

export function reportError(error: Error, context?: Record<string, unknown>): void {
console.error('Error reported:', error, context);
if (isErrorTrackingEnabled()) {
Sentry.captureException(error, { extra: context });
}
}

export function reportMessage(
Expand All @@ -10,18 +15,21 @@
context?: Record<string, unknown>
): void {
const consoleMethod = level === 'warning' ? 'warn' : level;
console[consoleMethod]('Message reported:', message, context);

Check warning on line 18 in apps/sites/src/utils/errorReporter.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
if (isErrorTrackingEnabled()) {
Sentry.captureMessage(message, { level, extra: context });
}
}

export function setUserContext(user: { id: string; email?: string }): void {
if (isErrorTrackingEnabled()) {
console.info('User context set:', user.id);
Sentry.setUser({ id: user.id, email: user.email });
}
}

export function clearUserContext(): void {
if (isErrorTrackingEnabled()) {
console.info('User context cleared');
Sentry.setUser(null);
}
}

Expand All @@ -31,6 +39,6 @@
data?: Record<string, unknown>
): void {
if (isErrorTrackingEnabled()) {
console.info('Breadcrumb:', { message, category, ...data });
Sentry.addBreadcrumb({ message, category, data });
}
}
Loading
Loading