Integrate backend into storywriter
This commit is contained in:
parent
64268b42c9
commit
d2d3ad42d8
|
|
@ -2,6 +2,16 @@ import { db } from "./db.ts";
|
|||
|
||||
export const TOKEN_TTL_DAYS = parseInt(process.env.TOKEN_TTL_DAYS ?? "30");
|
||||
|
||||
export const issueToken = async (userId: number): Promise<string> => {
|
||||
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 (${userId}, ${token}, ${expiresAt})`;
|
||||
return token;
|
||||
};
|
||||
|
||||
export const validateToken = async (token: string): Promise<number | null> => {
|
||||
const [row] = await db`
|
||||
SELECT user_id FROM auth_tokens
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { db } from "./db.ts";
|
||||
import { issueToken } from "./auth.ts";
|
||||
|
||||
const [username, password] = process.argv.slice(2);
|
||||
|
||||
if (!username || !password) {
|
||||
console.error("Usage: bun run login <username> <password>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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)) {
|
||||
console.error("Invalid credentials");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(await issueToken(user.id as number));
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { db } from "../db.ts";
|
||||
import { TOKEN_TTL_DAYS } from "../auth.ts";
|
||||
import { issueToken } from "../auth.ts";
|
||||
|
||||
export const handleLogin = async (req: Request): Promise<Response> => {
|
||||
if (req.method !== "POST") {
|
||||
|
|
@ -30,16 +30,6 @@ export const handleLogin = async (req: Request): Promise<Response> => {
|
|||
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})
|
||||
`;
|
||||
|
||||
const token = await issueToken(user.id as number);
|
||||
return Response.json({ token });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
--border: #3e3d32;
|
||||
--accent: #f92672;
|
||||
--accent-alt: #a6e22e;
|
||||
--danger: #c4265e;
|
||||
--danger-alt: #f92672;
|
||||
--text: #f8f8f2;
|
||||
--text-muted: #75715e;
|
||||
--text-dim: #cfcfc2;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"backend": "bun backend/src/index.ts",
|
||||
"backend:dev": "bun --hot backend/src/index.ts",
|
||||
"register": "bun backend/src/register.ts",
|
||||
"login": "bun backend/src/login.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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
.error {
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
|
@ -58,6 +58,7 @@ button {
|
|||
background: transparent;
|
||||
transition: all var(--transition);
|
||||
color: var(--text);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.buttonPrimary {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import { useState } from "preact/hooks";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { login } from "@common/storage";
|
||||
import { useInputState } from "@common/hooks/useInputState";
|
||||
import ui from "../assets/ui.module.css";
|
||||
import styles from "../assets/login.module.css";
|
||||
|
||||
interface IProps {
|
||||
onLogin?: () => void;
|
||||
class?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LoginForm = ({ onLogin, ['class']: cls, className }: IProps) => {
|
||||
const [username, handleUsername] = useInputState();
|
||||
const [password, handlePassword] = useInputState();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(username, password);
|
||||
onLogin?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form class={clsx(ui.form, cls, className)} onSubmit={handleSubmit}>
|
||||
<div class={ui.formGroup}>
|
||||
<label class={ui.label}>Username</label>
|
||||
<input
|
||||
class={ui.input}
|
||||
type="text"
|
||||
value={username}
|
||||
onInput={handleUsername}
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class={ui.formGroup}>
|
||||
<label class={ui.label}>Password</label>
|
||||
<input
|
||||
class={ui.input}
|
||||
type="password"
|
||||
value={password}
|
||||
onInput={handlePassword}
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <span class={styles.error}>{error}</span>}
|
||||
<button class={ui.buttonPrimary} type="submit" disabled={loading}>
|
||||
{loading ? 'Logging in…' : 'Log in'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { useEffect, useReducer, useState, type Dispatch, type Reducer, type StateUpdater } from "preact/hooks";
|
||||
import { loadObject, saveObject } from "@common/storage";
|
||||
import { storedStateKey, storedReducerKey } from "@common/hooks/useStored";
|
||||
|
||||
declare const GAME: string;
|
||||
export const remoteStateKey = (key: string) => `useRemoteState.${GAME}.${key}`;
|
||||
export const remoteReducerKey = (key: string) => `useRemoteReducer.${GAME}.${key}`;
|
||||
|
||||
export const useRemoteState = <T>(key: string, initialValue: T) => {
|
||||
const storedKey = `useRemoteState.${GAME}.${key}`;
|
||||
const storedKey = remoteStateKey(key);
|
||||
|
||||
const [value, setValue] = useState<T>(initialValue);
|
||||
|
||||
|
|
@ -34,10 +36,35 @@ const wrapReducer = <T, A>(reducer: Reducer<T, A>): Reducer<T, A | HydrateAction
|
|||
return reducer(state, action);
|
||||
};
|
||||
|
||||
export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, initialValue: T) => {
|
||||
const storedKey = `useRemoteReducer.${GAME}.${key}`;
|
||||
/** Copies a useStoredState value into the useRemoteState slot and syncs it remotely. */
|
||||
export const importStoredState = <T>(key: string): boolean => {
|
||||
const raw = localStorage.getItem(storedStateKey(key));
|
||||
if (!raw) return false;
|
||||
saveObject(remoteStateKey(key), JSON.parse(raw) as T);
|
||||
return true;
|
||||
};
|
||||
|
||||
const [state, dispatch] = useReducer(wrapReducer(reducer), initialValue);
|
||||
/** Copies a useStoredReducer value into the useRemoteReducer slot and syncs it remotely. */
|
||||
export const importStoredReducer = <T>(key: string): boolean => {
|
||||
const raw = localStorage.getItem(storedReducerKey(key));
|
||||
if (!raw) return false;
|
||||
saveObject(remoteReducerKey(key), JSON.parse(raw) as T);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, initialValue: T) => {
|
||||
const storedKey = remoteReducerKey(key);
|
||||
|
||||
const getInitialValue = (): T => {
|
||||
try {
|
||||
const storedValue = localStorage.getItem(storedKey);
|
||||
return storedValue ? JSON.parse(storedValue) : initialValue;
|
||||
} catch {
|
||||
return initialValue;
|
||||
}
|
||||
};
|
||||
|
||||
const [state, dispatch] = useReducer(wrapReducer(reducer), undefined, getInitialValue);
|
||||
|
||||
useEffect(() => {
|
||||
loadObject<T>(storedKey, initialValue).then((loaded) => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useEffect, useReducer, useState, type Dispatch, type Reducer, type StateUpdater } from "preact/hooks";
|
||||
|
||||
declare const GAME: string;
|
||||
export const storedStateKey = (key: string) => `useStoredState.${GAME}.${key}`;
|
||||
export const storedReducerKey = (key: string) => `useStoredReducer.${GAME}.${key}`;
|
||||
|
||||
export const useStoredState = <T>(key: string, initialValue: T) => {
|
||||
const storedKey = `useStoredState.${GAME}.${key}`;
|
||||
const storedKey = storedStateKey(key);
|
||||
|
||||
const [value, setValue] = useState<T>(() => {
|
||||
try {
|
||||
|
|
@ -22,7 +23,7 @@ export const useStoredState = <T>(key: string, initialValue: T) => {
|
|||
};
|
||||
|
||||
export const useStoredReducer = <T, A>(key: string, reducer: Reducer<T, A>, initialValue: T) => {
|
||||
const storedKey = `useStoredReducer.${GAME}.${key}`;
|
||||
const storedKey = storedReducerKey(key);
|
||||
|
||||
const getInitialValue = (): T => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,62 +1,93 @@
|
|||
const saveThrottleDelay = 2000;
|
||||
const SAVE_THROTTLE_MS = 2000;
|
||||
const TOKEN_KEY = '__storage_token__';
|
||||
const BASE_URL = '/storage/api';
|
||||
|
||||
const pendingSaves = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────
|
||||
|
||||
let token: string | null = localStorage.getItem(TOKEN_KEY);
|
||||
|
||||
export const isLoggedIn = () => token !== null;
|
||||
|
||||
export const login = async (username: string, password: string): Promise<void> => {
|
||||
const res = await fetch(`${BASE_URL}/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error ?? 'Login failed');
|
||||
token = json.token;
|
||||
localStorage.setItem(TOKEN_KEY, json.token);
|
||||
};
|
||||
|
||||
export const logout = () => {
|
||||
token = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
// ─── Storage ─────────────────────────────────────────────────
|
||||
|
||||
export const loadObject = async <T>(key: string, defaultObject: T): Promise<T> => {
|
||||
let localObject: Partial<T> = {};
|
||||
|
||||
try {
|
||||
const json = localStorage.getItem(key);
|
||||
if (json) {
|
||||
localObject = JSON.parse(json);
|
||||
}
|
||||
if (json) localObject = JSON.parse(json);
|
||||
} catch { }
|
||||
|
||||
let remoteObject: Partial<T> = {};
|
||||
if (token) {
|
||||
try {
|
||||
// TODO loading from a remote source
|
||||
const compressedData = new Blob([]);
|
||||
const decompressedData = await decompressBlob(compressedData);
|
||||
remoteObject = JSON.parse(await decompressedData.text());
|
||||
const res = await fetch(`${BASE_URL}/storage/${key}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) remoteObject = await res.json();
|
||||
} catch { }
|
||||
}
|
||||
|
||||
return { ...defaultObject, ...localObject, ...remoteObject };
|
||||
}
|
||||
|
||||
export const saveObject = <T>(key: string, obj: T) => {
|
||||
const existing = pendingSaves.get(key);
|
||||
if (existing) clearTimeout(existing);
|
||||
pendingSaves.set(key, setTimeout(() => {
|
||||
pendingSaves.delete(key);
|
||||
doSaveObject(key, obj);
|
||||
}, saveThrottleDelay));
|
||||
};
|
||||
|
||||
const doSaveObject = async <T>(key: string, obj: T) => {
|
||||
const saveData = JSON.stringify(obj);
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, saveData);
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const compressedData = await compressBlob(saveData);
|
||||
// TODO saving to remote storage
|
||||
void compressedData;
|
||||
} catch {
|
||||
if (token) {
|
||||
const existing = pendingSaves.get(key);
|
||||
if (existing) clearTimeout(existing);
|
||||
pendingSaves.set(key, setTimeout(() => {
|
||||
pendingSaves.delete(key);
|
||||
saveRemote(key, saveData);
|
||||
}, SAVE_THROTTLE_MS));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const saveRemote = async (key: string, saveData: string) => {
|
||||
try {
|
||||
await fetch(`${BASE_URL}/storage/${key}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: saveData,
|
||||
});
|
||||
} catch { }
|
||||
};
|
||||
|
||||
// ─── Compression utils ───────────────────────────────────────
|
||||
|
||||
export const compressBlob = async (blob: Blob | string): Promise<Blob> => {
|
||||
if (typeof blob === 'string') {
|
||||
blob = new Blob([blob]);
|
||||
}
|
||||
if (typeof blob === 'string') blob = new Blob([blob]);
|
||||
const cs = new CompressionStream("gzip");
|
||||
const compressedStream = blob.stream().pipeThrough(cs);
|
||||
return await new Response(compressedStream).blob();
|
||||
return await new Response(blob.stream().pipeThrough(cs)).blob();
|
||||
}
|
||||
|
||||
export const decompressBlob = async (blob: Blob): Promise<Blob> => {
|
||||
const ds = new DecompressionStream("gzip");
|
||||
const decompressedStream = blob.stream().pipeThrough(ds);
|
||||
return await new Response(decompressedStream).blob();
|
||||
return await new Response(blob.stream().pipeThrough(ds)).blob();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ import { render } from "preact";
|
|||
import styles from "./assets/game.module.css";
|
||||
import type { Game } from "../../../build/isGame";
|
||||
|
||||
declare const GAMES: Game[];
|
||||
|
||||
function GameList({ games }: { games: Game[] }) {
|
||||
return (
|
||||
<div class={styles.games}>
|
||||
|
|
|
|||
|
|
@ -71,10 +71,6 @@
|
|||
min-height: unset;
|
||||
}
|
||||
|
||||
.button {
|
||||
composes: button from '@common/assets/ui.module.css';
|
||||
}
|
||||
|
||||
.buttonSecondary {
|
||||
composes: buttonSecondary from '@common/assets/ui.module.css';
|
||||
}
|
||||
|
|
@ -84,6 +80,10 @@
|
|||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.buttonDanger {
|
||||
composes: buttonDanger from '@common/assets/ui.module.css';
|
||||
}
|
||||
|
||||
.inputRow {
|
||||
composes: inputRow from '@common/assets/ui.module.css';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,13 +10,14 @@ import { SamplingSettings } from "./settings/sampling";
|
|||
import { SystemInstructionSettings } from "./settings/system-instruction";
|
||||
import { ImageSettings } from "./settings/image";
|
||||
import { UserSettings } from "./settings/user";
|
||||
import { LoginSettings } from "./settings/login";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = "banned-tokens" | "system-instruction" | "chat-system-instruction" | "continue-prompt" | "connection" | "user" | "sampling" | "image";
|
||||
type Tab = "banned-tokens" | "system-instruction" | "chat-system-instruction" | "continue-prompt" | "connection" | "user" | "sampling" | "image" | "login";
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "connection", label: "Connection" },
|
||||
|
|
@ -27,6 +28,7 @@ const TABS: { id: Tab; label: string }[] = [
|
|||
{ id: "system-instruction", label: "System Instruction" },
|
||||
{ id: "continue-prompt", label: "Continue Prompt" },
|
||||
{ id: "chat-system-instruction", label: "Chat System Instruction" },
|
||||
{ id: "login", label: "Login" },
|
||||
];
|
||||
|
||||
export const SettingsModal = ({ open, onClose }: Props) => {
|
||||
|
|
@ -60,6 +62,7 @@ export const SettingsModal = ({ open, onClose }: Props) => {
|
|||
{activeTab === "connection" && <ConnectionSettings />}
|
||||
{activeTab === "sampling" && <SamplingSettings />}
|
||||
{activeTab === "image" && <ImageSettings />}
|
||||
{activeTab === "login" && <LoginSettings />}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useInputState } from "@common/hooks/useInputState";
|
|||
import { useUpdate } from "@common/hooks/useUpdate";
|
||||
import { fuzzyMatch } from "@common/utils";
|
||||
import clsx from "clsx";
|
||||
import { useMemo, useRef } from "preact/hooks";
|
||||
import { useEffect, useMemo, useRef } from "preact/hooks";
|
||||
import styles from "../../assets/settings-modal.module.css";
|
||||
import { useAppState } from "../../contexts/state";
|
||||
import LLM from "../../utils/llm";
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export const ImageSettings = () => {
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button class={styles.button} onClick={handleReset}>
|
||||
<button class={styles.buttonSecondary} onClick={handleReset}>
|
||||
Reset to defaults
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import { useState } from "preact/hooks";
|
||||
import { LoginForm } from "@common/components/LoginForm";
|
||||
import { isLoggedIn, logout } from "@common/storage";
|
||||
import { importStoredReducer } from "@common/hooks/useRemote";
|
||||
import { STATE_KEY } from "../../contexts/state";
|
||||
import styles from "../../assets/settings-modal.module.css";
|
||||
|
||||
export const LoginSettings = () => {
|
||||
const [loggedIn, setLoggedIn] = useState(isLoggedIn);
|
||||
const [imported, setImported] = useState(false);
|
||||
|
||||
const handleLogin = () => setLoggedIn(true);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
setLoggedIn(false);
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
if (importStoredReducer(STATE_KEY)) setImported(true);
|
||||
};
|
||||
|
||||
if (loggedIn) {
|
||||
return (
|
||||
<div class={styles.form}>
|
||||
<span class={styles.label}>Logged in. Save data is synced to the server.</span>
|
||||
<button class={styles.buttonSecondary} onClick={handleImport} disabled={imported}>
|
||||
{imported ? 'Imported' : 'Import settings from local storage'}
|
||||
</button>
|
||||
<button class={styles.buttonDanger} onClick={handleLogout}>Log out</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <LoginForm onLogin={handleLogin} class={styles.form} />;
|
||||
};
|
||||
|
|
@ -83,7 +83,7 @@ export const SamplingSettings = () => {
|
|||
/>
|
||||
))}
|
||||
<div>
|
||||
<button class={styles.button} onClick={handleReset}>
|
||||
<button class={styles.buttonSecondary} onClick={handleReset}>
|
||||
Reset to defaults
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useStoredReducer } from "@common/hooks/useStored";
|
||||
import { useRemoteReducer } from "@common/hooks/useRemote";
|
||||
import { createContext } from "preact";
|
||||
import { useContext, useMemo } from "preact/hooks";
|
||||
import Chapters from "../utils/chapters";
|
||||
|
|
@ -695,8 +695,10 @@ export const useAppState = () => useContext(StateContext);
|
|||
|
||||
// ─── Provider ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const STATE_KEY = 'state';
|
||||
|
||||
export const StateContextProvider = ({ children }: { children?: any }) => {
|
||||
const [state, dispatch] = useStoredReducer('state', reducer, DEFAULT_STATE);
|
||||
const [state, dispatch] = useRemoteReducer(STATE_KEY, reducer, DEFAULT_STATE);
|
||||
|
||||
const value = useMemo<AppState>(() => {
|
||||
const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
declare const GAME: string;
|
||||
|
||||
async function main() {
|
||||
const { default: runGame }: { default: RunGame } = await import(`./games/${GAME}`);
|
||||
await runGame();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ type Rect = [number, number, number, number];
|
|||
|
||||
type RunGame = () => Promise<void>;
|
||||
|
||||
declare const GAME: string;
|
||||
declare const GAMES: Game[];
|
||||
|
||||
declare namespace WebAssembly {
|
||||
type tc = 'i32' | 'i64' | 'f32' | 'f64';
|
||||
export class Function {
|
||||
|
|
|
|||
Loading…
Reference in New Issue