working v1

This commit is contained in:
Rph :3 2025-06-06 19:48:17 +02:00
parent 19f3e63467
commit e51ccfa79c
No known key found for this signature in database
15 changed files with 2176 additions and 6 deletions

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
DEFAULT_USER_PASSWORD=
ROOT_DOMAIN=
DATA_ROOT=
PORT=

4
.gitignore vendored
View File

@ -1 +1,3 @@
node_modules
node_modules
.env
data

1508
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,8 +10,23 @@
"license": "AGPL-3.0",
"type": "module",
"devDependencies": {
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.2",
"@types/node": "^22.15.29",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
},
"dependencies": {
"@types/better-sqlite3": "^7.6.13",
"argon2": "^0.43.0",
"better-sqlite3": "^11.10.0",
"body-parser": "^2.2.0",
"compression": "^1.8.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"html-rewriter-wasm": "^0.4.1",
"mime": "^4.0.7",
"zod": "^3.25.51"
}
}

61
src/auth.ts Normal file
View File

@ -0,0 +1,61 @@
import Database from "better-sqlite3";
import * as env from "./env.js";
import path from "path";
import argon2 from "argon2";
import { z } from "zod";
const db = new Database(path.join(env.DataRoot, "control.db"));
db.pragma("journal_mode = WAL");
export async function ensureDatabases() {
db.exec(`CREATE TABLE IF NOT EXISTS users(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username NOT NULL UNIQUE,
passwordHash NOT NULL,
allowNewUserCreation NOT NULL DEFAULT 0
)`);
db.prepare(`INSERT INTO users(username, passwordHash, allowNewUserCreation) VALUES(?, ?, 1)
ON CONFLICT DO NOTHING`).run("default", await argon2.hash(env.DefaultPassword, {
timeCost: 5
}));
db.exec(`CREATE TABLE IF NOT EXISTS sessions(
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(64)))),
user INTEGER NOT NULL
)`);
}
const authResponse = z.object({
passwordHash: z.string()
});
const sessionResponse = z.object({
id: z.string()
});
export async function authenticate(username: string, password: string): Promise<string|null> {
const rawrow = db.prepare("SELECT passwordHash FROM users WHERE username = ?").get(username);
if (!rawrow) {
return null;
}
const row = authResponse.parse(rawrow);
const valid = await argon2.verify(row.passwordHash, password);
if (!valid) return null;
const session = sessionResponse.parse(db.prepare("INSERT INTO sessions(user) VALUES(?) RETURNING id").get(username));
return session.id;
}
const sessionUserResponse = z.object({
user: z.string()
});
export function sessionValidate(session: string): string|null {
const rawrow = db.prepare("SELECT user FROM sessions WHERE id = ?").get(session);
if (!rawrow) return null;
const row = sessionUserResponse.parse(rawrow);
return row.user;
}

6
src/custom.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare namespace Express {
export interface Request {
game: string,
active_user: string
}
}

22
src/env.ts Normal file
View File

@ -0,0 +1,22 @@
import de from "dotenv";
de.config();
if (!process.env["DEFAULT_USER_PASSWORD"] || process.env["DEFAULT_USER_PASSWORD"].length < 8) {
throw new Error("DEFAULT_USER_PASSWORD must be set and at least 8 characters long.");
}
export const DefaultPassword: string = process.env["DEFAULT_USER_PASSWORD"];
if (!process.env["ROOT_DOMAIN"]) {
throw new Error("ROOT_DOMAIN must be set");
}
export const RootDomain: string = process.env["ROOT_DOMAIN"];
if (!process.env["DATA_ROOT"]) {
throw new Error("DATA_ROOT must be set");
}
export const DataRoot: string = process.env["DATA_ROOT"];
if (!process.env["PORT"] || !parseInt(process.env["PORT"]) || parseInt(process.env["PORT"]) < 1 || parseInt(process.env["PORT"]) > 65535) {
throw new Error("PORT must be set and be an integer between 1 and 65535");
}
export const Port: number = parseInt(process.env["PORT"]);

59
src/game.ts Normal file
View File

@ -0,0 +1,59 @@
import Database from 'better-sqlite3';
import { readdirSync, readFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import z from 'zod';
import { DataRoot } from './env.js';
const games_dir = readdirSync('data/games');
const manifest = z.object({
name: z.string(),
version: z.string(),
entrypoint: z.string(),
localstorage_setup: z.union([
z.literal("immediate"),
z.literal("deferred")
])
});
const manifestCache: Map<string, z.infer<typeof manifest>> = new Map();
for (let game of games_dir) {
const manifestContent = manifest.parse(JSON.parse(readFileSync('data/games/' + game + '/manifest.json', 'utf-8')));
manifestCache.set(game, manifestContent);
}
export function gameExists(name: string): boolean {
return manifestCache.has(name);
}
export function getAllGames(): Array<{ slug: string } & z.infer<typeof manifest>> {
let data = [];
for (let [k, v] of manifestCache.entries()) {
data.push({
slug: k,
...v
})
}
return data;
}
export function getGame(name: string): z.infer<typeof manifest> {
if (!manifestCache.has(name)) throw new Error("no game (ps5 reference)");
return manifestCache.get(name)!;
}
export function getLocalStorageDatabaseForUser(name: string, user: string): Database.Database {
if (!manifestCache.has(name)) throw new Error("no game (ps5 reference)");
mkdirSync(join(DataRoot, "storage", user, name), { recursive: true });
const db = new Database(join(DataRoot, "storage", user, name, "localStorage.db"));
db.pragma("journal_mode = WAL");
db.exec("CREATE TABLE IF NOT EXISTS kv(key TEXT PRIMARY KEY, value TEXT)");
return db;
}

165
src/host_router.ts Normal file
View File

@ -0,0 +1,165 @@
import express from "express";
import { sessionValidate } from "./auth.js";
import "./game.js";
import { getGame, getLocalStorageDatabaseForUser } from "./game.js";
import path from "path";
import { DataRoot, RootDomain } from "./env.js";
import mime from "mime";
import fs from "fs";
import { injectStandardHeader } from "./injector.js";
import z from "zod";
const router = express.Router();
const preauthRouter = express.Router();
const postauthRouter = express.Router();
const base = import.meta.dirname;
preauthRouter.get("/cgi-bin/rpc/dollhouse_authenticate", (req, res) => {
if (!req.query.sid) {
res.status(400).send("Bad Request");
return;
}
const username = sessionValidate(req.query.sid as string);
if (username === null) {
res.status(400).send("Bad Request");
return;
}
res.cookie("rdh_sid", req.query.sid as string, {
httpOnly: true,
path: "/",
maxAge: 86400 * 400 * 1000
});
res.set('Location', '/cgi-bin/startup');
res.status(307).send("Welcome");
});
postauthRouter.get("/cgi-bin/startup", (req, res) => {
const game = getGame(req.game);
res.set('Location', `/${game.entrypoint}`);
res.status(307).send("off you go girl");
});
postauthRouter.get("/cgi-bin/rpc/dollhouse_authenticate", (req, res) => {
const game = getGame(req.game);
res.set('Location', `/${game.entrypoint}`);
res.status(307).send("off you go girl");
});
postauthRouter.use("/cgi-bin/static", express.static(path.join(base, "injections")));
postauthRouter.get("/cgi-bin/rpc/ls/read_all", (req, res) => {
const db = getLocalStorageDatabaseForUser(req.game, req.active_user);
const results = db.prepare("SELECT * FROM kv").all();
db.close();
res.json(results);
});
const upsertRequest = z.object({
key: z.string(),
value: z.string()
});
postauthRouter.post("/cgi-bin/rpc/ls/upsert", express.json({
limit: 1024 * 1024 * 1024 * 16
}), (req, res) => {
const body = upsertRequest.parse(req.body);
const db = getLocalStorageDatabaseForUser(req.game, req.active_user);
db.prepare("INSERT INTO kv(key, value) VALUES(?, ?) ON CONFLICT (key) DO UPDATE SET value = ? WHERE key = ?").run(
body.key,
body.value,
body.value,
body.key
);
db.close();
res.json({ ok: true, "or is it?": "i don't know" });
});
const removeRequest = z.object({
key: z.string()
});
postauthRouter.post("/cgi-bin/rpc/ls/remove", express.json(), (req, res) => {
const body = removeRequest.parse(req.body);
const db = getLocalStorageDatabaseForUser(req.game, req.active_user);
db.prepare("DELETE FROM kv WHERE key = ?").run(
body.key
);
db.close();
res.json({ ok: true });
});
postauthRouter.post("/cgi-bin/rpc/ls/clear", (req, res) => {
const db = getLocalStorageDatabaseForUser(req.game, req.active_user);
db.prepare("DELETE FROM kv").run();
db.close();
res.json({ ok: true, "or is it?": "is it ever ok" });
});
postauthRouter.use((req, res) => {
const game = getGame(req.game);
const parsedUrl = URL.parse(req.url, `https://${RootDomain}`);
if (!parsedUrl) {
res.status(400).send("Bad Request");
return;
}
if (parsedUrl.pathname === "/") {
res.set('Location', '/cgi-bin/startup');
res.status(307).send("Welcome");
return;
}
const pathname = decodeURI(parsedUrl.pathname);
const resolved = path.resolve("/", pathname);
const realFile = path.join(DataRoot,'games', req.game, 'data', resolved);
console.log(realFile);
if (!fs.existsSync(realFile)) {
res.status(404).send("Not Found");
return;
}
res.set("Content-Type", mime.getType(realFile) || "application/octet-stream");
// TODO: Perform injection into the entrypoint
if (realFile.endsWith(game.entrypoint)) {
injectStandardHeader(realFile, res);
return;
}
// Fallback path
const size = fs.statSync(realFile);
res.set("Content-Length", size.size.toString());
const stream = fs.createReadStream(realFile);
stream.pipe(res);
});
router.use((req, res, next) => {
const session = req.cookies["rdh_sid"];
if (!session) {
return preauthRouter(req, res, next);
}
const username = sessionValidate(session);
if (username === null) {
return preauthRouter(req, res, next);
}
req.active_user = username;
return postauthRouter(req, res, next);
});
export default router;

View File

@ -1 +1,46 @@
console.log('mrraow');
import * as env from "./env.js";
import express from "express";
import { promises as fs } from "node:fs";
import path from "path";
import { ensureDatabases } from "./auth.js";
import RootRouter from "./root_router.js";
import HostRouter from "./host_router.js";
import cookieParser from "cookie-parser";
import { gameExists } from "./game.js";
console.log("[Checking structure]");
await fs.mkdir(path.join(env.DataRoot, "storage"), { recursive: true });
await fs.mkdir(path.join(env.DataRoot, "games"), { recursive: true });
await ensureDatabases();
const app = express();
app.use(cookieParser());
app.use((req, res, next) => {
let host = req.headers["host"];
if (!host) {
return next();
}
if (host === env.RootDomain) {
return RootRouter(req, res, next);
}
if (host.endsWith("." + env.RootDomain)) {
const game = host.split("." + env.RootDomain)[0];
if (gameExists(game)) {
req.game = game;
return HostRouter(req, res, next);
}
}
return next();
});
app.get("/", (req, res) => {
res.status(400).send("Invalid host.");
});
app.listen(env.Port);

167
src/injections/standard.js Normal file
View File

@ -0,0 +1,167 @@
(function() {
// Local Storage TODOs:
// - Consistent order operation with the server
// - Compressing requests
const backingStore = new Map();
let keys = [];
function getKey(key) {
console.log("[LocalStorageTracker] [G]", key);
if (backingStore.has(key.toString())) {
return backingStore.get(key.toString());
}
return null;
}
function setKey(key, value) {
console.log("[LocalStorageTracker] [S]", key, value);
fetch("/cgi-bin/rpc/ls/upsert", {
body: JSON.stringify({
key: key.toString(),
value: value.toString()
}),
headers: {
"Content-Type": "application/json"
},
method: "POST"
}).then(res => res.json()).then(r => {
if (r.ok) {
console.log("Sync ok");
} else {
console.error("Sync fucked");
}
})
if (keys.indexOf(key.toString()) === -1) {
keys.push(key.toString());
}
backingStore.set(key.toString(), value.toString());
}
function removeKey(key) {
console.log("[LocalStorageTracker] [R]", key);
fetch("/cgi-bin/rpc/ls/remove", {
body: JSON.stringify({
key: key.toString()
}),
headers: {
"Content-Type": "application/json"
},
method: "POST"
}).then(res => res.json()).then(r => {
if (r.ok) {
console.log("Sync ok");
} else {
console.error("Sync fucked");
}
})
if (keys.indexOf(key.toString()) !== -1) {
keys = keys.filter(k => k !== key.toString());
}
backingStore.delete(key);
}
function clear() {
console.log("[LocalStorageTracker] [C]");
fetch("/cgi-bin/rpc/ls/clear", {
body: JSON.stringify({}),
headers: {
"Content-Type": "application/json"
},
method: "POST"
}).then(res => res.json()).then(r => {
if (r.ok) {
console.log("Sync ok");
} else {
console.error("Sync fucked");
}
})
keys = [];
backingStore.clear();
}
function getKeyByIndex(index) {
console.log("[LocalStorageTracker] [K]", index);
let realIndex = Number(index);
if (Number.isNaN(realIndex)) {
return null;
}
if (realIndex < 0 || realIndex >= keys.length) {
return null;
}
return keys[realIndex];
}
const api = {
"clear": clear,
"getItem": getKey,
"setItem": setKey,
"removeItem": removeKey,
"key": getKeyByIndex,
"__rdh_debug": function() {
console.log(keys, backingStore);
}
}
Object.defineProperty(window, "localStorage", {
value: new Proxy({}, {
set(_, key, value, __) {
setKey(key, value);
},
get(_, k, __) {
if (api[k]) {
return api[k];
}
if (k === "length") {
return keys.length;
}
return getKey(k);
},
ownKeys(_) {
return [...keys];
}
}),
writable: false,
configurable: false
});
// Populate it with our initial values
const xhr = new XMLHttpRequest();
xhr.open("GET", "/cgi-bin/rpc/ls/read_all", false);
xhr.send(null);
if (xhr.status !== 200) {
alert("it appears to be fucked");
window.stop();
}
const responseBody = JSON.parse(xhr.responseText);
for (let { key, value } of responseBody) {
keys.push(key);
backingStore.set(key, value);
}
// DIE. DIE. DIE. (IndexedDB is hell to implement. I will do it later, for now zap it away so games fall back to localstorage.)
Object.defineProperty(window, "indexedDB", {
value: undefined,
writable: false,
configurable: false,
enumerable: false
});
})();

35
src/injector.ts Normal file
View File

@ -0,0 +1,35 @@
import { HTMLRewriter } from "html-rewriter-wasm";
import fs from "fs";
import stream from "node:stream";
export async function injectStandardHeader(filePath: string, output: stream.Writable) {
const fstream = fs.createReadStream(filePath);
const webStream = stream.Readable.toWeb(fstream);
const rewriter = new HTMLRewriter((outputChunk) => {
output.write(outputChunk);
});
rewriter.on('head', {
element(element) {
console.log(element);
element.prepend(`<script src="/cgi-bin/static/standard.js"></script>`, {
html: true
});
}
});
const reader = webStream.getReader();
while(1) {
const result = await reader.read();
if (result.done) {
rewriter.end();
rewriter.free();
output.end();
break;
}
rewriter.write(result.value);
}
}

69
src/root_router.ts Normal file
View File

@ -0,0 +1,69 @@
import express, { urlencoded } from "express";
import { join } from "path";
import z from "zod";
import { authenticate, sessionValidate } from "./auth.js";
import { getAllGames } from "./game.js";
import { RootDomain } from "./env.js";
const router = express.Router();
const preauthRouter = express.Router();
const postauthRouter = express.Router();
const base = import.meta.dirname;
const loginPayload = z.object({
username: z.string(),
password: z.string()
});
preauthRouter.use(urlencoded());
preauthRouter.get("/", (req, res) => {
res.sendFile(join(base, "statics", "login.html"));
});
preauthRouter.post("/", async (req, res) => {
const body = loginPayload.parse(req.body);
const result = await authenticate(body.username, body.password);
if (result === null) {
res.status(403).send("Bad username or password");
return;
}
res.cookie("rdh_sid", result, {
httpOnly: true,
path: "/",
maxAge: 86400 * 400 * 1000
});
res.set('Location', '/');
res.status(303).send("Welcome");
});
postauthRouter.get("/", (req, res) => {
let html = `<h1>Available games</h1><ul>`;
for (let game of getAllGames()) {
html = html + `<li><a href="${req.protocol}://${game.slug}.${RootDomain}/cgi-bin/rpc/dollhouse_authenticate?sid=${req.cookies["rdh_sid"]}">${game.name}</a></li>`
}
html = html + "</ul>"
res.set("Content-Type", "text/html;charset=UTF-8");
res.send(html);
});
router.use((req, res, next) => {
const session = req.cookies["rdh_sid"];
if (!session) {
return preauthRouter(req, res, next);
}
const username = sessionValidate(session);
if (username === null) {
return preauthRouter(req, res, next);
}
req.active_user = username;
return postauthRouter(req, res, next);
});
export default router;

16
src/statics/login.html Normal file
View File

@ -0,0 +1,16 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to Dollhouse</title>
</head>
<body>
<h1>Welcome to DollHouse.</h1>
<p>Please authenticate to your account.</p>
<form method="POST" action="/">
<label>Username: <input type="text" name="username"></label>
<label>Password: <input type="password" name="password"></label>
<input type="submit" value="Continue">
</form>
</body>
</html>

View File

@ -26,9 +26,9 @@
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "esnext", /* Specify what module code is generated. */
"module": "nodenext", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
"moduleResolution": "nodenext", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */