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": {
|
||||
"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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue