This commit is contained in:
MOHAN 2026-06-25 00:19:26 +05:30
commit d8a26f3604
29 changed files with 4448 additions and 0 deletions

10
.env.example Normal file
View File

@ -0,0 +1,10 @@
NODE_ENV=development
PORT=4100
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/whats_drive?schema=public
JWT_SECRET=replace-with-at-least-32-random-characters
JWT_EXPIRES_IN=7d
CORS_ORIGINS=*
MAX_UPLOAD_MB=250
GOOGLE_CREDENTIALS=./credentials.json
GOOGLE_TOKEN=./token.json
GOOGLE_DRIVE_ROOT_FOLDER=Whats-Drive-SaaS

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules
# Keep environment variables out of version control
.env
uploads/
generated/
credentials.json
token.json
/generated/prisma

0
api-local.stderr.log Normal file
View File

3
api-local.stdout.log Normal file
View File

@ -0,0 +1,3 @@
Whats Drive API listening on http://0.0.0.0:4100
GET /health 200 2.079 ms - 43
GET /health 200 0.841 ms - 43

0
api-test.stderr.log Normal file
View File

8
api-test.stdout.log Normal file
View File

@ -0,0 +1,8 @@
Whats Drive API listening on http://0.0.0.0:4100
GET /health 200 2.211 ms - 43
GET /health 200 0.506 ms - 43
POST /api/auth/register 201 4152.821 ms - 445
POST /api/files/upload 201 14743.616 ms - 299
DELETE /api/retention/account 401 277.713 ms - 46
DELETE /api/retention/account 204 346.733 ms - -
GET /api/auth/me 401 2.865 ms - 36

3658
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "backend",
"version": "1.0.0",
"description": "Private organizational Google Drive upload API for Whats Drive",
"main": "src/server.js",
"type": "module",
"scripts": {
"dev": "nodemon src/server.js",
"start": "node src/server.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"google:auth": "node scripts/google-auth.js",
"check": "node --check src/server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"googleapis": "^173.0.0",
"helmet": "^8.2.0",
"jsonwebtoken": "^9.0.3",
"morgan": "^1.11.0",
"multer": "^2.2.0",
"pg": "^8.22.0",
"zod": "^4.4.3"
},
"devDependencies": {
"nodemon": "^3.1.14",
"prisma": "^7.8.0"
},
"overrides": {
"@hono/node-server": "^1.19.13"
}
}

8
prisma.config.js Normal file
View File

@ -0,0 +1,8 @@
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: { path: "prisma/migrations" },
datasource: { url: process.env.DATABASE_URL },
});

View File

@ -0,0 +1,82 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"email" TEXT NOT NULL,
"gender" TEXT NOT NULL,
"age" INTEGER NOT NULL,
"driveFolderId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Folder" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"driveFolderId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"parentId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FileAsset" (
"id" TEXT NOT NULL,
"originalName" TEXT NOT NULL,
"driveFileId" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"sizeBytes" BIGINT NOT NULL,
"userId" TEXT NOT NULL,
"folderId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FileAsset_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_driveFolderId_key" ON "User"("driveFolderId");
-- CreateIndex
CREATE UNIQUE INDEX "Folder_driveFolderId_key" ON "Folder"("driveFolderId");
-- CreateIndex
CREATE INDEX "Folder_userId_parentId_idx" ON "Folder"("userId", "parentId");
-- CreateIndex
CREATE UNIQUE INDEX "Folder_userId_parentId_name_key" ON "Folder"("userId", "parentId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "FileAsset_driveFileId_key" ON "FileAsset"("driveFileId");
-- CreateIndex
CREATE INDEX "FileAsset_userId_folderId_createdAt_idx" ON "FileAsset"("userId", "folderId", "createdAt");
-- AddForeignKey
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Folder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FileAsset" ADD CONSTRAINT "FileAsset_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FileAsset" ADD CONSTRAINT "FileAsset_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
ALTER TABLE "Folder" ADD COLUMN "scopeKey" TEXT;
CREATE UNIQUE INDEX "Folder_scopeKey_key" ON "Folder"("scopeKey");

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

61
prisma/schema.prisma Normal file
View File

@ -0,0 +1,61 @@
// 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"
}
datasource db {
provider = "postgresql"
}
model User {
id String @id @default(uuid())
username String @unique
passwordHash String
name String
phone String @unique
email String @unique
gender String
age Int
driveFolderId String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
folders Folder[]
files FileAsset[]
}
model Folder {
id String @id @default(uuid())
name String
driveFolderId String @unique
scopeKey String? @unique
userId String
parentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: Cascade)
children Folder[] @relation("FolderTree")
files FileAsset[]
@@unique([userId, parentId, name])
@@index([userId, parentId])
}
model FileAsset {
id String @id @default(uuid())
originalName String
driveFileId String @unique
mimeType String
sizeBytes BigInt
userId String
folderId String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
@@index([userId, folderId, createdAt])
}

24
scripts/google-auth.js Normal file
View File

@ -0,0 +1,24 @@
import fs from "node:fs";
import readline from "node:readline";
import path from "node:path";
import { google } from "googleapis";
import { config } from "../src/config.js";
const credentials = JSON.parse(fs.readFileSync(config.credentialsPath, "utf8"));
const client = credentials.installed || credentials.web;
if (!client) throw new Error("Expected an installed or web OAuth client");
const oauth = new google.auth.OAuth2(client.client_id, client.client_secret, client.redirect_uris?.[0]);
const url = oauth.generateAuthUrl({
access_type: "offline",
prompt: "consent",
scope: ["https://www.googleapis.com/auth/drive.file"],
});
console.log(`Open this URL and approve access:\n\n${url}\n`);
const terminal = readline.createInterface({ input: process.stdin, output: process.stdout });
terminal.question("Paste the returned authorization code: ", async (code) => {
terminal.close();
const { tokens } = await oauth.getToken(code.trim());
fs.mkdirSync(path.dirname(config.tokenPath), { recursive: true });
fs.writeFileSync(config.tokenPath, JSON.stringify(tokens, null, 2));
console.log(`Google token saved to ${config.tokenPath}`);
});

36
src/app.js Normal file
View File

@ -0,0 +1,36 @@
import express from "express";
import cors from "cors";
import helmet from "helmet";
import morgan from "morgan";
import { rateLimit } from "express-rate-limit";
import { config } from "./config.js";
import { requireAuth } from "./middleware/auth.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";
export const app = express();
app.disable("x-powered-by");
app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
app.use(cors({
origin: config.corsOrigins === "*" ? true : config.corsOrigins,
credentials: config.corsOrigins !== "*",
}));
app.use(express.json({ limit: "1mb" }));
app.use(express.urlencoded({ extended: false, limit: "1mb" }));
app.use(morgan(config.NODE_ENV === "production" ? "combined" : "dev"));
app.get("/health", (request, response) => response.json({ status: "ok", service: "whats-drive-api" }));
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 100, standardHeaders: "draft-8", legacyHeaders: false });
const uploadLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 500, standardHeaders: "draft-8", legacyHeaders: false });
app.use("/api/auth", authLimiter, authRoutes);
app.use("/api/folders", requireAuth, folderRoutes);
app.use("/api/files/upload", uploadLimiter);
app.use("/api/files", requireAuth, fileRoutes);
app.use("/api/retention", requireAuth, retentionRoutes);
app.use(notFound);
app.use(errorHandler);

35
src/config.js Normal file
View File

@ -0,0 +1,35 @@
import "dotenv/config";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
const schema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(4100),
DATABASE_URL: z.string().min(1),
JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default("7d"),
CORS_ORIGINS: z.string().default("*"),
MAX_UPLOAD_MB: z.coerce.number().positive().default(250),
GOOGLE_CREDENTIALS: z.string().default("./credentials.json"),
GOOGLE_TOKEN: z.string().default("./token.json"),
GOOGLE_DRIVE_ROOT_FOLDER: z.string().min(1).default("Whats-Drive-SaaS"),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid environment configuration", parsed.error.flatten().fieldErrors);
process.exit(1);
}
const currentDir = path.dirname(fileURLToPath(import.meta.url));
export const projectRoot = path.resolve(currentDir, "..");
export const config = {
...parsed.data,
credentialsPath: path.resolve(projectRoot, parsed.data.GOOGLE_CREDENTIALS),
tokenPath: path.resolve(projectRoot, parsed.data.GOOGLE_TOKEN),
uploadDirectory: path.resolve(projectRoot, "uploads", "tmp"),
corsOrigins: parsed.data.CORS_ORIGINS === "*"
? "*"
: parsed.data.CORS_ORIGINS.split(",").map((value) => value.trim()).filter(Boolean),
};

6
src/db.js Normal file
View File

@ -0,0 +1,6 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@prisma/client";
import { config } from "./config.js";
const adapter = new PrismaPg({ connectionString: config.DATABASE_URL });
export const prisma = new PrismaClient({ adapter });

19
src/middleware/auth.js Normal file
View File

@ -0,0 +1,19 @@
import jwt from "jsonwebtoken";
import { config } from "../config.js";
import { prisma } from "../db.js";
export async function requireAuth(request, response, next) {
try {
const [scheme, token] = String(request.headers.authorization || "").split(" ");
if (scheme !== "Bearer" || !token) {
return response.status(401).json({ error: "Authentication required" });
}
const payload = jwt.verify(token, config.JWT_SECRET);
const user = await prisma.user.findUnique({ where: { id: payload.sub } });
if (!user) return response.status(401).json({ error: "Account no longer exists" });
request.user = user;
next();
} catch {
response.status(401).json({ error: "Invalid or expired session" });
}
}

View File

@ -0,0 +1,25 @@
import multer from "multer";
import { ZodError } from "zod";
import { HttpError } from "../utils/httpError.js";
export function notFound(request, response) {
response.status(404).json({ error: "Route not found" });
}
export function errorHandler(error, request, response, next) {
if (response.headersSent) return next(error);
if (error instanceof ZodError) {
return response.status(400).json({ error: "Invalid request", details: error.flatten().fieldErrors });
}
if (error instanceof multer.MulterError) {
return response.status(error.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({ error: error.message });
}
if (error instanceof HttpError) {
return response.status(error.status).json({ error: error.message, details: error.details });
}
if (error?.code === "P2002") {
return response.status(409).json({ error: "That value is already in use" });
}
console.error(error);
response.status(500).json({ error: "Internal server error" });
}

20
src/middleware/upload.js Normal file
View File

@ -0,0 +1,20 @@
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import multer from "multer";
import { config } from "../config.js";
fs.mkdirSync(config.uploadDirectory, { recursive: true });
const storage = multer.diskStorage({
destination: config.uploadDirectory,
filename: (request, file, callback) => {
const extension = path.extname(file.originalname).slice(0, 20);
callback(null, `${Date.now()}-${crypto.randomUUID()}${extension}`);
},
});
export const uploadSingle = multer({
storage,
limits: { fileSize: config.MAX_UPLOAD_MB * 1024 * 1024, files: 1 },
}).single("file");

65
src/routes/auth.js Normal file
View File

@ -0,0 +1,65 @@
import { Router } from "express";
import bcrypt from "bcryptjs";
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";
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({
username: usernameRule,
password: z.string().min(8).max(128),
name: z.string().trim().min(2).max(80),
phone: z.string().trim().min(7).max(20),
email: z.string().trim().toLowerCase().email().max(160),
gender: z.enum(["Male", "Female", "Non-binary", "Prefer not to say"]),
age: z.coerce.number().int().min(13).max(120),
});
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 });
}
router.post("/register", asyncHandler(async (request, response) => {
const input = registerSchema.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");
let driveFolderId;
try {
driveFolderId = await createUserRootFolder(input.name, input.username);
const { password, ...profile } = input;
const user = await prisma.user.create({
data: { ...profile, passwordHash: await bcrypt.hash(password, 12), driveFolderId },
});
response.status(201).json({ token: issueToken(user.id), user: publicUser(user) });
} catch (error) {
if (driveFolderId) await deleteDriveItem(driveFolderId).catch(() => {});
throw error;
}
}));
router.post("/login", asyncHandler(async (request, response) => {
const input = loginSchema.parse(request.body);
const user = await prisma.user.findUnique({ where: { username: input.username } });
if (!user || !(await bcrypt.compare(input.password, user.passwordHash))) {
throw new HttpError(401, "Invalid username or password");
}
response.json({ token: issueToken(user.id), user: publicUser(user) });
}));
router.get("/me", requireAuth, (request, response) => {
response.json({ user: publicUser(request.user) });
});
export default router;

82
src/routes/files.js Normal file
View File

@ -0,0 +1,82 @@
import fs from "node:fs/promises";
import { Router } from "express";
import { z } from "zod";
import { prisma } from "../db.js";
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";
const router = Router();
async function ownedFile(userId, id) {
const file = await prisma.fileAsset.findFirst({ where: { id, userId } });
if (!file) throw new HttpError(404, "File not found");
return file;
}
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 } });
if (!folder) throw new HttpError(404, "Folder not found");
}
const files = await prisma.fileAsset.findMany({
where: { userId: request.user.id, folderId },
orderBy: { createdAt: "desc" },
});
response.json({ files: files.map(serializeFile) });
}));
router.post("/upload", uploadSingle, asyncHandler(async (request, response) => {
if (!request.file) throw new HttpError(400, "Select a file to upload");
let uploaded;
try {
const folderId = request.body.folderId ? z.string().uuid().parse(request.body.folderId) : null;
let parentDriveFolderId = request.user.driveFolderId;
if (folderId) {
const folder = await prisma.folder.findFirst({ where: { id: folderId, userId: request.user.id } });
if (!folder) throw new HttpError(404, "Folder not found");
parentDriveFolderId = folder.driveFolderId;
}
uploaded = await uploadFile({
filePath: request.file.path,
originalName: request.file.originalname,
mimeType: request.file.mimetype || "application/octet-stream",
parentDriveFolderId,
});
const file = await prisma.fileAsset.create({
data: {
originalName: request.file.originalname,
driveFileId: uploaded.id,
mimeType: request.file.mimetype || uploaded.mimeType || "application/octet-stream",
sizeBytes: BigInt(request.file.size),
userId: request.user.id,
folderId,
},
});
response.status(201).json({ file: serializeFile(file) });
} catch (error) {
if (uploaded?.id) await deleteDriveItem(uploaded.id).catch(() => {});
throw error;
} finally {
await fs.unlink(request.file.path).catch(() => {});
}
}));
router.get("/:id/content", asyncHandler(async (request, response) => {
const file = await ownedFile(request.user.id, z.string().uuid().parse(request.params.id));
response.setHeader("Content-Type", file.mimeType);
response.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(file.originalName)}`);
await streamFile(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);
await prisma.fileAsset.delete({ where: { id: file.id } });
response.status(204).end();
}));
export default router;

74
src/routes/folders.js Normal file
View File

@ -0,0 +1,74 @@
import { Router } from "express";
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";
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()}`;
}
async function ownedFolder(userId, id) {
const folder = await prisma.folder.findFirst({ where: { id, userId } });
if (!folder) throw new HttpError(404, "Folder not found");
return folder;
}
router.get("/", asyncHandler(async (request, response) => {
const parentId = request.query.parentId ? z.string().uuid().parse(request.query.parentId) : null;
if (parentId) await ownedFolder(request.user.id, parentId);
const folders = await prisma.folder.findMany({
where: { userId: request.user.id, parentId },
orderBy: { name: "asc" },
include: { _count: { select: { children: true, files: true } } },
});
response.json({ folders });
}));
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 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);
try {
const folder = await prisma.folder.create({
data: { name: input.name, parentId: input.parentId || null, driveFolderId, scopeKey, userId: request.user.id },
});
response.status(201).json({ folder });
} catch (error) {
await deleteDriveItem(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 { name } = z.object({ name: folderName }).parse(request.body);
const scopeKey = folderScope(request.user.id, folder.parentId, name);
const duplicate = await prisma.folder.findFirst({ where: { scopeKey, NOT: { id: folder.id } }, select: { id: true } });
if (duplicate) throw new HttpError(409, "A folder with that name already exists here");
await renameDriveItem(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 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 prisma.folder.delete({ where: { id: folder.id } });
response.status(204).end();
}));
export default router;

21
src/routes/retention.js Normal file
View File

@ -0,0 +1,21 @@
import { Router } from "express";
import bcrypt from "bcryptjs";
import { z } from "zod";
import { prisma } from "../db.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import { HttpError } from "../utils/httpError.js";
const router = Router();
router.delete("/account", asyncHandler(async (request, response) => {
const { password } = z.object({ password: z.string().min(1).max(128) }).parse(request.body);
if (!(await bcrypt.compare(password, request.user.passwordHash))) {
throw new HttpError(401, "Password confirmation is incorrect");
}
// Relations cascade in PostgreSQL. Google Drive is deliberately untouched.
await prisma.user.delete({ where: { id: request.user.id } });
response.status(204).end();
}));
export default router;

19
src/server.js Normal file
View File

@ -0,0 +1,19 @@
import { app } from "./app.js";
import { config } from "./config.js";
import { prisma } from "./db.js";
const server = app.listen(config.PORT, "0.0.0.0", () => {
console.log(`Whats Drive API listening on http://0.0.0.0:${config.PORT}`);
});
async function shutdown(signal) {
console.log(`${signal} received; shutting down`);
server.close(async () => {
await prisma.$disconnect();
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000).unref();
}
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));

110
src/services/googleDrive.js Normal file
View File

@ -0,0 +1,110 @@
import fs from "node:fs";
import { google } from "googleapis";
import { config } from "../config.js";
let driveClient;
let applicationRootId;
function loadJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
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],
);
oauth.setCredentials(loadJson(config.tokenPath));
oauth.on("tokens", (tokens) => {
const existing = loadJson(config.tokenPath);
fs.writeFileSync(config.tokenPath, JSON.stringify({ ...existing, ...tokens }, null, 2));
});
driveClient = google.drive({ version: "v3", auth: oauth });
return driveClient;
}
function escapeQuery(value) {
return String(value).replaceAll("\\", "\\\\").replaceAll("'", "\\'");
}
export async function findOrCreateFolder(name, parentId) {
const drive = getDrive();
const query = [
"mimeType='application/vnd.google-apps.folder'",
`name='${escapeQuery(name)}'`,
"trashed=false",
parentId ? `'${parentId}' in parents` : null,
].filter(Boolean).join(" and ");
const existing = await drive.files.list({ q: query, fields: "files(id,name)", pageSize: 1 });
if (existing.data.files?.length) return existing.data.files[0].id;
const created = await drive.files.create({
requestBody: {
name,
mimeType: "application/vnd.google-apps.folder",
parents: parentId ? [parentId] : undefined,
},
fields: "id",
});
return created.data.id;
}
export async function getApplicationRootId() {
if (!applicationRootId) {
applicationRootId = await findOrCreateFolder(config.GOOGLE_DRIVE_ROOT_FOLDER);
}
return applicationRootId;
}
async function createFolder(name, parentId) {
const created = await getDrive().files.create({
requestBody: {
name,
mimeType: "application/vnd.google-apps.folder",
parents: parentId ? [parentId] : undefined,
},
fields: "id",
});
return created.data.id;
}
export async function createUserRootFolder(name, username) {
const root = await getApplicationRootId();
const safeName = String(name).trim().replace(/[\\/:*?"<>|]/g, "_");
return createFolder(`${safeName} (${username})`, root);
}
export async function createChildFolder(name, parentDriveFolderId) {
return createFolder(name, parentDriveFolderId);
}
export async function uploadFile({ filePath, originalName, mimeType, parentDriveFolderId }) {
const drive = getDrive();
const result = await drive.files.create({
requestBody: { name: originalName, parents: [parentDriveFolderId] },
media: { mimeType, body: fs.createReadStream(filePath) },
fields: "id,name,mimeType,size,createdTime",
});
return result.data;
}
export async function streamFile(driveFileId, response) {
const drive = getDrive();
const result = await drive.files.get(
{ fileId: driveFileId, alt: "media" },
{ responseType: "stream" },
);
result.data.pipe(response);
}
export async function renameDriveItem(driveFileId, name) {
await getDrive().files.update({ fileId: driveFileId, requestBody: { name } });
}
export async function deleteDriveItem(driveFileId) {
await getDrive().files.delete({ fileId: driveFileId });
}

View File

@ -0,0 +1,3 @@
export const asyncHandler = (handler) => (request, response, next) => {
Promise.resolve(handler(request, response, next)).catch(next);
};

7
src/utils/httpError.js Normal file
View File

@ -0,0 +1,7 @@
export class HttpError extends Error {
constructor(status, message, details) {
super(message);
this.status = status;
this.details = details;
}
}

16
src/utils/serializers.js Normal file
View File

@ -0,0 +1,16 @@
export function serializeFile(file) {
return { ...file, sizeBytes: Number(file.sizeBytes) };
}
export function publicUser(user) {
return {
id: user.id,
username: user.username,
name: user.name,
phone: user.phone,
email: user.email,
gender: user.gender,
age: user.age,
createdAt: user.createdAt,
};
}