first commit
This commit is contained in:
parent
503966e881
commit
d532c2e084
@ -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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,5 +5,6 @@ uploads/
|
||||
generated/
|
||||
credentials.json
|
||||
token.json
|
||||
.pgdata*/
|
||||
|
||||
/generated/prisma
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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");
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
18
src/middleware/adminAuth.js
Normal file
18
src/middleware/adminAuth.js
Normal file
@ -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" });
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
161
src/routes/admin.js
Normal file
161
src/routes/admin.js
Normal file
@ -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;
|
||||
@ -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) });
|
||||
}));
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user