diff --git a/.env.example b/.env.example index 4bbb069..60227e4 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,7 @@ CORS_ORIGINS=* MAX_UPLOAD_MB=250 GOOGLE_CREDENTIALS=./credentials.json GOOGLE_TOKEN=./token.json -GOOGLE_DRIVE_ROOT_FOLDER=Whats-Drive-SaaS +GOOGLE_DRIVE_ROOT_FOLDER=Metatron-Drive +ADMIN_USERNAME=admin +ADMIN_PASSWORD=replace-with-a-strong-admin-password +ADMIN_JWT_EXPIRES_IN=8h diff --git a/.gitignore b/.gitignore index 8cbf846..2c4cb47 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ uploads/ generated/ credentials.json token.json +.pgdata*/ /generated/prisma diff --git a/package.json b/package.json index 57a1bd4..79b2275 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "backend", "version": "1.0.0", - "description": "Private organizational Google Drive upload API for Whats Drive", + "description": "Private organizational Google Drive upload API for Metatron-Drive", "main": "src/server.js", "type": "module", "scripts": { diff --git a/prisma/migrations/20260630193000_admin_suspension/migration.sql b/prisma/migrations/20260630193000_admin_suspension/migration.sql new file mode 100644 index 0000000..6cbb7b9 --- /dev/null +++ b/prisma/migrations/20260630193000_admin_suspension/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "User" ADD COLUMN "isSuspended" BOOLEAN NOT NULL DEFAULT false; + +CREATE INDEX "User_isSuspended_createdAt_idx" ON "User"("isSuspended", "createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1bcf853..ddd62c4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,10 +21,13 @@ model User { gender String age Int driveFolderId String @unique + isSuspended Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt folders Folder[] files FileAsset[] + + @@index([isSuspended, createdAt]) } model Folder { diff --git a/src/app.js b/src/app.js index 7fa8f49..a1790db 100644 --- a/src/app.js +++ b/src/app.js @@ -10,6 +10,7 @@ import authRoutes from "./routes/auth.js"; import folderRoutes from "./routes/folders.js"; import fileRoutes from "./routes/files.js"; import retentionRoutes from "./routes/retention.js"; +import adminRoutes from "./routes/admin.js"; export const app = express(); app.disable("x-powered-by"); @@ -22,11 +23,13 @@ app.use(express.json({ limit: "1mb" })); app.use(express.urlencoded({ extended: false, limit: "1mb" })); app.use(morgan(config.NODE_ENV === "production" ? "combined" : "dev")); -app.get("/health", (request, response) => response.json({ status: "ok", service: "whats-drive-api" })); +app.get("/health", (request, response) => response.json({ status: "ok", service: "metatron-drive-api" })); const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 100, standardHeaders: "draft-8", legacyHeaders: false }); const uploadLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 500, standardHeaders: "draft-8", legacyHeaders: false }); +const adminLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 50, standardHeaders: "draft-8", legacyHeaders: false }); app.use("/api/auth", authLimiter, authRoutes); +app.use("/api/admin", adminLimiter, adminRoutes); app.use("/api/folders", requireAuth, folderRoutes); app.use("/api/files/upload", uploadLimiter); app.use("/api/files", requireAuth, fileRoutes); diff --git a/src/config.js b/src/config.js index d68d3ed..ead7088 100644 --- a/src/config.js +++ b/src/config.js @@ -13,7 +13,10 @@ const schema = z.object({ MAX_UPLOAD_MB: z.coerce.number().positive().default(250), GOOGLE_CREDENTIALS: z.string().default("./credentials.json"), GOOGLE_TOKEN: z.string().default("./token.json"), - GOOGLE_DRIVE_ROOT_FOLDER: z.string().min(1).default("Whats-Drive-SaaS"), + GOOGLE_DRIVE_ROOT_FOLDER: z.string().min(1).default("Metatron-Drive"), + ADMIN_USERNAME: z.string().trim().min(3).max(40).default("admin"), + ADMIN_PASSWORD: z.string().min(12), + ADMIN_JWT_EXPIRES_IN: z.string().default("8h"), }); const parsed = schema.safeParse(process.env); diff --git a/src/middleware/adminAuth.js b/src/middleware/adminAuth.js new file mode 100644 index 0000000..5151ca2 --- /dev/null +++ b/src/middleware/adminAuth.js @@ -0,0 +1,18 @@ +import jwt from "jsonwebtoken"; +import { config } from "../config.js"; + +export function requireAdmin(request, response, next) { + try { + const [scheme, token] = String(request.headers.authorization || "").split(" "); + if (scheme !== "Bearer" || !token) { + return response.status(401).json({ error: "Admin authentication required" }); + } + const payload = jwt.verify(token, config.JWT_SECRET); + if (payload.sub !== "admin" || payload.role !== "admin") { + return response.status(403).json({ error: "Administrator access required" }); + } + next(); + } catch { + response.status(401).json({ error: "Invalid or expired admin session" }); + } +} diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 9e59525..03fcd0f 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -11,6 +11,7 @@ export async function requireAuth(request, response, next) { const payload = jwt.verify(token, config.JWT_SECRET); const user = await prisma.user.findUnique({ where: { id: payload.sub } }); if (!user) return response.status(401).json({ error: "Account no longer exists" }); + if (user.isSuspended) return response.status(403).json({ error: "Account is suspended" }); request.user = user; next(); } catch { diff --git a/src/routes/admin.js b/src/routes/admin.js new file mode 100644 index 0000000..7bd8009 --- /dev/null +++ b/src/routes/admin.js @@ -0,0 +1,161 @@ +import { Router } from "express"; +import bcrypt from "bcryptjs"; +import crypto from "node:crypto"; +import jwt from "jsonwebtoken"; +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { config } from "../config.js"; +import { prisma } from "../db.js"; +import { requireAdmin } from "../middleware/adminAuth.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; +import { HttpError } from "../utils/httpError.js"; + +const router = Router(); +const usernameRule = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9._-]+$/); +const updateSchema = z.object({ + name: z.string().trim().min(2).max(80).optional(), + username: usernameRule.optional(), + email: z.string().trim().toLowerCase().email().max(160).optional(), + phone: z.string().trim().min(7).max(20).optional(), + gender: z.enum(["Male", "Female", "Non-binary", "Prefer not to say"]).optional(), + age: z.coerce.number().int().min(13).max(120).optional(), + createdAt: z.coerce.date().optional(), + isSuspended: z.boolean().optional(), + password: z.string().min(8).max(128).optional(), +}).refine((input) => Object.keys(input).length > 0, "At least one field is required"); + +function secureEqual(left, right) { + const leftBuffer = Buffer.from(String(left)); + const rightBuffer = Buffer.from(String(right)); + return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function adminUser(user) { + return { + id: user.id, + username: user.username, + name: user.name, + phone: user.phone, + email: user.email, + gender: user.gender, + age: user.age, + driveFolderId: user.driveFolderId, + isSuspended: user.isSuspended, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + counts: user._count ? { folders: user._count.folders, files: user._count.files } : undefined, + }; +} + +router.post("/login", (request, response) => { + const input = z.object({ + username: z.string().trim().min(1).max(40), + password: z.string().min(1).max(128), + }).parse(request.body); + if (!secureEqual(input.username, config.ADMIN_USERNAME) || !secureEqual(input.password, config.ADMIN_PASSWORD)) { + throw new HttpError(401, "Invalid administrator credentials"); + } + const token = jwt.sign({ role: "admin" }, config.JWT_SECRET, { + subject: "admin", + expiresIn: config.ADMIN_JWT_EXPIRES_IN, + }); + response.json({ token, admin: { username: config.ADMIN_USERNAME } }); +}); + +router.use(requireAdmin); + +router.get("/me", (request, response) => { + response.json({ admin: { username: config.ADMIN_USERNAME } }); +}); + +router.get("/stats", asyncHandler(async (request, response) => { + const [users, activeUsers, suspendedUsers, files, folders, storage] = await Promise.all([ + prisma.user.count(), + prisma.user.count({ where: { isSuspended: false } }), + prisma.user.count({ where: { isSuspended: true } }), + prisma.fileAsset.count(), + prisma.folder.count(), + prisma.fileAsset.aggregate({ _sum: { sizeBytes: true } }), + ]); + response.json({ + stats: { + users, + activeUsers, + suspendedUsers, + files, + folders, + storageBytes: Number(storage._sum.sizeBytes || 0n), + }, + }); +})); + +router.get("/users", asyncHandler(async (request, response) => { + const query = z.object({ + search: z.string().trim().max(100).default(""), + status: z.enum(["all", "active", "suspended"]).default("all"), + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(25), + }).parse(request.query); + const where = { + ...(query.status === "active" ? { isSuspended: false } : {}), + ...(query.status === "suspended" ? { isSuspended: true } : {}), + ...(query.search ? { + OR: ["name", "username", "email", "phone"].map((field) => ({ + [field]: { contains: query.search, mode: "insensitive" }, + })), + } : {}), + }; + const [users, total] = await Promise.all([ + prisma.user.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: (query.page - 1) * query.limit, + take: query.limit, + include: { _count: { select: { folders: true, files: true } } }, + }), + prisma.user.count({ where }), + ]); + response.json({ + users: users.map(adminUser), + pagination: { page: query.page, limit: query.limit, total, pages: Math.max(1, Math.ceil(total / query.limit)) }, + }); +})); + +router.patch("/users/:id", asyncHandler(async (request, response) => { + const input = updateSchema.parse(request.body); + const { password, ...profile } = input; + try { + const user = await prisma.user.update({ + where: { id: request.params.id }, + data: { + ...profile, + ...(password ? { passwordHash: await bcrypt.hash(password, 12) } : {}), + }, + include: { _count: { select: { folders: true, files: true } } }, + }); + response.json({ user: adminUser(user) }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + throw new HttpError(409, "Username, email, or phone is already registered"); + } + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") { + throw new HttpError(404, "User not found"); + } + throw error; + } +})); + +router.delete("/users/:id", asyncHandler(async (request, response) => { + try { + // Database relations cascade. Google Drive content is deliberately retained. + await prisma.user.delete({ where: { id: request.params.id } }); + response.status(204).end(); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") { + throw new HttpError(404, "User not found"); + } + throw error; + } +})); + +export default router; diff --git a/src/routes/auth.js b/src/routes/auth.js index 0789701..5353794 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -55,6 +55,7 @@ router.post("/login", asyncHandler(async (request, response) => { if (!user || !(await bcrypt.compare(input.password, user.passwordHash))) { throw new HttpError(401, "Invalid username or password"); } + if (user.isSuspended) throw new HttpError(403, "Account is suspended. Contact the administrator."); response.json({ token: issueToken(user.id), user: publicUser(user) }); })); diff --git a/src/server.js b/src/server.js index 79f17e1..2a98e49 100644 --- a/src/server.js +++ b/src/server.js @@ -3,7 +3,7 @@ import { config } from "./config.js"; import { prisma } from "./db.js"; const server = app.listen(config.PORT, "0.0.0.0", () => { - console.log(`Whats Drive API listening on http://0.0.0.0:${config.PORT}`); + console.log(`Metatron-Drive API listening on http://0.0.0.0:${config.PORT}`); }); async function shutdown(signal) {