first commit
This commit is contained in:
parent
503966e881
commit
d532c2e084
@ -7,4 +7,7 @@ CORS_ORIGINS=*
|
|||||||
MAX_UPLOAD_MB=250
|
MAX_UPLOAD_MB=250
|
||||||
GOOGLE_CREDENTIALS=./credentials.json
|
GOOGLE_CREDENTIALS=./credentials.json
|
||||||
GOOGLE_TOKEN=./token.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/
|
generated/
|
||||||
credentials.json
|
credentials.json
|
||||||
token.json
|
token.json
|
||||||
|
.pgdata*/
|
||||||
|
|
||||||
/generated/prisma
|
/generated/prisma
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "1.0.0",
|
"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",
|
"main": "src/server.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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
|
gender String
|
||||||
age Int
|
age Int
|
||||||
driveFolderId String @unique
|
driveFolderId String @unique
|
||||||
|
isSuspended Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
folders Folder[]
|
folders Folder[]
|
||||||
files FileAsset[]
|
files FileAsset[]
|
||||||
|
|
||||||
|
@@index([isSuspended, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Folder {
|
model Folder {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import authRoutes from "./routes/auth.js";
|
|||||||
import folderRoutes from "./routes/folders.js";
|
import folderRoutes from "./routes/folders.js";
|
||||||
import fileRoutes from "./routes/files.js";
|
import fileRoutes from "./routes/files.js";
|
||||||
import retentionRoutes from "./routes/retention.js";
|
import retentionRoutes from "./routes/retention.js";
|
||||||
|
import adminRoutes from "./routes/admin.js";
|
||||||
|
|
||||||
export const app = express();
|
export const app = express();
|
||||||
app.disable("x-powered-by");
|
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(express.urlencoded({ extended: false, limit: "1mb" }));
|
||||||
app.use(morgan(config.NODE_ENV === "production" ? "combined" : "dev"));
|
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 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 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/auth", authLimiter, authRoutes);
|
||||||
|
app.use("/api/admin", adminLimiter, adminRoutes);
|
||||||
app.use("/api/folders", requireAuth, folderRoutes);
|
app.use("/api/folders", requireAuth, folderRoutes);
|
||||||
app.use("/api/files/upload", uploadLimiter);
|
app.use("/api/files/upload", uploadLimiter);
|
||||||
app.use("/api/files", requireAuth, fileRoutes);
|
app.use("/api/files", requireAuth, fileRoutes);
|
||||||
|
|||||||
@ -13,7 +13,10 @@ const schema = z.object({
|
|||||||
MAX_UPLOAD_MB: z.coerce.number().positive().default(250),
|
MAX_UPLOAD_MB: z.coerce.number().positive().default(250),
|
||||||
GOOGLE_CREDENTIALS: z.string().default("./credentials.json"),
|
GOOGLE_CREDENTIALS: z.string().default("./credentials.json"),
|
||||||
GOOGLE_TOKEN: z.string().default("./token.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);
|
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 payload = jwt.verify(token, config.JWT_SECRET);
|
||||||
const user = await prisma.user.findUnique({ where: { id: payload.sub } });
|
const user = await prisma.user.findUnique({ where: { id: payload.sub } });
|
||||||
if (!user) return response.status(401).json({ error: "Account no longer exists" });
|
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;
|
request.user = user;
|
||||||
next();
|
next();
|
||||||
} catch {
|
} 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))) {
|
if (!user || !(await bcrypt.compare(input.password, user.passwordHash))) {
|
||||||
throw new HttpError(401, "Invalid username or password");
|
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) });
|
response.json({ token: issueToken(user.id), user: publicUser(user) });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { config } from "./config.js";
|
|||||||
import { prisma } from "./db.js";
|
import { prisma } from "./db.js";
|
||||||
|
|
||||||
const server = app.listen(config.PORT, "0.0.0.0", () => {
|
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) {
|
async function shutdown(signal) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user