This commit is contained in:
Michael Freno
2025-12-16 22:42:05 -05:00
commit 8fb748f401
81 changed files with 4378 additions and 0 deletions

28
.gitignore vendored Normal file
View File

@@ -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

32
README.md Normal file
View File

@@ -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)

11
app.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "@solidjs/start/config";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
vite: {
plugins: [tailwindcss()],
},
server: {
preset: "vercel",
},
});

BIN
bun.lockb Executable file

Binary file not shown.

258
docs/trpc-implementation.md Normal file
View File

@@ -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<string | null>(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 (
<div>
<p>{result}</p>
<button onClick={handleClick}>Call API</button>
</div>
);
}
```
## 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

24
package.json Normal file
View File

@@ -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"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 B

View File

@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidV4 } from "uuid";
import { env } from "@/env.mjs";
import { cookies } from "next/headers";
import { User } from "@/types/model-types";
import jwt from "jsonwebtoken";
import { ConnectionFactory } from "@/app/utils";
export async function GET(request: NextRequest) {
const params = request.nextUrl.searchParams;
const code = params.get("code");
if (code) {
const tokenResponse = await fetch(
"https://github.com/login/oauth/access_token",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
client_id: env.NEXT_PUBLIC_GITHUB_CLIENT_ID,
client_secret: env.GITHUB_CLIENT_SECRET,
code,
}),
},
);
const { access_token } = await tokenResponse.json();
const userResponse = await fetch("https://api.github.com/user", {
headers: {
Authorization: `token ${access_token}`,
},
});
const user = await userResponse.json();
const login = user.login;
const conn = ConnectionFactory();
const query = `SELECT * FROM User WHERE provider = ? AND display_name = ?`;
const params = ["github", login];
const res = await conn.execute({ sql: query, args: params });
if (res.rows[0]) {
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
},
);
(await cookies()).set({
name: "userIDToken",
value: token,
maxAge: 60 * 60 * 24 * 14,
});
} else {
const icon = user.avatar_url;
const email = user.email;
const userId = uuidV4();
const insertQuery = `INSERT INTO User (id, email, display_name, provider, image) VALUES (?, ?, ?, ?, ?)`;
const insertParams = [userId, email, login, "github", icon];
await conn.execute({ sql: insertQuery, args: insertParams });
const token = jwt.sign({ id: userId }, env.JWT_SECRET_KEY, {
expiresIn: 60 * 60 * 24 * 14, // expires in 14 days
});
(await cookies()).set({
name: "userIDToken",
value: token,
maxAge: 60 * 60 * 24 * 14,
});
}
return NextResponse.redirect(`${env.NEXT_PUBLIC_DOMAIN}/account`);
} else {
return NextResponse.json(
JSON.stringify({
success: false,
message: `authentication failed: no code on callback`,
}),
{ status: 401, headers: { "content-type": "application/json" } },
);
}
}

View File

@@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidV4 } from "uuid";
import { env } from "@/env.mjs";
import { cookies } from "next/headers";
import { User } from "@/types/model-types";
import jwt from "jsonwebtoken";
import { ConnectionFactory } from "@/app/utils";
export async function GET(request: NextRequest) {
const params = request.nextUrl.searchParams;
const code = params.get("code");
if (code) {
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
code: code,
client_id: env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
client_secret: env.GOOGLE_CLIENT_SECRET,
redirect_uri: "https://www.freno.me/api/auth/callback/google",
grant_type: "authorization_code",
}),
});
const { access_token } = await tokenResponse.json();
const userResponse = await fetch(
"https://www.googleapis.com/oauth2/v3/userinfo",
{
headers: {
Authorization: `Bearer ${access_token}`,
},
},
);
const userData = await userResponse.json();
console.log(userData);
const name = userData.name;
const image = userData.picture;
const email = userData.email;
const email_verified = userData.email_verified;
const conn = ConnectionFactory();
const query = `SELECT * FROM User WHERE provider = ? AND email = ?`;
const params = ["google", email];
const res = await conn.execute({ sql: query, args: params });
if (res.rows[0]) {
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
},
);
(await cookies()).set({
name: "userIDToken",
value: token,
maxAge: 60 * 60 * 24 * 14,
});
} else {
const userId = uuidV4();
const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
const insertParams = [
userId,
email,
email_verified,
name,
"google",
image,
];
await conn.execute({
sql: insertQuery,
args: insertParams,
});
const token = jwt.sign({ id: userId }, env.JWT_SECRET_KEY, {
expiresIn: 60 * 60 * 24 * 14, // expires in 14 days
});
(await cookies()).set({
name: "userIDToken",
value: token,
maxAge: 60 * 60 * 24 * 14,
});
}
return NextResponse.redirect(`${env.NEXT_PUBLIC_DOMAIN}/account`);
} else {
return NextResponse.json(
JSON.stringify({
success: false,
message: `authentication failed: no code on callback`,
}),
{ status: 401, headers: { "content-type": "application/json" } },
);
}
}

View File

@@ -0,0 +1,64 @@
import { JwtPayload } from "jsonwebtoken";
import { NextRequest, NextResponse } from "next/server";
import jwt from "jsonwebtoken";
import { env } from "@/env.mjs";
import { cookies } from "next/headers";
import { User } from "@/types/model-types";
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 = `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" } },
);
}
}

View File

@@ -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" } },
);
}
}

View File

@@ -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 },
);
}

View File

@@ -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 || [] });
}

View File

@@ -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 || [] });
}

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -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 });
}
}
}

View File

@@ -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 });
}
}
}

View File

@@ -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 };
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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<string, string> = {
"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 });
}
}

View File

@@ -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" } },
);
}

View File

@@ -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" } },
);
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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" } },
);
}
}

View File

@@ -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,
},
);
}
}

View File

@@ -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.",
});
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}

View File

@@ -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`,
});
}
}
}

View File

@@ -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" } },
);
}
}

View File

@@ -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 });
}
}

View File

@@ -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: `<html>
<head>
<style>
.center {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.button {
display: inline-block;
padding: 10px 20px;
text-align: center;
text-decoration: none;
color: #ffffff;
background-color: #007BFF;
border-radius: 6px;
transition: background-color 0.3s;
}
.button:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="center">
<p>Click the button below to verify email</p>
</div>
<br/>
<div class="center">
<a href=${env.NEXT_PUBLIC_DOMAIN}/api/lineage/email/verification/${userEmail}/?token=${token} class="button">Verify Email</a>
</div>
</body>
</html>
`,
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),
});
}

View File

@@ -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: `<html>
<head>
<style>
.center {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.button {
display: inline-block;
padding: 10px 20px;
text-align: center;
text-decoration: none;
color: #ffffff;
background-color: #007BFF;
border-radius: 6px;
transition: background-color 0.3s;
}
.button:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="center">
<p>Click the button below to verify email</p>
</div>
<br/>
<div class="center">
<a href=${env.NEXT_PUBLIC_DOMAIN}/api/lineage/email/verification/${userEmail}/?token=${token} class="button">Verify Email</a>
</div>
</body>
</html>
`,
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),
});
}

View File

@@ -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" } },
);
}
}

View File

@@ -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" } },
);
}
}

View File

@@ -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,
});
}

View File

@@ -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,
});
}

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -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,
});
}

View File

@@ -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,
});
}

View File

@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return new NextResponse(process.env.LINEAGE_OFFLINE_SERIALIZATION_SECRET);
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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));
}
}

View File

@@ -0,0 +1,20 @@
import * as bcrypt from "bcrypt";
// Asynchronous function to hash a password
export async function hashPassword(password: string): Promise<string> {
// 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<boolean> {
const match = await bcrypt.compare(password, hash);
return match;
}

View File

@@ -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);
}

View File

@@ -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 });
}
}

View File

@@ -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);
}

168
src/app.css Normal file
View File

@@ -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;
}
}

30
src/app.tsx Normal file
View File

@@ -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 (
<SplashProvider>
<div>
<TerminalSplash />
<Router
root={(props) => (
<div class="flex flex-row max-w-screen">
<LeftBar />
<div class="flex-1">
<Suspense>{props.children}</Suspense>
</div>
<RightBar />
</div>
)}
>
<FileRoutes />
</Router>
</div>
</SplashProvider>
);
}

43
src/components/Bars.tsx Normal file
View File

@@ -0,0 +1,43 @@
import { Typewriter } from "./Typewriter";
export function LeftBar() {
return (
<nav class="w-fit max-w-[25%] min-h-screen h-full border-r-2 border-r-maroon flex flex-col text-text text-xl font-bold py-10 px-4 gap-4 text-left">
<Typewriter keepAlive={false}>
<h3 class="text-2xl">Left Navigation</h3>
<ul>
<li class="hover:-translate-y-0.5 transition-transform duration-200 ease-in-out hover:text-green hover:font-bold hover:scale-110">
<a href="#home">Home</a>
</li>
<li class="hover:-translate-y-0.5 transition-transform duration-200 ease-in-out hover:text-green hover:font-bold hover:scale-110">
<a href="#about">About</a>
</li>
<li class="hover:-translate-y-0.5 transition-transform duration-200 ease-in-out hover:text-green hover:font-bold hover:scale-110">
<a href="#services">Services</a>
</li>
</ul>
</Typewriter>
</nav>
);
}
export function RightBar() {
return (
<nav class="w-fit max-w-[25%] min-h-screen h-full border-l-2 border-l-maroon flex flex-col text-text text-xl font-bold py-10 px-4 gap-4 text-right">
<Typewriter keepAlive={false}>
<h3 class="text-2xl">Right Navigation</h3>
<ul>
<li class="hover:-translate-y-0.5 transition-transform duration-200 ease-in-out hover:text-green hover:font-bold hover:scale-110">
<a href="#home">Home</a>
</li>
<li class="hover:-translate-y-0.5 transition-transform duration-200 ease-in-out hover:text-green hover:font-bold hover:scale-110">
<a href="#about">About</a>
</li>
<li class="hover:-translate-y-0.5 transition-transform duration-200 ease-in-out hover:text-green hover:font-bold hover:scale-110">
<a href="#services">Services</a>
</li>
</ul>
</Typewriter>
</nav>
);
}

View File

@@ -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 (
<Show when={shouldRender()}>
<div
class="fixed inset-0 z-50 w-screen h-screen bg-base flex overflow-hidden flex-col items-center justify-center mx-auto transition-opacity duration-500"
style={{ opacity: opacity() }}
>
<div class="font-mono text-text text-4xl whitespace-pre-wrap p-8 max-w-3xl">
<div class="flex justify-center items-center">
{spinnerChars[showing()]}
</div>
</div>
</div>
</Show>
);
}

View File

@@ -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 (
<div ref={containerRef} class={props.class}>
{resolved()}
<span ref={cursorRef} class={getCursorClass()}>
{" "}
</span>
</div>
);
}

29
src/context/splash.tsx Normal file
View File

@@ -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<boolean>;
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 (
<SplashContext.Provider value={{ showSplash, setShowSplash }}>
{props.children}
</SplashContext.Provider>
);
}

4
src/entry-client.tsx Normal file
View File

@@ -0,0 +1,4 @@
// @refresh reload
import { mount, StartClient } from "@solidjs/start/client";
mount(() => <StartClient />, document.getElementById("app")!);

29
src/entry-server.tsx Normal file
View File

@@ -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(() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
{assets}
</head>
<body>
<div id="app">{children}</div>
{scripts}
</body>
</html>
)}
/>
));

226
src/env/server.ts vendored Normal file
View File

@@ -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<typeof serverEnvSchema>;
export type ClientEnv = z.infer<typeof clientEnvSchema>;
// Custom error class for better error handling
class EnvironmentError extends Error {
constructor(
message: string,
public errors?: z.ZodFormattedError<any>,
) {
super(message);
this.name = "EnvironmentError";
}
}
// Validation function for server-side with detailed error messages
export const validateServerEnv = (
envVars: Record<string, string | undefined>,
): 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<string, string | undefined>,
): 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));
};

1
src/global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="@solidjs/start/env" />

23
src/lib/api.ts Normal file
View File

@@ -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<AppRouter>({
links: [
// will print out helpful logs when using client
loggerLink(),
// identifies what url will handle trpc requests
httpBatchLink({ url: `${getBaseUrl()}/api/trpc` })
],
});

19
src/routes/[...404].tsx Normal file
View File

@@ -0,0 +1,19 @@
import { Title } from "@solidjs/meta";
import { HttpStatusCode } from "@solidjs/start";
export default function NotFound() {
return (
<main>
<Title>Not Found</Title>
<HttpStatusCode code={404} />
<h1>Page Not Found</h1>
<p>
Visit{" "}
<a href="https://start.solidjs.com" target="_blank">
start.solidjs.com
</a>{" "}
to learn how to build SolidStart apps.
</p>
</main>
);
}

10
src/routes/about.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { Title } from "@solidjs/meta";
export default function About() {
return (
<main>
<Title>About</Title>
<h1>About</h1>
</main>
);
}

View File

@@ -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;

13
src/routes/index.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { Typewriter } from "~/components/Typewriter";
export default function Home() {
return (
<Typewriter speed={100} delay={2000}>
<main class="text-center mx-auto text-subtext0 p-4">
{/* 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
</main>
</Typewriter>
);
}

16
src/server/api/root.ts Normal file
View File

@@ -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;

View File

@@ -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}` };
}),
});

View File

@@ -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 };
}),
});

View File

@@ -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}!`;
})
});

View File

@@ -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" };
}),
});

View File

@@ -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" };
}),
});

6
src/server/api/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { initTRPC } from "@trpc/server";
export const t = initTRPC.create();
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;

249
src/server/utils.ts Normal file
View File

@@ -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<JwtPayload | undefined>((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<string | null> {
try {
const userIDToken = (await cookies()).get("userIDToken");
if (userIDToken) {
const decoded = await new Promise<JwtPayload | undefined>((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:
"<html><body><p>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.</p></body></html>",
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<boolean> {
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;
}

9
src/utils.ts Normal file
View File

@@ -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;
}

19
tsconfig.json Normal file
View File

@@ -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/*"]
}
}
}