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 { 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 });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user