Simple backend
This commit is contained in:
parent
6a7cfc0dc9
commit
64268b42c9
|
|
@ -0,0 +1,3 @@
|
||||||
|
DATABASE_PATH=data/db.sqlite
|
||||||
|
PORT=3001
|
||||||
|
TOKEN_TTL_DAYS=30
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
FROM oven/bun:1-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
VOLUME /app/data
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["bun", "src/index.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<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 };
|
||||||
|
};
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
@ -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}`);
|
||||||
|
|
@ -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`);
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const handleHealth = (_req: Request): Response => {
|
||||||
|
return Response.json({ status: "ok" });
|
||||||
|
};
|
||||||
|
|
@ -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 });
|
||||||
|
};
|
||||||
|
|
@ -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 });
|
||||||
|
};
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue