mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: API sync and documentation audit - 100% endpoint coverage (#94)
Code quality: - Add Zod validation to 14 route handlers that used raw JSON.parse (favicon, find-duplicates, barcode-read, upscale, blur-faces, erase-object, colorize, enhance-faces, red-eye-removal, remove-background/effects, auth, api-keys, roles, teams, analytics, settings, user-files) - Standardize error responses to safeParse + formatZodErrors pattern - Replace unsafe `as` type casts with schema validation OpenAPI spec (89 -> 115 operations): - Add 14 missing tool endpoints (adjust-colors, sharpening, optimize-for-web, image-enhancement, noise-removal, red-eye-removal, restore-photo, passport-photo, colorize, enhance-faces, image-to-base64) - Add 12 missing non-tool endpoints (analytics, features, audit-log, roles, admin-health) - Add typed error schemas for 401/403/409 responses - Add descriptions to all path parameters - Bump version from 0.9.0 to 1.15.9 Documentation: - Fix 8 incorrect env var defaults in configuration guide - Add 15 undocumented env vars to configuration guide - Fix tool ID mismatch (color-adjustments -> adjust-colors) - Add 4 new API sections (Roles, Audit Log, Analytics, Features) - Add image-enhancement to AI engine reference - Update AI tool count from 13 to 14 across all docs - Add 6 missing doc links to README
This commit is contained in:
@@ -2,6 +2,7 @@ import { createHash, randomBytes, randomUUID, scrypt, timingSafeEqual } from "no
|
||||
import { promisify } from "node:util";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { auditLog } from "../lib/audit.js";
|
||||
@@ -69,6 +70,34 @@ function validateUsername(username: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Zod schemas for auth request bodies ──────────────────────────
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
});
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: z.string().min(1, "New password is required"),
|
||||
});
|
||||
|
||||
const registerSchema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
role: z.string().optional(),
|
||||
team: z.string().optional(),
|
||||
});
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
role: z.string().optional(),
|
||||
team: z.string().optional(),
|
||||
});
|
||||
|
||||
const resetPasswordSchema = z.object({
|
||||
newPassword: z.string().min(1, "New password is required"),
|
||||
});
|
||||
|
||||
// ── Request helpers ───────────────────────────────────────────────
|
||||
|
||||
/** Extract the authenticated user attached by authMiddleware. */
|
||||
@@ -164,11 +193,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.status(403).send({ error: "Authentication is disabled" });
|
||||
}
|
||||
|
||||
const body = request.body as { username?: string; password?: string } | null;
|
||||
|
||||
if (!body?.username || !body?.password) {
|
||||
const parsed = loginSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Username and password are required" });
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
const user = db
|
||||
.select()
|
||||
@@ -290,17 +319,14 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
const authUser = requireAuth(request, reply);
|
||||
if (!authUser) return;
|
||||
|
||||
const body = request.body as {
|
||||
currentPassword?: string;
|
||||
newPassword?: string;
|
||||
} | null;
|
||||
|
||||
if (!body?.currentPassword || !body?.newPassword) {
|
||||
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 = validatePasswordStrength(body.newPassword);
|
||||
if (pwError) {
|
||||
@@ -386,18 +412,14 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
const admin = requirePermission("users:manage")(request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
const body = request.body as {
|
||||
username?: string;
|
||||
password?: string;
|
||||
role?: string;
|
||||
} | null;
|
||||
|
||||
if (!body?.username || !body?.password) {
|
||||
const parsed = registerSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Username and password are required",
|
||||
code: "VALIDATION_ERROR",
|
||||
});
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
const usernameError = validateUsername(body.username);
|
||||
if (usernameError) {
|
||||
@@ -443,8 +465,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve team — frontend sends team name (e.g. "Default"), not ID
|
||||
const requestedTeam = (body as { team?: string }).team;
|
||||
// Resolve team -- frontend sends team name (e.g. "Default"), not ID
|
||||
const requestedTeam = body.team;
|
||||
let teamId: string;
|
||||
let teamName: string;
|
||||
|
||||
@@ -487,13 +509,15 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Check user limit
|
||||
const userCount = db.select().from(schema.users).all().length;
|
||||
if (userCount >= MAX_USERS) {
|
||||
return reply.status(403).send({
|
||||
error: `User limit reached (${MAX_USERS} max)`,
|
||||
code: "USER_LIMIT_REACHED",
|
||||
});
|
||||
// Check user limit (0 = unlimited)
|
||||
if (MAX_USERS > 0) {
|
||||
const userCount = db.select().from(schema.users).all().length;
|
||||
if (userCount >= MAX_USERS) {
|
||||
return reply.status(403).send({
|
||||
error: `User limit reached (${MAX_USERS} max)`,
|
||||
code: "USER_LIMIT_REACHED",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
@@ -533,7 +557,14 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
if (!admin) return;
|
||||
|
||||
const { id } = request.params;
|
||||
const body = request.body as { role?: string; team?: string } | null;
|
||||
const parsed = updateUserSchema.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 user = db.select().from(schema.users).where(eq(schema.users.id, id)).get();
|
||||
|
||||
@@ -546,7 +577,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
};
|
||||
|
||||
// Escalation prevention
|
||||
if (body?.role) {
|
||||
if (body.role) {
|
||||
const roleHierarchy: Record<string, number> = { admin: 3, editor: 2, user: 1 };
|
||||
const actorLevel = roleHierarchy[admin.role] ?? 0;
|
||||
const targetLevel = roleHierarchy[body.role] ?? 0;
|
||||
@@ -558,7 +589,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (body?.role) {
|
||||
if (body.role) {
|
||||
const validBuiltinRoles = ["admin", "editor", "user"];
|
||||
const isValid =
|
||||
validBuiltinRoles.includes(body.role) ||
|
||||
@@ -591,7 +622,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof body?.team === "string" && body.team.trim()) {
|
||||
if (body.team?.trim()) {
|
||||
// Look up by name first, then fall back to ID
|
||||
const teamByName = db
|
||||
.select()
|
||||
@@ -628,14 +659,14 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
if (!admin) return;
|
||||
|
||||
const { id } = request.params;
|
||||
const body = request.body as { newPassword?: string } | null;
|
||||
|
||||
if (!body?.newPassword) {
|
||||
const parsed = resetPasswordSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: "New password is required",
|
||||
code: "VALIDATION_ERROR",
|
||||
});
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
const pwError = validatePasswordStrength(body.newPassword);
|
||||
if (pwError) {
|
||||
|
||||
Reference in New Issue
Block a user