Compare commits

..

No commits in common. "21308fa5bd242c1c77c13ebfced27869e95ac15b" and "8cb31194d76c07f7192d19147120b8490ee1b6e4" have entirely different histories.

35 changed files with 329 additions and 1133 deletions

View file

@ -1,11 +1,8 @@
PORT=3000 DB_USER=postgres
SERVICE_NAME=<dash-seperated-lowercased-unique-accross-projects-name> DB_PASSWORD=
DATABASE_URL="postgresql://..." DB_NAME=postgres
MINIO_ACCESS_KEY= DB_HOST=localhost
MINIO_SECRET_KEY= DB_PORT=5432
MINIO_ENDPOINT_URL=
MINIO_BUCKET_NAME=
BETTER_AUTH_SECRET=
# DO NOT CHANGE
BETTER_AUTH_URL=http://127.0.0.1:${PORT} DATABASE_URL='postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}'

View file

@ -1,6 +0,0 @@
DB_USER=
DB_PASSWORD=
DB_PORT=
MINIO_ROOT_USER=
MINIO_ROOT_PASSWORD=

3
.gitignore vendored
View file

@ -29,7 +29,6 @@ yarn-error.log*
.env.local .env.local
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.services
.env.production.local .env.production.local
# vercel # vercel
@ -42,5 +41,3 @@ yarn-error.log*
**/*.log **/*.log
package-lock.json package-lock.json
**/*.bun **/*.bun
./server

View file

@ -13,8 +13,8 @@ RUN bun install --frozen-lockfile
# Copy source code # Copy source code
COPY . . COPY . .
# RUN DB Migrations and build # Build the application
RUN bun run db:migrateb && bun run build RUN bun build ./src/index.ts --compile --outfile server
# Production stage # Production stage
FROM debian:bookworm-slim FROM debian:bookworm-slim

51
auth-schema.ts Normal file
View file

@ -0,0 +1,51 @@
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull(),
image: text("image"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at"),
updatedAt: timestamp("updated_at"),
});

BIN
bun.lockb

Binary file not shown.

View file

@ -1,44 +0,0 @@
services:
tracing:
image: jaegertracing/all-in-one:latest
environment:
COLLECTOR_ZIPKIN_HOST_PORT: 9411
COLLECTOR_OTLP_ENABLED: true
ports:
- "16686:16686"
db:
image: postgres:latest
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
ports:
- "${DB_PORT}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
minio:
image: minio/minio:latest
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 30s
timeout: 20s
retries: 3
networks:
api-network:
driver: bridge
volumes:
postgres_data:
minio_data:

View file

@ -4,10 +4,47 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- "${PORT}:${PORT}" - "3000:3000"
env_file:
- .env
environment: environment:
NODE_ENV: production - NODE_ENV=production
OTEL_EXPORTER_OTLP_ENDPOINT: http://tracing:4318 - OTEL_EXPORTER_OTLP_ENDPOINT=http://tracing:4318
OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf - OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
DATABASE_URL: ${DATABASE_URL} - DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
networks:
- api-network
depends_on:
- tracing
tracing:
image: jaegertracing/all-in-one:latest
environment:
- COLLECTOR_ZIPKIN_HOST_PORT=:9411
- COLLECTOR_OTLP_ENABLED=true
ports:
- "16686:16686"
networks:
- api-network
db:
image: postgres:latest
env_file:
- .env
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- api-network
networks:
api-network:
driver: bridge
volumes:
postgres_data:

View file

@ -3,7 +3,7 @@ import { defineConfig } from "drizzle-kit";
export default defineConfig({ export default defineConfig({
out: "./drizzle", out: "./drizzle",
schema: "./src/db/schema", schema: "./src/db/schema.ts",
dialect: "postgresql", dialect: "postgresql",
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_URL!, url: process.env.DATABASE_URL!,

View file

@ -16,13 +16,6 @@ CREATE TABLE "auth"."account" (
"updated_at" timestamp NOT NULL "updated_at" timestamp NOT NULL
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE "auth"."rate_limit" (
"id" text PRIMARY KEY NOT NULL,
"key" text,
"count" integer,
"last_request" integer
);
--> statement-breakpoint
CREATE TABLE "auth"."session" ( CREATE TABLE "auth"."session" (
"id" text PRIMARY KEY NOT NULL, "id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL, "expires_at" timestamp NOT NULL,
@ -55,16 +48,5 @@ CREATE TABLE "auth"."verification" (
"updated_at" timestamp "updated_at" timestamp
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE "note" (
"id" text PRIMARY KEY NOT NULL,
"title" text,
"content" text,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
"deletedAt" timestamp,
"ownerId" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "auth"."account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint ALTER TABLE "auth"."account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint ALTER TABLE "auth"."session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "note" ADD CONSTRAINT "note_ownerId_user_id_fk" FOREIGN KEY ("ownerId") REFERENCES "auth"."user"("id") ON DELETE no action ON UPDATE no action;

View file

@ -1,5 +1,5 @@
{ {
"id": "50bd2c27-8d45-478f-a894-b2f7cd4a718b", "id": "1ada386b-032f-4c0c-a867-e25f2e921167",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@ -110,43 +110,6 @@
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "isRLSEnabled": false
}, },
"auth.rate_limit": {
"name": "rate_limit",
"schema": "auth",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"last_request": {
"name": "last_request",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"auth.session": { "auth.session": {
"name": "session", "name": "session",
"schema": "auth", "schema": "auth",
@ -342,77 +305,6 @@
"policies": {}, "policies": {},
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "isRLSEnabled": false
},
"public.note": {
"name": "note",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"deletedAt": {
"name": "deletedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"ownerId": {
"name": "ownerId",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"note_ownerId_user_id_fk": {
"name": "note_ownerId_user_id_fk",
"tableFrom": "note",
"tableTo": "user",
"schemaTo": "auth",
"columnsFrom": [
"ownerId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
} }
}, },
"enums": {}, "enums": {},

View file

@ -5,8 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1737019435130, "when": 1736605568502,
"tag": "0000_uneven_professor_monster", "tag": "0000_rich_reavers",
"breakpoints": true "breakpoints": true
} }
] ]

View file

@ -5,13 +5,9 @@
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"dev": "bun run --watch src/index.ts", "dev": "bun run --watch src/index.ts",
"email": "email dev --dir src/emails", "email": "email dev --dir src/emails",
"auth:generate": "bun x @better-auth/cli generate --config src/lib/auth/auth.ts --output src/db/schema/auth.ts && drizzle-kit migrate",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:check": "drizzle-kit check",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:pull": "drizzle-kit pull",
"db:push": "drizzle-kit push",
"build": "bun build --compile --minify-whitespace --minify-syntax --target bun --outfile server ./src/index.ts" "build": "bun build --compile --minify-whitespace --minify-syntax --target bun --outfile server ./src/index.ts"
}, },
"dependencies": { "dependencies": {
@ -26,7 +22,6 @@
"drizzle-orm": "^0.38.3", "drizzle-orm": "^0.38.3",
"drizzle-typebox": "^0.2.1", "drizzle-typebox": "^0.2.1",
"elysia": "latest", "elysia": "latest",
"minio": "^8.0.3",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"pg": "^8.13.1", "pg": "^8.13.1",
"react": "^19.0.0", "react": "^19.0.0",

View file

@ -1,72 +0,0 @@
import { Elysia, t } from "elysia";
import { betterAuthView } from "../lib/auth/auth-view";
import { getAuthConfig, getBaseConfig } from "../lib/utils/env";
import { router } from "./routes";
const baseConfig = getBaseConfig();
const authConfig = getAuthConfig();
export const api = new Elysia({
name: baseConfig.SERVICE_NAME,
prefix: "/api",
detail: {
summary: `Get status`,
description: `Get status for ${baseConfig.SERVICE_NAME}`,
externalDocs: {
description: "Auth API",
url: `${authConfig.BETTER_AUTH_URL}/docs`,
},
},
})
.all("/auth/*", betterAuthView)
.use(router)
.get(
"",
() => {
return {
message: `Server is running`,
success: true,
name: baseConfig.SERVICE_NAME,
status: "active",
docs: {
default: "/api/docs",
auth: "/api/auth/docs",
},
};
},
{
response: {
200: t.Object(
{
message: t.String({ default: `Server is running` }),
success: t.Boolean({ default: true }),
name: t.String({ default: baseConfig.SERVICE_NAME }),
status: t.String({ default: `active` }),
docs: t.Object({
default: t.String({ default: "/api/docs" }),
auth: t.String({ default: "/api/auth/docs" }),
}),
},
{
description: "Success",
}
),
404: t.Object(
{
message: t.String({ default: `Not found` }),
success: t.Boolean({ default: false }),
name: t.String({ default: baseConfig.SERVICE_NAME }),
status: t.String({ default: `active` }),
docs: t.Object({
default: t.String({ default: "/api/docs" }),
auth: t.String({ default: "/api/auth/docs" }),
}),
},
{
description: "Not found",
}
),
},
}
);

View file

@ -0,0 +1,25 @@
import { Memo } from "./note.model";
export class Note {
constructor(
public data: Memo[] = [
{
data: "Moonhalo",
},
]
) {}
add(note: Memo) {
this.data.push(note);
return this.data;
}
remove(index: number) {
return this.data.splice(index, 1);
}
update(index: number, note: Partial<Memo>) {
return (this.data[index] = { ...this.data[index], ...note });
}
}

View file

@ -0,0 +1,7 @@
import { t } from "elysia";
export const memoSchema = t.Object({
data: t.String(),
});
export type Memo = typeof memoSchema.static;

View file

@ -0,0 +1,44 @@
import { Elysia, t } from "elysia";
import { Note } from "./note.controller";
import { memoSchema } from "./note.model";
export const note = new Elysia({ prefix: "/note" })
.decorate("note", new Note())
.model({
memo: t.Omit(memoSchema, ["author"]),
})
.get("/", ({ note }) => note.data)
.put("/", ({ note, body: { data } }) => note.add({ data }), {
body: "memo",
})
.get(
"/:index",
({ note, params: { index }, error }) => {
return note.data[index] ?? error(404, "Not Found :(");
},
{
params: t.Object({
index: t.Number(),
}),
}
)
.guard({
params: t.Object({
index: t.Number(),
}),
})
.delete("/:index", ({ note, params: { index }, error }) => {
if (index in note.data) return note.remove(index);
return error(422);
})
.patch(
"/:index",
({ note, params: { index }, body: { data }, error }) => {
if (index in note.data) return note.update(index, { data });
return error(422);
},
{
body: "memo",
}
);

36
src/api/otp/otp.route.ts Normal file
View file

@ -0,0 +1,36 @@
import { Elysia, t } from "elysia";
import { renderToStaticMarkup } from "react-dom/server";
import nodemailer from "nodemailer";
import OTPEmail from "../../emails/otp";
import { createElement } from "react";
const transporter = nodemailer.createTransport({
host: "smtp.gehenna.sh",
port: 465,
auth: {
user: "makoto",
pass: "12345678",
},
});
export const otp = new Elysia({ prefix: "/otp" }).get(
"/",
async ({ body }) => {
// Random between 100,000 and 999,999
const otp = ~~(Math.random() * (900_000 - 1)) + 100_000;
const html = renderToStaticMarkup(createElement(OTPEmail, { otp }));
await transporter.sendMail({
from: "ibuki@gehenna.sh",
to: body,
subject: "Verify your email address",
html,
});
return { success: true };
},
{
body: t.String({ format: "email" }),
}
);

View file

@ -1,6 +0,0 @@
import { Elysia, t } from "elysia";
import { noteRouter } from "./note/note.route";
export const router = new Elysia().use(noteRouter)

View file

@ -1,141 +0,0 @@
import { and, eq, isNull } from "drizzle-orm";
import { db } from "../../../db";
import { note } from "../../../db/schema/note";
import { CreateNoteType } from "./note.model";
export class NoteController {
async createNote(new_note: CreateNoteType, ownerId: string) {
const new_note_data = { ...new_note, ownerId: ownerId };
const result = await db
.insert(note)
.values(new_note_data)
.returning({
id: note.id,
title: note.title,
content: note.content,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
})
.execute();
return {
success: true,
data: result,
message: "Note created successfully",
error: null,
};
}
async getOwnerNotes(ownerId: string, limit:number=10, offset:number=0) {
const result = await db
.select({
id: note.id,
title: note.title,
content: note.content,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
})
.from(note)
.where(and(eq(note.ownerId, ownerId), isNull(note.deletedAt)))
.limit(limit).offset(offset)
.execute();
return {
success: true,
data: result,
message: "",
error: null,
};
}
async getNoteById(noteId: string, ownerId: string) {
const result = await db
.select({
id: note.id,
title: note.title,
content: note.content,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
})
.from(note)
.where(
and(
eq(note.id, noteId),
eq(note.ownerId, ownerId),
isNull(note.deletedAt)
)
)
.execute();
return {
success: true,
data: result,
message: "",
error: null,
};
}
async updateNoteById(
noteId: string,
updated_note: CreateNoteType,
ownerId: string
) {
const new_note_data = { ...updated_note, updatedAt: new Date() };
const result = await db
.update(note)
.set(new_note_data)
.where(
and(
eq(note.id, noteId),
eq(note.ownerId, ownerId),
isNull(note.deletedAt)
)
)
.returning({
id: note.id,
title: note.title,
content: note.content,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
})
.execute();
return {
success: true,
data: result,
message: "Note updated successfully",
error: null,
};
}
async deleteNoteById(noteId: string, ownerId: string) {
await db
.update(note)
.set({ deletedAt: new Date() })
.where(
and(
eq(note.id, noteId),
eq(note.ownerId, ownerId),
isNull(note.deletedAt)
)
)
.execute();
return {
success: true,
data: null,
message: "Note deleted successfully",
error: null,
};
}
async deleteAllNotes(ownerId: string) {
await db
.update(note)
.set({ deletedAt: new Date() })
.where(and(eq(note.ownerId, ownerId), isNull(note.deletedAt)))
.execute();
return {
success: true,
data: null,
message: "Notes deleted successfully",
error: null,
};
}
}

View file

@ -1,43 +0,0 @@
import { createSelectSchema } from "drizzle-typebox";
import { t } from "elysia";
import { note } from "../../../db/schema/note";
import { InferInsertModel } from "drizzle-orm";
import { commonResponses } from "../../../lib/utils/common";
export const _NoteSchema = createSelectSchema(note);
export const NoteSchema = t.Omit(_NoteSchema, ["deletedAt", "ownerId"]);
export type CreateNoteType = Pick<
InferInsertModel<typeof note>,
"title" | "content"
>;
export const createNoteSchema = t.Pick(NoteSchema, [
"title",
"content",
]);
export const getNoteResponses = {
200: t.Object({
success:t.Boolean({default:true}),
data: t.Array(NoteSchema),
error: t.Null(),
message: t.String()
}, {
description:"Success"
}) ,
...commonResponses
}
export const deleteNoteResponses = {
200: t.Object({
success:t.Boolean({default:true}),
data: t.Null(),
error: t.Null(),
message: t.String({default:"Note deletion succesful"})
}, {
description:"Success"
}),
...commonResponses
}

View file

@ -1,119 +0,0 @@
import { Elysia, t } from "elysia";
import { createNoteSchema, deleteNoteResponses, getNoteResponses, NoteSchema } from "./note.model";
import { NoteController } from "./note.controller";
import { userMiddleware } from "../../../middlewares/auth-middleware";
export const noteRouter = new Elysia({
prefix: "/note",
name: "CRUD Operations for Notes",
"analytic":true,
tags: ["Note"],
detail: {
description: "Notes CRUD operations",
},
})
.decorate("note", new NoteController())
.model({
note: NoteSchema,
})
.derive(({ request }) => userMiddleware(request))
.onError(({ error, code, }) => {
console.error(error);
return {
message: "",
success: false,
data: null,
error: code.toString()
};
})
.get(
"",
async ({ note, user, query}) => {
return await note.getOwnerNotes(user.id, query.limit, query.offset);
},
{
query:t.Object({
limit: t.Optional(t.Number()),
offset: t.Optional(t.Number())
}),
response:getNoteResponses,
detail:{
"description":"Get all notes of the user",
"summary":"Get all notes"
}
}
)
.get(
":id",
async ({ note, user, params:{id} }) => {
return await note.getNoteById(id, user.id);
},
{
params:t.Object({
id: t.String(),
}),
response: getNoteResponses,
detail:{
"description":"Get a note by Id",
"summary":"Get a note"
}
}
)
.post(
"",
async ({ body, note, user }) => {
return await note.createNote(body, user.id);
},
{
body: createNoteSchema,
response: getNoteResponses,
detail:{
"description":"Create a new note",
"summary":"Create a note"
}
}
).patch(
":id",
async ({ body, note, user, params:{id} }) => {
return await note.updateNoteById(id, body, user.id);
},
{
body: createNoteSchema,
params:t.Object({
id: t.String(),
}),
response: getNoteResponses,
detail:{
"description":"Update a note by Id",
"summary":"Update a note"
}
}
).delete(
":id",
async ({ note, user, params:{id} }) => {
return await note.deleteNoteById(id, user.id);
},
{
params:t.Object({
id: t.String(),
}),
response: deleteNoteResponses,
detail:{
"description":"Delete a note by Id",
"summary":"Delete a note"
}
}
)
.delete(
"",
async ({ note, user }) => {
return await note.deleteAllNotes(user.id);
},
{
response: deleteNoteResponses,
detail:{
"description":"Delete all notes of an user",
"summary":"Delete all notes"
}
}
)

View file

@ -1,7 +1,4 @@
import "dotenv/config"; import "dotenv/config";
import { drizzle } from "drizzle-orm/node-postgres"; import { drizzle } from "drizzle-orm/node-postgres";
import { getDbConfig } from "../lib/utils/env";
const dbConfig = getDbConfig() export const db = drizzle(process.env.DATABASE_URL!);
export const db = drizzle(dbConfig.DATABASE_URL);

53
src/db/schema.ts Normal file
View file

@ -0,0 +1,53 @@
import { pgTable, pgSchema, text, timestamp, boolean } from "drizzle-orm/pg-core";
export const auth_schema = pgSchema("auth")
export const user = auth_schema.table("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull(),
image: text("image"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
});
export const session = auth_schema.table("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id),
});
export const account = auth_schema.table("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
});
export const verification = auth_schema.table("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at"),
updatedAt: timestamp("updated_at"),
});

View file

@ -1,56 +0,0 @@
import { text, integer, timestamp, boolean, pgSchema } from "drizzle-orm/pg-core";
export const authSchema = pgSchema('auth');
export const user = authSchema.table("user", {
id: text("id").primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').notNull(),
image: text('image'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull()
});
export const session = authSchema.table("session", {
id: text("id").primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id').notNull().references(() => user.id)
});
export const account = authSchema.table("account", {
id: text("id").primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id').notNull().references(() => user.id),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull()
});
export const verification = authSchema.table("verification", {
id: text("id").primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at'),
updatedAt: timestamp('updated_at')
});
export const rateLimit = authSchema.table("rate_limit", {
id: text("id").primaryKey(),
key: text('key'),
count: integer('count'),
lastRequest: integer('last_request')
});

View file

@ -1,13 +0,0 @@
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { createId } from '@paralleldrive/cuid2'
import { user } from "./auth";
export const note = pgTable("note", {
id: text("id").primaryKey().$defaultFn(()=> `note_${createId()}`),
title: text("title"),
content: text("content"),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp(),
deletedAt: timestamp(),
ownerId: text().notNull().references(() => user.id)
})

View file

@ -1,28 +0,0 @@
import * as React from 'react'
import { Tailwind, Section, Text } from '@react-email/components'
export default function AuthEmail({ message, link }: { message: string, link: string }) {
return (
<Tailwind>
<Section className="flex justify-center items-center w-full min-h-screen font-sans">
<Section className="flex flex-col items-center w-76 rounded-2xl px-6 py-1 bg-gray-50">
<Text className="text-2xl font-medium text-violet-500">
{message}
</Text>
<Text className="text-gray-500 my-0">
Use the following Link to {message}
</Text>
<a href={link} className="text-blue-400 font-bold pt-2">Link</a>
<Text className="text-gray-600 text-xs">
Thanks
</Text>
</Section>
</Section>
</Tailwind>
)
}
AuthEmail.PreviewProps = {
link: "https://example.com",
message: "Verify your email address"
}

30
src/emails/otp.tsx Normal file
View file

@ -0,0 +1,30 @@
import * as React from 'react'
import { Tailwind, Section, Text } from '@react-email/components'
export default function OTPEmail({ otp }: { otp: number }) {
return (
<Tailwind>
<Section className="flex justify-center items-center w-full min-h-screen font-sans">
<Section className="flex flex-col items-center w-76 rounded-2xl px-6 py-1 bg-gray-50">
<Text className="text-xs font-medium text-violet-500">
Verify your Email Address
</Text>
<Text className="text-gray-500 my-0">
Use the following code to verify your email address
</Text>
<Text className="text-5xl font-bold pt-2">{otp}</Text>
<Text className="text-gray-400 font-light text-xs pb-4">
This code is valid for 10 minutes
</Text>
<Text className="text-gray-600 text-xs">
Thank you joining us
</Text>
</Section>
</Section>
</Tailwind>
)
}
OTPEmail.PreviewProps = {
otp: 123456
}

View file

@ -2,47 +2,27 @@ import { Elysia } from "elysia";
import { swagger } from "@elysiajs/swagger"; import { swagger } from "@elysiajs/swagger";
import { opentelemetry } from "@elysiajs/opentelemetry"; import { opentelemetry } from "@elysiajs/opentelemetry";
import { serverTiming } from "@elysiajs/server-timing"; import { serverTiming } from "@elysiajs/server-timing";
import { cors } from "@elysiajs/cors"; import { cors } from '@elysiajs/cors'
import { getBaseConfig, validateEnv } from "./lib/utils/env"; import { note } from "./api/note/note.route";
import { api } from "./api"; import { betterAuthView } from "./lib/auth/auth-view";
import { userMiddleware, userInfo } from "./middlewares/auth-middleware";
const baseConfig = getBaseConfig();
validateEnv();
const app = new Elysia() const app = new Elysia()
.use(cors()) .use(cors())
.use( .use(opentelemetry())
opentelemetry({ .use(swagger({
serviceName: baseConfig.SERVICE_NAME, path: "/docs",
}) }))
)
.use(serverTiming()) .use(serverTiming())
.use(
swagger({
path: "/api/docs",
documentation: {
info: {
title: baseConfig.SERVICE_NAME,
version: "0.0.1",
description: `API docs for ${baseConfig.SERVICE_NAME}`,
},
},
})
)
.onError(({ error, code }) => { .onError(({ error, code }) => {
if (code === "NOT_FOUND") if (code === "NOT_FOUND") return "Not Found :(";
return {
message: `Not Found`,
success: false,
name: baseConfig.SERVICE_NAME,
status: "active",
docs: {
default: "/api/docs",
auth: "/api/auth/docs",
},
};
console.error(error); console.error(error);
}) })
.use(api); .derive(({ request }) => userMiddleware(request))
app.listen(baseConfig.PORT); .all("/api/auth/*", betterAuthView)
console.log(`Server is running on: http://127.0.0.1:${baseConfig.PORT}`); .use(note)
.get("/user", ({ user, session }) => userInfo(user, session));
app.listen(3000);
console.log("Server is running on: http://localhost:3000")

View file

@ -2,66 +2,21 @@ import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "../../db/index"; import { db } from "../../db/index";
import { openAPI } from "better-auth/plugins" import { openAPI } from "better-auth/plugins"
import { user, account, verification, session, rateLimit } from "../../db/schema/auth"; import { user, account, verification, session } from "../../db/schema";
import { sendMail } from "../mail/mail";
import { renderToStaticMarkup } from "react-dom/server";
import { createElement } from "react";
import AuthEmail from "../../emails/auth";
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
// We're using Drizzle as our database
provider: "pg", provider: "pg",
schema: { schema: {
user: user, user: user,
session: session, session: session,
account: account, account: account,
verification: verification, verification: verification
rateLimit: rateLimit,
} }
}), }),
rateLimit: {
window: 60,
max: 100,
storage: "database",
modelName: "rateLimit", //optional by default "rateLimit" is used
customRules: {
"/sign-in/email": {
window: 3600 * 12,
max: 10,
},
"/two-factor/*": async (request) => {
// custom function to return rate limit window and max
return {
window: 3600 * 12,
max: 10,
}
}
},
},
emailAndPassword: { emailAndPassword: {
enabled: true, // If you want to use email and password auth enabled: true, // If you want to use email and password auth
requireEmailVerification: false,
sendResetPassword: async ({ user, url }, request) => {
const subject = "Reset your password";
const html = renderToStaticMarkup(createElement(AuthEmail, { message: subject, link: url }));
await sendMail({
to: user.email,
subject: subject,
html: html,
});
},
},
emailVerification: {
sendVerificationEmail: async ({ user, url, token }, request) => {
const subject = "Verify your email address";
const html = renderToStaticMarkup(createElement(AuthEmail, { message: subject, link: url }));
await sendMail({
to: user.email,
subject: subject,
html: html,
});
},
}, },
plugins: [ plugins: [
openAPI({ openAPI({

View file

@ -1,20 +0,0 @@
import nodemailer from 'nodemailer'
export async function sendMail({ to, subject, text, html }: { to: string, subject: string, text?: string, html?: string }) {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST!,
port: +process.env.SMTP_PORT!,
auth: {
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASSWORD!,
}
})
await transporter.sendMail({
from: process.env.SMTP_FROM!,
to,
subject,
text: text,
html: html,
})
}

View file

@ -1,143 +0,0 @@
import { Client } from 'minio';
import { Buffer } from 'buffer';
import { getMinioConfig } from '../utils/env';
// MinIO client configuration
const minioConfig = getMinioConfig()
const minioClient = new Client({
endPoint: minioConfig.MINIO_ENDPOINT_URL,
useSSL: false,
accessKey: minioConfig.MINIO_ACCESS_KEY,
secretKey: minioConfig.MINIO_SECRET_KEY
});
const BUCKET_NAME = minioConfig.MINIO_BUCKET_NAME;
const SIGNED_URL_EXPIRY = 24 * 60 * 60; // 24 hours in seconds
// Ensure bucket exists
const ensureBucket = async (): Promise<void> => {
const bucketExists = await minioClient.bucketExists(BUCKET_NAME);
if (!bucketExists) {
await minioClient.makeBucket(BUCKET_NAME);
}
};
/**
* Convert File/Blob to Buffer
* @param file - File or Blob object
* @returns Promise<Buffer>
*/
const fileToBuffer = async (file: File | Blob): Promise<Buffer> => {
const arrayBuffer = await file.arrayBuffer();
return Buffer.from(arrayBuffer);
};
/**
* Upload a file to MinIO and return its signed URL
* Supports both Buffer and File/Blob (from multipart form-data)
* @param filename - The name to store the file as
* @param file - The file to upload (Buffer, File, or Blob)
* @param contentType - Optional MIME type of the file
* @returns Promise with the signed URL of the uploaded file
*/
export const uploadFileAndGetUrl = async (
filename: string,
file: Buffer | File | Blob,
contentType?: string
): Promise<string> => {
try {
await ensureBucket();
// Convert File/Blob to Buffer if needed
const fileBuffer = Buffer.isBuffer(file) ? file : await fileToBuffer(file);
// If file is from form-data and no contentType is provided, use its type
const metadata: Record<string, string> = {};
if (!contentType && 'type' in file) {
contentType = file.type;
}
if (contentType) {
metadata['Content-Type'] = contentType;
}
// Upload the file
await minioClient.putObject(
BUCKET_NAME,
filename,
fileBuffer,
fileBuffer.length,
metadata
);
// Generate and return signed URL
const url = await minioClient.presignedGetObject(
BUCKET_NAME,
filename,
SIGNED_URL_EXPIRY
);
return url;
} catch (error) {
console.error('Error uploading file:', error);
if (error instanceof Error) {
throw new Error(`Failed to upload file: ${error.message}`);
}
throw new Error(`Failed to upload file: ${error}`);
}
};
/**
* Get a signed URL for an existing file
* @param filename - The name of the file to get URL for
* @returns Promise with the signed URL
*/
export const getSignedUrl = async (filename: string): Promise<string> => {
try {
// Check if file exists first
await minioClient.statObject(BUCKET_NAME, filename);
const url = await minioClient.presignedGetObject(
BUCKET_NAME,
filename,
SIGNED_URL_EXPIRY
);
return url;
} catch (error) {
console.error('Error generating signed URL:', error);
if (error instanceof Error) throw new Error(`Failed to generate signed URL: ${error.message}`);
throw new Error(`Failed to generate signed URL: ${error}`);
}
};
/**
* Delete a file from MinIO
* @param filename - The name of the file to delete
* @returns Promise<void>
*/
export const deleteFile = async (filename: string): Promise<void> => {
try {
await minioClient.removeObject(BUCKET_NAME, filename);
} catch (error) {
console.error('Error deleting file:', error);
if (error instanceof Error) throw new Error(`Failed to delete file: ${error.message}`);
throw new Error(`Failed to delete file: ${error}`);
}
};
// Usage examples:
// Upload a file and get its URL
// const fileBuffer = Buffer.from('Hello World');
// const url = await uploadFileAndGetUrl('hello.txt', fileBuffer);
// console.log('Uploaded file URL:', url);
// Get signed URL for existing file
// const url = await getSignedUrl('hello.txt');
// console.log('Signed URL:', url);
// Delete a file
// await deleteFile('hello.txt');
// console.log('File deleted successfully');

View file

@ -1,74 +0,0 @@
import { t } from "elysia";
export const commonResponses = {
400: t.Object(
{
data: t.Null(),
success: t.Boolean({ default: false }),
message: t.String({ default: "" }),
error: t.Number({ default: 400 }),
},
{
description:
"Bad Request. Usually due to missing parameters, or invalid parameters.",
}
),
401: t.Object(
{
data: t.Null(),
success: t.Boolean({ default: false }),
message: t.String({ default: "" }),
error: t.Number({ default: 401 }),
},
{
description: "Unauthorized. Due to missing or invalid authentication.",
}
),
403: t.Object(
{
data: t.Null(),
success: t.Boolean({ default: false }),
message: t.String({ default: "" }),
error: t.Number({ default: 403 }),
},
{
description:
"Forbidden. You do not have permission to access this resource or to perform this action.",
}
),
404: t.Object(
{
data: t.Null(),
success: t.Boolean({ default: false }),
message: t.String({ default: "" }),
error: t.Number({ default: 404 }),
},
{
description: "Not Found. The requested resource was not found.",
}
),
429: t.Object(
{
data: t.Null(),
success: t.Boolean({ default: false }),
message: t.String({ default: "" }),
error: t.Number({ default: 429 }),
},
{
description:
"Too Many Requests. You have exceeded the rate limit. Try again later.",
}
),
500: t.Object(
{
data: t.Null(),
success: t.Boolean({ default: false }),
message: t.String({ default: "" }),
error: t.Number({ default: 500 }),
},
{
description:
"Internal Server Error. This is a problem with the server that you cannot fix.",
}
),
};

View file

@ -1,119 +0,0 @@
import { z } from 'zod';
// Define the environment schema
const envSchema = z.object({
// Base
PORT: z.string().max(5),
SERVICE_NAME: z.string(),
// Database
DATABASE_URL: z.string().url(),
// MinIO
MINIO_ACCESS_KEY: z.string(),
MINIO_SECRET_KEY: z.string().min(8),
MINIO_ENDPOINT_URL: z.string().url(),
MINIO_BUCKET_NAME: z.string(),
// Auth
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.string().url(),
});
// Create a type from the schema
type EnvConfig = z.infer<typeof envSchema>;
/**
* Validates environment variables and returns a typed config object
* Prints warnings for missing or invalid variables
*/
export const validateEnv = (): EnvConfig => {
const warnings: string[] = [];
// Parse environment with warning collection
const config = envSchema.safeParse(process.env);
if (!config.success) {
console.warn('\n🚨 Environment Variable Warnings:');
// Collect and categorize warnings
config.error.errors.forEach((error) => {
const path = error.path.join('.');
const message = error.message;
let warningMessage = `${path}: ${message}`;
// Add specific functionality warnings
if (path.startsWith('DB_') || path === 'DATABASE_URL') {
warningMessage += '\n ⚠️ Database functionality may not work properly';
}
if (path.startsWith('MINIO_')) {
warningMessage += '\n ⚠️ File storage functionality may not work properly';
}
if (path.startsWith('BETTER_AUTH_')) {
warningMessage += '\n ⚠️ Authentication functionality may not work properly';
}
warnings.push(warningMessage);
});
// Print all warnings
warnings.forEach((warning) => console.warn(warning));
console.warn('\n');
throw new Error('Environment validation failed. Check warnings above.');
}
return config.data;
};
/**
* Get validated environment config
* Throws error if validation fails
*/
export const getConfig = (): EnvConfig => {
return validateEnv();
};
export const getBaseConfig = (): Pick<EnvConfig, 'PORT' | 'SERVICE_NAME'> => {
const config = getConfig();
return {
PORT: config.PORT,
SERVICE_NAME: config.SERVICE_NAME,
};
};
// Optional: Export individual config getters with type safety
export const getDbConfig = (): Pick<EnvConfig, 'DATABASE_URL'> => {
const config = getConfig();
return {
DATABASE_URL: config.DATABASE_URL,
};
};
export const getMinioConfig = (): Pick<EnvConfig, 'MINIO_ACCESS_KEY' | 'MINIO_SECRET_KEY' | 'MINIO_ENDPOINT_URL' | 'MINIO_BUCKET_NAME'> => {
const config = getConfig();
return {
MINIO_ACCESS_KEY: config.MINIO_ACCESS_KEY,
MINIO_SECRET_KEY: config.MINIO_SECRET_KEY,
MINIO_ENDPOINT_URL: config.MINIO_ENDPOINT_URL,
MINIO_BUCKET_NAME: config.MINIO_BUCKET_NAME,
};
};
export const getAuthConfig = (): Pick<EnvConfig, 'BETTER_AUTH_SECRET' | 'BETTER_AUTH_URL'> => {
const config = getConfig();
return {
BETTER_AUTH_SECRET: config.BETTER_AUTH_SECRET,
BETTER_AUTH_URL: config.BETTER_AUTH_URL,
};
};
// Usage example:
try {
const config = getConfig();
// Your application code here
} catch (error) {
// Handle validation errors
process.exit(1);
}

View file

@ -1,13 +1,15 @@
import { Session, User } from "better-auth/types"; import { Session, User } from "better-auth/types";
import { auth } from "../lib/auth/auth"; import { auth } from "../lib/auth/auth";
import { error } from "elysia";
// user middleware (compute user and session and pass to routes) // user middleware (compute user and session and pass to routes)
export const userMiddleware = async (request: Request) => { export const userMiddleware = async (request: Request) => {
const session = await auth.api.getSession({ headers: request.headers }); const session = await auth.api.getSession({ headers: request.headers });
if (!session) { if (!session) {
return error("Unauthorized", 401); return {
user: null,
session: null,
};
} }
return { return {