Configure VM services and fix auth flow

- point MySQL and Whisper settings to the VM
- add VM MySQL bootstrap scripts and docs
- allow LAN Vite origins for CORS
- fix Express 5 validation assignment crash
- allow login with username or email
- prevent recursive auth refresh retries
This commit is contained in:
KevinB-T committed 2026-05-15 15:19:49 +05:30
1 parent ca2d1cd716
commit 30894e7f27
151 files changed
+21128

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
NODE_ENV=development
HOST=127.0.0.1
PORT=4000
CLIENT_ORIGIN=http://localhost:5173
DB_HOST=192.168.X.X
DB_PORT=3306
DB_NAME=orphion
DB_USER=orphion
DB_PASSWORD=change_me
DB_AUTO_MIGRATE=true
JWT_ACCESS_SECRET=replace-with-a-long-random-access-secret
JWT_REFRESH_SECRET=replace-with-a-different-long-random-refresh-secret
JWT_COOKIE_SECURE=false
WHISPER_VM_IP=192.168.X.X
WHISPER_VM_PORT=8000
WHISPER_API_URL=http://192.168.X.X:8000
WHISPER_ALLOW_MOCK=false
STORAGE_DRIVER=smb
STORAGE_BASE_PATH=/mnt/orphion-audio
MAX_AUDIO_SIZE_MB=200
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@orphion/backend",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"migrate": "node src/config/migrate.js",
"lint": "eslint ."
},
"dependencies": {
"@aws-sdk/client-s3": "^3.940.0",
"axios": "^1.13.2",
"bcrypt": "^6.0.0",
"compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"form-data": "^4.0.5",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.1",
"multer": "^2.0.2",
"mysql2": "^3.15.3",
"nanoid": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"zod": "^4.4.3"
}
}
+27
View File
@@ -0,0 +1,27 @@
import express from "express";
import cookieParser from "cookie-parser";
import swaggerUi from "swagger-ui-express";
import { env } from "./config/env.js";
import { errorHandler, notFound } from "./middlewares/errorHandler.js";
import { requestLogger } from "./middlewares/requestLogger.js";
import { securityMiddleware } from "./middlewares/security.js";
import { openApiSpec } from "./docs/openapi.js";
import { routes } from "./routes/index.js";
export function createApp() {
const app = express();
securityMiddleware(app);
app.use(requestLogger);
app.use(express.json({ limit: env.requestBodyLimit }));
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(openApiSpec));
app.use(routes);
app.use(notFound);
app.use(errorHandler);
return app;
}
+46
View File
@@ -0,0 +1,46 @@
import mysql from "mysql2/promise";
import { env } from "./env.js";
export const pool = mysql.createPool({
host: env.database.host,
port: env.database.port,
user: env.database.user,
password: env.database.password,
database: env.database.name,
waitForConnections: true,
connectionLimit: env.database.connectionLimit,
namedPlaceholders: true,
decimalNumbers: true,
enableKeepAlive: true,
keepAliveInitialDelay: 0,
});
export async function query(sql, params = {}) {
const [rows] = await pool.execute(sql, params);
return rows;
}
export async function transaction(work) {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const result = await work(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
export async function pingDatabase() {
const connection = await pool.getConnection();
try {
await connection.ping();
return true;
} finally {
connection.release();
}
}
+129
View File
@@ -0,0 +1,129 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const backendRoot = path.resolve(__dirname, "../..");
const repoRoot = path.resolve(backendRoot, "..");
dotenv.config({ path: path.join(repoRoot, ".env"), quiet: true });
dotenv.config({ path: path.join(backendRoot, ".env"), override: true, quiet: true });
function stringEnv(name, fallback = "") {
const value = process.env[name];
return value && value.trim().length > 0 ? value.trim() : fallback;
}
function numberEnv(name, fallback) {
const value = Number(process.env[name]);
return Number.isFinite(value) ? value : fallback;
}
function boolEnv(name, fallback = false) {
const value = process.env[name];
if (value === undefined) return fallback;
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
}
function firstString(names, fallback = "") {
for (const name of names) {
const value = stringEnv(name);
if (value) return value;
}
return fallback;
}
function normalizeBaseUrl(value) {
return value.replace(/\/+$/, "");
}
const serviceHost = stringEnv("ORPHION_SERVICE_HOST", "127.0.0.1");
const whisperVmIp = firstString(["WHISPER_VM_IP", "WHISPER_HOST"], serviceHost);
const whisperVmPort = numberEnv("WHISPER_VM_PORT", numberEnv("WHISPER_PORT", 8000));
const whisperApiUrl = normalizeBaseUrl(
firstString(["WHISPER_API_URL"], `http://${whisperVmIp}:${whisperVmPort}`),
);
export const env = {
appName: "Orphion",
nodeEnv: stringEnv("NODE_ENV", "development"),
isProduction: stringEnv("NODE_ENV") === "production",
host: stringEnv("HOST", "127.0.0.1"),
port: numberEnv("PORT", 4000),
backendRoot,
repoRoot,
apiPrefix: "/api/v1",
clientOrigin: stringEnv("CLIENT_ORIGIN", "http://localhost:5173"),
trustProxy: boolEnv("TRUST_PROXY", false),
requestBodyLimit: stringEnv("REQUEST_BODY_LIMIT", "2mb"),
database: {
host: firstString(["DB_HOST", "MYSQL_HOST"], serviceHost),
port: numberEnv("DB_PORT", numberEnv("MYSQL_PORT", 3306)),
name: firstString(["DB_NAME", "MYSQL_DATABASE"], "orphion"),
user: firstString(["DB_USER", "MYSQL_USER"], "root"),
password: firstString(["DB_PASSWORD", "MYSQL_PASSWORD"], ""),
connectionLimit: numberEnv("DB_CONNECTION_LIMIT", numberEnv("MYSQL_CONNECTION_LIMIT", 10)),
autoMigrate: boolEnv("DB_AUTO_MIGRATE", boolEnv("MYSQL_AUTO_MIGRATE", true)),
},
auth: {
accessTokenSecret: firstString(["JWT_ACCESS_SECRET", "JWT_SECRET"], "replace-access-secret"),
refreshTokenSecret: firstString(
["JWT_REFRESH_SECRET"],
"replace-refresh-secret-with-a-different-long-value",
),
accessTokenTtl: stringEnv("JWT_ACCESS_EXPIRES_IN", stringEnv("JWT_EXPIRES_IN", "15m")),
refreshTokenTtl: stringEnv("JWT_REFRESH_EXPIRES_IN", "30d"),
accessCookieName: stringEnv("JWT_COOKIE_NAME", "orphion_access_token"),
refreshCookieName: stringEnv("JWT_REFRESH_COOKIE_NAME", "orphion_refresh_token"),
cookieSecure: boolEnv("JWT_COOKIE_SECURE", false),
cookieSameSite: stringEnv("JWT_COOKIE_SAME_SITE", "lax"),
},
upload: {
tempDir: path.resolve(backendRoot, stringEnv("UPLOAD_TEMP_DIR", ".tmp/uploads")),
maxAudioSizeMb: numberEnv("MAX_AUDIO_SIZE_MB", 200),
allowedMimeTypes: stringEnv(
"AUDIO_ALLOWED_MIME_TYPES",
"audio/webm,audio/wav,audio/mpeg,audio/mp4,audio/ogg,audio/x-m4a,video/webm",
)
.split(",")
.map((item) => item.trim())
.filter(Boolean),
},
storage: {
driver: stringEnv("STORAGE_DRIVER", "smb"),
basePath: path.resolve(
backendRoot,
stringEnv("STORAGE_BASE_PATH", stringEnv("REMOTE_STORAGE_PATH", "../storage/audio")),
),
publicBaseUrl: normalizeBaseUrl(stringEnv("STORAGE_PUBLIC_BASE_URL", "")),
httpBaseUrl: normalizeBaseUrl(stringEnv("STORAGE_HTTP_BASE_URL", "")),
httpToken: stringEnv("STORAGE_HTTP_TOKEN", ""),
s3: {
endpoint: stringEnv("S3_ENDPOINT", ""),
region: stringEnv("S3_REGION", "us-east-1"),
bucket: stringEnv("S3_BUCKET", ""),
accessKeyId: stringEnv("S3_ACCESS_KEY_ID", ""),
secretAccessKey: stringEnv("S3_SECRET_ACCESS_KEY", ""),
forcePathStyle: boolEnv("S3_FORCE_PATH_STYLE", true),
},
},
whisper: {
vmIp: whisperVmIp,
vmPort: whisperVmPort,
apiUrl: whisperApiUrl,
transcribePath: stringEnv("WHISPER_TRANSCRIBE_PATH", "/transcribe"),
healthPath: stringEnv("WHISPER_HEALTH_PATH", "/health"),
fileField: stringEnv("WHISPER_FILE_FIELD", "file"),
timeoutMs: numberEnv("WHISPER_TIMEOUT_MS", 900000),
retries: numberEnv("WHISPER_RETRIES", 2),
retryDelayMs: numberEnv("WHISPER_RETRY_DELAY_MS", 1500),
modelName: stringEnv("WHISPER_MODEL_NAME", "faster-whisper-large-v3"),
allowMock: boolEnv("WHISPER_ALLOW_MOCK", false),
queueConcurrency: numberEnv("TRANSCRIPTION_QUEUE_CONCURRENCY", 1),
},
rateLimit: {
windowMs: numberEnv("RATE_LIMIT_WINDOW_MS", 15 * 60 * 1000),
max: numberEnv("RATE_LIMIT_MAX", 300),
authMax: numberEnv("AUTH_RATE_LIMIT_MAX", 30),
},
};
+99
View File
@@ -0,0 +1,99 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import mysql from "mysql2/promise";
import { env } from "./env.js";
import { pool, query } from "./database.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function identifier(value) {
return `\`${String(value).replaceAll("`", "``")}\``;
}
function splitSql(source) {
return source
.split(";")
.map((statement) => statement.trim())
.filter(Boolean);
}
export async function ensureDatabase() {
const connection = await mysql.createConnection({
host: env.database.host,
port: env.database.port,
user: env.database.user,
password: env.database.password,
});
try {
await connection.query(
`CREATE DATABASE IF NOT EXISTS ${identifier(
env.database.name,
)} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`,
);
} finally {
await connection.end();
}
}
export async function ensureSchema() {
await ensureDatabase();
const schema = await fs.readFile(path.join(__dirname, "schema.sql"), "utf8");
for (const statement of splitSql(schema)) {
await pool.query(statement);
}
await runMigrations();
}
export async function runMigrations() {
await query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL UNIQUE,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
const migrationDir = path.join(__dirname, "migrations");
const entries = await fs.readdir(migrationDir).catch(() => []);
const files = entries.filter((file) => file.endsWith(".sql")).sort();
for (const file of files) {
const existing = await query("SELECT id FROM schema_migrations WHERE name = :name LIMIT 1", {
name: file,
});
if (existing.length > 0) continue;
const source = await fs.readFile(path.join(migrationDir, file), "utf8");
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
for (const statement of splitSql(source)) {
await connection.query(statement);
}
await connection.execute("INSERT INTO schema_migrations (name) VALUES (:name)", {
name: file,
});
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
ensureSchema()
.then(() => {
console.info("Database schema is ready.");
return pool.end();
})
.catch(async (error) => {
console.error(error);
await pool.end().catch(() => {});
process.exit(1);
});
}
+106
View File
@@ -0,0 +1,106 @@
CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
full_name VARCHAR(140) NOT NULL,
username VARCHAR(80) NOT NULL,
email VARCHAR(180) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('admin', 'member') NOT NULL DEFAULT 'member',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY users_username_unique (username),
UNIQUE KEY users_email_unique (email),
INDEX users_role_idx (role)
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
token_hash CHAR(64) NOT NULL,
user_agent VARCHAR(255) NULL,
ip_address VARCHAR(64) NULL,
expires_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT refresh_tokens_user_fk FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY refresh_tokens_hash_unique (token_hash),
INDEX refresh_tokens_user_idx (user_id),
INDEX refresh_tokens_expiry_idx (expires_at)
);
CREATE TABLE IF NOT EXISTS audio_assets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
storage_driver VARCHAR(32) NOT NULL,
storage_key VARCHAR(700) NOT NULL,
public_url VARCHAR(900) NULL,
original_name VARCHAR(255) NULL,
mime_type VARCHAR(120) NULL,
file_size BIGINT UNSIGNED NULL,
checksum_sha256 CHAR(64) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY audio_assets_storage_unique (storage_driver, storage_key(255)),
INDEX audio_assets_created_idx (created_at)
);
CREATE TABLE IF NOT EXISTS transcripts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
sender_id BIGINT UNSIGNED NOT NULL,
receiver_id BIGINT UNSIGNED NULL,
audio_asset_id BIGINT UNSIGNED NULL,
title VARCHAR(220) NULL,
transcript_text LONGTEXT NULL,
language VARCHAR(40) NULL,
timestamps JSON NULL,
status ENUM('queued', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'queued',
failure_reason TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT transcripts_sender_fk FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT transcripts_receiver_fk FOREIGN KEY (receiver_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT transcripts_audio_asset_fk FOREIGN KEY (audio_asset_id) REFERENCES audio_assets(id) ON DELETE SET NULL,
INDEX transcripts_sender_idx (sender_id),
INDEX transcripts_receiver_idx (receiver_id),
INDEX transcripts_audio_asset_idx (audio_asset_id),
INDEX transcripts_status_idx (status),
INDEX transcripts_created_idx (created_at),
FULLTEXT KEY transcripts_search_idx (title, transcript_text)
);
CREATE TABLE IF NOT EXISTS transcript_shares (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
transcript_id BIGINT UNSIGNED NOT NULL,
sender_id BIGINT UNSIGNED NOT NULL,
receiver_id BIGINT UNSIGNED NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT transcript_shares_transcript_fk FOREIGN KEY (transcript_id) REFERENCES transcripts(id) ON DELETE CASCADE,
CONSTRAINT transcript_shares_sender_fk FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT transcript_shares_receiver_fk FOREIGN KEY (receiver_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY transcript_shares_unique (transcript_id, receiver_id),
INDEX transcript_shares_sender_idx (sender_id),
INDEX transcript_shares_receiver_idx (receiver_id)
);
CREATE TABLE IF NOT EXISTS audio_metadata (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
transcript_id BIGINT UNSIGNED NOT NULL,
file_size BIGINT UNSIGNED NULL,
duration DECIMAL(12, 3) NULL,
processing_time DECIMAL(12, 3) NULL,
model_name VARCHAR(120) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT audio_metadata_transcript_fk FOREIGN KEY (transcript_id) REFERENCES transcripts(id) ON DELETE CASCADE,
UNIQUE KEY audio_metadata_transcript_unique (transcript_id)
);
CREATE TABLE IF NOT EXISTS transcription_jobs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
transcript_id BIGINT UNSIGNED NOT NULL,
status ENUM('queued', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'queued',
attempts INT UNSIGNED NOT NULL DEFAULT 0,
last_error TEXT NULL,
queued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP NULL,
completed_at TIMESTAMP NULL,
CONSTRAINT transcription_jobs_transcript_fk FOREIGN KEY (transcript_id) REFERENCES transcripts(id) ON DELETE CASCADE,
INDEX transcription_jobs_status_idx (status),
INDEX transcription_jobs_transcript_idx (transcript_id)
);
@@ -0,0 +1,13 @@
import { createTranscriptionRequest } from "../services/transcriptionService.js";
import { sendSuccess } from "../utils/apiResponse.js";
export async function transcribeAudio(req, res) {
const duration = Number(req.body.duration);
const result = await createTranscriptionRequest({
userId: req.user.id,
file: req.file,
title: String(req.body.title ?? "").trim() || null,
duration: Number.isFinite(duration) ? duration : null,
});
sendSuccess(res, "Audio accepted for transcription", result, 202);
}
+60
View File
@@ -0,0 +1,60 @@
import {
changePassword,
loginUser,
logoutSession,
refreshSession,
registerUser,
requestPasswordReset,
updateProfile,
} from "../services/authService.js";
import { clearAuthCookies, setAuthCookies } from "../services/tokenService.js";
import { env } from "../config/env.js";
import { sendSuccess } from "../utils/apiResponse.js";
export async function register(req, res) {
const { user, tokens } = await registerUser(req.body, req);
setAuthCookies(res, tokens);
sendSuccess(res, "Account created", { user }, 201);
}
export async function login(req, res) {
const { user, tokens } = await loginUser(req.body, req);
setAuthCookies(res, tokens);
sendSuccess(res, "Signed in", { user });
}
export async function refresh(req, res) {
const refreshToken = req.cookies?.[env.auth.refreshCookieName];
const {
user,
accessToken,
refreshToken: nextRefreshToken,
} = await refreshSession(refreshToken, req);
setAuthCookies(res, { accessToken, refreshToken: nextRefreshToken });
sendSuccess(res, "Session refreshed", { user });
}
export async function me(req, res) {
sendSuccess(res, "Authenticated user", { user: req.user });
}
export async function logout(req, res) {
await logoutSession(req.cookies?.[env.auth.refreshCookieName]);
clearAuthCookies(res);
sendSuccess(res, "Signed out");
}
export async function forgotPassword(req, res) {
await requestPasswordReset(req.body.email);
sendSuccess(res, "If that email exists, reset instructions will be sent");
}
export async function updateCurrentUser(req, res) {
const user = await updateProfile(req.user.id, req.body);
sendSuccess(res, "Profile updated", { user });
}
export async function changeCurrentPassword(req, res) {
await changePassword(req.user.id, req.body);
sendSuccess(res, "Password changed");
}
@@ -0,0 +1,116 @@
import {
deleteTranscript,
findTranscriptByIdForSender,
findTranscriptByIdForUser,
listInboxTranscripts,
listSentTranscripts,
listVisibleTranscripts,
sendTranscriptToUser,
updateTranscript,
} from "../repositories/transcriptRepository.js";
import { findUserById } from "../repositories/userRepository.js";
import { getTranscriptJob } from "../services/transcriptionService.js";
import { openAudioStream, removeAudio } from "../services/storage/storageService.js";
import { AppError } from "../utils/AppError.js";
import { sendSuccess } from "../utils/apiResponse.js";
import { safeFilename } from "../utils/safeFilename.js";
export async function listTranscripts(req, res) {
const transcripts = await listVisibleTranscripts(req.user.id, req.query);
sendSuccess(res, "Transcripts retrieved", {
transcripts,
stats: buildStats(transcripts, req.user.id),
});
}
export async function inbox(req, res) {
const transcripts = await listInboxTranscripts(req.user.id, req.query);
sendSuccess(res, "Inbox retrieved", { transcripts });
}
export async function sent(req, res) {
const transcripts = await listSentTranscripts(req.user.id, req.query);
sendSuccess(res, "Sent transcripts retrieved", { transcripts });
}
export async function getTranscript(req, res) {
const transcript = await findTranscriptByIdForUser(req.params.id, req.user.id);
if (!transcript) throw new AppError("Transcript not found", 404, "TRANSCRIPT_NOT_FOUND");
const job = await getTranscriptJob(transcript.id);
sendSuccess(res, "Transcript retrieved", {
transcript,
job: job
? {
id: Number(job.id),
status: job.status,
attempts: Number(job.attempts),
lastError: job.last_error,
}
: null,
});
}
export async function updateTranscriptText(req, res) {
const existing = await findTranscriptByIdForSender(req.params.id, req.user.id);
if (!existing) throw new AppError("Transcript not found", 404, "TRANSCRIPT_NOT_FOUND");
if (existing.status !== "completed") {
throw new AppError("Only completed transcripts can be edited", 409, "TRANSCRIPT_NOT_READY");
}
const transcript = await updateTranscript(req.params.id, req.user.id, {
title: req.body.title ?? null,
transcriptText: req.body.transcriptText,
});
sendSuccess(res, "Transcript updated", { transcript });
}
export async function removeTranscript(req, res) {
const transcript = await deleteTranscript(req.params.id, req.user.id);
if (!transcript) throw new AppError("Transcript not found", 404, "TRANSCRIPT_NOT_FOUND");
await removeAudio(transcript.audioPath);
sendSuccess(res, "Transcript deleted");
}
export async function sendTranscript(req, res) {
const { transcriptId, receiverId } = req.body;
if (receiverId === Number(req.user.id)) {
throw new AppError("Choose another user as the recipient", 400, "INVALID_RECIPIENT");
}
if (!(await findUserById(receiverId))) {
throw new AppError("Recipient not found", 404, "RECIPIENT_NOT_FOUND");
}
const existing = await findTranscriptByIdForSender(transcriptId, req.user.id);
if (!existing) throw new AppError("Transcript not found", 404, "TRANSCRIPT_NOT_FOUND");
const transcript = await sendTranscriptToUser(transcriptId, req.user.id, receiverId);
sendSuccess(res, "Transcript shared", { transcript });
}
export async function streamAudio(req, res) {
const transcript = await findTranscriptByIdForUser(req.params.id, req.user.id);
if (!transcript?.audioPath) {
throw new AppError("Audio not found", 404, "AUDIO_NOT_FOUND");
}
const stream = await openAudioStream(transcript.audioPath);
res.setHeader("Content-Type", transcript.metadata?.mimeType ?? "audio/webm");
stream.pipe(res);
}
export async function downloadTranscript(req, res) {
const transcript = await findTranscriptByIdForUser(req.params.id, req.user.id);
if (!transcript) throw new AppError("Transcript not found", 404, "TRANSCRIPT_NOT_FOUND");
const filename = `${safeFilename(transcript.title || `transcript-${transcript.id}`)}.txt`;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.send(transcript.transcriptText);
}
function buildStats(transcripts, userId) {
return {
totalRecordings: transcripts.filter((item) => item.senderId === Number(userId)).length,
totalTranscripts: transcripts.length,
sentFiles: transcripts.filter((item) => item.senderId === Number(userId) && item.receiverId)
.length,
receivedFiles: transcripts.filter((item) => item.receiverId === Number(userId)).length,
};
}
@@ -0,0 +1,16 @@
import { checkWhisperHealth, getTranscriptionStatus } from "../services/transcriptionService.js";
import { transcriptionQueueState } from "../jobs/transcriptionQueue.js";
import { sendSuccess } from "../utils/apiResponse.js";
export async function status(req, res) {
const data = await getTranscriptionStatus(req.params.id, req.user.id);
sendSuccess(res, "Transcription status retrieved", data);
}
export async function whisperHealth(_req, res) {
const whisper = await checkWhisperHealth();
sendSuccess(res, "Whisper health retrieved", {
whisper,
queue: transcriptionQueueState(),
});
}
+11
View File
@@ -0,0 +1,11 @@
import { listUsers } from "../repositories/userRepository.js";
import { sendSuccess } from "../utils/apiResponse.js";
export async function searchUsers(req, res) {
const users = await listUsers({
excludeId: req.user.id,
q: String(req.query.q ?? "").trim(),
limit: req.query.limit,
});
sendSuccess(res, "Users retrieved", { users });
}
+84
View File
@@ -0,0 +1,84 @@
import swaggerJSDoc from "swagger-jsdoc";
import { env } from "../config/env.js";
export const openApiSpec = swaggerJSDoc({
definition: {
openapi: "3.0.0",
info: {
title: "Orphion API",
version: "1.0.0",
description: "Production API for recording, transcribing, sharing, and playing MoM audio.",
},
servers: [{ url: env.apiPrefix }],
components: {
securitySchemes: {
cookieAuth: {
type: "apiKey",
in: "cookie",
name: env.auth.accessCookieName,
},
},
},
security: [{ cookieAuth: [] }],
paths: {
"/auth/register": { post: { tags: ["Auth"], summary: "Create an account" } },
"/auth/login": { post: { tags: ["Auth"], summary: "Sign in" } },
"/auth/refresh": { post: { tags: ["Auth"], summary: "Refresh session cookies" } },
"/auth/me": { get: { tags: ["Auth"], summary: "Get current user" } },
"/auth/logout": { post: { tags: ["Auth"], summary: "Sign out" } },
"/audio/transcribe": {
post: {
tags: ["Audio"],
summary: "Upload audio and queue transcription",
requestBody: {
required: true,
content: {
"multipart/form-data": {
schema: {
type: "object",
properties: {
audio: { type: "string", format: "binary" },
title: { type: "string" },
duration: { type: "number" },
},
required: ["audio"],
},
},
},
},
},
},
"/transcriptions/{id}/status": {
get: {
tags: ["Transcription"],
summary: "Get transcription job status",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
},
},
"/transcriptions/whisper/health": {
get: { tags: ["Transcription"], summary: "Check Whisper VM health" },
},
"/transcripts": { get: { tags: ["Transcripts"], summary: "List visible transcripts" } },
"/transcripts/inbox": {
get: { tags: ["Transcripts"], summary: "List received transcripts" },
},
"/transcripts/sent": { get: { tags: ["Transcripts"], summary: "List sent transcripts" } },
"/transcripts/send": { post: { tags: ["Transcripts"], summary: "Share transcript" } },
"/transcripts/{id}": {
get: {
tags: ["Transcripts"],
summary: "Get transcript",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
},
patch: { tags: ["Transcripts"], summary: "Update transcript" },
delete: { tags: ["Transcripts"], summary: "Delete transcript" },
},
"/transcripts/{id}/audio": { get: { tags: ["Transcripts"], summary: "Stream audio" } },
"/transcripts/{id}/download": {
get: { tags: ["Transcripts"], summary: "Download transcript text" },
},
"/users": { get: { tags: ["Users"], summary: "Search users" } },
},
},
apis: [],
});
+20
View File
@@ -0,0 +1,20 @@
import { createApp } from "./app.js";
import { env } from "./config/env.js";
import { ensureSchema } from "./config/migrate.js";
import { logger } from "./utils/logger.js";
async function start() {
if (env.database.autoMigrate) {
await ensureSchema();
}
const app = createApp();
app.listen(env.port, env.host, () => {
logger.info(`Orphion API listening on http://${env.host}:${env.port}`);
});
}
start().catch((error) => {
logger.error("Failed to start Orphion API", { error: error.stack ?? error.message });
process.exit(1);
});
+37
View File
@@ -0,0 +1,37 @@
import { env } from "../config/env.js";
import { logger } from "../utils/logger.js";
const queue = [];
const active = new Set();
let handler = null;
export function configureTranscriptionQueue(processor) {
handler = processor;
}
export function enqueueTranscriptionJob(jobId) {
queue.push(Number(jobId));
processQueue();
}
export function transcriptionQueueState() {
return {
queued: queue.length,
active: active.size,
concurrency: env.whisper.queueConcurrency,
};
}
function processQueue() {
if (!handler) return;
while (active.size < env.whisper.queueConcurrency && queue.length > 0) {
const jobId = queue.shift();
active.add(jobId);
Promise.resolve(handler(jobId))
.catch((error) => logger.error("Transcription job failed", { jobId, error: error.message }))
.finally(() => {
active.delete(jobId);
processQueue();
});
}
}
+40
View File
@@ -0,0 +1,40 @@
import { env } from "../config/env.js";
import { findUserById } from "../repositories/userRepository.js";
import { verifyAccessToken } from "../services/tokenService.js";
import { AppError } from "../utils/AppError.js";
export async function authenticate(req, _res, next) {
try {
const header = req.headers.authorization;
const bearer = header?.startsWith("Bearer ") ? header.slice(7) : null;
const token = req.cookies?.[env.auth.accessCookieName] ?? req.cookies?.orphion_token ?? bearer;
if (!token) {
throw new AppError("Authentication required", 401, "AUTH_REQUIRED");
}
const payload = verifyAccessToken(token);
const user = await findUserById(payload.sub);
if (!user) {
throw new AppError("Invalid session", 401, "INVALID_SESSION");
}
req.user = user;
next();
} catch (error) {
next(error.status ? error : new AppError("Invalid or expired session", 401, "INVALID_SESSION"));
}
}
export function authorize(...roles) {
return function roleGuard(req, _res, next) {
if (!req.user) {
next(new AppError("Authentication required", 401, "AUTH_REQUIRED"));
return;
}
if (roles.length > 0 && !roles.includes(req.user.role)) {
next(new AppError("Insufficient permissions", 403, "FORBIDDEN"));
return;
}
next();
};
}
+27
View File
@@ -0,0 +1,27 @@
import { sendError } from "../utils/apiResponse.js";
import { AppError } from "../utils/AppError.js";
import { logger } from "../utils/logger.js";
export function notFound(req, _res, next) {
next(new AppError(`Route not found: ${req.method} ${req.originalUrl}`, 404, "NOT_FOUND"));
}
export function errorHandler(err, req, res, _next) {
let error = err;
if (err?.code === "LIMIT_FILE_SIZE") {
error = new AppError("Audio file is too large", 413, "AUDIO_TOO_LARGE");
}
if (err?.name === "ZodError") {
error = new AppError("Validation failed", 400, "VALIDATION_ERROR", err.flatten());
}
if (!error.status || error.status >= 500) {
logger.error("Unhandled API error", {
method: req.method,
path: req.originalUrl,
error: err?.stack ?? err?.message ?? String(err),
});
}
sendError(res, error);
}
+10
View File
@@ -0,0 +1,10 @@
import morgan from "morgan";
import { logger } from "../utils/logger.js";
export const requestLogger = morgan("combined", {
stream: {
write(message) {
logger.info(message.trim());
},
},
});
+43
View File
@@ -0,0 +1,43 @@
import compression from "compression";
import cors from "cors";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import { env } from "../config/env.js";
export function securityMiddleware(app) {
if (env.trustProxy) {
app.set("trust proxy", 1);
}
const origins = env.clientOrigin.split(",").map((origin) => origin.trim());
app.use(
helmet({
crossOriginResourcePolicy: { policy: "cross-origin" },
}),
);
app.use(compression());
app.use(
cors({
origin(origin, cb) {
if (!origin || origins.includes(origin)) return cb(null, true);
return cb(new Error(`Origin ${origin} is not allowed`));
},
credentials: true,
}),
);
app.use(
rateLimit({
windowMs: env.rateLimit.windowMs,
limit: env.rateLimit.max,
standardHeaders: "draft-8",
legacyHeaders: false,
}),
);
}
export const authRateLimiter = rateLimit({
windowMs: env.rateLimit.windowMs,
limit: env.rateLimit.authMax,
standardHeaders: "draft-8",
legacyHeaders: false,
});
+28
View File
@@ -0,0 +1,28 @@
import fs from "node:fs";
import multer from "multer";
import { env } from "../config/env.js";
import { randomStorageName } from "../utils/crypto.js";
import { AppError } from "../utils/AppError.js";
fs.mkdirSync(env.upload.tempDir, { recursive: true });
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, env.upload.tempDir),
filename: (_req, file, cb) => cb(null, randomStorageName(file.originalname)),
});
export const audioUpload = multer({
storage,
limits: {
fileSize: env.upload.maxAudioSizeMb * 1024 * 1024,
},
fileFilter: (_req, file, cb) => {
const allowed =
env.upload.allowedMimeTypes.includes(file.mimetype) || file.mimetype.startsWith("audio/");
if (!allowed) {
cb(new AppError("Unsupported audio type", 400, "UNSUPPORTED_AUDIO_TYPE"));
return;
}
cb(null, true);
},
});
+23
View File
@@ -0,0 +1,23 @@
function setRequestValue(req, key, value) {
if (value === undefined) return;
Object.defineProperty(req, key, {
value,
writable: true,
enumerable: true,
configurable: true,
});
}
export function validate(schema) {
return function validationMiddleware(req, _res, next) {
const parsed = schema.parse({
body: req.body,
query: req.query,
params: req.params,
});
setRequestValue(req, "body", parsed.body);
setRequestValue(req, "query", parsed.query);
setRequestValue(req, "params", parsed.params);
next();
};
}
+68
View File
@@ -0,0 +1,68 @@
export function toUser(row) {
if (!row) return null;
return {
id: Number(row.id),
fullName: row.full_name,
username: row.username,
email: row.email,
role: row.role ?? "member",
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at,
};
}
export function toUserSummary(row, prefix = "") {
if (!row?.[`${prefix}id`]) return null;
return {
id: Number(row[`${prefix}id`]),
fullName: row[`${prefix}full_name`],
username: row[`${prefix}username`],
email: row[`${prefix}email`],
role: row[`${prefix}role`] ?? "member",
};
}
export function parseJsonArray(value) {
if (!value) return [];
if (Array.isArray(value)) return value;
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function toTranscript(row) {
if (!row) return null;
return {
id: Number(row.id),
senderId: Number(row.sender_id),
receiverId: row.receiver_id ? Number(row.receiver_id) : null,
audioAssetId: row.audio_asset_id ? Number(row.audio_asset_id) : null,
title: row.title,
audioPath: row.storage_key ?? row.audio_path ?? null,
audioUrl: row.public_url ?? null,
transcriptText: row.transcript_text ?? "",
language: row.language,
timestamps: parseJsonArray(row.timestamps),
status: row.status ?? "completed",
transcriptionStatus: row.status ?? "completed",
failureReason: row.failure_reason ?? null,
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at,
sender: toUserSummary(row, "sender_"),
receiver: toUserSummary(row, "receiver_"),
metadata: row.metadata_id
? {
fileSize: row.file_size === null ? null : Number(row.file_size),
duration: row.duration === null ? null : Number(row.duration),
processingTime: row.processing_time === null ? null : Number(row.processing_time),
modelName: row.model_name,
originalName: row.original_name ?? null,
mimeType: row.mime_type ?? null,
storageDriver: row.storage_driver ?? null,
}
: null,
};
}
@@ -0,0 +1,46 @@
import { query } from "../config/database.js";
export async function createRefreshToken({
userId,
tokenHash,
expiresAt,
userAgent = null,
ipAddress = null,
}) {
await query(
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at, user_agent, ip_address)
VALUES (:userId, :tokenHash, :expiresAt, :userAgent, :ipAddress)`,
{ userId, tokenHash, expiresAt, userAgent, ipAddress },
);
}
export async function findActiveRefreshToken(tokenHash) {
const rows = await query(
`SELECT *
FROM refresh_tokens
WHERE token_hash = :tokenHash
AND revoked_at IS NULL
AND expires_at > CURRENT_TIMESTAMP
LIMIT 1`,
{ tokenHash },
);
return rows[0] ?? null;
}
export async function revokeRefreshToken(tokenHash) {
await query(
`UPDATE refresh_tokens
SET revoked_at = CURRENT_TIMESTAMP
WHERE token_hash = :tokenHash AND revoked_at IS NULL`,
{ tokenHash },
);
}
export async function revokeAllUserRefreshTokens(userId) {
await query(
`UPDATE refresh_tokens
SET revoked_at = CURRENT_TIMESTAMP
WHERE user_id = :userId AND revoked_at IS NULL`,
{ userId },
);
}
@@ -0,0 +1,310 @@
import { query, transaction } from "../config/database.js";
import { toTranscript } from "../models/mappers.js";
const transcriptSelect = `
SELECT
t.*,
aa.storage_driver,
aa.storage_key,
aa.public_url,
aa.original_name,
aa.mime_type,
am.id AS metadata_id,
COALESCE(am.file_size, aa.file_size) AS file_size,
am.duration,
am.processing_time,
am.model_name,
sender.full_name AS sender_full_name,
sender.username AS sender_username,
sender.email AS sender_email,
sender.role AS sender_role,
receiver.full_name AS receiver_full_name,
receiver.username AS receiver_username,
receiver.email AS receiver_email,
receiver.role AS receiver_role
FROM transcripts t
LEFT JOIN audio_assets aa ON aa.id = t.audio_asset_id
LEFT JOIN audio_metadata am ON am.transcript_id = t.id
LEFT JOIN users sender ON sender.id = t.sender_id
LEFT JOIN users receiver ON receiver.id = t.receiver_id
`;
function appendFilters(base, params, filters, mode) {
const clauses = [
mode === "inbox"
? "t.receiver_id = :userId"
: mode === "sent"
? "t.sender_id = :userId AND t.receiver_id IS NOT NULL"
: "(t.sender_id = :userId OR t.receiver_id = :userId)",
];
if (filters?.q) {
clauses.push("(t.title LIKE :qLike OR t.transcript_text LIKE :qLike)");
params.qLike = `%${filters.q}%`;
}
if (filters?.sender) {
clauses.push("t.sender_id = :sender");
params.sender = Number(filters.sender);
}
if (filters?.receiver) {
clauses.push("t.receiver_id = :receiver");
params.receiver = Number(filters.receiver);
}
if (filters?.date) {
clauses.push("DATE(t.created_at) = :date");
params.date = filters.date;
}
if (filters?.status) {
clauses.push("t.status = :status");
params.status = filters.status;
}
return `${base} WHERE ${clauses.join(" AND ")} ORDER BY t.created_at DESC`;
}
export async function createAudioAsset(asset) {
const result = await query(
`INSERT INTO audio_assets
(storage_driver, storage_key, public_url, original_name, mime_type, file_size, checksum_sha256)
VALUES
(:storageDriver, :storageKey, :publicUrl, :originalName, :mimeType, :fileSize, :checksumSha256)`,
{
storageDriver: asset.storageDriver,
storageKey: asset.storageKey,
publicUrl: asset.publicUrl ?? null,
originalName: asset.originalName ?? null,
mimeType: asset.mimeType ?? null,
fileSize: asset.fileSize ?? null,
checksumSha256: asset.checksumSha256 ?? null,
},
);
return result.insertId;
}
export async function createQueuedTranscript({
senderId,
title,
audioAssetId,
duration = null,
fileSize = null,
modelName = null,
}) {
return transaction(async (connection) => {
const [insert] = await connection.execute(
`INSERT INTO transcripts
(sender_id, receiver_id, audio_asset_id, title, transcript_text, language, timestamps, status)
VALUES (:senderId, NULL, :audioAssetId, :title, '', NULL, JSON_ARRAY(), 'queued')`,
{
senderId,
audioAssetId,
title: title || null,
},
);
await connection.execute(
`INSERT INTO audio_metadata (transcript_id, file_size, duration, model_name)
VALUES (:transcriptId, :fileSize, :duration, :modelName)`,
{
transcriptId: insert.insertId,
fileSize,
duration,
modelName,
},
);
const [job] = await connection.execute(
`INSERT INTO transcription_jobs (transcript_id, status)
VALUES (:transcriptId, 'queued')`,
{ transcriptId: insert.insertId },
);
return { transcriptId: insert.insertId, jobId: job.insertId };
});
}
export async function listVisibleTranscripts(userId, filters = {}) {
const params = { userId };
const rows = await query(appendFilters(transcriptSelect, params, filters, "all"), params);
return rows.map(toTranscript);
}
export async function listInboxTranscripts(userId, filters = {}) {
const params = { userId };
const rows = await query(appendFilters(transcriptSelect, params, filters, "inbox"), params);
return rows.map(toTranscript);
}
export async function listSentTranscripts(userId, filters = {}) {
const params = { userId };
const rows = await query(appendFilters(transcriptSelect, params, filters, "sent"), params);
return rows.map(toTranscript);
}
export async function findTranscriptByIdForUser(id, userId) {
const rows = await query(
`${transcriptSelect}
WHERE t.id = :id AND (t.sender_id = :userId OR t.receiver_id = :userId)
LIMIT 1`,
{ id, userId },
);
return toTranscript(rows[0]);
}
export async function findTranscriptById(id) {
const rows = await query(
`${transcriptSelect}
WHERE t.id = :id
LIMIT 1`,
{ id },
);
return toTranscript(rows[0]);
}
export async function findTranscriptByIdForSender(id, senderId) {
const rows = await query(
`${transcriptSelect}
WHERE t.id = :id AND t.sender_id = :senderId
LIMIT 1`,
{ id, senderId },
);
return toTranscript(rows[0]);
}
export async function findTranscriptByJobForUser(jobId, userId) {
const rows = await query(
`${transcriptSelect}
INNER JOIN transcription_jobs tj ON tj.transcript_id = t.id
WHERE tj.id = :jobId AND (t.sender_id = :userId OR t.receiver_id = :userId)
LIMIT 1`,
{ jobId, userId },
);
return toTranscript(rows[0]);
}
export async function updateTranscript(id, senderId, { title, transcriptText }) {
await query(
`UPDATE transcripts
SET title = :title, transcript_text = :transcriptText
WHERE id = :id AND sender_id = :senderId`,
{ id, senderId, title: title || null, transcriptText },
);
return findTranscriptBySenderOrId(id, senderId);
}
async function findTranscriptBySenderOrId(id, senderId) {
return findTranscriptByIdForSender(id, senderId);
}
export async function completeTranscript(id, result) {
await query(
`UPDATE transcripts
SET transcript_text = :transcriptText,
language = :language,
timestamps = :timestamps,
status = 'completed',
failure_reason = NULL
WHERE id = :id`,
{
id,
transcriptText: result.transcriptText,
language: result.language,
timestamps: JSON.stringify(result.timestamps ?? []),
},
);
await query(
`UPDATE audio_metadata
SET duration = COALESCE(:duration, duration),
processing_time = :processingTime,
model_name = :modelName
WHERE transcript_id = :id`,
{
id,
duration: result.duration ?? null,
processingTime: result.processingTime ?? null,
modelName: result.modelName ?? null,
},
);
}
export async function markTranscriptProcessing(id) {
await query(
"UPDATE transcripts SET status = 'processing', failure_reason = NULL WHERE id = :id",
{
id,
},
);
}
export async function failTranscript(id, reason) {
await query(
`UPDATE transcripts
SET status = 'failed', failure_reason = :reason
WHERE id = :id`,
{ id, reason: String(reason).slice(0, 2000) },
);
}
export async function sendTranscriptToUser(id, senderId, receiverId) {
return transaction(async (connection) => {
await connection.execute(
`UPDATE transcripts
SET receiver_id = :receiverId
WHERE id = :id AND sender_id = :senderId`,
{ id, senderId, receiverId },
);
await connection.execute(
`INSERT INTO transcript_shares (transcript_id, sender_id, receiver_id)
VALUES (:id, :senderId, :receiverId)
ON DUPLICATE KEY UPDATE sender_id = VALUES(sender_id)`,
{ id, senderId, receiverId },
);
return findTranscriptBySenderOrId(id, senderId);
});
}
export async function deleteTranscript(id, senderId) {
const transcript = await findTranscriptByIdForSender(id, senderId);
if (!transcript) return null;
await query("DELETE FROM transcripts WHERE id = :id AND sender_id = :senderId", { id, senderId });
return transcript;
}
export async function findJobById(id) {
const rows = await query("SELECT * FROM transcription_jobs WHERE id = :id LIMIT 1", { id });
return rows[0] ?? null;
}
export async function findJobByTranscriptId(transcriptId) {
const rows = await query(
"SELECT * FROM transcription_jobs WHERE transcript_id = :transcriptId ORDER BY id DESC LIMIT 1",
{ transcriptId },
);
return rows[0] ?? null;
}
export async function markJobProcessing(id) {
await query(
`UPDATE transcription_jobs
SET status = 'processing', attempts = attempts + 1, started_at = COALESCE(started_at, CURRENT_TIMESTAMP)
WHERE id = :id`,
{ id },
);
}
export async function completeJob(id) {
await query(
`UPDATE transcription_jobs
SET status = 'completed', completed_at = CURRENT_TIMESTAMP
WHERE id = :id`,
{ id },
);
}
export async function failJob(id, error) {
await query(
`UPDATE transcription_jobs
SET status = 'failed', last_error = :error, completed_at = CURRENT_TIMESTAMP
WHERE id = :id`,
{ id, error: String(error).slice(0, 2000) },
);
}
@@ -0,0 +1,69 @@
import { query } from "../config/database.js";
import { toUser } from "../models/mappers.js";
export async function createUser({ fullName, username, email, passwordHash, role = "member" }) {
const result = await query(
`INSERT INTO users (full_name, username, email, password_hash, role)
VALUES (:fullName, :username, :email, :passwordHash, :role)`,
{ fullName, username, email, passwordHash, role },
);
return findUserById(result.insertId);
}
export async function findUserByEmail(email) {
const rows = await query("SELECT * FROM users WHERE email = :email LIMIT 1", { email });
return rows[0] ?? null;
}
export async function findUserByUsername(username) {
const rows = await query("SELECT * FROM users WHERE username = :username LIMIT 1", { username });
return rows[0] ?? null;
}
export async function findUserById(id) {
const rows = await query(
"SELECT id, full_name, username, email, role, created_at, updated_at FROM users WHERE id = :id LIMIT 1",
{ id },
);
return toUser(rows[0]);
}
export async function findUserCredentialsById(id) {
const rows = await query("SELECT * FROM users WHERE id = :id LIMIT 1", { id });
return rows[0] ?? null;
}
export async function updateUserProfile(id, { fullName, username }) {
await query(
`UPDATE users
SET full_name = :fullName, username = :username
WHERE id = :id`,
{ id, fullName, username },
);
return findUserById(id);
}
export async function updateUserPassword(id, passwordHash) {
await query("UPDATE users SET password_hash = :passwordHash WHERE id = :id", {
id,
passwordHash,
});
}
export async function listUsers({ excludeId, q = "", limit = 25 }) {
const rows = await query(
`SELECT id, full_name, username, email, role, created_at, updated_at
FROM users
WHERE id <> :excludeId
AND (
:q = ''
OR full_name LIKE :likeQ
OR username LIKE :likeQ
OR email LIKE :likeQ
)
ORDER BY full_name ASC
LIMIT :limit`,
{ excludeId, q, likeQ: `%${q}%`, limit: Math.min(Number(limit) || 25, 50) },
);
return rows.map(toUser);
}
+32
View File
@@ -0,0 +1,32 @@
import { Router } from "express";
import { pingDatabase } from "../config/database.js";
import { env } from "../config/env.js";
import { checkWhisperHealth } from "../services/transcriptionService.js";
import { sendSuccess } from "../utils/apiResponse.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import { v1Routes } from "./v1/index.js";
export const routes = Router();
routes.get(
"/health",
asyncHandler(async (_req, res) => {
const [database, whisper] = await Promise.all([
pingDatabase()
.then(() => ({ healthy: true }))
.catch((error) => ({
healthy: false,
error: error.message,
})),
checkWhisperHealth(),
]);
sendSuccess(res, "Orphion API is healthy", {
service: "orphion-api",
env: env.nodeEnv,
database,
whisper,
});
}),
);
routes.use(env.apiPrefix, v1Routes);
+14
View File
@@ -0,0 +1,14 @@
import { Router } from "express";
import { transcribeAudio } from "../../controllers/audioController.js";
import { authenticate } from "../../middlewares/authenticate.js";
import { audioUpload } from "../../middlewares/upload.js";
import { asyncHandler } from "../../utils/asyncHandler.js";
export const audioRoutes = Router();
audioRoutes.post(
"/transcribe",
authenticate,
audioUpload.single("audio"),
asyncHandler(transcribeAudio),
);
+48
View File
@@ -0,0 +1,48 @@
import { Router } from "express";
import {
changeCurrentPassword,
forgotPassword,
login,
logout,
me,
refresh,
register,
updateCurrentUser,
} from "../../controllers/authController.js";
import { asyncHandler } from "../../utils/asyncHandler.js";
import { authenticate } from "../../middlewares/authenticate.js";
import { authRateLimiter } from "../../middlewares/security.js";
import { validate } from "../../middlewares/validate.js";
import {
changePasswordSchema,
forgotPasswordSchema,
loginSchema,
registerSchema,
updateProfileSchema,
} from "../../validators/authValidators.js";
export const authRoutes = Router();
authRoutes.post("/register", authRateLimiter, validate(registerSchema), asyncHandler(register));
authRoutes.post("/login", authRateLimiter, validate(loginSchema), asyncHandler(login));
authRoutes.post("/refresh", asyncHandler(refresh));
authRoutes.post(
"/forgot-password",
authRateLimiter,
validate(forgotPasswordSchema),
asyncHandler(forgotPassword),
);
authRoutes.get("/me", authenticate, asyncHandler(me));
authRoutes.post("/logout", asyncHandler(logout));
authRoutes.patch(
"/profile",
authenticate,
validate(updateProfileSchema),
asyncHandler(updateCurrentUser),
);
authRoutes.patch(
"/password",
authenticate,
validate(changePasswordSchema),
asyncHandler(changeCurrentPassword),
);
+14
View File
@@ -0,0 +1,14 @@
import { Router } from "express";
import { authRoutes } from "./authRoutes.js";
import { audioRoutes } from "./audioRoutes.js";
import { transcriptRoutes } from "./transcriptRoutes.js";
import { transcriptionRoutes } from "./transcriptionRoutes.js";
import { userRoutes } from "./userRoutes.js";
export const v1Routes = Router();
v1Routes.use("/auth", authRoutes);
v1Routes.use("/audio", audioRoutes);
v1Routes.use("/transcripts", transcriptRoutes);
v1Routes.use("/transcriptions", transcriptionRoutes);
v1Routes.use("/users", userRoutes);
+42
View File
@@ -0,0 +1,42 @@
import { Router } from "express";
import {
downloadTranscript,
getTranscript,
inbox,
listTranscripts,
removeTranscript,
sendTranscript,
sent,
streamAudio,
updateTranscriptText,
} from "../../controllers/transcriptController.js";
import { authenticate } from "../../middlewares/authenticate.js";
import { validate } from "../../middlewares/validate.js";
import { asyncHandler } from "../../utils/asyncHandler.js";
import {
sendTranscriptSchema,
transcriptIdSchema,
transcriptListSchema,
updateTranscriptSchema,
} from "../../validators/transcriptValidators.js";
export const transcriptRoutes = Router();
transcriptRoutes.use(authenticate);
transcriptRoutes.get("/", validate(transcriptListSchema), asyncHandler(listTranscripts));
transcriptRoutes.get("/inbox", validate(transcriptListSchema), asyncHandler(inbox));
transcriptRoutes.get("/sent", validate(transcriptListSchema), asyncHandler(sent));
transcriptRoutes.post("/send", validate(sendTranscriptSchema), asyncHandler(sendTranscript));
transcriptRoutes.get("/:id/audio", validate(transcriptIdSchema), asyncHandler(streamAudio));
transcriptRoutes.get(
"/:id/download",
validate(transcriptIdSchema),
asyncHandler(downloadTranscript),
);
transcriptRoutes.get("/:id", validate(transcriptIdSchema), asyncHandler(getTranscript));
transcriptRoutes.patch(
"/:id",
validate(updateTranscriptSchema),
asyncHandler(updateTranscriptText),
);
transcriptRoutes.delete("/:id", validate(transcriptIdSchema), asyncHandler(removeTranscript));
@@ -0,0 +1,16 @@
import { Router } from "express";
import { status, whisperHealth } from "../../controllers/transcriptionController.js";
import { authenticate } from "../../middlewares/authenticate.js";
import { validate } from "../../middlewares/validate.js";
import { asyncHandler } from "../../utils/asyncHandler.js";
import { transcriptionJobSchema } from "../../validators/transcriptValidators.js";
export const transcriptionRoutes = Router();
transcriptionRoutes.get("/whisper/health", authenticate, asyncHandler(whisperHealth));
transcriptionRoutes.get(
"/:id/status",
authenticate,
validate(transcriptionJobSchema),
asyncHandler(status),
);
+10
View File
@@ -0,0 +1,10 @@
import { Router } from "express";
import { searchUsers } from "../../controllers/userController.js";
import { authenticate } from "../../middlewares/authenticate.js";
import { validate } from "../../middlewares/validate.js";
import { asyncHandler } from "../../utils/asyncHandler.js";
import { userSearchSchema } from "../../validators/userValidators.js";
export const userRoutes = Router();
userRoutes.get("/", authenticate, validate(userSearchSchema), asyncHandler(searchUsers));
+84
View File
@@ -0,0 +1,84 @@
import bcrypt from "bcrypt";
import {
createUser,
findUserByEmail,
findUserById,
findUserByUsername,
findUserCredentialsById,
updateUserPassword,
updateUserProfile,
} from "../repositories/userRepository.js";
import { AppError } from "../utils/AppError.js";
import { issueTokenPair, rotateRefreshToken, revokeToken } from "./tokenService.js";
import { toUser } from "../models/mappers.js";
export async function registerUser(input, req) {
const normalizedEmail = input.email.toLowerCase().trim();
const normalizedUsername = input.username.toLowerCase().trim();
if (await findUserByEmail(normalizedEmail)) {
throw new AppError("Email is already registered", 409, "EMAIL_TAKEN");
}
if (await findUserByUsername(normalizedUsername)) {
throw new AppError("Username is already taken", 409, "USERNAME_TAKEN");
}
const passwordHash = await bcrypt.hash(input.password, 12);
const user = await createUser({
fullName: input.fullName.trim(),
username: normalizedUsername,
email: normalizedEmail,
passwordHash,
});
const tokens = await issueTokenPair(user, req);
return { user, tokens };
}
export async function loginUser({ identifier, email, password }, req) {
const normalizedIdentifier = String(identifier ?? email ?? "")
.toLowerCase()
.trim();
const record = normalizedIdentifier.includes("@")
? await findUserByEmail(normalizedIdentifier)
: await findUserByUsername(normalizedIdentifier);
if (!record || !(await bcrypt.compare(password, record.password_hash))) {
throw new AppError("Invalid username/email or password", 401, "INVALID_CREDENTIALS");
}
const user = toUser(record);
const tokens = await issueTokenPair(user, req);
return { user, tokens };
}
export async function refreshSession(refreshToken, req) {
return rotateRefreshToken(refreshToken, req);
}
export async function logoutSession(refreshToken) {
await revokeToken(refreshToken);
}
export async function updateProfile(userId, input) {
const fullName = input.fullName.trim();
const username = input.username.toLowerCase().trim();
const existing = await findUserByUsername(username);
if (existing && Number(existing.id) !== Number(userId)) {
throw new AppError("Username is already taken", 409, "USERNAME_TAKEN");
}
return updateUserProfile(userId, { fullName, username });
}
export async function changePassword(userId, { currentPassword, newPassword }) {
const record = await findUserCredentialsById(userId);
if (!record || !(await bcrypt.compare(currentPassword, record.password_hash))) {
throw new AppError("Current password is incorrect", 401, "INVALID_CURRENT_PASSWORD");
}
const passwordHash = await bcrypt.hash(newPassword, 12);
await updateUserPassword(userId, passwordHash);
}
export async function requestPasswordReset(email) {
await findUserByEmail(String(email).toLowerCase().trim());
return { accepted: true };
}
export { findUserById };
@@ -0,0 +1,59 @@
import fs from "node:fs";
import { Readable } from "node:stream";
import { env } from "../../config/env.js";
import { AppError } from "../../utils/AppError.js";
function buildUrl(storageKey) {
if (!env.storage.httpBaseUrl) {
throw new AppError("STORAGE_HTTP_BASE_URL is required for HTTP storage", 500, "STORAGE_CONFIG");
}
return `${env.storage.httpBaseUrl}/${storageKey}`;
}
function headers() {
return env.storage.httpToken ? { Authorization: `Bearer ${env.storage.httpToken}` } : {};
}
export function createHttpAdapter() {
return {
name: "http",
async save({ sourcePath, storageKey, mimeType }) {
const response = await fetch(buildUrl(storageKey), {
method: "PUT",
headers: {
...headers(),
"Content-Type": mimeType || "application/octet-stream",
},
body: fs.createReadStream(sourcePath),
duplex: "half",
});
if (!response.ok) {
throw new AppError(
`Remote storage rejected upload (${response.status})`,
502,
"STORAGE_UPLOAD",
);
}
return {
storageDriver: this.name,
storageKey,
publicUrl: env.storage.publicBaseUrl
? `${env.storage.publicBaseUrl}/${storageKey}`
: buildUrl(storageKey),
};
},
async createReadStream(storageKey) {
const response = await fetch(buildUrl(storageKey), { headers: headers() });
if (!response.ok || !response.body) {
throw new AppError("Audio file not found in remote storage", 404, "AUDIO_NOT_FOUND");
}
return Readable.fromWeb(response.body);
},
async remove(storageKey) {
await fetch(buildUrl(storageKey), { method: "DELETE", headers: headers() }).catch(() => {});
},
};
}
@@ -0,0 +1,32 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { env } from "../../config/env.js";
export function createLocalAdapter(basePath = env.storage.basePath) {
return {
name: "local",
async save({ sourcePath, storageKey }) {
const destination = path.join(basePath, storageKey);
await fsp.mkdir(path.dirname(destination), { recursive: true });
await pipeline(fs.createReadStream(sourcePath), fs.createWriteStream(destination));
return {
storageDriver: this.name,
storageKey,
publicUrl: env.storage.publicBaseUrl
? `${env.storage.publicBaseUrl}/${encodeURIComponent(storageKey)}`
: null,
};
},
createReadStream(storageKey) {
return fs.createReadStream(path.join(basePath, storageKey));
},
async remove(storageKey) {
await fsp.unlink(path.join(basePath, storageKey)).catch(() => {});
},
};
}
+66
View File
@@ -0,0 +1,66 @@
import fs from "node:fs";
import { env } from "../../config/env.js";
import { AppError } from "../../utils/AppError.js";
async function client() {
const { S3Client } = await import("@aws-sdk/client-s3");
if (!env.storage.s3.bucket) {
throw new AppError("S3_BUCKET is required for S3 storage", 500, "STORAGE_CONFIG");
}
return new S3Client({
endpoint: env.storage.s3.endpoint || undefined,
region: env.storage.s3.region,
forcePathStyle: env.storage.s3.forcePathStyle,
credentials:
env.storage.s3.accessKeyId && env.storage.s3.secretAccessKey
? {
accessKeyId: env.storage.s3.accessKeyId,
secretAccessKey: env.storage.s3.secretAccessKey,
}
: undefined,
});
}
export function createS3Adapter() {
return {
name: "s3",
async save({ sourcePath, storageKey, mimeType }) {
const { PutObjectCommand } = await import("@aws-sdk/client-s3");
const s3 = await client();
await s3.send(
new PutObjectCommand({
Bucket: env.storage.s3.bucket,
Key: storageKey,
Body: fs.createReadStream(sourcePath),
ContentType: mimeType,
}),
);
return {
storageDriver: this.name,
storageKey,
publicUrl: env.storage.publicBaseUrl ? `${env.storage.publicBaseUrl}/${storageKey}` : null,
};
},
async createReadStream(storageKey) {
const { GetObjectCommand } = await import("@aws-sdk/client-s3");
const s3 = await client();
const response = await s3.send(
new GetObjectCommand({ Bucket: env.storage.s3.bucket, Key: storageKey }),
);
if (!response.Body) {
throw new AppError("Audio file not found in S3 storage", 404, "AUDIO_NOT_FOUND");
}
return response.Body;
},
async remove(storageKey) {
const { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
const s3 = await client();
await s3
.send(new DeleteObjectCommand({ Bucket: env.storage.s3.bucket, Key: storageKey }))
.catch(() => {});
},
};
}
@@ -0,0 +1,8 @@
import { createLocalAdapter } from "./localAdapter.js";
export function createSmbAdapter(basePath) {
return {
...createLocalAdapter(basePath),
name: "smb",
};
}
@@ -0,0 +1,68 @@
import fs from "node:fs/promises";
import path from "node:path";
import { createHash } from "node:crypto";
import { env } from "../../config/env.js";
import { AppError } from "../../utils/AppError.js";
import { randomStorageName } from "../../utils/crypto.js";
import { createHttpAdapter } from "./httpAdapter.js";
import { createLocalAdapter } from "./localAdapter.js";
import { createS3Adapter } from "./s3Adapter.js";
import { createSmbAdapter } from "./smbAdapter.js";
function adapter() {
switch (env.storage.driver) {
case "local":
return createLocalAdapter(env.storage.basePath);
case "smb":
case "nfs":
return createSmbAdapter(env.storage.basePath);
case "http":
return createHttpAdapter();
case "s3":
return createS3Adapter();
default:
throw new AppError(
`Unsupported storage driver: ${env.storage.driver}`,
500,
"STORAGE_CONFIG",
);
}
}
async function checksum(filePath) {
const file = await fs.readFile(filePath);
return createHash("sha256").update(file).digest("hex");
}
export async function saveAudio(file, userId) {
const today = new Date().toISOString().slice(0, 10);
const storageKey = path.posix.join(String(userId), today, randomStorageName(file.originalname));
const activeAdapter = adapter();
const stored = await activeAdapter.save({
sourcePath: file.path,
storageKey,
mimeType: file.mimetype,
});
return {
...stored,
originalName: file.originalname,
mimeType: file.mimetype,
fileSize: file.size,
checksumSha256: await checksum(file.path),
};
}
export async function openAudioStream(storageKey) {
return adapter().createReadStream(storageKey);
}
export async function removeAudio(storageKey) {
if (!storageKey) return;
await adapter().remove(storageKey);
}
export async function cleanupTempFile(file) {
if (file?.path) {
await fs.unlink(file.path).catch(() => {});
}
}
+86
View File
@@ -0,0 +1,86 @@
import jwt from "jsonwebtoken";
import { env } from "../config/env.js";
import {
createRefreshToken,
findActiveRefreshToken,
revokeRefreshToken,
} from "../repositories/tokenRepository.js";
import { findUserById } from "../repositories/userRepository.js";
import { AppError } from "../utils/AppError.js";
import { randomToken, sha256 } from "../utils/crypto.js";
function cookieOptions(maxAge) {
return {
httpOnly: true,
sameSite: env.auth.cookieSameSite,
secure: env.auth.cookieSecure,
maxAge,
path: "/",
};
}
export function signAccessToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
env.auth.accessTokenSecret,
{
expiresIn: env.auth.accessTokenTtl,
},
);
}
export function verifyAccessToken(token) {
return jwt.verify(token, env.auth.accessTokenSecret);
}
export async function issueTokenPair(user, req) {
const accessToken = signAccessToken(user);
const refreshToken = randomToken(64);
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await createRefreshToken({
userId: user.id,
tokenHash: sha256(refreshToken),
expiresAt,
userAgent: req.get("user-agent")?.slice(0, 255) ?? null,
ipAddress: req.ip ?? null,
});
return { accessToken, refreshToken };
}
export async function rotateRefreshToken(refreshToken, req) {
if (!refreshToken) {
throw new AppError("Refresh token is required", 401, "REFRESH_TOKEN_REQUIRED");
}
const tokenHash = sha256(refreshToken);
const record = await findActiveRefreshToken(tokenHash);
if (!record) {
throw new AppError("Invalid or expired refresh token", 401, "INVALID_REFRESH_TOKEN");
}
await revokeRefreshToken(tokenHash);
const user = await findUserById(record.user_id);
if (!user) {
throw new AppError("Invalid refresh token", 401, "INVALID_REFRESH_TOKEN");
}
const tokens = await issueTokenPair(user, req);
return { user, ...tokens };
}
export async function revokeToken(refreshToken) {
if (refreshToken) {
await revokeRefreshToken(sha256(refreshToken));
}
}
export function setAuthCookies(res, { accessToken, refreshToken }) {
res.cookie(env.auth.accessCookieName, accessToken, cookieOptions(15 * 60 * 1000));
res.cookie(env.auth.refreshCookieName, refreshToken, cookieOptions(30 * 24 * 60 * 60 * 1000));
}
export function clearAuthCookies(res) {
res.clearCookie(env.auth.accessCookieName, cookieOptions(0));
res.clearCookie(env.auth.refreshCookieName, cookieOptions(0));
}
@@ -0,0 +1,122 @@
import { env } from "../config/env.js";
import {
enqueueTranscriptionJob,
configureTranscriptionQueue,
} from "../jobs/transcriptionQueue.js";
import {
completeJob,
completeTranscript,
createAudioAsset,
createQueuedTranscript,
failJob,
failTranscript,
findJobById,
findJobByTranscriptId,
findTranscriptById,
findTranscriptByIdForUser,
findTranscriptByJobForUser,
markJobProcessing,
markTranscriptProcessing,
} from "../repositories/transcriptRepository.js";
import { cleanupTempFile, openAudioStream, saveAudio } from "./storage/storageService.js";
import { transcribeAudioStream, checkWhisperHealth } from "../transcription/whisperClient.js";
import { AppError } from "../utils/AppError.js";
export async function createTranscriptionRequest({ userId, file, title, duration }) {
if (!file) {
throw new AppError("Audio file is required", 400, "AUDIO_REQUIRED");
}
let savedAsset = null;
try {
savedAsset = await saveAudio(file, userId);
const audioAssetId = await createAudioAsset(savedAsset);
const { transcriptId, jobId } = await createQueuedTranscript({
senderId: userId,
title,
audioAssetId,
duration,
fileSize: file.size,
modelName: env.whisper.modelName,
});
enqueueTranscriptionJob(jobId);
return {
job: { id: Number(jobId), status: "queued" },
transcript: await findTranscriptByIdForUser(transcriptId, userId),
};
} catch (error) {
if (savedAsset?.storageKey) {
await cleanupFailedRemoteAudio(savedAsset.storageKey);
}
throw error;
} finally {
await cleanupTempFile(file);
}
}
async function cleanupFailedRemoteAudio(storageKey) {
const { removeAudio } = await import("./storage/storageService.js");
await removeAudio(storageKey).catch(() => {});
}
export async function processTranscriptionJob(jobId) {
const job = await findJobById(jobId);
if (!job || !["queued", "failed"].includes(job.status)) return;
await markJobProcessing(jobId);
await markTranscriptProcessing(job.transcript_id);
try {
const transcript = await findTranscriptById(job.transcript_id);
if (!transcript?.audioPath) {
throw new AppError("Transcript audio asset is missing", 500, "AUDIO_MISSING");
}
const result = await transcribeAudioStream({
streamFactory: () => openAudioStream(transcript.audioPath),
filename: transcript.metadata?.originalName ?? `transcript-${transcript.id}.webm`,
mimeType: transcript.metadata?.mimeType ?? "audio/webm",
});
await completeTranscript(transcript.id, result);
await completeJob(jobId);
} catch (error) {
await failTranscript(job.transcript_id, error.message);
await failJob(jobId, error.message);
throw error;
}
}
configureTranscriptionQueue(processTranscriptionJob);
export async function getTranscriptionStatus(jobId, userId) {
const job = await findJobById(jobId);
if (!job) {
throw new AppError("Transcription job not found", 404, "JOB_NOT_FOUND");
}
const transcript = await findTranscriptByJobForUser(jobId, userId);
if (!transcript) {
throw new AppError("Transcription job not found", 404, "JOB_NOT_FOUND");
}
return {
job: {
id: Number(job.id),
transcriptId: Number(job.transcript_id),
status: job.status,
attempts: Number(job.attempts),
lastError: job.last_error,
queuedAt: job.queued_at,
startedAt: job.started_at,
completedAt: job.completed_at,
},
transcript,
};
}
export async function getTranscriptJob(transcriptId) {
return findJobByTranscriptId(transcriptId);
}
export { checkWhisperHealth };
+121
View File
@@ -0,0 +1,121 @@
import axios from "axios";
import FormData from "form-data";
import { env } from "../config/env.js";
import { AppError } from "../utils/AppError.js";
function endpoint(path) {
return `${env.whisper.apiUrl}${path.startsWith("/") ? path : `/${path}`}`;
}
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export async function checkWhisperHealth() {
if (env.whisper.allowMock) {
return { healthy: true, mode: "mock", url: env.whisper.apiUrl };
}
try {
const response = await axios.get(endpoint(env.whisper.healthPath), {
timeout: Math.min(env.whisper.timeoutMs, 5000),
validateStatus: (status) => status >= 200 && status < 500,
});
return {
healthy: response.status >= 200 && response.status < 300,
status: response.status,
url: env.whisper.apiUrl,
data: response.data ?? null,
};
} catch (error) {
return {
healthy: false,
url: env.whisper.apiUrl,
error: error.message,
};
}
}
export async function transcribeAudioStream({ stream, streamFactory, filename, mimeType }) {
if (env.whisper.allowMock) {
return mockTranscript(filename);
}
let lastError;
for (let attempt = 0; attempt <= env.whisper.retries; attempt += 1) {
try {
const currentStream = streamFactory ? await streamFactory() : stream;
return await postToWhisper({ stream: currentStream, filename, mimeType });
} catch (error) {
lastError = error;
if (attempt >= env.whisper.retries) break;
await delay(env.whisper.retryDelayMs * (attempt + 1));
}
}
throw new AppError(
`Whisper VM unavailable: ${lastError?.message ?? "request failed"}`,
503,
"WHISPER_UNAVAILABLE",
);
}
async function postToWhisper({ stream, filename, mimeType }) {
const start = Date.now();
const form = new FormData();
form.append(env.whisper.fileField, stream, {
filename,
contentType: mimeType,
});
const response = await axios.post(endpoint(env.whisper.transcribePath), form, {
headers: form.getHeaders(),
maxBodyLength: Infinity,
maxContentLength: Infinity,
timeout: env.whisper.timeoutMs,
validateStatus: (status) => status >= 200 && status < 300,
});
return normalizeTranscript(response.data, Date.now() - start);
}
function normalizeTranscript(payload, elapsedMs) {
const transcriptText =
payload.transcript_text ??
payload.transcript ??
payload.text ??
payload.result?.transcript_text ??
payload.result?.text ??
"";
if (!transcriptText) {
throw new AppError("Whisper VM returned an empty transcript", 502, "EMPTY_TRANSCRIPT");
}
return {
transcriptText,
language: payload.language ?? payload.result?.language ?? null,
timestamps: payload.timestamps ?? payload.segments ?? payload.result?.timestamps ?? [],
duration: numberOrNull(payload.duration ?? payload.result?.duration),
processingTime: Number((elapsedMs / 1000).toFixed(3)),
modelName: env.whisper.modelName,
};
}
function mockTranscript(filename) {
return {
transcriptText: `Mock transcript for ${filename}. Configure WHISPER_API_URL to use Faster-Whisper Large v3.`,
language: "en",
timestamps: [{ start: 0, end: 4, text: "Mock transcript generated for local development." }],
duration: null,
processingTime: 0.15,
modelName: env.whisper.modelName,
};
}
function numberOrNull(value) {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
+16
View File
@@ -0,0 +1,16 @@
export class AppError extends Error {
constructor(message, status = 500, code = "INTERNAL_ERROR", details = null) {
super(message);
this.name = "AppError";
this.status = status;
this.code = code;
this.details = details;
}
}
export function assertFound(value, message = "Resource not found") {
if (!value) {
throw new AppError(message, 404, "NOT_FOUND");
}
return value;
}
+19
View File
@@ -0,0 +1,19 @@
export function sendSuccess(res, message, data = {}, status = 200) {
return res.status(status).json({
success: true,
message,
data,
});
}
export function sendError(res, error) {
const status = error?.status ?? 500;
return res.status(status).json({
success: false,
message: error?.message ?? "Internal server error",
error: {
code: error?.code ?? "INTERNAL_ERROR",
details: error?.details ?? null,
},
});
}
+5
View File
@@ -0,0 +1,5 @@
export function asyncHandler(handler) {
return function wrappedHandler(req, res, next) {
Promise.resolve(handler(req, res, next)).catch(next);
};
}
+19
View File
@@ -0,0 +1,19 @@
import crypto from "node:crypto";
export function sha256(value) {
return crypto.createHash("sha256").update(value).digest("hex");
}
export function randomToken(bytes = 48) {
return crypto.randomBytes(bytes).toString("base64url");
}
export function randomStorageName(originalName = "audio.webm") {
const safeExt = originalName.includes(".")
? originalName
.slice(originalName.lastIndexOf("."))
.replace(/[^a-z0-9.]/gi, "")
.toLowerCase()
: ".webm";
return `${Date.now()}-${crypto.randomUUID()}${safeExt || ".webm"}`;
}
+18
View File
@@ -0,0 +1,18 @@
const levels = ["debug", "info", "warn", "error"];
function write(level, message, meta) {
const payload = {
level,
time: new Date().toISOString(),
message,
...(meta ? { meta } : {}),
};
const output = JSON.stringify(payload);
if (level === "error") console.error(output);
else if (level === "warn") console.warn(output);
else console.info(output);
}
export const logger = Object.fromEntries(
levels.map((level) => [level, (message, meta) => write(level, message, meta)]),
);
+10
View File
@@ -0,0 +1,10 @@
import path from "node:path";
export function safeFilename(value, fallback = "transcript") {
return (
path
.basename(String(value ?? ""))
.replace(/[^a-z0-9._-]+/gi, "-")
.replace(/^-+|-+$/g, "") || fallback
);
}
+57
View File
@@ -0,0 +1,57 @@
import { z } from "zod";
export const registerSchema = z.object({
body: z.object({
fullName: z.string().trim().min(2).max(140),
username: z
.string()
.trim()
.min(3)
.max(40)
.regex(/^[a-z0-9_]+$/i, "Use letters, numbers, or underscores"),
email: z.string().trim().email().max(180),
password: z.string().min(8).max(128),
}),
});
export const loginSchema = z.object({
body: z
.object({
identifier: z.string().trim().min(1).optional(),
email: z.string().trim().min(1).optional(),
password: z.string().min(1),
})
.refine((input) => input.identifier || input.email, {
path: ["identifier"],
message: "Email or username is required",
})
.transform((input) => ({
identifier: input.identifier ?? input.email,
password: input.password,
})),
});
export const forgotPasswordSchema = z.object({
body: z.object({
email: z.string().trim().email(),
}),
});
export const updateProfileSchema = z.object({
body: z.object({
fullName: z.string().trim().min(2).max(140),
username: z
.string()
.trim()
.min(3)
.max(40)
.regex(/^[a-z0-9_]+$/i, "Use letters, numbers, or underscores"),
}),
});
export const changePasswordSchema = z.object({
body: z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8).max(128),
}),
});
@@ -0,0 +1,40 @@
import { z } from "zod";
export const transcriptListSchema = z.object({
query: z.object({
q: z.string().optional(),
sender: z.string().optional(),
receiver: z.string().optional(),
date: z.string().optional(),
status: z.enum(["queued", "processing", "completed", "failed"]).optional(),
}),
});
export const updateTranscriptSchema = z.object({
params: z.object({
id: z.string().regex(/^\d+$/),
}),
body: z.object({
title: z.string().trim().max(220).optional(),
transcriptText: z.string().trim().min(1),
}),
});
export const transcriptIdSchema = z.object({
params: z.object({
id: z.string().regex(/^\d+$/),
}),
});
export const sendTranscriptSchema = z.object({
body: z.object({
transcriptId: z.coerce.number().int().positive(),
receiverId: z.coerce.number().int().positive(),
}),
});
export const transcriptionJobSchema = z.object({
params: z.object({
id: z.string().regex(/^\d+$/),
}),
});
+8
View File
@@ -0,0 +1,8 @@
import { z } from "zod";
export const userSearchSchema = z.object({
query: z.object({
q: z.string().optional().default(""),
limit: z.coerce.number().int().min(1).max(50).optional().default(25),
}),
});