feat: Implement multi-tenant architecture with organization support

- Added organization model and membership management to support multi-tenancy.
- Updated file and folder routes to include organization context for ownership checks.
- Integrated Google Drive connection management per organization, ensuring proper authorization and folder structure.
- Enhanced user account deletion to prevent removal if the user owns organizations.
- Introduced middleware for organization validation and admin role checks.
- Created organization admin routes for managing memberships and retrieving statistics.
- Implemented token encryption/decryption for secure Google Drive token storage.
- Updated serializers to include organization data in user memberships.
- Added database migration for new organization and membership tables.
This commit is contained in:
MOHAN 2026-07-06 15:37:26 +05:30
parent 22863fc861
commit 6b105c07c4
13 changed files with 757 additions and 162 deletions

View File

@ -0,0 +1,78 @@
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,8 +1,3 @@
// 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 { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
@ -20,32 +15,82 @@ model User {
email String @unique email String @unique
gender String gender String
age Int age Int
driveFolderId String @unique
isSuspended Boolean @default(false) isSuspended Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
memberships Membership[]
folders Folder[] folders Folder[]
files FileAsset[] files FileAsset[]
@@index([isSuspended, createdAt]) @@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 { model Folder {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
driveFolderId String @unique driveFolderId String @unique
scopeKey String? @unique scopeKey String? @unique
userId String userId String
organizationId String
parentId String? parentId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) 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) parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: Cascade)
children Folder[] @relation("FolderTree") children Folder[] @relation("FolderTree")
files FileAsset[] files FileAsset[]
@@unique([userId, parentId, name]) @@unique([organizationId, userId, parentId, name])
@@index([userId, parentId]) @@index([organizationId, userId, parentId])
} }
model FileAsset { model FileAsset {
@ -55,10 +100,12 @@ model FileAsset {
mimeType String mimeType String
sizeBytes BigInt sizeBytes BigInt
userId String userId String
organizationId String
folderId String? folderId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) 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) folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
@@index([userId, folderId, createdAt]) @@index([organizationId, userId, folderId, createdAt])
} }

View File

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

View File

@ -0,0 +1,31 @@
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,7 +4,6 @@ import jwt from "jsonwebtoken";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "../db.js"; import { prisma } from "../db.js";
import { config } from "../config.js"; import { config } from "../config.js";
import { createUserRootFolder, deleteDriveItem } from "../services/googleDrive.js";
import { asyncHandler } from "../utils/asyncHandler.js"; import { asyncHandler } from "../utils/asyncHandler.js";
import { HttpError } from "../utils/httpError.js"; import { HttpError } from "../utils/httpError.js";
import { publicUser } from "../utils/serializers.js"; import { publicUser } from "../utils/serializers.js";
@ -12,7 +11,7 @@ import { requireAuth } from "../middleware/auth.js";
const router = Router(); const router = Router();
const usernameRule = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9._-]+$/); const usernameRule = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9._-]+$/);
const registerSchema = z.object({ const profileSchema = z.object({
username: usernameRule, username: usernameRule,
password: z.string().min(8).max(128), password: z.string().min(8).max(128),
name: z.string().trim().min(2).max(80), name: z.string().trim().min(2).max(80),
@ -21,46 +20,72 @@ const registerSchema = z.object({
gender: z.enum(["Male", "Female", "Non-binary", "Prefer not to say"]), gender: z.enum(["Male", "Female", "Non-binary", "Prefer not to say"]),
age: z.coerce.number().int().min(13).max(120), 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) }); const loginSchema = z.object({ username: usernameRule, password: z.string().min(1).max(128) });
function issueToken(userId) { function issueToken(userId) {
return jwt.sign({}, config.JWT_SECRET, { subject: userId, expiresIn: config.JWT_EXPIRES_IN }); 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) => { router.post("/register", asyncHandler(async (request, response) => {
const input = registerSchema.parse(request.body); 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({ const duplicate = await prisma.user.findFirst({
where: { OR: [{ username: input.username }, { email: input.email }, { phone: input.phone }] }, where: { OR: [{ username: input.username }, { email: input.email }, { phone: input.phone }] },
select: { id: true }, select: { id: true },
}); });
if (duplicate) throw new HttpError(409, "Username, email, or phone is already registered"); if (duplicate) throw new HttpError(409, "An account already exists. Sign in and join this organization instead.");
const { password, organizationCode, ...profile } = input;
let driveFolderId;
try {
driveFolderId = await createUserRootFolder(input.name, input.username);
const { password, ...profile } = input;
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { ...profile, passwordHash: await bcrypt.hash(password, 12), driveFolderId }, 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),
}); });
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) => { router.post("/login", asyncHandler(async (request, response) => {
const input = loginSchema.parse(request.body); const input = loginSchema.parse(request.body);
const user = await prisma.user.findUnique({ where: { username: input.username } }); const user = await prisma.user.findUnique({ where: { username: input.username }, include: membershipInclude() });
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."); 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) }); response.json({ token: issueToken(user.id), user: publicUser(user) });
})); }));
router.get("/me", requireAuth, (request, response) => { router.get("/me", requireAuth, asyncHandler(async (request, response) => {
response.json({ user: publicUser(request.user) }); 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 });
}));
export default router; export default router;

View File

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

View File

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

View File

@ -0,0 +1,98 @@
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;

144
src/routes/organizations.js Normal file
View File

@ -0,0 +1,144 @@
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,6 +13,13 @@ router.delete("/account", asyncHandler(async (request, response) => {
throw new HttpError(401, "Password confirmation is incorrect"); 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. // Relations cascade in PostgreSQL. Google Drive is deliberately untouched.
await prisma.user.delete({ where: { id: request.user.id } }); await prisma.user.delete({ where: { id: request.user.id } });
response.status(204).end(); response.status(204).end();

View File

@ -1,110 +1,210 @@
import fs from "node:fs"; import fs from "node:fs";
import { google } from "googleapis"; import { google } from "googleapis";
import { config } from "../config.js"; import { config } from "../config.js";
import { prisma } from "../db.js";
import { HttpError } from "../utils/httpError.js";
import { decryptToken, encryptToken } from "../utils/tokenVault.js";
let driveClient; const DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
let applicationRootId;
function loadJson(filePath) { function loadOAuthDefinition() {
return JSON.parse(fs.readFileSync(filePath, "utf8")); 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;
} }
export function getDrive() { function oauthClient() {
if (driveClient) return driveClient; const definition = loadOAuthDefinition();
const credentials = loadJson(config.credentialsPath); return new google.auth.OAuth2(
const client = credentials.installed || credentials.web; definition.client_id,
if (!client) throw new Error("Invalid Google OAuth credentials file"); definition.client_secret,
const oauth = new google.auth.OAuth2( definition.redirect_uris?.[0],
client.client_id,
client.client_secret,
client.redirect_uris?.[0],
); );
oauth.setCredentials(loadJson(config.tokenPath)); }
oauth.on("tokens", (tokens) => {
const existing = loadJson(config.tokenPath); export function googleAuthorizationUrl(state) {
fs.writeFileSync(config.tokenPath, JSON.stringify({ ...existing, ...tokens }, null, 2)); return oauthClient().generateAuthUrl({
access_type: "offline",
prompt: "consent",
include_granted_scopes: true,
scope: [DRIVE_SCOPE],
state,
}); });
driveClient = google.drive({ version: "v3", auth: oauth });
return driveClient;
} }
function escapeQuery(value) { export function authorizationInput(value) {
return String(value).replaceAll("\\", "\\\\").replaceAll("'", "\\'"); 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 };
}
} }
export async function findOrCreateFolder(name, parentId) { async function driveForOrganization(organizationId) {
const drive = getDrive(); const connection = await prisma.googleDriveConnection.findUnique({ where: { organizationId } });
const query = [ if (!connection || connection.status !== "CONNECTED") {
"mimeType='application/vnd.google-apps.folder'", throw new HttpError(409, "This organization has not connected Google Drive");
`name='${escapeQuery(name)}'`, }
"trashed=false", const client = oauthClient();
parentId ? `'${parentId}' in parents` : null, let stored = decryptToken(connection.tokenCiphertext);
].filter(Boolean).join(" and "); client.setCredentials(stored);
const existing = await drive.files.list({ q: query, fields: "files(id,name)", pageSize: 1 }); client.on("tokens", async (tokens) => {
if (existing.data.files?.length) return existing.data.files[0].id; stored = { ...stored, ...tokens };
const created = await drive.files.create({ 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({
requestBody: { requestBody: {
name, name,
mimeType: "application/vnd.google-apps.folder", mimeType: "application/vnd.google-apps.folder",
parents: parentId ? [parentId] : undefined, parents: parentId ? [parentId] : undefined,
}, },
fields: "id", fields: "id",
supportsAllDrives: true,
}); });
return created.data.id; return result.data.id;
} }
export async function getApplicationRootId() { export async function connectOrganizationDrive({ organization, code, storageType, sharedDriveId }) {
if (!applicationRootId) { const client = oauthClient();
applicationRootId = await findOrCreateFolder(config.GOOGLE_DRIVE_ROOT_FOLDER); 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.");
} }
return applicationRootId; 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({
async function createFolder(name, parentId) { where: { organizationId: organization.id },
const created = await getDrive().files.create({ });
requestBody: { const sameStorage = existing &&
name, existing.storageType === storageType &&
mimeType: "application/vnd.google-apps.folder", (storageType !== "SHARED_DRIVE" || existing.sharedDriveId === sharedDriveId);
parents: parentId ? [parentId] : undefined, 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(),
}, },
fields: "id",
}); });
return created.data.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;
} }
export async function createUserRootFolder(name, username) { export async function ensureMembershipRoot(membershipId) {
const root = await getApplicationRootId(); const membership = await prisma.membership.findUnique({
const safeName = String(name).trim().replace(/[\\/:*?"<>|]/g, "_"); where: { id: membershipId },
return createFolder(`${safeName} (${username})`, root); 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 createChildFolder(name, parentDriveFolderId) { export async function createChildFolder(organizationId, name, parentDriveFolderId) {
return createFolder(name, parentDriveFolderId); const { drive } = await driveForOrganization(organizationId);
return createFolderWithDrive(drive, name, parentDriveFolderId);
} }
export async function uploadFile({ filePath, originalName, mimeType, parentDriveFolderId }) { export async function uploadFile({ organizationId, filePath, originalName, mimeType, parentDriveFolderId }) {
const drive = getDrive(); const { drive } = await driveForOrganization(organizationId);
const result = await drive.files.create({ const result = await drive.files.create({
requestBody: { name: originalName, parents: [parentDriveFolderId] }, requestBody: { name: originalName, parents: [parentDriveFolderId] },
media: { mimeType, body: fs.createReadStream(filePath) }, media: { mimeType, body: fs.createReadStream(filePath) },
fields: "id,name,mimeType,size,createdTime", fields: "id,name,mimeType,size,createdTime",
supportsAllDrives: true,
}); });
return result.data; return result.data;
} }
export async function streamFile(driveFileId, response) { export async function streamFile(organizationId, driveFileId, response) {
const drive = getDrive(); const { drive } = await driveForOrganization(organizationId);
const result = await drive.files.get( const result = await drive.files.get(
{ fileId: driveFileId, alt: "media" }, { fileId: driveFileId, alt: "media", supportsAllDrives: true },
{ responseType: "stream" }, { responseType: "stream" },
); );
result.data.pipe(response); result.data.pipe(response);
} }
export async function renameDriveItem(driveFileId, name) { export async function renameDriveItem(organizationId, driveFileId, name) {
await getDrive().files.update({ fileId: driveFileId, requestBody: { name } }); const { drive } = await driveForOrganization(organizationId);
await drive.files.update({
fileId: driveFileId,
requestBody: { name },
supportsAllDrives: true,
});
} }
export async function deleteDriveItem(driveFileId) { export async function deleteDriveItem(organizationId, driveFileId) {
await getDrive().files.delete({ fileId: driveFileId }); const { drive } = await driveForOrganization(organizationId);
await drive.files.delete({ fileId: driveFileId, supportsAllDrives: true });
} }

View File

@ -2,6 +2,21 @@ export function serializeFile(file) {
return { ...file, sizeBytes: Number(file.sizeBytes) }; 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) { export function publicUser(user) {
return { return {
id: user.id, id: user.id,
@ -12,5 +27,6 @@ export function publicUser(user) {
gender: user.gender, gender: user.gender,
age: user.age, age: user.age,
createdAt: user.createdAt, createdAt: user.createdAt,
memberships: user.memberships?.map(publicMembership),
}; };
} }

23
src/utils/tokenVault.js Normal file
View File

@ -0,0 +1,23 @@
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"));
}