From 64268b42c96110d44cba9fa06a64922ed34ba161 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Thu, 16 Apr 2026 14:24:40 +0000 Subject: [PATCH] Simple backend --- backend/.env.example | 3 +++ backend/Dockerfile | 8 ++++++ backend/src/auth.ts | 32 +++++++++++++++++++++++ backend/src/db.ts | 35 +++++++++++++++++++++++++ backend/src/index.ts | 42 ++++++++++++++++++++++++++++++ backend/src/register.ts | 19 ++++++++++++++ backend/src/routes/health.ts | 3 +++ backend/src/routes/login.ts | 45 ++++++++++++++++++++++++++++++++ backend/src/routes/storage.ts | 48 +++++++++++++++++++++++++++++++++++ backend/tsconfig.json | 16 ++++++++++++ package.json | 9 ++++++- 11 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/src/auth.ts create mode 100644 backend/src/db.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/register.ts create mode 100644 backend/src/routes/health.ts create mode 100644 backend/src/routes/login.ts create mode 100644 backend/src/routes/storage.ts create mode 100644 backend/tsconfig.json diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..6b1ad6c --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,3 @@ +DATABASE_PATH=data/db.sqlite +PORT=3001 +TOKEN_TTL_DAYS=30 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c3e557c --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,8 @@ +FROM oven/bun:1-alpine +WORKDIR /app + +COPY src/ ./src/ + +VOLUME /app/data +EXPOSE 3001 +CMD ["bun", "src/index.ts"] diff --git a/backend/src/auth.ts b/backend/src/auth.ts new file mode 100644 index 0000000..573a791 --- /dev/null +++ b/backend/src/auth.ts @@ -0,0 +1,32 @@ +import { db } from "./db.ts"; + +export const TOKEN_TTL_DAYS = parseInt(process.env.TOKEN_TTL_DAYS ?? "30"); + +export const validateToken = async (token: string): Promise => { + const [row] = await db` + SELECT user_id FROM auth_tokens + WHERE token = ${token} AND expires_at > datetime('now') + `; + if (!row) return null; + + const expiresAt = new Date(Date.now() + TOKEN_TTL_DAYS * 86_400_000) + .toISOString() + .replace("T", " ") + .slice(0, 19); + await db`UPDATE auth_tokens SET expires_at = ${expiresAt} WHERE token = ${token}`; + + return row.user_id as number; +}; + +export const requireAuth = async (req: Request): Promise<{ userId: number } | Response> => { + const auth = req.headers.get("Authorization"); + if (!auth?.startsWith("Bearer ")) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const token = auth.slice(7); + const userId = await validateToken(token); + if (userId === null) { + return Response.json({ error: "Invalid or expired token" }, { status: 401 }); + } + return { userId }; +}; diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..b848f7a --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,35 @@ +import { sql } from 'bun'; + +export const db = sql; + +await db`PRAGMA journal_mode = WAL`; +await db`PRAGMA foreign_keys = ON`; + +await db` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ) +`; + +await db` + CREATE TABLE IF NOT EXISTS auth_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT UNIQUE NOT NULL, + expires_at TEXT NOT NULL + ) +`; + +await db` + CREATE TABLE IF NOT EXISTS storage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT NOT NULL DEFAULT '{}', + updated_at TEXT DEFAULT (datetime('now')), + UNIQUE(user_id, key) + ) +`; diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..ba910fa --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,42 @@ +import { handleHealth } from "./routes/health.ts"; +import { handleLogin } from "./routes/login.ts"; +import { handleStorage } from "./routes/storage.ts"; + +// db is initialized via side-effects when its imports are resolved +await import("./db.ts"); + +const PORT = parseInt(process.env.PORT ?? "3001"); + +const router = async (req: Request): Promise => { + const url = new URL(req.url); + console.log(`${req.method} ${url.pathname}`); + // Split and strip empty segments so any prefix before /api is ignored. + // lastIndexOf so the rightmost /api segment is the routing boundary. + const segments = url.pathname.split("/").filter(Boolean); + const apiIdx = segments.lastIndexOf("api"); + if (apiIdx === -1) { + return Response.json({ error: "Not found" }, { status: 404 }); + } + + const [resource, ...rest] = segments.slice(apiIdx + 1); + + switch (resource) { + case "health": + return handleHealth(req); + case "login": + return handleLogin(req); + case "storage": { + const key = rest.join("/"); + return handleStorage(req, key); + } + default: + return Response.json({ error: "Not found" }, { status: 404 }); + } +}; + +Bun.serve({ + port: PORT, + fetch: router, +}); + +console.log(`tsgames-backend listening on port ${PORT}`); diff --git a/backend/src/register.ts b/backend/src/register.ts new file mode 100644 index 0000000..482ab5e --- /dev/null +++ b/backend/src/register.ts @@ -0,0 +1,19 @@ +import { db } from "./db.ts"; + +const [username, password] = process.argv.slice(2); + +if (!username || !password) { + console.error("Usage: bun run register "); + process.exit(1); +} + +const [existing] = await db`SELECT id FROM users WHERE username = ${username}`; +if (existing) { + console.error(`User "${username}" already exists`); + process.exit(1); +} + +const passwordHash = await Bun.password.hash(password); +await db`INSERT INTO users (username, password_hash) VALUES (${username}, ${passwordHash})`; + +console.log(`User "${username}" registered successfully`); diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts new file mode 100644 index 0000000..d6ee7e8 --- /dev/null +++ b/backend/src/routes/health.ts @@ -0,0 +1,3 @@ +export const handleHealth = (_req: Request): Response => { + return Response.json({ status: "ok" }); +}; diff --git a/backend/src/routes/login.ts b/backend/src/routes/login.ts new file mode 100644 index 0000000..92dc4f6 --- /dev/null +++ b/backend/src/routes/login.ts @@ -0,0 +1,45 @@ +import { db } from "../db.ts"; +import { TOKEN_TTL_DAYS } from "../auth.ts"; + +export const handleLogin = async (req: Request): Promise => { + if (req.method !== "POST") { + return Response.json({ error: "Method not allowed" }, { status: 405 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + if (!body || typeof body !== "object") { + return Response.json({ error: "Body must be a JSON object" }, { status: 400 }); + } + + const { username, password } = body as { username?: string; password?: string }; + if (!username || !password) { + return Response.json({ error: "username and password required" }, { status: 400 }); + } + + const [user] = await db` + SELECT id, password_hash FROM users WHERE username = ${username} + `; + + if (!user || !await Bun.password.verify(password, user.password_hash as string)) { + return Response.json({ error: "Invalid credentials" }, { status: 401 }); + } + + const token = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + TOKEN_TTL_DAYS * 86_400_000) + .toISOString() + .replace("T", " ") + .slice(0, 19); + + await db` + INSERT INTO auth_tokens (user_id, token, expires_at) + VALUES (${user.id}, ${token}, ${expiresAt}) + `; + + return Response.json({ token }); +}; diff --git a/backend/src/routes/storage.ts b/backend/src/routes/storage.ts new file mode 100644 index 0000000..41061de --- /dev/null +++ b/backend/src/routes/storage.ts @@ -0,0 +1,48 @@ +import { db } from "../db.ts"; +import { requireAuth } from "../auth.ts"; + +export const handleStorage = async (req: Request, key: string): Promise => { + if (!key) { + return Response.json({ error: "Storage key required" }, { status: 400 }); + } + + const auth = await requireAuth(req); + if (auth instanceof Response) return auth; + const { userId } = auth; + + if (req.method === "GET") { + const [row] = await db` + SELECT value FROM storage WHERE user_id = ${userId} AND key = ${key} + `; + if (!row) return Response.json({}); + try { + return Response.json(JSON.parse(row.value as string)); + } catch { + return Response.json({}); + } + } + + if (req.method === "POST") { + let body: unknown; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + if (body === null || body === undefined) { + return Response.json({ error: "Body required" }, { status: 400 }); + } + + const value = JSON.stringify(body); + await db` + INSERT INTO storage (user_id, key, value, updated_at) + VALUES (${userId}, ${key}, ${value}, datetime('now')) + ON CONFLICT(user_id, key) + DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `; + return Response.json({ ok: true }); + } + + return Response.json({ error: "Method not allowed" }, { status: 405 }); +}; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..173722f --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": false, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/package.json b/package.json index 3fc5d13..9ed0f77 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,14 @@ "scripts": { "start": "bun --hot build/server.ts", "bake": "bun build/build.ts", - "test": "bun test" + "test": "bun test", + "backend": "bun backend/src/index.ts", + "backend:dev": "bun --hot backend/src/index.ts", + "register": "bun backend/src/register.ts", + "docker:build": "docker build -t git.pabloader.ru/pabloid/tsgames:latest backend/", + "docker:push": "docker push git.pabloader.ru/pabloid/tsgames:latest", + "docker:update": "docker service update --force --image git.pabloader.ru/pabloid/tsgames:latest tsgames_backend", + "bake:server": "bun run docker:build && bun run docker:push && bun run docker:update" }, "dependencies": { "@huggingface/gguf": "0.3.4",