This commit is contained in:
k
2026-09-21 12:56:11 +02:00
parent a5da592fcf
commit 6cb9cba384
28 changed files with 1290 additions and 63 deletions
+19
View File
@@ -1,4 +1,6 @@
# --- server --- # --- server ---
# development locally; the NixOS module sets production (gates DRIVER_DEBUG).
NODE_ENV=development
PORT=3000 PORT=3000
HOST=127.0.0.1 HOST=127.0.0.1
# ADMIN access key (plain). This is the admin account's key — it manages all # ADMIN access key (plain). This is the admin account's key — it manages all
@@ -9,16 +11,33 @@ APP_PASSWORD=change-me
SESSION_TTL_HOURS=720 SESSION_TTL_HOURS=720
# Data dir: holds kisassistant.db (SQLite: users, sessions, chats, shares) # Data dir: holds kisassistant.db (SQLite: users, sessions, chats, shares)
DATA_DIR=./data DATA_DIR=./data
# Local file CDN (avatars). NixOS module points it under the state dir.
CDN_DIR=./cdn
# Local file CDN (avatars today, more namespaces tomorrow). # Local file CDN (avatars today, more namespaces tomorrow).
# Dev default is a folder next to the codebase; NixOS points it under state dir. # Dev default is a folder next to the codebase; NixOS points it under state dir.
CDN_DIR=./cdn CDN_DIR=./cdn
# --- platform tools: OCR (tesseract binary) ---
# TESSERACT_BIN=/nix/store/…-tesseract-5.5.3/bin/tesseract
OCR_LANG=eng
OCR_MAX_CHARS=100000
# Per-driver debug logging to stdout ([driver:name] lines). # Per-driver debug logging to stdout ([driver:name] lines).
DRIVER_DEBUG=true DRIVER_DEBUG=true
# Public docs wiki (Gitea wiki pages of this repo). GET /wiki redirects here. # Public docs wiki (Gitea wiki pages of this repo). GET /wiki redirects here.
WIKI_URL=https://git.kisakay.com/k/chat/wiki WIKI_URL=https://git.kisakay.com/k/chat/wiki
# --- key recovery via email (optional — empty SMTP_HOST disables it) ---
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=KisAssistant <[email protected]>
APP_URL=http://localhost:3000
RESET_TTL_MIN=60
# --- rate limit on /api/auth/login (per IP, sliding window) --- # --- rate limit on /api/auth/login (per IP, sliding window) ---
RATE_LIMIT_MAX=5 RATE_LIMIT_MAX=5
RATE_LIMIT_WINDOW_MIN=10 RATE_LIMIT_WINDOW_MIN=10
+14 -8
View File
@@ -15,7 +15,8 @@ Driver priority:
Docs: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (backend, drivers, DB) · Docs: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (backend, drivers, DB) ·
[`docs/FRONTEND.md`](docs/FRONTEND.md) (components, theming) · [`docs/FRONTEND.md`](docs/FRONTEND.md) (components, theming) ·
[`docs/OPERATIONS.md`](docs/OPERATIONS.md) (env, dev, accounts, NixOS). [`docs/OPERATIONS.md`](docs/OPERATIONS.md) (env, dev, accounts, NixOS) ·
[`docs/NIXOS-HOSTING.md`](docs/NIXOS-HOSTING.md) (server hosting tutorial).
Agent instructions: [`AGENTS.md`](AGENTS.md). Agent instructions: [`AGENTS.md`](AGENTS.md).
## Quick start ## Quick start
@@ -90,10 +91,12 @@ Enable an API driver later: `MISTRAL_ENABLED=true` + `MISTRAL_API_KEY=…`, rest
## NixOS hosting (chat.kisakay.com) ## NixOS hosting (chat.kisakay.com)
Full tutorial: **[`docs/NIXOS-HOSTING.md`](docs/NIXOS-HOSTING.md)**.
Flake exposes `packages.<system>.default` (backend + prebuilt frontend) and Flake exposes `packages.<system>.default` (backend + prebuilt frontend) and
`nixosModules.default`. The frontend is built offline via `buildNpmPackage` `nixosModules.default`. The frontend is built offline inside Nix with Bun from
(`frontend/package-lock.json`); on first build, replace the placeholder `frontend/bun.lock`; on a deps hash mismatch, paste the `got: sha256-…` value
`npmDepsHash` with the hash Nix suggests. Nix reports into `bunDepsHash` in `flake.nix`.
```nix ```nix
kisassistant.url = "http://git.kisakay.com/k/chat"; # adjust to real repo path kisassistant.url = "http://git.kisakay.com/k/chat"; # adjust to real repo path
@@ -101,15 +104,18 @@ kisassistant.url = "http://git.kisakay.com/k/chat"; # adjust to real repo path
imports = [ kisassistant.nixosModules.default ]; imports = [ kisassistant.nixosModules.default ];
services.kisassistant = { services.kisassistant = {
enable = true; enable = true;
package = kisassistant.packages.${pkgs.system}.default; package = kisassistant.packages.${pkgs.system}.default; # required
domain = "chat.kisakay.com"; # nginx reverse proxy + ACME enableNginx = true; # nginx reverse proxy + ACME
domain = "chat.kisakay.com"; # vhost name (required with enableNginx)
passwordFile = "/run/secrets/kisassistant-password"; # agenix/sops-nix passwordFile = "/run/secrets/kisassistant-password"; # agenix/sops-nix
ollamaHost = "http://10.66.66.4:11434"; ollamaHost = "http://10.66.66.4:11434";
}; };
security.acme.acceptTerms = true; # required for the TLS certificate
``` ```
Nginx terminates TLS; the app listens on `127.0.0.1:3000`. SQLite lives in Nginx terminates TLS; the app listens on `127.0.0.1:3000` with
`/var/lib/kisassistant/data`. `NODE_ENV=production`. SQLite lives in `/var/lib/kisassistant/data`, avatars
(file CDN) in `/var/lib/kisassistant/cdn`.
## Frontend ## Frontend
+8
View File
@@ -4,8 +4,12 @@
"workspaces": { "workspaces": {
"": { "": {
"name": "kisassistant", "name": "kisassistant",
"dependencies": {
"nodemailer": "^10.0.10",
},
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/nodemailer": "^8.0.2",
}, },
}, },
}, },
@@ -14,8 +18,12 @@
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="],
"@types/nodemailer": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], "bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"nodemailer": ["[email protected]", "", {}, "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A=="],
"undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], "undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
} }
} }
+12 -1
View File
@@ -43,8 +43,9 @@ Created idempotently at boot in `getDb()` (no migration framework; keep it so).
| Table | Purpose | | Table | Purpose |
|---|---| |---|---|
| `users` | `id, username UNIQUE, display_name, avatar_url, theme, key_hash, created_at`. The `admin` row is bootstrapped (its `key_hash` is unused — admin auth goes through `APP_PASSWORD`). | | `users` | `id, username UNIQUE, display_name, avatar_url, theme, email, key_hash, created_at`. The `admin` row is bootstrapped (its `key_hash` is unused — admin auth goes through `APP_PASSWORD`). |
| `sessions` | `token_hash PK, user_id, created_at, expires_at`. Bearer tokens, 256-bit, hashed with sha256. Expired rows are purged lazily on access. | | `sessions` | `token_hash PK, user_id, created_at, expires_at`. Bearer tokens, 256-bit, hashed with sha256. Expired rows are purged lazily on access. |
| `resets` | `token_hash PK, user_id, created_at, expires_at`. One-time key-recovery tokens (single use, consumed on POST). |
| `conversations` | `id, user_id, title, topic, model, created_at, updated_at`. | | `conversations` | `id, user_id, title, topic, model, created_at, updated_at`. |
| `messages` | `id AUTOINCREMENT, conv_id → conversations ON DELETE CASCADE, role, content, created_at`. | | `messages` | `id AUTOINCREMENT, conv_id → conversations ON DELETE CASCADE, role, content, created_at`. |
| `shares` | `conv_id PK → conversations ON DELETE CASCADE, public_id UNIQUE, created_at`. | | `shares` | `conv_id PK → conversations ON DELETE CASCADE, public_id UNIQUE, created_at`. |
@@ -80,6 +81,16 @@ exists: key = account id (`/cdn/avatar/<id>.png`), ownership enforced.
- Served with detected `Content-Type`, `X-Content-Type-Options: nosniff`, - Served with detected `Content-Type`, `X-Content-Type-Options: nosniff`,
`Cache-Control: public, max-age=3600`. `Cache-Control: public, max-age=3600`.
## Platform tools
`src/tools/`: `PlatformTool` interface + registry (today: `ocr` via the
tesseract binary, availability probed at boot). `POST /api/tools/ocr` takes
raw image bytes (5 MB, magic-verified, 20 jobs/hour/account) and returns
`{ text, truncated, chars }` — transcription happens server-side with
per-driver-style stdout logging; a missing binary yields a clean 501. Text
attachments use the `text` CDN namespace (500 KB, UTF-8 validated). The model
only ever sees reviewed text blocks, never raw files.
## Drivers`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`), ## Drivers`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`),
`DriverRegistry` with global priority `DriverRegistry` with global priority
`ollama → mistral → puppeteer-openai (stub) → deepseek → anthropic → openai`. `ollama → mistral → puppeteer-openai (stub) → deepseek → anthropic → openai`.
+2 -1
View File
@@ -37,7 +37,8 @@ read-only `SharePage` (no auth), everything else renders the authenticated app.
chip (opens settings), Accounts button (admin), logout. chip (opens settings), Accounts button (admin), logout.
- `Chat.tsx` — header (title, topic badge, driver-grouped model `Picker`, - `Chat.tsx` — header (title, topic badge, driver-grouped model `Picker`,
share button), message list (`Markdown` for assistant, emerald bubble for share button), message list (`Markdown` for assistant, emerald bubble for
user), rounded composer (Enter to send, Shift+Enter for newline). user), rounded composer with paperclip attach menu, attachment chips
(Enter to send, Shift+Enter for newline).
- `dialogs.tsx` — `ConvEditDialog` (title + topic), `ShareModal` (public link, - `dialogs.tsx` — `ConvEditDialog` (title + topic), `ShareModal` (public link,
copy, unshare), `SettingsModal` (display name, avatar upload box with copy, unshare), `SettingsModal` (display name, avatar upload box with
drag & drop, avatar URL, theme). drag & drop, avatar URL, theme).
+231
View File
@@ -0,0 +1,231 @@
# Hosting KisAssistant on a NixOS server
End-to-end guide: build the flake, wire the module into `configuration.nix`,
secure it with agenix/sops-nix, and put it behind nginx with automatic TLS.
Contents:
1. [Prerequisites](#1-prerequisites)
2. [Add the flake to your system inputs](#2-add-the-flake-to-your-system-inputs)
3. [Secrets (`APP_PASSWORD`, API keys)](#3-secrets-app_password-api-keys)
4. [Enable the service](#4-enable-the-service)
5. [TLS reverse proxy (`enableNginx`)](#5-tls-reverse-proxy-enablenginx)
6. [First deploy & smoke test](#6-first-deploy--smoke-test)
7. [Operations: logs, backups, updates](#7-operations-logs-backups-updates)
8. [Module option reference](#8-module-option-reference)
---
## 1. Prerequisites
- A NixOS machine (unstable channel or a flake lock close to it) with
` networking` + `firewall` under your control.
- Bun is **not** installed on the server — the flake wraps the runtime.
- DNS: an A/AAAA record for your domain (e.g. `chat.kisakay.com`) pointing at
the server. ACME (Let's Encrypt) needs port 80/443 reachable during
issuance.
- Ollama reachable from the server (e.g. `http://10.66.66.4:11434` on your
LAN/tailscale).
## 2. Add the flake to your system inputs
In your system flake (or `flake.nix` of your NixOS config):
```nix
{
inputs.kisassistant.url = "http://git.kisakay.com/k/chat"; # adjust to real repo path
outputs = { self, nixpkgs, kisassistant, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
./configuration.nix
kisassistant.nixosModules.default
];
};
};
}
```
If you don't use flakes for your system config, import the module by path:
```nix
imports = [ /etc/nixos/kisassistant/module.nix ];
# and point the package at a build:
services.kisassistant.package =
(builtins.getFlake "git+http://git.kisakay.com/k/chat").packages.x86_64-linux.default;
```
> The frontend is compiled **offline inside the Nix build** with Bun from
> `frontend/bun.lock` (fixed-output deps fetch). If `nix build` ever fails with
> a `bunDepsHash` mismatch, paste the `got: sha256-…` value from the error into
> `bunDepsHash` in `flake.nix` and rebuild. This is normal after dependency
> bumps.
## 3. Secrets (`APP_PASSWORD`, API keys)
Never put secrets in the Nix store. Use [agenix](https://github.com/ryantm/agenix)
or [sops-nix](https://github.com/Mic92/sops-nix). Minimal agenix setup:
```bash
# on your machine, in the secrets repo of the host
npx agenix generate kisassistant-password -a $(readlink -f /etc/ssh/ssh_host_ed25519_key.pub)
# write the admin key into the file, e.g.:
echo "$(openssl rand -base64 24)" | agenix -e kisassistant-password.age # keep a copy!
```
Then on the host:
```nix
age.secrets.kisassistant-password.file = /etc/nixos/secrets/kisassistant-password.age;
```
The module consumes it as `passwordFile` and injects it into the service via
systemd `LoadCredential` — the file is read at runtime only.
## 4. Enable the service
```nix
services.kisassistant = {
enable = true;
# REQUIRED — the module refuses to evaluate without it.
package = kisassistant.packages.${pkgs.system}.default;
# admin key file (agenix/sops-nix path on the host)
passwordFile = config.age.secrets.kisassistant-password.path;
# storage (created by systemd StateDirectory, owned by the service user)
# SQLite: /var/lib/kisassistant/data
# CDN: /var/lib/kisassistant/cdn
dataDir = "/var/lib/kisassistant";
# LLM backends
ollamaHost = "http://10.66.66.4:11434";
extraEnv = {
MISTRAL_ENABLED = "true"; # non-secret toggles only — see the note below
};
};
```
The module automatically sets for the systemd unit:
| Env | Value |
|---|---|
| `NODE_ENV` | `production` |
| `DATA_DIR` | `/var/lib/kisassistant/data` |
| `CDN_DIR` | `/var/lib/kisassistant/cdn` |
| `DRIVER_DEBUG` | `false` |
| `PORT` / `HOST` | `127.0.0.1:3000` (configurable) |
The module also puts `tesseract` on the service `PATH`, so the OCR platform
tool (`POST /api/tools/ocr`, image attachments) works out of the box.
`OCR_LANG` / `OCR_MAX_CHARS` can be overridden via `extraEnv`.
> **API driver keys**: `extraEnv` values land in the unit's `Environment=`,
> which is world-readable in the systemd unit file. For driver API keys prefer
> `EnvironmentFile=` via a drop-in (or pass them from agenix with
> `systemd.services.kisassistant.serviceConfig.EnvironmentFile`), e.g.
> `services.kisassistant.extraEnv = { MISTRAL_ENABLED = "true"; };` plus a
> drop-in containing the key.
## 5. TLS reverse proxy (`enableNginx`)
The conventional switch is `enableNginx` — a tri-state:
| Value | Behaviour |
|---|---|
| `true` | nginx vhost + ACME configured (requires `domain`) |
| `false` | nginx never touched, even if `domain` is set |
| `null` (default) | legacy: nginx configured iff `domain` is set |
```nix
services.kisassistant = {
enableNginx = true;
domain = "chat.kisakay.com"; # vhost name + ACME cert name
# host/port default to 127.0.0.1:3000
};
# Required once per host for ACME (Let's Encrypt ToS):
security.acme.acceptTerms = true;
security.acme.defaults.email = "you@kisakay.com";
# Keep the backend loopback-only and let nginx own 80/443:
networking.firewall.allowedTCPPorts = [ 80 443 ];
```
The generated vhost: `forceSSL`, `enableACME`, `recommendedProxySettings`, and
`proxy_buffering off` on `/` so SSE token streaming (`/api/chat`) is never
buffered.
**Own nginx instead?** Set `enableNginx = false` (or omit `domain`) and write
your own vhost; proxy to `http://127.0.0.1:3000` and remember
`proxy_buffering off;` for `/` (or at least `/api/chat`).
## 6. First deploy & smoke test
```bash
sudo nixos-rebuild switch --flake .#myhost
```
Check the unit and the proxy:
```bash
systemctl status kisassistant
journalctl -u kisassistant -f # boot log (APP_PASSWORD errors are fatal)
curl -I https://chat.kisakay.com/ # 200 via nginx
curl -s https://chat.kisakay.com/api/models -H "Authorization: Bearer $TOKEN"
```
Log in through the web UI as `admin` with the `APP_PASSWORD` from your secret
file, then create user accounts from the **Accounts** panel (each gets a
one-time `ka_…` key). See `docs/OPERATIONS.md` for account management.
If port 3000 is used by something else on the host, change
`services.kisassistant.port` and the proxy target follows automatically.
## 7. Operations: logs, backups, updates
**Logs** — per-driver debug lines are off in production
(`DRIVER_DEBUG=false`); flip temporarily with:
```bash
sudo systemctl edit kisassistant # [Service] Environment=DRIVER_DEBUG=true
sudo systemctl restart kisassistant
```
**Backups** — everything is in `/var/lib/kisassistant`:
```bash
# SQLite is in WAL mode; prefer the backup API over raw copies:
sqlite3 /var/lib/kisassistant/data/kisassistant.db ".backup '/backup/kisassistant.db'"
systemctl stop kisassistant && tar -C /var/lib -czf /backup/kisassistant-$(date +%F).tgz kisassistant && systemctl start kisassistant
```
**Updates** — the usual flake flow:
```bash
nix flake update kisassistant # or update the rev you pin
sudo nixos-rebuild switch --flake .#myhost
sudo nix store gc # keep the previous generation around as needed
```
After dependency bumps the first build may fail with a `bunDepsHash` mismatch
— paste the reported hash into `flake.nix` (see section 2).
## 8. Module option reference
| Option | Type / default | Description |
|---|---|---|
| `enable` | bool | the switch |
| `package` | null **(required)** | flake package, e.g. `kisassistant.packages.${pkgs.system}.default` |
| `port` | port, `3000` | backend listen port |
| `host` | str, `127.0.0.1` | backend bind address (keep loopback behind nginx) |
| `dataDir` | str, `/var/lib/kisassistant` | state root (`data/` + `cdn/` live inside) |
| `user` | str, `kisassistant` | system user/group for the service |
| `passwordFile` | null \| path | file containing `APP_PASSWORD` (agenix/sops-nix) |
| `ollamaHost` | str, `http://localhost:11434` | Ollama endpoint |
| `domain` | null \| str | nginx vhost name, required when `enableNginx = true` |
| `enableNginx` | null \| bool, `null` | `true`/`false` force the bundled reverse proxy on/off; `null` = nginx iff `domain` set (legacy) |
| `extraEnv` | attrs of str, `{}` | extra environment (non-secret values only) |
+52 -8
View File
@@ -6,12 +6,22 @@ Copy `.env.example` to `.env`. Full list:
| Variable | Default | Purpose | | Variable | Default | Purpose |
|---|---|---| |---|---|---|
| `NODE_ENV` | `development` | `production` under the NixOS module (and CI); gates the `DRIVER_DEBUG` default |
| `PORT` / `HOST` | `3000` / `127.0.0.1` | listen address (keep localhost behind nginx) | | `PORT` / `HOST` | `3000` / `127.0.0.1` | listen address (keep localhost behind nginx) |
| `APP_PASSWORD` | *(required)* | **admin** access key — generate with `openssl rand -base64 24` | | `APP_PASSWORD` | *(required)* | **admin** access key — generate with `openssl rand -base64 24` |
| `SESSION_TTL_HOURS` | `720` | Bearer session lifetime (30 days) | | `SESSION_TTL_HOURS` | `720` | Bearer session lifetime (30 days) |
| `DATA_DIR` | `./data` | holds `kisassistant.db` (SQLite) | | `DATA_DIR` | `./data` | holds `kisassistant.db` (SQLite) |
| `CDN_DIR` | `./cdn` | local file CDN storage (avatars; NixOS: under state dir) | | `CDN_DIR` | `./cdn` | local file CDN storage (avatars; NixOS: under state dir) |
| `OCR_LANG` | `eng` | tesseract language(s) for the OCR tool |
| `OCR_MAX_CHARS` | `100000` | transcription cap (longer results are truncated + flagged) |
| `TESSERACT_BIN` | `tesseract` | path to the tesseract binary (NixOS module puts it on PATH; missing binary cleanly disables OCR with 501) |
| `DRIVER_DEBUG` | `true` dev / `false` prod | per-driver debug logging to stdout |
| `WIKI_URL` | `https://git.kisakay.com/k/chat/wiki` | remote docs wiki — `GET /wiki` redirects (302) there | | `WIKI_URL` | `https://git.kisakay.com/k/chat/wiki` | remote docs wiki — `GET /wiki` redirects (302) there |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_SECURE` | *(empty)* / `587` / `false` | key-recovery mailer — empty host disables it (`false` = STARTTLS, `true` = implicit TLS/465) |
| `SMTP_USER` / `SMTP_PASS` | *(empty)* | SMTP auth (omit both for open relays / local catchers) |
| `SMTP_FROM` | `KisAssistant <[email protected]>` | sender address |
| `APP_URL` | `http://localhost:3000` | public base URL used in reset links — **must** be the real origin in prod |
| `RESET_TTL_MIN` | `60` | reset-link lifetime (single use) |
| `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MIN` | `5` / `10` | login rate limit per IP | | `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 | | `OLLAMA_HOST` / `OLLAMA_ENABLED` | `http://10.66.66.4:11434` / `true` | local-model driver |
| `MISTRAL_*`, `DEEPSEEK_*`, `ANTHROPIC_*`, `OPENAI_*` | disabled | API drivers: set `*_ENABLED=true` **and** `*_API_KEY` | | `MISTRAL_*`, `DEEPSEEK_*`, `ANTHROPIC_*`, `OPENAI_*` | disabled | API drivers: set `*_ENABLED=true` **and** `*_API_KEY` |
@@ -41,8 +51,21 @@ unshare. See `AGENTS.md`.
5. Rotate a compromised key with the regenerate button (old sessions die); 5. Rotate a compromised key with the regenerate button (old sessions die);
delete removes the account with all its chats and shares. delete removes the account with all its chats and shares.
## Local file CDN (avatars) ## Key recovery via email (optional)
Set the `SMTP_*` vars (and the real public `APP_URL`) to enable it; otherwise
the login page hides the "Forgot your access key?" link.
1. Admin sets a recovery email at account creation (or the user sets their own
in profile settings).
2. User clicks the link on the login page and enters their username. The API
always answers `{ok:true}` (no account enumeration) and, if the account has
an email, sends a one-time `/reset/<token>` link (60 min, single use).
3. Opening the link shows a confirm page; confirming **rotates the key
immediately** — the new `ka_…` key is displayed once, old sessions die.
(`admin` itself is excluded: its key lives in `.env`.)
## Local file CDN (avatars)
`PUT /cdn/<ns>/<key>` (Bearer) / `GET /cdn/<ns>/<key>.<ext>` (public), e.g. `PUT /cdn/<ns>/<key>` (Bearer) / `GET /cdn/<ns>/<key>.<ext>` (public), e.g.
`/cdn/avatar/<account-id>.png`. Namespaces live in `CDN_NAMESPACES` `/cdn/avatar/<account-id>.png`. Namespaces live in `CDN_NAMESPACES`
(`src/cdn.ts`) — add one to extend (future usage). (`src/cdn.ts`) — add one to extend (future usage).
@@ -55,26 +78,47 @@ unshare. See `AGENTS.md`.
- Served with fixed `Content-Type`, `nosniff`, `Cache-Control: public, - Served with fixed `Content-Type`, `nosniff`, `Cache-Control: public,
max-age=3600`. No listing, no traversal (strict charsets + SPA fallback). max-age=3600`. No listing, no traversal (strict charsets + SPA fallback).
## Platform tools & attachments
Uploads always go through platform tools (`GET /api/tools` lists them with
availability). Today: **OCR** (`POST /api/tools/ocr`, tesseract).
- Images (jpg/png/webp, 5 MB max, magic-verified, 20 jobs/hour/account) are
transcribed **on the backend to TXT** — the model never receives image
bytes. Text files ride the `text` CDN namespace (UTF-8 validated, 500 KB
max, keys prefixed with the account id).
- The UI (paperclip menu in the composer) explains this and always shows a
**pre-transcription preview**: the user reviews/edits the text, then
attaches it. Attachments travel as labeled text blocks inside the message,
so they persist in history and work with every model.
## NixOS deployment (`chat.kisakay.com`) ## NixOS deployment (`chat.kisakay.com`)
`flake.nix` exposes `packages.<system>.default` (backend + prebuilt frontend) Full step-by-step guide: **[NIXOS-HOSTING.md](./NIXOS-HOSTING.md)**.
and `nixosModules.default`. The frontend is compiled offline with
`buildNpmPackage` from `frontend/package-lock.json` — commit that file, and on `flake.nix` exposes `packages.<system>.default` (backend + prebuilt frontend,
a hash mismatch paste the hash Nix reports into `npmDepsHash`. frontend compiled offline from `frontend/bun.lock` via a fixed-output
`bun install`) and `nixosModules.default`.
```nix ```nix
imports = [ kisassistant.nixosModules.default ]; imports = [ kisassistant.nixosModules.default ];
services.kisassistant = { services.kisassistant = {
enable = true; enable = true;
package = kisassistant.packages.${pkgs.system}.default; package = kisassistant.packages.${pkgs.system}.default; # required
domain = "chat.kisakay.com"; # nginx + ACME domain = "chat.kisakay.com"; # legacy switch: implies nginx + ACME
enableNginx = true; # explicit; requires domain
passwordFile = "/run/secrets/kisassistant-password"; # agenix/sops-nix passwordFile = "/run/secrets/kisassistant-password"; # agenix/sops-nix
ollamaHost = "http://10.66.66.4:11434"; ollamaHost = "http://10.66.66.4:11434";
extraEnv = { MISTRAL_ENABLED = "true"; }; # optional extraEnv = { MISTRAL_ENABLED = "true"; }; # optional
# secrets for API drivers: pass via environment files / extraEnv, never the store
}; };
security.acme.acceptTerms = true; # required for the ACME/TLS vhost
``` ```
The module sets `NODE_ENV=production`, `CDN_DIR=/var/lib/kisassistant/cdn`
and `DRIVER_DEBUG=false` for the systemd service. `enableNginx` is a
tri-state: `true`/`false` force the bundled reverse proxy on/off, `null`
(the default) keeps the legacy behaviour (nginx iff `domain` is set).
Secrets (`passwordFile`, API keys) must come from files (agenix/sops-nix), never Secrets (`passwordFile`, API keys) must come from files (agenix/sops-nix), never
baked into the Nix store. The service stores SQLite under baked into the Nix store. The service stores SQLite under
`/var/lib/kisassistant/data`; nginx terminates TLS and proxies to `/var/lib/kisassistant/data`; nginx terminates TLS and proxies to
+89 -6
View File
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { api, clearToken, getToken } from "./lib/api.ts"; import { api, clearToken, getToken } from "./lib/api.ts";
import type { ChatMessage, Conversation, DriverModel, User } from "./lib/types.ts"; import type { Attachment, ChatMessage, Conversation, DriverModel, FilePreview, User } from "./lib/types.ts";
import { Login } from "./components/Login.tsx"; import { Login } from "./components/Login.tsx";
import { Sidebar } from "./components/Sidebar.tsx"; import { Sidebar } from "./components/Sidebar.tsx";
import { Chat } from "./components/Chat.tsx"; import { Chat } from "./components/Chat.tsx";
import { ConvEditDialog, SettingsModal, ShareModal } from "./components/dialogs.tsx"; import { ConvEditDialog, FilePreviewModal, SettingsModal, ShareModal } from "./components/dialogs.tsx";
import { AdminPanel } from "./components/AdminPanel.tsx"; import { AdminPanel } from "./components/AdminPanel.tsx";
import { SharePage } from "./components/SharePage.tsx"; import { SharePage } from "./components/SharePage.tsx";
import { ResetPage } from "./components/ResetPage.tsx";
import { ConfirmDialog } from "./components/ui.tsx"; import { ConfirmDialog } from "./components/ui.tsx";
function shareIdFromPath(): string | null { function shareIdFromPath(): string | null {
@@ -16,6 +17,12 @@ function shareIdFromPath(): string | null {
// Module-level: the path never changes without a full reload, so hook order stays stable. // Module-level: the path never changes without a full reload, so hook order stays stable.
const SHARE_ID = typeof window !== "undefined" ? shareIdFromPath() : null; const SHARE_ID = typeof window !== "undefined" ? shareIdFromPath() : null;
const RESET_TOKEN = typeof window !== "undefined" ? resetTokenFromPath() : null;
function resetTokenFromPath(): string | null {
const m = window.location.pathname.match(/^\/reset\/([A-Za-z0-9_-]{6,80})\/?$/);
return m ? m[1]! : null;
}
function applyTheme(theme: string) { function applyTheme(theme: string) {
const root = document.documentElement; const root = document.documentElement;
@@ -27,7 +34,7 @@ function applyTheme(theme: string) {
export function App() { export function App() {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [checking, setChecking] = useState(!SHARE_ID && !!getToken()); const [checking, setChecking] = useState(!SHARE_ID && !RESET_TOKEN && !!getToken());
const [models, setModels] = useState<DriverModel[]>([]); const [models, setModels] = useState<DriverModel[]>([]);
const [model, setModel] = useState(""); const [model, setModel] = useState("");
const [convs, setConvs] = useState<Conversation[]>([]); const [convs, setConvs] = useState<Conversation[]>([]);
@@ -42,8 +49,16 @@ export function App() {
const [deleteConv, setDeleteConv] = useState<Conversation | null>(null); const [deleteConv, setDeleteConv] = useState<Conversation | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [adminOpen, setAdminOpen] = useState(false); const [adminOpen, setAdminOpen] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [attachError, setAttachError] = useState<string | null>(null);
const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
const [previewBusy, setPreviewBusy] = useState(false);
const [ocrAvailable, setOcrAvailable] = useState(false);
// Public share route: no auth needed. SHARE_ID is constant for the page lifetime. // Public routes: no auth needed. IDs are constant for the page lifetime.
if (RESET_TOKEN) {
return <ResetPage token={RESET_TOKEN} />;
}
if (SHARE_ID) { if (SHARE_ID) {
return <SharePage publicId={SHARE_ID} />; return <SharePage publicId={SHARE_ID} />;
} }
@@ -71,7 +86,12 @@ export function App() {
.then(async (res) => { .then(async (res) => {
setUser(res.user); setUser(res.user);
applyTheme(res.user.theme); applyTheme(res.user.theme);
const [m, c] = await Promise.all([api.models().catch(() => ({ models: [] })), api.convs().catch(() => ({ conversations: [] }))]); const [m, c, t] = await Promise.all([
api.models().catch(() => ({ models: [] })),
api.convs().catch(() => ({ conversations: [] })),
api.tools().catch(() => ({ tools: [] as { name: string; available: boolean }[] })),
]);
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available));
setModels(m.models); setModels(m.models);
if (m.models.length > 0) setModel(m.models[0]!.id); if (m.models.length > 0) setModel(m.models[0]!.id);
setConvs(c.conversations); setConvs(c.conversations);
@@ -106,6 +126,7 @@ export function App() {
setModels(m.models); setModels(m.models);
if (m.models.length > 0) setModel(m.models[0]!.id); if (m.models.length > 0) setModel(m.models[0]!.id);
}).catch(() => {}); }).catch(() => {});
api.tools().then((t) => setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available))).catch(() => {});
refreshConvs(); refreshConvs();
} }
@@ -116,6 +137,8 @@ export function App() {
setConvs([]); setConvs([]);
setMessages([]); setMessages([]);
setActiveId(null); setActiveId(null);
setAttachments([]);
setFilePreview(null);
} }
async function newChat() { async function newChat() {
@@ -140,8 +163,14 @@ export function App() {
} }
} }
const userMsg: ChatMessage = { role: "user", content: text }; const userMsg: ChatMessage = { role: "user", content: text };
// Attachments travel as reviewed text blocks appended to the message.
const blocks = attachments.map((a) =>
`[attached ${a.kind === "ocr" ? "image transcription" : "text file"}: ${a.name}]\n\`\`\`text\n${a.text}\n\`\`\``,
);
if (blocks.length > 0) userMsg.content = [text, ...blocks].filter(Boolean).join("\n\n");
const history = [...messages, userMsg]; const history = [...messages, userMsg];
setMessages(history); setMessages(history);
setAttachments([]);
setSending(true); setSending(true);
setStreaming(""); setStreaming("");
let full = ""; let full = "";
@@ -161,8 +190,51 @@ export function App() {
} }
} }
async function removeConv(c: Conversation) { /** Platform-tools upload flow: image -> OCR, text -> CDN, both previewed before attach. */
async function handlePickFile(kind: "ocr" | "text", file: File) {
if (!user) return;
setAttachError(null);
if (kind === "ocr" && file.size > 5 * 1024 * 1024) {
setAttachError("Image too large (max 5MB).");
return;
}
if (kind === "text" && file.size > 500 * 1024) {
setAttachError("Text file too large (max 500KB).");
return;
}
setFilePreview({ name: file.name, kind, text: "", truncated: false });
setPreviewBusy(true);
try { try {
if (kind === "ocr") {
const r = await api.ocrImage(file);
setFilePreview({ name: file.name, kind, text: r.text, truncated: r.truncated });
} else {
const key = `${user.id}-${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
const put = await fetch(`/cdn/text/${key}`, {
method: "PUT",
headers: { Authorization: `Bearer ${getToken()}`, "Content-Type": "text/plain" },
body: file,
});
const pdata = (await put.json().catch(() => ({}))) as { error?: string; url?: string };
if (!put.ok || !pdata.url) throw new Error(pdata.error || `Upload failed (${put.status})`);
const text = await (await fetch(pdata.url)).text();
setFilePreview({ name: file.name, kind, text, truncated: false });
}
} catch (e) {
setFilePreview(null);
setAttachError(e instanceof Error ? e.message : "Upload failed");
} finally {
setPreviewBusy(false);
}
}
function attachPreviewText(text: string) {
if (!filePreview || !text.trim()) return;
setAttachments((prev) => [...prev, { id: crypto.randomUUID(), name: filePreview.name, kind: filePreview.kind, text }]);
setFilePreview(null);
}
async function removeConv(c: Conversation) { try {
await api.deleteConv(c.id); await api.deleteConv(c.id);
if (activeId === c.id) { if (activeId === c.id) {
setActiveId(null); setActiveId(null);
@@ -218,6 +290,17 @@ export function App() {
sidebarCollapsed={sidebarCollapsed} sidebarCollapsed={sidebarCollapsed}
onExpandSidebar={() => setSidebarCollapsed(false)} onExpandSidebar={() => setSidebarCollapsed(false)}
onOpenNav={() => setMobileNav(true)} onOpenNav={() => setMobileNav(true)}
attachments={attachments}
attachError={attachError}
ocrAvailable={ocrAvailable}
onRemoveAttachment={(id) => setAttachments((prev) => prev.filter((a) => a.id !== id))}
onPickFile={handlePickFile}
/>
<FilePreviewModal
preview={filePreview}
busy={previewBusy}
onClose={() => { if (!previewBusy) setFilePreview(null); }}
onAttach={attachPreviewText}
/> />
<ConvEditDialog <ConvEditDialog
+10 -2
View File
@@ -16,6 +16,7 @@ export function AdminPanel({ open, onClose }: { open: boolean; onClose: () => vo
// create form // create form
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState(""); const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
async function refresh() { async function refresh() {
setBusy(true); setBusy(true);
@@ -43,10 +44,11 @@ export function AdminPanel({ open, onClose }: { open: boolean; onClose: () => vo
e.preventDefault(); e.preventDefault();
setError(""); setError("");
try { try {
const res = await api.adminCreate({ username: username.trim().toLowerCase(), displayName: displayName.trim() || undefined }); const res = await api.adminCreate({ username: username.trim().toLowerCase(), displayName: displayName.trim() || undefined, email: email.trim() || undefined });
setFreshKey({ username: res.user.username, key: res.key }); setFreshKey({ username: res.user.username, key: res.key });
setUsername(""); setUsername("");
setDisplayName(""); setDisplayName("");
setEmail("");
setShowCreate(false); setShowCreate(false);
await refresh(); await refresh();
} catch (err) { } catch (err) {
@@ -103,6 +105,9 @@ export function AdminPanel({ open, onClose }: { open: boolean; onClose: () => vo
<div className="min-w-40 flex-1"> <div className="min-w-40 flex-1">
<Field label="Display name"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Alice" /></Field> <Field label="Display name"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Alice" /></Field>
</div> </div>
<div className="min-w-40 flex-1">
<Field label="Email (recovery)"><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field>
</div>
<Button size="sm" type="submit">Create</Button> <Button size="sm" type="submit">Create</Button>
<Button size="sm" variant="ghost" type="button" onClick={() => setShowCreate(false)}>Cancel</Button> <Button size="sm" variant="ghost" type="button" onClick={() => setShowCreate(false)}>Cancel</Button>
</form> </form>
@@ -148,6 +153,7 @@ export function AdminPanel({ open, onClose }: { open: boolean; onClose: () => vo
function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) { function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) {
const [displayName, setDisplayName] = useState(""); const [displayName, setDisplayName] = useState("");
const [avatarUrl, setAvatarUrl] = useState(""); const [avatarUrl, setAvatarUrl] = useState("");
const [email, setEmail] = useState("");
const [theme, setTheme] = useState("auto"); const [theme, setTheme] = useState("auto");
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -155,6 +161,7 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose
if (user) { if (user) {
setDisplayName(user.displayName); setDisplayName(user.displayName);
setAvatarUrl(user.avatarUrl); setAvatarUrl(user.avatarUrl);
setEmail(user.email);
setTheme(user.theme); setTheme(user.theme);
setError(""); setError("");
} }
@@ -164,7 +171,7 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose
e.preventDefault(); e.preventDefault();
if (!user) return; if (!user) return;
try { try {
await api.adminPatch(user.id, { displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), theme }); await api.adminPatch(user.id, { displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), email: email.trim(), theme });
onSaved(); onSaved();
onClose(); onClose();
} catch (err) { } catch (err) {
@@ -177,6 +184,7 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose
<form onSubmit={save} className="space-y-4"> <form onSubmit={save} className="space-y-4">
<Field label="Display name"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required /></Field> <Field label="Display name"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required /></Field>
<Field label="Avatar URL"><Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" /></Field> <Field label="Avatar URL"><Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" /></Field>
<Field label="Email (recovery)"><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field>
<Field label="Theme"> <Field label="Theme">
<Picker <Picker
ariaLabel="Theme" ariaLabel="Theme"
+83 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { BookOpen, Bot, Cpu, Menu, PanelLeftOpen, SendHorizontal, Share2, User as UserIcon } from "lucide-react"; import { BookOpen, Bot, Cpu, FileText, Menu, PanelLeftOpen, Paperclip, ScanText, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react";
import type { ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts"; import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
import { Avatar, IconButton, LOGO_URL, Logo, Picker, type PickerGroup, Spinner } from "./ui.tsx"; import { Avatar, IconButton, LOGO_URL, Logo, Picker, type PickerGroup, Spinner } from "./ui.tsx";
import { Markdown } from "./Markdown.tsx"; import { Markdown } from "./Markdown.tsx";
import { cn } from "../lib/cn.ts"; import { cn } from "../lib/cn.ts";
@@ -43,6 +43,11 @@ export function Chat({
sidebarCollapsed, sidebarCollapsed,
onExpandSidebar, onExpandSidebar,
onOpenNav, onOpenNav,
attachments,
attachError,
ocrAvailable,
onRemoveAttachment,
onPickFile,
}: { }: {
user: User; user: User;
conv: Conversation | null; conv: Conversation | null;
@@ -57,10 +62,18 @@ export function Chat({
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
onExpandSidebar: () => void; onExpandSidebar: () => void;
onOpenNav: () => void; onOpenNav: () => void;
attachments: Attachment[];
attachError: string | null;
ocrAvailable: boolean;
onRemoveAttachment: (id: string) => void;
onPickFile: (kind: "ocr" | "text", file: File) => void;
}) { }) {
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [attachOpen, setAttachOpen] = useState(false);
const boxRef = useRef<HTMLDivElement>(null); const boxRef = useRef<HTMLDivElement>(null);
const taRef = useRef<HTMLTextAreaElement>(null); const taRef = useRef<HTMLTextAreaElement>(null);
const imgRef = useRef<HTMLInputElement>(null);
const txtRef = useRef<HTMLInputElement>(null);
const modelGroups: PickerGroup[] = (() => { const modelGroups: PickerGroup[] = (() => {
const byDriver = new Map<string, { value: string; label: string; hint: string }[]>(); const byDriver = new Map<string, { value: string; label: string; hint: string }[]>();
@@ -162,7 +175,73 @@ export function Chat({
</div> </div>
<div className="px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] sm:px-8"> <div className="px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] sm:px-8">
<form onSubmit={submit} className="mx-auto flex w-full max-w-3xl items-end gap-2 rounded-[1.75rem] border border-stone-200 bg-white p-2 pl-5 shadow-lg dark:border-zinc-700 dark:bg-zinc-900"> <div className="mx-auto w-full max-w-3xl">
{attachError && (
<p className="mb-2 rounded-2xl bg-red-500/10 px-4 py-2 text-sm text-red-600 dark:text-red-400" role="alert">{attachError}</p>
)}
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((a) => (
<span key={a.id} className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-emerald-600/30 bg-emerald-600/10 py-1 pl-3 pr-1.5 text-xs font-medium dark:border-emerald-500/30 dark:bg-emerald-500/10">
{a.kind === "ocr" ? <ScanText size={13} /> : <FileText size={13} />}
<span className="max-w-40 truncate">{a.name}</span>
<button onClick={() => onRemoveAttachment(a.id)} aria-label={`Remove ${a.name}`} className="rounded-full p-1 transition hover:bg-black/10 dark:hover:bg-white/10">
<X size={13} />
</button>
</span>
))}
</div>
)}
</div>
<div className="relative mx-auto w-full max-w-3xl">
{attachOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setAttachOpen(false)} />
<div className="absolute bottom-full left-0 z-50 mb-2 w-80 max-w-[calc(100vw-2rem)] rounded-3xl border border-stone-200 bg-white p-2 shadow-2xl dark:border-zinc-700 dark:bg-zinc-900">
<button
onClick={() => { setAttachOpen(false); imgRef.current?.click(); }}
disabled={!ocrAvailable}
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left transition hover:bg-stone-100 disabled:opacity-50 dark:hover:bg-zinc-800"
>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-emerald-600/10 text-emerald-600 dark:text-emerald-400">
<ScanText size={16} />
</span>
<span>
<span className="block text-sm font-medium">Transcribe image {ocrAvailable ? "" : "(unavailable)"}</span>
<span className="block text-xs opacity-60">JPG, PNG, WEBP — OCR on the server, you review the text first</span>
</span>
</button>
<button
onClick={() => { setAttachOpen(false); txtRef.current?.click(); }}
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left transition hover:bg-stone-100 dark:hover:bg-zinc-800"
>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-emerald-600/10 text-emerald-600 dark:text-emerald-400">
<FileText size={16} />
</span>
<span>
<span className="block text-sm font-medium">Attach text file</span>
<span className="block text-xs opacity-60">TXT or Markdown, max 500KB</span>
</span>
</button>
<p className="px-3 pb-1.5 pt-2 text-xs opacity-60">
Uploads go through platform tools only. Images are sent to the model as reviewed text — never as raw images.
</p>
</div>
</>
)}
</div>
<form onSubmit={submit} className="mx-auto flex w-full max-w-3xl items-end gap-2 rounded-[1.75rem] border border-stone-200 bg-white p-2 pl-2 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
<button
type="button"
onClick={() => setAttachOpen((o) => !o)}
aria-label="Attach a file"
title="Attach a file"
className="grid h-10 w-10 shrink-0 place-items-center rounded-full transition hover:bg-stone-100 active:scale-95 dark:hover:bg-zinc-800"
>
<Paperclip size={17} className="opacity-70" />
</button>
<input ref={imgRef} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) onPickFile("ocr", f); e.target.value = ""; }} />
<input ref={txtRef} type="file" accept=".txt,.md,text/plain,text/markdown" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) onPickFile("text", f); e.target.value = ""; }} />
<textarea <textarea
ref={taRef} ref={taRef}
rows={1} rows={1}
@@ -180,7 +259,7 @@ export function Chat({
/> />
<button <button
type="submit" type="submit"
disabled={!draft.trim() || sending} disabled={(!draft.trim() && attachments.length === 0) || sending}
aria-label="Send" aria-label="Send"
className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-emerald-600 text-white shadow transition hover:bg-emerald-500 active:scale-95 disabled:opacity-40 dark:bg-emerald-500 dark:text-zinc-950 dark:hover:bg-emerald-400" className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-emerald-600 text-white shadow transition hover:bg-emerald-500 active:scale-95 disabled:opacity-40 dark:bg-emerald-500 dark:text-zinc-950 dark:hover:bg-emerald-400"
> >
+18 -1
View File
@@ -1,8 +1,9 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { ArrowRight, KeyRound, ShieldCheck, User, Zap } from "lucide-react"; import { ArrowRight, KeyRound, ShieldCheck, User, Zap } from "lucide-react";
import { api, setToken } from "../lib/api.ts"; import { api, setToken } from "../lib/api.ts";
import type { User as UserType } from "../lib/types.ts"; import type { User as UserType } from "../lib/types.ts";
import { Button, Input, Logo, Spinner } from "./ui.tsx"; import { Button, Input, Logo, Spinner } from "./ui.tsx";
import { RecoverDialog } from "./dialogs.tsx";
const PERKS = [ const PERKS = [
{ icon: ShieldCheck, text: "Private by design — no cookies, no tracking" }, { icon: ShieldCheck, text: "Private by design — no cookies, no tracking" },
@@ -14,6 +15,16 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
const [key, setKey] = useState(""); const [key, setKey] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [recoverOpen, setRecoverOpen] = useState(false);
const [recoveryOn, setRecoveryOn] = useState(false);
const [recoveryFrom, setRecoveryFrom] = useState<string | undefined>(undefined);
useEffect(() => {
api.methods().then((m) => {
setRecoveryOn(m.recovery);
setRecoveryFrom(m.from);
}).catch(() => {});
}, []);
async function submit(e: React.FormEvent) { async function submit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -78,6 +89,12 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
{busy ? <Spinner /> : (<>Unlock <ArrowRight size={17} /></>)} {busy ? <Spinner /> : (<>Unlock <ArrowRight size={17} /></>)}
</Button> </Button>
</form> </form>
{recoveryOn && (
<button onClick={() => setRecoverOpen(true)} className="mt-3 w-full text-center text-sm opacity-60 transition hover:opacity-100 hover:underline">
Forgot your access key?
</button>
)}
<RecoverDialog open={recoverOpen} onClose={() => setRecoverOpen(false)} from={recoveryFrom} />
<ul className="mt-6 space-y-2 border-t border-stone-200/70 pt-5 dark:border-zinc-800"> <ul className="mt-6 space-y-2 border-t border-stone-200/70 pt-5 dark:border-zinc-800">
{PERKS.map((p) => ( {PERKS.map((p) => (
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useState } from "react";
import { KeyRound, MailWarning, Sparkles } from "lucide-react";
import { api } from "../lib/api.ts";
import { Button, CopyButton, Logo, Spinner } from "./ui.tsx";
type State =
| { kind: "loading" }
| { kind: "invalid" }
| { kind: "ready"; username: string }
| { kind: "working" }
| { kind: "done"; key: string }
| { kind: "error"; message: string };
/** Public one-time key reset page: /reset/:token */
export function ResetPage({ token }: { token: string }) {
const [state, setState] = useState<State>({ kind: "loading" });
useEffect(() => {
api.resetCheck(token)
.then((r) => setState({ kind: "ready", username: r.username }))
.catch(() => setState({ kind: "invalid" }));
}, [token]);
async function consume() {
setState({ kind: "working" });
try {
const r = await api.resetConsume(token);
setState({ kind: "done", key: r.key });
} catch (err) {
setState({ kind: "error", message: err instanceof Error ? err.message : "Reset failed" });
}
}
return (
<div className="grid min-h-full place-items-center bg-gradient-to-br from-stone-100 via-stone-50 to-emerald-100/60 p-6 dark:from-zinc-950 dark:via-zinc-950 dark:to-emerald-950/40">
<div className="w-full max-w-sm rounded-[2rem] border border-white/60 bg-white/80 p-8 text-center shadow-2xl backdrop-blur-xl dark:border-zinc-700/60 dark:bg-zinc-900/80">
<Logo size={56} className="mx-auto mb-4 rounded-[1.2rem] shadow-lg" />
{state.kind === "loading" && <p className="flex items-center justify-center gap-2 text-sm opacity-60"><Spinner size={15} /> Checking link…</p>}
{state.kind === "invalid" && (
<div className="space-y-3">
<p className="flex items-center justify-center gap-2 font-semibold"><MailWarning size={18} className="text-red-500" /> Link invalid</p>
<p className="text-sm opacity-70">This reset link is unknown, already used, or expired. Request a new one from the login page.</p>
<Button size="sm" onClick={() => (window.location.href = "/")}>Back to login</Button>
</div>
)}
{state.kind === "ready" && (
<div className="space-y-4">
<h1 className="text-xl font-bold">Reset key for @{state.username}?</h1>
<p className="text-sm opacity-70">This issues a fresh access key immediately. Your old key stops working.</p>
<Button variant="gradient" size="lg" className="w-full" onClick={consume}>
<KeyRound size={16} /> Issue new key
</Button>
</div>
)}
{state.kind === "working" && <p className="flex items-center justify-center gap-2 text-sm opacity-60"><Spinner size={15} /> Issuing…</p>}
{state.kind === "done" && (
<div className="space-y-4">
<h1 className="flex items-center justify-center gap-2 text-xl font-bold"><Sparkles size={18} className="text-emerald-500" /> New key ready</h1>
<p className="text-sm opacity-70">Copy it now — it won't be shown again. Then log in with it.</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-2xl bg-stone-100 px-3 py-2.5 text-sm dark:bg-zinc-800">{state.key}</code>
<CopyButton text={state.key} />
</div>
<Button size="lg" className="w-full" onClick={() => (window.location.href = "/")}>Go to login</Button>
</div>
)}
{state.kind === "error" && (
<div className="space-y-3">
<p className="font-semibold text-red-600 dark:text-red-400">Reset failed</p>
<p className="text-sm opacity-70">{state.message}</p>
<Button size="sm" onClick={() => (window.location.href = "/")}>Back to login</Button>
</div>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -148,7 +148,7 @@ export function Sidebar({
}} }}
onTouchEnd={cancelPress} onTouchEnd={cancelPress}
className={cn( className={cn(
"group flex cursor-pointer items-center gap-2 rounded-2xl px-3 py-2.5 text-sm transition", "group flex cursor-pointer select-none items-center gap-2 rounded-2xl px-3 py-2.5 text-sm transition [-webkit-touch-callout:none]",
c.id === activeId c.id === activeId
? "bg-emerald-600/10 font-medium text-emerald-900 dark:bg-emerald-500/10 dark:text-emerald-100" ? "bg-emerald-600/10 font-medium text-emerald-900 dark:bg-emerald-500/10 dark:text-emerald-100"
: "hover:bg-stone-200/50 dark:hover:bg-zinc-800/70", : "hover:bg-stone-200/50 dark:hover:bg-zinc-800/70",
+141 -3
View File
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Globe, ImagePlus, Link2, Pencil, Settings2, Tag, Unplug } from "lucide-react"; import { Globe, FileText, ImagePlus, Link2, Mail, Pencil, ScanText, Settings2, Tag, Unplug } from "lucide-react";
import { api } from "../lib/api.ts"; import { api } from "../lib/api.ts";
import { cn } from "../lib/cn.ts"; import { cn } from "../lib/cn.ts";
import type { Conversation, User } from "../lib/types.ts"; import type { Conversation, FilePreview, User } from "../lib/types.ts";
import { Avatar, Button, CopyButton, Field, Input, Modal, Picker, Spinner } from "./ui.tsx"; import { Avatar, Button, CopyButton, Field, Input, Modal, Picker, Spinner } from "./ui.tsx";
/* ---------- Rename + topic ---------- */ /* ---------- Rename + topic ---------- */
@@ -67,6 +67,139 @@ export function ConvEditDialog({
); );
} }
/* ---------- Key recovery ---------- */
export function RecoverDialog({ open, onClose, from }: { open: boolean; onClose: () => void; from?: string }) {
const [username, setUsername] = useState("");
const [sent, setSent] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (open) {
setUsername("");
setSent(false);
setError("");
}
}, [open ]);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (busy) return;
setBusy(true);
setError("");
try {
await api.recover(username.trim().toLowerCase());
setSent(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Request failed");
} finally {
setBusy(false);
}
}
return (
<Modal open={open} onClose={onClose} title="Recover access key" icon={Mail}>
{sent ? (
<div className="space-y-4">
<p className="text-sm opacity-80">
If <strong>@{username.trim().toLowerCase() || "…"}</strong> exists and has a recovery
email, a reset link is on its way{from ? <> from <strong>{from}</strong></> : ""} (valid 60 minutes, single use).
</p>
<p className="rounded-2xl bg-amber-500/10 px-4 py-2.5 text-sm opacity-90 dark:bg-amber-500/10">
Check your inbox — and your spam folder, the message may have landed there.
</p>
<p className="text-sm opacity-70">
Nothing arrives? Then no recovery email is set on your account and the
reset is not possible — please contact the site administrator.
</p>
<div className="flex justify-end">
<Button size="sm" onClick={onClose}>Done</Button>
</div>
</div>
) : (
<form onSubmit={submit} className="space-y-4">
<p className="text-sm opacity-70">
Enter your username. You'll receive a link that issues a fresh key
(the old one stops working).
</p>
<Field label="Username">
<Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" required />
</Field>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" type="button" onClick={onClose}>Cancel</Button>
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : "Send reset link"}</Button>
</div>
</form>
)}
</Modal>
);
}
/* ---------- File preview (pre-transcription review) ---------- */
export function FilePreviewModal({
preview,
busy,
onClose,
onAttach,
}: {
preview: FilePreview | null;
busy: boolean;
onClose: () => void;
onAttach: (text: string) => void;
}) {
const [text, setText] = useState("");
useEffect(() => {
if (preview) setText(preview.text);
}, [preview]);
const isOcr = preview?.kind === "ocr";
return (
<Modal
open={preview !== null}
onClose={onClose}
title={isOcr ? "Review transcription" : "Review text attachment"}
icon={isOcr ? ScanText : FileText}
wide
>
{busy ? (
<p className="flex items-center gap-2 py-8 text-sm opacity-70">
<Spinner size={16} />
{isOcr ? "Transcribing the image on the server (OCR)…" : "Uploading through platform tools…"}
</p>
) : (
<div className="space-y-3">
<p className="rounded-2xl bg-emerald-600/10 px-4 py-2.5 text-sm opacity-90">
{isOcr
? <>The image was transcribed to text on the server — <strong>the model receives this text, never the image</strong>. Fix any reading mistakes below, then attach.</>
: <>The file was uploaded through platform tools. <strong>Its text content is attached to your message.</strong> Edit below if needed, then attach.</>}
</p>
{preview?.truncated && (
<p className="rounded-2xl bg-amber-500/10 px-4 py-2.5 text-sm">Transcription truncated to the server limit — the end is missing.</p>
)}
<textarea
rows={10}
value={text}
onChange={(e) => setText(e.target.value)}
className="w-full resize-y rounded-2xl border border-stone-200 bg-stone-50 p-4 font-mono text-[13px] leading-relaxed outline-none focus:border-emerald-500 dark:border-zinc-700 dark:bg-zinc-800"
/>
<div className="flex items-center justify-between">
<span className="text-xs opacity-60">{preview?.name} · {text.length.toLocaleString()} chars</span>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={onClose}>Cancel</Button>
<Button size="sm" onClick={() => onAttach(text)} disabled={!text.trim()}>Attach to message</Button>
</div>
</div>
</div>
)}
</Modal>
);
}
/* ---------- Share ---------- */ /* ---------- Share ---------- */
export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClose: () => void }) { export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClose: () => void }) {
@@ -128,6 +261,7 @@ export function SettingsModal({
}) { }) {
const [displayName, setDisplayName] = useState(""); const [displayName, setDisplayName] = useState("");
const [avatarUrl, setAvatarUrl] = useState(""); const [avatarUrl, setAvatarUrl] = useState("");
const [email, setEmail] = useState("");
const [theme, setTheme] = useState("auto"); const [theme, setTheme] = useState("auto");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -140,6 +274,7 @@ export function SettingsModal({
if (user) { if (user) {
setDisplayName(user.displayName); setDisplayName(user.displayName);
setAvatarUrl(user.avatarUrl); setAvatarUrl(user.avatarUrl);
setEmail(user.email);
setTheme(user.theme); setTheme(user.theme);
setError(""); setError("");
setUploadError(""); setUploadError("");
@@ -174,7 +309,7 @@ export function SettingsModal({
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
const res = await api.updateMe({ displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), theme }); const res = await api.updateMe({ displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), email: email.trim(), theme });
onSaved(res.user); onSaved(res.user);
onClose(); onClose();
} catch (err) { } catch (err) {
@@ -244,6 +379,9 @@ export function SettingsModal({
<Field label="Avatar URL" hint="Host an image on catbox.moe (or anywhere) and paste the direct link here. Empty = initials."> <Field label="Avatar URL" hint="Host an image on catbox.moe (or anywhere) and paste the direct link here. Empty = initials.">
<Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" inputMode="url" /> <Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" inputMode="url" />
</Field> </Field>
<Field label="Recovery email" hint="Optional. Used only to send you a fresh access key if you lose it.">
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" />
</Field>
<Field label="Theme"> <Field label="Theme">
<Picker <Picker
ariaLabel="Theme" ariaLabel="Theme"
+10
View File
@@ -5,9 +5,19 @@
html, body, #root { html, body, #root {
height: 100%; height: 100%;
} }
/* Mobile browser chrome (URL bar) shrinks the visible area — dynamic
viewport units keep the app glued to the real screen height. */
@supports (height: 100dvh) {
html, body, #root {
height: 100dvh;
}
}
body { body {
@apply bg-stone-100 text-stone-900 antialiased dark:bg-zinc-950 dark:text-zinc-100; @apply bg-stone-100 text-stone-900 antialiased dark:bg-zinc-950 dark:text-zinc-100;
/* Kill the 300ms tap delay, keep gestures app-like. */
touch-action: manipulation;
overscroll-behavior-y: none;
} }
/* slim scrollbars */ /* slim scrollbars */
+28 -3
View File
@@ -46,16 +46,22 @@ export const api = {
verify: () => req<{ ok: boolean; user: User }>("/api/auth/verify"), verify: () => req<{ ok: boolean; user: User }>("/api/auth/verify"),
logout: () => req<{ ok: boolean }>("/api/auth/logout", { method: "POST" }).catch(() => ({ ok: true as const })), logout: () => req<{ ok: boolean }>("/api/auth/logout", { method: "POST" }).catch(() => ({ ok: true as const })),
methods: () => req<{ recovery: boolean; from?: string }>("/api/auth/methods", {}, false),
recover: (username: string) =>
req<{ ok: boolean }>("/api/auth/recover", { method: "POST", body: JSON.stringify({ username }) }, false),
resetCheck: (token: string) => req<{ ok: boolean; username: string }>(`/api/auth/reset/${token}`, {}, false),
resetConsume: (token: string) => req<{ key: string }>(`/api/auth/reset/${token}`, { method: "POST" }, false),
me: () => req<{ user: User }>("/api/me"), me: () => req<{ user: User }>("/api/me"),
updateMe: (patch: { displayName?: string; avatarUrl?: string; theme?: string }) => updateMe: (patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
req<{ user: User }>("/api/me", { method: "PATCH", body: JSON.stringify(patch) }), req<{ user: User }>("/api/me", { method: "PATCH", body: JSON.stringify(patch) }),
adminList: () => req<{ users: User[] }>("/api/admin/users"), adminList: () => req<{ users: User[] }>("/api/admin/users"),
adminCreate: (u: { username: string; displayName?: string; avatarUrl?: string; theme?: string }) => adminCreate: (u: { username: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
req<{ user: User; key: string }>("/api/admin/users", { method: "POST", body: JSON.stringify(u) }), req<{ user: User; key: string }>("/api/admin/users", { method: "POST", body: JSON.stringify(u) }),
adminDelete: (id: string) => req<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }), adminDelete: (id: string) => req<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
adminRegenerate: (id: string) => req<{ key: string }>(`/api/admin/users/${id}/regenerate`, { method: "POST" }), adminRegenerate: (id: string) => req<{ key: string }>(`/api/admin/users/${id}/regenerate`, { method: "POST" }),
adminPatch: (id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string }) => adminPatch: (id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
convs: () => req<{ conversations: Conversation[] }>("/api/conversations"), convs: () => req<{ conversations: Conversation[] }>("/api/conversations"),
@@ -73,6 +79,25 @@ export const api = {
models: () => req<{ models: DriverModel[] }>("/api/models"), models: () => req<{ models: DriverModel[] }>("/api/models"),
tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[] }>("/api/tools"),
/** Send an image to the platform OCR tool. Returns editable text (never the raw image). */
ocrImage: async (file: File): Promise<{ text: string; truncated: boolean; chars: number }> => {
const res = await fetch("/api/tools/ocr", {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}`, "Content-Type": file.type || "application/octet-stream" },
body: file,
});
if (res.status === 401) {
clearToken();
window.location.reload();
throw new ApiError(401, "Session expired.");
}
const data = (await res.json().catch(() => ({}))) as { error?: string; text?: string; truncated?: boolean; chars?: number };
if (!res.ok) throw new ApiError(res.status, data.error || `OCR failed (${res.status})`);
return { text: data.text ?? "", truncated: !!data.truncated, chars: data.chars ?? 0 };
},
/** Avatar upload to the local CDN. Returns the public /cdn/… URL. */ /** Avatar upload to the local CDN. Returns the public /cdn/… URL. */
uploadAvatar: async (userId: string, file: File): Promise<{ url: string }> => { uploadAvatar: async (userId: string, file: File): Promise<{ url: string }> => {
const res = await fetch(`/cdn/avatar/${userId}`, { const res = await fetch(`/cdn/avatar/${userId}`, {
+15
View File
@@ -4,6 +4,7 @@ export interface User {
displayName: string; displayName: string;
avatarUrl: string; avatarUrl: string;
theme: string; theme: string;
email: string;
createdAt: number; createdAt: number;
isAdmin: boolean; isAdmin: boolean;
} }
@@ -30,6 +31,20 @@ export interface DriverModel {
label: string; label: string;
} }
export interface Attachment {
id: string;
name: string;
kind: "ocr" | "text";
text: string;
}
export interface FilePreview {
name: string;
kind: "ocr" | "text";
text: string;
truncated: boolean;
}
export interface SharedChat { export interface SharedChat {
title: string; title: string;
topic: string; topic: string;
+2
View File
@@ -93,6 +93,8 @@ in {
} // lib.optionalAttrs (cfg.passwordFile != null) { } // lib.optionalAttrs (cfg.passwordFile != null) {
LoadCredential = "app-password:${cfg.passwordFile}"; LoadCredential = "app-password:${cfg.passwordFile}";
}; };
# tesseract binary for the OCR platform tool (see TESSERACT_BIN).
path = [ pkgs.tesseract ];
script = '' script = ''
${lib.optionalString (cfg.passwordFile != null) '' ${lib.optionalString (cfg.passwordFile != null) ''
export APP_PASSWORD="$(cat "$CREDENTIALS_DIRECTORY/app-password")" export APP_PASSWORD="$(cat "$CREDENTIALS_DIRECTORY/app-password")"
+5 -2
View File
@@ -22,8 +22,11 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"typecheck:frontend": "bun --cwd frontend tsc --noEmit" "typecheck:frontend": "bun --cwd frontend tsc --noEmit"
}, },
"dependencies": {}, "dependencies": {
"nodemailer": "^10.0.10"
},
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest",
"@types/nodemailer": "^8.0.2"
} }
} }
+1 -1
View File
@@ -1 +1 @@
/nix/store/1xb7lpfvycb2yvk34x59mqx6hn95pnx5-kisassistant-0.2.0 /nix/store/l149xpcs2ywk0kl4l2lk0avddfviarnq-kisassistant-0.2.0
+54 -9
View File
@@ -14,27 +14,34 @@ import { config } from "./config.ts";
// the client. Everything else is systematically blacklisted. // the client. Everything else is systematically blacklisted.
export type ImageExt = "jpg" | "png" | "webp"; export type ImageExt = "jpg" | "png" | "webp";
export type TextExt = "txt";
export type CdnExt = ImageExt | TextExt;
export interface CdnNamespace { export interface CdnNamespace {
maxBytes: number; maxBytes: number;
windowMs: number; windowMs: number;
maxUploads: number; maxUploads: number;
/** Allowed content kinds for this namespace. */
kinds: ("image" | "text")[];
} }
export const CDN_NAMESPACES: Record<string, CdnNamespace> = { export const CDN_NAMESPACES: Record<string, CdnNamespace> = {
// Avatar rules (spec): 5 MB max, 5 changes max per 2 h per account. // Avatar rules (spec): 5 MB max, 5 changes max per 2 h per account.
avatar: { maxBytes: 5 * 1024 * 1024, windowMs: 2 * 3600_000, maxUploads: 5 }, avatar: { maxBytes: 5 * 1024 * 1024, windowMs: 2 * 3600_000, maxUploads: 5, kinds: ["image"] },
// Chat text attachments (spec): 500 KB max plain-text files.
text: { maxBytes: 500 * 1024, windowMs: 3600_000, maxUploads: 30, kinds: ["text"] },
}; };
export const CDN_EXTS: ImageExt[] = ["jpg", "png", "webp"]; export const CDN_EXTS: CdnExt[] = ["jpg", "png", "webp", "txt"];
const CDN_MIME: Record<ImageExt, string> = { const CDN_MIME: Record<CdnExt, string> = {
jpg: "image/jpeg", jpg: "image/jpeg",
png: "image/png", png: "image/png",
webp: "image/webp", webp: "image/webp",
txt: "text/plain; charset=utf-8",
}; };
export function cdnMime(ext: ImageExt): string { export function cdnMime(ext: CdnExt): string {
return CDN_MIME[ext]; return CDN_MIME[ext];
} }
@@ -46,7 +53,7 @@ export function validKey(key: string): boolean {
return /^[A-Za-z0-9_-]{1,64}$/.test(key); return /^[A-Za-z0-9_-]{1,64}$/.test(key);
} }
export function validExt(ext: string): ext is ImageExt { export function validExt(ext: string): ext is CdnExt {
return (CDN_EXTS as string[]).includes(ext); return (CDN_EXTS as string[]).includes(ext);
} }
@@ -54,11 +61,11 @@ function cdnDir(): string {
return config.cdnDir; return config.cdnDir;
} }
export function cdnPath(ns: string, key: string, ext: ImageExt): string { export function cdnPath(ns: string, key: string, ext: CdnExt): string {
return join(cdnDir(), ns, `${key}.${ext}`); return join(cdnDir(), ns, `${key}.${ext}`);
} }
export async function cdnFile(ns: string, key: string, ext: ImageExt): Promise<{ path: string; exists: boolean }> { export async function cdnFile(ns: string, key: string, ext: CdnExt): Promise<{ path: string; exists: boolean }> {
const path = cdnPath(ns, key, ext); const path = cdnPath(ns, key, ext);
return { path, exists: await Bun.file(path).exists() }; return { path, exists: await Bun.file(path).exists() };
} }
@@ -89,7 +96,45 @@ export function detectImageType(buf: Uint8Array): ImageExt | null {
return null; return null;
} }
/** Remove every stored variant for a key (used before replacing an avatar). */ /**
* Detect plain-text content: strict UTF-8 decode of a sample plus no NUL
* bytes anywhere (binary files are rejected, not just mislabeled ones).
*/
export function isTextContent(buf: Uint8Array): boolean {
if (buf.length === 0) return false;
for (let i = 0; i < buf.length; i++) {
if (buf[i] === 0) return false;
}
try {
new TextDecoder("utf-8", { fatal: true }).decode(buf.subarray(0, Math.min(buf.length, 65536)));
} catch {
return false;
}
return true;
}
/**
* Detect the stored extension for a namespace from content (never the client).
* Returns null when the content is not allowed in this namespace.
*/
export function detectCdnType(ns: string, buf: Uint8Array): CdnExt | null {
const kinds = CDN_NAMESPACES[ns]?.kinds ?? [];
if (kinds.includes("image")) {
const img = detectImageType(buf);
if (img) return img;
}
if (kinds.includes("text") && isTextContent(buf)) return "txt";
return null;
}
/** Human description of what a namespace accepts (for 415 errors). */
export function namespaceAccepts(ns: string): string {
const kinds = CDN_NAMESPACES[ns]?.kinds ?? [];
const parts: string[] = [];
if (kinds.includes("image")) parts.push("jpg/png/webp images");
if (kinds.includes("text")) parts.push("plain-text files");
return parts.join(" and ") || "nothing";
}
export async function removeCdnVariants(ns: string, key: string): Promise<void> { export async function removeCdnVariants(ns: string, key: string): Promise<void> {
const { unlink } = await import("node:fs/promises"); const { unlink } = await import("node:fs/promises");
for (const ext of CDN_EXTS) { for (const ext of CDN_EXTS) {
@@ -101,7 +146,7 @@ export async function removeCdnVariants(ns: string, key: string): Promise<void>
} }
} }
export async function writeCdnFile(ns: string, key: string, ext: ImageExt, data: Uint8Array): Promise<string> { export async function writeCdnFile(ns: string, key: string, ext: CdnExt, data: Uint8Array): Promise<string> {
await mkdir(join(cdnDir(), ns), { recursive: true }); await mkdir(join(cdnDir(), ns), { recursive: true });
await removeCdnVariants(ns, key); await removeCdnVariants(ns, key);
await Bun.write(cdnPath(ns, key, ext), data); await Bun.write(cdnPath(ns, key, ext), data);
+14
View File
@@ -54,6 +54,20 @@ export const config = {
// Dev default is a folder next to the codebase; NixOS module points it // Dev default is a folder next to the codebase; NixOS module points it
// under the service state dir. // under the service state dir.
cdnDir: env("CDN_DIR", "./cdn"), cdnDir: env("CDN_DIR", "./cdn"),
// OCR tool (tesseract binary).
ocrLang: env("OCR_LANG", "eng"),
ocrMaxChars: envInt("OCR_MAX_CHARS", 100_000),
// Key recovery via email (optional — empty SMTP_HOST disables it).
smtpHost: env("SMTP_HOST", ""),
smtpPort: envInt("SMTP_PORT", 587),
smtpSecure: envBool("SMTP_SECURE", false), // true = implicit TLS (port 465)
smtpUser: env("SMTP_USER", ""),
smtpPass: env("SMTP_PASS", ""),
smtpFrom: env("SMTP_FROM", "KisAssistant <[email protected]>"),
appUrl: env("APP_URL", "http://localhost:3000").replace(/\/$/, ""),
resetTtlMs: envInt("RESET_TTL_MIN", 60) * 60_000,
}; };
export function assertConfig(): void { export function assertConfig(): void {
+62 -4
View File
@@ -12,6 +12,7 @@ export interface UserRow {
display_name: string; display_name: string;
avatar_url: string; avatar_url: string;
theme: string; theme: string;
email: string;
key_hash: string; key_hash: string;
created_at: number; created_at: number;
} }
@@ -47,6 +48,7 @@ export interface PublicUser {
displayName: string; displayName: string;
avatarUrl: string; avatarUrl: string;
theme: string; theme: string;
email: string;
createdAt: number; createdAt: number;
isAdmin: boolean; isAdmin: boolean;
} }
@@ -100,7 +102,18 @@ export function getDb(): Database {
public_id TEXT UNIQUE NOT NULL, public_id TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS resets(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
`); `);
// Email column added after the initial schema — keep idempotent.
const cols = db.query("PRAGMA table_info(users)").all() as { name: string }[];
if (!cols.some((c) => c.name === "email")) {
db.exec("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''");
}
// Admin pseudo-account row (auth still goes through APP_PASSWORD from .env). // Admin pseudo-account row (auth still goes through APP_PASSWORD from .env).
const admin = db.query("SELECT id FROM users WHERE username = 'admin'").get() as { id: string } | null; const admin = db.query("SELECT id FROM users WHERE username = 'admin'").get() as { id: string } | null;
if (!admin) { if (!admin) {
@@ -118,6 +131,7 @@ export function toPublicUser(u: UserRow): PublicUser {
displayName: u.display_name, displayName: u.display_name,
avatarUrl: u.avatar_url, avatarUrl: u.avatar_url,
theme: u.theme, theme: u.theme,
email: u.email ?? "",
createdAt: u.created_at, createdAt: u.created_at,
isAdmin: u.username === "admin", isAdmin: u.username === "admin",
}; };
@@ -137,7 +151,7 @@ export function listUsers(): UserRow[] {
return getDb().query("SELECT * FROM users ORDER BY created_at ASC").all() as UserRow[]; return getDb().query("SELECT * FROM users ORDER BY created_at ASC").all() as UserRow[];
} }
export function createUser(opts: { username: string; displayName: string; avatarUrl: string; theme: string; keyHash: string }): UserRow { export function createUser(opts: { username: string; displayName: string; avatarUrl: string; theme: string; email: string; keyHash: string }): UserRow {
const d = getDb(); const d = getDb();
const row: UserRow = { const row: UserRow = {
id: randomUUID(), id: randomUUID(),
@@ -145,22 +159,24 @@ export function createUser(opts: { username: string; displayName: string; avatar
display_name: opts.displayName, display_name: opts.displayName,
avatar_url: opts.avatarUrl, avatar_url: opts.avatarUrl,
theme: opts.theme, theme: opts.theme,
email: opts.email,
key_hash: opts.keyHash, key_hash: opts.keyHash,
created_at: Date.now(), created_at: Date.now(),
}; };
d.query("INSERT INTO users (id, username, display_name, avatar_url, theme, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)").run( d.query("INSERT INTO users (id, username, display_name, avatar_url, theme, email, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
row.id, row.username, row.display_name, row.avatar_url, row.theme, row.key_hash, row.created_at, row.id, row.username, row.display_name, row.avatar_url, row.theme, row.email, row.key_hash, row.created_at,
); );
return row; return row;
} }
export function updateUser(id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; keyHash?: string }): UserRow | null { export function updateUser(id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string; keyHash?: string }): UserRow | null {
const d = getDb(); const d = getDb();
const sets: string[] = []; const sets: string[] = [];
const vals: (string | number)[] = []; const vals: (string | number)[] = [];
if (patch.displayName !== undefined) { sets.push("display_name = ?"); vals.push(patch.displayName); } if (patch.displayName !== undefined) { sets.push("display_name = ?"); vals.push(patch.displayName); }
if (patch.avatarUrl !== undefined) { sets.push("avatar_url = ?"); vals.push(patch.avatarUrl); } if (patch.avatarUrl !== undefined) { sets.push("avatar_url = ?"); vals.push(patch.avatarUrl); }
if (patch.theme !== undefined) { sets.push("theme = ?"); vals.push(patch.theme); } if (patch.theme !== undefined) { sets.push("theme = ?"); vals.push(patch.theme); }
if (patch.email !== undefined) { sets.push("email = ?"); vals.push(patch.email); }
if (patch.keyHash !== undefined) { sets.push("key_hash = ?"); vals.push(patch.keyHash); } if (patch.keyHash !== undefined) { sets.push("key_hash = ?"); vals.push(patch.keyHash); }
if (sets.length > 0) { if (sets.length > 0) {
d.query(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`).run(...vals, id); d.query(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`).run(...vals, id);
@@ -181,6 +197,7 @@ export function deleteUser(id: string): void {
}); });
for (const c of convs) txn(c.id); for (const c of convs) txn(c.id);
d.query("DELETE FROM sessions WHERE user_id = ?").run(id); d.query("DELETE FROM sessions WHERE user_id = ?").run(id);
d.query("DELETE FROM resets WHERE user_id = ?").run(id);
d.query("DELETE FROM users WHERE id = ?").run(id); d.query("DELETE FROM users WHERE id = ?").run(id);
} }
@@ -274,6 +291,47 @@ export function getMessages(convId: string): MessageRow[] {
return getDb().query("SELECT * FROM messages WHERE conv_id = ? ORDER BY id ASC LIMIT 500").all(convId) as MessageRow[]; return getDb().query("SELECT * FROM messages WHERE conv_id = ? ORDER BY id ASC LIMIT 500").all(convId) as MessageRow[];
} }
// --- password-recovery resets (one-time, hashed, expiring) ---
export interface ResetRow {
token_hash: string;
user_id: string;
created_at: number;
expires_at: number;
}
export function createReset(tokenHash: string, userId: string, expiresAt: number): void {
const d = getDb();
// One active reset per account: replace any previous one.
d.query("DELETE FROM resets WHERE user_id = ?").run(userId);
d.query("INSERT INTO resets (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(
tokenHash, userId, Date.now(), expiresAt,
);
}
export function peekReset(tokenHash: string): ResetRow | null {
const d = getDb();
const r = d.query("SELECT * FROM resets WHERE token_hash = ?").get(tokenHash) as ResetRow | null;
if (!r) return null;
if (r.expires_at <= Date.now()) {
d.query("DELETE FROM resets WHERE token_hash = ?").run(tokenHash);
return null;
}
return r;
}
/** Consume a reset token (single use). Returns the row, or null if invalid/expired. */
export function consumeReset(tokenHash: string): ResetRow | null {
const r = peekReset(tokenHash);
if (!r) return null;
getDb().query("DELETE FROM resets WHERE token_hash = ?").run(tokenHash);
return r;
}
export function deleteUserResets(userId: string): void {
getDb().query("DELETE FROM resets WHERE user_id = ?").run(userId);
}
// --- public shares --- // --- public shares ---
export function ensureShare(convId: string): string { export function ensureShare(convId: string): string {
+125 -8
View File
@@ -15,7 +15,9 @@ import {
} from "./auth.ts"; } from "./auth.ts";
import { import {
addMessage, addMessage,
consumeReset,
createConversation, createConversation,
createReset,
createUser, createUser,
deleteConversation, deleteConversation,
deleteShare, deleteShare,
@@ -30,6 +32,7 @@ import {
getUserByUsername, getUserByUsername,
listConversations, listConversations,
listUsers, listUsers,
peekReset,
toPublicUser, toPublicUser,
touchConversation, touchConversation,
updateConversation, updateConversation,
@@ -37,11 +40,15 @@ import {
} from "./db.ts"; } from "./db.ts";
import type { ChatMessage } from "./drivers/types.ts"; import type { ChatMessage } from "./drivers/types.ts";
import { DriverRegistry } from "./drivers/registry.ts"; import { DriverRegistry } from "./drivers/registry.ts";
import { createHash, randomBytes } from "node:crypto";
import { mailEnabled, sendRecoveryEmail } from "./mail.ts";
import { import {
CDN_NAMESPACES, CDN_NAMESPACES,
cdnFile, cdnFile,
cdnMime, cdnMime,
detectCdnType,
detectImageType, detectImageType,
namespaceAccepts,
recordUpload, recordUpload,
uploadAllowed, uploadAllowed,
validExt, validExt,
@@ -49,9 +56,12 @@ import {
validNamespace, validNamespace,
writeCdnFile, writeCdnFile,
} from "./cdn.ts"; } from "./cdn.ts";
import { OCR_MAX_BYTES, ocrAllowed, recordOcrJob } from "./tools/ocr.ts";
import { tools } from "./tools/registry.ts";
assertConfig(); assertConfig();
const registry = new DriverRegistry(); const registry = new DriverRegistry();
await tools.init();
function json(data: unknown, status = 200): Response { function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), { return new Response(JSON.stringify(data), {
@@ -117,6 +127,16 @@ function validUsername(v: unknown): string | null {
return t; return t;
} }
/** Empty string (unset) or a plausible email address. */
function validEmail(v: unknown): string | null {
if (v === "" || v === undefined || v === null) return "";
if (typeof v !== "string") return null;
const t = v.trim();
if (t.length > 320) return null;
if (!/^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/.test(t)) return null;
return t;
}
async function readJson(req: Request): Promise<{ ok: boolean; body: Record<string, unknown> }> { async function readJson(req: Request): Promise<{ ok: boolean; body: Record<string, unknown> }> {
try { try {
return { ok: true, body: (await req.json()) as Record<string, unknown> }; return { ok: true, body: (await req.json()) as Record<string, unknown> };
@@ -185,9 +205,11 @@ const server = Bun.serve({
const [, ns, key] = cdnPut as [string, string, string]; const [, ns, key] = cdnPut as [string, string, string];
if (!validNamespace(ns) || !validKey(key)) return json({ error: "unknown namespace or key" }, 404); if (!validNamespace(ns) || !validKey(key)) return json({ error: "unknown namespace or key" }, 404);
const rules = CDN_NAMESPACES[ns]!; const rules = CDN_NAMESPACES[ns]!;
// Avatar ownership: your own account id, unless admin. // Ownership: avatar keys are exactly your account id; text keys must
if (ns === "avatar" && key !== user.id && !user.isAdmin) { // live under your account id prefix. Admin may write for anyone.
return json({ error: "forbidden" }, 403); if (!user.isAdmin) {
if (ns === "avatar" && key !== user.id) return json({ error: "forbidden" }, 403);
if (ns === "text" && !key.startsWith(user.id)) return json({ error: "forbidden" }, 403);
} }
const declared = Number(req.headers.get("content-length") || "0"); const declared = Number(req.headers.get("content-length") || "0");
const maxMb = (rules.maxBytes / (1024 * 1024)).toFixed(0); const maxMb = (rules.maxBytes / (1024 * 1024)).toFixed(0);
@@ -200,9 +222,9 @@ const server = Bun.serve({
} }
if (buf.length < 16) return json({ error: "empty file" }, 400); if (buf.length < 16) return json({ error: "empty file" }, 400);
if (buf.length > rules.maxBytes) return json({ error: `file too large (max ${maxMb}MB)` }, 413); if (buf.length > rules.maxBytes) return json({ error: `file too large (max ${maxMb}MB)` }, 413);
const ext = detectImageType(buf); const ext = detectCdnType(ns, buf);
if (!ext) { if (!ext) {
return json({ error: "unsupported file type: only jpg/png/webp images are accepted (verified by content, not extension)" }, 415); return json({ error: `unsupported file type: this namespace only accepts ${namespaceAccepts(ns)} (verified by content, not extension)` }, 415);
} }
const rl = uploadAllowed(user.id, ns); const rl = uploadAllowed(user.id, ns);
if (!rl.ok) { if (!rl.ok) {
@@ -247,6 +269,53 @@ const server = Bun.serve({
return json({ ok: true }); return json({ ok: true });
} }
// --- key recovery via email (optional — needs SMTP configured) ---
if (path === "/api/auth/methods" && req.method === "GET") {
return json({ recovery: mailEnabled(), from: mailEnabled() ? config.smtpFrom : undefined });
}
if (path === "/api/auth/recover" && req.method === "POST") {
const ip = clientIp(req, server);
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
const { ok, body } = await readJson(req);
// Never enumerate accounts: always answer ok.
if (ok && typeof body.username === "string") {
const name = body.username.trim().toLowerCase();
const target = name === "admin" ? null : getUserByUsername(name);
if (target && target.email && mailEnabled()) {
const token = "rt_" + randomBytes(24).toString("base64url");
createReset(createHash("sha256").update(token).digest("hex"), target.id, Date.now() + config.resetTtlMs);
sendRecoveryEmail(target.email, target.username, `${config.appUrl}/reset/${token}`).catch(() => {});
clearAttempts(ip);
return json({ ok: true });
}
}
recordAttempt(ip);
await Bun.sleep(400);
return json({ ok: true });
}
const resetMatch = path.match(/^\/api\/auth\/reset\/([A-Za-z0-9_-]{6,80})$/);
if (resetMatch) {
const tokenHash = createHash("sha256").update(resetMatch[1]!).digest("hex");
if (req.method === "GET") {
const r = peekReset(tokenHash);
const u = r ? getUserById(r.user_id) : null;
if (!r || !u) return json({ error: "invalid or expired link" }, 404);
return json({ ok: true, username: u.username });
}
if (req.method === "POST") {
const r = consumeReset(tokenHash);
const u = r ? getUserById(r.user_id) : null;
if (!r || !u || u.username === "admin") return json({ error: "invalid or expired link" }, 404);
// Recovery = instant key rotation; the new key is shown exactly once.
const key = newAccessKey();
updateUser(u.id, { keyHash: hashSecret(key) });
deleteUserSessions(u.id);
return json({ key });
}
}
// --- everything below requires auth --- // --- everything below requires auth ---
if (path.startsWith("/api/")) { if (path.startsWith("/api/")) {
const user = requireAuth(req); const user = requireAuth(req);
@@ -259,7 +328,7 @@ const server = Bun.serve({
if (path === "/api/me" && req.method === "PATCH") { if (path === "/api/me" && req.method === "PATCH") {
const { ok, body } = await readJson(req); const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400); if (!ok) return json({ error: "invalid JSON" }, 400);
const patch: { displayName?: string; avatarUrl?: string; theme?: string } = {}; const patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string } = {};
if (body.displayName !== undefined) { if (body.displayName !== undefined) {
const d = cleanStr(body.displayName, 60); const d = cleanStr(body.displayName, 60);
if (d === null) return json({ error: "displayName: 1-60 chars" }, 400); if (d === null) return json({ error: "displayName: 1-60 chars" }, 400);
@@ -274,6 +343,11 @@ const server = Bun.serve({
if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark" }, 400); if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark" }, 400);
patch.theme = body.theme; patch.theme = body.theme;
} }
if (body.email !== undefined) {
const e = validEmail(body.email);
if (e === null) return json({ error: "invalid email address" }, 400);
patch.email = e;
}
const updated = updateUser(user.id, patch); const updated = updateUser(user.id, patch);
if (!updated) return json({ error: "user not found" }, 404); if (!updated) return json({ error: "user not found" }, 404);
return json({ user: toPublicUser(updated) }); return json({ user: toPublicUser(updated) });
@@ -298,8 +372,10 @@ const server = Bun.serve({
if (avatarUrl === null) return json({ error: "avatarUrl must be an http(s) URL or empty" }, 400); if (avatarUrl === null) return json({ error: "avatarUrl must be an http(s) URL or empty" }, 400);
const theme = body.theme === undefined ? "auto" : body.theme; const theme = body.theme === undefined ? "auto" : body.theme;
if (!validTheme(theme)) return json({ error: "theme must be auto|light|dark" }, 400); if (!validTheme(theme)) return json({ error: "theme must be auto|light|dark" }, 400);
const email = body.email === undefined ? "" : validEmail(body.email);
if (email === null) return json({ error: "invalid email address" }, 400);
const key = newAccessKey(); const key = newAccessKey();
const created = createUser({ username, displayName, avatarUrl, theme, keyHash: hashSecret(key) }); const created = createUser({ username, displayName, avatarUrl, theme, email, keyHash: hashSecret(key) });
// Show the raw key exactly once. // Show the raw key exactly once.
return json({ user: toPublicUser(created), key }, 201); return json({ user: toPublicUser(created), key }, 201);
} }
@@ -322,7 +398,7 @@ const server = Bun.serve({
if (req.method === "PATCH" && !adminUserMatch[2]) { if (req.method === "PATCH" && !adminUserMatch[2]) {
const { ok, body } = await readJson(req); const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400); if (!ok) return json({ error: "invalid JSON" }, 400);
const patch: { displayName?: string; avatarUrl?: string; theme?: string } = {}; const patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string } = {};
if (body.displayName !== undefined) { if (body.displayName !== undefined) {
const d = cleanStr(body.displayName, 60); const d = cleanStr(body.displayName, 60);
if (d === null) return json({ error: "displayName: 1-60 chars" }, 400); if (d === null) return json({ error: "displayName: 1-60 chars" }, 400);
@@ -337,6 +413,11 @@ const server = Bun.serve({
if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark" }, 400); if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark" }, 400);
patch.theme = body.theme; patch.theme = body.theme;
} }
if (body.email !== undefined) {
const e = validEmail(body.email);
if (e === null) return json({ error: "invalid email address" }, 400);
patch.email = e;
}
const updated = updateUser(target.id, patch); const updated = updateUser(target.id, patch);
return json({ user: toPublicUser(updated!) }); return json({ user: toPublicUser(updated!) });
} }
@@ -414,6 +495,42 @@ const server = Bun.serve({
return json({ models }); return json({ models });
} }
// --- platform tools (uploads always go through tools) ---
if (path === "/api/tools" && req.method === "GET") {
return json({ tools: tools.list() });
}
if (path === "/api/tools/ocr" && req.method === "POST") {
const ocr = tools.ocr;
if (!ocr.isAvailable()) return json({ error: ocr.unavailableReason() ?? "ocr unavailable" }, 501);
const declared = Number(req.headers.get("content-length") || "0");
if (declared > OCR_MAX_BYTES) return json({ error: "image too large (max 5MB)" }, 413);
let buf: Uint8Array;
try {
buf = new Uint8Array(await req.arrayBuffer());
} catch {
return json({ error: "unreadable body" }, 400);
}
if (buf.length < 16) return json({ error: "empty file" }, 400);
if (buf.length > OCR_MAX_BYTES) return json({ error: "image too large (max 5MB)" }, 413);
if (!detectImageType(buf)) {
return json({ error: "only jpg/png/webp images are accepted (verified by content, not extension)" }, 415);
}
const rl = ocrAllowed(user.id);
if (!rl.ok) {
return json({ error: "ocr rate limited: 20 jobs per hour", retryAfterSec: rl.retryAfterSec }, 429);
}
recordOcrJob(user.id);
try {
const { text, truncated } = await ocr.transcribe(buf);
return json({ text, truncated, chars: text.length });
} catch (e) {
const msg = (e as Error).message;
if (/not available|failed to start/i.test(msg)) return json({ error: msg }, 501);
return json({ error: msg }, 502);
}
}
if (path === "/api/chat" && req.method === "POST") { if (path === "/api/chat" && req.method === "POST") {
const { ok, body } = await readJson(req); const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400); if (!ok) return json({ error: "invalid JSON" }, 400);
+68
View File
@@ -0,0 +1,68 @@
import type { Transporter } from "nodemailer";
import { config } from "./config.ts";
let transporter: Transporter | null = null;
let warned = false;
export function mailEnabled(): boolean {
return config.smtpHost.length > 0;
}
// Lazy import: the backend boots (and serves) fine without node_modules
// installed; SMTP is only needed when actually sending.
async function loadMailer(): Promise<typeof import("nodemailer") | null> {
try {
return await import("nodemailer");
} catch {
if (!warned) {
warned = true;
console.error("[mail] nodemailer not installed — run `bun install` for email support");
}
return null;
}
}
async function getTransporter(): Promise<Transporter | null> {
if (!mailEnabled()) return null;
if (!transporter) {
const mailer = await loadMailer();
if (!mailer) return null;
transporter = mailer.createTransport({
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure,
auth: config.smtpUser ? { user: config.smtpUser, pass: config.smtpPass } : undefined,
});
}
return transporter;
}
/** Send a key-recovery email with a one-time reset link. Fire-and-forget safe. */
export async function sendRecoveryEmail(to: string, username: string, resetUrl: string): Promise<boolean> {
const t = await getTransporter();
if (!t) {
if (!warned) {
warned = true;
console.error("[mail] SMTP_HOST not set — recovery emails disabled");
}
return false;
}
try {
await t.sendMail({
from: config.smtpFrom,
to,
subject: "KisAssistant — recover your access key",
text:
`Hello ${username},\n\n` +
`Someone requested a new access key for your KisAssistant account.\n` +
`Open this link within 60 minutes to get a fresh key (your old one stops working):\n\n` +
`${resetUrl}\n\n` +
`If you did not request this, just ignore this email.\n`,
});
console.log(`[mail] recovery email sent to ${username}`);
return true;
} catch (e) {
console.error("[mail] send failed:", (e as Error).message);
return false;
}
}
+117
View File
@@ -0,0 +1,117 @@
import { config } from "../config.ts";
import { driverLog, fmtMs } from "../drivers/log.ts";
import type { PlatformTool } from "./types.ts";
/**
* OCR tool: transcribes JPG/PNG/WEBP images to TXT on the backend
* (tesseract binary) so the model only ever receives text.
* The frontend shows the transcription for user review before sending.
*/
const OCR_MAX_BYTES = 5 * 1024 * 1024;
// CPU protection: max OCR jobs per account per hour.
const OCR_WINDOW_MS = 3600_000;
const OCR_MAX_JOBS = 20;
const ocrJobs = new Map<string, number[]>();
export function ocrAllowed(userId: string): { ok: boolean; retryAfterSec: number } {
const now = Date.now();
const list = (ocrJobs.get(userId) ?? []).filter((t) => t > now - OCR_WINDOW_MS);
ocrJobs.set(userId, list);
if (list.length >= OCR_MAX_JOBS) {
return { ok: false, retryAfterSec: Math.max(1, Math.ceil((list[0]! + OCR_WINDOW_MS - now) / 1000)) };
}
return { ok: true, retryAfterSec: 0 };
}
export function recordOcrJob(userId: string): void {
const list = ocrJobs.get(userId) ?? [];
list.push(Date.now());
ocrJobs.set(userId, list);
}
function tessBin(): string {
return process.env["TESSERACT_BIN"] || "tesseract";
}
let availableCache: boolean | null = null;
async function checkBinary(): Promise<boolean> {
try {
const proc = Bun.spawn([tessBin(), "--version"], { stdout: "pipe", stderr: "pipe" });
const code = await proc.exited;
return code === 0;
} catch {
return false;
}
}
export class OcrTool implements PlatformTool {
readonly name = "ocr";
readonly description = "Transcribe image (jpg/png/webp) to text on the server (tesseract) for user review before sending to the model.";
private available: boolean | null = null;
async init(): Promise<void> {
if (availableCache === null) {
availableCache = await checkBinary();
driverLog("tools", `ocr engine ${tessBin()}: ${availableCache ? "available" : "MISSING — image transcription disabled"}`);
}
this.available = availableCache;
}
isAvailable(): boolean {
return this.available ?? false;
}
unavailableReason(): string | null {
if (this.isAvailable()) return null;
return `tesseract binary not found (looked for "${tessBin()}"). Install tesseract or set TESSERACT_BIN.`;
}
async transcribe(image: Uint8Array): Promise<{ text: string; truncated: boolean }> {
if (!this.isAvailable()) throw new Error(this.unavailableReason() ?? "ocr unavailable");
const t0 = Date.now();
const maxChars = config.ocrMaxChars;
let proc;
try {
proc = Bun.spawn([tessBin(), "stdin", "stdout", "-l", config.ocrLang, "--psm", "3"], {
stdin: image,
stdout: "pipe",
stderr: "pipe",
});
} catch (e) {
throw new Error(`ocr engine failed to start: ${(e as Error).message}`);
}
const timeout = setTimeout(() => {
try {
proc.kill();
} catch {
// already exited
}
}, 120_000);
try {
const [code, out, errText] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
if (code !== 0) {
throw new Error(`ocr failed (exit ${code}): ${errText.trim().slice(0, 300)}`);
}
let text = out.replace(/\f/g, "").trim();
let truncated = false;
if (text.length > maxChars) {
text = text.slice(0, maxChars);
truncated = true;
}
driverLog("tools", `ocr done: ${text.length} chars${truncated ? " (truncated)" : ""} in ${fmtMs(Date.now() - t0)}`);
return { text, truncated };
} finally {
clearTimeout(timeout);
}
}
}
export { OCR_MAX_BYTES };
+23
View File
@@ -0,0 +1,23 @@
import { OcrTool } from "./ocr.ts";
import type { PlatformTool } from "./types.ts";
/** Platform tools registry (upload flows always go through tools). */
class ToolRegistry {
readonly ocr = new OcrTool();
private tools: PlatformTool[] = [this.ocr];
async init(): Promise<void> {
await this.ocr.init();
}
list(): { name: string; description: string; available: boolean; reason: string | null }[] {
return this.tools.map((t) => ({
name: t.name,
description: t.description,
available: t.isAvailable(),
reason: t.unavailableReason(),
}));
}
}
export const tools = new ToolRegistry();
+8
View File
@@ -0,0 +1,8 @@
/** Platform tool: a server-side capability the frontend invokes explicitly
* (uploads always go through tools — never raw to the model). */
export interface PlatformTool {
readonly name: string;
readonly description: string;
isAvailable(): boolean;
unavailableReason(): string | null;
}