Compare commits

..

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

13 changed files with 157 additions and 752 deletions

View File

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

View File

@ -1,3 +1,8 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Get a free hosted Postgres database in seconds: `npx create-db`
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
@ -15,82 +20,32 @@ 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([organizationId, userId, parentId, name]) @@unique([userId, parentId, name])
@@index([organizationId, userId, parentId]) @@index([userId, parentId])
} }
model FileAsset { model FileAsset {
@ -100,12 +55,10 @@ 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([organizationId, userId, folderId, createdAt]) @@index([userId, folderId, createdAt])
} }

View File

@ -5,15 +5,12 @@ import morgan from "morgan";
import { rateLimit } from "express-rate-limit"; import { 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");
@ -33,11 +30,9 @@ const uploadLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 500, standard
const adminLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 50, standardHeaders: "draft-8", legacyHeaders: false }); 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/organizations", authLimiter, organizationRoutes); app.use("/api/folders", requireAuth, folderRoutes);
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, requireOrganization, fileRoutes); app.use("/api/files", requireAuth, fileRoutes);
app.use("/api/retention", requireAuth, retentionRoutes); app.use("/api/retention", requireAuth, retentionRoutes);
app.use(notFound); app.use(notFound);

View File

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

View File

@ -4,6 +4,7 @@ import jwt from "jsonwebtoken";
import { z } from "zod"; import { 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";
@ -11,7 +12,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 profileSchema = z.object({ const registerSchema = 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),
@ -20,72 +21,46 @@ const profileSchema = 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, "An account already exists. Sign in and join this organization instead."); if (duplicate) throw new HttpError(409, "Username, email, or phone is already registered");
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: { data: { ...profile, passwordHash: await bcrypt.hash(password, 12), driveFolderId },
...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 }, include: membershipInclude() }); const user = await prisma.user.findUnique({ where: { username: input.username } });
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, asyncHandler(async (request, response) => { router.get("/me", requireAuth, (request, response) => {
const user = await prisma.user.findUnique({ where: { id: request.user.id }, include: membershipInclude() }); response.json({ user: publicUser(request.user) });
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,14 +6,12 @@ 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, ensureMembershipRoot, streamFile, uploadFile } from "../services/googleDrive.js"; import { deleteDriveItem, streamFile, uploadFile } from "../services/googleDrive.js";
const router = Router(); const router = Router();
async function ownedFile(request, id) { async function ownedFile(userId, id) {
const file = await prisma.fileAsset.findFirst({ const file = await prisma.fileAsset.findFirst({ where: { id, userId } });
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;
} }
@ -21,14 +19,11 @@ async function ownedFile(request, 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({ const folder = await prisma.folder.findFirst({ where: { id: folderId, userId: request.user.id }, select: { id: true } });
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, organizationId: request.organization.id, folderId }, where: { userId: request.user.id, folderId },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
response.json({ files: files.map(serializeFile) }); response.json({ files: files.map(serializeFile) });
@ -39,17 +34,13 @@ 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 = await ensureMembershipRoot(request.membership.id); let parentDriveFolderId = request.user.driveFolderId;
if (!parentDriveFolderId) throw new HttpError(409, "Organization administrator must connect Google Drive first");
if (folderId) { if (folderId) {
const folder = await prisma.folder.findFirst({ const folder = await prisma.folder.findFirst({ where: { id: folderId, userId: request.user.id } });
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",
@ -62,13 +53,12 @@ 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(request.organization.id, uploaded.id).catch(() => {}); if (uploaded?.id) await deleteDriveItem(uploaded.id).catch(() => {});
throw error; throw error;
} finally { } finally {
await fs.unlink(request.file.path).catch(() => {}); await fs.unlink(request.file.path).catch(() => {});
@ -76,15 +66,15 @@ router.post("/upload", uploadSingle, asyncHandler(async (request, response) => {
})); }));
router.get("/:id/content", asyncHandler(async (request, response) => { router.get("/:id/content", asyncHandler(async (request, response) => {
const file = await ownedFile(request, z.string().uuid().parse(request.params.id)); const file = await ownedFile(request.user.id, z.string().uuid().parse(request.params.id));
response.setHeader("Content-Type", file.mimeType); response.setHeader("Content-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(request.organization.id, file.driveFileId, response); await streamFile(file.driveFileId, response);
})); }));
router.delete("/:id", asyncHandler(async (request, response) => { router.delete("/:id", asyncHandler(async (request, response) => {
const file = await ownedFile(request, z.string().uuid().parse(request.params.id)); const file = await ownedFile(request.user.id, z.string().uuid().parse(request.params.id));
await deleteDriveItem(request.organization.id, file.driveFileId); await deleteDriveItem(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,29 +3,27 @@ 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, ensureMembershipRoot, renameDriveItem } from "../services/googleDrive.js"; import { createChildFolder, deleteDriveItem, 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(organizationId, userId, parentId, name) { function folderScope(userId, parentId, name) {
return `${organizationId}:${userId}:${parentId || "root"}:${name.trim().toLowerCase()}`; return `${userId}:${parentId || "root"}:${name.trim().toLowerCase()}`;
} }
async function ownedFolder(request, id) { async function ownedFolder(userId, id) {
const folder = await prisma.folder.findFirst({ const folder = await prisma.folder.findFirst({ where: { id, userId } });
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, parentId); if (parentId) await ownedFolder(request.user.id, parentId);
const folders = await prisma.folder.findMany({ const folders = await prisma.folder.findMany({
where: { userId: request.user.id, organizationId: request.organization.id, parentId }, where: { userId: request.user.id, parentId },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
include: { _count: { select: { children: true, files: true } } }, include: { _count: { select: { children: true, files: true } } },
}); });
@ -34,50 +32,41 @@ 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, input.parentId) : null; const parent = input.parentId ? await ownedFolder(request.user.id, input.parentId) : null;
const scopeKey = folderScope(request.organization.id, request.user.id, input.parentId, input.name); const scopeKey = folderScope(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 memberRoot = parent?.driveFolderId || await ensureMembershipRoot(request.membership.id); const driveFolderId = await createChildFolder(input.name, parent?.driveFolderId || request.user.driveFolderId);
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: { data: { name: input.name, parentId: input.parentId || null, driveFolderId, scopeKey, userId: request.user.id },
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(request.organization.id, driveFolderId).catch(() => {}); await deleteDriveItem(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, z.string().uuid().parse(request.params.id)); const folder = await ownedFolder(request.user.id, z.string().uuid().parse(request.params.id));
const { name } = z.object({ name: folderName }).parse(request.body); const { name } = z.object({ name: folderName }).parse(request.body);
const scopeKey = folderScope(request.organization.id, request.user.id, folder.parentId, name); const scopeKey = folderScope(request.user.id, folder.parentId, name);
const duplicate = await prisma.folder.findFirst({ where: { scopeKey, NOT: { id: folder.id } }, select: { id: true } }); 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(request.organization.id, folder.driveFolderId, name); await renameDriveItem(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, z.string().uuid().parse(request.params.id)); const folder = await ownedFolder(request.user.id, z.string().uuid().parse(request.params.id));
const counts = await prisma.folder.findUnique({ 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(request.organization.id, folder.driveFolderId); await deleteDriveItem(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

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

View File

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

View File

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

View File

@ -2,21 +2,6 @@ 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,
@ -27,6 +12,5 @@ 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),
}; };
} }

View File

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