mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add team-level storage quotas with enforcement on upload and save
This commit is contained in:
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user