Compare commits
No commits in common. "main" and "0.0.3" have entirely different histories.
14 changed files with 16 additions and 302 deletions
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -4,26 +4,6 @@ All notable changes to this project are documented here. Versions are git tags;
|
||||||
release tags `X.Y.Z` are built and deployed automatically (pre-release tags such
|
release tags `X.Y.Z` are built and deployed automatically (pre-release tags such
|
||||||
as `0.0.0a1` are built and staged only).
|
as `0.0.0a1` are built and staged only).
|
||||||
|
|
||||||
## [0.1.0] - 2026-06-20
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- Enforce the 12h session lifetime server-side (reject sessions older than the TTL
|
|
||||||
even if the sealed cookie is intact), so a leaked cookie can't be replayed forever.
|
|
||||||
- Send `X-Content-Type-Options: nosniff` when serving user-uploaded receipt and
|
|
||||||
attachment bytes.
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
- Add deploy docs: `deploy/CICD.md` (which git push/tag triggers which pipeline
|
|
||||||
steps) and `deploy/AUTH.md` (the Authelia/OIDC integration contract + a sequence
|
|
||||||
diagram of the login flow). Cross-linked from the README and `deploy/INSTALL.md`.
|
|
||||||
|
|
||||||
## [0.0.4] - 2026-06-20
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- AI tab: correction notes are now full-width, wrapping, auto-growing textareas
|
|
||||||
(Save/Delete per note) instead of single-line inputs that truncated long notes,
|
|
||||||
so the whole note is readable and editable.
|
|
||||||
|
|
||||||
## [0.0.3] - 2026-06-20
|
## [0.0.3] - 2026-06-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -12,15 +12,7 @@ substantiation, with optional AI auto-fill of the amount/date/category/patient.
|
||||||
|
|
||||||
It's a single static Go binary (`CGO_ENABLED=0`, pure-Go SQLite). Configure via
|
It's a single static Go binary (`CGO_ENABLED=0`, pure-Go SQLite). Configure via
|
||||||
environment (see [.env.example](.env.example)); `./scripts/build.sh` builds it and
|
environment (see [.env.example](.env.example)); `./scripts/build.sh` builds it and
|
||||||
`./scripts/run.sh` runs it locally.
|
`./scripts/run.sh` runs it locally. Deployment notes: [deploy/INSTALL.md](deploy/INSTALL.md).
|
||||||
|
|
||||||
Deployment:
|
|
||||||
- **[deploy/CICD.md](deploy/CICD.md)** — what each git push/tag triggers (branch =
|
|
||||||
build+test; release tag `X.Y.Z` = build+deploy; pre-release tag = build+stage).
|
|
||||||
- **[deploy/INSTALL.md](deploy/INSTALL.md)** — one-time host setup, on-disk layout,
|
|
||||||
manual deploy, and rollback.
|
|
||||||
- **[deploy/AUTH.md](deploy/AUTH.md)** — the Authelia (OIDC) integration: which
|
|
||||||
config fields must agree with which app env vars, and how access is granted/revoked.
|
|
||||||
|
|
||||||
## AI classifier correction notes
|
## AI classifier correction notes
|
||||||
|
|
||||||
|
|
|
||||||
13
SPEC.md
13
SPEC.md
|
|
@ -37,12 +37,9 @@ editing, or general reporting beyond the Tally view.
|
||||||
email is present.
|
email is present.
|
||||||
- **Authorization gate:** the user MUST be a member of `REQUIRED_GROUP` (exact,
|
- **Authorization gate:** the user MUST be a member of `REQUIRED_GROUP` (exact,
|
||||||
case-sensitive match). Otherwise the callback returns **403**.
|
case-sensitive match). Otherwise the callback returns **403**.
|
||||||
- On success a `hsa_session` cookie is set for **12 hours**. The session is an
|
- On success a `hsa_session` cookie is set for **12 hours**. Cookies are
|
||||||
**AES-256-GCM** sealed token (tamper-evident; key = `SHA-256(SESSION_SECRET)`),
|
`HttpOnly`, `SameSite=Lax`, and `Secure` whenever `REDIRECT_URL` is `https://`.
|
||||||
and the 12h lifetime is enforced **server-side** (a session older than the TTL is
|
The session is an AES-encrypted token; the key is `SHA-256(SESSION_SECRET)`.
|
||||||
rejected even if the cookie value is intact), not just via the cookie's MaxAge.
|
|
||||||
Cookies are `HttpOnly`, `SameSite=Lax`, and `Secure` whenever `REDIRECT_URL` is
|
|
||||||
`https://`.
|
|
||||||
- `GET /logout` clears the session and redirects to `/login`.
|
- `GET /logout` clears the session and redirects to `/login`.
|
||||||
- **Public routes** (no session required): `/healthz`, `/static/*`, `/login`,
|
- **Public routes** (no session required): `/healthz`, `/static/*`, `/login`,
|
||||||
`/callback`, `/logout`. **Every other route requires a valid session**; missing
|
`/callback`, `/logout`. **Every other route requires a valid session**; missing
|
||||||
|
|
@ -206,9 +203,7 @@ filename, attachment count, and tags, with links to add another / Manage / Expor
|
||||||
|
|
||||||
- `GET /receipt/{id}/file` and `GET /attachment/{id}/file` serve the stored bytes
|
- `GET /receipt/{id}/file` and `GET /attachment/{id}/file` serve the stored bytes
|
||||||
**from the DB blob** (so serving works even if the on-disk copy is gone), with
|
**from the DB blob** (so serving works even if the on-disk copy is gone), with
|
||||||
the original MIME type, `Content-Disposition: inline`, and
|
the original MIME type and `Content-Disposition: inline`.
|
||||||
`X-Content-Type-Options: nosniff` (so the browser won't sniff user bytes past the
|
|
||||||
declared, upload-time-allowlisted type).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
110
deploy/AUTH.md
110
deploy/AUTH.md
|
|
@ -1,110 +0,0 @@
|
||||||
# Authentication — Authelia (OIDC)
|
|
||||||
|
|
||||||
The app is an **OIDC Relying Party**: it runs the standard authorization-code + PKCE
|
|
||||||
flow against Authelia directly. It is **not** behind Authelia's forward-auth /
|
|
||||||
`access_control` (note `hsa.maisym.com` is intentionally absent from those rules).
|
|
||||||
So Authelia **authenticates** the user (password + WebAuthn); the **app**
|
|
||||||
**authorizes** them via the `hsa-users` group claim.
|
|
||||||
|
|
||||||
## Flow — every redirect & call, and what it carries
|
|
||||||
|
|
||||||
Two cookies are in play: **`hsa_login`** (short-lived, holds the PKCE verifier +
|
|
||||||
state + nonce during the login round-trip) and **`hsa_session`** (the 12h
|
|
||||||
authenticated session). Front-channel = via the browser (302 redirects);
|
|
||||||
back-channel = direct server-to-server calls the browser never sees.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
autonumber
|
|
||||||
actor Browser
|
|
||||||
participant App as App (hsa.maisym.com)
|
|
||||||
participant Authelia as Authelia (auth.maisym.com)
|
|
||||||
|
|
||||||
Note over App,Authelia: at startup, the App discovers Authelia via<br/>GET /.well-known/openid-configuration and JWKS<br/>(learns its endpoints and signing keys)
|
|
||||||
|
|
||||||
Browser->>App: GET / (protected page, no hsa_session)
|
|
||||||
App-->>Browser: 302 to /login
|
|
||||||
|
|
||||||
Browser->>App: GET /login
|
|
||||||
Note right of App: make PKCE verifier, state, nonce<br/>seal them into the hsa_login cookie (10m)
|
|
||||||
App-->>Browser: 302 to Authelia /authorize, Set-Cookie hsa_login<br/>params client_id, redirect_uri, response_type=code,<br/>scope openid profile email groups, state, nonce,<br/>code_challenge S256 of verifier, method S256
|
|
||||||
|
|
||||||
Browser->>Authelia: GET /authorize with those params
|
|
||||||
Note over Browser,Authelia: password and WebAuthn (2FA)
|
|
||||||
Authelia-->>Browser: 302 to /callback with code and state
|
|
||||||
|
|
||||||
Browser->>App: GET /callback (code, state, Cookie hsa_login)
|
|
||||||
Note right of App: decode hsa_login and check<br/>returned state equals stored state (CSRF)
|
|
||||||
App->>Authelia: POST /token back-channel<br/>code, code_verifier, redirect_uri,<br/>grant_type authorization_code,<br/>Authorization Basic client_id and client_secret
|
|
||||||
Authelia-->>App: access_token and id_token (JWT)
|
|
||||||
Note right of App: verify id_token signature via JWKS<br/>and check nonce equals stored nonce
|
|
||||||
App->>Authelia: GET /userinfo back-channel<br/>Authorization Bearer access_token
|
|
||||||
Authelia-->>App: email, preferred_username, groups
|
|
||||||
Note right of App: require hsa-users in groups, else 403<br/>seal Session subject, groups, issuedAt<br/>into hsa_session cookie (12h, AES-256-GCM)
|
|
||||||
App-->>Browser: 302 to /, Set-Cookie hsa_session, clear hsa_login
|
|
||||||
|
|
||||||
Browser->>App: GET / (Cookie hsa_session)
|
|
||||||
Note right of App: decrypt and verify hsa_session,<br/>reject if older than the 12h TTL
|
|
||||||
App-->>Browser: 200, the app
|
|
||||||
```
|
|
||||||
|
|
||||||
## The contract — what must agree on both sides
|
|
||||||
|
|
||||||
| Authelia `configuration.yml` | App `.env` | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| issuer `https://auth.maisym.com` | `ISSUER_URL` | app discovers `ISSUER_URL/.well-known/openid-configuration` |
|
|
||||||
| `client_id: hsa-tracker` | `OIDC_CLIENT_ID` | exact match |
|
|
||||||
| `client_secret` (**argon2id hash**) | `OIDC_CLIENT_SECRET` (**plaintext**) | Authelia stores the hash; the app holds the plaintext that hashes to it |
|
|
||||||
| `redirect_uris` | `REDIRECT_URL` | must match character-for-character (`https://hsa.maisym.com/callback`) |
|
|
||||||
| `scopes: [openid, profile, email, groups]` | — | the **`groups`** scope delivers the claim authz depends on |
|
|
||||||
| `require_pkce: true`, `pkce_challenge_method: S256` | — | the app always uses PKCE S256 |
|
|
||||||
| `token_endpoint_auth_method: client_secret_basic` | — | matches the Go OAuth2 client default |
|
|
||||||
| `userinfo_signed_response_alg: 'none'` | — | the app reads UserInfo as plain JSON |
|
|
||||||
| — | `REQUIRED_GROUP=hsa-users` | the group the app demands; defined per-user in `users_database.yml` |
|
|
||||||
|
|
||||||
## The Authelia client block (this deployment)
|
|
||||||
|
|
||||||
In `identity_providers.oidc.clients` (secret redacted):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- client_id: 'hsa-tracker'
|
|
||||||
client_name: 'HSA Receipt Tracker'
|
|
||||||
client_secret: '$argon2id$v=19$m=65536,t=3,p=4$argon2xxxxxxxxxxx' # hash; plaintext lives in the app .env
|
|
||||||
public: false
|
|
||||||
authorization_policy: 'two_factor' # users must pass WebAuthn
|
|
||||||
require_pkce: true
|
|
||||||
pkce_challenge_method: 'S256'
|
|
||||||
redirect_uris:
|
|
||||||
- 'https://hsa.maisym.com/callback'
|
|
||||||
- 'http://localhost:8080/callback' # local dev
|
|
||||||
scopes: ['openid', 'profile', 'email', 'groups']
|
|
||||||
response_types: ['code']
|
|
||||||
grant_types: ['authorization_code']
|
|
||||||
token_endpoint_auth_method: 'client_secret_basic'
|
|
||||||
userinfo_signed_response_alg: 'none'
|
|
||||||
```
|
|
||||||
|
|
||||||
## The three things that actually bite
|
|
||||||
|
|
||||||
1. **client_secret is a hash on the Authelia side, plaintext on the app side.** To
|
|
||||||
rotate: `authelia crypto hash generate argon2 --password '<plaintext>'`, put the
|
|
||||||
resulting `$argon2id$...` **hash** in `configuration.yml` and the **plaintext** in
|
|
||||||
the app's `OIDC_CLIENT_SECRET`. A mismatch fails the token exchange.
|
|
||||||
2. **redirect_uri must match exactly** (scheme, host, path, no trailing slash) or
|
|
||||||
Authelia refuses the callback.
|
|
||||||
3. **The `groups` scope is load-bearing.** Drop it and the app gets no `groups`
|
|
||||||
claim → no `hsa-users` → **403 for everyone**.
|
|
||||||
|
|
||||||
## Who can log in / how to revoke
|
|
||||||
|
|
||||||
Access requires an Authelia account that passes 2FA **and** is in `hsa-users`
|
|
||||||
(`users_database.yml`):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
users:
|
|
||||||
jm: { groups: ['admins', 'hsa-users'], ... } # password: '$argon2id$...argon2xxxxxxxxxxx'
|
|
||||||
lynna: { groups: ['hsa-users'], ... } # password: '$argon2id$...argon2xxxxxxxxxxx'
|
|
||||||
```
|
|
||||||
|
|
||||||
To revoke someone: remove them from the `hsa-users` group (or set `disabled: true`).
|
|
||||||
An already-issued app session still lasts up to its 12h server-side TTL.
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
# CI/CD — what git actions trigger what
|
|
||||||
|
|
||||||
The pipeline is a single Forgejo Actions workflow,
|
|
||||||
[.forgejo/workflows/build.yml](../.forgejo/workflows/build.yml), triggered on every
|
|
||||||
push (`on: [push]`, which covers both branch and tag pushes). This document explains
|
|
||||||
which git action produces which outcome. For one-time host setup, the on-disk
|
|
||||||
layout, and manual deploy/rollback, see [INSTALL.md](INSTALL.md).
|
|
||||||
|
|
||||||
## Trigger behavior at a glance
|
|
||||||
|
|
||||||
| You push… | Build + test | Stage binary to host | Activate (go live) |
|
|
||||||
|---|:---:|:---:|:---:|
|
|
||||||
| any branch (no tag) | ✅ | — | — |
|
|
||||||
| a **release** tag `X.Y.Z` (e.g. `1.4.0`) | ✅ | ✅ | ✅ |
|
|
||||||
| a **pre-release** tag (anything else, e.g. `0.0.0a3`) | ✅ | ✅ | — |
|
|
||||||
|
|
||||||
- **Branch push** → builds and runs the full test suite, uploads the `hsa` binary as
|
|
||||||
a run artifact. Nothing touches the server. This is your PR / pre-merge gate.
|
|
||||||
- **Release tag** `X.Y.Z` → builds, tests, copies the binary to the host, and makes
|
|
||||||
it live (symlink swap + service restart).
|
|
||||||
- **Pre-release tag** → builds, tests, and copies the binary to the host, but does
|
|
||||||
**not** activate it. Use this to park a build on the server (QA, manual
|
|
||||||
activation) without flipping production.
|
|
||||||
|
|
||||||
The release-vs-pre-release decision is purely the tag name: the **Activate** step
|
|
||||||
runs only when the tag matches the regex `^[0-9]+\.[0-9]+\.[0-9]+$`. Note this means
|
|
||||||
a `v`-prefixed tag (`v1.4.0`) would build + stage but **not** activate — tag with
|
|
||||||
**bare numbers** (`1.4.0`).
|
|
||||||
|
|
||||||
## What each step does
|
|
||||||
|
|
||||||
1. **Checkout** — `git clone` of the pushed branch/tag (the runner doesn't use a
|
|
||||||
checkout action).
|
|
||||||
2. **Test** — `go test ./...` inside a `golang:1.26` container.
|
|
||||||
3. **Build** — static binary: `CGO_ENABLED=0 go build -buildvcs=false -ldflags "-s -w"`
|
|
||||||
(pure-Go SQLite, so no C toolchain; `-buildvcs=false` avoids the container's
|
|
||||||
dubious-ownership git error).
|
|
||||||
4. **Upload binary** — the `hsa` binary as a downloadable run artifact.
|
|
||||||
5. **Stage release** *(tags only)* — `scp` the binary to
|
|
||||||
`~/hsa-app/releases/hsa-app-V<tag>/hsa` on the host and `chmod +x` it.
|
|
||||||
6. **Activate release** *(release tags `X.Y.Z` only)* — `ln -sfn` the
|
|
||||||
`~/hsa-app/hsa` symlink to the new release and `systemctl --user restart hsa_app`.
|
|
||||||
Pre-release tags log "staged but not activated" and stop here.
|
|
||||||
|
|
||||||
## Cutting a release
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. land your change on main (push branch -> CI builds/tests -> merge)
|
|
||||||
# 2. tag and push:
|
|
||||||
git tag -a 1.4.0 -m "1.4.0 — <summary>"
|
|
||||||
git push origin 1.4.0
|
|
||||||
```
|
|
||||||
|
|
||||||
The tag push runs the whole pipeline through Activate. Watch it under the repo's
|
|
||||||
**Actions** tab; the **Activate release** step is the one that goes live. Roll back
|
|
||||||
by activating an older still-staged release (see [INSTALL.md](INSTALL.md)).
|
|
||||||
|
|
||||||
## Runner requirements
|
|
||||||
|
|
||||||
- One Forgejo `act_runner` registered with the **`shell`** label (`runs-on: shell`).
|
|
||||||
The job runs directly on the host, which therefore must have the **docker CLI**
|
|
||||||
available (the Test/Build steps run inside `golang:1.26` via `docker run`).
|
|
||||||
- The deploy steps `ssh`/`scp` from the runner host to the app host (they may be the
|
|
||||||
same machine). The app must run as a **user systemd service** named `hsa_app`,
|
|
||||||
reachable via `systemctl --user` — which requires `loginctl enable-linger` for the
|
|
||||||
deploy user (see [INSTALL.md](INSTALL.md)).
|
|
||||||
|
|
||||||
## Required repo configuration (Settings → Actions)
|
|
||||||
|
|
||||||
| Name | Kind | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `FORGEJO_SSH` | **Secret** | private SSH key authorized on the app host |
|
|
||||||
| `HSA_APP_HOST` | **Variable** | app host name / IP |
|
|
||||||
| `HSA_APP_USER` | **Variable** | SSH user that owns `~/hsa-app` and the user service |
|
|
||||||
|
|
||||||
If these are missing, branch pushes still build/test, but the Stage/Activate steps
|
|
||||||
fail on tags.
|
|
||||||
|
|
@ -13,9 +13,6 @@ The symlink decouples "what's on disk" from "what's running," so activating a
|
||||||
staged pre-release or rolling back is just a symlink repoint + restart (see
|
staged pre-release or rolling back is just a symlink repoint + restart (see
|
||||||
below).
|
below).
|
||||||
|
|
||||||
For the pipeline side — which git pushes/tags trigger which steps, the runner
|
|
||||||
requirements, and the required repo secrets/variables — see [CICD.md](CICD.md).
|
|
||||||
|
|
||||||
## Layout on the host
|
## Layout on the host
|
||||||
|
|
||||||
Mutable state (DB, env, config) lives at the top of `~/hsa-app/` and survives
|
Mutable state (DB, env, config) lives at the top of `~/hsa-app/` and survives
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,6 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"maisym.com/hsa/internal/auth"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// uploadWithClassifyBlob posts a receipt plus a round-tripped AI suggestion blob.
|
// uploadWithClassifyBlob posts a receipt plus a round-tripped AI suggestion blob.
|
||||||
|
|
@ -88,25 +85,6 @@ func TestAI_NoMissWhenSuggestionMatches(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuth_StaleSessionRedirects(t *testing.T) {
|
|
||||||
s := testServerWithStore(t)
|
|
||||||
// A session issued well beyond the TTL must be rejected server-side even though
|
|
||||||
// the cookie value itself is a valid (untampered) sealed token.
|
|
||||||
old := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"},
|
|
||||||
IssuedAt: time.Now().Add(-13 * time.Hour)}
|
|
||||||
v, err := auth.EncodeSession(old, s.cfg.SessionKey)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: v})
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.Routes().ServeHTTP(rec, req)
|
|
||||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login" {
|
|
||||||
t.Errorf("stale session: status=%d location=%q, want 302 -> /login", rec.Code, rec.Header().Get("Location"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAI_NotesCRUD(t *testing.T) {
|
func TestAI_NotesCRUD(t *testing.T) {
|
||||||
s := testServerWithStore(t)
|
s := testServerWithStore(t)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,6 @@ func (s *Server) requireAuth(next http.Handler) http.Handler {
|
||||||
http.Redirect(w, r, "/login", http.StatusFound)
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Server-side expiry: a cookie's MaxAge is client-enforced, so also reject a
|
|
||||||
// session older than its TTL — a captured cookie value can't be replayed forever.
|
|
||||||
if time.Since(sess.IssuedAt) > sessionTTL {
|
|
||||||
s.clearCookie(w, sessionCookie)
|
|
||||||
http.Redirect(w, r, "/login", http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx := context.WithValue(r.Context(), sessionKey, sess)
|
ctx := context.WithValue(r.Context(), sessionKey, sess)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -202,22 +202,7 @@ form.row {
|
||||||
form.row input { flex: 1; padding: .4rem; }
|
form.row input { flex: 1; padding: .4rem; }
|
||||||
form.row button { padding: .4rem .7rem; }
|
form.row button { padding: .4rem .7rem; }
|
||||||
.add input { border: 1px solid #137333; }
|
.add input { border: 1px solid #137333; }
|
||||||
button.danger { background: #fce8e6; border: 1px solid #d93025; color: #a50e0e; }
|
form.row button.danger { background: #fce8e6; border: 1px solid #d93025; color: #a50e0e; }
|
||||||
|
|
||||||
/* AI correction notes: full-width, wrapping, auto-growing editors. */
|
|
||||||
form.note { margin: .75rem 0 1rem; }
|
|
||||||
form.note textarea {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font: inherit;
|
|
||||||
padding: .5rem;
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 3rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
form.note.add textarea { border: 1px solid #137333; }
|
|
||||||
form.note .note-actions { display: flex; gap: .4rem; margin-top: .35rem; }
|
|
||||||
form.note .note-actions button { padding: .4rem .9rem; }
|
|
||||||
|
|
||||||
/* AI tab: read-only prompt + miss-review table. */
|
/* AI tab: read-only prompt + miss-review table. */
|
||||||
pre.prompt {
|
pre.prompt {
|
||||||
|
|
|
||||||
|
|
@ -10,29 +10,18 @@
|
||||||
<h2>Correction notes</h2>
|
<h2>Correction notes</h2>
|
||||||
<p><small>Free-text rules appended to the classifier prompt to fix recurring misreads. Editing a note keeps the old version in history.</small></p>
|
<p><small>Free-text rules appended to the classifier prompt to fix recurring misreads. Editing a note keeps the old version in history.</small></p>
|
||||||
{{range .Notes}}
|
{{range .Notes}}
|
||||||
<form class="note" method="post" action="/ai/notes/edit">
|
<form class="row" method="post" action="/ai/notes/edit">
|
||||||
<input type="hidden" name="id" value="{{.ID}}">
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
<textarea name="text" rows="2">{{.Text}}</textarea>
|
<input type="text" name="text" value="{{.Text}}">
|
||||||
<div class="note-actions">
|
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
<button type="submit" formaction="/ai/notes/delete" class="danger">Delete</button>
|
<button type="submit" formaction="/ai/notes/delete" class="danger">✕</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
<form class="note add" method="post" action="/ai/notes">
|
<form class="row add" method="post" action="/ai/notes">
|
||||||
<textarea name="text" rows="2" placeholder="New correction note…"></textarea>
|
<input type="text" name="text" placeholder="New correction note">
|
||||||
<div class="note-actions"><button type="submit">Add note</button></div>
|
<button type="submit">Add</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script>
|
|
||||||
// Grow each note's textarea to fit its full text, so long notes are readable.
|
|
||||||
document.querySelectorAll("form.note textarea").forEach(function (ta) {
|
|
||||||
function fit() { ta.style.height = "auto"; ta.style.height = ta.scrollHeight + "px"; }
|
|
||||||
ta.addEventListener("input", fit);
|
|
||||||
fit();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h2>Review misreads {{if .Misses}}({{len .Misses}}){{end}}</h2>
|
<h2>Review misreads {{if .Misses}}({{len .Misses}}){{end}}</h2>
|
||||||
{{if not .Misses}}
|
{{if not .Misses}}
|
||||||
<p><small>No misreads to review — the AI matched what you submitted (or nothing has been classified yet).</small></p>
|
<p><small>No misreads to review — the AI matched what you submitted (or nothing has been classified yet).</small></p>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"maisym.com/hsa/internal/auth"
|
"maisym.com/hsa/internal/auth"
|
||||||
"maisym.com/hsa/internal/config"
|
"maisym.com/hsa/internal/config"
|
||||||
|
|
@ -40,7 +39,7 @@ func testServerWithStore(t *testing.T) *Server {
|
||||||
|
|
||||||
func authCookie(t *testing.T, s *Server) *http.Cookie {
|
func authCookie(t *testing.T, s *Server) *http.Cookie {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}, IssuedAt: time.Now()}
|
sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}}
|
||||||
v, err := auth.EncodeSession(sess, s.cfg.SessionKey)
|
v, err := auth.EncodeSession(sess, s.cfg.SessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,6 @@ func (s *Server) handleReceiptFile(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", rec.MimeType)
|
w.Header().Set("Content-Type", rec.MimeType)
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff") // don't let the browser sniff user bytes
|
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", rec.OriginalFilename))
|
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", rec.OriginalFilename))
|
||||||
http.ServeContent(w, r, rec.OriginalFilename, rec.UploadedAt, bytes.NewReader(rec.ImageData))
|
http.ServeContent(w, r, rec.OriginalFilename, rec.UploadedAt, bytes.NewReader(rec.ImageData))
|
||||||
}
|
}
|
||||||
|
|
@ -229,7 +228,6 @@ func (s *Server) handleAttachmentFile(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", a.MimeType)
|
w.Header().Set("Content-Type", a.MimeType)
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff") // don't let the browser sniff user bytes
|
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", a.OriginalFilename))
|
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", a.OriginalFilename))
|
||||||
http.ServeContent(w, r, a.OriginalFilename, a.UploadedAt, bytes.NewReader(a.ImageData))
|
http.ServeContent(w, r, a.OriginalFilename, a.UploadedAt, bytes.NewReader(a.ImageData))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,6 @@ import (
|
||||||
const (
|
const (
|
||||||
sessionCookie = "hsa_session"
|
sessionCookie = "hsa_session"
|
||||||
loginCookie = "hsa_login"
|
loginCookie = "hsa_login"
|
||||||
|
|
||||||
// sessionTTL bounds a session both as the cookie's MaxAge and as a server-side
|
|
||||||
// age check (see requireAuth), so a leaked cookie value can't be replayed forever.
|
|
||||||
sessionTTL = 12 * time.Hour
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server holds the wired dependencies for the HTTP handlers.
|
// Server holds the wired dependencies for the HTTP handlers.
|
||||||
|
|
@ -228,7 +224,7 @@ func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
s.serverError(w, "encode session", err)
|
s.serverError(w, "encode session", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setCookie(w, sessionCookie, encoded, sessionTTL)
|
s.setCookie(w, sessionCookie, encoded, 12*time.Hour)
|
||||||
http.Redirect(w, r, "/", http.StatusFound)
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"maisym.com/hsa/internal/auth"
|
"maisym.com/hsa/internal/auth"
|
||||||
"maisym.com/hsa/internal/config"
|
"maisym.com/hsa/internal/config"
|
||||||
|
|
@ -65,7 +64,7 @@ func TestHome_NoSession_RedirectsToLogin(t *testing.T) {
|
||||||
|
|
||||||
func TestHome_ValidSession_ShowsUploadForm(t *testing.T) {
|
func TestHome_ValidSession_ShowsUploadForm(t *testing.T) {
|
||||||
s := testServerWithStore(t)
|
s := testServerWithStore(t)
|
||||||
sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}, IssuedAt: time.Now()}
|
sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}}
|
||||||
cookie, err := auth.EncodeSession(sess, s.cfg.SessionKey)
|
cookie, err := auth.EncodeSession(sess, s.cfg.SessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue