From 8fb748f401d81f6014ed5661e35dccefecfa2edd Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 16 Dec 2025 22:42:05 -0500 Subject: [PATCH] init --- .gitignore | 28 ++ README.md | 32 +++ app.config.ts | 11 + bun.lockb | Bin 0 -> 237325 bytes docs/trpc-implementation.md | 258 ++++++++++++++++++ package.json | 24 ++ public/favicon.ico | Bin 0 -> 664 bytes src/api/auth/callback/github/route.ts | 84 ++++++ src/api/auth/callback/google/route.ts | 99 +++++++ src/api/auth/email-login/[email]/route.ts | 64 +++++ .../auth/email-verification/[email]/route.ts | 49 ++++ .../comment-reactions/[commentID]/route.ts | 21 ++ .../comment-reactions/add/[type]/route.ts | 27 ++ .../comment-reactions/remove/[type]/route.ts | 28 ++ .../comments/get-all/[post_id]/route.ts | 16 ++ src/api/database/comments/get-all/route.ts | 9 + src/api/database/post-like/add/route.ts | 17 ++ src/api/database/post-like/remove/route.ts | 19 ++ .../post/[category]/by-id/[id]/route.ts | 43 +++ .../post/[category]/by-title/[title]/route.ts | 73 +++++ .../post/[category]/manipulation/route.ts | 157 +++++++++++ src/api/database/user/email/route.ts | 20 ++ src/api/database/user/from-id/[id]/route.ts | 35 +++ src/api/database/user/image/[id]/route.ts | 33 +++ .../database/user/public-data/[id]/route.ts | 31 +++ .../downloads/public/[asset_name]/route.ts | 43 +++ src/api/lineage/_database_mgmt/loose/route.ts | 40 +++ src/api/lineage/_database_mgmt/old/route.ts | 42 +++ src/api/lineage/analytics/route.ts | 42 +++ src/api/lineage/apple/email/route.ts | 26 ++ src/api/lineage/apple/registration/route.ts | 134 +++++++++ src/api/lineage/database/creds/route.ts | 89 ++++++ .../lineage/database/deletion/cancel/route.ts | 69 +++++ .../lineage/database/deletion/check/route.ts | 24 ++ .../lineage/database/deletion/cron/route.ts | 73 +++++ .../lineage/database/deletion/init/route.ts | 154 +++++++++++ src/api/lineage/email/login/route.ts | 81 ++++++ src/api/lineage/email/refresh/token/route.ts | 33 +++ .../email/refresh/verification/route.ts | 107 ++++++++ src/api/lineage/email/registration/route.ts | 144 ++++++++++ .../email/verification/[email]/route.ts | 96 +++++++ src/api/lineage/google/registration/route.ts | 101 +++++++ src/api/lineage/json_service/attacks/route.ts | 27 ++ .../lineage/json_service/conditions/route.ts | 13 + .../lineage/json_service/dungeons/route.ts | 7 + src/api/lineage/json_service/enemies/route.ts | 8 + src/api/lineage/json_service/items/route.ts | 45 +++ src/api/lineage/json_service/misc/route.ts | 23 ++ src/api/lineage/offline_secret/route.ts | 5 + src/api/lineage/pvp/battle_result/route.ts | 28 ++ src/api/lineage/pvp/route.ts | 154 +++++++++++ src/api/lineage/tokens/route.ts | 27 ++ src/api/passwordHashing.ts | 20 ++ src/api/s3/deleteImage/route.ts | 35 +++ src/api/s3/getPreSignedURL/route.ts | 41 +++ src/api/s3/simpleDeleteImage/route.ts | 31 +++ src/app.css | 168 ++++++++++++ src/app.tsx | 30 ++ src/components/Bars.tsx | 43 +++ src/components/TerminalSplash.tsx | 57 ++++ src/components/Typewriter.tsx | 164 +++++++++++ src/context/splash.tsx | 29 ++ src/entry-client.tsx | 4 + src/entry-server.tsx | 29 ++ src/env/server.ts | 226 +++++++++++++++ src/global.d.ts | 1 + src/lib/api.ts | 23 ++ src/routes/[...404].tsx | 19 ++ src/routes/about.tsx | 10 + src/routes/api/trpc/[trpc].ts | 19 ++ src/routes/index.tsx | 13 + src/server/api/root.ts | 16 ++ src/server/api/routers/auth.ts | 34 +++ src/server/api/routers/database.ts | 101 +++++++ src/server/api/routers/example.ts | 11 + src/server/api/routers/lineage.ts | 124 +++++++++ src/server/api/routers/misc.ts | 34 +++ src/server/api/utils.ts | 6 + src/server/utils.ts | 249 +++++++++++++++++ src/utils.ts | 9 + tsconfig.json | 19 ++ 81 files changed, 4378 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.config.ts create mode 100755 bun.lockb create mode 100644 docs/trpc-implementation.md create mode 100644 package.json create mode 100644 public/favicon.ico create mode 100644 src/api/auth/callback/github/route.ts create mode 100644 src/api/auth/callback/google/route.ts create mode 100644 src/api/auth/email-login/[email]/route.ts create mode 100644 src/api/auth/email-verification/[email]/route.ts create mode 100644 src/api/database/comment-reactions/[commentID]/route.ts create mode 100644 src/api/database/comment-reactions/add/[type]/route.ts create mode 100644 src/api/database/comment-reactions/remove/[type]/route.ts create mode 100644 src/api/database/comments/get-all/[post_id]/route.ts create mode 100644 src/api/database/comments/get-all/route.ts create mode 100644 src/api/database/post-like/add/route.ts create mode 100644 src/api/database/post-like/remove/route.ts create mode 100644 src/api/database/post/[category]/by-id/[id]/route.ts create mode 100644 src/api/database/post/[category]/by-title/[title]/route.ts create mode 100644 src/api/database/post/[category]/manipulation/route.ts create mode 100644 src/api/database/user/email/route.ts create mode 100644 src/api/database/user/from-id/[id]/route.ts create mode 100644 src/api/database/user/image/[id]/route.ts create mode 100644 src/api/database/user/public-data/[id]/route.ts create mode 100644 src/api/downloads/public/[asset_name]/route.ts create mode 100644 src/api/lineage/_database_mgmt/loose/route.ts create mode 100644 src/api/lineage/_database_mgmt/old/route.ts create mode 100644 src/api/lineage/analytics/route.ts create mode 100644 src/api/lineage/apple/email/route.ts create mode 100644 src/api/lineage/apple/registration/route.ts create mode 100644 src/api/lineage/database/creds/route.ts create mode 100644 src/api/lineage/database/deletion/cancel/route.ts create mode 100644 src/api/lineage/database/deletion/check/route.ts create mode 100644 src/api/lineage/database/deletion/cron/route.ts create mode 100644 src/api/lineage/database/deletion/init/route.ts create mode 100644 src/api/lineage/email/login/route.ts create mode 100644 src/api/lineage/email/refresh/token/route.ts create mode 100644 src/api/lineage/email/refresh/verification/route.ts create mode 100644 src/api/lineage/email/registration/route.ts create mode 100644 src/api/lineage/email/verification/[email]/route.ts create mode 100644 src/api/lineage/google/registration/route.ts create mode 100644 src/api/lineage/json_service/attacks/route.ts create mode 100644 src/api/lineage/json_service/conditions/route.ts create mode 100644 src/api/lineage/json_service/dungeons/route.ts create mode 100644 src/api/lineage/json_service/enemies/route.ts create mode 100644 src/api/lineage/json_service/items/route.ts create mode 100644 src/api/lineage/json_service/misc/route.ts create mode 100644 src/api/lineage/offline_secret/route.ts create mode 100644 src/api/lineage/pvp/battle_result/route.ts create mode 100644 src/api/lineage/pvp/route.ts create mode 100644 src/api/lineage/tokens/route.ts create mode 100644 src/api/passwordHashing.ts create mode 100644 src/api/s3/deleteImage/route.ts create mode 100644 src/api/s3/getPreSignedURL/route.ts create mode 100644 src/api/s3/simpleDeleteImage/route.ts create mode 100644 src/app.css create mode 100644 src/app.tsx create mode 100644 src/components/Bars.tsx create mode 100644 src/components/TerminalSplash.tsx create mode 100644 src/components/Typewriter.tsx create mode 100644 src/context/splash.tsx create mode 100644 src/entry-client.tsx create mode 100644 src/entry-server.tsx create mode 100644 src/env/server.ts create mode 100644 src/global.d.ts create mode 100644 src/lib/api.ts create mode 100644 src/routes/[...404].tsx create mode 100644 src/routes/about.tsx create mode 100644 src/routes/api/trpc/[trpc].ts create mode 100644 src/routes/index.tsx create mode 100644 src/server/api/root.ts create mode 100644 src/server/api/routers/auth.ts create mode 100644 src/server/api/routers/database.ts create mode 100644 src/server/api/routers/example.ts create mode 100644 src/server/api/routers/lineage.ts create mode 100644 src/server/api/routers/misc.ts create mode 100644 src/server/api/utils.ts create mode 100644 src/server/utils.ts create mode 100644 src/utils.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..751513c --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +dist +.wrangler +.output +.vercel +.netlify +.vinxi +app.config.timestamp_*.js + +# Environment +.env +.env*.local + +# dependencies +/node_modules + +# IDEs and editors +/.idea +.project +.classpath +*.launch +.settings/ + +# Temp +gitignore + +# System Files +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..9337430 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# SolidStart + +Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com); + +## Creating a project + +```bash +# create a new project in the current directory +npm init solid@latest + +# create a new project in my-app +npm init solid@latest my-app +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```bash +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +Solid apps are built with _presets_, which optimise your project for deployment to different environments. + +By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`. + +## This project was created with the [Solid CLI](https://github.com/solidjs-community/solid-cli) diff --git a/app.config.ts b/app.config.ts new file mode 100644 index 0000000..ed79e70 --- /dev/null +++ b/app.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "@solidjs/start/config"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + vite: { + plugins: [tailwindcss()], + }, + server: { + preset: "vercel", + }, +}); diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..15d0c07f21f664c9544feca5af17dbdfe6b07b3d GIT binary patch literal 237325 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 Wu|Mf}K|k!W*)tpDfBirG@BaW|>2nVN literal 0 HcmV?d00001 diff --git a/docs/trpc-implementation.md b/docs/trpc-implementation.md new file mode 100644 index 0000000..201c955 --- /dev/null +++ b/docs/trpc-implementation.md @@ -0,0 +1,258 @@ +# tRPC Implementation Documentation + +## Overview + +This project implements a [tRPC](https://trpc.io/) API layer to provide type-safe communication between the frontend and backend. The implementation follows SolidStart's server-side rendering architecture with a clear separation of concerns. + +## Architecture + +The tRPC setup is organized in the following structure: + +``` +src/ +├── server/ +│ └── api/ +│ ├── root.ts # Main router that combines all sub-routers +│ ├── utils.ts # tRPC utility functions and initialization +│ └── routers/ # Individual API route groups +│ ├── auth.ts # Authentication procedures +│ ├── database.ts # Database operations +│ ├── example.ts # Example procedures +│ ├── lineage.ts # Lineage-related APIs +│ └── misc.ts # Miscellaneous endpoints +└── routes/ + └── api/ + └── trpc/ + └── [trpc].ts # API endpoint handler +``` + +## How to Use tRPC Procedures from the Frontend + +The `api` client is pre-configured and available for use in components: + +```typescript +import { api } from "~/lib/api"; + +// Example usage in a component +export function MyComponent() { + const [result, setResult] = useState(null); + + const handleClick = async () => { + try { + // Call a tRPC procedure + const data = await api.example.hello.query("World"); + setResult(data); + } catch (error) { + console.error("Error calling tRPC procedure:", error); + } + }; + + return ( +
+

{result}

+ +
+ ); +} +``` + +## API Route Structure + +### Root Router (`src/server/api/root.ts`) + +The main router combines all individual routers: + +```typescript +export const appRouter = createTRPCRouter({ + example: exampleRouter, + auth: authRouter, + database: databaseRouter, + lineage: lineageRouter, + misc: miscRouter +}); +``` + +### Procedure Types + +tRPC provides two main procedure types: +- **Query**: For read-only operations (GET requests) +- **Mutation**: For write operations (POST, PUT, DELETE requests) + +Example: + +```typescript +// Query procedure - read-only +publicProcedure + .input(z.string()) + .query(({ input }) => { + return `Hello ${input}!`; + }) + +// Mutation procedure - write operation +publicProcedure + .input(z.object({ name: z.string() })) + .mutation(({ input }) => { + // Logic for creating/updating data + return { success: true, name: input.name }; + }) +``` + +## Adding New Endpoints + +### 1. Create a new router file + +Create a new file in `src/server/api/routers/`: + +```typescript +import { createTRPCRouter, publicProcedure } from "../utils"; +import { z } from "zod"; + +export const myRouter = createTRPCRouter({ + // Add your procedures here + hello: publicProcedure + .input(z.string()) + .query(({ input }) => { + return `Hello ${input}!`; + }), +}); +``` + +### 2. Register the router in the root + +Add your new router to `src/server/api/root.ts`: + +```typescript +import { exampleRouter } from "./routers/example"; +import { authRouter } from "./routers/auth"; +import { databaseRouter } from "./routers/database"; +import { lineageRouter } from "./routers/lineage"; +import { miscRouter } from "./routers/misc"; +import { myRouter } from "./routers/myRouter"; // Add this import +import { createTRPCRouter } from "./utils"; + +export const appRouter = createTRPCRouter({ + example: exampleRouter, + auth: authRouter, + database: databaseRouter, + lineage: lineageRouter, + misc: miscRouter, + myRouter: myRouter, // Add this line +}); +``` + +### 3. Use in frontend + +```typescript +// In your frontend component +const data = await api.myRouter.hello.query("World"); +``` + +## Best Practices + +1. **Type Safety**: Always use Zod schemas to validate input data and return types. + +2. **Error Handling**: Implement proper error handling with try/catch blocks in async procedures. + +3. **Procedure Organization**: Group related procedures into logical routers. + +4. **Consistent Naming**: Use clear, descriptive names for your procedures and routers. + +5. **Documentation**: Document each procedure with clear descriptions of what it does. + +## Example Usage Patterns + +### Query Procedure (GET) +```typescript +// In your router file +getPosts: publicProcedure + .input(z.object({ + limit: z.number().optional(), + offset: z.number().optional() + })) + .query(({ input }) => { + // Return data from database or external service + return { posts: [], total: 0 }; + }) +``` + +```typescript +// In frontend component +const { data, isLoading } = api.database.getPosts.useQuery({ limit: 10 }); +``` + +### Mutation Procedure (POST/PUT/DELETE) +```typescript +// In your router file +createPost: publicProcedure + .input(z.object({ + title: z.string(), + content: z.string() + })) + .mutation(({ input }) => { + // Create post in database + return { success: true, post: { id: "1", ...input } }; + }) +``` + +```typescript +// In frontend component +const { mutateAsync } = api.database.createPost.useMutation(); + +const handleClick = async () => { + try { + const result = await mutateAsync({ + title: "New Post", + content: "Post content" + }); + console.log("Created post:", result); + } catch (error) { + console.error("Error creating post:", error); + } +}; +``` + +## Available Endpoints + +### Auth +- `auth.githubCallback` - GitHub OAuth callback +- `auth.googleCallback` - Google OAuth callback +- `auth.emailLogin` - Email login +- `auth.emailVerification` - Email verification + +### Database +- `database.getCommentReactions` - Get comment reactions +- `database.postCommentReaction` - Add comment reaction +- `database.deleteCommentReaction` - Remove comment reaction +- `database.getComments` - Get comments for a post +- `database.getPosts` - Get posts with pagination +- `database.createPost` - Create new post +- `database.updatePost` - Update existing post +- `database.deletePost` - Delete post +- `database.getPostLikes` - Get likes for a post +- `database.likePost` - Like a post +- `database.unlikePost` - Unlike a post + +### Lineage +- `lineage.databaseManagement` - Database management operations +- `lineage.analytics` - Analytics endpoints +- `lineage.appleAuth` - Apple authentication +- `lineage.emailLogin` - Email login +- `lineage.emailRegister` - Email registration +- `lineage.emailVerify` - Email verification +- `lineage.googleRegister` - Google registration +- `lineage.attacks` - Attack data +- `lineage.conditions` - Condition data +- `lineage.dungeons` - Dungeon data +- `lineage.enemies` - Enemy data +- `lineage.items` - Item data +- `lineage.misc` - Miscellaneous data +- `lineage.offlineSecret` - Offline secret endpoint +- `lineage.pvpGet` - PvP GET operations +- `lineage.pvpPost` - PvP POST operations +- `lineage.tokens` - Token operations + +### Misc +- `misc.downloads` - Downloads endpoint +- `misc.s3Delete` - Delete S3 object +- `misc.s3Get` - Get S3 object +- `misc.hashPassword` - Hash password \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..ce178dd --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "example-with-trpc", + "type": "module", + "scripts": { + "dev": "vinxi dev", + "build": "vinxi build", + "start": "vinxi start" + }, + "dependencies": { + "@solidjs/meta": "^0.29.4", + "@solidjs/router": "^0.15.0", + "@solidjs/start": "^1.1.0", + "@tailwindcss/vite": "^4.0.7", + "@trpc/client": "^10.45.2", + "@trpc/server": "^10.45.2", + "@typeschema/valibot": "^0.13.4", + "solid-js": "^1.9.5", + "valibot": "^0.29.0", + "vinxi": "^0.5.7" + }, + "engines": { + "node": ">=22" + } +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..fb282da0719ef6ab4c1732df93be6216b0d85520 GIT binary patch literal 664 zcmV;J0%!e+P)m9ebk1R zejT~~6f_`?;`cEd!+`7(hw@%%2;?RN8gX-L?z6cM( zKoG@&w+0}f@Pfvwc+deid)qgE!L$ENKYjViZC_Zcr>L(`2oXUT8f0mRQ(6-=HN_Ai zeBBEz3WP+1Cw`m!49Wf!MnZzp5bH8VkR~BcJ1s-j90TAS2Yo4j!J|KodxYR%3Numw zA?gq6e`5@!W~F$_De3yt&uspo&2yLb$(NwcPPI-4LGc!}HdY%jfq@AFs8LiZ4k(p} zZ!c9o+qbWYs-Mg zgdyTALzJX&7QXHdI_DPTFL33;w}88{e6Zk)MX0kN{3DX9uz#O_L58&XRH$Nvvu;fO zf&)7@?C~$z1K<>j0ga$$MIg+5xN;eQ?1-CA=`^Y169@Ab6!vcaNP=hxfKN%@Ly^R* zK1iv*s1Yl6_dVyz8>ZqYhz6J4|3fQ@2LQeX@^%W(B~8>=MoEmBEGGD1;gHXlpX>!W ym)!leA2L@`cpb^hy)P75=I!`pBYxP7<2VfQ3j76qLgzIA0000 }, +) { + const readyParams = await context.params; + const secretKey = env.JWT_SECRET_KEY; + const params = request.nextUrl.searchParams; + const token = params.get("token"); + const userEmail = readyParams.email; + try { + if (token) { + const decoded = jwt.verify(token, secretKey) as JwtPayload; + if (decoded.email == userEmail) { + const conn = ConnectionFactory(); + const query = `SELECT * FROM User WHERE email = ?`; + const params = [decoded.email]; + const res = await conn.execute({ sql: query, args: params }); + const token = jwt.sign( + { id: (res.rows[0] as unknown as User).id }, + env.JWT_SECRET_KEY, + { + expiresIn: 60 * 60 * 24 * 14, // expires in 14 days + }, + ); + if (decoded.rememberMe) { + (await cookies()).set({ + name: "userIDToken", + value: token, + maxAge: 60 * 60 * 24 * 14, + }); + } else { + (await cookies()).set({ + name: "userIDToken", + value: token, + }); + } + return NextResponse.redirect(`${env.NEXT_PUBLIC_DOMAIN}/account`); + } + } + return NextResponse.json( + JSON.stringify({ + success: false, + message: `authentication failed: no token`, + }), + { status: 401, headers: { "content-type": "application/json" } }, + ); + } catch (err) { + return NextResponse.json( + JSON.stringify({ + success: false, + message: `authentication failed: ${err}`, + }), + { status: 401, headers: { "content-type": "application/json" } }, + ); + } +} diff --git a/src/api/auth/email-verification/[email]/route.ts b/src/api/auth/email-verification/[email]/route.ts new file mode 100644 index 0000000..d9749cf --- /dev/null +++ b/src/api/auth/email-verification/[email]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import jwt, { JwtPayload } from "jsonwebtoken"; +import { env } from "@/env.mjs"; +import { ConnectionFactory } from "@/app/utils"; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ email: string }> }, +) { + const readyParams = await context.params; + const secretKey = env.JWT_SECRET_KEY; + const params = request.nextUrl.searchParams; + const token = params.get("token"); + const userEmail = readyParams.email; + try { + if (token) { + const decoded = jwt.verify(token, secretKey) as JwtPayload; + if (decoded.email == userEmail) { + const conn = ConnectionFactory(); + const query = `UPDATE User SET email_verified = ? WHERE email = ?`; + const params = [true, userEmail]; + await conn.execute({ sql: query, args: params }); + return new NextResponse( + JSON.stringify({ + success: true, + message: "email verification success, you may close this window", + }), + { status: 202, headers: { "content-type": "application/json" } }, + ); + } + } + return NextResponse.json( + JSON.stringify({ + success: false, + message: `authentication failed: no token`, + }), + { status: 401, headers: { "content-type": "application/json" } }, + ); + } catch (err) { + console.error("Invalid token:", err); + return new NextResponse( + JSON.stringify({ + success: false, + message: "authentication failed: Invalid token", + }), + { status: 401, headers: { "content-type": "application/json" } }, + ); + } +} diff --git a/src/api/database/comment-reactions/[commentID]/route.ts b/src/api/database/comment-reactions/[commentID]/route.ts new file mode 100644 index 0000000..5eb2520 --- /dev/null +++ b/src/api/database/comment-reactions/[commentID]/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { ConnectionFactory } from "@/app/utils"; + +export async function GET( + _: Request, + context: { params: Promise<{ commentID: string }> }, +) { + const readyParams = await context.params; + const commentID = readyParams.commentID; + const conn = ConnectionFactory(); + const commentQuery = "SELECT * FROM CommentReaction WHERE comment_id = ?"; + const commentParams = [commentID]; + const commentResults = await conn.execute({ + sql: commentQuery, + args: commentParams, + }); + return NextResponse.json( + { commentReactions: commentResults.rows }, + { status: 202 }, + ); +} diff --git a/src/api/database/comment-reactions/add/[type]/route.ts b/src/api/database/comment-reactions/add/[type]/route.ts new file mode 100644 index 0000000..778a336 --- /dev/null +++ b/src/api/database/comment-reactions/add/[type]/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { ConnectionFactory } from "@/app/utils"; +import { CommentReactionInput } from "@/types/input-types"; +import { CommentReaction } from "@/types/model-types"; + +export async function POST( + input: NextRequest, + context: { params: Promise<{ type: string }> }, +) { + const readyParams = await context.params; + const inputData = (await input.json()) as CommentReactionInput; + const { comment_id, user_id } = inputData; + const conn = ConnectionFactory(); + const query = ` + INSERT INTO CommentReaction (type, comment_id, user_id) + VALUES (?, ?, ?) + `; + const params = [readyParams.type, comment_id, user_id]; + await conn.execute({ sql: query, args: params }); + const followUpQuery = `SELECT * FROM CommentReaction WHERE comment_id = ?`; + const followUpParams = [comment_id]; + const res = await conn.execute({ sql: followUpQuery, args: followUpParams }); + const data = (res.rows as unknown as CommentReaction[]).filter( + (commentReaction) => commentReaction.comment_id == comment_id, + ); + return NextResponse.json({ commentReactions: data || [] }); +} diff --git a/src/api/database/comment-reactions/remove/[type]/route.ts b/src/api/database/comment-reactions/remove/[type]/route.ts new file mode 100644 index 0000000..246f448 --- /dev/null +++ b/src/api/database/comment-reactions/remove/[type]/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { ConnectionFactory } from "@/app/utils"; +import { CommentReactionInput } from "@/types/input-types"; +import { CommentReaction } from "@/types/model-types"; + +export async function POST( + input: NextRequest, + context: { params: Promise<{ type: string }> }, +) { + const readyParams = await context.params; + const inputData = (await input.json()) as CommentReactionInput; + const { comment_id, user_id } = inputData; + const conn = ConnectionFactory(); + const query = ` + DELETE FROM CommentReaction + WHERE type = ? AND comment_id = ? AND user_id = ? + `; + const params = [readyParams.type, comment_id, user_id]; + await conn.execute({ sql: query, args: params }); + + const followUpQuery = `SELECT * FROM CommentReaction WHERE comment_id = ?`; + const followUpParams = [comment_id]; + const res = await conn.execute({ sql: followUpQuery, args: followUpParams }); + const data = (res.rows as unknown as CommentReaction[]).filter( + (commentReaction) => commentReaction.comment_id == comment_id, + ); + return NextResponse.json({ commentReactions: data || [] }); +} diff --git a/src/api/database/comments/get-all/[post_id]/route.ts b/src/api/database/comments/get-all/[post_id]/route.ts new file mode 100644 index 0000000..7aff6c7 --- /dev/null +++ b/src/api/database/comments/get-all/[post_id]/route.ts @@ -0,0 +1,16 @@ +import { ConnectionFactory } from "@/app/utils"; +import { NextResponse } from "next/server"; + +export async function GET( + _: Request, + context: { + params: Promise<{ post_id: string }>; + }, +) { + const readyParams = await context.params; + const conn = ConnectionFactory(); + const query = `SELECT * FROM Comment WHERE post_id = ?`; + const params = [readyParams.post_id]; + const res = await conn.execute({ sql: query, args: params }); + return NextResponse.json({ comments: res.rows }, { status: 302 }); +} diff --git a/src/api/database/comments/get-all/route.ts b/src/api/database/comments/get-all/route.ts new file mode 100644 index 0000000..6d80b34 --- /dev/null +++ b/src/api/database/comments/get-all/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; +import { ConnectionFactory } from "@/app/utils"; + +export async function GET() { + const conn = ConnectionFactory(); + const query = `SELECT * FROM Comment`; + const res = await conn.execute(query); + return NextResponse.json({ comments: res.rows }); +} diff --git a/src/api/database/post-like/add/route.ts b/src/api/database/post-like/add/route.ts new file mode 100644 index 0000000..72b824c --- /dev/null +++ b/src/api/database/post-like/add/route.ts @@ -0,0 +1,17 @@ +import { ConnectionFactory } from "@/app/utils"; +import { PostLikeInput } from "@/types/input-types"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(input: NextRequest) { + const inputData = (await input.json()) as PostLikeInput; + const { user_id, post_id } = inputData; + const conn = ConnectionFactory(); + const query = `INSERT INTO PostLike (user_id, post_id) + VALUES (?, ?)`; + const params = [user_id, post_id]; + await conn.execute({ sql: query, args: params }); + const followUpQuery = `SELECT * FROM PostLike WHERE post_id = ?`; + const followUpParams = [post_id]; + const res = await conn.execute({ sql: followUpQuery, args: followUpParams }); + return NextResponse.json({ newLikes: res.rows }); +} diff --git a/src/api/database/post-like/remove/route.ts b/src/api/database/post-like/remove/route.ts new file mode 100644 index 0000000..2aae7d7 --- /dev/null +++ b/src/api/database/post-like/remove/route.ts @@ -0,0 +1,19 @@ +import { ConnectionFactory } from "@/app/utils"; +import { PostLikeInput } from "@/types/input-types"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(input: NextRequest) { + const inputData = (await input.json()) as PostLikeInput; + const { user_id, post_id } = inputData; + const conn = ConnectionFactory(); + const query = ` + DELETE FROM PostLike + WHERE user_id = ? AND post_id = ? + `; + const params = [user_id, post_id]; + await conn.execute({ sql: query, args: params }); + const followUpQuery = `SELECT * FROM PostLike WHERE post_id=?`; + const followUpParams = [post_id]; + const res = await conn.execute({ sql: followUpQuery, args: followUpParams }); + return NextResponse.json({ newLikes: res.rows }); +} diff --git a/src/api/database/post/[category]/by-id/[id]/route.ts b/src/api/database/post/[category]/by-id/[id]/route.ts new file mode 100644 index 0000000..97f756c --- /dev/null +++ b/src/api/database/post/[category]/by-id/[id]/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { ConnectionFactory } from "@/app/utils"; + +export async function GET( + _: NextRequest, + context: { params: Promise<{ category: string; id: string }> }, +) { + const readyParams = await context.params; + if (readyParams.category !== "blog" && readyParams.category !== "project") { + return NextResponse.json( + { error: "invalid category value" }, + { status: 400 }, + ); + } else { + try { + const conn = ConnectionFactory(); + const query = `SELECT * FROM Post WHERE id = ?`; + const params = [parseInt(readyParams.id)]; + const results = await conn.execute({ sql: query, args: params }); + const tagQuery = `SELECT * FROM Tag WHERE post_id = ?`; + const tagRes = await conn.execute({ sql: tagQuery, args: params }); + if (results.rows[0]) { + return NextResponse.json( + { + post: results.rows[0], + tags: tagRes.rows, + }, + { status: 200 }, + ); + } else { + return NextResponse.json( + { + post: [], + }, + { status: 204 }, + ); + } + } catch (e) { + console.error(e); + return NextResponse.json({ error: e }, { status: 400 }); + } + } +} diff --git a/src/api/database/post/[category]/by-title/[title]/route.ts b/src/api/database/post/[category]/by-title/[title]/route.ts new file mode 100644 index 0000000..a3ea110 --- /dev/null +++ b/src/api/database/post/[category]/by-title/[title]/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { env } from "@/env.mjs"; +import { ConnectionFactory } from "@/app/utils"; +import { Post } from "@/types/model-types"; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ category: string; title: string }> }, +) { + const readyParams = await context.params; + if (readyParams.category !== "blog" && readyParams.category !== "project") { + return NextResponse.json( + { error: "invalid category value" }, + { status: 400 }, + ); + } else { + try { + let privilegeLevel = "anonymous"; + const token = request.cookies.get("userIDToken"); + if (token) { + if (token.value == env.ADMIN_ID) { + privilegeLevel = "admin"; + } else { + privilegeLevel = "user"; + } + } + const conn = ConnectionFactory(); + + const projectQuery = + "SELECT p.*, c.*, l.*,t.* FROM Post p JOIN Comment c ON p.id = c.post_id JOIN PostLike l ON p.id = l.post_idJOIN Tag t ON p.id = t.post_id WHERE p.title = ? AND p.category = ? AND p.published = ?;"; + const projectParams = [readyParams.title, readyParams.category, true]; + const projectResults = await conn.execute({ + sql: projectQuery, + args: projectParams, + }); + if (projectResults.rows[0]) { + const post_id = (projectResults.rows[0] as unknown as Post).id; + + const commentQuery = "SELECT * FROM Comment WHERE post_id = ?"; + const commentResults = await conn.execute({ + sql: commentQuery, + args: [post_id], + }); + + const likeQuery = "SELECT * FROM PostLike WHERE post_id = ?"; + const likeQueryResults = await conn.execute({ + sql: likeQuery, + args: [post_id], + }); + + const tagsQuery = "SELECT * FROM Tag WHERE post_id = ?"; + const tagResults = await conn.execute({ + sql: tagsQuery, + args: [post_id], + }); + + return NextResponse.json( + { + project: projectResults.rows[0], + comments: commentResults.rows, + likes: likeQueryResults.rows, + tagResults: tagResults.rows, + privilegeLevel: privilegeLevel, + }, + { status: 200 }, + ); + } + return NextResponse.json({ status: 200 }); + } catch (e) { + return NextResponse.json({ error: e }, { status: 400 }); + } + } +} diff --git a/src/api/database/post/[category]/manipulation/route.ts b/src/api/database/post/[category]/manipulation/route.ts new file mode 100644 index 0000000..9e6d306 --- /dev/null +++ b/src/api/database/post/[category]/manipulation/route.ts @@ -0,0 +1,157 @@ +import { NextRequest, NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { env } from "@/env.mjs"; +import { ConnectionFactory } from "@/app/utils"; + +interface POSTInputData { + title: string; + subtitle: string | null; + body: string | null; + banner_photo: string | null; + published: boolean; + tags: string[] | null; +} + +interface PATCHInputData { + id: number; + title: string | null; + subtitle: string | null; + body: string | null; + banner_photo: string | null; + published: boolean | null; + tags: string[] | null; +} + +export async function POST( + input: NextRequest, + context: { params: Promise<{ category: string }> }, +) { + const readyParams = await context.params; + if (readyParams.category !== "blog" && readyParams.category !== "project") { + return NextResponse.json( + { error: "invalid category value" }, + { status: 400 }, + ); + } else { + try { + const inputData = (await input.json()) as POSTInputData; + const { title, subtitle, body, banner_photo, published, tags } = + inputData; + const userIDCookie = (await cookies()).get("userIDToken"); + const fullURL = env.NEXT_PUBLIC_AWS_BUCKET_STRING + banner_photo; + + if (userIDCookie) { + const author_id = userIDCookie.value; + const conn = ConnectionFactory(); + const query = ` + INSERT INTO Post (title, category, subtitle, body, banner_photo, published, author_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + `; + const params = [ + title, + readyParams.category, + subtitle, + body, + banner_photo ? fullURL : null, + published, + author_id, + ]; + const results = await conn.execute({ sql: query, args: params }); + if (tags) { + let query = "INSERT INTO Tag (value, post_id) VALUES "; + let values = tags.map( + (tag) => `("${tag}", ${results.lastInsertRowid})`, + ); + query += values.join(", "); + await conn.execute(query); + } + return NextResponse.json( + { data: results.lastInsertRowid }, + { status: 201 }, + ); + } + return NextResponse.json({ error: "no cookie" }, { status: 401 }); + } catch (e) { + console.error(e); + return NextResponse.json({ error: e }, { status: 400 }); + } + } +} +export async function PATCH(input: NextRequest) { + try { + const inputData = (await input.json()) as PATCHInputData; + + const conn = ConnectionFactory(); + const { query, params } = await createUpdateQuery(inputData); + const results = await conn.execute({ + sql: query, + args: params as string[], + }); + const { tags, id } = inputData; + const deleteTagsQuery = `DELETE FROM Tag WHERE post_id = ?`; + await conn.execute({ sql: deleteTagsQuery, args: [id.toString()] }); + if (tags) { + let query = "INSERT INTO Tag (value, post_id) VALUES "; + let values = tags.map((tag) => `("${tag}", ${id})`); + query += values.join(", "); + await conn.execute(query); + } + return NextResponse.json( + { data: results.lastInsertRowid }, + { status: 201 }, + ); + } catch (e) { + console.error(e); + return NextResponse.json({ error: e }, { status: 400 }); + } +} + +async function createUpdateQuery(data: PATCHInputData) { + const { id, title, subtitle, body, banner_photo, published } = data; + + let query = "UPDATE Post SET "; + let params = []; + let first = true; + + if (title !== null) { + query += first ? "title = ?" : ", title = ?"; + params.push(title); + first = false; + } + + if (subtitle !== null) { + query += first ? "subtitle = ?" : ", subtitle = ?"; + params.push(subtitle); + first = false; + } + + if (body !== null) { + query += first ? "body = ?" : ", body = ?"; + params.push(body); + first = false; + } + + if (banner_photo !== null) { + query += first ? "banner_photo = ?" : ", banner_photo = ?"; + if (banner_photo == "_DELETE_IMAGE_") { + params.push(undefined); + } else { + params.push(env.NEXT_PUBLIC_AWS_BUCKET_STRING + banner_photo); + } + first = false; + } + + if (published !== null) { + query += first ? "published = ?" : ", published = ?"; + params.push(published); + first = false; + } + + query += first ? "author_id = ?" : ", author_id = ?"; + params.push((await cookies()).get("userIDToken")?.value); + + query += " WHERE id = ?"; + params.push(id); + + return { query, params }; +} diff --git a/src/api/database/user/email/route.ts b/src/api/database/user/email/route.ts new file mode 100644 index 0000000..835e29a --- /dev/null +++ b/src/api/database/user/email/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { newEmailInput } from "@/types/input-types"; +import { ConnectionFactory } from "@/app/utils"; + +export async function POST(input: NextRequest) { + const inputData = (await input.json()) as newEmailInput; + const { id, newEmail } = inputData; + const oldEmail = (await cookies()).get("emailToken"); + const conn = ConnectionFactory(); + const query = `UPDATE User SET email = ? WHERE id = ? AND email = ?`; + const params = [newEmail, id, oldEmail]; + try { + const res = await conn.execute({ sql: query, args: params as string[] }); + return NextResponse.json({ res: res }, { status: 202 }); + } catch (e) { + console.log(e); + return NextResponse.json({ status: 400 }); + } +} diff --git a/src/api/database/user/from-id/[id]/route.ts b/src/api/database/user/from-id/[id]/route.ts new file mode 100644 index 0000000..7684365 --- /dev/null +++ b/src/api/database/user/from-id/[id]/route.ts @@ -0,0 +1,35 @@ +import { User } from "@/types/model-types"; +import { ConnectionFactory } from "@/app/utils"; +import { NextResponse } from "next/server"; +export async function GET( + _: Request, + context: { params: Promise<{ id: string }> }, +) { + try { + const conn = ConnectionFactory(); + const userQuery = "SELECT * FROM User WHERE id =?"; + const params = await context.params; + const userParams = [params.id]; + const res = await conn.execute({ sql: userQuery, args: userParams }); + if (res.rows[0]) { + const user = res.rows[0] as unknown as User; + if (user && user.display_name !== "user deleted") + return NextResponse.json( + { + id: user.id, + email: user.email, + emailVerified: user.email_verified, + image: user.image, + displayName: user.display_name, + provider: user.provider, + hasPassword: !!user.password_hash, + }, + { status: 202 }, + ); + } + return NextResponse.json({}, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({}, { status: 200 }); + } +} diff --git a/src/api/database/user/image/[id]/route.ts b/src/api/database/user/image/[id]/route.ts new file mode 100644 index 0000000..89eebce --- /dev/null +++ b/src/api/database/user/image/[id]/route.ts @@ -0,0 +1,33 @@ +import { ConnectionFactory } from "@/app/utils"; +import { env } from "@/env.mjs"; +import { changeImageInput } from "@/types/input-types"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET( + _: Request, + context: { params: Promise<{ id: string }> }, +) { + const conn = ConnectionFactory(); + const query = "SELECT * FROM User WHERE id = ?"; + const params = await context.params; + const idArr = [params.id]; + const results = await conn.execute({ sql: query, args: idArr }); + return NextResponse.json({ user: results.rows[0] }, { status: 200 }); +} +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> }, +) { + const inputData = (await request.json()) as changeImageInput; + const { imageURL } = inputData; + try { + const conn = ConnectionFactory(); + const query = `UPDATE User SET image = ? WHERE id = ?`; + const fullURL = env.NEXT_PUBLIC_AWS_BUCKET_STRING + imageURL; + const params = [imageURL ? fullURL : null, (await context.params).id]; + await conn.execute({ sql: query, args: params }); + return NextResponse.json({ res: "success" }, { status: 200 }); + } catch (err) { + return NextResponse.json({ res: err }, { status: 500 }); + } +} diff --git a/src/api/database/user/public-data/[id]/route.ts b/src/api/database/user/public-data/[id]/route.ts new file mode 100644 index 0000000..7075add --- /dev/null +++ b/src/api/database/user/public-data/[id]/route.ts @@ -0,0 +1,31 @@ +import { User } from "@/types/model-types"; +import { ConnectionFactory } from "@/app/utils"; +import { NextResponse } from "next/server"; +export async function GET( + _: Request, + context: { params: Promise<{ id: string }> }, +) { + try { + const conn = ConnectionFactory(); + const userQuery = "SELECT email, display_name, image FROM User WHERE id =?"; + const params = await context.params; + const userParams = [params.id]; + const res = await conn.execute({ sql: userQuery, args: userParams }); + if (res.rows[0]) { + const user = res.rows[0] as unknown as User; + if (user && user.display_name !== "user deleted") + return NextResponse.json( + { + email: user.email, + image: user.image, + display_name: user.display_name, + }, + { status: 202 }, + ); + } + return NextResponse.json({}, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({}, { status: 200 }); + } +} diff --git a/src/api/downloads/public/[asset_name]/route.ts b/src/api/downloads/public/[asset_name]/route.ts new file mode 100644 index 0000000..0e1b180 --- /dev/null +++ b/src/api/downloads/public/[asset_name]/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { env } from "@/env.mjs"; + +const assets: Record = { + "shapes-with-abigail": "shapes-with-abigail.apk", + "magic-delve": "magic-delve.apk", + cork: "Cork.zip", +}; + +const bucket = "frenomedownloads"; + +export async function GET( + _: Request, + context: { + params: Promise<{ asset_name: string }>; + }, +) { + const readyParams = await context.params; + const params = { + Bucket: bucket, + Key: assets[readyParams.asset_name], + Expires: 60 * 60, + }; + const credentials = { + accessKeyId: env._AWS_ACCESS_KEY, + secretAccessKey: env._AWS_SECRET_KEY, + }; + try { + const client = new S3Client({ + region: env.AWS_REGION, + credentials: credentials, + }); + + const command = new GetObjectCommand(params); + const signedUrl = await getSignedUrl(client, command, { expiresIn: 120 }); + return NextResponse.json({ downloadURL: signedUrl }); + } catch (e) { + console.log(e); + return NextResponse.json({ error: e }, { status: 400 }); + } +} diff --git a/src/api/lineage/_database_mgmt/loose/route.ts b/src/api/lineage/_database_mgmt/loose/route.ts new file mode 100644 index 0000000..0d3fe8f --- /dev/null +++ b/src/api/lineage/_database_mgmt/loose/route.ts @@ -0,0 +1,40 @@ +import { LineageConnectionFactory } from "@/app/utils"; +import { env } from "@/env.mjs"; +import { createClient as createAPIClient } from "@tursodatabase/api"; +import { NextResponse } from "next/server"; + +const IGNORE = ["frenome", "magic-delve-conductor"]; + +export async function GET() { + const conn = LineageConnectionFactory(); + const query = "SELECT database_url FROM User WHERE database_url IS NOT NULL"; + try { + const res = await conn.execute(query); + const turso = createAPIClient({ + org: "mikefreno", + token: env.TURSO_DB_API_TOKEN, + }); + const linkedDatabaseUrls = res.rows.map((row) => row.database_url); + + const all_dbs = await turso.databases.list(); + console.log(all_dbs); + const dbs_to_delete = all_dbs.filter((db) => { + return !IGNORE.includes(db.name) && !linkedDatabaseUrls.includes(db.name); + }); + //console.log("will delete:", dbs_to_delete); + } catch (e) { + return new NextResponse( + JSON.stringify({ + success: false, + message: e, + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + return new NextResponse( + JSON.stringify({ + success: true, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); +} diff --git a/src/api/lineage/_database_mgmt/old/route.ts b/src/api/lineage/_database_mgmt/old/route.ts new file mode 100644 index 0000000..6db0479 --- /dev/null +++ b/src/api/lineage/_database_mgmt/old/route.ts @@ -0,0 +1,42 @@ +import { LineageConnectionFactory } from "@/app/utils"; +import { env } from "@/env.mjs"; + +import { createClient as createAPIClient } from "@tursodatabase/api"; +import { NextResponse } from "next/server"; + +export async function GET() { + const conn = LineageConnectionFactory(); + const query = + "SELECT * FROM User WHERE datetime(db_destroy_date) < datetime('now');"; + try { + const res = await conn.execute(query); + const turso = createAPIClient({ + org: "mikefreno", + token: env.TURSO_DB_API_TOKEN, + }); + + res.rows.forEach(async (row) => { + const db_url = row.database_url; + + await turso.databases.delete(db_url as string); + const query = + "UPDATE User SET database_url = ?, database_token = ?, db_destroy_date = ? WHERE id = ?"; + const params = [null, null, null, row.id]; + conn.execute({ sql: query, args: params }); + }); + } catch (e) { + return new NextResponse( + JSON.stringify({ + success: false, + message: e, + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + return new NextResponse( + JSON.stringify({ + success: true, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); +} diff --git a/src/api/lineage/analytics/route.ts b/src/api/lineage/analytics/route.ts new file mode 100644 index 0000000..fe7be7f --- /dev/null +++ b/src/api/lineage/analytics/route.ts @@ -0,0 +1,42 @@ +import { LineageConnectionFactory } from "@/app/utils"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const { + playerID, + dungeonProgression, + playerClass, + spellCount, + proficiencies, + jobs, + resistanceTable, + damageTable, + } = await req.json(); + const conn = LineageConnectionFactory(); + try { + const res = await conn.execute({ + sql: ` + INSERT OR REPLACE INTO Analytics + (playerID, dungeonProgression, playerClass, spellCount, proficiencies, jobs, resistanceTable, damageTable) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, + args: [ + playerID, + JSON.stringify(dungeonProgression), + playerClass, + spellCount, + JSON.stringify(proficiencies), + JSON.stringify(jobs), + JSON.stringify(resistanceTable), + JSON.stringify(damageTable), + ], + }); + console.log(res); + + return NextResponse.json({ status: 200 }); + } catch (e) { + console.error(e); + + return NextResponse.json({ status: 500 }); + } +} diff --git a/src/api/lineage/apple/email/route.ts b/src/api/lineage/apple/email/route.ts new file mode 100644 index 0000000..065c0af --- /dev/null +++ b/src/api/lineage/apple/email/route.ts @@ -0,0 +1,26 @@ +import { LineageConnectionFactory } from "@/app/utils"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const { userString } = await req.json(); + if (!userString) { + return new NextResponse( + JSON.stringify({ + success: false, + message: "Missing required fields", + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + const conn = LineageConnectionFactory(); + const query = "SELECT * FROM User WHERE apple_user_string = ?"; + const res = await conn.execute({ sql: query, args: [userString] }); + if (res.rows.length > 0) { + return NextResponse.json( + { success: true, email: res.rows[0].email }, + { status: 200 }, + ); + } else { + return NextResponse.json({ success: false }, { status: 404 }); + } +} diff --git a/src/api/lineage/apple/registration/route.ts b/src/api/lineage/apple/registration/route.ts new file mode 100644 index 0000000..3cd5fd9 --- /dev/null +++ b/src/api/lineage/apple/registration/route.ts @@ -0,0 +1,134 @@ +import { LineageConnectionFactory, LineageDBInit } from "@/app/utils"; +import { NextRequest, NextResponse } from "next/server"; + +import { createClient as createAPIClient } from "@tursodatabase/api"; +import { env } from "@/env.mjs"; + +export async function POST(request: NextRequest) { + const { email, userString } = await request.json(); + if (!userString) { + return new NextResponse( + JSON.stringify({ + success: false, + message: "Missing required fields", + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + let dbName; + let dbToken; + const conn = LineageConnectionFactory(); + + try { + let checkUserQuery = "SELECT * FROM User WHERE apple_user_string = ?"; + + let args = [userString]; + if (email) { + args.push(email); + checkUserQuery += " OR email = ?"; + } + const checkUserResult = await conn.execute({ + sql: checkUserQuery, + args: args, + }); + + if (checkUserResult.rows.length > 0) { + const setClauses = []; + const values = []; + + if (email) { + setClauses.push("email = ?"); + values.push(email); + } + setClauses.push("provider = ?", "apple_user_string = ?"); + values.push("apple", userString); + const whereClause = `WHERE apple_user_string = ?${ + email && " OR email = ?" + }`; + values.push(userString); + if (email) { + values.push(email); + } + + const updateQuery = `UPDATE User SET ${setClauses.join( + ", ", + )} ${whereClause}`; + const updateRes = await conn.execute({ + sql: updateQuery, + args: values, + }); + if (updateRes.rowsAffected != 0) { + return new NextResponse( + JSON.stringify({ + success: true, + message: "User information updated", + email: checkUserResult.rows[0].email, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } else { + return new NextResponse( + JSON.stringify({ + success: false, + message: "User update failed!", + }), + { status: 418, headers: { "content-type": "application/json" } }, + ); + } + } else { + // User doesn't exist, insert new user and init database + const dbInit = await LineageDBInit(); + dbToken = dbInit.token; + dbName = dbInit.dbName; + + try { + const insertQuery = ` + INSERT INTO User (email, email_verified, apple_user_string, provider, database_name, database_token) + VALUES (?, ?, ?, ?, ?, ?) + `; + await conn.execute({ + sql: insertQuery, + args: [email, true, userString, "apple", dbName, dbToken], + }); + + return new NextResponse( + JSON.stringify({ + success: true, + message: "New user created", + dbName, + dbToken, + }), + { status: 201, headers: { "content-type": "application/json" } }, + ); + } catch (error) { + const turso = createAPIClient({ + org: "mikefreno", + token: env.TURSO_DB_API_TOKEN, + }); + await turso.databases.delete(dbName); + console.error(error); + } + } + } catch (error) { + if (dbName) { + try { + const turso = createAPIClient({ + org: "mikefreno", + token: env.TURSO_DB_API_TOKEN, + }); + await turso.databases.delete(dbName); + } catch (deleteErr) { + console.error("Error deleting database:", deleteErr); + } + } + console.error("Error in Apple Sign-Up handler:", error); + return new NextResponse( + JSON.stringify({ + success: false, + message: "An error occurred while processing the request", + }), + { status: 500, headers: { "content-type": "application/json" } }, + ); + } +} diff --git a/src/api/lineage/database/creds/route.ts b/src/api/lineage/database/creds/route.ts new file mode 100644 index 0000000..d230198 --- /dev/null +++ b/src/api/lineage/database/creds/route.ts @@ -0,0 +1,89 @@ +import { env } from "@/env.mjs"; +import { NextRequest, NextResponse } from "next/server"; +import jwt from "jsonwebtoken"; +import { LineageConnectionFactory } from "@/app/utils"; +import { OAuth2Client } from "google-auth-library"; +const CLIENT_ID = env.NEXT_PUBLIC_GOOGLE_CLIENT_ID_MAGIC_DELVE; + +const client = new OAuth2Client(CLIENT_ID); + +export async function POST(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return new NextResponse(JSON.stringify({ valid: false }), { status: 401 }); + } + const { email, provider } = await req.json(); + if (!email) { + return new NextResponse( + JSON.stringify({ success: false, message: "missing email in body" }), + { + status: 401, + }, + ); + } + + const token = authHeader.split(" ")[1]; + + try { + let valid_request = false; + if (provider == "email") { + const decoded = jwt.verify(token, env.JWT_SECRET_KEY) as jwt.JwtPayload; + if (decoded.email == email) { + valid_request = true; + } + } else if (provider == "google") { + const ticket = await client.verifyIdToken({ + idToken: token, + audience: CLIENT_ID, + }); + if (ticket.getPayload()?.email == email) { + valid_request = true; + } + } else { + const conn = LineageConnectionFactory(); + const query = "SELECT * FROM User WHERE apple_user_string = ?"; + const res = await conn.execute({ sql: query, args: [token] }); + if (res.rows.length > 0 && res.rows[0].email == email) { + valid_request = true; + } + } + + if (valid_request) { + const conn = LineageConnectionFactory(); + const query = "SELECT * FROM User WHERE email = ? LIMIT 1"; + const params = [email]; + const res = await conn.execute({ sql: query, args: params }); + if (res.rows.length === 1) { + const user = res.rows[0]; + return new NextResponse( + JSON.stringify({ + success: true, + db_name: user.database_name, + db_token: user.database_token, + }), + { status: 200 }, + ); + } + return new NextResponse( + JSON.stringify({ success: false, message: "no user found" }), + { + status: 404, + }, + ); + } else { + return new NextResponse( + JSON.stringify({ success: false, message: "destroy token" }), + { + status: 401, + }, + ); + } + } catch (error) { + return new NextResponse( + JSON.stringify({ success: false, message: error }), + { + status: 401, + }, + ); + } +} diff --git a/src/api/lineage/database/deletion/cancel/route.ts b/src/api/lineage/database/deletion/cancel/route.ts new file mode 100644 index 0000000..731cac8 --- /dev/null +++ b/src/api/lineage/database/deletion/cancel/route.ts @@ -0,0 +1,69 @@ +import { LineageConnectionFactory, validateLineageRequest } from "@/app/utils"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return NextResponse.json({ + status: 401, + ok: false, + message: "Missing or invalid authorization header.", + }); + } + const auth_token = authHeader.split(" ")[1]; + + const { email } = await req.json(); + if (!email) { + return NextResponse.json({ + status: 400, + ok: false, + message: "Email is required to cancel the cron job.", + }); + } + + const conn = LineageConnectionFactory(); + + const resUser = await conn.execute({ + sql: `SELECT * FROM User WHERE email = ?;`, + args: [email], + }); + if (resUser.rows.length === 0) { + return NextResponse.json({ + status: 404, + ok: false, + message: "User not found.", + }); + } + const userRow = resUser.rows[0]; + if (!userRow) { + return NextResponse.json({ status: 404, ok: false }); + } + + const valid = await validateLineageRequest({ auth_token, userRow }); + if (!valid) { + return NextResponse.json({ + status: 401, + ok: false, + message: "Invalid credentials for cancelation.", + }); + } + + const result = await conn.execute({ + sql: `DELETE FROM cron WHERE email = ?;`, + args: [email], + }); + + if (result.rowsAffected > 0) { + return NextResponse.json({ + status: 200, + ok: true, + message: "Cron job(s) canceled successfully.", + }); + } else { + return NextResponse.json({ + status: 404, + ok: false, + message: "No cron job found for the given email.", + }); + } +} diff --git a/src/api/lineage/database/deletion/check/route.ts b/src/api/lineage/database/deletion/check/route.ts new file mode 100644 index 0000000..53581b9 --- /dev/null +++ b/src/api/lineage/database/deletion/check/route.ts @@ -0,0 +1,24 @@ +import { LineageConnectionFactory } from "@/app/utils"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const { email } = await req.json(); + const conn = LineageConnectionFactory(); + try { + const res = await conn.execute({ + sql: `SELECT * FROM cron WHERE email = ?`, + args: [email], + }); + const cronRow = res.rows[0]; + if (!cronRow) { + return NextResponse.json({ status: 204, ok: true }); + } + return NextResponse.json({ + ok: true, + status: 200, + created_at: cronRow.created_at, + }); + } catch (e) { + return NextResponse.json({ status: 500, ok: false }); + } +} diff --git a/src/api/lineage/database/deletion/cron/route.ts b/src/api/lineage/database/deletion/cron/route.ts new file mode 100644 index 0000000..6ad2198 --- /dev/null +++ b/src/api/lineage/database/deletion/cron/route.ts @@ -0,0 +1,73 @@ +import { dumpAndSendDB, LineageConnectionFactory } from "@/app/utils"; +import { NextResponse } from "next/server"; +import { createClient as createAPIClient } from "@tursodatabase/api"; +import { env } from "@/env.mjs"; + +export async function GET() { + const conn = LineageConnectionFactory(); + const res = await conn.execute( + `SELECT * FROM cron WHERE created_at <= datetime('now', '-1 day');`, + ); + + if (res.rows.length > 0) { + const executed_ids = []; + for (const row of res.rows) { + const { id, db_name, db_token, send_dump_target, email } = row; + + if (send_dump_target) { + const res = await dumpAndSendDB({ + dbName: db_name as string, + dbToken: db_token as string, + sendTarget: send_dump_target as string, + }); + if (res.success) { + //const res = await turso.databases.delete(db_name as string); + // + const res = await fetch( + `https://api.turso.tech/v1/organizations/mikefreno/databases/${db_name}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`, + }, + }, + ); + if (res.ok) { + executed_ids.push(id); + // Shouldn't fail. No idea what the response from turso would be at this point - not documented + } + } + } else { + const res = await fetch( + `https://api.turso.tech/v1/organizations/mikefreno/databases/${db_name}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`, + }, + }, + ); + if (res.ok) { + conn.execute({ + sql: `DELETE FROM User WHERE email = ?`, + args: [email], + }); + executed_ids.push(id); + // Shouldn't fail. No idea what the response from turso would be at this point - not documented + } + } + } + if (executed_ids.length > 0) { + const placeholders = executed_ids.map(() => "?").join(", "); + const deleteQuery = `DELETE FROM cron WHERE id IN (${placeholders});`; + await conn.execute({ sql: deleteQuery, args: executed_ids }); + + return NextResponse.json({ + status: 200, + message: + "Processed databases deleted and corresponding cron rows removed.", + }); + } + } + return NextResponse.json({ status: 200, ok: true }); +} diff --git a/src/api/lineage/database/deletion/init/route.ts b/src/api/lineage/database/deletion/init/route.ts new file mode 100644 index 0000000..b9b3b02 --- /dev/null +++ b/src/api/lineage/database/deletion/init/route.ts @@ -0,0 +1,154 @@ +import { + dumpAndSendDB, + LineageConnectionFactory, + validateLineageRequest, +} from "@/app/utils"; +import { env } from "@/env.mjs"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return NextResponse.json({ status: 401, ok: false }); + } + + const auth_token = authHeader.split(" ")[1]; + const { email, db_name, db_token, skip_cron, send_dump_target } = + await req.json(); + if (!email || !db_name || !db_token || !auth_token) { + return NextResponse.json({ + status: 401, + message: "Missing required fields", + }); + } + + const conn = LineageConnectionFactory(); + const res = await conn.execute({ + sql: `SELECT * FROM User WHERE email = ?`, + args: [email], + }); + const userRow = res.rows[0]; + if (!userRow) { + return NextResponse.json({ status: 404, ok: false }); + } + + const valid = await validateLineageRequest({ auth_token, userRow }); + if (!valid) { + return NextResponse.json({ + ok: false, + status: 401, + message: "Invalid Verification", + }); + } + + const { database_token, database_name } = userRow; + + if (database_token !== db_token || database_name !== db_name) { + return NextResponse.json({ + ok: false, + status: 401, + message: "Incorrect Verification", + }); + } + + if (skip_cron) { + if (send_dump_target) { + const res = await dumpAndSendDB({ + dbName: db_name, + dbToken: db_token, + sendTarget: send_dump_target, + }); + if (res.success) { + //const turso = createAPIClient({ + //org: "mikefreno", + //token: env.TURSO_DB_API_TOKEN, + //}); + //const res = await turso.databases.delete(db_name); // seems unreliable, using rest api instead + const res = await fetch( + `https://api.turso.tech/v1/organizations/mikefreno/databases/${db_name}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`, + }, + }, + ); + if (res.ok) { + conn.execute({ + sql: `DELETE FROM User WHERE email = ?`, + args: [email], + }); + return NextResponse.json({ + ok: true, + status: 200, + message: `Account and Database deleted, db dump sent to email: ${send_dump_target}`, + }); + } else { + // Shouldn't fail. No idea what the response from turso would be at this point - not documented + return NextResponse.json({ + status: 500, + message: "Unknown", + ok: false, + }); + } + } else { + return NextResponse.json({ + ok: false, + status: 500, + message: res.reason, + }); + } + } else { + //const turso = createAPIClient({ + //org: "mikefreno", + //token: env.TURSO_DB_API_TOKEN, + //}); + //const res = await turso.databases.delete(db_name); + const res = await fetch( + `https://api.turso.tech/v1/organizations/mikefreno/databases/${db_name}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`, + }, + }, + ); + if (res.ok) { + conn.execute({ + sql: `DELETE FROM User WHERE email = ?`, + args: [email], + }); + return NextResponse.json({ + ok: true, + status: 200, + message: `Account and Database deleted`, + }); + } else { + // Shouldn't fail. No idea what the response from turso would be at this point - not documented + return NextResponse.json({ + ok: false, + status: 500, + message: "Unknown", + }); + } + } + } else { + const insertRes = await conn.execute({ + sql: `INSERT INTO cron (email, db_name, db_token, send_dump_target) VALUES (?, ?, ?, ?)`, + args: [email, db_name, db_token, send_dump_target], + }); + if (insertRes.rowsAffected > 0) { + return NextResponse.json({ + ok: true, + status: 200, + message: `Deletion scheduled.`, + }); + } else { + return NextResponse.json({ + ok: false, + status: 500, + message: `Deletion not scheduled, due to server failure`, + }); + } + } +} diff --git a/src/api/lineage/email/login/route.ts b/src/api/lineage/email/login/route.ts new file mode 100644 index 0000000..1a12fbd --- /dev/null +++ b/src/api/lineage/email/login/route.ts @@ -0,0 +1,81 @@ +import { LINEAGE_JWT_EXPIRY, LineageConnectionFactory } from "@/app/utils"; +import { NextRequest, NextResponse } from "next/server"; +import { checkPassword } from "../../../passwordHashing"; +import jwt from "jsonwebtoken"; +import { env } from "@/env.mjs"; + +interface InputData { + email: string; + password: string; +} + +export async function POST(input: NextRequest) { + const inputData = (await input.json()) as InputData; + const { email, password } = inputData; + if (email && password) { + if (password.length < 8) { + return new NextResponse( + JSON.stringify({ + success: false, + message: "Invalid Credentials", + }), + { status: 401, headers: { "content-type": "application/json" } }, + ); + } + const conn = LineageConnectionFactory(); + const query = `SELECT * FROM User WHERE email = ? AND provider = ? LIMIT 1`; + const params = [email, "email"]; + const res = await conn.execute({ sql: query, args: params }); + if (res.rows.length == 0) { + return new NextResponse( + JSON.stringify({ + success: false, + message: "Invalid Credentials", + }), + { status: 401, headers: { "content-type": "application/json" } }, + ); + } + const user = res.rows[0]; + if (user.email_verified === 0) { + return new NextResponse( + JSON.stringify({ + success: false, + message: "Email not yet verified!", + }), + { status: 401, headers: { "content-type": "application/json" } }, + ); + } + const valid = await checkPassword(password, user.password_hash as string); + if (!valid) { + return new NextResponse( + JSON.stringify({ + success: false, + message: "Invalid Credentials", + }), + { status: 401, headers: { "content-type": "application/json" } }, + ); + } + + // create token + const token = jwt.sign( + { userId: user.id, email: user.email }, + env.JWT_SECRET_KEY, + { expiresIn: LINEAGE_JWT_EXPIRY }, + ); + + return NextResponse.json({ + success: true, + message: "Login successful", + token: token, + email: email, + }); + } else { + return new NextResponse( + JSON.stringify({ + success: false, + message: "Missing required fields", + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } +} diff --git a/src/api/lineage/email/refresh/token/route.ts b/src/api/lineage/email/refresh/token/route.ts new file mode 100644 index 0000000..52aeb0e --- /dev/null +++ b/src/api/lineage/email/refresh/token/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import jwt from "jsonwebtoken"; +import { env } from "@/env.mjs"; +import { LINEAGE_JWT_EXPIRY } from "@/app/utils"; + +export async function GET(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return new NextResponse(JSON.stringify({ valid: false }), { status: 401 }); + } + + const token = authHeader.split(" ")[1]; + + try { + const decoded = jwt.verify(token, env.JWT_SECRET_KEY) as jwt.JwtPayload; + + const newToken = jwt.sign( + { userId: decoded.userId, email: decoded.email }, + env.JWT_SECRET_KEY, + { expiresIn: LINEAGE_JWT_EXPIRY }, + ); + + return NextResponse.json({ + status: 200, + ok: true, + valid: true, + token: newToken, + email: decoded.email, + }); + } catch (error) { + return NextResponse.json({ status: 401, ok: false }); + } +} diff --git a/src/api/lineage/email/refresh/verification/route.ts b/src/api/lineage/email/refresh/verification/route.ts new file mode 100644 index 0000000..6cb43fc --- /dev/null +++ b/src/api/lineage/email/refresh/verification/route.ts @@ -0,0 +1,107 @@ +import { LineageConnectionFactory } from "@/app/utils"; +import { env } from "@/env.mjs"; +import jwt from "jsonwebtoken"; +import { NextRequest, NextResponse } from "next/server"; + +interface InputData { + email: string; +} +export async function POST(input: NextRequest) { + const inputData = (await input.json()) as InputData; + const { email } = inputData; + const conn = LineageConnectionFactory(); + const query = "SELECT * FROM User WHERE email = ?"; + const params = [email]; + + const res = await conn.execute({ sql: query, args: params }); + + if (res.rows.length == 0 || res.rows[0].email_verified) { + return new NextResponse( + JSON.stringify({ + success: false, + message: "Invalid Request", + }), + { status: 409, headers: { "content-type": "application/json" } }, + ); + } + + const email_res = await sendEmailVerification(email); + const json = await email_res.json(); + if (json.messageId) { + return new NextResponse( + JSON.stringify({ + success: true, + message: "Email verification sent!", + }), + { status: 201, headers: { "content-type": "application/json" } }, + ); + } else { + return NextResponse.json(json); + } +} + +async function sendEmailVerification(userEmail: string) { + const apiKey = env.SENDINBLUE_KEY as string; + const apiUrl = "https://api.sendinblue.com/v3/smtp/email"; + + const secretKey = env.JWT_SECRET_KEY; + const payload = { email: userEmail }; + const token = jwt.sign(payload, secretKey, { expiresIn: "15m" }); + + const sendinblueData = { + sender: { + name: "MikeFreno", + email: "lifeandlineage_no_reply@freno.me", + }, + to: [ + { + email: userEmail, + }, + ], + htmlContent: ` + + + + +
+

Click the button below to verify email

+
+
+ + + +`, + subject: `Life and Lineage email verification`, + }; + return await fetch(apiUrl, { + method: "POST", + headers: { + accept: "application/json", + "api-key": apiKey, + "content-type": "application/json", + }, + body: JSON.stringify(sendinblueData), + }); +} diff --git a/src/api/lineage/email/registration/route.ts b/src/api/lineage/email/registration/route.ts new file mode 100644 index 0000000..253ec2f --- /dev/null +++ b/src/api/lineage/email/registration/route.ts @@ -0,0 +1,144 @@ +import { NextRequest, NextResponse } from "next/server"; +import { hashPassword } from "../../../passwordHashing"; +import { LineageConnectionFactory } from "@/app/utils"; +import { env } from "@/env.mjs"; +import jwt from "jsonwebtoken"; +import { LibsqlError } from "@libsql/client/web"; + +interface InputData { + email: string; + password: string; + password_conf: string; +} + +export async function POST(input: NextRequest) { + const inputData = (await input.json()) as InputData; + const { email, password, password_conf } = inputData; + + if (email && password && password_conf) { + if (password == password_conf) { + const passwordHash = await hashPassword(password); + const conn = LineageConnectionFactory(); + const userCreationQuery = ` + INSERT INTO User (email, provider, password_hash) + VALUES (?, ?, ?) + `; + const params = [email, "email", passwordHash]; + try { + await conn.execute({ sql: userCreationQuery, args: params }); + + const res = await sendEmailVerification(email); + const json = await res.json(); + if (json.messageId) { + return new NextResponse( + JSON.stringify({ + success: true, + message: "Email verification sent!", + }), + { status: 201, headers: { "content-type": "application/json" } }, + ); + } else { + return NextResponse.json(json); + } + } catch (e) { + console.error(e); + if (e instanceof LibsqlError && e.code === "SQLITE_CONSTRAINT") { + return new NextResponse( + JSON.stringify({ + success: false, + message: "User already exists", + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + return new NextResponse( + JSON.stringify({ + success: false, + message: "An error occurred while creating the user", + }), + { status: 500, headers: { "content-type": "application/json" } }, + ); + } + } + return new NextResponse( + JSON.stringify({ + success: false, + message: "Password mismatch", + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + return new NextResponse( + JSON.stringify({ + success: false, + message: "Missing required fields", + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); +} + +async function sendEmailVerification(userEmail: string) { + const apiKey = env.SENDINBLUE_KEY as string; + const apiUrl = "https://api.sendinblue.com/v3/smtp/email"; + + const secretKey = env.JWT_SECRET_KEY; + const payload = { email: userEmail }; + const token = jwt.sign(payload, secretKey, { expiresIn: "15m" }); + + const sendinblueData = { + sender: { + name: "MikeFreno", + email: "lifeandlineage_no_reply@freno.me", + }, + to: [ + { + email: userEmail, + }, + ], + htmlContent: ` + + + + +
+

Click the button below to verify email

+
+
+ + + +`, + subject: `Life and Lineage email verification`, + }; + return await fetch(apiUrl, { + method: "POST", + headers: { + accept: "application/json", + "api-key": apiKey, + "content-type": "application/json", + }, + body: JSON.stringify(sendinblueData), + }); +} diff --git a/src/api/lineage/email/verification/[email]/route.ts b/src/api/lineage/email/verification/[email]/route.ts new file mode 100644 index 0000000..6928abd --- /dev/null +++ b/src/api/lineage/email/verification/[email]/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from "next/server"; +import { env } from "@/env.mjs"; +import jwt, { JwtPayload } from "jsonwebtoken"; +import { LineageConnectionFactory, LineageDBInit } from "@/app/utils"; +import { createClient as createAPIClient } from "@tursodatabase/api"; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ email: string }> }, +) { + const secretKey = env.JWT_SECRET_KEY; + const params = request.nextUrl.searchParams; + const token = params.get("token"); + const userEmail = (await context.params).email; + + let conn; + let dbName; + let dbToken; + + try { + if (!token) { + return NextResponse.json( + { success: false, message: "Authentication failed: no token" }, + { status: 401, headers: { "content-type": "application/json" } }, + ); + } + + const decoded = jwt.verify(token, secretKey) as JwtPayload; + if (decoded.email !== userEmail) { + return NextResponse.json( + { success: false, message: "Authentication failed: email mismatch" }, + { status: 401, headers: { "content-type": "application/json" } }, + ); + } + + conn = LineageConnectionFactory(); + const dbInit = await LineageDBInit(); + dbName = dbInit.dbName; + dbToken = dbInit.token; + + const query = `UPDATE User SET email_verified = ?, database_name = ?, database_token = ? WHERE email = ?`; + const queryParams = [true, dbName, dbToken, userEmail]; + const res = await conn.execute({ sql: query, args: queryParams }); + + if (res.rowsAffected === 0) { + throw new Error("User not found or update failed"); + } + + return new NextResponse( + JSON.stringify({ + success: true, + message: + "Email verification success. You may close this window and sign in within the app.", + }), + { status: 202, headers: { "content-type": "application/json" } }, + ); + } catch (err) { + console.error("Error in email verification:", err); + + // Delete the database if it was created + if (dbName) { + try { + const turso = createAPIClient({ + org: "mikefreno", + token: env.TURSO_DB_API_TOKEN, + }); + await turso.databases.delete(dbName); + console.log(`Database ${dbName} deleted due to error`); + } catch (deleteErr) { + console.error("Error deleting database:", deleteErr); + } + } + + // Attempt to revert the User table update if conn is available + if (conn) { + try { + await conn.execute({ + sql: `UPDATE User SET email_verified = ?, database_name = ?, database_token = ? WHERE email = ?`, + args: [false, null, null, userEmail], + }); + console.log("User table update reverted"); + } catch (revertErr) { + console.error("Error reverting User table update:", revertErr); + } + } + + return new NextResponse( + JSON.stringify({ + success: false, + message: + "Authentication failed: An error occurred during email verification. Please try again.", + }), + { status: 500, headers: { "content-type": "application/json" } }, + ); + } +} diff --git a/src/api/lineage/google/registration/route.ts b/src/api/lineage/google/registration/route.ts new file mode 100644 index 0000000..fe0a9cc --- /dev/null +++ b/src/api/lineage/google/registration/route.ts @@ -0,0 +1,101 @@ +import { LineageConnectionFactory, LineageDBInit } from "@/app/utils"; +import { NextRequest, NextResponse } from "next/server"; + +import { createClient as createAPIClient } from "@tursodatabase/api"; +import { env } from "@/env.mjs"; + +export async function POST(request: NextRequest) { + const { email } = await request.json(); + if (!email) { + return new NextResponse( + JSON.stringify({ + success: false, + message: "Missing required fields", + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const conn = LineageConnectionFactory(); + + try { + // Check if the user exists + const checkUserQuery = "SELECT * FROM User WHERE email = ?"; + const checkUserResult = await conn.execute({ + sql: checkUserQuery, + args: [email], + }); + + if (checkUserResult.rows.length > 0) { + const updateQuery = ` + UPDATE User + SET provider = ? + WHERE email = ? + `; + const updateRes = await conn.execute({ + sql: updateQuery, + args: ["google", email], + }); + + if (updateRes.rowsAffected != 0) { + return new NextResponse( + JSON.stringify({ + success: true, + message: "User information updated", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } else { + return new NextResponse( + JSON.stringify({ + success: false, + message: "User update failed!", + }), + { status: 418, headers: { "content-type": "application/json" } }, + ); + } + } else { + // User doesn't exist, insert new user and init database + let db_name; + try { + const { token, dbName } = await LineageDBInit(); + db_name = dbName; + console.log("init success"); + const insertQuery = ` + INSERT INTO User (email, email_verified, provider, database_name, database_token) + VALUES (?, ?, ?, ?, ?) + `; + await conn.execute({ + sql: insertQuery, + args: [email, true, "google", dbName, token], + }); + + console.log("insert success"); + + return new NextResponse( + JSON.stringify({ + success: true, + message: "New user created", + }), + { status: 201, headers: { "content-type": "application/json" } }, + ); + } catch (error) { + const turso = createAPIClient({ + org: "mikefreno", + token: env.TURSO_DB_API_TOKEN, + }); + await turso.databases.delete(db_name!); + console.error(error); + } + } + } catch (error) { + console.error("Error in Google Sign-Up handler:", error); + return new NextResponse( + JSON.stringify({ + success: false, + message: "An error occurred while processing the request", + }), + { status: 500, headers: { "content-type": "application/json" } }, + ); + } +} diff --git a/src/api/lineage/json_service/attacks/route.ts b/src/api/lineage/json_service/attacks/route.ts new file mode 100644 index 0000000..a471070 --- /dev/null +++ b/src/api/lineage/json_service/attacks/route.ts @@ -0,0 +1,27 @@ +import playerAttacks from "@/lineage-json/attack-route/playerAttacks.json"; +import mageBooks from "@/lineage-json/attack-route/mageBooks.json"; +import mageSpells from "@/lineage-json/attack-route/mageSpells.json"; +import necroBooks from "@/lineage-json/attack-route/necroBooks.json"; +import necroSpells from "@/lineage-json/attack-route/necroSpells.json"; +import rangerBooks from "@/lineage-json/attack-route/rangerBooks.json"; +import rangerSpells from "@/lineage-json/attack-route/rangerSpells.json"; +import paladinBooks from "@/lineage-json/attack-route/paladinBooks.json"; +import paladinSpells from "@/lineage-json/attack-route/paladinSpells.json"; +import summons from "@/lineage-json/attack-route/summons.json"; +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ + ok: true, + playerAttacks, + mageBooks, + mageSpells, + necroBooks, + necroSpells, + rangerBooks, + rangerSpells, + paladinBooks, + paladinSpells, + summons, + }); +} diff --git a/src/api/lineage/json_service/conditions/route.ts b/src/api/lineage/json_service/conditions/route.ts new file mode 100644 index 0000000..3e27497 --- /dev/null +++ b/src/api/lineage/json_service/conditions/route.ts @@ -0,0 +1,13 @@ +import conditions from "@/lineage-json/conditions-route/conditions.json"; +import debilitations from "@/lineage-json/conditions-route/debilitations.json"; +import sanityDebuffs from "@/lineage-json/conditions-route/sanityDebuffs.json"; +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ + ok: true, + conditions, + debilitations, + sanityDebuffs, + }); +} diff --git a/src/api/lineage/json_service/dungeons/route.ts b/src/api/lineage/json_service/dungeons/route.ts new file mode 100644 index 0000000..503858a --- /dev/null +++ b/src/api/lineage/json_service/dungeons/route.ts @@ -0,0 +1,7 @@ +import dungeons from "@/lineage-json/dungeon-route/dungeons.json"; +import specialEncounters from "@/lineage-json/dungeon-route/specialEncounters.json"; +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ ok: true, dungeons, specialEncounters }); +} diff --git a/src/api/lineage/json_service/enemies/route.ts b/src/api/lineage/json_service/enemies/route.ts new file mode 100644 index 0000000..15c3a59 --- /dev/null +++ b/src/api/lineage/json_service/enemies/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import bosses from "@/lineage-json/enemy-route/bosses.json"; +import enemies from "@/lineage-json/enemy-route/enemy.json"; +import enemyAttacks from "@/lineage-json/enemy-route/enemyAttacks.json"; + +export async function GET() { + return NextResponse.json({ ok: true, bosses, enemies, enemyAttacks }); +} diff --git a/src/api/lineage/json_service/items/route.ts b/src/api/lineage/json_service/items/route.ts new file mode 100644 index 0000000..636428b --- /dev/null +++ b/src/api/lineage/json_service/items/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import arrows from "@/lineage-json/item-route/arrows.json"; +import bows from "@/lineage-json/item-route/bows.json"; +import foci from "@/lineage-json/item-route/foci.json"; +import hats from "@/lineage-json/item-route/hats.json"; +import junk from "@/lineage-json/item-route/junk.json"; +import melee from "@/lineage-json/item-route/melee.json"; +import robes from "@/lineage-json/item-route/robes.json"; +import wands from "@/lineage-json/item-route/wands.json"; +import ingredients from "@/lineage-json/item-route/ingredients.json"; +import storyItems from "@/lineage-json/item-route/storyItems.json"; +import artifacts from "@/lineage-json/item-route/artifacts.json"; +import shields from "@/lineage-json/item-route/shields.json"; +import bodyArmor from "@/lineage-json/item-route/bodyArmor.json"; +import helmets from "@/lineage-json/item-route/helmets.json"; +import suffix from "@/lineage-json/item-route/suffix.json"; +import prefix from "@/lineage-json/item-route/prefix.json"; +import potions from "@/lineage-json/item-route/potions.json"; +import poison from "@/lineage-json/item-route/poison.json"; +import staves from "@/lineage-json/item-route/staves.json"; + +export async function GET() { + return NextResponse.json({ + ok: true, + arrows, + bows, + foci, + hats, + junk, + melee, + robes, + wands, + ingredients, + storyItems, + artifacts, + shields, + bodyArmor, + helmets, + suffix, + prefix, + potions, + poison, + staves, + }); +} diff --git a/src/api/lineage/json_service/misc/route.ts b/src/api/lineage/json_service/misc/route.ts new file mode 100644 index 0000000..80248d7 --- /dev/null +++ b/src/api/lineage/json_service/misc/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import activities from "@/lineage-json/misc-route/activities.json"; +import investments from "@/lineage-json/misc-route/investments.json"; +import jobs from "@/lineage-json/misc-route/jobs.json"; +import manaOptions from "@/lineage-json/misc-route/manaOptions.json"; +import otherOptions from "@/lineage-json/misc-route/otherOptions.json"; +import healthOptions from "@/lineage-json/misc-route/healthOptions.json"; +import sanityOptions from "@/lineage-json/misc-route/sanityOptions.json"; +import pvpRewards from "@/lineage-json/misc-route/pvpRewards.json"; + +export async function GET() { + return NextResponse.json({ + ok: true, + activities, + investments, + jobs, + manaOptions, + otherOptions, + healthOptions, + sanityOptions, + pvpRewards, + }); +} diff --git a/src/api/lineage/offline_secret/route.ts b/src/api/lineage/offline_secret/route.ts new file mode 100644 index 0000000..cf1e0ba --- /dev/null +++ b/src/api/lineage/offline_secret/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return new NextResponse(process.env.LINEAGE_OFFLINE_SERIALIZATION_SECRET); +} diff --git a/src/api/lineage/pvp/battle_result/route.ts b/src/api/lineage/pvp/battle_result/route.ts new file mode 100644 index 0000000..33f502c --- /dev/null +++ b/src/api/lineage/pvp/battle_result/route.ts @@ -0,0 +1,28 @@ +import { LineageConnectionFactory } from "@/app/utils"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const { winnerLinkID, loserLinkID } = await req.json(); + + const conn = LineageConnectionFactory(); + + try { + await conn.execute({ + sql: ` + UPDATE PvP_Characters + SET + winCount = winCount + CASE WHEN linkID = ? THEN 1 ELSE 0 END, + lossCount = lossCount + CASE WHEN linkID = ? THEN 1 ELSE 0 END + WHERE linkID IN (?, ?) + `, + args: [winnerLinkID, loserLinkID, winnerLinkID, loserLinkID], + }); + return NextResponse.json({ + ok: true, + status: 200, + }); + } catch (e) { + console.error(e); + return NextResponse.json({ ok: false, status: 500 }); + } +} diff --git a/src/api/lineage/pvp/route.ts b/src/api/lineage/pvp/route.ts new file mode 100644 index 0000000..bf8cd2f --- /dev/null +++ b/src/api/lineage/pvp/route.ts @@ -0,0 +1,154 @@ +import { LineageConnectionFactory } from "@/app/utils"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const { character, linkID, pushToken, pushCurrentlyEnabled } = + await req.json(); + + try { + const conn = LineageConnectionFactory(); + const res = await conn.execute({ + sql: `SELECT * FROM PvP_Characters WHERE linkID = ?`, + args: [linkID], + }); + if (res.rows.length == 0) { + //create + await conn.execute({ + sql: `INSERT INTO PvP_Characters ( + linkID, + blessing, + playerClass, + name, + maxHealth, + maxSanity, + maxMana, + baseManaRegen, + strength, + intelligence, + dexterity, + resistanceTable, + damageTable, + attackStrings, + knownSpells, + pushToken, + pushCurrentlyEnabled + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + linkID, + character.playerClass, + character.name, + character.maxHealth, + character.maxSanity, + character.maxMana, + character.baseManaRegen, + character.strength, + character.intelligence, + character.dexterity, + character.resistanceTable, + character.damageTable, + character.attackStrings, + character.knownSpells, + pushToken, + pushCurrentlyEnabled, + ], + }); + return NextResponse.json({ + ok: true, + winCount: 0, + lossCount: 0, + tokenRedemptionCount: 0, + status: 201, + }); + } else { + //update + await conn.execute({ + sql: `UPDATE PvP_Characters SET + playerClass = ?, + blessing = ?, + name = ?, + maxHealth = ?, + maxSanity = ?, + maxMana = ?, + baseManaRegen = ?, + strength = ?, + intelligence = ?, + dexterity = ?, + resistanceTable = ?, + damageTable = ?, + attackStrings = ?, + knownSpells = ?, + pushToken = ?, + pushCurrentlyEnabled = ? + WHERE linkID = ?`, + args: [ + character.playerClass, + character.blessing, + character.name, + character.maxHealth, + character.maxSanity, + character.maxMana, + character.baseManaRegen, + character.strength, + character.intelligence, + character.dexterity, + character.resistanceTable, + character.damageTable, + character.attackStrings, + character.knownSpells, + pushToken, + pushCurrentlyEnabled, + linkID, + ], + }); + return NextResponse.json({ + ok: true, + winCount: res.rows[0].winCount, + lossCount: res.rows[0].lossCount, + tokenRedemptionCount: res.rows[0].tokenRedemptionCount, + status: 200, + }); + } + } catch (e) { + console.error(e); + return NextResponse.json({ ok: false, status: 500 }); + } +} + +export async function GET() { + // Get three opponents, high, med, low, based on win/loss ratio + const conn = LineageConnectionFactory(); + try { + const res = await conn.execute( + ` + SELECT playerClass, + blessing, + name, + maxHealth, + maxSanity, + maxMana, + baseManaRegen, + strength, + intelligence, + dexterity, + resistanceTable, + damageTable, + attackStrings, + knownSpells, + linkID, + winCount, + lossCount + FROM PvP_Characters + ORDER BY RANDOM() + LIMIT 3 + `, + ); + return NextResponse.json({ + ok: true, + characters: res.rows, + status: 200, + }); + } catch (e) { + console.error(e); + return NextResponse.json({ ok: false, status: 500 }); + } +} diff --git a/src/api/lineage/tokens/route.ts b/src/api/lineage/tokens/route.ts new file mode 100644 index 0000000..5059814 --- /dev/null +++ b/src/api/lineage/tokens/route.ts @@ -0,0 +1,27 @@ +import { LineageConnectionFactory } from "@/app/utils"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const { token } = await req.json(); + if (!token) { + return new NextResponse( + JSON.stringify({ success: false, message: "missing token in body" }), + { + status: 401, + }, + ); + } + const conn = LineageConnectionFactory(); + const query = "SELECT * FROM Token WHERE token = ?"; + const res = await conn.execute({ sql: query, args: [token] }); + if (res.rows.length > 0) { + const queryUpdate = + "UPDATE Token SET last_updated_at = datetime('now') WHERE token = ?"; + const resUpdate = await conn.execute({ sql: queryUpdate, args: [token] }); + return NextResponse.json(JSON.stringify(resUpdate)); + } else { + const queryInsert = "INSERT INTO Token (token) VALUES (?)"; + const resInsert = await conn.execute({ sql: queryInsert, args: [token] }); + return NextResponse.json(JSON.stringify(resInsert)); + } +} diff --git a/src/api/passwordHashing.ts b/src/api/passwordHashing.ts new file mode 100644 index 0000000..28f1f4f --- /dev/null +++ b/src/api/passwordHashing.ts @@ -0,0 +1,20 @@ +import * as bcrypt from "bcrypt"; + +// Asynchronous function to hash a password +export async function hashPassword(password: string): Promise { + // 10 here is the number of rounds of hashing to apply + // The higher the number, the more secure but also the slower + const saltRounds = 10; + const salt = await bcrypt.genSalt(saltRounds); + const hashedPassword = await bcrypt.hash(password, salt); + return hashedPassword; +} + +// Asynchronous function to check a password against a hash +export async function checkPassword( + password: string, + hash: string +): Promise { + const match = await bcrypt.compare(password, hash); + return match; +} diff --git a/src/api/s3/deleteImage/route.ts b/src/api/s3/deleteImage/route.ts new file mode 100644 index 0000000..902a4cd --- /dev/null +++ b/src/api/s3/deleteImage/route.ts @@ -0,0 +1,35 @@ +import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3"; +import { NextRequest } from "next/dist/server/web/spec-extension/request"; +import { NextResponse } from "next/server"; +import { ConnectionFactory } from "@/app/utils"; +import { env } from "@/env.mjs"; + +interface InputData { + key: string; + newAttachmentString: string; + type: string; + id: number; +} + +export async function POST(input: NextRequest) { + const inputData = (await input.json()) as InputData; + const { key, newAttachmentString, type, id } = inputData; + // Parse the url to get the bucket and key + + const s3params = { + Bucket: env.AWS_S3_BUCKET_NAME, + Key: key, + }; + + const client = new S3Client({ + region: env.AWS_REGION, + }); + + const command = new DeleteObjectCommand(s3params); + const res = await client.send(command); + const conn = ConnectionFactory(); + const query = `UPDATE ${type} SET attachments = ? WHERE id = ?`; + const dbparams = [newAttachmentString, id]; + await conn.execute({ sql: query, args: dbparams }); + return NextResponse.json(res); +} diff --git a/src/api/s3/getPreSignedURL/route.ts b/src/api/s3/getPreSignedURL/route.ts new file mode 100644 index 0000000..3fedb3d --- /dev/null +++ b/src/api/s3/getPreSignedURL/route.ts @@ -0,0 +1,41 @@ +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { NextRequest, NextResponse } from "next/server"; +import { env } from "@/env.mjs"; + +interface InputData { + type: string; + title: string; + filename: string; +} + +export async function POST(input: NextRequest) { + const inputData = (await input.json()) as InputData; + const { type, title, filename } = inputData; + const credentials = { + accessKeyId: env._AWS_ACCESS_KEY, + secretAccessKey: env._AWS_SECRET_KEY, + }; + try { + const client = new S3Client({ + region: env.AWS_REGION, + credentials: credentials, + }); + const Key = `${type}/${title}/${filename}`; + const ext = /^.+\.([^.]+)$/.exec(filename); + + const s3params = { + Bucket: env.AWS_S3_BUCKET_NAME, + Key, + ContentType: `image/${ext![1]}`, + }; + 3; + const command = new PutObjectCommand(s3params); + + const signedUrl = await getSignedUrl(client, command, { expiresIn: 120 }); + return NextResponse.json({ uploadURL: signedUrl, key: Key }); + } catch (e) { + console.log(e); + return NextResponse.json({ error: e }, { status: 400 }); + } +} diff --git a/src/api/s3/simpleDeleteImage/route.ts b/src/api/s3/simpleDeleteImage/route.ts new file mode 100644 index 0000000..0dbb5a3 --- /dev/null +++ b/src/api/s3/simpleDeleteImage/route.ts @@ -0,0 +1,31 @@ +import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3"; +import { NextRequest } from "next/dist/server/web/spec-extension/request"; +import { NextResponse } from "next/server"; + +import { env } from "@/env.mjs"; + +interface InputData { + key: string; + newAttachmentString: string; + type: string; + id: number; +} + +export async function POST(input: NextRequest) { + const inputData = (await input.json()) as InputData; + const { key } = inputData; + // Parse the url to get the bucket and key + + const s3params = { + Bucket: env.AWS_S3_BUCKET_NAME, + Key: key, + }; + + const client = new S3Client({ + region: env.AWS_REGION, + }); + + const command = new DeleteObjectCommand(s3params); + const res = await client.send(command); + return NextResponse.json(res); +} diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..f92926e --- /dev/null +++ b/src/app.css @@ -0,0 +1,168 @@ +@import "tailwindcss"; + +:root { + /* Comments indicate what they are used for in vim/term + /* Base Colors (Background/Text) */ + --color-base: #fbf1c7; /* Main background color (lightest) */ + --color-mantle: #f3eac1; /* Secondary background (slightly darker) */ + --color-crust: #e7deb7; /* Lowest background layer (darkest) */ + + /* Text Colors */ + --color-text: #654735; /* Primary text color */ + --color-subtext1: #7b5d44; /* Secondary text (comments, less important) */ + --color-subtext0: #8f6f56; /* Tertiary text (even less important) */ + + /* Surface Colors */ + --color-surface0: #dfd6b1; /* Surface layer 0 (medium background) */ + --color-surface1: #c9c19f; /* Surface layer 1 (darker surface) */ + --color-surface2: #a79c86; /* Surface layer 2 (darkest surface) */ + + /* Overlay Colors */ + --color-overlay0: #c9aa8c; /* Overlay layer 0 */ + --color-overlay1: #b6977a; /* Overlay layer 1 */ + --color-overlay2: #a28368; /* Overlay layer 2 */ + + /* Accent Colors (Syntax/Highlighting) */ + --color-red: #c14a4a; /* Error messages, important keywords */ + --color-maroon: #c14a4a; /* Error messages, critical elements */ + --color-peach: #c35e0a; /* Warning messages, operators */ + --color-yellow: #a96b2c; /* Variables, parameters, attributes */ + --color-green: #6c782e; /* Strings, literals, constants */ + --color-teal: #4c7a5d; /* Functions, methods, built-ins */ + --color-sky: #4c7a5d; /* Functions, methods (alternative) */ + --color-sapphire: #4c7a5d; /* Functions, methods (alternative) */ + --color-blue: #45707a; /* Keywords, types, classes */ + --color-lavender: #45707a; /* Comments, documentation strings */ + --color-pink: #945e80; /* Special syntax elements, identifiers */ + --color-mauve: #945e80; /* Special syntax elements (alternative) */ + --color-flamingo: #c14a4a; /* Error messages, critical elements */ + --color-rosewater: #c14a4a; /* Error messages, critical elements */ +} + +@theme { + --color-rosewater: #c14a4a; + --color-flamingo: #c14a4a; + --color-pink: #945e80; + --color-mauve: #945e80; + --color-red: #c14a4a; + --color-maroon: #c14a4a; + --color-peach: #c35e0a; + --color-yellow: #a96b2c; + --color-green: #6c782e; + --color-teal: #4c7a5d; + --color-sky: #4c7a5d; + --color-sapphire: #4c7a5d; + --color-blue: #45707a; + --color-lavender: #45707a; + --color-text: #654735; + --color-subtext1: #7b5d44; + --color-subtext0: #8f6f56; + --color-overlay2: #a28368; + --color-overlay1: #b6977a; + --color-overlay0: #c9aa8c; + --color-surface2: #a79c86; + --color-surface1: #c9c19f; + --color-surface0: #dfd6b1; + --color-base: #fbf1c7; + --color-mantle: #f3eac1; + --color-crust: #e7deb7; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-rosewater: #efc9c2; + --color-flamingo: #ebb2b2; + --color-pink: #f2a7de; + --color-mauve: #b889f4; + --color-red: #ea7183; + --color-maroon: #ea838c; + --color-peach: #f39967; + --color-yellow: #eaca89; + --color-green: #96d382; + --color-teal: #78cec1; + --color-sky: #91d7e3; + --color-sapphire: #68bae0; + --color-blue: #739df2; + --color-lavender: #a0a8f6; + --color-text: #b5c1f1; + --color-subtext1: #a6b0d8; + --color-subtext0: #959ec2; + --color-overlay2: #848cad; + --color-overlay1: #717997; + --color-overlay0: #63677f; + --color-surface2: #505469; + --color-surface1: #3e4255; + --color-surface0: #2c2f40; + --color-base: #1e1e2e; + --color-mantle: #141620; + --color-crust: #0e0f16; + } + @theme { + --color-rosewater: #efc9c2; + --color-flamingo: #ebb2b2; + --color-pink: #f2a7de; + --color-mauve: #b889f4; + --color-red: #ea7183; + --color-maroon: #ea838c; + --color-peach: #f39967; + --color-yellow: #eaca89; + --color-green: #96d382; + --color-teal: #78cec1; + --color-sky: #91d7e3; + --color-sapphire: #68bae0; + --color-blue: #739df2; + --color-lavender: #a0a8f6; + --color-text: #b5c1f1; + --color-subtext1: #a6b0d8; + --color-subtext0: #959ec2; + --color-overlay2: #848cad; + --color-overlay1: #717997; + --color-overlay0: #63677f; + --color-surface2: #505469; + --color-surface1: #3e4255; + --color-surface0: #2c2f40; + --color-base: #1e1e2e; + --color-mantle: #141620; + --color-crust: #0e0f16; + } +} + +:root { + font-family: "Source Code Pro", monospace; +} + +body { + background: var(--color-base); + color: var(--color-crust); +} + +.cursor-typing { + display: inline-block; + width: 2px; + background-color: var(--color-text); + vertical-align: text-bottom; + margin-left: 2px; + position: absolute; +} + +/* Block cursor when done typing */ +.cursor-block { + display: inline-block; + width: 1ch; + background-color: var(--color-text); + vertical-align: text-bottom; + animation: blink 1s infinite; + margin-left: 2px; + position: absolute; +} + +@keyframes blink { + 0%, + 50% { + opacity: 1; + } + 51%, + 100% { + opacity: 0; + } +} diff --git a/src/app.tsx b/src/app.tsx new file mode 100644 index 0000000..5a246fe --- /dev/null +++ b/src/app.tsx @@ -0,0 +1,30 @@ +import { Router } from "@solidjs/router"; +import { FileRoutes } from "@solidjs/start/router"; +import { Suspense } from "solid-js"; +import "./app.css"; +import { LeftBar, RightBar } from "./components/Bars"; +import { TerminalSplash } from "./components/TerminalSplash"; +import { SplashProvider } from "./context/splash"; + +export default function App() { + return ( + +
+ + ( +
+ +
+ {props.children} +
+ +
+ )} + > + +
+
+
+ ); +} diff --git a/src/components/Bars.tsx b/src/components/Bars.tsx new file mode 100644 index 0000000..598618e --- /dev/null +++ b/src/components/Bars.tsx @@ -0,0 +1,43 @@ +import { Typewriter } from "./Typewriter"; + +export function LeftBar() { + return ( + + ); +} + +export function RightBar() { + return ( + + ); +} diff --git a/src/components/TerminalSplash.tsx b/src/components/TerminalSplash.tsx new file mode 100644 index 0000000..994eb62 --- /dev/null +++ b/src/components/TerminalSplash.tsx @@ -0,0 +1,57 @@ +import { Show, onMount, onCleanup, createSignal } from "solid-js"; +import { useSplash } from "../context/splash"; + +const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +export function TerminalSplash() { + const { showSplash, setShowSplash } = useSplash(); + const [showing, setShowing] = createSignal(0); + const [isVisible, setIsVisible] = createSignal(true); + + onMount(() => { + const interval = setInterval(() => { + setShowing((prev) => (prev + 1) % spinnerChars.length); + }, 50); + + // Hide splash after 1.5 seconds + const timeoutId = setTimeout(() => { + setShowSplash(false); + }, 1500); + + onCleanup(() => { + clearInterval(interval); + clearTimeout(timeoutId); + }); + }); + + // Handle fade out when splash is hidden + const shouldRender = () => showSplash() || isVisible(); + + // Trigger fade out, then hide after transition + const opacity = () => { + if (!showSplash() && isVisible()) { + setTimeout(() => setIsVisible(false), 500); + return "0"; + } + if (showSplash()) { + setIsVisible(true); + return "1"; + } + return "0"; + }; + + return ( + +
+
+
+ {spinnerChars[showing()]} +
+
+
+
+ ); +} diff --git a/src/components/Typewriter.tsx b/src/components/Typewriter.tsx new file mode 100644 index 0000000..49b0818 --- /dev/null +++ b/src/components/Typewriter.tsx @@ -0,0 +1,164 @@ +import { JSX, onMount, createSignal, children } from "solid-js"; +import { useSplash } from "~/context/splash"; + +export function Typewriter(props: { + children: JSX.Element; + speed?: number; + class?: string; + keepAlive?: boolean | number; + delay?: number; +}) { + const { keepAlive = true, delay = 0 } = props; + let containerRef: HTMLDivElement | undefined; + let cursorRef: HTMLDivElement | undefined; + const [isTyping, setIsTyping] = createSignal(false); + const [isDelaying, setIsDelaying] = createSignal(delay > 0); + const [keepAliveCountdown, setKeepAliveCountdown] = createSignal( + typeof keepAlive === "number" ? keepAlive : -1, + ); + const resolved = children(() => props.children); + const { showSplash } = useSplash(); + + onMount(() => { + if (!containerRef || !cursorRef) return; + + // FIRST: Walk DOM and hide all text immediately + const textNodes: { node: Text; text: string; startIndex: number }[] = []; + let totalChars = 0; + + const walkDOM = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent || ""; + if (text.trim().length > 0) { + textNodes.push({ + node: node as Text, + text: text, + startIndex: totalChars, + }); + totalChars += text.length; + + // Replace text with spans for each character + const span = document.createElement("span"); + text.split("").forEach((char, i) => { + const charSpan = document.createElement("span"); + charSpan.textContent = char; + charSpan.style.opacity = "0"; + charSpan.setAttribute( + "data-char-index", + String(totalChars - text.length + i), + ); + span.appendChild(charSpan); + }); + node.parentNode?.replaceChild(span, node); + } + } else if (node.nodeType === Node.ELEMENT_NODE) { + Array.from(node.childNodes).forEach(walkDOM); + } + }; + + walkDOM(containerRef); + + // Position cursor at the first character location + const firstChar = containerRef.querySelector( + '[data-char-index="0"]', + ) as HTMLElement; + if (firstChar && cursorRef) { + // Insert cursor before the first character + firstChar.parentNode?.insertBefore(cursorRef, firstChar); + // Set cursor height to match first character + cursorRef.style.height = `${firstChar.offsetHeight}px`; + } + + // THEN: Wait for splash to be hidden before starting the animation + const checkSplashHidden = () => { + if (showSplash()) { + setTimeout(checkSplashHidden, 10); + } else { + // Start delay if specified + if (delay > 0) { + setTimeout(() => { + setIsDelaying(false); + startReveal(); + }, delay); + } else { + startReveal(); + } + } + }; + + const startReveal = () => { + setIsTyping(true); // Switch to typing cursor + + // Animate revealing characters + let currentIndex = 0; + const speed = props.speed || 30; + + const revealNextChar = () => { + if (currentIndex < totalChars) { + const charSpan = containerRef?.querySelector( + `[data-char-index="${currentIndex}"]`, + ) as HTMLElement; + + if (charSpan) { + charSpan.style.opacity = "1"; + + // Move cursor after this character and match its height + if (cursorRef) { + charSpan.parentNode?.insertBefore( + cursorRef, + charSpan.nextSibling, + ); + + // Match the height of the current character + const charHeight = charSpan.offsetHeight; + cursorRef.style.height = `${charHeight}px`; + } + } + + currentIndex++; + setTimeout(revealNextChar, 1000 / speed); + } else { + // Typing finished, switch to block cursor + setIsTyping(false); + + // Start keepAlive countdown if it's a number + if (typeof keepAlive === "number") { + const keepAliveInterval = setInterval(() => { + setKeepAliveCountdown((prev) => { + if (prev <= 1) { + clearInterval(keepAliveInterval); + return 0; + } + return prev - 1; + }); + }, 1000); + } + } + }; + + setTimeout(revealNextChar, 100); + }; + + checkSplashHidden(); + }); + + const getCursorClass = () => { + if (isDelaying()) return "cursor-block"; // Blinking block during delay + if (isTyping()) return "cursor-typing"; // Thin line while typing + + // After typing is done + if (typeof keepAlive === "number") { + return keepAliveCountdown() > 0 ? "cursor-block" : "hidden"; + } + return keepAlive ? "cursor-block" : "hidden"; + }; + + return ( +
+ {resolved()} + + {" "} + +
+ ); +} diff --git a/src/context/splash.tsx b/src/context/splash.tsx new file mode 100644 index 0000000..48cd438 --- /dev/null +++ b/src/context/splash.tsx @@ -0,0 +1,29 @@ +import { Accessor, createContext, useContext } from "solid-js"; +import { createSignal } from "solid-js"; + +// Create context with initial value +const SplashContext = createContext<{ + showSplash: Accessor; + setShowSplash: (show: boolean) => void; +}>({ + showSplash: () => true, + setShowSplash: () => {}, +}); + +export function useSplash() { + const context = useContext(SplashContext); + if (!context) { + throw new Error("useSplash must be used within a SplashProvider"); + } + return context; +} + +export function SplashProvider(props: { children: any }) { + const [showSplash, setShowSplash] = createSignal(true); + + return ( + + {props.children} + + ); +} diff --git a/src/entry-client.tsx b/src/entry-client.tsx new file mode 100644 index 0000000..0ca4e3c --- /dev/null +++ b/src/entry-client.tsx @@ -0,0 +1,4 @@ +// @refresh reload +import { mount, StartClient } from "@solidjs/start/client"; + +mount(() => , document.getElementById("app")!); diff --git a/src/entry-server.tsx b/src/entry-server.tsx new file mode 100644 index 0000000..fbf7abe --- /dev/null +++ b/src/entry-server.tsx @@ -0,0 +1,29 @@ +// @refresh reload +import { createHandler, StartServer } from "@solidjs/start/server"; +import { validateServerEnv } from "./env/server"; + +try { + const validatedEnv = validateServerEnv(import.meta.env); + console.log("Environment validation successful"); +} catch (error) { + console.error("Environment validation failed:", error); +} + +export default createHandler(() => ( + ( + + + + + + {assets} + + +
{children}
+ {scripts} + + + )} + /> +)); diff --git a/src/env/server.ts b/src/env/server.ts new file mode 100644 index 0000000..2cc81bc --- /dev/null +++ b/src/env/server.ts @@ -0,0 +1,226 @@ +import { z } from "zod"; + +const serverEnvSchema = z.object({ + // Server-side environment variables + NODE_ENV: z.enum(["development", "test", "production"]), + ADMIN_EMAIL: z.string().min(1), + ADMIN_ID: z.string().min(1), + JWT_SECRET_KEY: z.string().min(1), + DANGEROUS_DBCOMMAND_PASSWORD: z.string().min(1), + AWS_REGION: z.string().min(1), + AWS_S3_BUCKET_NAME: z.string().min(1), + _AWS_ACCESS_KEY: z.string().min(1), + _AWS_SECRET_KEY: z.string().min(1), + GOOGLE_CLIENT_SECRET: z.string().min(1), + GITHUB_CLIENT_SECRET: z.string().min(1), + EMAIL_SERVER: z.string().min(1), + EMAIL_FROM: z.string().min(1), + SENDINBLUE_KEY: z.string().min(1), + TURSO_DB_URL: z.string().min(1), + TURSO_DB_TOKEN: z.string().min(1), + TURSO_LINEAGE_URL: z.string().min(1), + TURSO_LINEAGE_TOKEN: z.string().min(1), + TURSO_DB_API_TOKEN: z.string().min(1), + LINEAGE_OFFLINE_SERIALIZATION_SECRET: z.string().min(1), +}); + +const clientEnvSchema = z.object({ + // Client-side environment variables (using VITE_ prefix for SolidStart) + VITE_DOMAIN: z.string().min(1), + VITE_AWS_BUCKET_STRING: z.string().min(1), + VITE_GOOGLE_CLIENT_ID: z.string().min(1), + VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE: z.string().min(1), + VITE_GITHUB_CLIENT_ID: z.string().min(1), + VITE_WEBSOCKET: z.string().min(1), +}); + +// Combined environment schema +export const envSchema = z.object({ + server: serverEnvSchema, + client: clientEnvSchema, +}); + +// Type inference +export type ServerEnv = z.infer; +export type ClientEnv = z.infer; + +// Custom error class for better error handling +class EnvironmentError extends Error { + constructor( + message: string, + public errors?: z.ZodFormattedError, + ) { + super(message); + this.name = "EnvironmentError"; + } +} + +// Validation function for server-side with detailed error messages +export const validateServerEnv = ( + envVars: Record, +): ServerEnv => { + try { + return serverEnvSchema.parse(envVars); + } catch (error) { + if (error instanceof z.ZodError) { + const formattedErrors = error.format(); + const missingVars = Object.entries(formattedErrors) + .filter( + ([_, value]) => + value._errors.length > 0 && value._errors[0] === "Required", + ) + .map(([key, _]) => key); + + const invalidVars = Object.entries(formattedErrors) + .filter( + ([_, value]) => + value._errors.length > 0 && value._errors[0] !== "Required", + ) + .map(([key, value]) => ({ + key, + error: value._errors[0], + })); + + let errorMessage = "Environment validation failed:\n"; + + if (missingVars.length > 0) { + errorMessage += `Missing required variables: ${missingVars.join( + ", ", + )}\n`; + } + + if (invalidVars.length > 0) { + errorMessage += "Invalid values:\n"; + invalidVars.forEach(({ key, error }) => { + errorMessage += ` ${key}: ${error}\n`; + }); + } + + throw new EnvironmentError(errorMessage, formattedErrors); + } + throw new EnvironmentError( + "Environment validation failed with unknown error", + undefined, + ); + } +}; + +// Validation function for client-side (runtime) with detailed error messages +export const validateClientEnv = ( + envVars: Record, +): ClientEnv => { + try { + return clientEnvSchema.parse(envVars); + } catch (error) { + if (error instanceof z.ZodError) { + const formattedErrors = error.format(); + const missingVars = Object.entries(formattedErrors) + .filter( + ([_, value]) => + value._errors.length > 0 && value._errors[0] === "Required", + ) + .map(([key, _]) => key); + + const invalidVars = Object.entries(formattedErrors) + .filter( + ([_, value]) => + value._errors.length > 0 && value._errors[0] !== "Required", + ) + .map(([key, value]) => ({ + key, + error: value._errors[0], + })); + + let errorMessage = "Client environment validation failed:\n"; + + if (missingVars.length > 0) { + errorMessage += `Missing required variables: ${missingVars.join( + ", ", + )}\n`; + } + + if (invalidVars.length > 0) { + errorMessage += "Invalid values:\n"; + invalidVars.forEach(({ key, error }) => { + errorMessage += ` ${key}: ${error}\n`; + }); + } + + throw new EnvironmentError(errorMessage, formattedErrors); + } + throw new EnvironmentError( + "Client environment validation failed with unknown error", + undefined, + ); + } +}; + +// Environment validation for server startup with better error reporting +export const env = (() => { + try { + // Validate server environment variables + const validatedServerEnv = validateServerEnv(import.meta.env); + + console.log("✅ Environment validation successful"); + return validatedServerEnv; + } catch (error) { + if (error instanceof EnvironmentError) { + console.error("❌ Environment validation failed:", error.message); + if (error.errors) { + console.error( + "Detailed errors:", + JSON.stringify(error.errors, null, 2), + ); + } + throw new Error(`Environment validation failed: ${error.message}`); + } + console.error("❌ Unexpected environment validation error:", error); + throw new Error("Unexpected environment validation error occurred"); + } +})(); + +// For client-side validation (useful in components) +export const getClientEnvValidation = () => { + try { + return validateClientEnv(import.meta.env); + } catch (error) { + if (error instanceof EnvironmentError) { + console.error("❌ Client environment validation failed:", error.message); + throw new Error(`Client environment validation failed: ${error.message}`); + } + throw new Error("Client environment validation failed with unknown error"); + } +}; + +// Helper function to check if a variable is missing +export const isMissingEnvVar = (varName: string): boolean => { + return !import.meta.env[varName] || import.meta.env[varName]?.trim() === ""; +}; + +// Helper function to get all missing environment variables +export const getMissingEnvVars = (): string[] => { + const requiredVars = [ + "NODE_ENV", + "ADMIN_EMAIL", + "ADMIN_ID", + "JWT_SECRET_KEY", + "DANGEROUS_DBCOMMAND_PASSWORD", + "AWS_REGION", + "AWS_S3_BUCKET_NAME", + "_AWS_ACCESS_KEY", + "_AWS_SECRET_KEY", + "GOOGLE_CLIENT_SECRET", + "GITHUB_CLIENT_SECRET", + "EMAIL_SERVER", + "EMAIL_FROM", + "SENDINBLUE_KEY", + "TURSO_DB_URL", + "TURSO_DB_TOKEN", + "TURSO_LINEAGE_URL", + "TURSO_LINEAGE_TOKEN", + "TURSO_DB_API_TOKEN", + "LINEAGE_OFFLINE_SERIALIZATION_SECRET", + ]; + + return requiredVars.filter((varName) => isMissingEnvVar(varName)); +}; diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..dc6f10c --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..47860d6 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,23 @@ +import { + createTRPCProxyClient, + httpBatchLink, + loggerLink, +} from '@trpc/client'; +import { AppRouter } from "~/server/api/root"; + +const getBaseUrl = () => { + if (typeof window !== "undefined") return ""; + // replace example.com with your actual production url + if (process.env.NODE_ENV === "production") return "https://example.com"; + return `http://localhost:${process.env.PORT ?? 3000}`; +}; + +// create the client, export it +export const api = createTRPCProxyClient({ + links: [ + // will print out helpful logs when using client + loggerLink(), + // identifies what url will handle trpc requests + httpBatchLink({ url: `${getBaseUrl()}/api/trpc` }) + ], +}); diff --git a/src/routes/[...404].tsx b/src/routes/[...404].tsx new file mode 100644 index 0000000..4ea71ec --- /dev/null +++ b/src/routes/[...404].tsx @@ -0,0 +1,19 @@ +import { Title } from "@solidjs/meta"; +import { HttpStatusCode } from "@solidjs/start"; + +export default function NotFound() { + return ( +
+ Not Found + +

Page Not Found

+

+ Visit{" "} + + start.solidjs.com + {" "} + to learn how to build SolidStart apps. +

+
+ ); +} diff --git a/src/routes/about.tsx b/src/routes/about.tsx new file mode 100644 index 0000000..c1c2dcf --- /dev/null +++ b/src/routes/about.tsx @@ -0,0 +1,10 @@ +import { Title } from "@solidjs/meta"; + +export default function About() { + return ( +
+ About +

About

+
+ ); +} diff --git a/src/routes/api/trpc/[trpc].ts b/src/routes/api/trpc/[trpc].ts new file mode 100644 index 0000000..451d21a --- /dev/null +++ b/src/routes/api/trpc/[trpc].ts @@ -0,0 +1,19 @@ +import type { APIEvent } from "@solidjs/start/server"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { appRouter } from "~/server/api/root"; + +const handler = (event: APIEvent) => + // adapts tRPC to fetch API style requests + fetchRequestHandler({ + // the endpoint handling the requests + endpoint: "/api/trpc", + // the request object + req: event.request, + // the router for handling the requests + router: appRouter, + // any arbitrary data that should be available to all actions + createContext: () => event + }); + +export const GET = handler; +export const POST = handler; diff --git a/src/routes/index.tsx b/src/routes/index.tsx new file mode 100644 index 0000000..b548e11 --- /dev/null +++ b/src/routes/index.tsx @@ -0,0 +1,13 @@ +import { Typewriter } from "~/components/Typewriter"; + +export default function Home() { + return ( + +
+ {/* fill in a ipsum lorem */} + ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem + ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem +
+
+ ); +} diff --git a/src/server/api/root.ts b/src/server/api/root.ts new file mode 100644 index 0000000..e9b5a15 --- /dev/null +++ b/src/server/api/root.ts @@ -0,0 +1,16 @@ +import { exampleRouter } from "./routers/example"; +import { authRouter } from "./routers/auth"; +import { databaseRouter } from "./routers/database"; +import { lineageRouter } from "./routers/lineage"; +import { miscRouter } from "./routers/misc"; +import { createTRPCRouter } from "./utils"; + +export const appRouter = createTRPCRouter({ + example: exampleRouter, + auth: authRouter, + database: databaseRouter, + lineage: lineageRouter, + misc: miscRouter +}); + +export type AppRouter = typeof appRouter; diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts new file mode 100644 index 0000000..3171a51 --- /dev/null +++ b/src/server/api/routers/auth.ts @@ -0,0 +1,34 @@ +import { createTRPCRouter, publicProcedure } from "../utils"; +import { z } from "zod"; + +export const authRouter = createTRPCRouter({ + // GitHub callback route + githubCallback: publicProcedure + .query(async () => { + // Implementation for GitHub OAuth callback + return { message: "GitHub callback endpoint" }; + }), + + // Google callback route + googleCallback: publicProcedure + .query(async () => { + // Implementation for Google OAuth callback + return { message: "Google callback endpoint" }; + }), + + // Email login route + emailLogin: publicProcedure + .input(z.object({ email: z.string().email() })) + .mutation(async ({ input }) => { + // Implementation for email login + return { message: `Email login initiated for ${input.email}` }; + }), + + // Email verification route + emailVerification: publicProcedure + .input(z.object({ email: z.string().email() })) + .query(async ({ input }) => { + // Implementation for email verification + return { message: `Email verification requested for ${input.email}` }; + }), +}); \ No newline at end of file diff --git a/src/server/api/routers/database.ts b/src/server/api/routers/database.ts new file mode 100644 index 0000000..b7a843e --- /dev/null +++ b/src/server/api/routers/database.ts @@ -0,0 +1,101 @@ +import { createTRPCRouter, publicProcedure } from "../utils"; +import { z } from "zod"; + +export const databaseRouter = createTRPCRouter({ + // Comment reactions routes + getCommentReactions: publicProcedure + .input(z.object({ commentId: z.string() })) + .query(({ input }) => { + // Implementation for getting comment reactions + return { commentId: input.commentId, reactions: [] }; + }), + + postCommentReaction: publicProcedure + .input(z.object({ + commentId: z.string(), + reactionType: z.string() + })) + .mutation(({ input }) => { + // Implementation for posting comment reaction + return { success: true, commentId: input.commentId }; + }), + + deleteCommentReaction: publicProcedure + .input(z.object({ + commentId: z.string(), + reactionType: z.string() + })) + .mutation(({ input }) => { + // Implementation for deleting comment reaction + return { success: true, commentId: input.commentId }; + }), + + // Comments routes + getComments: publicProcedure + .input(z.object({ postId: z.string() })) + .query(({ input }) => { + // Implementation for getting comments + return { postId: input.postId, comments: [] }; + }), + + // Post manipulation routes + getPosts: publicProcedure + .input(z.object({ + limit: z.number().optional(), + offset: z.number().optional() + })) + .query(({ input }) => { + // Implementation for getting posts + return { posts: [], total: 0 }; + }), + + createPost: publicProcedure + .input(z.object({ + title: z.string(), + content: z.string() + })) + .mutation(({ input }) => { + // Implementation for creating post + return { success: true, post: { id: "1", ...input } }; + }), + + updatePost: publicProcedure + .input(z.object({ + id: z.string(), + title: z.string().optional(), + content: z.string().optional() + })) + .mutation(({ input }) => { + // Implementation for updating post + return { success: true, postId: input.id }; + }), + + deletePost: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(({ input }) => { + // Implementation for deleting post + return { success: true, postId: input.id }; + }), + + // Post likes routes + getPostLikes: publicProcedure + .input(z.object({ postId: z.string() })) + .query(({ input }) => { + // Implementation for getting post likes + return { postId: input.postId, likes: [] }; + }), + + likePost: publicProcedure + .input(z.object({ postId: z.string() })) + .mutation(({ input }) => { + // Implementation for liking post + return { success: true, postId: input.postId }; + }), + + unlikePost: publicProcedure + .input(z.object({ postId: z.string() })) + .mutation(({ input }) => { + // Implementation for unliking post + return { success: true, postId: input.postId }; + }), +}); \ No newline at end of file diff --git a/src/server/api/routers/example.ts b/src/server/api/routers/example.ts new file mode 100644 index 0000000..4e171b9 --- /dev/null +++ b/src/server/api/routers/example.ts @@ -0,0 +1,11 @@ +import { wrap } from "@typeschema/valibot"; +import { string } from "valibot"; +import { createTRPCRouter, publicProcedure } from "../utils"; + +export const exampleRouter = createTRPCRouter({ + hello: publicProcedure + .input(wrap(string())) + .query(({ input }) => { + return `Hello ${input}!`; + }) +}); diff --git a/src/server/api/routers/lineage.ts b/src/server/api/routers/lineage.ts new file mode 100644 index 0000000..0723489 --- /dev/null +++ b/src/server/api/routers/lineage.ts @@ -0,0 +1,124 @@ +import { createTRPCRouter, publicProcedure } from "../utils"; +import { z } from "zod"; + +export const lineageRouter = createTRPCRouter({ + // Database management routes (GET) + databaseManagement: publicProcedure + .query(async () => { + // Implementation for database management + return { message: "Database management endpoint" }; + }), + + // Analytics route (GET) + analytics: publicProcedure + .query(async () => { + // Implementation for analytics + return { message: "Analytics endpoint" }; + }), + + // Apple authentication routes (GET) + appleAuth: publicProcedure + .query(async () => { + // Implementation for Apple authentication + return { message: "Apple authentication endpoint" }; + }), + + // Email login/registration/verification routes (GET/POST) + emailLogin: publicProcedure + .input(z.object({ email: z.string().email(), password: z.string() })) + .mutation(async ({ input }) => { + // Implementation for email login + return { message: `Email login for ${input.email}` }; + }), + + emailRegister: publicProcedure + .input(z.object({ email: z.string().email(), password: z.string() })) + .mutation(async ({ input }) => { + // Implementation for email registration + return { message: `Email registration for ${input.email}` }; + }), + + emailVerify: publicProcedure + .input(z.object({ token: z.string() })) + .mutation(async ({ input }) => { + // Implementation for email verification + return { message: "Email verification endpoint" }; + }), + + // Google registration route (POST) + googleRegister: publicProcedure + .input(z.object({ + googleId: z.string(), + email: z.string().email(), + name: z.string() + })) + .mutation(async ({ input }) => { + // Implementation for Google registration + return { message: `Google registration for ${input.email}` }; + }), + + // JSON service routes (GET - attacks, conditions, dungeons, enemies, items, misc) + attacks: publicProcedure + .query(async () => { + // Implementation for attacks data + return { message: "Attacks data" }; + }), + + conditions: publicProcedure + .query(async () => { + // Implementation for conditions data + return { message: "Conditions data" }; + }), + + dungeons: publicProcedure + .query(async () => { + // Implementation for dungeons data + return { message: "Dungeons data" }; + }), + + enemies: publicProcedure + .query(async () => { + // Implementation for enemies data + return { message: "Enemies data" }; + }), + + items: publicProcedure + .query(async () => { + // Implementation for items data + return { message: "Items data" }; + }), + + misc: publicProcedure + .query(async () => { + // Implementation for miscellaneous data + return { message: "Miscellaneous data" }; + }), + + // Offline secret route (GET) + offlineSecret: publicProcedure + .query(async () => { + // Implementation for offline secret + return { message: "Offline secret endpoint" }; + }), + + // PvP routes (GET/POST) + pvpGet: publicProcedure + .query(async () => { + // Implementation for PvP GET + return { message: "PvP GET endpoint" }; + }), + + pvpPost: publicProcedure + .input(z.object({ player1: z.string(), player2: z.string() })) + .mutation(async ({ input }) => { + // Implementation for PvP POST + return { message: `PvP battle between ${input.player1} and ${input.player2}` }; + }), + + // Tokens route (GET) + tokens: publicProcedure + .query(async () => { + // Implementation for tokens + return { message: "Tokens endpoint" }; + }), +}); \ No newline at end of file diff --git a/src/server/api/routers/misc.ts b/src/server/api/routers/misc.ts new file mode 100644 index 0000000..63e4911 --- /dev/null +++ b/src/server/api/routers/misc.ts @@ -0,0 +1,34 @@ +import { createTRPCRouter, publicProcedure } from "../utils"; +import { z } from "zod"; + +export const miscRouter = createTRPCRouter({ + // Downloads endpoint (GET) + downloads: publicProcedure + .query(async () => { + // Implementation for downloads logic would go here + return { message: "Downloads endpoint" }; + }), + + // S3 operations (DELETE/GET) + s3Delete: publicProcedure + .input(z.object({ key: z.string() })) + .mutation(async ({ input }) => { + // Implementation for S3 delete logic would go here + return { message: `Deleted S3 object with key: ${input.key}` }; + }), + + s3Get: publicProcedure + .input(z.object({ key: z.string() })) + .query(async ({ input }) => { + // Implementation for S3 get logic would go here + return { message: `Retrieved S3 object with key: ${input.key}` }; + }), + + // Password hashing endpoint (POST) + hashPassword: publicProcedure + .input(z.object({ password: z.string() })) + .mutation(async ({ input }) => { + // Implementation for password hashing logic would go here + return { message: "Password hashed successfully" }; + }), +}); \ No newline at end of file diff --git a/src/server/api/utils.ts b/src/server/api/utils.ts new file mode 100644 index 0000000..c082886 --- /dev/null +++ b/src/server/api/utils.ts @@ -0,0 +1,6 @@ +import { initTRPC } from "@trpc/server"; + +export const t = initTRPC.create(); + +export const createTRPCRouter = t.router; +export const publicProcedure = t.procedure; diff --git a/src/server/utils.ts b/src/server/utils.ts new file mode 100644 index 0000000..0cdd764 --- /dev/null +++ b/src/server/utils.ts @@ -0,0 +1,249 @@ +import jwt, { JwtPayload } from "jsonwebtoken"; +import { cookies } from "next/headers"; + +export const LINEAGE_JWT_EXPIRY = "14d"; + +export async function getPrivilegeLevel(): Promise< + "anonymous" | "admin" | "user" +> { + try { + const userIDToken = (await cookies()).get("userIDToken"); + + if (userIDToken) { + const decoded = await new Promise((resolve) => { + jwt.verify( + userIDToken.value, + env.JWT_SECRET_KEY, + async (err, decoded) => { + if (err) { + console.log("Failed to authenticate token."); + (await cookies()).set({ + name: "userIDToken", + value: "", + maxAge: 0, + expires: new Date("2016-10-05"), + }); + resolve(undefined); + } else { + resolve(decoded as JwtPayload); + } + }, + ); + }); + + if (decoded) { + return decoded.id === env.ADMIN_ID ? "admin" : "user"; + } + } + } catch (e) { + return "anonymous"; + } + return "anonymous"; +} +export async function getUserID(): Promise { + try { + const userIDToken = (await cookies()).get("userIDToken"); + + if (userIDToken) { + const decoded = await new Promise((resolve) => { + jwt.verify( + userIDToken.value, + env.JWT_SECRET_KEY, + async (err, decoded) => { + if (err) { + console.log("Failed to authenticate token."); + (await cookies()).set({ + name: "userIDToken", + value: "", + maxAge: 0, + expires: new Date("2016-10-05"), + }); + resolve(undefined); + } else { + resolve(decoded as JwtPayload); + } + }, + ); + }); + + if (decoded) { + return decoded.id; + } + } + } catch (e) { + return null; + } + return null; +} + +import { createClient, Row } from "@libsql/client/web"; +import { env } from "@/env.mjs"; + +// Turso +export function ConnectionFactory() { + const config = { + url: env.TURSO_DB_URL, + authToken: env.TURSO_DB_TOKEN, + }; + + const conn = createClient(config); + return conn; +} + +export function LineageConnectionFactory() { + const config = { + url: env.TURSO_LINEAGE_URL, + authToken: env.TURSO_LINEAGE_TOKEN, + }; + + const conn = createClient(config); + return conn; +} + +import { v4 as uuid } from "uuid"; +import { createClient as createAPIClient } from "@tursodatabase/api"; +import { checkPassword } from "./api/passwordHashing"; +import { OAuth2Client } from "google-auth-library"; + +export async function LineageDBInit() { + const turso = createAPIClient({ + org: "mikefreno", + token: env.TURSO_DB_API_TOKEN, + }); + + const db_name = uuid(); + const db = await turso.databases.create(db_name, { group: "default" }); + + const token = await turso.databases.createToken(db_name, { + authorization: "full-access", + }); + + const conn = PerUserDBConnectionFactory(db.name, token.jwt); + await conn.execute(` + CREATE TABLE checkpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + last_updated TEXT NOT NULL, + player_age INTEGER NOT NULL, + player_data TEXT, + time_data TEXT, + dungeon_data TEXT, + character_data TEXT, + shops_data TEXT + ) +`); + + return { token: token.jwt, dbName: db.name }; +} + +export function PerUserDBConnectionFactory(dbName: string, token: string) { + const config = { + url: `libsql://${dbName}-mikefreno.turso.io`, + authToken: token, + }; + const conn = createClient(config); + return conn; +} + +export async function dumpAndSendDB({ + dbName, + dbToken, + sendTarget, +}: { + dbName: string; + dbToken: string; + sendTarget: string; +}): Promise<{ + success: boolean; + reason?: string; +}> { + const res = await fetch(`https://${dbName}-mikefreno.turso.io/dump`, { + method: "GET", + headers: { + Authorization: `Bearer ${dbToken}`, + }, + }); + if (!res.ok) { + console.error(res); + return { success: false, reason: "bad dump request response" }; + } + const text = await res.text(); + const base64Content = Buffer.from(text, "utf-8").toString("base64"); + + const apiKey = env.SENDINBLUE_KEY as string; + const apiUrl = "https://api.brevo.com/v3/smtp/email"; + + const emailPayload = { + sender: { + name: "no_reply@freno.me", + email: "no_reply@freno.me", + }, + to: [ + { + email: sendTarget, + }, + ], + subject: "Your Lineage Database Dump", + htmlContent: + "

Please find the attached database dump. This contains the state of your person remote Lineage remote saves. Should you ever return to Lineage, you can upload this file to reinstate the saves you had.

", + attachment: [ + { + content: base64Content, + name: "database_dump.txt", + }, + ], + }; + const sendRes = await fetch(apiUrl, { + method: "POST", + headers: { + accept: "application/json", + "api-key": apiKey, + "content-type": "application/json", + }, + body: JSON.stringify(emailPayload), + }); + + if (!sendRes.ok) { + return { success: false, reason: "email send failure" }; + } else { + return { success: true }; + } +} + +export async function validateLineageRequest({ + auth_token, + userRow, +}: { + auth_token: string; + userRow: Row; +}): Promise { + const { provider, email } = userRow; + if (provider === "email") { + const decoded = jwt.verify( + auth_token, + env.JWT_SECRET_KEY, + ) as jwt.JwtPayload; + if (email !== decoded.email) { + return false; + } + } else if (provider == "apple") { + const { apple_user_string } = userRow; + if (apple_user_string !== auth_token) { + return false; + } + } else if (provider == "google") { + const CLIENT_ID = env.NEXT_PUBLIC_GOOGLE_CLIENT_ID_MAGIC_DELVE; + const client = new OAuth2Client(CLIENT_ID); + const ticket = await client.verifyIdToken({ + idToken: auth_token, + audience: CLIENT_ID, + }); + if (ticket.getPayload()?.email !== email) { + return false; + } + } else { + return false; + } + return true; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..c37bdf4 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,9 @@ +export function ConnectionFactory() { + const config = { + url: env.TURSO_DB_URL, + authToken: env.TURSO_DB_TOKEN, + }; + + const conn = createClient(config); + return conn; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7d5871a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "allowJs": true, + "strict": true, + "noEmit": true, + "types": ["vinxi/types/client"], + "isolatedModules": true, + "paths": { + "~/*": ["./src/*"] + } + } +}