1
0
Fork 0

Simple backend

This commit is contained in:
Pabloader 2026-04-16 14:24:40 +00:00
parent 6a7cfc0dc9
commit 64268b42c9
11 changed files with 259 additions and 1 deletions

3
backend/.env.example Normal file
View File

@ -0,0 +1,3 @@
DATABASE_PATH=data/db.sqlite
PORT=3001
TOKEN_TTL_DAYS=30

8
backend/Dockerfile Normal file
View File

@ -0,0 +1,8 @@
FROM oven/bun:1-alpine
WORKDIR /app
COPY src/ ./src/
VOLUME /app/data
EXPOSE 3001
CMD ["bun", "src/index.ts"]

32
backend/src/auth.ts Normal file
View File

@ -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<number | null> => {
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 };
};

35
backend/src/db.ts Normal file
View File

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

42
backend/src/index.ts Normal file
View File

@ -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<Response> => {
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}`);

19
backend/src/register.ts Normal file
View File

@ -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 <username> <password>");
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`);

View File

@ -0,0 +1,3 @@
export const handleHealth = (_req: Request): Response => {
return Response.json({ status: "ok" });
};

View File

@ -0,0 +1,45 @@
import { db } from "../db.ts";
import { TOKEN_TTL_DAYS } from "../auth.ts";
export const handleLogin = async (req: Request): Promise<Response> => {
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 });
};

View File

@ -0,0 +1,48 @@
import { db } from "../db.ts";
import { requireAuth } from "../auth.ts";
export const handleStorage = async (req: Request, key: string): Promise<Response> => {
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 });
};

16
backend/tsconfig.json Normal file
View File

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

View File

@ -6,7 +6,14 @@
"scripts": { "scripts": {
"start": "bun --hot build/server.ts", "start": "bun --hot build/server.ts",
"bake": "bun build/build.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": { "dependencies": {
"@huggingface/gguf": "0.3.4", "@huggingface/gguf": "0.3.4",