diff --git a/.env b/.env new file mode 100644 index 0000000..aaeba75 --- /dev/null +++ b/.env @@ -0,0 +1,29 @@ +SERVER_URL=http://localhost +SERVER_PORT=3000 + +DATABASE_URL=postgres://postgres:saimon%40567@localhost:5432/planpost_canvas_dev + +MINIO_ACCESS_KEY=rEiuiqB8JCSmWt7AswOM +MINIO_SECRET_KEY=en3ut7Zp71uAfGrhvMkH6Pk7ZM1qZb9mFxj7KzD5 +MINIO_ENDPOINT=localhost +MINIO_PORT=9000 + +JWT_ACCESS_TOKEN_SECRET=planpostai%^$_%43%65576canvas_dev_2025_planpostai!@43223_canvas$%%$$ + +JWT_REFRESH_TOKEN_SECRET=planpostai!@!@@**&$$43223_canvas_dev_2025_planpostai@45342356$%^$349332$$ + +JWT_EMAIL_TOKEN_SECRET=planpostAi!!@@!!!3242d2345ffdddf4^$367sss744!!@canvas + +JWT_EMAIL_RESET_PASSWORD_SECRET=planpostAi!!!3ddssdeersssdffd^$367744!!!!@@!!!3242234ff54^$3677ff44!!@canvas + + +USER_CANVAS_JWT_ACCESS_TOKEN_SECRET=planpostai%^$_%43%65576canvas%%$$ + +MAIL_HOST=mail.adspillar.com +MAIL_PORT=465 +MAIL_USER=canvas@adspillar.com +MAIL_PASS=Adspillar2025!!canvas!! + +PEXELS_URL=https://api.pexels.com/v1 +PEXELS_ACCESS_KEY=6PK7hLvOuG6nFsmC8c9EV0P8hGkyHeIhYpiRxhkEfh2ePK0GhiQypBhI + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87e5610 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +**/*.trace +**/*.zip +**/*.tar.gz +**/*.tgz +**/*.log +package-lock.json +**/*.bun \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100644 index 0000000..61569aa Binary files /dev/null and b/bun.lockb differ diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..5f6e03a --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'drizzle-kit'; +import { ENV } from './src/config/env'; + +export default defineConfig({ + out: './drizzle', + schema: './src/db/schema.ts', + dialect: 'postgresql', + dbCredentials: { + url: ENV.DATABASE_URL!, + }, +}); diff --git a/drizzle/0000_workable_iron_man.sql b/drizzle/0000_workable_iron_man.sql new file mode 100644 index 0000000..5e49d87 --- /dev/null +++ b/drizzle/0000_workable_iron_man.sql @@ -0,0 +1,52 @@ +CREATE TABLE "project_category" ( + "category_id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid, + "category" text NOT NULL, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE "projects" ( + "project_id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "userId" uuid, + "category" uuid, + "object" json, + "name" text, + "description" text, + "is_public" boolean DEFAULT true NOT NULL, + "preview_url" text, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE "shapes" ( + "shape_id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "shapes" text NOT NULL, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE "uploads" ( + "upload_id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "filename" text NOT NULL, + "url" text NOT NULL, + "projectId" uuid, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE "users" ( + "user_id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" text NOT NULL, + "name" text, + "password" text NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "is_verified" boolean DEFAULT false NOT NULL, + "is_admin" boolean DEFAULT false NOT NULL, + "refresh_token" text +); +--> statement-breakpoint +ALTER TABLE "project_category" ADD CONSTRAINT "project_category_user_id_users_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "projects" ADD CONSTRAINT "projects_userId_users_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "projects" ADD CONSTRAINT "projects_category_project_category_category_id_fk" FOREIGN KEY ("category") REFERENCES "public"."project_category"("category_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "uploads" ADD CONSTRAINT "uploads_projectId_projects_project_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..60a359e --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,354 @@ +{ + "id": "90741dda-2291-48fc-a730-1d0c20ef1991", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.project_category": { + "name": "project_category", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_category_user_id_users_user_id_fk": { + "name": "project_category_user_id_users_user_id_fk", + "tableFrom": "project_category", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "object": { + "name": "object", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "preview_url": { + "name": "preview_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_userId_users_user_id_fk": { + "name": "projects_userId_users_user_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_category_project_category_category_id_fk": { + "name": "projects_category_project_category_category_id_fk", + "tableFrom": "projects", + "tableTo": "project_category", + "columnsFrom": [ + "category" + ], + "columnsTo": [ + "category_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shapes": { + "name": "shapes", + "schema": "", + "columns": { + "shape_id": { + "name": "shape_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shapes": { + "name": "shapes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.uploads": { + "name": "uploads", + "schema": "", + "columns": { + "upload_id": { + "name": "upload_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "projectId": { + "name": "projectId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "uploads_projectId_projects_project_id_fk": { + "name": "uploads_projectId_projects_project_id_fk", + "tableFrom": "uploads", + "tableTo": "projects", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "project_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_verified": { + "name": "is_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..43dae21 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1739942538665, + "tag": "0000_workable_iron_man", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/env.example.js b/env.example.js new file mode 100644 index 0000000..13e0dc3 --- /dev/null +++ b/env.example.js @@ -0,0 +1,18 @@ +// all of these are required to run the project + + +// SERVER_URL +// SERVER_PORT + +// DATABASE_URL + +// MINIO_ACCESS_KEY +// MINIO_SECRET_KEY +// MINIO_ENDPOINT +// MINIO_PORT + +// CLERK_SECRET_KEY + +// JWT_ACCESS_TOKEN_SECRET + +// JWT_REFRESH_TOKEN_SECRET \ No newline at end of file diff --git a/env.examples b/env.examples new file mode 100644 index 0000000..798e1ca --- /dev/null +++ b/env.examples @@ -0,0 +1,28 @@ +SERVER_URL= +SERVER_PORT= + +DATABASE_URL= + +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +MINIO_ENDPOINT= +MINIO_PORT= + +JWT_ACCESS_TOKEN_SECRET= + +JWT_REFRESH_TOKEN_SECRET= + +JWT_EMAIL_TOKEN_SECRET= + +JWT_EMAIL_RESET_PASSWORD_SECRET= + + +USER_CANVAS_JWT_ACCESS_TOKEN_SECRET= + +MAIL_HOST= +MAIL_PORT= +MAIL_USER= +MAIL_PASS= + +PEXELS_URL= +PEXELS_ACCESS_KEY= \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..a34c745 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "canvas_backend", + "version": "1.0.50", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "db:studio": "drizzle-kit studio", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:push": "drizzle-kit push:pg", + "dev": "bun run --watch src/app.ts" + }, + "dependencies": { + "@elysiajs/cookie": "^0.8.0", + "@elysiajs/cors": "^1.2.0", + "@elysiajs/swagger": "^1.2.0", + "dotenv": "^16.4.7", + "drizzle-orm": "^0.38.4", + "elysia": "latest", + "jsonwebtoken": "^9.0.2", + "minio": "^8.0.3", + "nodemailer": "^6.10.0", + "pg": "^8.13.1", + "postgres": "^3.4.5" + }, + "devDependencies": { + "@types/pg": "^8.11.10", + "bun-types": "latest", + "drizzle-kit": "^0.30.2", + "tsx": "^4.19.2" + }, + "module": "src/app.js" +} \ No newline at end of file diff --git a/src/api/auth/auth.controller.ts b/src/api/auth/auth.controller.ts new file mode 100644 index 0000000..9ca4ca2 --- /dev/null +++ b/src/api/auth/auth.controller.ts @@ -0,0 +1,150 @@ +import { ENV } from "../../config/env" +// @ts-ignore +import jwt from "jsonwebtoken"; +import { sendResetPasswordEmail, sendVerificationEmail, storeRefreshToken } from "../../helper/auth/auth.helper"; +import { db } from "../../db"; +import { users } from "../../db/schema"; +// @ts-ignore +import { eq } from "drizzle-orm"; +import path from "path"; + +export const registerUser = async (email: string, password: string, name: string, set: any) => { + const bcryptHash = await Bun.password.hash(password, { + algorithm: "bcrypt", + cost: 10, // number between 4-31 + }); + + const saveUser = await db.insert(users).values({ email, password: bcryptHash, name }).returning({ id: users.id }); + + if (saveUser.length > 0 && saveUser[0].id) { + const token = jwt.sign({ id: saveUser[0].id }, ENV.JWT_EMAIL_TOKEN_SECRET, { expiresIn: '10m' }); + const response = await sendVerificationEmail(email, token, set); + return response; + } else { + set.status = 500; + return { status: 500, message: "Failed to register user" }; + } +}; + +export const loginUser = async (email: string, cookie: any, set: any) => { + try { + // generate access token + const accessToken = jwt.sign({ email }, ENV.JWT_ACCESS_TOKEN_SECRET, { expiresIn: '3h' }); + + // generate refresh token + const refreshToken = jwt.sign({ email }, ENV.JWT_REFRESH_TOKEN_SECRET, { expiresIn: '7d' }); + + // store refresh token in db + const storeRToken = await storeRefreshToken(email, refreshToken); + + if (storeRToken.success === true) { + cookie.access_token.set({ + value: accessToken, + httpOnly: true, + secure: true, // Set to true in production + sameSite: 'none', // Adjust based on your needs + path: "/", + maxAge: 3 * 60 * 60, // 3 hours in seconds + }); + cookie.refresh_token.set({ + value: refreshToken, + httpOnly: true, + secure: true, // Set to true in production + sameSite: 'none', // Adjust based on your needs + path: "/", + maxAge: 7 * 24 * 60 * 60, // 7 days in seconds + }); + set.status = 200; + return { status: 200, message: "User log-in successful", token: accessToken }; + } + else { + set.status = 500; + return { status: 500, message: storeRToken.message }; + } + } catch (error) { + console.error(error); + set.status = 500; + return { status: 500, message: "Internal server error" }; + } +} + +// for resetting password, this will send the verification e-mail +export const resetPassword = async (email: string, set: any) => { + try { + const user = await db.select({ id: users.id }).from(users).where(eq(users.email, email)); + if (user.length === 0) { + set.status = 404; + return { status: 404, message: "User not found" }; + } + else { + const token = jwt.sign({ id: user[0].id }, ENV.JWT_EMAIL_RESET_PASSWORD_SECRET, { expiresIn: '10m' }); + const sendEmail = await sendResetPasswordEmail(email, token, set); + return sendEmail; + } + } + catch (error) { + console.error(error); + set.status = 500; + return { status: 500, message: "Internal server error" }; + } +} + +// for resending verification e-mail this is only for users who have not verified their e-mail yet +export const resendVerificationEmail = async (email: string, set: any) => { + try { + const user = await db.select({ id: users.id, isVerified: users.is_verified, isActive: users.is_active }).from(users).where(eq(users.email, email)); + if (user.length === 0) { + set.status = 404; + return { status: 404, message: "User not found" }; + } + else { + if (user[0].isVerified) { + set.status = 400; + return { status: 400, message: "User already verified" }; + } + else if (!user[0].isActive) { + set.status = 400; + return { status: 400, message: "User is not active" }; + } + else { + const token = jwt.sign({ id: user[0].id }, ENV.JWT_EMAIL_TOKEN_SECRET, { expiresIn: '10m' }); + const sendEmail = await sendVerificationEmail(email, token, set); + return sendEmail; + } + } + } catch (error) { + console.error(error); + set.status = 500; + return { status: 500, message: "Internal server error" }; + } +} + +// this controller is for e-mail verification +export const verifyUser = async (id: string, set: any) => { + try { + const user = await db.select({ isVerified: users.is_verified }).from(users).where(eq(users.id, id)); + + if (user.length === 0) { + set.status = 404; + return Bun.file(path.resolve('./src/html/error.html')); + } + + if (user[0].isVerified) { + set.status = 200; + return Bun.file(path.resolve('./src/html/alreadyVerify.html')); + } + + await db.update(users).set({ is_verified: true }).where(eq(users.id, id)); + set.status = 200; + return Bun.file(path.resolve('./src/html/success.html')); + } catch (error) { + console.log(error); + set.status = 500; + return { status: 500, message: "Internal server error" }; + } +} + + + + + diff --git a/src/api/auth/auth.route.ts b/src/api/auth/auth.route.ts new file mode 100644 index 0000000..49ade50 --- /dev/null +++ b/src/api/auth/auth.route.ts @@ -0,0 +1,178 @@ +import Elysia, { t } from "elysia"; +import { verifyAuth } from "../../middlewares/auth.middlewares"; +import { loginUser, registerUser, resendVerificationEmail, resetPassword, verifyUser } from "./auth.controller"; +import { checkUserInDb } from "../../helper/auth/auth.helper"; +// @ts-ignore +import jwt from "jsonwebtoken"; +import { ENV } from "../../config/env"; +import path from "path"; +import { db } from "../../db"; +import { users } from "../../db/schema"; +import { eq } from "drizzle-orm"; + +export const authRoute = new Elysia({ + prefix: "/auth", + tags: ["Auth"], + detail: { + description: "Routes for managing users", + } +}); + +authRoute.post("/login", async ({ body, cookie, set }) => { + const { email, password } = body; + const findUser = await checkUserInDb(email, password); + if (findUser.success === true && findUser.can_login === true) { + const response = await loginUser(findUser.email as string, cookie, set); + return response; + } + else if (findUser.message === "User not verified") { + set.status = 401; + return { status: 401, message: "User not verified, please verify your email" }; + } + else { + set.status = 404; + return { status: 404, message: findUser.message }; + } +}, { + body: t.Object({ + email: t.String(), + password: t.String() + }) +}); + +authRoute.post("/register", async ({ body, set }) => { + const { email, password, name } = body; + const findUser = await checkUserInDb(email, password); + if (findUser.success === true && findUser.can_register === true) { + const response = await registerUser(email, password, name, set); + return response; + } + else { + set.status = 409; + return { status: 409, message: "User already exists, please login" }; + } +}, { + body: t.Object({ + email: t.String(), + password: t.String(), + name: t.String(), + }) +}) + +authRoute.get("/isAuthenticated", async ({ cookie }) => { + const response = await verifyAuth(cookie); + return response; +}) + +// for resetting password +authRoute.post('/reset-password/verify', async ({ body, set }) => { + const { email } = body; + const response = await resetPassword(email, set); + return response; +}, { + body: t.Object({ + email: t.String(), + }) +}) + +// for e-mail verification +authRoute.get('/verify', async ({ query, set }) => { + const { token } = query; + try { + const decoded = jwt.verify(token, ENV.JWT_EMAIL_TOKEN_SECRET); + const response = await verifyUser(decoded.id as string, set); + return response; + } catch (error: any) { + set.status = 400; + if (error.name === 'TokenExpiredError') { + return Bun.file(path.resolve('./src/html/error.html')); + } + return Bun.file(path.resolve('./src/html/error.html')); + } +}); + +// for resend verification email, if user did not receive the verification email or the verification email expired +authRoute.post('/resend-verification', async ({ body, set }) => { + const { email } = body; + const response = await resendVerificationEmail(email, set); + return response; +}, { + body: t.Object({ + email: t.String(), + }) +}); + +authRoute.get("/reset-password", async ({ query, set }) => { + const { token } = query; + try { + const decoded = jwt.verify(token, ENV.JWT_EMAIL_RESET_PASSWORD_SECRET) as { id: string }; + + if (!decoded?.id) { + set.status = 400; + return { status: 400, message: "Invalid token" }; + } + + // Serve the reset password page where the user can enter a new password + return Bun.file(path.resolve('./src/html/resetPassword.html')); + + } catch (error: any) { + set.status = 400; + if (error.name === 'TokenExpiredError') { + return Bun.file(path.resolve('./src/html/error.html')); + } + return Bun.file(path.resolve('./src/html/error.html')); + } +}, { + query: t.Object({ + token: t.String(), + }) +}); + +authRoute.post("/reset-password", async ({ query, set, body }) => { + const { token } = query; + const { password } = body; + + try { + const decoded = jwt.verify(token, ENV.JWT_EMAIL_RESET_PASSWORD_SECRET) as { id: string }; + + if (!decoded?.id) { + set.status = 400; + return { status: 400, message: "Invalid token" }; + } + + // Hash the new password + const bcryptHash = await Bun.password.hash(password, { + algorithm: "bcrypt", + cost: 10, + }); + + // Update password in the database + const updatePassword = await db + .update(users) + .set({ password: bcryptHash }) + .where(eq(users.id, decoded.id)); + + if (updatePassword?.rowCount !== 0) { + return { status: 200, message: "Password updated successfully" }; + } else { + set.status = 400; + return { status: 400, message: "Password update failed" }; + } + + } catch (error: any) { + set.status = 400; + if (error.name === "TokenExpiredError") { + return { status: 400, message: "Token has expired" }; + } + return { status: 400, message: "Invalid request" }; + } +}, { + body: t.Object({ + password: t.String(), + }), + query: t.Object({ + token: t.String(), + }) +}); + + diff --git a/src/api/category/category.controller.ts b/src/api/category/category.controller.ts new file mode 100644 index 0000000..ed9919c --- /dev/null +++ b/src/api/category/category.controller.ts @@ -0,0 +1,45 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../db"; +import { category } from "../../db/schema"; + +export const getAllCategory = async (token: string) => { + try { + const allCategory = await db.select({ id: category?.id, name: category?.category }).from(category); + + if (allCategory.length === 0) { + return { status: 404, message: "No categories found", token } + } + + return { status: 200, message: "Categories fetched successfully", data: allCategory, token }; + + } catch (error: any) { + console.error('Error fetching categories:', error); + return { status: 500, message: "An error occurred while fetching the categories", token }; + } +} + +export const createCategory = async (token: string, name: string, user_id: string) => { + try { + const newCategory = await db.insert(category).values({ category: name, user_id: user_id }); + + return { status: 200, message: "Category created successfully", data: newCategory, token }; + + } catch (error: any) { + console.error('Error creating category:', error); + return { status: 500, message: "An error occurred while creating the category", token }; + } +} + +export const deleteCategory = async (token: string, id: string) => { + try { + const deleted = await db.delete(category).where(eq(category?.id, id)).returning({ id: category?.id, name: category?.category }); + if (deleted.length === 0) { + return { status: 404, message: "Category not found", token }; + } + return { status: 200, message: "Category deleted successfully", data: deleted, token }; + } + catch (error: any) { + console.error('Error deleting category:', error); + return { status: 500, message: "An error occurred while deleting the category", token }; + } +} \ No newline at end of file diff --git a/src/api/category/category.route.ts b/src/api/category/category.route.ts new file mode 100644 index 0000000..c1e553e --- /dev/null +++ b/src/api/category/category.route.ts @@ -0,0 +1,53 @@ +import Elysia, { t } from "elysia"; +import { verifyAuth } from "../../middlewares/auth.middlewares"; +import { createCategory, deleteCategory, getAllCategory } from "./category.controller"; + +export const categoryRoutes = new Elysia({ + prefix: "/category", + tags: ["Category routes"], + detail: { + description: "Routes for managing project category", + } +}).derive(async ({ cookie }) => { + const authData = await verifyAuth(cookie); + return { authData }; // Inject into context +}); + +categoryRoutes.get("/get-all", async ({ authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const data = await getAllCategory(token); + return data; + } +}) + +categoryRoutes.post("/create", async ({ authData, body: { name } }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const user_id = authData?.userId; + const data = await createCategory(token, name, user_id); + return data; + } +}, { + body: t.Object({ + name: t.String(), + }) +}) + +categoryRoutes.delete("/delete/:id", async ({ authData, params: { id } }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const data = await deleteCategory(token, id); + return data; + } +}, { + params: t.Object({ + id: t.String(), + }) +}) \ No newline at end of file diff --git a/src/api/design/design.controller.ts b/src/api/design/design.controller.ts new file mode 100644 index 0000000..697a164 --- /dev/null +++ b/src/api/design/design.controller.ts @@ -0,0 +1,29 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../db"; +import { projects } from "../../db/schema"; + +export const getAllDesign = async (token: string) => { + try { + const getDesign = await db.select({ + object: projects.object, + name: projects.name, + description: projects.description, + preview_url: projects.preview_url, + }).from(projects).where(eq(projects.is_public, true)); + + // Filter out projects where the object field is an empty object + const filteredDesigns = getDesign?.filter(project => + project.object && Object.keys(project.object).length > 0 + ); + + if (filteredDesigns.length === 0) { + return { status: 404, message: "No designs found", token }; + } else { + return { status: 200, message: "Designs fetched successfully", data: filteredDesigns, token }; + } + + } catch (error: any) { + console.error('Error fetching designs:', error); + return { status: 500, message: "Internal Server Error" }; + } +}; diff --git a/src/api/design/design.route.ts b/src/api/design/design.route.ts new file mode 100644 index 0000000..5d66136 --- /dev/null +++ b/src/api/design/design.route.ts @@ -0,0 +1,28 @@ +import { Elysia, t } from "elysia"; +import { ENV } from "../../config/env"; +// @ts-ignore +import jwt from "jsonwebtoken"; +import { getAllDesign } from "./design.controller"; + +export const designRoutes = new Elysia({ + prefix: "/design", + tags: ["Design"], + detail: { + description: "Routes for managing design", + } +}).derive(({ headers }) => { + const token = headers?.authorization?.split(" ")[1]; + return { token }; +}); + +designRoutes.get("/", async ({ token }) => { + const verifyToken = await jwt.verify(token, ENV.USER_CANVAS_JWT_ACCESS_TOKEN_SECRET); + if (!verifyToken) { + return { status: 401, message: "Unauthorized" } + } + else { + const data = await getAllDesign(token as string); + return data; + } +}); + diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..212e676 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,24 @@ +import Elysia from "elysia"; +import { projectRoutes } from "./project/project.route"; +import { uploadRoutes } from "./upload/upload.route"; +import { authRoute } from "./auth/auth.route"; +import { uploadShapesRoutes } from "./uploadShapes/upload.shapes.route"; +import { photoLibraryRoutes } from "./photoLibrary/photo.library.route"; +import { categoryRoutes } from "./category/category.route"; +import { designRoutes } from "./design/design.route"; + +export const api = new Elysia({ + prefix: "/api", +}); + +api.get("/", () => { + return "Hello from PlanPostAI Canvas API" +}) + +api.use(authRoute); +api.use(projectRoutes); +api.use(uploadRoutes); +api.use(photoLibraryRoutes); +api.use(uploadShapesRoutes); +api.use(categoryRoutes); +api.use(designRoutes); \ No newline at end of file diff --git a/src/api/photoLibrary/photo.library.controller.ts b/src/api/photoLibrary/photo.library.controller.ts new file mode 100644 index 0000000..82f9b0c --- /dev/null +++ b/src/api/photoLibrary/photo.library.controller.ts @@ -0,0 +1,21 @@ +import { ENV } from "../../config/env"; + +export const getPhotos = async (keyword: string, pre_page: number, token: string) => { + try { + const url = `${ENV.PEXELS_URL}/search?query=${keyword}&per_page=${pre_page}`; + const response = await fetch(url, { + headers: { + Authorization: process.env.PEXELS_ACCESS_KEY as string, + }, + }) + + if (!response.ok) { + return { status: 500, message: "An error occurred while getting the photos", token } + } + const data = await response.json(); + return { data, token } + } catch (error: any) { + console.log("Error in getting photos:", error.message || error.toString()); + return { status: 500, message: "An error occurred while getting the photos", token }; + } +} \ No newline at end of file diff --git a/src/api/photoLibrary/photo.library.route.ts b/src/api/photoLibrary/photo.library.route.ts new file mode 100644 index 0000000..643d775 --- /dev/null +++ b/src/api/photoLibrary/photo.library.route.ts @@ -0,0 +1,31 @@ +import Elysia, { t } from "elysia"; +import { verifyAuth } from "../../middlewares/auth.middlewares"; +import { getPhotos } from "./photo.library.controller"; + +export const photoLibraryRoutes = new Elysia({ + prefix: "/photos", + tags: ["Photo library"], + detail: { + description: "Routes for managing photo library", + } +}).derive(async ({ cookie }) => { + const authData = await verifyAuth(cookie); + return { authData }; // Inject into context +}); + +photoLibraryRoutes.get("/", async ({ query, authData +}) => { + if (authData?.status !== 200) + return authData; + else { + const { keyword, per_page } = query; + const token = authData?.token; + const data = await getPhotos(keyword, per_page, token); + return { data }; + } +}, { + query: t.Object({ + keyword: t.String(), + per_page: t.Number(), + }) +}) \ No newline at end of file diff --git a/src/api/project/project.controller.ts b/src/api/project/project.controller.ts new file mode 100644 index 0000000..7730a0c --- /dev/null +++ b/src/api/project/project.controller.ts @@ -0,0 +1,191 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../db"; +import { category, projects, uploads } from "../../db/schema"; +import { createEmptyProject } from "../../helper/projects/createProject"; +import { createBucket } from "../../helper/upload/createBucket"; +import { removeBucket } from "../../helper/upload/removeBucket"; + +export const getAllProjects = async (userId: string, token: string) => { + try { + // Fetch all projects for the given user + const allProjects = await db.select({ + id: projects.id, + name: projects.name, + description: projects.description, + preview_url: projects.preview_url, + object: projects.object, + category_name: category.category, + category: category.id, + }).from(projects).leftJoin(category, eq(projects.category, category.id)).where(eq(projects.userId, userId)); + + // Identify projects where 'object' is empty or 'object.objects' is empty + const projectsToDelete = allProjects?.filter(proj => + (proj.object && typeof proj.object === "object" && Object.keys(proj.object).length === 0) || + (proj.object?.objects && Array.isArray(proj.object.objects) && proj.object.objects.length === 0) + ); + + // Delete projects with empty 'object' or empty 'object.objects' + await Promise.all( + projectsToDelete?.map(async (proj) => { + // Step 1: Delete associated uploads first + await db.delete(uploads).where(eq(uploads.projectId, proj.id)); + + // Step 2: Delete the project itself + await db.delete(projects).where(eq(projects.id, proj.id)); + + // Step 3: Delete the associated bucket + await removeBucket(proj.id); + }) + ); + + // Get remaining projects + const remainingProjects = allProjects?.filter(proj => + !( + (proj.object && typeof proj.object === "object" && Object.keys(proj.object).length === 0) || + (proj.object?.objects && Array.isArray(proj.object.objects) && proj.object.objects.length === 0) + ) + ); + + if (remainingProjects?.length === 0) { + return { status: 404, message: "No projects found", token }; + } + + return { status: 200, message: "Projects fetched successfully", data: remainingProjects, token }; + } catch (error: any) { + console.log(error.message); + return { status: 500, message: "An error occurred while fetching projects", token }; + } +}; + +export const getEachProjects = async (id: string, token: string) => { + try { + const project = await db.select({ + id: projects.id, + name: projects.name, + description: projects.description, + preview_url: projects.preview_url, + object: projects.object, + category_name: category.category, + category_id: projects.category, + }).from(projects).leftJoin(category, eq(projects.category, category.id)).where(eq(projects.id, id)); + + if (project.length === 0) { + return { status: 404, message: "Project not found", token }; + } + return { status: 200, message: "Project fetched successfully", data: project[0], token }; + } catch (error: any) { + console.log(error.message); + return { status: 500, message: "An error occurred while fetching projects", token }; + } +}; + +export const createProject = async (userId: string, token: string) => { + try { + const { id } = await createEmptyProject(userId); + const bucket = await createBucket(id); + return { status: 200, message: "New project created successfully", data: { id, bucketName: bucket }, token }; + + } catch (error: any) { + console.log(error.message); + return { status: 500, message: "An error occurred while creating projects", token } + } +}; + +export const updateProject = async (id: string, body: any, token: string) => { + try { + // 1. Validate if project exists + const existingProject = await db.select().from(projects).where(eq(projects.id, id)); + if (existingProject.length === 0) { + return { status: 404, message: "Project not found", token }; + } + + const { object, name, description, preview_url, category } = body; + + // The preview_url will come from client-side as well, where before updating the project a project capture will be taken and uploaded to the bucket. than the url will be sent to the server.And rest of them are normal process + + // 2. Validate if the category exists + if (category === "" || category === undefined || category === null) { + const updatedProject = await db.update(projects).set({ + object, + name, + description, + preview_url, + }).where(eq(projects.id, id)).returning({ + id: projects.id, + object: projects.object, + name: projects.name, + description: projects.description, + preview_url: projects.preview_url, + category: projects.category + }); + + if (updatedProject.length === 0) { + return { status: 500, message: "Failed to update the project", token }; + } + return { status: 200, message: "Project updated successfully", data: updatedProject[0], token }; + } + else { + const updatedProject = await db.update(projects).set({ + object, + name, + description, + preview_url, + category + }).where(eq(projects.id, id)).returning({ + id: projects.id, + object: projects.object, + name: projects.name, + description: projects.description, + preview_url: projects.preview_url, + category: projects.category + }); + + if (updatedProject.length === 0) { + return { status: 500, message: "Failed to update the project", token }; + } + return { status: 200, message: "Project updated successfully", data: updatedProject[0], token }; + } + } catch (error: any) { + console.log("Error updating project:", error.message || error.toString()); + return { status: 500, message: "An error occurred while updating the project", token }; + } +}; + +export const deleteProject = async (id: string, token: string) => { + try { + const deletedUploads = await db + .delete(uploads) + .where(eq(uploads.projectId, id)) + .returning({ id: uploads.id }); + + if (deletedUploads.length >= 0) { + // Step 4: Delete the project + const deletedProject = await db + .delete(projects) + .where(eq(projects.id, id)) + .returning({ id: projects.id }); + + if (deletedProject.length === 0) { + return { status: 404, message: "Project not found", token }; + } + + // Step 5: Delete the associated bucket + const bucketDeletionResult = await removeBucket(id); + + if (bucketDeletionResult.status !== 200) { + return { + status: bucketDeletionResult.status, + message: `Error deleting bucket: ${bucketDeletionResult.message}`, + token + }; + } + return { status: 200, message: "Project and associated bucket deleted successfully", token }; + } + } catch (error: any) { + console.log("Error in deleteProject:", error.message || error.toString()); + return { status: 500, message: "An error occurred while deleting the project", token }; + } +}; + + + diff --git a/src/api/project/project.route.ts b/src/api/project/project.route.ts new file mode 100644 index 0000000..2ad5b20 --- /dev/null +++ b/src/api/project/project.route.ts @@ -0,0 +1,86 @@ +import { Elysia, t } from "elysia"; +import { createProject, deleteProject, getAllProjects, getEachProjects, updateProject } from "./project.controller"; +import { verifyAuth } from "../../middlewares/auth.middlewares"; + +export const projectRoutes = new Elysia({ + prefix: "/projects", + tags: ["Projects"], + detail: { + description: "Routes for managing projects", + } +}).derive(async ({ cookie }) => { + const authData = await verifyAuth(cookie); + return { authData }; // Inject into context +}); + +projectRoutes.get("/each/:project_id", async ({ params: { project_id }, authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const response = await getEachProjects(project_id, token); + return response; + } +}, { + params: t.Object({ + project_id: t.String() + }) +}); + +projectRoutes.get("/", async ({ authData }: any) => { + if (authData?.status !== 200) + return authData; + else { + const userId = authData.userId; + const token = authData.token; + const response = await getAllProjects(userId, token); + return response; + } +}); + +projectRoutes.post("/create", async ({ authData }: any) => { + if (authData?.status !== 200) + return authData; + else { + const userId = authData.userId; + const token = authData.token; + const response = await createProject(userId, token); + return response; + } +}); + +projectRoutes.put("/update/:project_id", async ({ body, params: { project_id }, authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const response = await updateProject(project_id, body, token); + return response; + } +}, { + params: t.Object({ + project_id: t.String() + }), + body: t.Object({ + object: t.Record(t.String(), t.Any()), // Allows any JSON object + name: t.String(), + description: t.String(), + preview_url: t.String(), + category: t.String(), + }) +}); + +projectRoutes.delete("/delete/:project_id", async ({ params: { project_id }, authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const response = await deleteProject(project_id, token); + return response; + } +}, { + params: t.Object({ + project_id: t.String() + }) +}); + diff --git a/src/api/upload/upload.controller.ts b/src/api/upload/upload.controller.ts new file mode 100644 index 0000000..4d7fab5 --- /dev/null +++ b/src/api/upload/upload.controller.ts @@ -0,0 +1,106 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../db"; +import { projects, uploads } from "../../db/schema"; +import { uploadToMinio } from "../../helper/upload/uploadToMinio"; +import { removeFromMinio } from "../../helper/upload/removeFromMinio"; + +export const uploadPhoto = async (file: File, project_id: string, userId: string, token: string) => { + try { + // Validate userId + if (!userId || typeof userId !== "string") { + return { status: 400, message: "Invalid user ID", token }; + } + + // Validate projectId + if (!project_id || typeof project_id !== "string") { + return { status: 400, message: "Invalid project ID", token }; + } + + // Validate file input + if (!file || !(file instanceof File) || !file.name) { + return { status: 400, message: "Invalid or missing file", token }; + } + const findProject = await db.select().from(projects).where(eq(projects.id, project_id)); + + if (findProject.length > 0) { + // Extract file extension (e.g., ".jpg", ".png") + const fileExtension = file.name.substring(file.name.lastIndexOf(".")); + + // Generate a unique filename using the timestamp + const timestamp = Date.now(); // Current timestamp in milliseconds + const uniqueFileName = `${file.name.split(".")[0]}-${timestamp}${fileExtension}`; + + // Upload file to MinIO with the unique filename + const urlLink = await uploadToMinio(file, project_id, uniqueFileName); + if (!urlLink || !urlLink.url) { + return { status: 500, message: "File upload failed", token }; + } + + // Save file info in DB with modified filename + const saveFile = await db.insert(uploads).values({ + filename: uniqueFileName, + url: urlLink.url, + projectId: project_id, + }).returning(); + + return { status: 200, message: "File uploaded successfully", data: saveFile, token }; + } + else { + return { status: 404, message: "No projects found with this project id", token } + } + } catch (error: any) { + console.error("Error processing file:", error); + return { status: 500, message: "An error occurred while uploading the photo", token }; + } +}; + +export const deletePhoto = async (url: string, token: string) => { + try { + if (!url) { + return { status: 404, message: "File url is missing", token } + } + + const deleteFile = await db + .delete(uploads) + .where(eq(uploads.url, url)) + .returning(); + + // Ensure there's a file to delete + if (!deleteFile || deleteFile.length === 0) { + return { status: 404, message: "File not found", token }; + } + + const { projectId, filename } = deleteFile[0]; + + // Ensure projectId and filename are valid + if (!projectId || !filename) { + return { status: 400, message: "Invalid project ID or filename", token }; + } + + const minioRemove = await removeFromMinio(projectId, filename); + + return { status: 200, message: minioRemove.msg, token }; + + } catch (error: any) { + console.error("Error processing file:", error); + return { status: 500, message: `An error occurred while deleting the photo: ${error.message}`, token }; + } +}; + +export const getAllPhoto = async (id: string, token: string) => { + try { + // project id + if (!id) { + return { status: 404, message: "Project ID is missing", token } + } + const getAllPhoto = await db.select().from(uploads).where(eq(uploads.projectId, id)); + if (getAllPhoto.length === 0) { + return { status: 200, message: "No photos found for the given project ID", data: [], token } + } + return { status: 200, message: "All photos retrieved successfully", data: getAllPhoto, token }; + + } catch (error: any) { + console.log(`Error getting photos: ${error.message}`); + return { status: 500, message: "An error occurred while getting the photos", token } + } +} diff --git a/src/api/upload/upload.route.ts b/src/api/upload/upload.route.ts new file mode 100644 index 0000000..1ed2699 --- /dev/null +++ b/src/api/upload/upload.route.ts @@ -0,0 +1,60 @@ +import { Elysia, t } from "elysia"; +import { deletePhoto, getAllPhoto, uploadPhoto } from "./upload.controller"; +import { verifyAuth } from "../../middlewares/auth.middlewares"; + +export const uploadRoutes = new Elysia({ + prefix: "/uploads", + tags: ["Uploads"], + detail: { + description: "Routes for uploading and managing photos", + } +}).derive(async ({ cookie }) => { + const authData = await verifyAuth(cookie); + return { authData }; // Inject into context +}); + +uploadRoutes.post("/add", async ({ body, authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const user_id: String | any = authData?.userId; + const { id: project_id, file } = body; + const response = await uploadPhoto(file, project_id, user_id, token); + return response; + } +}, { + body: t.Object({ + file: t.File(), + id: t.String(), + }) +}); + +uploadRoutes.delete("/delete", async ({ query, authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const { url } = query; + const response = await deletePhoto(url, token); + return response; + } +}, { + query: t.Object({ + url: t.String(), + }) +}); + +uploadRoutes.get("/getAll/:id", async ({ params: { id }, authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const response = await getAllPhoto(id, token); + return response; + } +}, { + params: t.Object({ + id: t.String() + }) +}); \ No newline at end of file diff --git a/src/api/uploadShapes/upload.shapes.controller.ts b/src/api/uploadShapes/upload.shapes.controller.ts new file mode 100644 index 0000000..ef2bced --- /dev/null +++ b/src/api/uploadShapes/upload.shapes.controller.ts @@ -0,0 +1,42 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../db"; +import { shapes } from "../../db/schema"; + +export const uploadShapes = async (shape: string, token: string) => { + // This function is responsible for uploading shapes to the server. + try { + const shapeUpload = await db.insert(shapes).values({ shapes: shape }).returning(); + return { status: 200, message: "Shape uploaded successfully", token, shapeUpload }; + } catch (error: any) { + console.error("Error processing upload:", error); + return { status: 500, message: "An error occurred while uploading the shape", token }; + } +}; + +export const deleteShape = async (shapeId: string, token: string) => { + try { + const shapeDelete = await db.delete(shapes).where(eq(shapes.id, shapeId)).returning(); + return { status: 200, message: "Shape deleted successfully", token, shapeDelete }; + } catch (error: any) { + console.error("Error deleting shape:", error); + return { status: 500, message: "An error occurred while deleting the shape", token }; + } +}; + +export const getShapes = async (token: string) => { + try { + const allShapes = await db.select().from(shapes); + + if (allShapes.length === 0) { + return { status: 404, message: "No shapes found", token }; + } + + else { + return { status: 200, message: "Shapes retrieved successfully", token, allShapes }; + } + + } catch (error: any) { + console.error("Error getting shapes:", error); + return { status: 500, message: "An error occurred while getting the shapes", token }; + } +}; \ No newline at end of file diff --git a/src/api/uploadShapes/upload.shapes.route.ts b/src/api/uploadShapes/upload.shapes.route.ts new file mode 100644 index 0000000..f91b19a --- /dev/null +++ b/src/api/uploadShapes/upload.shapes.route.ts @@ -0,0 +1,53 @@ +import Elysia, { t } from "elysia"; +import { verifyAuth } from "../../middlewares/auth.middlewares"; +import { deleteShape, getShapes, uploadShapes } from "./upload.shapes.controller"; + +export const uploadShapesRoutes = new Elysia({ + prefix: "/upload-shapes", + tags: ["Shape uploads"], + detail: { + description: "Routes for uploading and managing shapes", + } +}).derive(async ({ cookie }) => { + const authData = await verifyAuth(cookie); + return { authData }; // Inject into context +}); + +uploadShapesRoutes.post("/add", async ({ body, authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const { shape } = body; + const response = await uploadShapes(shape, token); + return response; + } +}, { + body: t.Object({ + shape: t.String(), + }) +}); + +uploadShapesRoutes.delete("/delete/:id", async ({ authData, params: { id } }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const response = await deleteShape(id, token); + return response; + } +}, { + params: t.Object({ + id: t.String() + }), +}); + +uploadShapesRoutes.get("/get", async ({ authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const response = await getShapes(token); + return response; + } +}); \ No newline at end of file diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..4b7955b --- /dev/null +++ b/src/app.ts @@ -0,0 +1,59 @@ +import { Elysia } from "elysia"; +import swagger from '@elysiajs/swagger'; + +import { ENV } from "./config/env"; +import cors from "@elysiajs/cors"; +import { api } from "./api"; + +const allowedOrigins = [ + "http://localhost:5175", + "http://localhost:5173", + // allowed canvas backend (user) origins(for user) + "https://localhost:3001", +]; + +const app = new Elysia({ + prefix: "", + tags: ["Default"], +}) + .use(cors({ + origin: allowedOrigins, + methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With", "Accept", "Origin", "Access-Control-Allow-Origin"], + credentials: true, + })) + .use(swagger({ + path: "/api/docs", + documentation: { + info: { + title: "Canvas API", + version: "1.0.0", + description: "Canvas API Documentation", + }, + tags: [ + { + name: "Projects", + description: "All APIs related to Projects", + }, + { + name: "Uploads", + description: "All APIs related to Uploads" + } + ], + } + })) + .onError(({ code, error }) => { + if (code === 'NOT_FOUND') + return 'Not Found :('; + console.log("hello from app.ts under error"); + console.error(error) + }); + +// all routes here +app.use(api); + +app.listen(ENV.SERVER_PORT, () => { + console.log(`🦊 Elysia is running at ${ENV.SERVER_URL}:${ENV.SERVER_PORT}`) +}) + + diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..1f41a62 --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,22 @@ +import 'dotenv/config' + +export const ENV = { + SERVER_URL: process.env.SERVER_URL, + SERVER_PORT: process.env.SERVER_PORT || 5000, + DATABASE_URL: process.env.DATABASE_URL, + MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY, + MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY, + MINIO_ENDPOINT: process.env.MINIO_ENDPOINT, + MINIO_PORT: process.env.MINIO_PORT, + JWT_ACCESS_TOKEN_SECRET: process.env.JWT_ACCESS_TOKEN_SECRET, + JWT_REFRESH_TOKEN_SECRET: process.env.JWT_REFRESH_TOKEN_SECRET, + JWT_EMAIL_TOKEN_SECRET: process.env.JWT_EMAIL_TOKEN_SECRET, + JWT_EMAIL_RESET_PASSWORD_SECRET: process.env.JWT_EMAIL_RESET_PASSWORD_SECRET, + USER_CANVAS_JWT_ACCESS_TOKEN_SECRET: process.env.USER_CANVAS_JWT_ACCESS_TOKEN_SECRET, + MAIL_HOST: process.env.MAIL_HOST, + MAIL_PORT: process.env.MAIL_PORT, + MAIL_USER: process.env.MAIL_USER, + MAIL_PASS: process.env.MAIL_PASS, + PEXELS_URL: process.env.PEXELS_URL, + PEXELS_ACCESS_KEY: process.env.PEXELS_ACCESS_KEY, +} \ No newline at end of file diff --git a/src/config/minioClient.ts b/src/config/minioClient.ts new file mode 100644 index 0000000..798b1a1 --- /dev/null +++ b/src/config/minioClient.ts @@ -0,0 +1,10 @@ +import { Client } from "minio"; +import { ENV } from "../config/env"; + +export const minioClient = new Client({ + endPoint: ENV.MINIO_ENDPOINT!, + port: ENV.MINIO_PORT, + useSSL: false, + accessKey: ENV.MINIO_ACCESS_KEY, + secretKey: ENV.MINIO_SECRET_KEY, +}) \ No newline at end of file diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..eb8b284 --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,5 @@ +import { drizzle } from "drizzle-orm/node-postgres"; + +import { ENV } from "../config/env"; + +export const db = drizzle(ENV.DATABASE_URL!); \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..296bfe1 --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,49 @@ +import { boolean, json, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +export const users = pgTable("users", { + id: uuid("user_id").defaultRandom().primaryKey(), + email: text("email").notNull(), + name: text("name"), + password: text("password").notNull(), + is_active: boolean("is_active").notNull().default(true), + is_verified: boolean("is_verified").notNull().default(false), + is_admin: boolean("is_admin").notNull().default(false), + refresh_token: text("refresh_token"), +}); + +export const projects = pgTable("projects", { + id: uuid("project_id").defaultRandom().primaryKey(), + userId: uuid().references(() => users.id), + category: uuid().references(() => category.id), + object: json(), + name: text("name"), + description: text("description"), + is_public: boolean("is_public").notNull().default(true), + preview_url: text("preview_url"), + created_at: timestamp("created_at").defaultNow(), + updated_at: timestamp("updated_at").defaultNow(), +}); + +export const uploads = pgTable("uploads", { + id: uuid("upload_id").defaultRandom().primaryKey(), + filename: text("filename").notNull(), + url: text("url").notNull(), + projectId: uuid().references(() => projects.id), + created_at: timestamp("created_at").defaultNow(), + updated_at: timestamp("updated_at").defaultNow(), +}); + +export const shapes = pgTable("shapes", { + id: uuid("shape_id").defaultRandom().primaryKey(), + shapes: text("shapes").notNull(), + created_at: timestamp("created_at").defaultNow(), + updated_at: timestamp("updated_at").defaultNow(), +}); + +export const category = pgTable("project_category", { + id: uuid("category_id").defaultRandom().primaryKey(), + user_id: uuid().references(() => users.id), + category: text("category").notNull(), + created_at: timestamp("created_at").defaultNow(), + updated_at: timestamp("updated_at").defaultNow(), +}); diff --git a/src/helper/auth/auth.helper.ts b/src/helper/auth/auth.helper.ts new file mode 100644 index 0000000..a0dbe5a --- /dev/null +++ b/src/helper/auth/auth.helper.ts @@ -0,0 +1,177 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../db"; +import { users } from "../../db/schema"; +import { ENV } from "../../config/env"; +// @ts-ignore +import nodemailer from 'nodemailer'; + +export const checkUserInDb = async (email: string, password: string): Promise<{ + success: boolean; + message: string; + can_register?: boolean; + can_login?: boolean; + email?: string; +}> => { + try { + // function isAdspillarEmail(email: string) { + // const regex = /^[a-zA-Z0-9._%+-]+@adspillar\.com$/; + // return regex.test(email); + // } + + // if (!isAdspillarEmail(email)) { + // return { success: false, message: "Invalid email domain", can_register: false, can_login: false }; + // } + + // else { + // const findUser = await db.select({ + // email: users.email, + // password: users.password, + // is_active: users.is_active, + // is_verified: users.is_verified, + // refresh_token: users.refresh_token, + // }).from(users).where(eq(users.email, email)); + + // if (!findUser[0]) { + // return { success: true, message: "User not found", can_register: true }; + // } + + // const hash = findUser[0].password; + // const isMatch = await Bun.password.verify(password, hash); + + // if (isMatch && findUser[0].is_verified && findUser[0].is_active) { + // return { + // success: true, + // message: "User verified successfully", + // can_login: true, + // email: findUser[0].email // Ensure email is included + // }; + // } + // else if (isMatch && findUser[0].is_verified === false && findUser[0].is_active) { + // return { success: false, message: "User not verified", can_login: false }; + // } + // else if (isMatch && findUser[0].is_active === false && findUser[0].is_verified) { + // return { success: false, message: "User not active", can_login: false }; + // } + // else { + // return { success: false, message: "Invalid password", can_login: false }; + // } + // } + + const findUser = await db.select({ + email: users.email, + password: users.password, + is_active: users.is_active, + is_verified: users.is_verified, + refresh_token: users.refresh_token, + }).from(users).where(eq(users.email, email)); + + if (!findUser[0]) { + return { success: true, message: "Wrong credentials", can_register: true }; + } + + const hash = findUser[0].password; + const isMatch = await Bun.password.verify(password, hash); + + if (isMatch && findUser[0].is_verified && findUser[0].is_active) { + return { + success: true, + message: "User verified successfully", + can_login: true, + email: findUser[0].email, // Ensure email is included + can_register: false + }; + } + else if (isMatch && findUser[0].is_verified === false && findUser[0].is_active) { + return { success: false, message: "User not verified", can_login: false, can_register: false }; + } + else if (isMatch && findUser[0].is_active === false && findUser[0].is_verified) { + return { success: false, message: "User not active", can_login: false, can_register: false }; + } + else { + return { success: false, message: "Invalid credentials", can_login: false, can_register: false }; + } + + } catch (error: any) { + console.log("Error verifying user:", error); + return { success: false, message: "Error verifying user" }; + } +}; + + +export const storeRefreshToken = async (email: string, refreshToken: string): Promise<{ success: boolean; message: string }> => { + try { + await db.update(users).set({ refresh_token: refreshToken }).where(eq(users.email, email)); + return { success: true, message: "Refresh token stored successfully" }; + } catch (error) { + console.log("Error storing refresh token:", error); + return { success: false, message: "Error storing refresh token" }; + } +} + +export const sendVerificationEmail = async (email: string, token: string, set: any) => { + const sendEmail = async (email: string, token: string) => { + try { + const transporter = nodemailer.createTransport({ + host: ENV.MAIL_HOST, + port: ENV.MAIL_PORT, + auth: { + user: ENV.MAIL_USER, + pass: ENV.MAIL_PASS, + }, + }); + + const url = `${ENV.SERVER_URL}:${ENV.SERVER_PORT}/api/auth/verify?token=${token}`; + const mailOptions = { + from: ENV.MAIL_USER, + to: email, + subject: 'Verify Your Email Address', + html: `
Please verify your email by clicking the following link:
+ +This link will be valid for the next 10 minutes.
`, + }; + + await transporter.sendMail(mailOptions); + return { status: 200, message: "Verification email sent, link will valid till next 10 minutes" }; + } catch (error) { + console.error("Error sending email:", error); + return { status: 500, message: "Internal server error, unable to send email" }; + } + }; + const emailResponse = await sendEmail(email, token); + set.status = emailResponse.status; + return emailResponse; +} + +export const sendResetPasswordEmail = async (email: string, token: string, set: any) => { + const sendEmail = async (email: string, token: string) => { + try { + const transporter = nodemailer.createTransport({ + host: ENV.MAIL_HOST, + port: ENV.MAIL_PORT, + auth: { + user: ENV.MAIL_USER, + pass: ENV.MAIL_PASS, + }, + }); + + const url = `${ENV.SERVER_URL}:${ENV.SERVER_PORT}/api/auth/reset-password?token=${token}`; + const mailOptions = { + from: ENV.MAIL_USER, + to: email, + subject: 'Reset Your Password', + html: `Please reset your password by clicking the following link:
+ +This link will be valid for the next 10 minutes.
`, + }; + await transporter.sendMail(mailOptions); + return { status: 200, message: "Reset password email sent, link will valid till next 10 minutes" }; + } catch (error) { + console.error("Error sending email:", error); + return { status: 500, message: "Internal server error, unable to send email" }; + } + }; + const emailResponse = await sendEmail(email, token); + set.status = emailResponse.status; + return emailResponse; +} + diff --git a/src/helper/projects/createProject.ts b/src/helper/projects/createProject.ts new file mode 100644 index 0000000..912f284 --- /dev/null +++ b/src/helper/projects/createProject.ts @@ -0,0 +1,24 @@ +import { db } from "../../db"; +import { projects } from "../../db/schema"; + +export const createEmptyProject = async (userId: string): Promise<{ id: string }> => { + try { + // Insert a new row with default values + const [newProject] = await db + .insert(projects) + .values({ + userId: userId, + object: {}, // Empty object as default + name: "", // Empty name + description: "", // Empty description + preview_url: "", // Empty preview URL + is_public: true, // Add default value for is_public + }) + .returning({ id: projects.id }); // Returning the ID of the created project + // Return the newly created project's ID + return { id: newProject.id }; + } catch (error) { + console.error("Error creating an empty project:", error); + throw new Error("Failed to create an empty project"); + } +}; diff --git a/src/helper/upload/createBucket.ts b/src/helper/upload/createBucket.ts new file mode 100644 index 0000000..b546eb4 --- /dev/null +++ b/src/helper/upload/createBucket.ts @@ -0,0 +1,34 @@ +import { minioClient } from "../../config/minioClient"; + +export const createBucket = async (bucketName: string) => { + try { + const bucketExists = await minioClient.bucketExists(bucketName); + if (!bucketExists) { + // Create the bucket + await minioClient.makeBucket(bucketName, "us-east-1"); + + // Set the bucket policy to make it public + const bucketPolicy = JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: "*", + Action: ["s3:GetObject"], + Resource: [`arn:aws:s3:::${bucketName}/*`], + }, + ], + }); + + await minioClient.setBucketPolicy(bucketName, bucketPolicy); + + return bucketName; // Return the bucket name if created successfully + } else { + return bucketName; // Return the bucket name if it already exists + } + } catch (error: any) { + console.error("Error creating or configuring bucket:", error); + // Optionally rethrow the error with additional context + throw new Error(`Error creating bucket "${bucketName}": ${error.message}`); + } +}; diff --git a/src/helper/upload/removeBucket.ts b/src/helper/upload/removeBucket.ts new file mode 100644 index 0000000..4b875b4 --- /dev/null +++ b/src/helper/upload/removeBucket.ts @@ -0,0 +1,30 @@ +import { minioClient } from "../../config/minioClient"; + +export const removeBucket = async (bucketName: string) => { + try { + // Check if the bucket exists before proceeding + const bucketExists = await minioClient.bucketExists(bucketName); + if (!bucketExists) { + return { status: 404, message: `Bucket ${bucketName} does not exist` }; + } + + // List objects in the bucket, which returns a stream + const objects = minioClient.listObjects(bucketName); + + // Iterate over the stream of objects using 'for await...of' + for await (const obj of objects) { + await minioClient.removeObject(bucketName, obj.name); + console.log(`Removed object: ${obj.name}`); + } + + // Now remove the bucket after clearing all objects + await minioClient.removeBucket(bucketName); + + return { status: 200, message: `Bucket ${bucketName} and its data removed successfully` }; + } catch (error: any) { + console.log(`Error removing bucket ${bucketName}: ${error.message}`); + return { status: 500, message: `Error removing bucket ${bucketName}: ${error.message}` }; + } +}; + + diff --git a/src/helper/upload/removeFromMinio.ts b/src/helper/upload/removeFromMinio.ts new file mode 100644 index 0000000..615a6f9 --- /dev/null +++ b/src/helper/upload/removeFromMinio.ts @@ -0,0 +1,16 @@ +import { minioClient } from "../../config/minioClient"; + +interface RemoveFromMinioResponse { + msg: string; +} + +export const removeFromMinio = async (bucketName: string, objectName: string): PromiseSomething went wrong. Please try again later or contact support.
+ Go Back +