mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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("/");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
+835
-812
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user