Compare commits

..

No commits in common. "6b105c07c4eb863777ad92f7c02dfa091223fd6f" and "2266dde52e1e72977b206bd1f4315da96b9999dc" have entirely different histories.

13 changed files with 157 additions and 752 deletions

View File

@ -1,78 +0,0 @@
CREATE TABLE "Organization" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Membership" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'MEMBER',
"status" TEXT NOT NULL DEFAULT 'PENDING',
"rejectionReason" TEXT,
"driveFolderId" TEXT,
"reviewedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Membership_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "GoogleDriveConnection" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"tokenCiphertext" TEXT NOT NULL,
"storageType" TEXT NOT NULL DEFAULT 'MY_DRIVE',
"sharedDriveId" TEXT,
"rootFolderId" TEXT,
"status" TEXT NOT NULL DEFAULT 'CONNECTED',
"scope" TEXT,
"connectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GoogleDriveConnection_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "Folder" ADD COLUMN "organizationId" TEXT;
ALTER TABLE "FileAsset" ADD COLUMN "organizationId" TEXT;
INSERT INTO "Organization" ("id", "code", "name", "status", "updatedAt")
VALUES ('00000000-0000-0000-0000-000000000001', 'METATRON', 'Metatron Drive Legacy', 'ACTIVE', CURRENT_TIMESTAMP);
INSERT INTO "Membership" ("id", "userId", "organizationId", "role", "status", "driveFolderId", "reviewedAt", "updatedAt")
SELECT "id", "id", '00000000-0000-0000-0000-000000000001',
CASE WHEN "id" = (SELECT "id" FROM "User" ORDER BY "createdAt" ASC LIMIT 1) THEN 'OWNER' ELSE 'MEMBER' END,
CASE WHEN "isSuspended" THEN 'SUSPENDED' ELSE 'APPROVED' END,
"driveFolderId", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM "User";
UPDATE "Folder" SET "organizationId" = '00000000-0000-0000-0000-000000000001';
UPDATE "FileAsset" SET "organizationId" = '00000000-0000-0000-0000-000000000001';
ALTER TABLE "Folder" ALTER COLUMN "organizationId" SET NOT NULL;
ALTER TABLE "FileAsset" ALTER COLUMN "organizationId" SET NOT NULL;
ALTER TABLE "User" DROP COLUMN "driveFolderId";
DROP INDEX IF EXISTS "Folder_userId_parentId_name_key";
DROP INDEX IF EXISTS "Folder_userId_parentId_idx";
DROP INDEX IF EXISTS "FileAsset_userId_folderId_createdAt_idx";
CREATE UNIQUE INDEX "Organization_code_key" ON "Organization"("code");
CREATE INDEX "Organization_status_createdAt_idx" ON "Organization"("status", "createdAt");
CREATE UNIQUE INDEX "Membership_driveFolderId_key" ON "Membership"("driveFolderId");
CREATE UNIQUE INDEX "Membership_userId_organizationId_key" ON "Membership"("userId", "organizationId");
CREATE INDEX "Membership_organizationId_status_createdAt_idx" ON "Membership"("organizationId", "status", "createdAt");
CREATE INDEX "Membership_userId_status_idx" ON "Membership"("userId", "status");
CREATE UNIQUE INDEX "GoogleDriveConnection_organizationId_key" ON "GoogleDriveConnection"("organizationId");
CREATE UNIQUE INDEX "Folder_organizationId_userId_parentId_name_key" ON "Folder"("organizationId", "userId", "parentId", "name");
CREATE INDEX "Folder_organizationId_userId_parentId_idx" ON "Folder"("organizationId", "userId", "parentId");
CREATE INDEX "FileAsset_organizationId_userId_folderId_createdAt_idx" ON "FileAsset"("organizationId", "userId", "folderId", "createdAt");
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "GoogleDriveConnection" ADD CONSTRAINT "GoogleDriveConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FileAsset" ADD CONSTRAINT "FileAsset_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,3 +1,8 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Get a free hosted Postgres database in seconds: `npx create-db`
generator client {
provider = "prisma-client-js"
}
@ -7,105 +12,53 @@ datasource db {
}
model User {
id String @id @default(uuid())
username String @unique
passwordHash String
name String
phone String @unique
email String @unique
gender String
age Int
isSuspended Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships Membership[]
folders Folder[]
files FileAsset[]
id String @id @default(uuid())
username String @unique
passwordHash String
name String
phone String @unique
email String @unique
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 Organization {
id String @id @default(uuid())
code String @unique
name String
status String @default("ACTIVE")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships Membership[]
driveConnection GoogleDriveConnection?
folders Folder[]
files FileAsset[]
@@index([status, createdAt])
}
model Membership {
id String @id @default(uuid())
userId String
organizationId String
role String @default("MEMBER")
status String @default("PENDING")
rejectionReason String?
driveFolderId String? @unique
reviewedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@unique([userId, organizationId])
@@index([organizationId, status, createdAt])
@@index([userId, status])
}
model GoogleDriveConnection {
id String @id @default(uuid())
organizationId String @unique
tokenCiphertext String
storageType String @default("MY_DRIVE")
sharedDriveId String?
rootFolderId String?
status String @default("CONNECTED")
scope String?
connectedAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
}
model Folder {
id String @id @default(uuid())
name String
driveFolderId String @unique
scopeKey String? @unique
userId String
organizationId String
parentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: Cascade)
children Folder[] @relation("FolderTree")
files FileAsset[]
id String @id @default(uuid())
name String
driveFolderId String @unique
scopeKey String? @unique
userId String
parentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: Cascade)
children Folder[] @relation("FolderTree")
files FileAsset[]
@@unique([organizationId, userId, parentId, name])
@@index([organizationId, userId, parentId])
@@unique([userId, parentId, name])
@@index([userId, parentId])
}
model FileAsset {
id String @id @default(uuid())
originalName String
driveFileId String @unique
mimeType String
sizeBytes BigInt
userId String
organizationId String
folderId String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
id String @id @default(uuid())
originalName String
driveFileId String @unique
mimeType String
sizeBytes BigInt
userId String
folderId String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
@@index([organizationId, userId, folderId, createdAt])
@@index([userId, folderId, createdAt])
}

View File

@ -5,15 +5,12 @@ import morgan from "morgan";
import { rateLimit } from "express-rate-limit";
import { config } from "./config.js";
import { requireAuth } from "./middleware/auth.js";
import { requireOrganization, requireOrganizationAdmin } from "./middleware/organization.js";
import { errorHandler, notFound } from "./middleware/errorHandler.js";
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";
import organizationRoutes from "./routes/organizations.js";
import organizationAdminRoutes from "./routes/organizationAdmin.js";
export const app = express();
app.disable("x-powered-by");
@ -33,11 +30,9 @@ const uploadLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 500, standard
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/organizations", authLimiter, organizationRoutes);
app.use("/api/org-admin", adminLimiter, requireAuth, requireOrganization, requireOrganizationAdmin, organizationAdminRoutes);
app.use("/api/folders", requireAuth, requireOrganization, folderRoutes);
app.use("/api/folders", requireAuth, folderRoutes);
app.use("/api/files/upload", uploadLimiter);
app.use("/api/files", requireAuth, requireOrganization, fileRoutes);
app.use("/api/files", requireAuth, fileRoutes);
app.use("/api/retention", requireAuth, retentionRoutes);
app.use(notFound);

View File

@ -1,31 +0,0 @@
import { prisma } from "../db.js";
export async function requireOrganization(request, response, next) {
try {
const organizationId = String(request.headers["x-organization-id"] || "");
if (!organizationId) return response.status(400).json({ error: "Select an organization" });
const membership = await prisma.membership.findUnique({
where: { userId_organizationId: { userId: request.user.id, organizationId } },
include: { organization: true },
});
if (!membership) return response.status(403).json({ error: "You do not belong to this organization" });
if (membership.status !== "APPROVED") {
return response.status(403).json({ error: `Membership is ${membership.status.toLowerCase()}` });
}
if (membership.organization.status !== "ACTIVE") {
return response.status(403).json({ error: "Organization is not active" });
}
request.membership = membership;
request.organization = membership.organization;
next();
} catch (error) {
next(error);
}
}
export function requireOrganizationAdmin(request, response, next) {
if (!request.membership || !["OWNER", "ADMIN"].includes(request.membership.role)) {
return response.status(403).json({ error: "Organization administrator access required" });
}
next();
}

View File

@ -4,6 +4,7 @@ import jwt from "jsonwebtoken";
import { z } from "zod";
import { prisma } from "../db.js";
import { config } from "../config.js";
import { createUserRootFolder, deleteDriveItem } from "../services/googleDrive.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import { HttpError } from "../utils/httpError.js";
import { publicUser } from "../utils/serializers.js";
@ -11,7 +12,7 @@ import { requireAuth } from "../middleware/auth.js";
const router = Router();
const usernameRule = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9._-]+$/);
const profileSchema = z.object({
const registerSchema = z.object({
username: usernameRule,
password: z.string().min(8).max(128),
name: z.string().trim().min(2).max(80),
@ -20,72 +21,46 @@ const profileSchema = z.object({
gender: z.enum(["Male", "Female", "Non-binary", "Prefer not to say"]),
age: z.coerce.number().int().min(13).max(120),
});
const registerSchema = profileSchema.extend({ organizationCode: z.string().trim().toUpperCase().min(4).max(20) });
const loginSchema = z.object({ username: usernameRule, password: z.string().min(1).max(128) });
function issueToken(userId) {
return jwt.sign({}, config.JWT_SECRET, { subject: userId, expiresIn: config.JWT_EXPIRES_IN });
}
function membershipInclude() {
return { memberships: { include: { organization: true }, orderBy: { createdAt: "asc" } } };
}
router.post("/register", asyncHandler(async (request, response) => {
const input = registerSchema.parse(request.body);
const organization = await prisma.organization.findUnique({ where: { code: input.organizationCode } });
if (!organization || organization.status !== "ACTIVE") throw new HttpError(404, "Organization not found");
const duplicate = await prisma.user.findFirst({
where: { OR: [{ username: input.username }, { email: input.email }, { phone: input.phone }] },
select: { id: true },
});
if (duplicate) throw new HttpError(409, "An account already exists. Sign in and join this organization instead.");
const { password, organizationCode, ...profile } = input;
const user = await prisma.user.create({
data: {
...profile,
passwordHash: await bcrypt.hash(password, 12),
memberships: { create: { organizationId: organization.id, status: "PENDING", role: "MEMBER" } },
},
include: membershipInclude(),
});
response.status(202).json({
message: "Application submitted. You can sign in after an organization administrator approves it.",
user: publicUser(user),
});
if (duplicate) throw new HttpError(409, "Username, email, or phone is already registered");
let driveFolderId;
try {
driveFolderId = await createUserRootFolder(input.name, input.username);
const { password, ...profile } = input;
const user = await prisma.user.create({
data: { ...profile, passwordHash: await bcrypt.hash(password, 12), driveFolderId },
});
response.status(201).json({ token: issueToken(user.id), user: publicUser(user) });
} catch (error) {
if (driveFolderId) await deleteDriveItem(driveFolderId).catch(() => {});
throw error;
}
}));
router.post("/login", asyncHandler(async (request, response) => {
const input = loginSchema.parse(request.body);
const user = await prisma.user.findUnique({ where: { username: input.username }, include: membershipInclude() });
const user = await prisma.user.findUnique({ where: { username: input.username } });
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.");
if (!user.memberships.some((membership) => membership.status === "APPROVED" && membership.organization.status === "ACTIVE")) {
throw new HttpError(403, "Your organization membership is awaiting approval");
}
response.json({ token: issueToken(user.id), user: publicUser(user) });
}));
router.get("/me", requireAuth, asyncHandler(async (request, response) => {
const user = await prisma.user.findUnique({ where: { id: request.user.id }, include: membershipInclude() });
response.json({ user: publicUser(user) });
}));
router.post("/join", requireAuth, asyncHandler(async (request, response) => {
const { organizationCode } = z.object({ organizationCode: z.string().trim().toUpperCase().min(4).max(20) }).parse(request.body);
const organization = await prisma.organization.findUnique({ where: { code: organizationCode } });
if (!organization || organization.status !== "ACTIVE") throw new HttpError(404, "Organization not found");
const existing = await prisma.membership.findUnique({
where: { userId_organizationId: { userId: request.user.id, organizationId: organization.id } },
});
if (existing) throw new HttpError(409, `Membership is already ${existing.status.toLowerCase()}`);
const membership = await prisma.membership.create({
data: { userId: request.user.id, organizationId: organization.id, status: "PENDING", role: "MEMBER" },
include: { organization: true },
});
response.status(202).json({ message: "Join request submitted", membership });
}));
router.get("/me", requireAuth, (request, response) => {
response.json({ user: publicUser(request.user) });
});
export default router;

View File

@ -6,14 +6,12 @@ import { uploadSingle } from "../middleware/upload.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import { HttpError } from "../utils/httpError.js";
import { serializeFile } from "../utils/serializers.js";
import { deleteDriveItem, ensureMembershipRoot, streamFile, uploadFile } from "../services/googleDrive.js";
import { deleteDriveItem, streamFile, uploadFile } from "../services/googleDrive.js";
const router = Router();
async function ownedFile(request, id) {
const file = await prisma.fileAsset.findFirst({
where: { id, userId: request.user.id, organizationId: request.organization.id },
});
async function ownedFile(userId, id) {
const file = await prisma.fileAsset.findFirst({ where: { id, userId } });
if (!file) throw new HttpError(404, "File not found");
return file;
}
@ -21,14 +19,11 @@ async function ownedFile(request, id) {
router.get("/", asyncHandler(async (request, response) => {
const folderId = request.query.folderId ? z.string().uuid().parse(request.query.folderId) : null;
if (folderId) {
const folder = await prisma.folder.findFirst({
where: { id: folderId, userId: request.user.id, organizationId: request.organization.id },
select: { id: true },
});
const folder = await prisma.folder.findFirst({ where: { id: folderId, userId: request.user.id }, select: { id: true } });
if (!folder) throw new HttpError(404, "Folder not found");
}
const files = await prisma.fileAsset.findMany({
where: { userId: request.user.id, organizationId: request.organization.id, folderId },
where: { userId: request.user.id, folderId },
orderBy: { createdAt: "desc" },
});
response.json({ files: files.map(serializeFile) });
@ -39,17 +34,13 @@ router.post("/upload", uploadSingle, asyncHandler(async (request, response) => {
let uploaded;
try {
const folderId = request.body.folderId ? z.string().uuid().parse(request.body.folderId) : null;
let parentDriveFolderId = await ensureMembershipRoot(request.membership.id);
if (!parentDriveFolderId) throw new HttpError(409, "Organization administrator must connect Google Drive first");
let parentDriveFolderId = request.user.driveFolderId;
if (folderId) {
const folder = await prisma.folder.findFirst({
where: { id: folderId, userId: request.user.id, organizationId: request.organization.id },
});
const folder = await prisma.folder.findFirst({ where: { id: folderId, userId: request.user.id } });
if (!folder) throw new HttpError(404, "Folder not found");
parentDriveFolderId = folder.driveFolderId;
}
uploaded = await uploadFile({
organizationId: request.organization.id,
filePath: request.file.path,
originalName: request.file.originalname,
mimeType: request.file.mimetype || "application/octet-stream",
@ -62,13 +53,12 @@ router.post("/upload", uploadSingle, asyncHandler(async (request, response) => {
mimeType: request.file.mimetype || uploaded.mimeType || "application/octet-stream",
sizeBytes: BigInt(request.file.size),
userId: request.user.id,
organizationId: request.organization.id,
folderId,
},
});
response.status(201).json({ file: serializeFile(file) });
} catch (error) {
if (uploaded?.id) await deleteDriveItem(request.organization.id, uploaded.id).catch(() => {});
if (uploaded?.id) await deleteDriveItem(uploaded.id).catch(() => {});
throw error;
} finally {
await fs.unlink(request.file.path).catch(() => {});
@ -76,15 +66,15 @@ router.post("/upload", uploadSingle, asyncHandler(async (request, response) => {
}));
router.get("/:id/content", asyncHandler(async (request, response) => {
const file = await ownedFile(request, z.string().uuid().parse(request.params.id));
const file = await ownedFile(request.user.id, z.string().uuid().parse(request.params.id));
response.setHeader("Content-Type", file.mimeType);
response.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(file.originalName)}`);
await streamFile(request.organization.id, file.driveFileId, response);
await streamFile(file.driveFileId, response);
}));
router.delete("/:id", asyncHandler(async (request, response) => {
const file = await ownedFile(request, z.string().uuid().parse(request.params.id));
await deleteDriveItem(request.organization.id, file.driveFileId);
const file = await ownedFile(request.user.id, z.string().uuid().parse(request.params.id));
await deleteDriveItem(file.driveFileId);
await prisma.fileAsset.delete({ where: { id: file.id } });
response.status(204).end();
}));

View File

@ -3,29 +3,27 @@ import { z } from "zod";
import { prisma } from "../db.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import { HttpError } from "../utils/httpError.js";
import { createChildFolder, deleteDriveItem, ensureMembershipRoot, renameDriveItem } from "../services/googleDrive.js";
import { createChildFolder, deleteDriveItem, renameDriveItem } from "../services/googleDrive.js";
const router = Router();
const folderName = z.string().trim().min(1).max(120).refine((name) => !/[\\/:*?"<>|]/.test(name), "Folder name contains invalid characters");
const optionalParent = z.string().uuid().nullable().optional();
function folderScope(organizationId, userId, parentId, name) {
return `${organizationId}:${userId}:${parentId || "root"}:${name.trim().toLowerCase()}`;
function folderScope(userId, parentId, name) {
return `${userId}:${parentId || "root"}:${name.trim().toLowerCase()}`;
}
async function ownedFolder(request, id) {
const folder = await prisma.folder.findFirst({
where: { id, userId: request.user.id, organizationId: request.organization.id },
});
async function ownedFolder(userId, id) {
const folder = await prisma.folder.findFirst({ where: { id, userId } });
if (!folder) throw new HttpError(404, "Folder not found");
return folder;
}
router.get("/", asyncHandler(async (request, response) => {
const parentId = request.query.parentId ? z.string().uuid().parse(request.query.parentId) : null;
if (parentId) await ownedFolder(request, parentId);
if (parentId) await ownedFolder(request.user.id, parentId);
const folders = await prisma.folder.findMany({
where: { userId: request.user.id, organizationId: request.organization.id, parentId },
where: { userId: request.user.id, parentId },
orderBy: { name: "asc" },
include: { _count: { select: { children: true, files: true } } },
});
@ -34,50 +32,41 @@ router.get("/", asyncHandler(async (request, response) => {
router.post("/", asyncHandler(async (request, response) => {
const input = z.object({ name: folderName, parentId: optionalParent }).parse(request.body);
const parent = input.parentId ? await ownedFolder(request, input.parentId) : null;
const scopeKey = folderScope(request.organization.id, request.user.id, input.parentId, input.name);
const parent = input.parentId ? await ownedFolder(request.user.id, input.parentId) : null;
const scopeKey = folderScope(request.user.id, input.parentId, input.name);
const duplicate = await prisma.folder.findUnique({ where: { scopeKey }, select: { id: true } });
if (duplicate) throw new HttpError(409, "A folder with that name already exists here");
const memberRoot = parent?.driveFolderId || await ensureMembershipRoot(request.membership.id);
if (!memberRoot) throw new HttpError(409, "Organization administrator must connect Google Drive first");
const driveFolderId = await createChildFolder(request.organization.id, input.name, memberRoot);
const driveFolderId = await createChildFolder(input.name, parent?.driveFolderId || request.user.driveFolderId);
try {
const folder = await prisma.folder.create({
data: {
name: input.name,
parentId: input.parentId || null,
driveFolderId,
scopeKey,
userId: request.user.id,
organizationId: request.organization.id,
},
data: { name: input.name, parentId: input.parentId || null, driveFolderId, scopeKey, userId: request.user.id },
});
response.status(201).json({ folder });
} catch (error) {
await deleteDriveItem(request.organization.id, driveFolderId).catch(() => {});
await deleteDriveItem(driveFolderId).catch(() => {});
throw error;
}
}));
router.patch("/:id", asyncHandler(async (request, response) => {
const folder = await ownedFolder(request, z.string().uuid().parse(request.params.id));
const folder = await ownedFolder(request.user.id, z.string().uuid().parse(request.params.id));
const { name } = z.object({ name: folderName }).parse(request.body);
const scopeKey = folderScope(request.organization.id, request.user.id, folder.parentId, name);
const scopeKey = folderScope(request.user.id, folder.parentId, name);
const duplicate = await prisma.folder.findFirst({ where: { scopeKey, NOT: { id: folder.id } }, select: { id: true } });
if (duplicate) throw new HttpError(409, "A folder with that name already exists here");
await renameDriveItem(request.organization.id, folder.driveFolderId, name);
await renameDriveItem(folder.driveFolderId, name);
const updated = await prisma.folder.update({ where: { id: folder.id }, data: { name, scopeKey } });
response.json({ folder: updated });
}));
router.delete("/:id", asyncHandler(async (request, response) => {
const folder = await ownedFolder(request, z.string().uuid().parse(request.params.id));
const folder = await ownedFolder(request.user.id, z.string().uuid().parse(request.params.id));
const counts = await prisma.folder.findUnique({
where: { id: folder.id },
select: { _count: { select: { children: true, files: true } } },
});
if (counts._count.children || counts._count.files) throw new HttpError(409, "Folder must be empty before deletion");
await deleteDriveItem(request.organization.id, folder.driveFolderId);
await deleteDriveItem(folder.driveFolderId);
await prisma.folder.delete({ where: { id: folder.id } });
response.status(204).end();
}));

View File

@ -1,98 +0,0 @@
import { Router } from "express";
import { z } from "zod";
import { prisma } from "../db.js";
import { ensureMembershipRoot } from "../services/googleDrive.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import { HttpError } from "../utils/httpError.js";
const router = Router();
function adminMembership(membership, counts = {}) {
return {
id: membership.id,
role: membership.role,
status: membership.status,
rejectionReason: membership.rejectionReason,
reviewedAt: membership.reviewedAt,
createdAt: membership.createdAt,
user: {
id: membership.user.id,
username: membership.user.username,
name: membership.user.name,
phone: membership.user.phone,
email: membership.user.email,
gender: membership.user.gender,
age: membership.user.age,
isSuspended: membership.user.isSuspended,
},
counts,
};
}
router.get("/stats", asyncHandler(async (request, response) => {
const organizationId = request.organization.id;
const [members, approved, pending, rejected, files, folders, storage] = await Promise.all([
prisma.membership.count({ where: { organizationId } }),
prisma.membership.count({ where: { organizationId, status: "APPROVED" } }),
prisma.membership.count({ where: { organizationId, status: "PENDING" } }),
prisma.membership.count({ where: { organizationId, status: "REJECTED" } }),
prisma.fileAsset.count({ where: { organizationId } }),
prisma.folder.count({ where: { organizationId } }),
prisma.fileAsset.aggregate({ where: { organizationId }, _sum: { sizeBytes: true } }),
]);
response.json({ stats: { members, approved, pending, rejected, files, folders, storageBytes: Number(storage._sum.sizeBytes || 0n) } });
}));
router.get("/memberships", asyncHandler(async (request, response) => {
const query = z.object({
search: z.string().trim().max(100).default(""),
status: z.enum(["ALL", "PENDING", "APPROVED", "REJECTED", "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 = {
organizationId: request.organization.id,
...(query.status === "ALL" ? {} : { status: query.status }),
...(query.search ? { user: { OR: ["name", "username", "email", "phone"].map((field) => ({ [field]: { contains: query.search, mode: "insensitive" } })) } } : {}),
};
const [memberships, total] = await Promise.all([
prisma.membership.findMany({ where, include: { user: true }, orderBy: { createdAt: "desc" }, skip: (query.page - 1) * query.limit, take: query.limit }),
prisma.membership.count({ where }),
]);
const rows = await Promise.all(memberships.map(async (membership) => {
const [folders, files] = await Promise.all([
prisma.folder.count({ where: { organizationId: request.organization.id, userId: membership.userId } }),
prisma.fileAsset.count({ where: { organizationId: request.organization.id, userId: membership.userId } }),
]);
return adminMembership(membership, { folders, files });
}));
response.json({ memberships: rows, pagination: { page: query.page, limit: query.limit, total, pages: Math.max(1, Math.ceil(total / query.limit)) } });
}));
router.patch("/memberships/:id", asyncHandler(async (request, response) => {
const input = z.object({
status: z.enum(["APPROVED", "REJECTED", "SUSPENDED"]).optional(),
role: z.enum(["MEMBER", "ADMIN"]).optional(),
rejectionReason: z.string().trim().max(500).nullable().optional(),
}).refine((value) => Object.keys(value).length > 0, "At least one change is required").parse(request.body);
const membership = await prisma.membership.findFirst({
where: { id: request.params.id, organizationId: request.organization.id },
include: { user: true },
});
if (!membership) throw new HttpError(404, "Membership not found");
if (membership.role === "OWNER") throw new HttpError(403, "The organization owner cannot be modified here");
if (input.status === "REJECTED" && !input.rejectionReason) throw new HttpError(400, "A rejection reason is required");
const updated = await prisma.membership.update({
where: { id: membership.id },
data: {
...input,
...(input.status ? { reviewedAt: new Date() } : {}),
...(input.status === "APPROVED" ? { rejectionReason: null } : {}),
},
include: { user: true },
});
if (updated.status === "APPROVED" && !updated.driveFolderId) await ensureMembershipRoot(updated.id);
response.json({ membership: adminMembership(updated) });
}));
export default router;

View File

@ -1,144 +0,0 @@
import crypto from "node:crypto";
import { Router } from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { z } from "zod";
import { config } from "../config.js";
import { prisma } from "../db.js";
import { requireAuth } from "../middleware/auth.js";
import { requireOrganization, requireOrganizationAdmin } from "../middleware/organization.js";
import { authorizationInput, connectOrganizationDrive, googleAuthorizationUrl } from "../services/googleDrive.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import { HttpError } from "../utils/httpError.js";
import { publicUser } from "../utils/serializers.js";
const router = Router();
const usernameRule = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9._-]+$/);
const ownerSchema = z.object({
organizationName: z.string().trim().min(2).max(120),
name: z.string().trim().min(2).max(80),
username: usernameRule,
email: z.string().trim().toLowerCase().email().max(160),
phone: z.string().trim().min(7).max(20),
password: z.string().min(8).max(128),
gender: z.enum(["Male", "Female", "Non-binary", "Prefer not to say"]).default("Prefer not to say"),
age: z.coerce.number().int().min(13).max(120).default(18),
});
function issueToken(userId) {
return jwt.sign({}, config.JWT_SECRET, { subject: userId, expiresIn: config.JWT_EXPIRES_IN });
}
async function uniqueCode() {
for (let attempt = 0; attempt < 10; attempt++) {
const code = crypto.randomBytes(5).toString("base64url").replace(/[-_]/g, "").toUpperCase().slice(0, 8);
if (code.length >= 6 && !(await prisma.organization.findUnique({ where: { code }, select: { id: true } }))) return code;
}
throw new Error("Could not generate organization code");
}
router.get("/lookup/:code", asyncHandler(async (request, response) => {
const code = z.string().trim().toUpperCase().min(4).max(20).parse(request.params.code);
const organization = await prisma.organization.findUnique({ where: { code }, select: { id: true, code: true, name: true, status: true } });
if (!organization || organization.status !== "ACTIVE") throw new HttpError(404, "Organization not found");
response.json({ organization });
}));
router.post("/register", asyncHandler(async (request, response) => {
const input = ownerSchema.parse(request.body);
const duplicate = await prisma.user.findFirst({
where: { OR: [{ username: input.username }, { email: input.email }, { phone: input.phone }] },
select: { id: true },
});
if (duplicate) throw new HttpError(409, "Username, email, or phone is already registered");
const code = await uniqueCode();
const user = await prisma.user.create({
data: {
username: input.username,
passwordHash: await bcrypt.hash(input.password, 12),
name: input.name,
email: input.email,
phone: input.phone,
gender: input.gender,
age: input.age,
memberships: {
create: {
role: "OWNER",
status: "APPROVED",
reviewedAt: new Date(),
organization: { create: { name: input.organizationName, code, status: "ACTIVE" } },
},
},
},
include: { memberships: { include: { organization: true } } },
});
response.status(201).json({ token: issueToken(user.id), user: publicUser(user), organization: user.memberships[0].organization });
}));
router.use(requireAuth, requireOrganization);
router.get("/current", (request, response) => {
response.json({ organization: request.organization, membership: request.membership });
});
router.get("/drive", requireOrganizationAdmin, asyncHandler(async (request, response) => {
const connection = await prisma.googleDriveConnection.findUnique({ where: { organizationId: request.organization.id } });
response.json({ connection: connection ? {
status: connection.status,
storageType: connection.storageType,
sharedDriveId: connection.sharedDriveId,
rootFolderId: connection.rootFolderId,
connectedAt: connection.connectedAt,
} : null });
}));
router.post("/drive/authorization-url", requireOrganizationAdmin, (request, response) => {
const state = jwt.sign(
{ purpose: "google-drive-connect", organizationId: request.organization.id, userId: request.user.id },
config.JWT_SECRET,
{ expiresIn: "10m" },
);
response.json({ authorizationUrl: googleAuthorizationUrl(state), state });
});
router.post("/drive/exchange", requireOrganizationAdmin, asyncHandler(async (request, response) => {
const input = z.object({
redirectUrlOrCode: z.string().trim().min(1),
state: z.string().optional(),
storageType: z.enum(["MY_DRIVE", "SHARED_DRIVE"]),
sharedDriveId: z.string().trim().min(3).optional(),
}).parse(request.body);
const parsed = authorizationInput(input.redirectUrlOrCode);
const state = parsed.state || input.state;
if (!parsed.code || !state) throw new HttpError(400, "The redirect URL is missing its authorization code or state");
let payload;
try { payload = jwt.verify(state, config.JWT_SECRET); } catch { throw new HttpError(400, "Authorization session expired. Start Google connection again."); }
if (payload.purpose !== "google-drive-connect" || payload.organizationId !== request.organization.id || payload.userId !== request.user.id) {
throw new HttpError(403, "Google authorization session does not match this organization");
}
const connection = await connectOrganizationDrive({
organization: request.organization,
code: parsed.code,
storageType: input.storageType,
sharedDriveId: input.sharedDriveId,
});
response.json({ connection: {
status: connection.status,
storageType: connection.storageType,
sharedDriveId: connection.sharedDriveId,
rootFolderId: connection.rootFolderId,
connectedAt: connection.connectedAt,
} });
}));
router.delete("/drive", requireOrganizationAdmin, asyncHandler(async (request, response) => {
const [folders, files] = await Promise.all([
prisma.folder.count({ where: { organizationId: request.organization.id } }),
prisma.fileAsset.count({ where: { organizationId: request.organization.id } }),
]);
if (folders || files) throw new HttpError(409, "Google Drive cannot be disconnected while organization content exists");
await prisma.googleDriveConnection.deleteMany({ where: { organizationId: request.organization.id } });
response.status(204).end();
}));
export default router;

View File

@ -13,13 +13,6 @@ router.delete("/account", asyncHandler(async (request, response) => {
throw new HttpError(401, "Password confirmation is incorrect");
}
const ownedOrganizations = await prisma.membership.count({
where: { userId: request.user.id, role: "OWNER" },
});
if (ownedOrganizations) {
throw new HttpError(409, "Transfer or close organizations you own before deleting your account");
}
// Relations cascade in PostgreSQL. Google Drive is deliberately untouched.
await prisma.user.delete({ where: { id: request.user.id } });
response.status(204).end();

View File

@ -1,210 +1,110 @@
import fs from "node:fs";
import { google } from "googleapis";
import { config } from "../config.js";
import { prisma } from "../db.js";
import { HttpError } from "../utils/httpError.js";
import { decryptToken, encryptToken } from "../utils/tokenVault.js";
const DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
let driveClient;
let applicationRootId;
function loadOAuthDefinition() {
const credentials = JSON.parse(fs.readFileSync(config.credentialsPath, "utf8"));
const definition = credentials.installed || credentials.web;
if (!definition) throw new Error("Invalid Google OAuth credentials file");
return definition;
function loadJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function oauthClient() {
const definition = loadOAuthDefinition();
return new google.auth.OAuth2(
definition.client_id,
definition.client_secret,
definition.redirect_uris?.[0],
export function getDrive() {
if (driveClient) return driveClient;
const credentials = loadJson(config.credentialsPath);
const client = credentials.installed || credentials.web;
if (!client) throw new Error("Invalid Google OAuth credentials file");
const oauth = new google.auth.OAuth2(
client.client_id,
client.client_secret,
client.redirect_uris?.[0],
);
}
export function googleAuthorizationUrl(state) {
return oauthClient().generateAuthUrl({
access_type: "offline",
prompt: "consent",
include_granted_scopes: true,
scope: [DRIVE_SCOPE],
state,
oauth.setCredentials(loadJson(config.tokenPath));
oauth.on("tokens", (tokens) => {
const existing = loadJson(config.tokenPath);
fs.writeFileSync(config.tokenPath, JSON.stringify({ ...existing, ...tokens }, null, 2));
});
driveClient = google.drive({ version: "v3", auth: oauth });
return driveClient;
}
export function authorizationInput(value) {
const input = String(value || "").trim();
if (!input) throw new HttpError(400, "Paste the Google localhost redirect URL");
try {
const url = new URL(input);
const error = url.searchParams.get("error");
if (error) throw new HttpError(400, `Google authorization failed: ${error}`);
return { code: url.searchParams.get("code"), state: url.searchParams.get("state") };
} catch (error) {
if (error instanceof HttpError) throw error;
return { code: input, state: null };
}
function escapeQuery(value) {
return String(value).replaceAll("\\", "\\\\").replaceAll("'", "\\'");
}
async function driveForOrganization(organizationId) {
const connection = await prisma.googleDriveConnection.findUnique({ where: { organizationId } });
if (!connection || connection.status !== "CONNECTED") {
throw new HttpError(409, "This organization has not connected Google Drive");
}
const client = oauthClient();
let stored = decryptToken(connection.tokenCiphertext);
client.setCredentials(stored);
client.on("tokens", async (tokens) => {
stored = { ...stored, ...tokens };
await prisma.googleDriveConnection.update({
where: { organizationId },
data: { tokenCiphertext: encryptToken(stored), status: "CONNECTED" },
}).catch(() => {});
});
return { drive: google.drive({ version: "v3", auth: client }), connection };
}
async function createFolderWithDrive(drive, name, parentId) {
const result = await drive.files.create({
export async function findOrCreateFolder(name, parentId) {
const drive = getDrive();
const query = [
"mimeType='application/vnd.google-apps.folder'",
`name='${escapeQuery(name)}'`,
"trashed=false",
parentId ? `'${parentId}' in parents` : null,
].filter(Boolean).join(" and ");
const existing = await drive.files.list({ q: query, fields: "files(id,name)", pageSize: 1 });
if (existing.data.files?.length) return existing.data.files[0].id;
const created = await drive.files.create({
requestBody: {
name,
mimeType: "application/vnd.google-apps.folder",
parents: parentId ? [parentId] : undefined,
},
fields: "id",
supportsAllDrives: true,
});
return result.data.id;
return created.data.id;
}
export async function connectOrganizationDrive({ organization, code, storageType, sharedDriveId }) {
const client = oauthClient();
const { tokens } = await client.getToken(code);
if (!tokens.refresh_token) {
throw new HttpError(400, "Google did not return a refresh token. Reconnect and approve access again.");
export async function getApplicationRootId() {
if (!applicationRootId) {
applicationRootId = await findOrCreateFolder(config.GOOGLE_DRIVE_ROOT_FOLDER);
}
client.setCredentials(tokens);
const drive = google.drive({ version: "v3", auth: client });
const parentId = storageType === "SHARED_DRIVE" ? sharedDriveId : null;
if (storageType === "SHARED_DRIVE" && !parentId) {
throw new HttpError(400, "Shared Drive ID is required");
}
const existing = await prisma.googleDriveConnection.findUnique({
where: { organizationId: organization.id },
});
const sameStorage = existing &&
existing.storageType === storageType &&
(storageType !== "SHARED_DRIVE" || existing.sharedDriveId === sharedDriveId);
let rootFolderId = sameStorage ? existing.rootFolderId : null;
if (rootFolderId) {
try {
await drive.files.get({ fileId: rootFolderId, fields: "id", supportsAllDrives: true });
} catch {
throw new HttpError(409, "The connected Google account cannot access the existing organization root folder");
}
}
if (existing && !sameStorage) {
const [folderCount, fileCount] = await Promise.all([
prisma.folder.count({ where: { organizationId: organization.id } }),
prisma.fileAsset.count({ where: { organizationId: organization.id } }),
]);
if (folderCount || fileCount) {
throw new HttpError(409, "Storage type cannot be changed after organization content has been created");
}
}
const safeName = organization.name.trim().replace(/[\\/:*?"<>|]/g, "_");
rootFolderId ||= await createFolderWithDrive(drive, `${safeName} - Metatron Drive`, parentId);
const connection = await prisma.googleDriveConnection.upsert({
where: { organizationId: organization.id },
create: {
organizationId: organization.id,
tokenCiphertext: encryptToken(tokens),
storageType,
sharedDriveId: storageType === "SHARED_DRIVE" ? sharedDriveId : null,
rootFolderId,
scope: tokens.scope || DRIVE_SCOPE,
status: "CONNECTED",
},
update: {
tokenCiphertext: encryptToken(tokens),
storageType,
sharedDriveId: storageType === "SHARED_DRIVE" ? sharedDriveId : null,
rootFolderId,
scope: tokens.scope || DRIVE_SCOPE,
status: "CONNECTED",
connectedAt: new Date(),
return applicationRootId;
}
async function createFolder(name, parentId) {
const created = await getDrive().files.create({
requestBody: {
name,
mimeType: "application/vnd.google-apps.folder",
parents: parentId ? [parentId] : undefined,
},
fields: "id",
});
if (!sameStorage) {
await prisma.membership.updateMany({
where: { organizationId: organization.id },
data: { driveFolderId: null },
});
}
const approved = await prisma.membership.findMany({
where: { organizationId: organization.id, status: "APPROVED", driveFolderId: null },
select: { id: true },
});
for (const membership of approved) await ensureMembershipRoot(membership.id);
return connection;
return created.data.id;
}
export async function ensureMembershipRoot(membershipId) {
const membership = await prisma.membership.findUnique({
where: { id: membershipId },
include: { user: true, organization: { include: { driveConnection: true } } },
});
if (!membership || membership.status !== "APPROVED") throw new HttpError(403, "Membership is not approved");
if (membership.driveFolderId) return membership.driveFolderId;
const connection = membership.organization.driveConnection;
if (!connection?.rootFolderId || connection.status !== "CONNECTED") return null;
const { drive } = await driveForOrganization(membership.organizationId);
const safeName = membership.user.name.trim().replace(/[\\/:*?"<>|]/g, "_");
const driveFolderId = await createFolderWithDrive(
drive,
`${safeName} (${membership.user.username})`,
connection.rootFolderId,
);
await prisma.membership.update({ where: { id: membership.id }, data: { driveFolderId } });
return driveFolderId;
export async function createUserRootFolder(name, username) {
const root = await getApplicationRootId();
const safeName = String(name).trim().replace(/[\\/:*?"<>|]/g, "_");
return createFolder(`${safeName} (${username})`, root);
}
export async function createChildFolder(organizationId, name, parentDriveFolderId) {
const { drive } = await driveForOrganization(organizationId);
return createFolderWithDrive(drive, name, parentDriveFolderId);
export async function createChildFolder(name, parentDriveFolderId) {
return createFolder(name, parentDriveFolderId);
}
export async function uploadFile({ organizationId, filePath, originalName, mimeType, parentDriveFolderId }) {
const { drive } = await driveForOrganization(organizationId);
export async function uploadFile({ filePath, originalName, mimeType, parentDriveFolderId }) {
const drive = getDrive();
const result = await drive.files.create({
requestBody: { name: originalName, parents: [parentDriveFolderId] },
media: { mimeType, body: fs.createReadStream(filePath) },
fields: "id,name,mimeType,size,createdTime",
supportsAllDrives: true,
});
return result.data;
}
export async function streamFile(organizationId, driveFileId, response) {
const { drive } = await driveForOrganization(organizationId);
export async function streamFile(driveFileId, response) {
const drive = getDrive();
const result = await drive.files.get(
{ fileId: driveFileId, alt: "media", supportsAllDrives: true },
{ fileId: driveFileId, alt: "media" },
{ responseType: "stream" },
);
result.data.pipe(response);
}
export async function renameDriveItem(organizationId, driveFileId, name) {
const { drive } = await driveForOrganization(organizationId);
await drive.files.update({
fileId: driveFileId,
requestBody: { name },
supportsAllDrives: true,
});
export async function renameDriveItem(driveFileId, name) {
await getDrive().files.update({ fileId: driveFileId, requestBody: { name } });
}
export async function deleteDriveItem(organizationId, driveFileId) {
const { drive } = await driveForOrganization(organizationId);
await drive.files.delete({ fileId: driveFileId, supportsAllDrives: true });
export async function deleteDriveItem(driveFileId) {
await getDrive().files.delete({ fileId: driveFileId });
}

View File

@ -2,21 +2,6 @@ export function serializeFile(file) {
return { ...file, sizeBytes: Number(file.sizeBytes) };
}
export function publicMembership(membership) {
return {
id: membership.id,
role: membership.role,
status: membership.status,
rejectionReason: membership.rejectionReason,
organization: membership.organization ? {
id: membership.organization.id,
code: membership.organization.code,
name: membership.organization.name,
status: membership.organization.status,
} : undefined,
};
}
export function publicUser(user) {
return {
id: user.id,
@ -27,6 +12,5 @@ export function publicUser(user) {
gender: user.gender,
age: user.age,
createdAt: user.createdAt,
memberships: user.memberships?.map(publicMembership),
};
}

View File

@ -1,23 +0,0 @@
import crypto from "node:crypto";
import { config } from "../config.js";
const key = crypto.createHash("sha256").update(config.JWT_SECRET).digest();
export function encryptToken(value) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const encrypted = Buffer.concat([cipher.update(JSON.stringify(value), "utf8"), cipher.final()]);
return [iv, cipher.getAuthTag(), encrypted].map((part) => part.toString("base64url")).join(".");
}
export function decryptToken(value) {
const [ivValue, tagValue, encryptedValue] = String(value).split(".");
if (!ivValue || !tagValue || !encryptedValue) throw new Error("Invalid encrypted Google token");
const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(ivValue, "base64url"));
decipher.setAuthTag(Buffer.from(tagValue, "base64url"));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(encryptedValue, "base64url")),
decipher.final(),
]);
return JSON.parse(decrypted.toString("utf8"));
}