Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Dependencies & Package Managers
node_modules/
.pnpm-store
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# Coverage
coverage/

# Build Output
dist/

# Production / Deployment
.vercel/
.turbo/

# Environment Variables
.env
.env.*
!.env.example

# Logs & Debug
logs/
*.log
*.log.*
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
lerna-debug.log*

# TypeScript
*.tsbuildinfo

# IDE & System Files
.idea/
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

# Miscellaneous
*.pem
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
auto-install-peers = true
24 changes: 24 additions & 0 deletions apps/backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
NODE_ENV=development

# Server
PORT=3000
HOST=0.0.0.0

# URLs
FRONTEND_URL=http://localhost:5173
BACKEND_URL=http://localhost:3000

# Database
MONGODB_URI=mongodb://localhost:27017/
DATABASE_NAME=ViNotes

# Auth
AUTH_GOOGLE_ID=your_google_client_id
AUTH_GOOGLE_SECRET=your_google_client_secret
BETTER_AUTH_SECRET=your_better_auth_secret_32_chars_min

# SMTP for sending emails
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_EMAIL=your_smtp_email@example.com
SMTP_EMAIL_PASSWORD=your_email_password
15 changes: 15 additions & 0 deletions apps/backend/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import config from "@repo/eslint-config/node";

export default [
...config,

{
files: ["**/*.ts"],
languageOptions: {
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: import.meta.dirname,
},
},
},
];
30 changes: 30 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "backend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch --env-file=.env src/index.ts",
"build": "tsup",
"start": "node --env-file=.env dist/index.js",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/auth": "workspace:*",
"cors": "^2.8.6",
"express": "^5.2.1",
"express-rate-limit": "^8.3.2",
"mongodb": "^7.1.1",
"mongoose": "^9.4.1",
"nodemailer": "^8.0.4"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/nodemailer": "^8.0.0",
"tsup": "^8.5.1",
"tsx": "^4.21.0"
}
}
91 changes: 91 additions & 0 deletions apps/backend/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { betterAuth } from "better-auth";
import { mongodbAdapter } from "better-auth/adapters/mongodb";
import { twoFactor, emailOTP, admin as adminPlugin } from "better-auth/plugins";
import { getMongoClient } from "./lib/mongodb";
import { ENV } from "./env";
import { sendNoReplyMail } from "./lib/sendMail";
import { ac, roles } from "@repo/auth";

// MongoDB
const db = getMongoClient(ENV.MONGODB_URI).db(ENV.DATABASE_NAME);

// Auth config
export const auth = betterAuth({
appName: "Vi Notes",
baseURL: ENV.BACKEND_URL,
trustedOrigins: [ENV.FRONTEND_URL],

database: mongodbAdapter(db),

account: {
accountLinking: { enabled: true },
},

emailAndPassword: {
enabled: true,
requireEmailVerification: true,
onExistingUserSignUp: async ({ user }) => {
await sendNoReplyMail({
sendTo: user.email,
subject: "Sign-up attempt with your email",
html: "Someone tried to create an account using your email address. If this was you, try signing in instead. If not, you can safely ignore this email.",
});
},
},

socialProviders: {
google: {
clientId: ENV.AUTH_GOOGLE_ID,
clientSecret: ENV.AUTH_GOOGLE_SECRET,
},
},

emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
try {
await sendNoReplyMail({
sendTo: user.email,
subject: "Verify your email address",
html: `
<p>Hello ${user.name ?? ""},</p>
<p>Please verify your email by clicking the link below:</p>
<a href="${url}">${url}</a>
`,
});
} catch (error) {
console.error("Verify email send failed", error);
}
},

sendOnSignUp: true,
sendOnSignIn: true,

autoSignInAfterVerification: true,
},

plugins: [
twoFactor(),
emailOTP({
disableSignUp: false,

async sendVerificationOTP({ email, otp, type }) {
let subject = "Your verification code";
const html = `<strong>${otp}</strong>`;

if (type === "sign-in") subject = "Sign-in verification code";
else if (type === "email-verification") subject = "Verify your email";
else if (type === "forget-password") subject = "Reset your password";

try {
await sendNoReplyMail({ sendTo: email, subject, html });
} catch (error) {
console.error("OTP email send failed", error);
}
},
}),

adminPlugin({ ac, roles }),
],
});

export type Session = typeof auth.$Infer.Session;
40 changes: 40 additions & 0 deletions apps/backend/src/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { z } from "zod";

const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),

// Server
PORT: z.coerce.number().default(3000),
HOST: z.string().default("0.0.0.0"),

// URLs
FRONTEND_URL: z.url(),
BACKEND_URL: z.url(),

// Database
MONGODB_URI: z.url(),
DATABASE_NAME: z.string().default("ViNotes"),

// Auth
AUTH_GOOGLE_ID: z.string(),
AUTH_GOOGLE_SECRET: z.string(),
BETTER_AUTH_SECRET: z.string().min(32),

// SMTP for sending emails
SMTP_HOST: z.string(),
SMTP_PORT: z.coerce.number().default(465),
SMTP_EMAIL: z.email(),
SMTP_EMAIL_PASSWORD: z.string(),
});

const parsed = EnvSchema.safeParse(process.env);

if (!parsed.success) {
console.error("❌ Invalid environment variables:");
console.error(z.treeifyError(parsed.error));
console.error(parsed.error.issues);
process.exit(1);
}

export const ENV = parsed.data;
export type Env = typeof ENV;
85 changes: 85 additions & 0 deletions apps/backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import express from "express";

// Middleware
import { errorMiddleware } from "./middleware/error.middleware";
import { corsMiddleware } from "./middleware/cors.middleware";
import { rateLimitMiddleware } from "./middleware/rateLimit.middleware";

import registerRoutes from "./routes/index";
import { toNodeHandler } from "better-auth/node";
import { auth } from "./auth";
import { ENV } from "./env";
import { getLocalIP } from "./lib/getLocalIP";
import { authMiddleware } from "./middleware/auth.middleware";
import connectToDatabase from "./lib/mongoose";
import {
startStaleWritingSessionSweep,
stopStaleWritingSessionSweep,
} from "./services/writingSession.staleSweep";

async function bootstrap() {
const app = express();

try {
await connectToDatabase();
startStaleWritingSessionSweep();
} catch (err) {
console.error("❌ Failed to connect DB:", err);
process.exit(1);
}

// CORS
app.use(corsMiddleware);

// Auth (must be before body parsing and other middlewares)
app.all("/api/auth/*splat", toNodeHandler(auth));

// Body parsing
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ extended: true }));

// Middleware
app.use((req, res, next) => {
if (req.path.startsWith("/api/auth")) return next();
return rateLimitMiddleware(req, res, next);
});

// Attach user and session to req
app.use(authMiddleware);

// Routes
registerRoutes(app);

// Error handler must be last
app.use(errorMiddleware);

// Start server
const server = app.listen(ENV.PORT, ENV.HOST, () => {
console.info(`\n🤖 Server running on:`);

console.info(`➜ Local: http://localhost:${ENV.PORT}`);

if (ENV.HOST === "0.0.0.0") {
console.info(`➜ Network: http://${getLocalIP()}:${ENV.PORT}`);
} else {
console.info(`➜ Host: http://${ENV.HOST}:${ENV.PORT}`);
}
});

// Graceful shutdown
const shutdown = (signal: string) => {
console.info(`Received ${signal}, shutting down...`);

stopStaleWritingSessionSweep();

server.close(() => {
console.info("Server closed");
process.exit(0);
});
};

process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
}

void bootstrap();
13 changes: 13 additions & 0 deletions apps/backend/src/lib/getLocalIP.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import os from "os";

export function getLocalIP() {
const nets = os.networkInterfaces();
for (const name of Object.keys(nets)) {
for (const net of nets[name] ?? []) {
if (net.family === "IPv4" && !net.internal) {
return net.address;
}
}
}
return "localhost";
}
22 changes: 22 additions & 0 deletions apps/backend/src/lib/mongodb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { MongoClient } from "mongodb";

const NODE_ENV = process.env.NODE_ENV;

// Extend NodeJS global type to avoid TS errors in development
declare global {
var _mongoClient: MongoClient | undefined;
}

let mongoClient: MongoClient | undefined;

export function getMongoClient(uri: string) {
// In development, reuse global instance to prevent multiple connections on HMR
if (NODE_ENV === "development") {
global._mongoClient ??= new MongoClient(uri);
return global._mongoClient;
}

mongoClient ??= new MongoClient(uri);

return mongoClient;
}
Loading