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:
parent
22863fc861
commit
6b105c07c4
@ -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;
|
||||
@ -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 {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
@ -20,32 +15,82 @@ model User {
|
||||
email String @unique
|
||||
gender String
|
||||
age Int
|
||||
driveFolderId String @unique
|
||||
isSuspended Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
memberships Membership[]
|
||||
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[]
|
||||
|
||||
@@unique([userId, parentId, name])
|
||||
@@index([userId, parentId])
|
||||
@@unique([organizationId, userId, parentId, name])
|
||||
@@index([organizationId, userId, parentId])
|
||||
}
|
||||
|
||||
model FileAsset {
|
||||
@ -55,10 +100,12 @@ model FileAsset {
|
||||
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)
|
||||
|
||||
@@index([userId, folderId, createdAt])
|
||||
@@index([organizationId, userId, folderId, createdAt])
|
||||
}
|
||||
|
||||
@ -5,12 +5,15 @@ 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");
|
||||
@ -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 });
|
||||
app.use("/api/auth", authLimiter, authRoutes);
|
||||
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", requireAuth, fileRoutes);
|
||||
app.use("/api/files", requireAuth, requireOrganization, fileRoutes);
|
||||
app.use("/api/retention", requireAuth, retentionRoutes);
|
||||
|
||||
app.use(notFound);
|
||||
|
||||
31
src/middleware/organization.js
Normal file
31
src/middleware/organization.js
Normal 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();
|
||||
}
|
||||
@ -4,7 +4,6 @@ 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";
|
||||
@ -12,7 +11,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 registerSchema = z.object({
|
||||
const profileSchema = z.object({
|
||||
username: usernameRule,
|
||||
password: z.string().min(8).max(128),
|
||||
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"]),
|
||||
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, "Username, email, or phone is already registered");
|
||||
|
||||
let driveFolderId;
|
||||
try {
|
||||
driveFolderId = await createUserRootFolder(input.name, input.username);
|
||||
const { password, ...profile } = input;
|
||||
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), 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) => {
|
||||
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))) {
|
||||
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, (request, response) => {
|
||||
response.json({ user: publicUser(request.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 });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
||||
@ -6,12 +6,14 @@ 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, streamFile, uploadFile } from "../services/googleDrive.js";
|
||||
import { deleteDriveItem, ensureMembershipRoot, streamFile, uploadFile } from "../services/googleDrive.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
async function ownedFile(userId, id) {
|
||||
const file = await prisma.fileAsset.findFirst({ where: { id, userId } });
|
||||
async function ownedFile(request, id) {
|
||||
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");
|
||||
return file;
|
||||
}
|
||||
@ -19,11 +21,14 @@ async function ownedFile(userId, 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 }, 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");
|
||||
}
|
||||
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" },
|
||||
});
|
||||
response.json({ files: files.map(serializeFile) });
|
||||
@ -34,13 +39,17 @@ 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 = 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) {
|
||||
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");
|
||||
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",
|
||||
@ -53,12 +62,13 @@ 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(uploaded.id).catch(() => {});
|
||||
if (uploaded?.id) await deleteDriveItem(request.organization.id, uploaded.id).catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
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) => {
|
||||
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-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) => {
|
||||
const file = await ownedFile(request.user.id, z.string().uuid().parse(request.params.id));
|
||||
await deleteDriveItem(file.driveFileId);
|
||||
const file = await ownedFile(request, z.string().uuid().parse(request.params.id));
|
||||
await deleteDriveItem(request.organization.id, file.driveFileId);
|
||||
await prisma.fileAsset.delete({ where: { id: file.id } });
|
||||
response.status(204).end();
|
||||
}));
|
||||
|
||||
@ -3,27 +3,29 @@ import { z } from "zod";
|
||||
import { prisma } from "../db.js";
|
||||
import { asyncHandler } from "../utils/asyncHandler.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 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(userId, parentId, name) {
|
||||
return `${userId}:${parentId || "root"}:${name.trim().toLowerCase()}`;
|
||||
function folderScope(organizationId, userId, parentId, name) {
|
||||
return `${organizationId}:${userId}:${parentId || "root"}:${name.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
async function ownedFolder(userId, id) {
|
||||
const folder = await prisma.folder.findFirst({ where: { id, userId } });
|
||||
async function ownedFolder(request, id) {
|
||||
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");
|
||||
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.user.id, parentId);
|
||||
if (parentId) await ownedFolder(request, parentId);
|
||||
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" },
|
||||
include: { _count: { select: { children: true, files: true } } },
|
||||
});
|
||||
@ -32,41 +34,50 @@ 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.user.id, input.parentId) : null;
|
||||
const scopeKey = folderScope(request.user.id, input.parentId, input.name);
|
||||
const parent = input.parentId ? await ownedFolder(request, input.parentId) : null;
|
||||
const scopeKey = folderScope(request.organization.id, 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 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 {
|
||||
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 });
|
||||
} catch (error) {
|
||||
await deleteDriveItem(driveFolderId).catch(() => {});
|
||||
await deleteDriveItem(request.organization.id, driveFolderId).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
|
||||
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 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 } });
|
||||
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 } });
|
||||
response.json({ folder: updated });
|
||||
}));
|
||||
|
||||
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({
|
||||
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(folder.driveFolderId);
|
||||
await deleteDriveItem(request.organization.id, folder.driveFolderId);
|
||||
await prisma.folder.delete({ where: { id: folder.id } });
|
||||
response.status(204).end();
|
||||
}));
|
||||
|
||||
98
src/routes/organizationAdmin.js
Normal file
98
src/routes/organizationAdmin.js
Normal 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
144
src/routes/organizations.js
Normal 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;
|
||||
@ -13,6 +13,13 @@ 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();
|
||||
|
||||
@ -1,110 +1,210 @@
|
||||
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";
|
||||
|
||||
let driveClient;
|
||||
let applicationRootId;
|
||||
const DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
||||
|
||||
function loadJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
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;
|
||||
}
|
||||
|
||||
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],
|
||||
function oauthClient() {
|
||||
const definition = loadOAuthDefinition();
|
||||
return new google.auth.OAuth2(
|
||||
definition.client_id,
|
||||
definition.client_secret,
|
||||
definition.redirect_uris?.[0],
|
||||
);
|
||||
oauth.setCredentials(loadJson(config.tokenPath));
|
||||
oauth.on("tokens", (tokens) => {
|
||||
const existing = loadJson(config.tokenPath);
|
||||
fs.writeFileSync(config.tokenPath, JSON.stringify({ ...existing, ...tokens }, null, 2));
|
||||
}
|
||||
|
||||
export function googleAuthorizationUrl(state) {
|
||||
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) {
|
||||
return String(value).replaceAll("\\", "\\\\").replaceAll("'", "\\'");
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
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({
|
||||
requestBody: {
|
||||
name,
|
||||
mimeType: "application/vnd.google-apps.folder",
|
||||
parents: parentId ? [parentId] : undefined,
|
||||
},
|
||||
fields: "id",
|
||||
supportsAllDrives: true,
|
||||
});
|
||||
return created.data.id;
|
||||
return result.data.id;
|
||||
}
|
||||
|
||||
export async function getApplicationRootId() {
|
||||
if (!applicationRootId) {
|
||||
applicationRootId = await findOrCreateFolder(config.GOOGLE_DRIVE_ROOT_FOLDER);
|
||||
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.");
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
async function createFolder(name, parentId) {
|
||||
const created = await getDrive().files.create({
|
||||
requestBody: {
|
||||
name,
|
||||
mimeType: "application/vnd.google-apps.folder",
|
||||
parents: parentId ? [parentId] : undefined,
|
||||
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(),
|
||||
},
|
||||
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) {
|
||||
const root = await getApplicationRootId();
|
||||
const safeName = String(name).trim().replace(/[\\/:*?"<>|]/g, "_");
|
||||
return createFolder(`${safeName} (${username})`, root);
|
||||
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 createChildFolder(name, parentDriveFolderId) {
|
||||
return createFolder(name, parentDriveFolderId);
|
||||
export async function createChildFolder(organizationId, name, parentDriveFolderId) {
|
||||
const { drive } = await driveForOrganization(organizationId);
|
||||
return createFolderWithDrive(drive, name, parentDriveFolderId);
|
||||
}
|
||||
|
||||
export async function uploadFile({ filePath, originalName, mimeType, parentDriveFolderId }) {
|
||||
const drive = getDrive();
|
||||
export async function uploadFile({ organizationId, filePath, originalName, mimeType, parentDriveFolderId }) {
|
||||
const { drive } = await driveForOrganization(organizationId);
|
||||
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(driveFileId, response) {
|
||||
const drive = getDrive();
|
||||
export async function streamFile(organizationId, driveFileId, response) {
|
||||
const { drive } = await driveForOrganization(organizationId);
|
||||
const result = await drive.files.get(
|
||||
{ fileId: driveFileId, alt: "media" },
|
||||
{ fileId: driveFileId, alt: "media", supportsAllDrives: true },
|
||||
{ responseType: "stream" },
|
||||
);
|
||||
result.data.pipe(response);
|
||||
}
|
||||
|
||||
export async function renameDriveItem(driveFileId, name) {
|
||||
await getDrive().files.update({ fileId: driveFileId, requestBody: { name } });
|
||||
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 deleteDriveItem(driveFileId) {
|
||||
await getDrive().files.delete({ fileId: driveFileId });
|
||||
export async function deleteDriveItem(organizationId, driveFileId) {
|
||||
const { drive } = await driveForOrganization(organizationId);
|
||||
await drive.files.delete({ fileId: driveFileId, supportsAllDrives: true });
|
||||
}
|
||||
|
||||
@ -2,6 +2,21 @@ 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,
|
||||
@ -12,5 +27,6 @@ export function publicUser(user) {
|
||||
gender: user.gender,
|
||||
age: user.age,
|
||||
createdAt: user.createdAt,
|
||||
memberships: user.memberships?.map(publicMembership),
|
||||
};
|
||||
}
|
||||
|
||||
23
src/utils/tokenVault.js
Normal file
23
src/utils/tokenVault.js
Normal 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"));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user