Done
This commit is contained in:
commit
d8a26f3604
10
.env.example
Normal file
10
.env.example
Normal 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
9
.gitignore
vendored
Normal 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
0
api-local.stderr.log
Normal file
3
api-local.stdout.log
Normal file
3
api-local.stdout.log
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
Whats Drive API listening on http://0.0.0.0:4100
|
||||||
|
[0mGET /health [32m200[0m 2.079 ms - 43[0m
|
||||||
|
[0mGET /health [32m200[0m 0.841 ms - 43[0m
|
||||||
0
api-test.stderr.log
Normal file
0
api-test.stderr.log
Normal file
8
api-test.stdout.log
Normal file
8
api-test.stdout.log
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
Whats Drive API listening on http://0.0.0.0:4100
|
||||||
|
[0mGET /health [32m200[0m 2.211 ms - 43[0m
|
||||||
|
[0mGET /health [32m200[0m 0.506 ms - 43[0m
|
||||||
|
[0mPOST /api/auth/register [32m201[0m 4152.821 ms - 445[0m
|
||||||
|
[0mPOST /api/files/upload [32m201[0m 14743.616 ms - 299[0m
|
||||||
|
[0mDELETE /api/retention/account [33m401[0m 277.713 ms - 46[0m
|
||||||
|
[0mDELETE /api/retention/account [32m204[0m 346.733 ms - -[0m
|
||||||
|
[0mGET /api/auth/me [33m401[0m 2.865 ms - 36[0m
|
||||||
3658
package-lock.json
generated
Normal file
3658
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal 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
8
prisma.config.js
Normal 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 },
|
||||||
|
});
|
||||||
82
prisma/migrations/20260623184824_init/migration.sql
Normal file
82
prisma/migrations/20260623184824_init/migration.sql
Normal 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;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "Folder" ADD COLUMN "scopeKey" TEXT;
|
||||||
|
CREATE UNIQUE INDEX "Folder_scopeKey_key" ON "Folder"("scopeKey");
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
61
prisma/schema.prisma
Normal 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
24
scripts/google-auth.js
Normal 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
36
src/app.js
Normal 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
35
src/config.js
Normal 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
6
src/db.js
Normal 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
19
src/middleware/auth.js
Normal 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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/middleware/errorHandler.js
Normal file
25
src/middleware/errorHandler.js
Normal 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
20
src/middleware/upload.js
Normal 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
65
src/routes/auth.js
Normal 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
82
src/routes/files.js
Normal 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
74
src/routes/folders.js
Normal 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
21
src/routes/retention.js
Normal 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
19
src/server.js
Normal 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
110
src/services/googleDrive.js
Normal 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 });
|
||||||
|
}
|
||||||
3
src/utils/asyncHandler.js
Normal file
3
src/utils/asyncHandler.js
Normal 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
7
src/utils/httpError.js
Normal 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
16
src/utils/serializers.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user