feat: add team-level storage quotas with enforcement on upload and save

This commit is contained in:
SnapOtter
2026-06-13 20:49:28 +08:00
parent aaa8a37c9b
commit d064286559
2 changed files with 75 additions and 20 deletions
+26 -8
View File
@@ -14,7 +14,7 @@ import { z } from "zod";
import { db, schema } from "../db/index.js"; import { db, schema } from "../db/index.js";
import { requirePermission } from "../permissions.js"; import { requirePermission } from "../permissions.js";
const teamNameSchema = z.object({ const teamBodySchema = z.object({
name: z name: z
.string({ required_error: "Team name is required" }) .string({ required_error: "Team name is required" })
.transform((v) => v.trim()) .transform((v) => v.trim())
@@ -24,6 +24,8 @@ const teamNameSchema = z.object({
.min(1, "Team name is required") .min(1, "Team name is required")
.max(50, "Team name must be 50 characters or fewer"), .max(50, "Team name must be 50 characters or fewer"),
), ),
storageQuota: z.number().int().positive().nullable().optional(),
retentionHours: z.number().int().positive().nullable().optional(),
}); });
export async function teamsRoutes(app: FastifyInstance): Promise<void> { export async function teamsRoutes(app: FastifyInstance): Promise<void> {
@@ -36,6 +38,8 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
.select({ .select({
id: schema.teams.id, id: schema.teams.id,
name: schema.teams.name, name: schema.teams.name,
storageQuota: schema.teams.storageQuota,
retentionHours: schema.teams.retentionHours,
memberCount: sql<number>`(SELECT COUNT(*)::int FROM users WHERE users.team = ${schema.teams.id})`, memberCount: sql<number>`(SELECT COUNT(*)::int FROM users WHERE users.team = ${schema.teams.id})`,
createdAt: schema.teams.createdAt, createdAt: schema.teams.createdAt,
}) })
@@ -54,14 +58,14 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
const admin = await requirePermission("teams:manage")(request, reply); const admin = await requirePermission("teams:manage")(request, reply);
if (!admin) return; if (!admin) return;
const parsed = teamNameSchema.safeParse(request.body); const parsed = teamBodySchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ return reply.status(400).send({
error: parsed.error.issues.map((i) => i.message).join("; "), error: parsed.error.issues.map((i) => i.message).join("; "),
code: "VALIDATION_ERROR", code: "VALIDATION_ERROR",
}); });
} }
const trimmedName = parsed.data.name; const { name: trimmedName, storageQuota, retentionHours } = parsed.data;
// Check for duplicate name (case-insensitive) // Check for duplicate name (case-insensitive)
const [existing] = await db const [existing] = await db
@@ -75,9 +79,19 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
const id = randomUUID(); const id = randomUUID();
await db.insert(schema.teams).values({ id, name: trimmedName }); await db.insert(schema.teams).values({
id,
name: trimmedName,
storageQuota: storageQuota ?? null,
retentionHours: retentionHours ?? null,
});
return reply.status(201).send({ id, name: trimmedName }); return reply.status(201).send({
id,
name: trimmedName,
storageQuota: storageQuota ?? null,
retentionHours: retentionHours ?? null,
});
}); });
// PUT /api/v1/teams/:id — Rename team (admin only) // PUT /api/v1/teams/:id — Rename team (admin only)
@@ -94,14 +108,14 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
return reply.status(404).send({ error: "Team not found", code: "NOT_FOUND" }); return reply.status(404).send({ error: "Team not found", code: "NOT_FOUND" });
} }
const parsed = teamNameSchema.safeParse(request.body); const parsed = teamBodySchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ return reply.status(400).send({
error: parsed.error.issues.map((i) => i.message).join("; "), error: parsed.error.issues.map((i) => i.message).join("; "),
code: "VALIDATION_ERROR", code: "VALIDATION_ERROR",
}); });
} }
const trimmedName = parsed.data.name; const { name: trimmedName, storageQuota, retentionHours } = parsed.data;
// Check for duplicate name (case-insensitive), excluding current team // Check for duplicate name (case-insensitive), excluding current team
const [duplicate] = await db const [duplicate] = await db
@@ -115,7 +129,11 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
return reply.status(409).send({ error: "Team name already exists", code: "CONFLICT" }); return reply.status(409).send({ error: "Team name already exists", code: "CONFLICT" });
} }
await db.update(schema.teams).set({ name: trimmedName }).where(eq(schema.teams.id, id)); const updateFields: Partial<typeof schema.teams.$inferInsert> = { name: trimmedName };
if (storageQuota !== undefined) updateFields.storageQuota = storageQuota;
if (retentionHours !== undefined) updateFields.retentionHours = retentionHours;
await db.update(schema.teams).set(updateFields).where(eq(schema.teams.id, id));
return reply.send({ ok: true }); return reply.send({ ok: true });
}, },
+49 -12
View File
@@ -82,27 +82,64 @@ function serializeFile(row: typeof schema.userFiles.$inferSelect) {
} }
/** /**
* Check whether a user has exceeded their storage quota. * Check whether a user (and their team) has exceeded their storage quota.
* Returns the total bytes used, or throws if the quota is exceeded. * Uses the pre-computed storageUsed counter on the users table.
* Throws with statusCode 413 if the quota is exceeded.
*/ */
async function checkStorageQuota(userId: string | null): Promise<void> { async function checkStorageQuota(userId: string | null, additionalBytes = 0): Promise<void> {
if (!userId || env.MAX_STORAGE_PER_USER_MB <= 0) return; if (!userId) return;
const [result] = await db const [user] = await db
.select({ total: sql<number>`coalesce(sum(${schema.userFiles.size}), 0)` }) .select({
.from(schema.userFiles) storageUsed: schema.users.storageUsed,
.where(eq(schema.userFiles.userId, userId)); storageQuota: schema.users.storageQuota,
team: schema.users.team,
})
.from(schema.users)
.where(eq(schema.users.id, userId))
.limit(1);
const usedBytes = result?.total ?? 0; if (!user) return;
const limitBytes = env.MAX_STORAGE_PER_USER_MB * 1024 * 1024; const { storageUsed, storageQuota, team } = user;
if (usedBytes >= limitBytes) { // Per-user quota: user-level override, then env fallback
const userLimit =
storageQuota ??
(env.MAX_STORAGE_PER_USER_MB > 0 ? env.MAX_STORAGE_PER_USER_MB * 1024 * 1024 : 0);
if (userLimit > 0 && storageUsed + additionalBytes > userLimit) {
const error = new Error( const error = new Error(
`Storage quota exceeded. Used ${(usedBytes / (1024 * 1024)).toFixed(1)}MB of ${env.MAX_STORAGE_PER_USER_MB}MB`, `Storage quota exceeded. Used ${((storageUsed + additionalBytes) / (1024 * 1024)).toFixed(1)}MB of ${(userLimit / (1024 * 1024)).toFixed(1)}MB`,
); );
(error as Error & { statusCode: number }).statusCode = 413; (error as Error & { statusCode: number }).statusCode = 413;
throw error; throw error;
} }
// Per-team quota
if (team) {
const [teamRow] = await db
.select({ storageQuota: schema.teams.storageQuota })
.from(schema.teams)
.where(eq(schema.teams.id, team))
.limit(1);
if (teamRow?.storageQuota) {
const [teamUsed] = await db
.select({
total: sql<number>`coalesce(sum(${schema.users.storageUsed}), 0)`,
})
.from(schema.users)
.where(eq(schema.users.team, team));
const teamTotal = Number(teamUsed.total);
if (teamTotal + additionalBytes > teamRow.storageQuota) {
const error = new Error(
`Team storage quota exceeded. Team used ${((teamTotal + additionalBytes) / (1024 * 1024)).toFixed(1)}MB of ${(teamRow.storageQuota / (1024 * 1024)).toFixed(1)}MB`,
);
(error as Error & { statusCode: number }).statusCode = 413;
throw error;
}
}
}
} }
// ── Route registration ───────────────────────────────────────────── // ── Route registration ─────────────────────────────────────────────