diff --git a/.env.example b/.env.example index 232b737..55da29c 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,9 @@ APP_PASSWORD=change-me SESSION_TTL_HOURS=720 # Data dir: holds kisassistant.db (SQLite: users, sessions, chats, shares) DATA_DIR=./data +# Local file CDN (avatars today, more namespaces tomorrow). +# Dev default is a folder next to the codebase; NixOS points it under state dir. +CDN_DIR=./cdn # Per-driver debug logging to stdout ([driver:name] lines). DRIVER_DEBUG=true diff --git a/.gitignore b/.gitignore index fcc0255..a853831 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ -data/ +/data/ +/cdn/ node_modules/ dist/ -public/ +/public/ .env *.log *.db diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7e0d2c0..516eba8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -63,9 +63,24 @@ conversations → sessions → user) inside a transaction. **returned once** at creation/regeneration. Regeneration also wipes the account's sessions. -## Drivers +## Local file CDN -`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`), +`src/cdn.ts` + routes in `index.ts`. Generic namespaced layout +(`PUT /cdn//` authed, `GET /cdn//.` public) so future +file kinds plug into `CDN_NAMESPACES` without new routes. Today only `avatar` +exists: key = account id (`/cdn/avatar/.png`), ownership enforced. + +- Type safety by **magic bytes** (JPEG `FF D8 FF`, PNG signature, `RIFF…WEBP`); + only `jpg`/`png`/`webp` are representable, the stored extension is always + detected, everything else is `415`. Route regex charsets make traversal + unmatchable (attempts fall through to the SPA fallback). +- Per-namespace limits: `avatar` = 5 MB max (`413`), 5 uploads / 2 h / + account (`429` + `Retry-After`), counted on success. Re-upload replaces all + stored variants for the key. +- Served with detected `Content-Type`, `X-Content-Type-Options: nosniff`, + `Cache-Control: public, max-age=3600`. + +## Drivers`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`), `DriverRegistry` with global priority `ollama → mistral → puppeteer-openai (stub) → deepseek → anthropic → openai`. Models are addressed as `"driver:model"` (split on the first `:`). diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 2c7660e..0dc35ca 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -39,7 +39,8 @@ read-only `SharePage` (no auth), everything else renders the authenticated app. share button), message list (`Markdown` for assistant, emerald bubble for user), rounded composer (Enter to send, Shift+Enter for newline). - `dialogs.tsx` — `ConvEditDialog` (title + topic), `ShareModal` (public link, - copy, unshare), `SettingsModal` (display name, avatar URL, theme). + copy, unshare), `SettingsModal` (display name, avatar upload box with + drag & drop, avatar URL, theme). - `AdminPanel.tsx` — No-KYC account management: create (key shown once), regenerate (revokes sessions), edit, delete with confirm. - `SharePage.tsx` — public read-only view, fetches `/api/share/:id` unauthenticated. @@ -52,3 +53,12 @@ Tailwind `darkMode: "class"`. `applyTheme()` in `App.tsx` toggles the `dark` class from the account theme (`auto` follows `prefers-color-scheme` with a live media-query listener). Accent: emerald; shapes: `rounded-2xl/3xl` everywhere; every surface needs a `dark:` variant. UI text is English only. + +## Mobile + +Below the `md` breakpoint the sidebar becomes a slide-in drawer (backdrop tap +or swipe-left closes it, hamburger in the chat header opens it). Modals render +as bottom sheets with a drag handle — pull down past the threshold to dismiss +(tap backdrop or Escape works too). Composer respects +`env(safe-area-inset-bottom)`. No separate mobile codebase: same components, +responsive Tailwind classes + minimal touch handlers. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 9758b4c..83becca 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -10,6 +10,7 @@ Copy `.env.example` to `.env`. Full list: | `APP_PASSWORD` | *(required)* | **admin** access key — generate with `openssl rand -base64 24` | | `SESSION_TTL_HOURS` | `720` | Bearer session lifetime (30 days) | | `DATA_DIR` | `./data` | holds `kisassistant.db` (SQLite) | +| `CDN_DIR` | `./cdn` | local file CDN storage (avatars; NixOS: under state dir) | | `WIKI_URL` | `https://git.kisakay.com/k/chat/wiki` | remote docs wiki — `GET /wiki` redirects (302) there | | `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MIN` | `5` / `10` | login rate limit per IP | | `OLLAMA_HOST` / `OLLAMA_ENABLED` | `http://10.66.66.4:11434` / `true` | local-model driver | @@ -35,11 +36,25 @@ unshare. See `AGENTS.md`. 1. Log in as `admin` with the `APP_PASSWORD` key. 2. Open **Accounts** → *New account* → username (+ display name). 3. Copy the one-time `ka_…` key and hand it to the owner (chat, QR code, …). -4. Users set their display name, avatar URL (upload e.g. to `catbox.moe`, paste - the direct link) and theme in the profile settings. +4. Users set their display name, avatar (upload box in profile settings, or a + remote URL e.g. from `catbox.moe`) and theme in the profile settings. 5. Rotate a compromised key with the regenerate button (old sessions die); delete removes the account with all its chats and shares. +## Local file CDN (avatars) + +`PUT /cdn//` (Bearer) / `GET /cdn//.` (public), e.g. +`/cdn/avatar/.png`. Namespaces live in `CDN_NAMESPACES` +(`src/cdn.ts`) — add one to extend (future usage). + +- Allowed types **only** `jpg`/`png`/`webp`, verified by **magic bytes**, never + by extension (client filename is ignored; the stored extension comes from + detection). Everything else → `415`, unknown namespace/ext → `404`. +- `avatar` rules: 5 MB max (`413` above), **5 uploads max per 2 h per account** + (`429` + `Retry-After`), ownership enforced (own id, or admin). +- Served with fixed `Content-Type`, `nosniff`, `Cache-Control: public, + max-age=3600`. No listing, no traversal (strict charsets + SPA fallback). + ## NixOS deployment (`chat.kisakay.com`) `flake.nix` exposes `packages..default` (backend + prebuilt frontend) diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d9c0837 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1789785513, + "narHash": "sha256-B44WL6h0XoLjJ41bUPJk0X5SDinLCII//6EcBLXKiJ0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "20b1ddd1aa5ace70c9468305030aa4f9ef79671b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix index 0791d79..1c4e262 100644 --- a/flake.nix +++ b/flake.nix @@ -5,19 +5,47 @@ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; }; - outputs = { self, nixpkgs }: { + outputs = { self, nixpkgs }: + let lib = nixpkgs.lib; in { nixosModules.default = import ./module.nix; packages.x86_64-linux.default = let pkgs = nixpkgs.legacyPackages.x86_64-linux; - # Frontend: built offline from the npm lockfile. If the build fails - # with a hash mismatch, replace npmDepsHash with the suggested one. - frontend = pkgs.buildNpmPackage { - pname = "kisassistant-frontend"; - version = "0.2.0"; + version = "0.2.0"; + + # Frontend: two-phase bun build. + # Phase 1 (fixed-output, has network): populate node_modules from + # frontend/bun.lock. If the build fails with a hash mismatch, paste + # the "got: sha256-…" value into bunDepsHash below. + bunDeps = pkgs.stdenv.mkDerivation { + pname = "kisassistant-frontend-bun-deps"; + inherit version; src = ./frontend; - npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; - npmBuildScript = "build"; + nativeBuildInputs = [ pkgs.bun ]; + buildPhase = '' + export HOME=$TMPDIR + export BUN_INSTALL_CACHE_DIR=$TMPDIR/bun-cache + bun install --frozen-lockfile --ignore-scripts + ''; + installPhase = '' + cp -r node_modules $out + ''; + outputHashAlgo = "sha256"; + outputHashMode = "recursive"; + outputHash = "sha256-0AhK5sK0FmqxTb1kedUDPH9aOiYKN7z1jWQVFsnb1hs="; + }; + + # Phase 2 (offline): vite build against the pre-fetched node_modules. + frontend = pkgs.stdenv.mkDerivation { + pname = "kisassistant-frontend"; + inherit version; + src = ./frontend; + nativeBuildInputs = [ pkgs.bun ]; + buildPhase = '' + export HOME=$TMPDIR + ln -s ${bunDeps} node_modules + bun run build + ''; installPhase = '' mkdir -p $out cp -r dist/* $out/ @@ -25,7 +53,7 @@ }; in pkgs.stdenv.mkDerivation { pname = "kisassistant"; - version = "0.2.0"; + inherit version; src = ./.; nativeBuildInputs = [ pkgs.bun ]; buildPhase = '' diff --git a/frontend/public/logo.jpg b/frontend/public/logo.jpg new file mode 100644 index 0000000..3297738 Binary files /dev/null and b/frontend/public/logo.jpg differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 98ea7f7..3fc1615 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,6 +36,7 @@ export function App() { const [streaming, setStreaming] = useState(""); const [sending, setSending] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [mobileNav, setMobileNav] = useState(false); const [editConv, setEditConv] = useState(null); const [shareConv, setShareConv] = useState(null); const [deleteConv, setDeleteConv] = useState(null); @@ -191,15 +192,17 @@ export function App() { activeId={activeId} collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(true)} - onNew={newChat} - onSelect={setActiveId} + onNew={() => { newChat(); setMobileNav(false); }} + onSelect={(id) => { setActiveId(id); setMobileNav(false); }} onRename={setEditConv} onTopic={setEditConv} onShare={setShareConv} onDelete={setDeleteConv} - onOpenSettings={() => setSettingsOpen(true)} - onOpenAdmin={() => setAdminOpen(true)} + onOpenSettings={() => { setSettingsOpen(true); setMobileNav(false); }} + onOpenAdmin={() => { setAdminOpen(true); setMobileNav(false); }} onLogout={logout} + mobileOpen={mobileNav} + onCloseMobile={() => setMobileNav(false)} /> active && setShareConv(active)} sidebarCollapsed={sidebarCollapsed} onExpandSidebar={() => setSidebarCollapsed(false)} + onOpenNav={() => setMobileNav(true)} /> void; sidebarCollapsed: boolean; onExpandSidebar: () => void; + onOpenNav: () => void; }) { const [draft, setDraft] = useState(""); const boxRef = useRef(null); @@ -95,10 +97,15 @@ export function Chat({ return (
+ + + {sidebarCollapsed && ( - - - + + + + + )} @@ -154,7 +161,7 @@ export function Chat({
-
+