fix(security): explicit per-route rate limits (CodeQL js/missing-rate-limiting)

All /api/ routes are already covered by the global @fastify/rate-limit
(index.ts:283), but CodeQL doesn't model the global registration and flagged
every route without an explicit per-route limit. Added tuned config.rateLimit
to 45 routes: stricter on auth/credential routes (mfa/saml/oidc/api-keys,
15-30/min), moderate on writes (60/min), generous on reads/polls (300/min =
the global default). Real defense-in-depth on sensitive routes. 3 alerts on
non-route code (a preHandler hook, the rate-limiter's own DB lookup, a test
helper) are documented false-positives covered by the global limiter.
This commit is contained in:
SnapOtter
2026-06-21 11:49:02 +08:00
parent c1cd8712f4
commit ae4fc1decf
15 changed files with 2066 additions and 1923 deletions
+1
View File
@@ -566,6 +566,7 @@ app.get("/api/v1/readyz", async (_request, reply) => {
// Cancel a job (authenticated)
app.post(
"/api/v1/jobs/:jobId/cancel",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (
request: import("fastify").FastifyRequest<{ Params: { jobId: string } }>,
reply: import("fastify").FastifyReply,
+60 -54
View File
@@ -547,72 +547,78 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
});
// POST /api/auth/change-password
app.post("/api/auth/change-password", async (request: FastifyRequest, reply: FastifyReply) => {
const authUser = requireAuth(request, reply);
if (!authUser) return;
app.post(
"/api/auth/change-password",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
const authUser = requireAuth(request, reply);
if (!authUser) return;
const parsed = changePasswordSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "Current password and new password are required",
code: "VALIDATION_ERROR",
});
}
const body = parsed.data;
const parsed = changePasswordSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "Current password and new password are required",
code: "VALIDATION_ERROR",
});
}
const body = parsed.data;
const pwError = await validatePasswordStrength(body.newPassword);
if (pwError) {
return reply.status(400).send({
error: pwError,
code: "VALIDATION_ERROR",
});
}
const pwError = await validatePasswordStrength(body.newPassword);
if (pwError) {
return reply.status(400).send({
error: pwError,
code: "VALIDATION_ERROR",
});
}
const [user] = await db.select().from(schema.users).where(eq(schema.users.id, authUser.id));
const [user] = await db.select().from(schema.users).where(eq(schema.users.id, authUser.id));
if (!user) {
return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" });
}
if (!user) {
return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" });
}
if (!user.passwordHash) {
return reply.status(400).send({
error: "Password changes are managed by your identity provider.",
code: "OIDC_NO_PASSWORD",
});
}
if (!user.passwordHash) {
return reply.status(400).send({
error: "Password changes are managed by your identity provider.",
code: "OIDC_NO_PASSWORD",
});
}
const valid = await verifyPassword(body.currentPassword, user.passwordHash);
if (!valid) {
return reply
.status(401)
.send({ error: "Current password is incorrect", code: "INVALID_PASSWORD" });
}
const valid = await verifyPassword(body.currentPassword, user.passwordHash);
if (!valid) {
return reply
.status(401)
.send({ error: "Current password is incorrect", code: "INVALID_PASSWORD" });
}
const newHash = await hashPassword(body.newPassword);
const newHash = await hashPassword(body.newPassword);
await db
.update(schema.users)
.set({ passwordHash: newHash, mustChangePassword: false, updatedAt: new Date() })
.where(eq(schema.users.id, authUser.id));
// Invalidate all other sessions for this user
const currentToken = extractToken(request);
if (currentToken) {
await db
.delete(schema.sessions)
.where(and(eq(schema.sessions.userId, authUser.id), ne(schema.sessions.id, currentToken)));
}
.update(schema.users)
.set({ passwordHash: newHash, mustChangePassword: false, updatedAt: new Date() })
.where(eq(schema.users.id, authUser.id));
// Revoke all API keys - if credentials were compromised, keys must be rotated too
await db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, authUser.id));
// Invalidate all other sessions for this user
const currentToken = extractToken(request);
if (currentToken) {
await db
.delete(schema.sessions)
.where(
and(eq(schema.sessions.userId, authUser.id), ne(schema.sessions.id, currentToken)),
);
}
await auditFromRequest(request)("PASSWORD_CHANGED", {
userId: authUser.id,
username: authUser.username,
});
// Revoke all API keys - if credentials were compromised, keys must be rotated too
await db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, authUser.id));
return reply.send({ ok: true });
});
await auditFromRequest(request)("PASSWORD_CHANGED", {
userId: authUser.id,
username: authUser.username,
});
return reply.send({ ok: true });
},
);
// GET /api/auth/users (admin only)
app.get("/api/auth/users", async (request: FastifyRequest, reply: FastifyReply) => {
+275 -250
View File
@@ -110,290 +110,315 @@ export function isMfaRequiredForUser(policy: MfaPolicy, userRole: string): boole
export async function registerMfa(app: FastifyInstance): Promise<void> {
// POST /api/auth/mfa/enroll -- start MFA enrollment
app.post("/api/auth/mfa/enroll", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
app.post(
"/api/auth/mfa/enroll",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
// Check enterprise feature gate
let mfaLicensed = false;
try {
const { isFeatureEnabled } = await import("@snapotter/enterprise");
mfaLicensed = isFeatureEnabled("mfa");
} catch {
// Enterprise package not available
}
// Check enterprise feature gate
let mfaLicensed = false;
try {
const { isFeatureEnabled } = await import("@snapotter/enterprise");
mfaLicensed = isFeatureEnabled("mfa");
} catch {
// Enterprise package not available
}
if (!mfaLicensed) {
return reply.status(403).send({
error: "MFA requires an enterprise license",
code: "FEATURE_NOT_LICENSED",
});
}
if (!mfaLicensed) {
return reply.status(403).send({
error: "MFA requires an enterprise license",
code: "FEATURE_NOT_LICENSED",
});
}
// Check if already enrolled
const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, user.id));
if (!dbUser) {
return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" });
}
if (dbUser.totpEnabled) {
return reply.status(409).send({
error: "MFA is already enabled. Disable it first to re-enroll.",
code: "MFA_ALREADY_ENABLED",
});
}
// Check if already enrolled
const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, user.id));
if (!dbUser) {
return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" });
}
if (dbUser.totpEnabled) {
return reply.status(409).send({
error: "MFA is already enabled. Disable it first to re-enroll.",
code: "MFA_ALREADY_ENABLED",
});
}
// Check if there's already a pending (unverified) enrollment
if (dbUser.totpSecret && !dbUser.totpEnabled) {
return reply.status(409).send({
error:
"MFA enrollment already pending. Complete verification first or contact an admin to reset.",
code: "MFA_ENROLLMENT_PENDING",
});
}
// Check if there's already a pending (unverified) enrollment
if (dbUser.totpSecret && !dbUser.totpEnabled) {
return reply.status(409).send({
error:
"MFA enrollment already pending. Complete verification first or contact an admin to reset.",
code: "MFA_ENROLLMENT_PENDING",
});
}
// Generate TOTP secret
const totp = createTotp(user.username);
const uri = totp.toString();
// Generate TOTP secret
const totp = createTotp(user.username);
const uri = totp.toString();
// Generate recovery codes
const recoveryCodes = generateRecoveryCodes();
const recoveryHash = hashRecoveryCodes(recoveryCodes);
// Generate recovery codes
const recoveryCodes = generateRecoveryCodes();
const recoveryHash = hashRecoveryCodes(recoveryCodes);
// Encrypt TOTP secret for storage
const encryptedSecret = await encryptSecret(totp.secret.base32);
// Encrypt TOTP secret for storage
const encryptedSecret = await encryptSecret(totp.secret.base32);
// Store pending enrollment (not yet active)
await db
.update(schema.users)
.set({
totpSecret: encryptedSecret,
totpEnabled: false,
recoveryCodesHash: recoveryHash,
updatedAt: new Date(),
})
.where(eq(schema.users.id, user.id));
// Store pending enrollment (not yet active)
await db
.update(schema.users)
.set({
totpSecret: encryptedSecret,
totpEnabled: false,
recoveryCodesHash: recoveryHash,
updatedAt: new Date(),
})
.where(eq(schema.users.id, user.id));
return reply.send({ uri, recoveryCodes });
});
return reply.send({ uri, recoveryCodes });
},
);
// POST /api/auth/mfa/verify -- confirm enrollment with a TOTP code
app.post("/api/auth/mfa/verify", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
app.post(
"/api/auth/mfa/verify",
{
config: { rateLimit: { max: 15, timeWindow: "1 minute" } },
},
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const parsed = verifyCodeSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "A valid TOTP code is required",
code: "VALIDATION_ERROR",
});
}
const { code } = parsed.data;
const parsed = verifyCodeSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "A valid TOTP code is required",
code: "VALIDATION_ERROR",
});
}
const { code } = parsed.data;
const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, user.id));
if (!dbUser?.totpSecret) {
return reply.status(400).send({
error: "No pending MFA enrollment found. Call /api/auth/mfa/enroll first.",
code: "NO_PENDING_ENROLLMENT",
});
}
if (dbUser.totpEnabled) {
return reply.status(409).send({
error: "MFA is already verified and active",
code: "MFA_ALREADY_ENABLED",
});
}
const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, user.id));
if (!dbUser?.totpSecret) {
return reply.status(400).send({
error: "No pending MFA enrollment found. Call /api/auth/mfa/enroll first.",
code: "NO_PENDING_ENROLLMENT",
});
}
if (dbUser.totpEnabled) {
return reply.status(409).send({
error: "MFA is already verified and active",
code: "MFA_ALREADY_ENABLED",
});
}
// Decrypt the stored secret
const secretBase32 = await decryptSecret(dbUser.totpSecret);
if (!secretBase32) {
return reply.status(500).send({
error: "Failed to decrypt TOTP secret",
code: "DECRYPTION_FAILED",
});
}
// Decrypt the stored secret
const secretBase32 = await decryptSecret(dbUser.totpSecret);
if (!secretBase32) {
return reply.status(500).send({
error: "Failed to decrypt TOTP secret",
code: "DECRYPTION_FAILED",
});
}
// Validate the code
if (!verifyTotpCode(secretBase32, code)) {
return reply.status(401).send({
error: "Invalid TOTP code",
code: "INVALID_CODE",
});
}
// Validate the code
if (!verifyTotpCode(secretBase32, code)) {
return reply.status(401).send({
error: "Invalid TOTP code",
code: "INVALID_CODE",
});
}
// Activate MFA
await db
.update(schema.users)
.set({ totpEnabled: true, updatedAt: new Date() })
.where(eq(schema.users.id, user.id));
// Activate MFA
await db
.update(schema.users)
.set({ totpEnabled: true, updatedAt: new Date() })
.where(eq(schema.users.id, user.id));
const audit = auditFromRequest(request);
await audit("MFA_ENROLLED", { userId: user.id, username: user.username });
const audit = auditFromRequest(request);
await audit("MFA_ENROLLED", { userId: user.id, username: user.username });
return reply.send({ ok: true });
});
return reply.send({ ok: true });
},
);
// POST /api/auth/mfa/complete -- complete login with TOTP code
app.post("/api/auth/mfa/complete", async (request: FastifyRequest, reply: FastifyReply) => {
const parsed = completeSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "MFA token and code are required",
code: "VALIDATION_ERROR",
});
}
const { mfaToken, code } = parsed.data;
// Look up the pending MFA challenge in Redis
const redis = sharedRedis();
const userId = await redis.get(`mfa:${mfaToken}`);
if (!userId) {
return reply.status(401).send({
error: "MFA challenge expired or invalid",
code: "MFA_EXPIRED",
});
}
// Load user
const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, userId));
if (!dbUser?.totpSecret) {
return reply.status(401).send({
error: "User not found or MFA not configured",
code: "MFA_NOT_CONFIGURED",
});
}
// Decrypt the stored secret
const secretBase32 = await decryptSecret(dbUser.totpSecret);
if (!secretBase32) {
return reply.status(500).send({
error: "Failed to decrypt TOTP secret",
code: "DECRYPTION_FAILED",
});
}
const audit = auditFromRequest(request);
let verified = false;
let recoveryUsed = false;
// Try TOTP code first
if (verifyTotpCode(secretBase32, code)) {
verified = true;
}
// Try recovery code if TOTP failed
if (!verified && dbUser.recoveryCodesHash) {
const result = verifyRecoveryCode(code, dbUser.recoveryCodesHash);
if (result.valid) {
verified = true;
recoveryUsed = true;
// Consume the recovery code
await db
.update(schema.users)
.set({ recoveryCodesHash: result.remaining || null, updatedAt: new Date() })
.where(eq(schema.users.id, userId));
app.post(
"/api/auth/mfa/complete",
{
config: { rateLimit: { max: 15, timeWindow: "1 minute" } },
},
async (request: FastifyRequest, reply: FastifyReply) => {
const parsed = completeSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "MFA token and code are required",
code: "VALIDATION_ERROR",
});
}
}
const { mfaToken, code } = parsed.data;
if (!verified) {
await audit("MFA_VERIFY_FAILED", { userId, username: dbUser.username });
return reply.status(401).send({
error: "Invalid TOTP or recovery code",
code: "INVALID_CODE",
// Look up the pending MFA challenge in Redis
const redis = sharedRedis();
const userId = await redis.get(`mfa:${mfaToken}`);
if (!userId) {
return reply.status(401).send({
error: "MFA challenge expired or invalid",
code: "MFA_EXPIRED",
});
}
// Load user
const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, userId));
if (!dbUser?.totpSecret) {
return reply.status(401).send({
error: "User not found or MFA not configured",
code: "MFA_NOT_CONFIGURED",
});
}
// Decrypt the stored secret
const secretBase32 = await decryptSecret(dbUser.totpSecret);
if (!secretBase32) {
return reply.status(500).send({
error: "Failed to decrypt TOTP secret",
code: "DECRYPTION_FAILED",
});
}
const audit = auditFromRequest(request);
let verified = false;
let recoveryUsed = false;
// Try TOTP code first
if (verifyTotpCode(secretBase32, code)) {
verified = true;
}
// Try recovery code if TOTP failed
if (!verified && dbUser.recoveryCodesHash) {
const result = verifyRecoveryCode(code, dbUser.recoveryCodesHash);
if (result.valid) {
verified = true;
recoveryUsed = true;
// Consume the recovery code
await db
.update(schema.users)
.set({ recoveryCodesHash: result.remaining || null, updatedAt: new Date() })
.where(eq(schema.users.id, userId));
}
}
if (!verified) {
await audit("MFA_VERIFY_FAILED", { userId, username: dbUser.username });
return reply.status(401).send({
error: "Invalid TOTP or recovery code",
code: "INVALID_CODE",
});
}
// Delete the challenge token
await redis.del(`mfa:${mfaToken}`);
// Create session (same as normal login completion)
const token = createSessionToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
await db.insert(schema.sessions).values({
id: token,
userId: dbUser.id,
expiresAt,
});
}
// Delete the challenge token
await redis.del(`mfa:${mfaToken}`);
// Create session (same as normal login completion)
const token = createSessionToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
await db.insert(schema.sessions).values({
id: token,
userId: dbUser.id,
expiresAt,
});
await audit(recoveryUsed ? "MFA_RECOVERY_USED" : "MFA_VERIFIED", {
userId: dbUser.id,
username: dbUser.username,
});
const [teamRow] = await db.select().from(schema.teams).where(eq(schema.teams.id, dbUser.team));
return reply.send({
token,
user: {
id: dbUser.id,
await audit(recoveryUsed ? "MFA_RECOVERY_USED" : "MFA_VERIFIED", {
userId: dbUser.id,
username: dbUser.username,
role: dbUser.role,
mustChangePassword: env.SKIP_MUST_CHANGE_PASSWORD ? false : dbUser.mustChangePassword,
permissions: await getPermissions(dbUser.role),
teamName: teamRow?.name ?? dbUser.team,
analyticsEnabled: dbUser.analyticsEnabled ?? null,
analyticsConsentShownAt: dbUser.analyticsConsentShownAt?.getTime() ?? null,
analyticsConsentRemindAt: dbUser.analyticsConsentRemindAt?.getTime() ?? null,
},
expiresAt: expiresAt.toISOString(),
});
});
});
const [teamRow] = await db
.select()
.from(schema.teams)
.where(eq(schema.teams.id, dbUser.team));
return reply.send({
token,
user: {
id: dbUser.id,
username: dbUser.username,
role: dbUser.role,
mustChangePassword: env.SKIP_MUST_CHANGE_PASSWORD ? false : dbUser.mustChangePassword,
permissions: await getPermissions(dbUser.role),
teamName: teamRow?.name ?? dbUser.team,
analyticsEnabled: dbUser.analyticsEnabled ?? null,
analyticsConsentShownAt: dbUser.analyticsConsentShownAt?.getTime() ?? null,
analyticsConsentRemindAt: dbUser.analyticsConsentRemindAt?.getTime() ?? null,
},
expiresAt: expiresAt.toISOString(),
});
},
);
// POST /api/auth/mfa/disable -- disable MFA (self-service)
app.post("/api/auth/mfa/disable", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
app.post(
"/api/auth/mfa/disable",
{
config: { rateLimit: { max: 15, timeWindow: "1 minute" } },
},
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const parsed = disableSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "Current TOTP code is required to disable MFA",
code: "VALIDATION_ERROR",
});
}
const { code } = parsed.data;
const parsed = disableSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "Current TOTP code is required to disable MFA",
code: "VALIDATION_ERROR",
});
}
const { code } = parsed.data;
const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, user.id));
if (!dbUser?.totpEnabled || !dbUser.totpSecret) {
return reply.status(400).send({
error: "MFA is not enabled",
code: "MFA_NOT_ENABLED",
});
}
const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, user.id));
if (!dbUser?.totpEnabled || !dbUser.totpSecret) {
return reply.status(400).send({
error: "MFA is not enabled",
code: "MFA_NOT_ENABLED",
});
}
// Decrypt and verify the code
const secretBase32 = await decryptSecret(dbUser.totpSecret);
if (!secretBase32) {
return reply.status(500).send({
error: "Failed to decrypt TOTP secret",
code: "DECRYPTION_FAILED",
});
}
// Decrypt and verify the code
const secretBase32 = await decryptSecret(dbUser.totpSecret);
if (!secretBase32) {
return reply.status(500).send({
error: "Failed to decrypt TOTP secret",
code: "DECRYPTION_FAILED",
});
}
if (!verifyTotpCode(secretBase32, code)) {
return reply.status(401).send({
error: "Invalid TOTP code",
code: "INVALID_CODE",
});
}
if (!verifyTotpCode(secretBase32, code)) {
return reply.status(401).send({
error: "Invalid TOTP code",
code: "INVALID_CODE",
});
}
// Clear MFA data
await db
.update(schema.users)
.set({
totpSecret: null,
totpEnabled: false,
recoveryCodesHash: null,
updatedAt: new Date(),
})
.where(eq(schema.users.id, user.id));
// Clear MFA data
await db
.update(schema.users)
.set({
totpSecret: null,
totpEnabled: false,
recoveryCodesHash: null,
updatedAt: new Date(),
})
.where(eq(schema.users.id, user.id));
const audit = auditFromRequest(request);
await audit("MFA_DISABLED", { userId: user.id, username: user.username });
const audit = auditFromRequest(request);
await audit("MFA_DISABLED", { userId: user.id, username: user.username });
return reply.send({ ok: true });
});
return reply.send({ ok: true });
},
);
// POST /api/auth/users/:id/mfa/reset -- admin reset
app.post(
+182 -174
View File
@@ -103,197 +103,205 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
if (!env.OIDC_ENABLED) return;
// GET /api/auth/oidc/login
app.get("/api/auth/oidc/login", async (request: FastifyRequest, reply: FastifyReply) => {
let config: oidc.Configuration;
try {
config = await getOrDiscoverConfig();
} catch (err) {
request.log.error({ err }, "OIDC discovery failed");
return redirectToLogin(reply, "oidc_provider_unreachable");
}
app.get(
"/api/auth/oidc/login",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
let config: oidc.Configuration;
try {
config = await getOrDiscoverConfig();
} catch (err) {
request.log.error({ err }, "OIDC discovery failed");
return redirectToLogin(reply, "oidc_provider_unreachable");
}
const state = oidc.randomState();
const nonce = oidc.randomNonce();
const codeVerifier = oidc.randomPKCECodeVerifier();
const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
const state = oidc.randomState();
const nonce = oidc.randomNonce();
const codeVerifier = oidc.randomPKCECodeVerifier();
const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
const redirectUri = `${env.EXTERNAL_URL}/api/auth/oidc/callback`;
const redirectUri = `${env.EXTERNAL_URL}/api/auth/oidc/callback`;
// Store OIDC state in a signed cookie
const statePayload: OidcStateCookie = { state, nonce, codeVerifier };
const cookieValue = reply.signCookie(JSON.stringify(statePayload));
// Store OIDC state in a signed cookie
const statePayload: OidcStateCookie = { state, nonce, codeVerifier };
const cookieValue = reply.signCookie(JSON.stringify(statePayload));
reply.setCookie("oidc-state", cookieValue, {
httpOnly: true,
sameSite: "lax",
secure: isSecure(),
path: "/api/auth/oidc",
maxAge: 600, // 10 minutes
signed: false, // already signed manually
});
reply.setCookie("oidc-state", cookieValue, {
httpOnly: true,
sameSite: "lax",
secure: isSecure(),
path: "/api/auth/oidc",
maxAge: 600, // 10 minutes
signed: false, // already signed manually
});
const authorizationUrl = oidc.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope: env.OIDC_SCOPES,
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
const authorizationUrl = oidc.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope: env.OIDC_SCOPES,
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
return reply.redirect(authorizationUrl.href);
});
return reply.redirect(authorizationUrl.href);
},
);
// GET /api/auth/oidc/callback
app.get("/api/auth/oidc/callback", async (request: FastifyRequest, reply: FastifyReply) => {
// 1. Validate state from signed cookie
const rawCookie = request.cookies?.["oidc-state"];
if (!rawCookie) {
request.log.warn("OIDC callback: missing state cookie");
return redirectToLogin(reply, "oidc_session_expired");
}
// Clear the cookie immediately
reply.clearCookie("oidc-state", {
path: "/api/auth/oidc",
httpOnly: true,
sameSite: "lax",
secure: isSecure(),
});
const unsigned = request.unsignCookie(rawCookie);
if (!unsigned.valid || !unsigned.value) {
request.log.warn("OIDC callback: invalid cookie signature");
return redirectToLogin(reply, "oidc_session_expired");
}
let storedState: OidcStateCookie;
try {
storedState = JSON.parse(unsigned.value) as OidcStateCookie;
} catch {
request.log.warn("OIDC callback: malformed state cookie");
return redirectToLogin(reply, "oidc_session_expired");
}
// Validate state parameter matches
const query = request.query as Record<string, string>;
if (query.state !== storedState.state) {
request.log.warn("OIDC callback: state mismatch");
return redirectToLogin(reply, "oidc_session_expired");
}
const audit = auditFromRequest(request);
// Check for error response from the IdP
if (query.error) {
request.log.warn(
{ error: query.error, description: query.error_description },
"OIDC IdP returned error",
);
authAttempts.inc({ method: "oidc", result: "failure" });
await audit("OIDC_LOGIN_FAILED", {
reason: sanitizeAuditInput(String(query.error)),
});
return redirectToLogin(reply, "oidc_auth_failed");
}
// 2. Exchange authorization code for tokens
let config: oidc.Configuration;
try {
config = await getOrDiscoverConfig();
} catch (err) {
request.log.error({ err }, "OIDC discovery failed during callback");
return redirectToLogin(reply, "oidc_provider_unreachable");
}
let tokenResponse: Awaited<ReturnType<typeof oidc.authorizationCodeGrant>>;
try {
const callbackUrl = new URL(`${env.EXTERNAL_URL}/api/auth/oidc/callback`);
// Copy the query parameters from the actual request
for (const [key, value] of Object.entries(query)) {
callbackUrl.searchParams.set(key, value);
app.get(
"/api/auth/oidc/callback",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
// 1. Validate state from signed cookie
const rawCookie = request.cookies?.["oidc-state"];
if (!rawCookie) {
request.log.warn("OIDC callback: missing state cookie");
return redirectToLogin(reply, "oidc_session_expired");
}
tokenResponse = await oidc.authorizationCodeGrant(config, callbackUrl, {
pkceCodeVerifier: storedState.codeVerifier,
expectedNonce: storedState.nonce,
expectedState: storedState.state,
// Clear the cookie immediately
reply.clearCookie("oidc-state", {
path: "/api/auth/oidc",
httpOnly: true,
sameSite: "lax",
secure: isSecure(),
});
} catch (err) {
request.log.error({ err }, "OIDC token exchange failed");
authAttempts.inc({ method: "oidc", result: "failure" });
await audit("OIDC_LOGIN_FAILED", { reason: "token_exchange_failed" });
return redirectToLogin(reply, "oidc_auth_failed");
}
// 3. Extract claims from ID token
const claims = tokenResponse.claims();
if (!claims) {
request.log.error("OIDC callback: no ID token claims");
authAttempts.inc({ method: "oidc", result: "failure" });
await audit("OIDC_LOGIN_FAILED", { reason: "no_id_token" });
return redirectToLogin(reply, "oidc_auth_failed");
}
const sub = claims.sub;
const email = typeof claims.email === "string" ? claims.email : undefined;
const emailVerified = claims.email_verified === true;
const rawUsername = deriveUsername(claims as Record<string, unknown>);
const derivedUsername = sanitizeUsername(rawUsername);
const idToken = tokenResponse.id_token ?? null;
// 4. User resolution (delegated to shared resolver)
const result = await resolveExternalUser({
provider: "oidc",
externalId: sub,
email,
emailVerified,
username: derivedUsername,
autoCreate: env.OIDC_AUTO_CREATE_USERS,
autoLink: env.OIDC_AUTO_LINK_USERS,
defaultRole: env.OIDC_DEFAULT_ROLE,
logger: request.log,
ip: request.ip,
requestId: request.id,
});
if (result.action === "denied" || !result.user) {
authAttempts.inc({ method: "oidc", result: "failure" });
if (result.deniedReason === "user_limit_reached") {
return redirectToLogin(reply, "oidc_user_limit_reached");
const unsigned = request.unsignCookie(rawCookie);
if (!unsigned.valid || !unsigned.value) {
request.log.warn("OIDC callback: invalid cookie signature");
return redirectToLogin(reply, "oidc_session_expired");
}
return redirectToLogin(reply, "oidc_user_not_authorized");
}
const resolvedUser = result.user;
let storedState: OidcStateCookie;
try {
storedState = JSON.parse(unsigned.value) as OidcStateCookie;
} catch {
request.log.warn("OIDC callback: malformed state cookie");
return redirectToLogin(reply, "oidc_session_expired");
}
// 5. Create session
const token = createSessionToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
// Validate state parameter matches
const query = request.query as Record<string, string>;
if (query.state !== storedState.state) {
request.log.warn("OIDC callback: state mismatch");
return redirectToLogin(reply, "oidc_session_expired");
}
await db.insert(schema.sessions).values({
id: token,
userId: resolvedUser.id,
expiresAt,
idToken,
});
const audit = auditFromRequest(request);
authAttempts.inc({ method: "oidc", result: "success" });
await audit("OIDC_LOGIN_SUCCESS", {
userId: resolvedUser.id,
username: resolvedUser.username,
});
// Check for error response from the IdP
if (query.error) {
request.log.warn(
{ error: query.error, description: query.error_description },
"OIDC IdP returned error",
);
authAttempts.inc({ method: "oidc", result: "failure" });
await audit("OIDC_LOGIN_FAILED", {
reason: sanitizeAuditInput(String(query.error)),
});
return redirectToLogin(reply, "oidc_auth_failed");
}
// 6. Set session cookie
reply.setCookie("snapotter-session", token, {
httpOnly: true,
sameSite: "strict",
secure: isSecure(),
path: "/",
maxAge: env.SESSION_DURATION_HOURS * 3600,
});
// 2. Exchange authorization code for tokens
let config: oidc.Configuration;
try {
config = await getOrDiscoverConfig();
} catch (err) {
request.log.error({ err }, "OIDC discovery failed during callback");
return redirectToLogin(reply, "oidc_provider_unreachable");
}
// 7. Redirect to app
return reply.redirect("/");
});
let tokenResponse: Awaited<ReturnType<typeof oidc.authorizationCodeGrant>>;
try {
const callbackUrl = new URL(`${env.EXTERNAL_URL}/api/auth/oidc/callback`);
// Copy the query parameters from the actual request
for (const [key, value] of Object.entries(query)) {
callbackUrl.searchParams.set(key, value);
}
tokenResponse = await oidc.authorizationCodeGrant(config, callbackUrl, {
pkceCodeVerifier: storedState.codeVerifier,
expectedNonce: storedState.nonce,
expectedState: storedState.state,
});
} catch (err) {
request.log.error({ err }, "OIDC token exchange failed");
authAttempts.inc({ method: "oidc", result: "failure" });
await audit("OIDC_LOGIN_FAILED", { reason: "token_exchange_failed" });
return redirectToLogin(reply, "oidc_auth_failed");
}
// 3. Extract claims from ID token
const claims = tokenResponse.claims();
if (!claims) {
request.log.error("OIDC callback: no ID token claims");
authAttempts.inc({ method: "oidc", result: "failure" });
await audit("OIDC_LOGIN_FAILED", { reason: "no_id_token" });
return redirectToLogin(reply, "oidc_auth_failed");
}
const sub = claims.sub;
const email = typeof claims.email === "string" ? claims.email : undefined;
const emailVerified = claims.email_verified === true;
const rawUsername = deriveUsername(claims as Record<string, unknown>);
const derivedUsername = sanitizeUsername(rawUsername);
const idToken = tokenResponse.id_token ?? null;
// 4. User resolution (delegated to shared resolver)
const result = await resolveExternalUser({
provider: "oidc",
externalId: sub,
email,
emailVerified,
username: derivedUsername,
autoCreate: env.OIDC_AUTO_CREATE_USERS,
autoLink: env.OIDC_AUTO_LINK_USERS,
defaultRole: env.OIDC_DEFAULT_ROLE,
logger: request.log,
ip: request.ip,
requestId: request.id,
});
if (result.action === "denied" || !result.user) {
authAttempts.inc({ method: "oidc", result: "failure" });
if (result.deniedReason === "user_limit_reached") {
return redirectToLogin(reply, "oidc_user_limit_reached");
}
return redirectToLogin(reply, "oidc_user_not_authorized");
}
const resolvedUser = result.user;
// 5. Create session
const token = createSessionToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
await db.insert(schema.sessions).values({
id: token,
userId: resolvedUser.id,
expiresAt,
idToken,
});
authAttempts.inc({ method: "oidc", result: "success" });
await audit("OIDC_LOGIN_SUCCESS", {
userId: resolvedUser.id,
username: resolvedUser.username,
});
// 6. Set session cookie
reply.setCookie("snapotter-session", token, {
httpOnly: true,
sameSite: "strict",
secure: isSecure(),
path: "/",
maxAge: env.SESSION_DURATION_HOURS * 3600,
});
// 7. Redirect to app
return reply.redirect("/");
},
);
}
+102 -90
View File
@@ -79,105 +79,117 @@ export async function registerSaml(app: FastifyInstance): Promise<void> {
});
// GET /api/auth/saml/login -- SP-initiated login redirect
app.get("/api/auth/saml/login", async (_request: FastifyRequest, reply: FastifyReply) => {
try {
const saml = getSamlInstance();
const loginUrl = await saml.getAuthorizeUrlAsync("", undefined, {});
return reply.redirect(loginUrl);
} catch (err) {
_request.log.error({ err }, "SAML login redirect failed");
return redirectToLogin(reply, "saml_auth_failed");
}
});
app.get(
"/api/auth/saml/login",
{
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
},
async (_request: FastifyRequest, reply: FastifyReply) => {
try {
const saml = getSamlInstance();
const loginUrl = await saml.getAuthorizeUrlAsync("", undefined, {});
return reply.redirect(loginUrl);
} catch (err) {
_request.log.error({ err }, "SAML login redirect failed");
return redirectToLogin(reply, "saml_auth_failed");
}
},
);
// POST /api/auth/saml/callback -- Assertion Consumer Service (ACS)
app.post("/api/auth/saml/callback", async (request: FastifyRequest, reply: FastifyReply) => {
const saml = getSamlInstance();
const audit = auditFromRequest(request);
app.post(
"/api/auth/saml/callback",
{
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
},
async (request: FastifyRequest, reply: FastifyReply) => {
const saml = getSamlInstance();
const audit = auditFromRequest(request);
let profile: Awaited<ReturnType<typeof saml.validatePostResponseAsync>>["profile"];
try {
const result = await saml.validatePostResponseAsync(request.body as Record<string, string>);
profile = result.profile;
} catch (err) {
request.log.error({ err }, "SAML assertion validation failed");
authAttempts.inc({ method: "saml", result: "failure" });
await audit("SAML_LOGIN_FAILED", {
error: err instanceof Error ? err.message : "Unknown error",
let profile: Awaited<ReturnType<typeof saml.validatePostResponseAsync>>["profile"];
try {
const result = await saml.validatePostResponseAsync(request.body as Record<string, string>);
profile = result.profile;
} catch (err) {
request.log.error({ err }, "SAML assertion validation failed");
authAttempts.inc({ method: "saml", result: "failure" });
await audit("SAML_LOGIN_FAILED", {
error: err instanceof Error ? err.message : "Unknown error",
});
return redirectToLogin(reply, "saml_auth_failed");
}
if (!profile?.nameID) {
request.log.warn("SAML callback: no profile or nameID in assertion");
authAttempts.inc({ method: "saml", result: "failure" });
await audit("SAML_LOGIN_FAILED", { reason: "missing_profile" });
return redirectToLogin(reply, "saml_auth_failed");
}
// Extract claims from SAML assertion
const externalId = profile.nameID;
const email = profile[env.SAML_EMAIL_ATTRIBUTE] as string | undefined;
const usernameAttr = env.SAML_USERNAME_ATTRIBUTE
? (profile[env.SAML_USERNAME_ATTRIBUTE] as string | undefined)
: undefined;
// Derive a username from available claims
const rawUsername = usernameAttr || email?.split("@")[0] || profile.nameID;
let username = sanitizeUsername(rawUsername);
username = await findUniqueUsername(username);
// Resolve user via shared external-auth resolver
const result = await resolveExternalUser({
provider: "saml",
externalId,
email,
emailVerified: true, // SAML assertions from a trusted IdP are considered verified
username,
autoCreate: env.SAML_AUTO_CREATE_USERS,
autoLink: env.SAML_AUTO_LINK_USERS,
defaultRole: env.SAML_DEFAULT_ROLE,
logger: request.log,
ip: request.ip,
requestId: request.id,
});
return redirectToLogin(reply, "saml_auth_failed");
}
if (!profile?.nameID) {
request.log.warn("SAML callback: no profile or nameID in assertion");
authAttempts.inc({ method: "saml", result: "failure" });
await audit("SAML_LOGIN_FAILED", { reason: "missing_profile" });
return redirectToLogin(reply, "saml_auth_failed");
}
if (result.action === "denied" || !result.user) {
authAttempts.inc({ method: "saml", result: "failure" });
const errorParam =
result.deniedReason === "user_limit_reached"
? "saml_user_limit_reached"
: "saml_user_not_authorized";
return redirectToLogin(reply, errorParam);
}
// Extract claims from SAML assertion
const externalId = profile.nameID;
const email = profile[env.SAML_EMAIL_ATTRIBUTE] as string | undefined;
const usernameAttr = env.SAML_USERNAME_ATTRIBUTE
? (profile[env.SAML_USERNAME_ATTRIBUTE] as string | undefined)
: undefined;
const resolvedUser = result.user;
// Derive a username from available claims
const rawUsername = usernameAttr || email?.split("@")[0] || profile.nameID;
let username = sanitizeUsername(rawUsername);
username = await findUniqueUsername(username);
// Create session (same pattern as OIDC)
const token = createSessionToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
// Resolve user via shared external-auth resolver
const result = await resolveExternalUser({
provider: "saml",
externalId,
email,
emailVerified: true, // SAML assertions from a trusted IdP are considered verified
username,
autoCreate: env.SAML_AUTO_CREATE_USERS,
autoLink: env.SAML_AUTO_LINK_USERS,
defaultRole: env.SAML_DEFAULT_ROLE,
logger: request.log,
ip: request.ip,
requestId: request.id,
});
await db.insert(schema.sessions).values({
id: token,
userId: resolvedUser.id,
expiresAt,
});
if (result.action === "denied" || !result.user) {
authAttempts.inc({ method: "saml", result: "failure" });
const errorParam =
result.deniedReason === "user_limit_reached"
? "saml_user_limit_reached"
: "saml_user_not_authorized";
return redirectToLogin(reply, errorParam);
}
authAttempts.inc({ method: "saml", result: "success" });
await audit("SAML_LOGIN_SUCCESS", {
userId: resolvedUser.id,
username: resolvedUser.username,
});
const resolvedUser = result.user;
// Set session cookie and redirect to app
reply.setCookie("snapotter-session", token, {
httpOnly: true,
sameSite: "strict",
secure: isSecure(),
path: "/",
maxAge: env.SESSION_DURATION_HOURS * 3600,
});
// Create session (same pattern as OIDC)
const token = createSessionToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
await db.insert(schema.sessions).values({
id: token,
userId: resolvedUser.id,
expiresAt,
});
authAttempts.inc({ method: "saml", result: "success" });
await audit("SAML_LOGIN_SUCCESS", {
userId: resolvedUser.id,
username: resolvedUser.username,
});
// Set session cookie and redirect to app
reply.setCookie("snapotter-session", token, {
httpOnly: true,
sameSite: "strict",
secure: isSecure(),
path: "/",
maxAge: env.SESSION_DURATION_HOURS * 3600,
});
return reply.redirect("/");
});
return reply.redirect("/");
},
);
}
+35 -31
View File
@@ -38,45 +38,49 @@ export async function analyticsRoutes(app: FastifyInstance): Promise<void> {
};
});
app.put("/api/v1/user/analytics", async (request, reply) => {
const user = requireAuth(request, reply);
if (!user) return;
app.put(
"/api/v1/user/analytics",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (request, reply) => {
const user = requireAuth(request, reply);
if (!user) return;
const parsed = analyticsConsentSchema.safeParse(request.body ?? {});
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.issues.map((i) => i.message).join("; "),
code: "VALIDATION_ERROR",
});
}
const body = parsed.data;
const parsed = analyticsConsentSchema.safeParse(request.body ?? {});
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.issues.map((i) => i.message).join("; "),
code: "VALIDATION_ERROR",
});
}
const body = parsed.data;
const now = new Date();
const now = new Date();
if (body.remindLater) {
const remindAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
if (body.remindLater) {
const remindAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await db
.update(schema.users)
.set({
analyticsEnabled: null,
analyticsConsentShownAt: now,
analyticsConsentRemindAt: remindAt,
updatedAt: now,
})
.where(eq(schema.users.id, user.id));
return reply.send({ ok: true, analyticsEnabled: null });
}
const enabled = body.enabled === true;
await db
.update(schema.users)
.set({
analyticsEnabled: null,
analyticsEnabled: enabled,
analyticsConsentShownAt: now,
analyticsConsentRemindAt: remindAt,
analyticsConsentRemindAt: null,
updatedAt: now,
})
.where(eq(schema.users.id, user.id));
return reply.send({ ok: true, analyticsEnabled: null });
}
const enabled = body.enabled === true;
await db
.update(schema.users)
.set({
analyticsEnabled: enabled,
analyticsConsentShownAt: now,
analyticsConsentRemindAt: null,
updatedAt: now,
})
.where(eq(schema.users.id, user.id));
return reply.send({ ok: true, analyticsEnabled: enabled });
});
return reply.send({ ok: true, analyticsEnabled: enabled });
},
);
}
+103 -94
View File
@@ -22,122 +22,131 @@ const createApiKeySchema = z.object({
export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
// POST /api/v1/api-keys — Generate a new API key
app.post("/api/v1/api-keys", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
app.post(
"/api/v1/api-keys",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const parsed = createApiKeySchema.safeParse(request.body ?? {});
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.issues.map((i) => i.message).join("; "),
code: "VALIDATION_ERROR",
});
}
const body = parsed.data;
const name = body.name?.trim() || "Default API Key";
let scopedPermissions: string[] | null = null;
if (body.permissions && body.permissions.length > 0) {
const userPerms = await getPermissions(user.role);
const permSet = new Set<string>(userPerms);
const invalid = body.permissions.filter((p) => !permSet.has(p));
if (invalid.length > 0) {
const parsed = createApiKeySchema.safeParse(request.body ?? {});
if (!parsed.success) {
return reply.status(400).send({
error: `Cannot scope key with permissions you don't have: ${invalid.join(", ")}`,
error: parsed.error.issues.map((i) => i.message).join("; "),
code: "VALIDATION_ERROR",
});
}
scopedPermissions = body.permissions;
}
const body = parsed.data;
const name = body.name?.trim() || "Default API Key";
let expiresAt: Date | null = null;
if (body.expiresAt) {
const parsedDate = new Date(body.expiresAt);
if (Number.isNaN(parsedDate.getTime())) {
return reply
.status(400)
.send({ error: "Invalid expiresAt date", code: "VALIDATION_ERROR" });
let scopedPermissions: string[] | null = null;
if (body.permissions && body.permissions.length > 0) {
const userPerms = await getPermissions(user.role);
const permSet = new Set<string>(userPerms);
const invalid = body.permissions.filter((p) => !permSet.has(p));
if (invalid.length > 0) {
return reply.status(400).send({
error: `Cannot scope key with permissions you don't have: ${invalid.join(", ")}`,
code: "VALIDATION_ERROR",
});
}
scopedPermissions = body.permissions;
}
if (parsedDate <= new Date()) {
return reply
.status(400)
.send({ error: "expiresAt must be in the future", code: "VALIDATION_ERROR" });
let expiresAt: Date | null = null;
if (body.expiresAt) {
const parsedDate = new Date(body.expiresAt);
if (Number.isNaN(parsedDate.getTime())) {
return reply
.status(400)
.send({ error: "Invalid expiresAt date", code: "VALIDATION_ERROR" });
}
if (parsedDate <= new Date()) {
return reply
.status(400)
.send({ error: "expiresAt must be in the future", code: "VALIDATION_ERROR" });
}
expiresAt = parsedDate;
}
expiresAt = parsedDate;
}
// Generate a raw API key: "si_" prefix + 48 random bytes as hex
const rawKey = `si_${randomBytes(48).toString("hex")}`;
const keyHash = await hashPassword(rawKey);
const keyPrefix = computeKeyPrefix(rawKey);
const id = randomUUID();
// Generate a raw API key: "si_" prefix + 48 random bytes as hex
const rawKey = `si_${randomBytes(48).toString("hex")}`;
const keyHash = await hashPassword(rawKey);
const keyPrefix = computeKeyPrefix(rawKey);
const id = randomUUID();
try {
await db.insert(schema.apiKeys).values({
id,
try {
await db.insert(schema.apiKeys).values({
id,
userId: user.id,
keyHash,
keyPrefix,
name,
permissions: scopedPermissions,
expiresAt,
});
} catch {
return reply.status(409).send({ error: "Failed to create API key" });
}
await auditFromRequest(request)("API_KEY_CREATED", {
userId: user.id,
keyHash,
keyPrefix,
keyId: id,
keyName: name,
});
// Return the raw key ONCE — it cannot be retrieved again
return reply.status(201).send({
id,
key: rawKey,
name,
permissions: scopedPermissions,
expiresAt,
expiresAt: expiresAt?.toISOString() ?? null,
createdAt: new Date().toISOString(),
});
} catch {
return reply.status(409).send({ error: "Failed to create API key" });
}
await auditFromRequest(request)("API_KEY_CREATED", {
userId: user.id,
keyId: id,
keyName: name,
});
// Return the raw key ONCE — it cannot be retrieved again
return reply.status(201).send({
id,
key: rawKey,
name,
permissions: scopedPermissions,
expiresAt: expiresAt?.toISOString() ?? null,
createdAt: new Date().toISOString(),
});
});
},
);
// GET /api/v1/api-keys — List user's API keys (never returns the key itself)
app.get("/api/v1/api-keys", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
app.get(
"/api/v1/api-keys",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const selectFields = {
id: schema.apiKeys.id,
name: schema.apiKeys.name,
permissions: schema.apiKeys.permissions,
createdAt: schema.apiKeys.createdAt,
lastUsedAt: schema.apiKeys.lastUsedAt,
expiresAt: schema.apiKeys.expiresAt,
};
const keys = (await hasEffectivePermission(user, "apikeys:all"))
? await db.select(selectFields).from(schema.apiKeys)
: await db
.select(selectFields)
.from(schema.apiKeys)
.where(eq(schema.apiKeys.userId, user.id));
const selectFields = {
id: schema.apiKeys.id,
name: schema.apiKeys.name,
permissions: schema.apiKeys.permissions,
createdAt: schema.apiKeys.createdAt,
lastUsedAt: schema.apiKeys.lastUsedAt,
expiresAt: schema.apiKeys.expiresAt,
};
const keys = (await hasEffectivePermission(user, "apikeys:all"))
? await db.select(selectFields).from(schema.apiKeys)
: await db
.select(selectFields)
.from(schema.apiKeys)
.where(eq(schema.apiKeys.userId, user.id));
return reply.send({
apiKeys: keys.map((k) => ({
id: k.id,
name: k.name,
permissions: k.permissions ?? null,
createdAt: k.createdAt.toISOString(),
lastUsedAt: k.lastUsedAt?.toISOString() ?? null,
expiresAt: k.expiresAt?.toISOString() ?? null,
})),
});
});
return reply.send({
apiKeys: keys.map((k) => ({
id: k.id,
name: k.name,
permissions: k.permissions ?? null,
createdAt: k.createdAt.toISOString(),
lastUsedAt: k.lastUsedAt?.toISOString() ?? null,
expiresAt: k.expiresAt?.toISOString() ?? null,
})),
});
},
);
// DELETE /api/v1/api-keys/:id — Delete an API key
app.delete(
"/api/v1/api-keys/:id",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
+145 -123
View File
@@ -305,84 +305,91 @@ export async function registerScimRoutes(app: FastifyInstance): Promise<void> {
// ── User Operations ────────────────────────────────────────────
// POST /api/v1/scim/v2/Users -- create user
app.post("/api/v1/scim/v2/Users", async (request: FastifyRequest, reply: FastifyReply) => {
if (!(await scimAuth(request, reply))) return;
if (!(await requireScimFeature(reply))) return;
app.post(
"/api/v1/scim/v2/Users",
{
config: { rateLimit: { max: 120, timeWindow: "1 minute" } },
},
async (request: FastifyRequest, reply: FastifyReply) => {
if (!(await scimAuth(request, reply))) return;
if (!(await requireScimFeature(reply))) return;
const body = request.body as Record<string, unknown>;
const userName = body.userName as string | undefined;
const externalId = body.externalId as string | undefined;
const active = body.active !== false; // default true
const emails = body.emails as Array<{ value: string; primary?: boolean }> | undefined;
if (!userName) {
return reply.status(400).send(scimError(400, "userName is required"));
}
const body = request.body as Record<string, unknown>;
const userName = body.userName as string | undefined;
const externalId = body.externalId as string | undefined;
const active = body.active !== false; // default true
const emails = body.emails as Array<{ value: string; primary?: boolean }> | undefined;
if (!userName) {
return reply.status(400).send(scimError(400, "userName is required"));
}
// Check for duplicate username
const [existing] = await db
.select()
.from(schema.users)
.where(eq(schema.users.username, userName));
// Check for duplicate username
const [existing] = await db
.select()
.from(schema.users)
.where(eq(schema.users.username, userName));
if (existing) {
return reply.status(409).send(scimError(409, "User already exists"));
}
if (existing) {
return reply.status(409).send(scimError(409, "User already exists"));
}
const id = randomUUID();
const email = emails?.find((e) => e.primary)?.value ?? emails?.[0]?.value ?? null;
const id = randomUUID();
const email = emails?.find((e) => e.primary)?.value ?? emails?.[0]?.value ?? null;
// Resolve default team
const [defaultTeam] = await db
.select()
.from(schema.teams)
.where(eq(schema.teams.name, "Default"));
const teamId = defaultTeam?.id ?? "default-team-00000000";
// Resolve default team
const [defaultTeam] = await db
.select()
.from(schema.teams)
.where(eq(schema.teams.name, "Default"));
const teamId = defaultTeam?.id ?? "default-team-00000000";
const now = new Date();
await db.insert(schema.users).values({
id,
username: userName,
email,
externalId: externalId ?? null,
role: active ? "user" : "disabled",
team: teamId,
authProvider: "scim",
mustChangePassword: false,
createdAt: now,
updatedAt: now,
});
await auditLog(
request.log,
"SCIM_USER_PROVISIONED",
{
userId: id,
const now = new Date();
await db.insert(schema.users).values({
id,
username: userName,
externalId,
},
request.ip,
request.id,
);
email,
externalId: externalId ?? null,
role: active ? "user" : "disabled",
team: teamId,
authProvider: "scim",
mustChangePassword: false,
createdAt: now,
updatedAt: now,
});
const user = {
id,
username: userName,
email,
externalId: externalId ?? null,
role: active ? "user" : "disabled",
team: teamId,
legalHold: false,
passwordHash: null,
createdAt: now,
updatedAt: now,
};
await auditLog(
request.log,
"SCIM_USER_PROVISIONED",
{
userId: id,
username: userName,
externalId,
},
request.ip,
request.id,
);
return reply.status(201).send(toScimUser(user, defaultTeam?.name));
});
const user = {
id,
username: userName,
email,
externalId: externalId ?? null,
role: active ? "user" : "disabled",
team: teamId,
legalHold: false,
passwordHash: null,
createdAt: now,
updatedAt: now,
};
return reply.status(201).send(toScimUser(user, defaultTeam?.name));
},
);
// GET /api/v1/scim/v2/Users/:id -- get user
app.get(
"/api/v1/scim/v2/Users/:id",
{ config: { rateLimit: { max: 120, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
if (!(await scimAuth(request, reply))) return;
if (!(await requireScimFeature(reply))) return;
@@ -406,6 +413,7 @@ export async function registerScimRoutes(app: FastifyInstance): Promise<void> {
// GET /api/v1/scim/v2/Users -- list users with filter
app.get(
"/api/v1/scim/v2/Users",
{ config: { rateLimit: { max: 120, timeWindow: "1 minute" } } },
async (
request: FastifyRequest<{
Querystring: { filter?: string; startIndex?: string; count?: string };
@@ -477,6 +485,7 @@ export async function registerScimRoutes(app: FastifyInstance): Promise<void> {
// PUT /api/v1/scim/v2/Users/:id -- replace user
app.put(
"/api/v1/scim/v2/Users/:id",
{ config: { rateLimit: { max: 120, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
if (!(await scimAuth(request, reply))) return;
if (!(await requireScimFeature(reply))) return;
@@ -555,6 +564,7 @@ export async function registerScimRoutes(app: FastifyInstance): Promise<void> {
// PATCH /api/v1/scim/v2/Users/:id -- partial update
app.patch(
"/api/v1/scim/v2/Users/:id",
{ config: { rateLimit: { max: 120, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
if (!(await scimAuth(request, reply))) return;
if (!(await requireScimFeature(reply))) return;
@@ -661,6 +671,7 @@ export async function registerScimRoutes(app: FastifyInstance): Promise<void> {
// DELETE /api/v1/scim/v2/Users/:id -- deactivate user (soft delete)
app.delete(
"/api/v1/scim/v2/Users/:id",
{ config: { rateLimit: { max: 120, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
if (!(await scimAuth(request, reply))) return;
if (!(await requireScimFeature(reply))) return;
@@ -706,74 +717,81 @@ export async function registerScimRoutes(app: FastifyInstance): Promise<void> {
// ── Group Operations ───────────────────────────────────────────
// POST /api/v1/scim/v2/Groups -- create team
app.post("/api/v1/scim/v2/Groups", async (request: FastifyRequest, reply: FastifyReply) => {
if (!(await scimAuth(request, reply))) return;
if (!(await requireScimFeature(reply))) return;
app.post(
"/api/v1/scim/v2/Groups",
{
config: { rateLimit: { max: 120, timeWindow: "1 minute" } },
},
async (request: FastifyRequest, reply: FastifyReply) => {
if (!(await scimAuth(request, reply))) return;
if (!(await requireScimFeature(reply))) return;
const body = request.body as Record<string, unknown>;
const displayName = body.displayName as string | undefined;
const members = body.members as Array<{ value: string }> | undefined;
const body = request.body as Record<string, unknown>;
const displayName = body.displayName as string | undefined;
const members = body.members as Array<{ value: string }> | undefined;
if (!displayName) {
return reply.status(400).send(scimError(400, "displayName is required"));
}
// Check for duplicate team name
const [existing] = await db
.select()
.from(schema.teams)
.where(eq(schema.teams.name, displayName));
if (existing) {
return reply.status(409).send(scimError(409, "Group already exists"));
}
const id = randomUUID();
const now = new Date();
await db.insert(schema.teams).values({
id,
name: displayName,
createdAt: now,
});
// Assign members to the team
if (members && members.length > 0) {
for (const member of members) {
await db
.update(schema.users)
.set({ team: id, updatedAt: new Date() })
.where(eq(schema.users.id, member.value));
if (!displayName) {
return reply.status(400).send(scimError(400, "displayName is required"));
}
}
// Fetch actual members
const teamMembers = await db
.select({ id: schema.users.id, username: schema.users.username })
.from(schema.users)
.where(eq(schema.users.team, id));
// Check for duplicate team name
const [existing] = await db
.select()
.from(schema.teams)
.where(eq(schema.teams.name, displayName));
await auditLog(
request.log,
"SCIM_GROUP_SYNCED",
{
teamId: id,
teamName: displayName,
action: "created",
memberCount: teamMembers.length,
},
request.ip,
request.id,
);
if (existing) {
return reply.status(409).send(scimError(409, "Group already exists"));
}
return reply
.status(201)
.send(toScimGroup({ id, name: displayName, createdAt: now }, teamMembers));
});
const id = randomUUID();
const now = new Date();
await db.insert(schema.teams).values({
id,
name: displayName,
createdAt: now,
});
// Assign members to the team
if (members && members.length > 0) {
for (const member of members) {
await db
.update(schema.users)
.set({ team: id, updatedAt: new Date() })
.where(eq(schema.users.id, member.value));
}
}
// Fetch actual members
const teamMembers = await db
.select({ id: schema.users.id, username: schema.users.username })
.from(schema.users)
.where(eq(schema.users.team, id));
await auditLog(
request.log,
"SCIM_GROUP_SYNCED",
{
teamId: id,
teamName: displayName,
action: "created",
memberCount: teamMembers.length,
},
request.ip,
request.id,
);
return reply
.status(201)
.send(toScimGroup({ id, name: displayName, createdAt: now }, teamMembers));
},
);
// GET /api/v1/scim/v2/Groups/:id -- get team
app.get(
"/api/v1/scim/v2/Groups/:id",
{ config: { rateLimit: { max: 120, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
if (!(await scimAuth(request, reply))) return;
if (!(await requireScimFeature(reply))) return;
@@ -797,6 +815,7 @@ export async function registerScimRoutes(app: FastifyInstance): Promise<void> {
// GET /api/v1/scim/v2/Groups -- list teams
app.get(
"/api/v1/scim/v2/Groups",
{ config: { rateLimit: { max: 120, timeWindow: "1 minute" } } },
async (
request: FastifyRequest<{
Querystring: { filter?: string; startIndex?: string; count?: string };
@@ -862,6 +881,7 @@ export async function registerScimRoutes(app: FastifyInstance): Promise<void> {
// PUT /api/v1/scim/v2/Groups/:id -- replace team
app.put(
"/api/v1/scim/v2/Groups/:id",
{ config: { rateLimit: { max: 120, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
if (!(await scimAuth(request, reply))) return;
if (!(await requireScimFeature(reply))) return;
@@ -939,6 +959,7 @@ export async function registerScimRoutes(app: FastifyInstance): Promise<void> {
// PATCH /api/v1/scim/v2/Groups/:id -- update members
app.patch(
"/api/v1/scim/v2/Groups/:id",
{ config: { rateLimit: { max: 120, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
if (!(await scimAuth(request, reply))) return;
if (!(await requireScimFeature(reply))) return;
@@ -1048,6 +1069,7 @@ export async function registerScimRoutes(app: FastifyInstance): Promise<void> {
// DELETE /api/v1/scim/v2/Groups/:id -- delete team
app.delete(
"/api/v1/scim/v2/Groups/:id",
{ config: { rateLimit: { max: 120, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
if (!(await scimAuth(request, reply))) return;
if (!(await requireScimFeature(reply))) return;
+64 -58
View File
@@ -102,70 +102,76 @@ export async function registerUpgradeRoutes(app: FastifyInstance): Promise<void>
);
// GET /api/v1/admin/upgrade-check
app.get("/api/v1/admin/upgrade-check", async (request: FastifyRequest, reply: FastifyReply) => {
const user = await requirePermission("system:health")(request, reply);
if (!user) return;
if (!(await requireUpgradeFeature(reply))) return;
app.get(
"/api/v1/admin/upgrade-check",
{
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
},
async (request: FastifyRequest, reply: FastifyReply) => {
const user = await requirePermission("system:health")(request, reply);
if (!user) return;
if (!(await requireUpgradeFeature(reply))) return;
// Check database connectivity
let dbOk = false;
try {
await db.select().from(schema.settings).limit(1);
dbOk = true;
} catch {
/* db unreachable */
}
// Check Redis connectivity
let redisOk = false;
try {
redisOk = await pingRedis();
} catch {
/* redis unreachable */
}
// Check in-flight jobs
let activeCount = 0;
let noInFlightJobs = true;
try {
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(schema.jobs)
.where(sql`${schema.jobs.status} IN ('queued', 'processing')`);
activeCount = row?.count ?? 0;
noInFlightJobs = activeCount === 0;
} catch {
// If we can't query, assume there may be jobs
noInFlightJobs = false;
}
// Check disk space (> 1 GB free)
const MIN_FREE_BYTES = 1024 * 1024 * 1024; // 1 GB
let diskOk = true;
let freeGb = 0;
if (env.STORAGE_MODE !== "s3") {
// Check database connectivity
let dbOk = false;
try {
const stats = await statfs(env.WORKSPACE_PATH);
const freeBytes = stats.bfree * stats.bsize;
freeGb = Math.round((freeBytes / (1024 * 1024 * 1024)) * 100) / 100;
diskOk = freeBytes > MIN_FREE_BYTES;
await db.select().from(schema.settings).limit(1);
dbOk = true;
} catch {
// Path doesn't exist -- skip check
/* db unreachable */
}
}
const ready = diskOk && noInFlightJobs && dbOk && redisOk;
// Check Redis connectivity
let redisOk = false;
try {
redisOk = await pingRedis();
} catch {
/* redis unreachable */
}
return reply.send({
ready,
checks: {
diskSpace: { ok: diskOk, freeGb },
inFlightJobs: { ok: noInFlightJobs, activeCount },
databaseConnected: { ok: dbOk },
redisConnected: { ok: redisOk },
},
});
});
// Check in-flight jobs
let activeCount = 0;
let noInFlightJobs = true;
try {
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(schema.jobs)
.where(sql`${schema.jobs.status} IN ('queued', 'processing')`);
activeCount = row?.count ?? 0;
noInFlightJobs = activeCount === 0;
} catch {
// If we can't query, assume there may be jobs
noInFlightJobs = false;
}
// Check disk space (> 1 GB free)
const MIN_FREE_BYTES = 1024 * 1024 * 1024; // 1 GB
let diskOk = true;
let freeGb = 0;
if (env.STORAGE_MODE !== "s3") {
try {
const stats = await statfs(env.WORKSPACE_PATH);
const freeBytes = stats.bfree * stats.bsize;
freeGb = Math.round((freeBytes / (1024 * 1024 * 1024)) * 100) / 100;
diskOk = freeBytes > MIN_FREE_BYTES;
} catch {
// Path doesn't exist -- skip check
}
}
const ready = diskOk && noInFlightJobs && dbOk && redisOk;
return reply.send({
ready,
checks: {
diskSpace: { ok: diskOk, freeGb },
inFlightJobs: { ok: noInFlightJobs, activeCount },
databaseConnected: { ok: dbOk },
redisConnected: { ok: redisOk },
},
});
},
);
app.log.info("Enterprise upgrade management routes registered");
}
+26 -20
View File
@@ -106,32 +106,37 @@ function getDirSize(dirPath: string): number {
export async function registerFeatureRoutes(app: FastifyInstance): Promise<void> {
// GET /api/v1/features - List feature bundles and their statuses
app.get("/api/v1/features", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
app.get(
"/api/v1/features",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
// In non-Docker environments, all bundles are available natively
if (!isDockerEnvironment()) {
const bundles = Object.values(FEATURE_BUNDLES).map((bundle) => ({
id: bundle.id,
name: bundle.name,
description: bundle.description,
status: "installed" as const,
installedVersion: null,
estimatedSize: bundle.estimatedSize,
enablesTools: bundle.enablesTools,
progress: null,
error: null,
}));
return reply.send({ bundles });
}
// In non-Docker environments, all bundles are available natively
if (!isDockerEnvironment()) {
const bundles = Object.values(FEATURE_BUNDLES).map((bundle) => ({
id: bundle.id,
name: bundle.name,
description: bundle.description,
status: "installed" as const,
installedVersion: null,
estimatedSize: bundle.estimatedSize,
enablesTools: bundle.enablesTools,
progress: null,
error: null,
}));
return reply.send({ bundles });
}
return reply.send({ bundles: getFeatureStates() });
});
return reply.send({ bundles: getFeatureStates() });
},
);
// POST /api/v1/admin/features/:bundleId/install - Install a feature bundle
app.post(
"/api/v1/admin/features/:bundleId/install",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: BundleIdParams }>, reply: FastifyReply) => {
const admin = await requirePermission("features:manage")(request, reply);
if (!admin) return;
@@ -283,6 +288,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
// POST /api/v1/admin/features/:bundleId/uninstall - Uninstall a feature bundle
app.post(
"/api/v1/admin/features/:bundleId/uninstall",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: BundleIdParams }>, reply: FastifyReply) => {
const admin = await requirePermission("features:manage")(request, reply);
if (!admin) return;
+137 -130
View File
@@ -61,6 +61,7 @@ const OFFICE_MIMES = new Set([
export async function filePreviewRoutes(app: FastifyInstance): Promise<void> {
app.get(
"/api/v1/files/:id/preview",
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
@@ -219,141 +220,147 @@ export async function filePreviewRoutes(app: FastifyInstance): Promise<void> {
);
// ── On-demand preview for uploaded (non-stored) media files ─────
app.post("/api/v1/preview/generate", async (request: FastifyRequest, reply: FastifyReply) => {
// Optional auth -- the preview is for the user's own uploaded file
getAuthUser(request);
app.post(
"/api/v1/preview/generate",
{
config: { rateLimit: { max: 60, timeWindow: "1 minute" } },
},
async (request: FastifyRequest, reply: FastifyReply) => {
// Optional auth -- the preview is for the user's own uploaded file
getAuthUser(request);
const parts = request.parts();
let fileBuffer: Buffer | null = null;
let filename = "input";
const parts = request.parts();
let fileBuffer: Buffer | null = null;
let filename = "input";
for await (const part of parts) {
if (part.type !== "file") continue;
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = part.filename ?? "input";
break; // only process the first file
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No file provided" });
}
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const videoExts = new Set([
"avi",
"mkv",
"wmv",
"flv",
"mov",
"mpg",
"mpeg",
"m4v",
"3gp",
"3g2",
"ts",
"mts",
"m2ts",
"vob",
"divx",
"asf",
"rm",
"rmvb",
"f4v",
"ogv",
"mp4",
"webm",
"ogg",
]);
const audioExts = new Set([
"wav",
"flac",
"aac",
"wma",
"ogg",
"oga",
"opus",
"m4a",
"aiff",
"aif",
"amr",
"ape",
"ac3",
"dts",
"mp3",
]);
const isVideo = videoExts.has(ext);
const isAudio = audioExts.has(ext);
if (!isVideo && !isAudio) {
return reply.status(400).send({ error: "Unsupported file type for preview" });
}
const id = randomUUID();
const inputPath = join(tmpdir(), `snapotter-preview-${id}.${ext}`);
const outputExt = isVideo ? "mp4" : "mp3";
const outputPath = join(tmpdir(), `snapotter-preview-${id}-out.${outputExt}`);
try {
await writeFile(inputPath, fileBuffer);
if (isVideo) {
await runFfmpeg([
"-i",
inputPath,
"-t",
"30",
"-vf",
"scale='min(720,iw)':-2",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-crf",
"28",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
"-y",
outputPath,
]);
} else {
await runFfmpeg([
"-i",
inputPath,
"-t",
"60",
"-c:a",
"libmp3lame",
"-b:a",
"128k",
"-y",
outputPath,
]);
for await (const part of parts) {
if (part.type !== "file") continue;
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = part.filename ?? "input";
break; // only process the first file
}
const outputBuffer = await readFile(outputPath);
const contentType = isVideo ? "video/mp4" : "audio/mpeg";
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No file provided" });
}
return reply
.header("Content-Type", contentType)
.header("Content-Length", outputBuffer.length)
.send(outputBuffer);
} catch (err) {
request.log.error({ err, filename }, "On-demand preview generation failed");
return reply.status(422).send({ error: "Could not generate preview" });
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
});
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const videoExts = new Set([
"avi",
"mkv",
"wmv",
"flv",
"mov",
"mpg",
"mpeg",
"m4v",
"3gp",
"3g2",
"ts",
"mts",
"m2ts",
"vob",
"divx",
"asf",
"rm",
"rmvb",
"f4v",
"ogv",
"mp4",
"webm",
"ogg",
]);
const audioExts = new Set([
"wav",
"flac",
"aac",
"wma",
"ogg",
"oga",
"opus",
"m4a",
"aiff",
"aif",
"amr",
"ape",
"ac3",
"dts",
"mp3",
]);
const isVideo = videoExts.has(ext);
const isAudio = audioExts.has(ext);
if (!isVideo && !isAudio) {
return reply.status(400).send({ error: "Unsupported file type for preview" });
}
const id = randomUUID();
const inputPath = join(tmpdir(), `snapotter-preview-${id}.${ext}`);
const outputExt = isVideo ? "mp4" : "mp3";
const outputPath = join(tmpdir(), `snapotter-preview-${id}-out.${outputExt}`);
try {
await writeFile(inputPath, fileBuffer);
if (isVideo) {
await runFfmpeg([
"-i",
inputPath,
"-t",
"30",
"-vf",
"scale='min(720,iw)':-2",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-crf",
"28",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
"-y",
outputPath,
]);
} else {
await runFfmpeg([
"-i",
inputPath,
"-t",
"60",
"-c:a",
"libmp3lame",
"-b:a",
"128k",
"-y",
outputPath,
]);
}
const outputBuffer = await readFile(outputPath);
const contentType = isVideo ? "video/mp4" : "audio/mpeg";
return reply
.header("Content-Type", contentType)
.header("Content-Length", outputBuffer.length)
.send(outputBuffer);
} catch (err) {
request.log.error({ err, filename }, "On-demand preview generation failed");
return reply.status(422).send({ error: "Could not generate preview" });
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
},
);
app.log.info("File preview routes registered");
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -263,6 +263,7 @@ export async function registerProgressRoutes(app: FastifyInstance): Promise<void
app.get(
"/api/v1/jobs/:jobId/progress",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { jobId: string } }>, reply: FastifyReply) => {
const { jobId } = request.params;
+20 -15
View File
@@ -51,25 +51,29 @@ async function decryptIfNeeded(value: string): Promise<string> {
export async function settingsRoutes(app: FastifyInstance): Promise<void> {
// GET /api/v1/settings — Get all settings as a key-value object
app.get("/api/v1/settings", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
app.get(
"/api/v1/settings",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const isAdmin = user.role === "admin";
const rows = await db.select().from(schema.settings);
const isAdmin = user.role === "admin";
const rows = await db.select().from(schema.settings);
const settings: Record<string, string> = {};
for (const row of rows) {
if (!isAdmin && SENSITIVE_KEYS.has(row.key)) continue;
if (REDACTED_KEYS.has(row.key)) {
settings[row.key] = "********";
continue;
const settings: Record<string, string> = {};
for (const row of rows) {
if (!isAdmin && SENSITIVE_KEYS.has(row.key)) continue;
if (REDACTED_KEYS.has(row.key)) {
settings[row.key] = "********";
continue;
}
settings[row.key] = await decryptIfNeeded(row.value);
}
settings[row.key] = await decryptIfNeeded(row.value);
}
return reply.send({ settings });
});
return reply.send({ settings });
},
);
// PUT /api/v1/settings — Save settings (admin only)
app.put("/api/v1/settings", async (request: FastifyRequest, reply: FastifyReply) => {
@@ -146,6 +150,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
// GET /api/v1/settings/:key — Get a specific setting
app.get(
"/api/v1/settings/:key",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { key: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
+80 -72
View File
@@ -173,6 +173,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
*/
app.get(
"/api/v1/files",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (
request: FastifyRequest<{
Querystring: { search?: string; limit?: string; offset?: string };
@@ -344,6 +345,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
*/
app.get(
"/api/v1/files/:id",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
@@ -430,6 +432,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
*/
app.get(
"/api/v1/files/:id/download",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
@@ -469,6 +472,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
*/
app.get(
"/api/v1/files/:id/thumbnail",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
@@ -567,52 +571,55 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
* Bulk delete. Body: { ids: string[] }
* For each id, deletes the entire version chain (all ancestors and descendants).
*/
app.delete("/api/v1/files", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
app.delete(
"/api/v1/files",
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const deleteSchema = z.object({
ids: z.array(z.string()).min(1, "ids must be a non-empty array of strings"),
});
const parsed = deleteSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.issues.map((i) => i.message).join("; "),
const deleteSchema = z.object({
ids: z.array(z.string()).min(1, "ids must be a non-empty array of strings"),
});
}
const { ids } = parsed.data;
const parsed = deleteSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.issues.map((i) => i.message).join("; "),
});
}
const { ids } = parsed.data;
// Check files:all permission once upfront
const canDeleteAll = await hasEffectivePermission(user, "files:all");
// Check files:all permission once upfront
const canDeleteAll = await hasEffectivePermission(user, "files:all");
// Batch ownership check: single SELECT for all requested IDs
const candidates = await db
.select({ id: schema.userFiles.id, userId: schema.userFiles.userId })
.from(schema.userFiles)
.where(inArray(schema.userFiles.id, ids));
// Batch ownership check: single SELECT for all requested IDs
const candidates = await db
.select({ id: schema.userFiles.id, userId: schema.userFiles.userId })
.from(schema.userFiles)
.where(inArray(schema.userFiles.id, ids));
const validIds = candidates
.filter((f) => f.userId === user.id || canDeleteAll)
.map((f) => f.id);
const validIds = candidates
.filter((f) => f.userId === user.id || canDeleteAll)
.map((f) => f.id);
if (validIds.length === 0) {
await auditFromRequest(request)("FILE_DELETED", { userId: user.id, count: 0, ids });
return reply.send({ deleted: 0 });
}
if (validIds.length === 0) {
await auditFromRequest(request)("FILE_DELETED", { userId: user.id, count: 0, ids });
return reply.send({ deleted: 0 });
}
type DeleteChainRow = {
id: string;
stored_name: string;
size: number | null;
user_id: string | null;
};
type DeleteChainRow = {
id: string;
stored_name: string;
size: number | null;
user_id: string | null;
};
// Single recursive CTE to collect all chain members for every valid ID
const seedIds = sql.join(
validIds.map((id) => sql`${id}`),
sql`, `,
);
const cteResult = await db.execute<DeleteChainRow>(sql`
// Single recursive CTE to collect all chain members for every valid ID
const seedIds = sql.join(
validIds.map((id) => sql`${id}`),
sql`, `,
);
const cteResult = await db.execute<DeleteChainRow>(sql`
WITH RECURSIVE
ancestors(id, parent_id) AS (
SELECT id, parent_id FROM user_files
@@ -631,44 +638,45 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
)
SELECT DISTINCT id, stored_name, size, user_id FROM chain
`);
const chainRows = cteResult.rows;
const chainRows = cteResult.rows;
// Filesystem deletes (must loop; cannot batch across the OS)
for (const row of chainRows) {
await deleteStoredFile(row.stored_name);
await deleteThumbnail(row.stored_name);
}
// Batch DB delete
const chainIds = chainRows.map((r) => r.id);
if (chainIds.length > 0) {
await db.delete(schema.userFiles).where(inArray(schema.userFiles.id, chainIds));
}
// Decrement storageUsed per user (group by userId for files:all scenarios)
const perUserSizes = new Map<string, number>();
for (const row of chainRows) {
if (row.user_id && row.size) {
perUserSizes.set(row.user_id, (perUserSizes.get(row.user_id) ?? 0) + row.size);
// Filesystem deletes (must loop; cannot batch across the OS)
for (const row of chainRows) {
await deleteStoredFile(row.stored_name);
await deleteThumbnail(row.stored_name);
}
}
for (const [uid, totalSize] of perUserSizes) {
await db
.update(schema.users)
.set({
storageUsed: sql`GREATEST(0, ${schema.users.storageUsed} - ${totalSize})`,
})
.where(eq(schema.users.id, uid));
}
await auditFromRequest(request)("FILE_DELETED", {
userId: user.id,
count: chainRows.length,
ids,
});
// Batch DB delete
const chainIds = chainRows.map((r) => r.id);
if (chainIds.length > 0) {
await db.delete(schema.userFiles).where(inArray(schema.userFiles.id, chainIds));
}
return reply.send({ deleted: chainRows.length });
});
// Decrement storageUsed per user (group by userId for files:all scenarios)
const perUserSizes = new Map<string, number>();
for (const row of chainRows) {
if (row.user_id && row.size) {
perUserSizes.set(row.user_id, (perUserSizes.get(row.user_id) ?? 0) + row.size);
}
}
for (const [uid, totalSize] of perUserSizes) {
await db
.update(schema.users)
.set({
storageUsed: sql`GREATEST(0, ${schema.users.storageUsed} - ${totalSize})`,
})
.where(eq(schema.users.id, uid));
}
await auditFromRequest(request)("FILE_DELETED", {
userId: user.id,
count: chainRows.length,
ids,
});
return reply.send({ deleted: chainRows.length });
},
);
/**
* POST /api/v1/files/save-result