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 { requirePermission } from "../permissions.js";
const teamNameSchema = z.object({
const teamBodySchema = z.object({
name: z
.string({ required_error: "Team name is required" })
.transform((v) => v.trim())
@@ -24,6 +24,8 @@ const teamNameSchema = z.object({
.min(1, "Team name is required")
.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> {
@@ -36,6 +38,8 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
.select({
id: schema.teams.id,
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})`,
createdAt: schema.teams.createdAt,
})
@@ -54,14 +58,14 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
const admin = await requirePermission("teams:manage")(request, reply);
if (!admin) return;
const parsed = teamNameSchema.safeParse(request.body);
const parsed = teamBodySchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.issues.map((i) => i.message).join("; "),
code: "VALIDATION_ERROR",
});
}
const trimmedName = parsed.data.name;
const { name: trimmedName, storageQuota, retentionHours } = parsed.data;
// Check for duplicate name (case-insensitive)
const [existing] = await db
@@ -75,9 +79,19 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
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)
@@ -94,14 +108,14 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
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) {
return reply.status(400).send({
error: parsed.error.issues.map((i) => i.message).join("; "),
code: "VALIDATION_ERROR",
});
}
const trimmedName = parsed.data.name;
const { name: trimmedName, storageQuota, retentionHours } = parsed.data;
// Check for duplicate name (case-insensitive), excluding current team
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" });
}
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 });
},
+49 -12
View File
@@ -82,27 +82,64 @@ function serializeFile(row: typeof schema.userFiles.$inferSelect) {
}
/**
* Check whether a user has exceeded their storage quota.
* Returns the total bytes used, or throws if the quota is exceeded.
* Check whether a user (and their team) has exceeded their storage quota.
* 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> {
if (!userId || env.MAX_STORAGE_PER_USER_MB <= 0) return;
async function checkStorageQuota(userId: string | null, additionalBytes = 0): Promise<void> {
if (!userId) return;
const [result] = await db
.select({ total: sql<number>`coalesce(sum(${schema.userFiles.size}), 0)` })
.from(schema.userFiles)
.where(eq(schema.userFiles.userId, userId));
const [user] = await db
.select({
storageUsed: schema.users.storageUsed,
storageQuota: schema.users.storageQuota,
team: schema.users.team,
})
.from(schema.users)
.where(eq(schema.users.id, userId))
.limit(1);
const usedBytes = result?.total ?? 0;
const limitBytes = env.MAX_STORAGE_PER_USER_MB * 1024 * 1024;
if (!user) return;
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(
`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;
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 ─────────────────────────────────────────────