Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bec4e60bd | ||
|
|
dd963993eb | ||
|
|
a431dfa10b | ||
|
|
e957d385ec | ||
|
|
06ec312863 | ||
|
|
7d88ec5184 | ||
|
|
0c31e9b88d | ||
|
|
d15f115505 | ||
|
|
5211edf264 | ||
|
|
3e98fadb9f | ||
|
|
fef79b1836 | ||
|
|
8fbacd9f46 | ||
|
|
ab5c52c709 | ||
|
|
35b210951a | ||
|
|
4bd2788c7d | ||
|
|
e40be80e78 | ||
|
|
8a40349fa1 | ||
|
|
51613566ac | ||
|
|
0d4698c0de | ||
|
|
607e5a1708 | ||
|
|
39b0ab03fe | ||
|
|
535847477e | ||
|
|
aee1aafc74 | ||
|
|
3e55ddd88a | ||
|
|
ecb6a7385a | ||
|
|
88e3de606c | ||
|
|
b3a8cbd582 | ||
|
|
ed550fa914 | ||
|
|
89fc479bdb | ||
|
|
20754f15e2 | ||
|
|
bb141cdffc | ||
|
|
393a00e8dd | ||
|
|
7e2e1c4c60 |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Bulk Add Books
|
||||
type: http
|
||||
seq: 2
|
||||
seq: 1
|
||||
|
||||
http:
|
||||
method: POST
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Bulk Assign Books
|
||||
type: http
|
||||
seq: 3
|
||||
seq: 2
|
||||
|
||||
http:
|
||||
method: POST
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Bulk Remove Books
|
||||
type: http
|
||||
seq: 4
|
||||
seq: 3
|
||||
|
||||
http:
|
||||
method: POST
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Create Collection
|
||||
type: http
|
||||
seq: 5
|
||||
seq: 4
|
||||
|
||||
http:
|
||||
method: POST
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Create Shelf Mapping
|
||||
type: http
|
||||
seq: 6
|
||||
seq: 5
|
||||
|
||||
http:
|
||||
method: POST
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Delete Collection
|
||||
type: http
|
||||
seq: 7
|
||||
seq: 6
|
||||
|
||||
http:
|
||||
method: DELETE
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Delete Shelf Mapping
|
||||
type: http
|
||||
seq: 8
|
||||
seq: 7
|
||||
|
||||
http:
|
||||
method: DELETE
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Get Collection
|
||||
type: http
|
||||
seq: 9
|
||||
seq: 8
|
||||
|
||||
http:
|
||||
method: GET
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Get Book Collections
|
||||
type: http
|
||||
seq: 10
|
||||
seq: 9
|
||||
|
||||
http:
|
||||
method: GET
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: List Collections
|
||||
type: http
|
||||
seq: 11
|
||||
seq: 10
|
||||
|
||||
http:
|
||||
method: GET
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Preview Collection
|
||||
type: http
|
||||
seq: 12
|
||||
seq: 11
|
||||
|
||||
http:
|
||||
method: POST
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Reorder Collection Items
|
||||
type: http
|
||||
seq: 14
|
||||
seq: 12
|
||||
|
||||
http:
|
||||
method: PUT
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Update Collection
|
||||
type: http
|
||||
seq: 16
|
||||
seq: 14
|
||||
|
||||
http:
|
||||
method: PUT
|
||||
|
||||
+12
-6
@@ -1,12 +1,18 @@
|
||||
info:
|
||||
name: Reject Device Registration
|
||||
name: Cancel Device Registration
|
||||
type: http
|
||||
seq: 16
|
||||
seq: 3
|
||||
|
||||
http:
|
||||
method: POST
|
||||
url: '{{base_url}}/api/devices/reject/{{registration_id}}'
|
||||
url: '{{base_url}}/api/devices/register/cancel'
|
||||
auth: inherit
|
||||
body:
|
||||
type: json
|
||||
data: |-
|
||||
{
|
||||
"registration_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
@@ -23,9 +29,9 @@ runtime:
|
||||
|
||||
docs:
|
||||
content: |-
|
||||
# Reject Device Registration
|
||||
# Cancel Device Registration
|
||||
|
||||
`POST /api/devices/reject/:registration_id` — auth: jwt.
|
||||
`POST /api/devices/register/cancel` — auth: none.
|
||||
|
||||
Canonical reference: docs/developer/api/devices/reject_device_registration.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
|
||||
Canonical reference: docs/developer/api/devices/cancel_device_registration.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
|
||||
type: text/markdown
|
||||
@@ -0,0 +1,37 @@
|
||||
info:
|
||||
name: Claim Device (Pairing Code)
|
||||
type: http
|
||||
seq: 4
|
||||
|
||||
http:
|
||||
method: POST
|
||||
url: '{{base_url}}/api/devices/claim'
|
||||
auth: inherit
|
||||
body:
|
||||
type: json
|
||||
data: |-
|
||||
{
|
||||
"pairing_code": "583912"
|
||||
}
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
|
||||
runtime:
|
||||
scripts:
|
||||
- type: tests
|
||||
code: |-
|
||||
test("status is 200", function() {
|
||||
expect(res.status).to.eql(200);
|
||||
});
|
||||
|
||||
docs:
|
||||
content: |-
|
||||
# Claim Device (Pairing Code)
|
||||
|
||||
`POST /api/devices/claim` — auth: jwt.
|
||||
|
||||
Canonical reference: docs/developer/api/devices/claim_device.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
|
||||
type: text/markdown
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Clear Shelf
|
||||
type: http
|
||||
seq: 3
|
||||
seq: 5
|
||||
|
||||
http:
|
||||
method: DELETE
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Delete Device
|
||||
type: http
|
||||
seq: 4
|
||||
seq: 6
|
||||
|
||||
http:
|
||||
method: DELETE
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Download Sidecar Config
|
||||
type: http
|
||||
seq: 5
|
||||
seq: 7
|
||||
|
||||
http:
|
||||
method: GET
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Get Device
|
||||
type: http
|
||||
seq: 6
|
||||
seq: 8
|
||||
|
||||
http:
|
||||
method: GET
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Get Device Collection Mappings
|
||||
type: http
|
||||
seq: 7
|
||||
seq: 9
|
||||
|
||||
http:
|
||||
method: GET
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Get Device / Check Registration Status
|
||||
type: http
|
||||
seq: 8
|
||||
seq: 10
|
||||
|
||||
http:
|
||||
method: POST
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Get Shelf
|
||||
type: http
|
||||
seq: 9
|
||||
seq: 11
|
||||
|
||||
http:
|
||||
method: GET
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Get Device Sidecar Config
|
||||
type: http
|
||||
seq: 10
|
||||
seq: 12
|
||||
|
||||
http:
|
||||
method: GET
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Link Book
|
||||
type: http
|
||||
seq: 11
|
||||
seq: 13
|
||||
|
||||
http:
|
||||
method: POST
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: List User Devices
|
||||
type: http
|
||||
seq: 12
|
||||
seq: 14
|
||||
|
||||
http:
|
||||
method: GET
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: List Pending Device Registrations:<|end|><|start|><|start|>assistant to=edit<|channel|>analysis <|constrain|>xml<|message|><rfl:parameter name="path">
|
||||
type: http
|
||||
seq: 13
|
||||
seq: 15
|
||||
|
||||
http:
|
||||
method: GET
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
info:
|
||||
name: Look Up Pairing Code
|
||||
type: http
|
||||
seq: 16
|
||||
|
||||
http:
|
||||
method: POST
|
||||
url: '{{base_url}}/api/devices/claim/lookup'
|
||||
auth: inherit
|
||||
body:
|
||||
type: json
|
||||
data: |-
|
||||
{
|
||||
"pairing_code": "583912"
|
||||
}
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
|
||||
runtime:
|
||||
scripts:
|
||||
- type: tests
|
||||
code: |-
|
||||
test("status is 200", function() {
|
||||
expect(res.status).to.eql(200);
|
||||
});
|
||||
|
||||
docs:
|
||||
content: |-
|
||||
# Look Up Pairing Code
|
||||
|
||||
`POST /api/devices/claim/lookup` — auth: jwt.
|
||||
|
||||
Canonical reference: docs/developer/api/devices/lookup_pairing_code.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
|
||||
type: text/markdown
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Regenerate Device Token
|
||||
type: http
|
||||
seq: 14
|
||||
seq: 17
|
||||
|
||||
http:
|
||||
method: PUT
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Register Device
|
||||
type: http
|
||||
seq: 15
|
||||
seq: 18
|
||||
|
||||
http:
|
||||
method: POST
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Remove from Shelf
|
||||
type: http
|
||||
seq: 17
|
||||
seq: 19
|
||||
|
||||
http:
|
||||
method: DELETE
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Update Device
|
||||
type: http
|
||||
seq: 18
|
||||
seq: 20
|
||||
|
||||
http:
|
||||
method: PUT
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Update Device Collection Mapping
|
||||
type: http
|
||||
seq: 19
|
||||
seq: 21
|
||||
|
||||
http:
|
||||
method: PUT
|
||||
|
||||
@@ -168,7 +168,13 @@ func createContractDeviceNamed(t *testing.T, setup *TestServerSetup, name, devic
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
require.NoError(t, err, "Should create sync device")
|
||||
_ = device
|
||||
// Self-delete at test end: the contract helper attaches to the
|
||||
// SHARED admin (the web progress push adopts the device name, so
|
||||
// conflict tests drive a second named source without a separate
|
||||
// account) and names it for its surface — "koreader" passes every
|
||||
// name-based sweep, so without this the row survived forever (294
|
||||
// of them ended up on the dev account's device grid).
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteDevice(context.Background(), device.ID) })
|
||||
return token
|
||||
}
|
||||
|
||||
@@ -1208,6 +1214,30 @@ func pinAnnotationLifecycle(t *testing.T, setup *TestServerSetup, fx contractFix
|
||||
require.NoError(t, json.Unmarshal(raw, &body2))
|
||||
assertResponseShape(t, "annotation "+kind+" update", body2, rowKeys, rowKeys)
|
||||
|
||||
// The restore half of the round trip: what the reader fetches
|
||||
// when the book is reopened must return the persisted annotation
|
||||
// with the update applied — written to the DB, read back through
|
||||
// the same list endpoint the UI loads on open.
|
||||
t.Run(kind+" GET list restores the annotation", func(t *testing.T) {
|
||||
resp3 := doReq(t, "GET", annotationURL(setup.Server.URL, fx.itemID, kind, ""), nil, setup.RegularToken)
|
||||
defer resp3.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp3.StatusCode, "list must stay 200")
|
||||
var list []map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(resp3.Body).Decode(&list))
|
||||
var found map[string]interface{}
|
||||
for _, row := range list {
|
||||
if row["id"] == id {
|
||||
found = row
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, found, "created %s must come back in the list that restores a reopened book", kind)
|
||||
updates, _ := updateBody.(map[string]interface{})
|
||||
for key, want := range updates {
|
||||
assert.Equal(t, want, found[key], "%s field must round-trip from the DB on restore", key)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(kind+" DELETE response", func(t *testing.T) {
|
||||
resp3 := doReq(t, "DELETE", annotationURL(setup.Server.URL, fx.itemID, kind, id), nil, setup.RegularToken)
|
||||
defer resp3.Body.Close()
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package main
|
||||
|
||||
// Pairing-code flow integration tests. The register response mints a
|
||||
// 6-digit code shown on the registering device's screen; the person
|
||||
// holding the device types it into THEIR account (POST /api/devices/claim).
|
||||
// This replaces the retired broadcast-list approve, where
|
||||
// first-click-wins let any account claim (or reject) a device it could
|
||||
// see but not name.
|
||||
//
|
||||
// Devices claimed here land on the fresh per-run regular user, so the
|
||||
// harness cascade removes them; identifiers are uuid-suffixed to stay
|
||||
// clear of the UNIQUE(device_identifier) index across runs.
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// registerPairingDevice initiates a registration and returns its decoded
|
||||
// response (registration_id, pairing_code, ...).
|
||||
func registerPairingDevice(t *testing.T, setup *TestServerSetup, identifier string) map[string]interface{} {
|
||||
t.Helper()
|
||||
regRequest := map[string]interface{}{
|
||||
"device_name": "Test Pairing Device",
|
||||
"device_type": "koreader",
|
||||
"device_identifier": identifier,
|
||||
}
|
||||
regBody, _ := json.Marshal(regRequest)
|
||||
req := httptest.NewRequest("POST", "/api/devices/register", bytes.NewReader(regBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusCreated, rec.Code, "Should initiate registration")
|
||||
|
||||
var regResponse map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), ®Response)
|
||||
return regResponse
|
||||
}
|
||||
|
||||
// pollRegistrationStatus hits the device-side status endpoint (no auth —
|
||||
// the registration UUID is the bearer).
|
||||
func pollRegistrationStatus(t *testing.T, setup *TestServerSetup, registrationID string) (int, map[string]interface{}) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]string{"registration_id": registrationID})
|
||||
req := httptest.NewRequest("POST", "/api/devices/register/status", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
return rec.Code, resp
|
||||
}
|
||||
|
||||
func pairingCodeBody(code string) []byte {
|
||||
body, _ := json.Marshal(map[string]string{"pairing_code": code})
|
||||
return body
|
||||
}
|
||||
|
||||
// pairingLookupOrClaim POSTs to the given claim-family endpoint with the
|
||||
// given bearer token and returns status + decoded body.
|
||||
func pairingLookupOrClaim(t *testing.T, setup *TestServerSetup, path, token, code string) (int, map[string]interface{}) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("POST", path, bytes.NewReader(pairingCodeBody(code)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
return rec.Code, resp
|
||||
}
|
||||
|
||||
// wrongPairingCode derives a code guaranteed to differ from the target's
|
||||
// digits (rotate each digit +1 mod 10).
|
||||
func wrongPairingCode(code string) string {
|
||||
rotated := make([]byte, len(code))
|
||||
for i := 0; i < len(code); i++ {
|
||||
rotated[i] = byte('0' + (int(code[i]-'0')+1)%10)
|
||||
}
|
||||
return string(rotated)
|
||||
}
|
||||
|
||||
// pairingFormRequest POSTs urlencoded form data — the Content-Type the
|
||||
// browser's htmx forms actually send — with a Bearer token.
|
||||
func pairingFormRequest(t *testing.T, setup *TestServerSetup, path, token, body string) (int, map[string]interface{}) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("POST", path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
return rec.Code, resp
|
||||
}
|
||||
|
||||
// TestPairingClaimAcceptsBrowserFormEncoding pins the htmx path: the
|
||||
// confirm card claims via a urlencoded <form>, so echo v5 routes the
|
||||
// Content-Type to the `form:` binder, which maps by `form:` struct tag
|
||||
// only. (Regression: PairingCodeRequest shipped with a json-only tag,
|
||||
// leaving the code empty and every browser claim failing with 400 —
|
||||
// "how did lookup find the device if the code is wrong?") The JSON path
|
||||
// stays pinned by the tests above; this covers the form twin.
|
||||
func TestPairingClaimAcceptsBrowserFormEncoding(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
identifier := fmt.Sprintf("pairing-form-%s", uuid.New().String())
|
||||
|
||||
reg := registerPairingDevice(t, setup, identifier)
|
||||
code := reg["pairing_code"].(string)
|
||||
|
||||
// Form-encoded lookup (the same call the API-side confirm card makes).
|
||||
status, resp := pairingFormRequest(t, setup, "/api/devices/claim/lookup",
|
||||
setup.RegularToken, "pairing_code="+code)
|
||||
require.Equal(t, http.StatusOK, status, "form-encoded lookup must resolve the live code")
|
||||
assert.Equal(t, "Test Pairing Device", resp["device_name"])
|
||||
|
||||
// Form-encoded claim — exactly what the browser's Pair device
|
||||
// button sends (urlencoded hidden input, not JSON).
|
||||
status, resp = pairingFormRequest(t, setup, "/api/devices/claim",
|
||||
setup.RegularToken, "pairing_code="+code)
|
||||
require.Equal(t, http.StatusOK, status, "form-encoded claim must bind the device")
|
||||
assert.Equal(t, true, resp["approved"])
|
||||
|
||||
// The device's own poll delivers credentials — binding really happened.
|
||||
pollStatus, pollResp := pollRegistrationStatus(t, setup, reg["registration_id"].(string))
|
||||
require.Equal(t, http.StatusOK, pollStatus)
|
||||
assert.Equal(t, "approved", pollResp["status"])
|
||||
}
|
||||
|
||||
func TestPairingClaimHappyPath(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
identifier := fmt.Sprintf("pairing-happy-%s", uuid.New().String())
|
||||
|
||||
reg := registerPairingDevice(t, setup, identifier)
|
||||
code, ok := reg["pairing_code"].(string)
|
||||
require.True(t, ok, "register response must carry pairing_code")
|
||||
require.Len(t, code, 6, "pairing code must be 6 digits")
|
||||
for _, d := range code {
|
||||
require.GreaterOrEqual(t, d, '0')
|
||||
require.LessOrEqual(t, d, '9')
|
||||
}
|
||||
|
||||
// Lookup first — the confirm card's data, without binding.
|
||||
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim/lookup", setup.RegularToken, " "+code[:3]+" "+code[3:])
|
||||
require.Equal(t, http.StatusOK, status, "lookup should resolve the live code (spaces normalized)")
|
||||
assert.Equal(t, "Test Pairing Device", resp["device_name"])
|
||||
assert.NotEmpty(t, resp["expires_in"])
|
||||
|
||||
// Still pending — lookup must not have bound anything.
|
||||
pollStatus, pollResp := pollRegistrationStatus(t, setup, reg["registration_id"].(string))
|
||||
require.Equal(t, http.StatusOK, pollStatus)
|
||||
assert.Equal(t, "pending", pollResp["status"])
|
||||
|
||||
// Claim as the fresh regular user (deterministic account per doctrine).
|
||||
status, resp = pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code)
|
||||
require.Equal(t, http.StatusOK, status, "claim should bind the device")
|
||||
assert.Equal(t, true, resp["approved"])
|
||||
|
||||
// The device's own poll now delivers credentials.
|
||||
pollStatus, pollResp = pollRegistrationStatus(t, setup, reg["registration_id"].(string))
|
||||
require.Equal(t, http.StatusOK, pollStatus)
|
||||
assert.Equal(t, "approved", pollResp["status"])
|
||||
token, ok := pollResp["auth_token"].(string)
|
||||
require.True(t, ok, "approved poll must deliver the auth token")
|
||||
require.NotEmpty(t, token)
|
||||
|
||||
// The device row belongs to the claimer, and the poll-delivered token
|
||||
// matches the stored row.
|
||||
devices, err := setup.DB.ListDevicesByUser(context.Background(),
|
||||
pgtype.UUID{Bytes: [16]byte(getRegularUserID(t, setup.DB)), Valid: true})
|
||||
require.NoError(t, err)
|
||||
var found *database.Devices
|
||||
for i := range devices {
|
||||
if devices[i].DeviceIdentifier == identifier {
|
||||
found = &devices[i]
|
||||
}
|
||||
}
|
||||
require.NotNil(t, found, "device row must exist under the claimer")
|
||||
assert.Equal(t, token, found.AuthToken, "poll-delivered token must match the stored row")
|
||||
}
|
||||
|
||||
func TestPairingWrongCodeIsGenericAndNonDestructive(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
reg := registerPairingDevice(t, setup, fmt.Sprintf("pairing-miss-%s", uuid.New().String()))
|
||||
code := reg["pairing_code"].(string)
|
||||
|
||||
// A wrong code fails with the generic message — no hint whether any
|
||||
// registration is waiting.
|
||||
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, wrongPairingCode(code))
|
||||
require.Equal(t, http.StatusBadRequest, status)
|
||||
assert.Equal(t, "invalid pairing code", resp["error"])
|
||||
|
||||
// The miss must not have killed or claimed the real registration.
|
||||
status, resp = pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code)
|
||||
require.Equal(t, http.StatusOK, status, "the correct code must still claim after a miss")
|
||||
assert.Equal(t, true, resp["approved"])
|
||||
}
|
||||
|
||||
func TestPairingClaimBindsClaimerOnly(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
identifier := fmt.Sprintf("pairing-scope-%s", uuid.New().String())
|
||||
|
||||
reg := registerPairingDevice(t, setup, identifier)
|
||||
code := reg["pairing_code"].(string)
|
||||
|
||||
// The regular user claims it.
|
||||
status, _ := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
|
||||
// The admin (a different account) has no such device — and cannot
|
||||
// re-claim the now-used code either.
|
||||
adminDevices, err := setup.DB.ListDevicesByUser(context.Background(),
|
||||
pgtype.UUID{Bytes: [16]byte(getTestUserID(t, setup.DB)), Valid: true})
|
||||
require.NoError(t, err)
|
||||
for _, d := range adminDevices {
|
||||
assert.NotEqual(t, identifier, d.DeviceIdentifier,
|
||||
"device must be bound to the claimer, not to every account")
|
||||
}
|
||||
|
||||
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.Token, code)
|
||||
assert.Equal(t, http.StatusConflict, status, "an already-used code must not bind again")
|
||||
assert.Contains(t, resp["error"], "already used")
|
||||
}
|
||||
|
||||
func TestPairingReRegisterRetiresPriorCode(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
identifier := fmt.Sprintf("pairing-retry-%s", uuid.New().String())
|
||||
|
||||
reg1 := registerPairingDevice(t, setup, identifier)
|
||||
reg2 := registerPairingDevice(t, setup, identifier)
|
||||
|
||||
code1, ok := reg1["pairing_code"].(string)
|
||||
require.True(t, ok)
|
||||
code2, ok := reg2["pairing_code"].(string)
|
||||
require.True(t, ok)
|
||||
require.NotEqual(t, code1, code2, "each registration mints its own code")
|
||||
|
||||
// The retired attempt is gone from both the code and UUID channels.
|
||||
status, _ := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code1)
|
||||
assert.Equal(t, http.StatusBadRequest, status, "retired code must not claim")
|
||||
pollStatus, _ := pollRegistrationStatus(t, setup, reg1["registration_id"].(string))
|
||||
assert.Equal(t, http.StatusNotFound, pollStatus, "retired registration must not poll")
|
||||
|
||||
// The fresh code claims fine.
|
||||
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code2)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Equal(t, true, resp["approved"])
|
||||
}
|
||||
|
||||
func TestPairingCancelByRegistrationUUID(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
cancelled := registerPairingDevice(t, setup, fmt.Sprintf("pairing-cancel-%s", uuid.New().String()))
|
||||
kept := registerPairingDevice(t, setup, fmt.Sprintf("pairing-keep-%s", uuid.New().String()))
|
||||
|
||||
// Cancel only the first attempt — bearer is its own registration UUID.
|
||||
body, _ := json.Marshal(map[string]string{"registration_id": cancelled["registration_id"].(string)})
|
||||
req := httptest.NewRequest("POST", "/api/devices/register/cancel", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "cancel should succeed")
|
||||
|
||||
// Cancelled: code dead, status poll gone.
|
||||
code1 := cancelled["pairing_code"].(string)
|
||||
status, _ := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code1)
|
||||
assert.Equal(t, http.StatusBadRequest, status, "cancelled code must not claim")
|
||||
pollStatus, _ := pollRegistrationStatus(t, setup, cancelled["registration_id"].(string))
|
||||
assert.Equal(t, http.StatusNotFound, pollStatus, "cancelled registration must not poll")
|
||||
|
||||
// Idempotent: cancelling again is still a 200.
|
||||
req = httptest.NewRequest("POST", "/api/devices/register/cancel", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "cancel must be idempotent")
|
||||
|
||||
// The other registration is untouched by its neighbour's cancel.
|
||||
code2 := kept["pairing_code"].(string)
|
||||
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code2)
|
||||
require.Equal(t, http.StatusOK, status, "cancel must scope to its own registration only")
|
||||
assert.Equal(t, true, resp["approved"])
|
||||
}
|
||||
|
||||
func TestPairingAttemptRateLimit(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
// The regular user is fresh per setup, so the limiter key
|
||||
// (userID|ip) is this test's alone — the budget cannot have been
|
||||
// pre-spent by sibling tests sharing the process-global limiter.
|
||||
// Burn the budget with bad lookups (12/min); the 13th attempt must
|
||||
// be refused before the code check.
|
||||
for i := 0; i < 12; i++ {
|
||||
code := fmt.Sprintf("%06d", i*7919+13)
|
||||
status, _ := pairingLookupOrClaim(t, setup, "/api/devices/claim/lookup", setup.RegularToken, code)
|
||||
require.Equal(t, http.StatusBadRequest, status, "attempt %d should fail as a plain miss", i+1)
|
||||
}
|
||||
|
||||
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim/lookup", setup.RegularToken, "999999")
|
||||
assert.Equal(t, http.StatusTooManyRequests, status, "13th attempt must hit the budget")
|
||||
assert.Contains(t, resp["error"], "too many")
|
||||
}
|
||||
@@ -19,6 +19,9 @@ import (
|
||||
|
||||
func TestDeviceRegistrationFlow(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
// Approved through the shared admin's session; the row must not
|
||||
// outlive the test (name hygiene alone should never carry it).
|
||||
cleanupDeviceByIdentifier(t, setup, "kindle-test-hw-id-12345")
|
||||
|
||||
// Step 1: Initiate device registration
|
||||
regRequest := map[string]interface{}{
|
||||
@@ -208,6 +211,7 @@ func TestDeviceAuthentication(t *testing.T) {
|
||||
|
||||
// Create a device directly in the database
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
cleanupDeviceByIdentifier(t, setup, "test-device-123")
|
||||
|
||||
deviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
|
||||
_, err := setup.DB.CreateDevice(context.Background(), database.CreateDeviceParams{
|
||||
@@ -237,10 +241,25 @@ func TestDeviceAuthentication(t *testing.T) {
|
||||
func TestListPendingRegistrations(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices/pending", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
// A live registration must NOT appear in any account's pending list:
|
||||
// the broadcast is what let any user claim (or reject) someone else's
|
||||
// device. Pairing codes replaced it — see device_pairing_test.go.
|
||||
regRequest := map[string]interface{}{
|
||||
"device_name": "Broadcast Test Device",
|
||||
"device_type": "koreader",
|
||||
"device_identifier": fmt.Sprintf("broadcast-test-%s", uuid.New().String()),
|
||||
}
|
||||
regBody, _ := json.Marshal(regRequest)
|
||||
req := httptest.NewRequest("POST", "/api/devices/register", bytes.NewReader(regBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusCreated, rec.Code, "Should initiate registration")
|
||||
|
||||
req = httptest.NewRequest("GET", "/api/devices/pending", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
rec = httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "Should list pending registrations")
|
||||
|
||||
@@ -248,8 +267,9 @@ func TestListPendingRegistrations(t *testing.T) {
|
||||
json.Unmarshal(rec.Body.Bytes(), &response)
|
||||
|
||||
pending, ok := response["pending_registrations"].([]interface{})
|
||||
assert.True(t, ok, "Should have pending_registrations array")
|
||||
assert.NotNil(t, pending, "Pending registrations should not be nil")
|
||||
require.True(t, ok, "Should have pending_registrations array")
|
||||
assert.Empty(t, pending, "Unclaimed registrations must be invisible to every account")
|
||||
assert.Equal(t, float64(0), response["total"], "Total must stay zero while a registration is live")
|
||||
}
|
||||
|
||||
// Regression test for the "approve returns 500 after app reinstall" bug:
|
||||
@@ -263,6 +283,10 @@ func TestApproveDeviceReapprovalRotatesToken(t *testing.T) {
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
|
||||
identifier := fmt.Sprintf("repro-reinstall-%s", uuid.New().String())
|
||||
// Self-delete: the row is named "Reinstall Device" — the web
|
||||
// reinstall flow's own label — so the name-based sweeps can never
|
||||
// safely touch it. The identifier is this test's fixture alone.
|
||||
cleanupDeviceByIdentifier(t, setup, identifier)
|
||||
|
||||
registerAndApprove := func() string {
|
||||
regRequest := map[string]interface{}{
|
||||
@@ -336,6 +360,9 @@ func TestApproveDeviceReapprovalRotatesToken(t *testing.T) {
|
||||
|
||||
func TestApproveDeviceRegistration(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
// Approved through the shared admin's session; the row must not
|
||||
// outlive the test (name hygiene alone should never carry it).
|
||||
cleanupDeviceByIdentifier(t, setup, "test-approval-12345")
|
||||
|
||||
regRequest := map[string]interface{}{
|
||||
"device_name": "Test Device for Approval",
|
||||
@@ -370,13 +397,18 @@ func TestApproveDeviceRegistration(t *testing.T) {
|
||||
assert.True(t, approveResponse["approved"].(bool), "Should confirm approval")
|
||||
}
|
||||
|
||||
func TestRejectDeviceRegistration(t *testing.T) {
|
||||
// TestRejectDeviceRegistrationRemoved pins the removal: the reject route is
|
||||
// gone because unclaimed registrations are invisible since pairing codes
|
||||
// took over — there is nothing left for anyone to reject. See
|
||||
// device_pairing_test.go for the replacement flow (cancel by the
|
||||
// registering device itself).
|
||||
func TestRejectDeviceRegistrationRemoved(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
regRequest := map[string]interface{}{
|
||||
"device_name": "Test Device for Rejection",
|
||||
"device_type": "koreader",
|
||||
"device_identifier": "test-rejection-12345",
|
||||
"device_identifier": fmt.Sprintf("test-rejection-%s", uuid.New().String()),
|
||||
}
|
||||
regBody, _ := json.Marshal(regRequest)
|
||||
|
||||
@@ -385,25 +417,23 @@ func TestRejectDeviceRegistration(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, rec.Code, "Should initiate device registration")
|
||||
require.Equal(t, http.StatusCreated, rec.Code, "Should initiate device registration")
|
||||
|
||||
var regResponse map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), ®Response)
|
||||
|
||||
registrationID, ok := regResponse["registration_id"].(string)
|
||||
assert.True(t, ok, "Should have registration_id")
|
||||
require.True(t, ok, "Should have registration_id")
|
||||
|
||||
req = httptest.NewRequest("POST", fmt.Sprintf("/api/devices/reject/%s", registrationID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
rec = httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "Should reject device registration")
|
||||
|
||||
var rejectResponse map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &rejectResponse)
|
||||
|
||||
assert.Equal(t, "device registration rejected", rejectResponse["message"], "Should confirm rejection message")
|
||||
// 404 = no route, 405 = path-shaped collision without POST — either
|
||||
// proves the reject surface is gone.
|
||||
assert.True(t, rec.Code == http.StatusNotFound || rec.Code == http.StatusMethodNotAllowed,
|
||||
"Reject route must not exist (registration invisible, nothing to reject); got %d", rec.Code)
|
||||
}
|
||||
|
||||
func TestRegenerateDeviceToken_Success(t *testing.T) {
|
||||
|
||||
@@ -438,7 +438,15 @@ func TestMetadataWriteToFile(t *testing.T) {
|
||||
assert.Equal(t, "Comic Series", series)
|
||||
})
|
||||
|
||||
// Sweep ALL generations of the fixture dirs, not just this
|
||||
// process's: a killed run never reaches its RemoveAll, and the stale
|
||||
// root-owned dirs then sit on the user's bind mount forever (seven
|
||||
// of them were found in uploads/ in Nov 2026, one per past run —
|
||||
// each surviving run's cleanup skipped them by pid).
|
||||
matches, _ := filepath.Glob(filepath.Join(uploadsDir, "test-metadata-write-*"))
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll(filepath.Join(uploadsDir, fmt.Sprintf("test-metadata-write-%d", os.Getpid())))
|
||||
for _, dir := range matches {
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -96,6 +96,43 @@ type TestServerSetup struct {
|
||||
closed bool
|
||||
}
|
||||
|
||||
// cleanupDeviceByIdentifier deletes any devices rows carrying the given
|
||||
// identifier at test end. API-register tests need this instead of a
|
||||
// token-based delete: the status poll is single-use, so a row created
|
||||
// through register+approve changes tokens mid-test — the stable
|
||||
// identity is UNIQUE(device_identifier). Use for every device a test
|
||||
// attaches to the SHARED admin (testuser); See the DOCTRINE comment in
|
||||
// setupTestServer — rows that survive their test pile up on the live
|
||||
// dev account's device grid.
|
||||
func cleanupDeviceByIdentifier(t *testing.T, setup *TestServerSetup, identifier string) {
|
||||
t.Cleanup(func() {
|
||||
_, _ = setup.DBPool.Exec(context.Background(),
|
||||
"DELETE FROM devices WHERE device_identifier = $1", identifier)
|
||||
})
|
||||
}
|
||||
|
||||
// cleanupCollectionByID deletes one collections row at test end, scoped
|
||||
// through the RLS bridge to its owner — collections RLS (2026-10-31)
|
||||
// makes unscoped writes invisible (no-op). Use for a collection a test
|
||||
// attaches to the shared admin whose name CANNOT carry the "test"
|
||||
// marker the setup-time name sweep keys on (uuid-suffixed uniqueness
|
||||
// against stale UNIQUE(user_id, name) rows from past runs). Rows that
|
||||
// survive their test also get stranded forever — the 10 accumulated
|
||||
// "Admin Collection <uuid>" rows were exactly this.
|
||||
func cleanupCollectionByID(t *testing.T, setup *TestServerSetup, ownerID [16]byte, collectionID string) {
|
||||
t.Cleanup(func() {
|
||||
cid, err := uuid.Parse(collectionID)
|
||||
if err != nil {
|
||||
t.Logf("cleanupCollectionByID: unparsable id %q — %v", collectionID, err)
|
||||
return
|
||||
}
|
||||
scope := rls.WithUser(context.Background(), pgtype.UUID{Bytes: ownerID, Valid: true})
|
||||
if err := setup.DB.DeleteCollection(scope, pgtype.UUID{Bytes: cid, Valid: true}); err != nil {
|
||||
t.Logf("cleanupCollectionByID: delete failed for %s — %v", collectionID, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Close cleans up all resources in the correct order
|
||||
func (s *TestServerSetup) Close() error {
|
||||
s.mu.Lock()
|
||||
@@ -481,6 +518,13 @@ func (s *TestDeviceSetup) CreateDevice(t *testing.T, deviceName, deviceType, dev
|
||||
})
|
||||
require.NoError(t, err, "Should create device")
|
||||
|
||||
// Devices created through this helper self-delete at test end, so
|
||||
// callers owe nothing to name hygiene: setupDeviceTest attaches to
|
||||
// the SHARED admin, which the harness deliberately never deletes —
|
||||
// unqualified rows would pile up on the live dev account forever
|
||||
// (the "OPDS Folders Device" graveyard was exactly this gap).
|
||||
t.Cleanup(func() { _ = s.DB.DeleteDevice(context.Background(), device.ID) })
|
||||
|
||||
deviceUUID, err := uuid.FromBytes(device.ID.Bytes[0:16])
|
||||
require.NoError(t, err, "Should parse device ID")
|
||||
|
||||
@@ -809,9 +853,17 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
// Same passes for test DEVICES (identifier/name containing "test")
|
||||
// and test SAVED FILTERS (name containing "test"): both accumulated
|
||||
// across runs and poisoned later runs (duplicate identifiers, 409s,
|
||||
// non-empty "initially empty" assertions).
|
||||
// non-empty "initially empty" assertions). The second half of the
|
||||
// device predicate catches test suites that attach to the shared
|
||||
// admin with surface-specific names ("koreader" from the contract
|
||||
// pins, "OPDS Folders Device", "Reinstall Device") — every suite
|
||||
// now also self-deletes its rows, this is the drift safety net:
|
||||
// dev_contract_% auth tokens, contract-% / opds-folders-% /
|
||||
// repro-reinstall-% identifiers are unambiguously harness-owned.
|
||||
_, derr := dbPool.Exec(ctx,
|
||||
"DELETE FROM devices WHERE user_id = $1 AND (device_identifier ILIKE '%test%' OR device_name ILIKE '%test%')",
|
||||
"DELETE FROM devices WHERE user_id = $1 AND (device_identifier ILIKE '%test%' OR device_name ILIKE '%test%' "+
|
||||
"OR device_identifier ILIKE 'contract-%' OR device_identifier ILIKE 'opds-folders-%' OR device_identifier ILIKE 'repro-reinstall-%' "+
|
||||
"OR auth_token LIKE 'dev_contract_%')",
|
||||
adminUUID)
|
||||
if derr != nil {
|
||||
t.Logf("device cleanup failed: %v", derr)
|
||||
|
||||
@@ -241,6 +241,10 @@ func TestWebSocketUserScopedBroadcast(t *testing.T) {
|
||||
err = json.NewDecoder(collectionResp.Body).Decode(&collectionResult)
|
||||
require.NoError(t, err)
|
||||
collectionID := collectionResult["id"].(string)
|
||||
// The uuid suffix keeps the name off every run's UNIQUE(user_id,name),
|
||||
// which also keeps it out of the name-based cleanup sweep — self-delete
|
||||
// instead (owner-scoped, exactly who created it).
|
||||
cleanupCollectionByID(t, setup, getTestUserID(t, setup.DB), collectionID)
|
||||
|
||||
// Create a test book via API
|
||||
bookID := createTestMediaItemID(t, setup)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<!-- Ground truth: the Administration section itself (docs/admin/), the app
|
||||
sidebar's Administration group (templates/header.templ: Dashboard,
|
||||
Libraries, Podcasts, Hash Conflicts, Users, Settings), and the
|
||||
per-page ground-truth comments in this directory. This page is a
|
||||
directory; the pages it links to carry their own source pins. -->
|
||||
|
||||
# Admin Guide
|
||||
|
||||
Everything an instance administrator runs lives in this section. Like the
|
||||
rest of Bookhoard's docs, pages tell you what to click — but admin pages
|
||||
add the layer users don't need: **what each control actually does** before
|
||||
you click it, what it costs, and what it changes. (The calibration, if
|
||||
you're curious: *user docs = what you see, admin docs = what it does,
|
||||
developer docs = how it works*.)
|
||||
|
||||
## The Administration section
|
||||
|
||||
| Page | What it covers |
|
||||
| --- | --- |
|
||||
| [Dashboard](dashboard.md) | The `/admin` landing page: stats, file-watcher status, **Force Rescan All Libraries**, live scan progress |
|
||||
| [Libraries](libraries.md) | Creating libraries, folders, per-user visibility, per-library scans, deletion semantics |
|
||||
| [Processing Issues](library-processing-issues.md) | Per-library ledger of items the scanner couldn't process — Retry, Retry all, Dismiss |
|
||||
| [Archived Items](archived-items.md) | The missing-file lifecycle: two-strikes archiving, Merge/Restore/Delete, retention |
|
||||
| [Podcasts](podcasts.md) | The all-feeds maintenance view: disk usage, refresh, delete files, remove for everyone |
|
||||
| [Hash Conflicts](hash-conflicts.md) | Byte-identical duplicates and their resolution (merge reading data or keep both) |
|
||||
| [Users](users.md) | Accounts, roles, password resets, the last-admin guards |
|
||||
| [Settings](settings.md) | Every server-wide setting: key, default, range, what it does, restart or live |
|
||||
| [Scanning](scanning.md) | The deep-dive: shallow vs. force, and what every scan button actually does |
|
||||
|
||||
The app's own sidebar shows the first six as the **Administration** group;
|
||||
Processing Issues and Archived Items hang off the Libraries page.
|
||||
|
||||
## If you're setting up a fresh instance
|
||||
|
||||
The shortest sane path, in order:
|
||||
|
||||
1. **[Libraries](libraries.md)** → Create Library, add its folders
|
||||
2. Let the initial scan populate it (or **Force Rescan** to be sure)
|
||||
3. **[Settings](settings.md)** → set the **Base URL** to the address other
|
||||
devices will use, and check **Auto-Scan** is on
|
||||
4. **[Users](users.md)** → create accounts (or leave public registration
|
||||
on), set roles
|
||||
5. Point devices at the server — the [device guides](../user/user-guide.md)
|
||||
walk each one
|
||||
|
||||
## When something looks wrong
|
||||
|
||||
- Books vanished from the library? [Archived Items](archived-items.md) —
|
||||
they're almost certainly there, history intact.
|
||||
- A book's metadata is stale or wrong? Open the book → **Rescan**
|
||||
(re-extracts that one item, sidecars included).
|
||||
- Duplicates? [Hash Conflicts](hash-conflicts.md).
|
||||
- A scan didn't pick up your changes? [Scanning](scanning.md) explains
|
||||
which scans skip unchanged files — and which never do.
|
||||
@@ -0,0 +1,53 @@
|
||||
<!-- Ground truth: templates/admin_archived.templ (page copy, Merge/
|
||||
Restore/Delete row actions, Purge All Archived, retention sentence),
|
||||
internal/config/config.go (ARCHIVE_RETENTION_DAYS, default 90, 0 =
|
||||
manual purge only), internal/handlers/media.go (MergeArchivedMediaItem
|
||||
— reading progress/annotations/collections merge, per-user collisions
|
||||
keep the active copy), templates/admin_library.templ (entry banner).
|
||||
Re-verify against these when the page changes. -->
|
||||
|
||||
# Archived Items
|
||||
|
||||
Archived items are books whose files went missing from disk. Bookhoard
|
||||
doesn't delete them on the first absence: a library-wide scan that can't
|
||||
find a file marks the item missing, and only when the file is still absent
|
||||
for **two consecutive scans** does the item move to the archive. Until
|
||||
then — or after — the reader's history is preserved, so a file that comes
|
||||
back (or a move the scanner detects automatically) restores the item with
|
||||
its reading data intact.
|
||||
|
||||
You reach this page from the [Libraries](libraries.md) page, which shows
|
||||
an **Archived items** banner with the count whenever anything is archived.
|
||||
|
||||
## What the page shows
|
||||
|
||||
A table of every archived item: book title and author, the file path that
|
||||
went missing, its library, and a status column. When the list is empty you
|
||||
get the good news instead: "Nothing is archived or missing. All clear."
|
||||
|
||||
## Retention
|
||||
|
||||
By default archived items are **permanently deleted after 90 days**
|
||||
(configured with the `ARCHIVE_RETENTION_DAYS` environment variable; set it
|
||||
to `0` to keep archived items until you purge them by hand). The page
|
||||
header states your instance's current rule.
|
||||
|
||||
## Actions
|
||||
|
||||
| Action | What it actually does |
|
||||
| --- | --- |
|
||||
| **Merge** (only when a matching active book exists) | Folds this archived copy's reading data — progress, annotations, collections — into the active copy and removes the archived entry. If the same user has reading data on both copies, the active copy's data wins. This is the right choice when the duplicate is a leftover twin and the real book lives on. |
|
||||
| **Restore** | Puts the item back into the libraries immediately. If the file is genuinely still gone, the next scan archives it again — useful when you know the file just came back or is about to. |
|
||||
| **Delete** | Deletes the item **and its reading history** permanently. No archive, no undo. |
|
||||
|
||||
### Purge All Archived
|
||||
|
||||
The header button wipes every archived item at once — permanently, reading
|
||||
history included (a confirm dialog says exactly that). It exists for the
|
||||
"clean slate" moments; per-item **Delete** is the scalpel.
|
||||
|
||||
## Related
|
||||
|
||||
- [Scanning](scanning.md) — how scans decide what's missing, and why only
|
||||
library-wide scans (shallow or force) archive; a single-folder scan
|
||||
never marks its sibling folders' items missing.
|
||||
@@ -0,0 +1,72 @@
|
||||
<!-- Ground truth: templates/admin.templ (stats grid, File Watcher card,
|
||||
Quick Actions, Scanning panel), internal/router/frontend.go (/admin
|
||||
handler), internal/router/admin_library.go (getAdminStats),
|
||||
web/src/admin.ts (scanAllLibraries one-job-per-library, watch status
|
||||
fetch, progress rows), internal/handlers/scanner.go (watch status,
|
||||
coalescing 409). Re-verify against these when the page changes. -->
|
||||
|
||||
# Admin Dashboard
|
||||
|
||||
The dashboard at `/admin` is the admin landing page: a four-number health
|
||||
check, the file-watcher status, and the place where library-wide scans are
|
||||
launched and watched.
|
||||
|
||||
## The stats grid
|
||||
|
||||
Four counts, straight from the database, refreshed on every page load:
|
||||
|
||||
| Card | What the number counts |
|
||||
| --- | --- |
|
||||
| **Libraries** | Every library — book, comic, audiobook, and podcast libraries alike |
|
||||
| **Books** | Every item across all of them: books, comics, audiobooks, and podcast episodes |
|
||||
| **Users** | Every account on the instance |
|
||||
| **Devices** | Every registered sync device (Android, KOReader, Kobo) |
|
||||
|
||||
These are instance-wide totals — they don't filter by what any individual
|
||||
user can see.
|
||||
|
||||
## File Watcher
|
||||
|
||||
The **File Watcher** card shows how many libraries are currently under
|
||||
live watch for file changes. The watcher exists when **Auto-Scan** is
|
||||
enabled on the Settings page; the number updates on page load.
|
||||
|
||||
- Watching **0** libraries usually means auto-scan is off — turn it on in
|
||||
[Settings](settings.md) → Scanning, and the watcher arms on the next
|
||||
server start.
|
||||
- Watched libraries get shallow scans as files change, plus a periodic
|
||||
poll on the scan interval — see [Scanning](scanning.md) for exactly what
|
||||
those do.
|
||||
|
||||
## Quick Actions
|
||||
|
||||
### Force Rescan All Libraries
|
||||
|
||||
The big one. This is a **force rescan of every library**: each file is
|
||||
re-read and re-extracted with the scanner's current parsing, and hash
|
||||
identifiers are recomputed — nothing unchanged is skipped. Use it as the
|
||||
backfill tool after mass edits or scanner improvements; don't use it as a
|
||||
routine refresh (that's what the watcher and [per-library
|
||||
scans](scanning.md) are for, which skip unchanged files).
|
||||
|
||||
Mechanically it starts **one scan job per library** and reports per
|
||||
library: started, or skipped because that library already had a scan
|
||||
running (only one scan per library can run at a time).
|
||||
|
||||
### Manage Libraries
|
||||
|
||||
A shortcut to [Libraries](libraries.md), where folders, access, and
|
||||
per-library scans live.
|
||||
|
||||
## The Scanning panel
|
||||
|
||||
Whenever scans are running, a **Scanning Libraries** panel appears with a
|
||||
live progress row per library (progress bar, current file counts, and a
|
||||
per-library cancel button), plus:
|
||||
|
||||
- **Cancel all scans** — stops every queued or running scan job
|
||||
- The close button — hides the panel; scans keep running without it
|
||||
|
||||
The panel appears for any scan — the dashboard's own force rescan, or one
|
||||
started from the Libraries page. Progress updates live while the page is
|
||||
open; the jobs themselves run on the server either way.
|
||||
@@ -0,0 +1,56 @@
|
||||
<!-- Ground truth: templates/admin_hash_conflicts.templ (cards, actions,
|
||||
confirms), internal/handlers/hash_conflicts.go (ResolveHashConflict:
|
||||
keep_all marks resolved; keep reparents children to the kept copy,
|
||||
deletes the other catalog rows, records who resolved), internal/
|
||||
services/media_scanner.go (recordHashConflictIfAny), internal/services/
|
||||
hash_backfill.go (backfill-created conflicts). Re-verify against these
|
||||
when the page changes. -->
|
||||
|
||||
# Hash Conflicts
|
||||
|
||||
Hash Conflicts is the duplicate-content queue: groups of catalog items
|
||||
whose files are **byte-identical** (same SHA-256) but stored at more than
|
||||
one path in the same library. The scanner flags these as it goes — and
|
||||
the hash backfill job flags them retroactively for content that predates
|
||||
hash tracking — so both fresh scans and old libraries surface their
|
||||
duplicates here.
|
||||
|
||||
Duplicates usually mean the same book was copied into two folders, or a
|
||||
library was reorganized and both the old and new copies got scanned. The
|
||||
harm is quiet but real: split reading histories, double-counted library
|
||||
stats, and two entries for one book.
|
||||
|
||||
## What a conflict looks like
|
||||
|
||||
One card per group: a **Duplicate content** heading, a badge with the
|
||||
copy count, the library and a short fingerprint of the shared hash, and
|
||||
one row per copy — title, author, full path, file size, and a usage
|
||||
summary that's **highlighted when that copy holds reading data** (someone
|
||||
has actually read it).
|
||||
|
||||
## Resolving a group
|
||||
|
||||
Two outcomes, both confirm-guarded:
|
||||
|
||||
### Keep this copy
|
||||
|
||||
Per-copy button. Keeps the copy you chose and folds the **other copies'
|
||||
reading data into it** (their catalog rows are removed; the underlying
|
||||
files on disk are never touched — this platform's deletes act on the
|
||||
catalog, not your files). The row-level usage summaries exist precisely
|
||||
so you can pick the copy that already carries the reading history.
|
||||
|
||||
### Keep both
|
||||
|
||||
The group-level button, for duplicates that are intentional. It records
|
||||
the resolution and stops flagging this group — every copy stays in the
|
||||
catalog exactly as it is, nothing merged, nothing removed.
|
||||
|
||||
After either choice the card is replaced with the outcome, and the group
|
||||
never comes back (unless the same duplicate situation re-arises from a
|
||||
*new* scan, which creates a fresh group).
|
||||
|
||||
## When the page is empty
|
||||
|
||||
"No content duplicates detected." — the queue drains from the top; groups
|
||||
appear as scans and the backfill find them.
|
||||
@@ -0,0 +1,108 @@
|
||||
<!-- Ground truth: templates/admin_library.templ (page, modals, panel,
|
||||
delete-library warnings), internal/router/admin_library.go (create/
|
||||
edit/delete, folders, browse, visibility endpoints), internal/router/
|
||||
library.go (per-library scan route), internal/services/library_service.go
|
||||
(DeleteLibraryFolder removes the mapping only; SetLibraryVisibility),
|
||||
templates/utils.go (hxValsFolderScan), templates/admin_archived.templ.
|
||||
Re-verify against these when the page changes. -->
|
||||
|
||||
# Libraries
|
||||
|
||||
The Libraries page is where a Bookhoard instance is defined: what
|
||||
libraries exist, which folders on the server they read from, who can see
|
||||
them, and how they get scanned. Everything here is admin-only — regular
|
||||
users just see the result.
|
||||
|
||||
## Creating a library
|
||||
|
||||
**Create Library** opens a modal with three fields:
|
||||
|
||||
- **Library Name** (required) — what users see in their sidebar
|
||||
- **Description** (optional)
|
||||
- **Library Type** (required) — book, comic, audiobook, podcast; the type
|
||||
drives how the scanner parses files and how the reader presents them
|
||||
|
||||
A library with no folders can't scan — add at least one folder (below)
|
||||
before expecting anything to appear in it.
|
||||
|
||||
## The library card
|
||||
|
||||
Each library renders as a card: name, description, a type chip, a folder
|
||||
count, and two controls:
|
||||
|
||||
- **Force Rescan** — starts a force scan of this one library: every file
|
||||
re-read and re-extracted, hashes recomputed. This is the heavy,
|
||||
thorough pass ([Scanning](scanning.md) explains force vs. shallow and
|
||||
when each is worth it). If a scan is already running for the library,
|
||||
the click tells you instead of stacking a second one.
|
||||
- **Manage** — opens the library's panel, where everything else lives.
|
||||
|
||||
## The library panel
|
||||
|
||||
### Folders
|
||||
|
||||
Folders are the server-side directories the scanner walks. Each one shows
|
||||
its path with two icon actions: **Scan this folder** (a shallow scan
|
||||
scoped to just that folder's subtree — the quick "I just dropped files
|
||||
here" button) and remove-from-library (confirm-guarded).
|
||||
|
||||
Adding one: type a path, or click **Browse** to walk the server's
|
||||
filesystem in a modal (up with `..`, into a folder by clicking it,
|
||||
**Select This Folder** to fill the input), then **Add**.
|
||||
|
||||
Two things worth knowing:
|
||||
|
||||
- **Removing a folder only unregisters it.** The mapping is deleted;
|
||||
files on disk and existing catalog items are untouched. If no remaining
|
||||
folder covers those files, later library-wide scans will treat them as
|
||||
missing — the [Archived Items](archived-items.md) lifecycle takes over,
|
||||
history kept.
|
||||
- **Podcast download folders are feed-managed.** The scanner refuses to
|
||||
scan them by hand; the podcast feed system owns their contents.
|
||||
|
||||
### User Access
|
||||
|
||||
One checkbox per user. Checked (the default) means the library is visible
|
||||
to them; unchecking hides it from their browsing, apps, and OPDS —
|
||||
enforced in the database, so every listing obeys. Hiding a library never
|
||||
destroys anything: the user simply can't see it.
|
||||
|
||||
### Processing issues
|
||||
|
||||
When the library has items the scanner couldn't process, a link here
|
||||
shows the count and leads to its
|
||||
[Processing Issues](library-processing-issues.md) page.
|
||||
|
||||
### Edit Details and Delete Library
|
||||
|
||||
**Edit Details** changes the name and description. The library type is
|
||||
fixed at creation — it can't be edited later.
|
||||
|
||||
**Delete Library** is the destructive one, and its confirm dialog says
|
||||
precisely what it does:
|
||||
|
||||
- **Permanently removed, no archive or undo:** the library, its folder
|
||||
mappings, and all book records — with their reading progress,
|
||||
highlights, notes, bookmarks, and ratings.
|
||||
- **Not touched:** media files on disk. Delete the library, keep the
|
||||
files; re-create the library later and scan them fresh (with empty
|
||||
reading history).
|
||||
|
||||
## The archived banner
|
||||
|
||||
When any library has archived (missing-file) items, this page shows a
|
||||
banner with the total count and two shortcuts: **View Archived** (the
|
||||
[Archived Items](archived-items.md) page) and **Purge Archived Now** (the
|
||||
same permanent wipe the archived page's Purge All Archived performs).
|
||||
|
||||
## Every scan entry point, at a glance
|
||||
|
||||
| Where | Control | Kind |
|
||||
| --- | --- | --- |
|
||||
| This page, library card | **Force Rescan** | Force, this library |
|
||||
| This page, folder row | **Scan this folder** | Shallow, that folder |
|
||||
| Admin dashboard | **Force Rescan All Libraries** | Force, everything |
|
||||
| Settings → Scanning | **Auto-Scan** | Shallow, automatic |
|
||||
| A book's page | **Rescan** | Force, that one item |
|
||||
|
||||
Full semantics for each: [Scanning](scanning.md).
|
||||
@@ -0,0 +1,62 @@
|
||||
<!-- Ground truth: templates/admin_processing_issues.templ (stats cards,
|
||||
Retry all queue, per-issue Retry/Dismiss, severity badges),
|
||||
internal/handlers/processing_issues.go (RetryProcessingIssue — success
|
||||
closes the issue, failure returns the real error; resolve path),
|
||||
internal/router/library.go (issue routes), the deliberately-not-a-
|
||||
library-rescan design comment in the template. Re-verify against these
|
||||
when the page changes. -->
|
||||
|
||||
# Processing Issues
|
||||
|
||||
Processing Issues is each library's ledger of items the scanner could not
|
||||
fully process — a corrupt file, an unsupported format, metadata that
|
||||
wouldn't parse. It is per-library: you reach it from that library's panel
|
||||
on the [Libraries](libraries.md) page (the link only appears when the
|
||||
library has issues).
|
||||
|
||||
## What's on the page
|
||||
|
||||
Three count cards — **Errors**, **Warnings**, **Info** — show how many
|
||||
open issues sit at each severity (a card only appears when that severity
|
||||
has issues). Below them, one card per issue showing:
|
||||
|
||||
- the item's **title** and a plain-language **description** of what went
|
||||
wrong
|
||||
- **Type**, **Format**, **File** (full path), and **Library** type
|
||||
- a severity badge: red for errors, amber for warnings, neutral for info
|
||||
|
||||
## Fixing an issue
|
||||
|
||||
### Retry
|
||||
|
||||
Runs that one item back through the scanner. This is a per-item scan —
|
||||
the item's file is re-read and re-extracted exactly like a book-page
|
||||
[Rescan](scanning.md), not a pass over the library. If it succeeds, the
|
||||
issue closes itself; if it fails again, the page shows the *real* error,
|
||||
so you know why it still fails instead of staring at a stale complaint.
|
||||
|
||||
### Retry all
|
||||
|
||||
Queues every issue currently on the page and works through them **one at
|
||||
a time** — the button shows progress as "done / total" while the queue
|
||||
drains. This is deliberately not a whole-library scan: fixing a handful of
|
||||
bad files never re-reads the good ones. Per-issue **Retry** buttons are
|
||||
disabled while the queue is running.
|
||||
|
||||
When the queue finishes, the page refreshes itself from the server —
|
||||
closed issues disappear, still-failing ones stay with fresh error text.
|
||||
|
||||
### Dismiss
|
||||
|
||||
Closes an issue without re-processing — for warnings and info items you've
|
||||
looked at and judged fine (a confirm dialog guards the click). **Errors
|
||||
cannot be dismissed**: they must be fixed and successfully retried. The
|
||||
Dismiss button simply doesn't exist on an error card.
|
||||
|
||||
## How issues get here and leave
|
||||
|
||||
The scanner creates an issue whenever it fails to process an item during a
|
||||
scan. Issues leave the page three ways: a successful **Retry** (or any
|
||||
later scan that processes the item cleanly), a **Dismiss** you chose for a
|
||||
warning or info item, or the file's problem being fixed on disk and the
|
||||
next scan handling the item cleanly.
|
||||
@@ -0,0 +1,51 @@
|
||||
<!-- Ground truth: templates/admin_podcasts.templ (table, actions, orphaned
|
||||
badge, header totals, design comment: sorted by disk usage), internal/
|
||||
router/frontend.go (/admin/podcasts handler), podcast polling/download
|
||||
settings in internal/database/settings_registry.go (SettingsDefaults,
|
||||
Podcasts group). Re-verify against these when the page changes. -->
|
||||
|
||||
# All Podcasts
|
||||
|
||||
This is the maintenance view for the podcast subsystem: **every feed from
|
||||
every user**, sorted by disk usage, so the biggest disk hogs surface
|
||||
first. (The listening side — subscribing, playing, downloading episodes —
|
||||
lives in the [user podcast guide](../user/podcasts.md); this page is
|
||||
about running the thing.)
|
||||
|
||||
## The header totals
|
||||
|
||||
Above the table: how many feeds exist, how many episodes are downloaded
|
||||
versus total, and how much disk the downloads occupy instance-wide.
|
||||
|
||||
## The table
|
||||
|
||||
One row per feed:
|
||||
|
||||
| Column | Meaning |
|
||||
| --- | --- |
|
||||
| **Feed** | Title (links to its podcast page) and host |
|
||||
| **Library** | Which library holds it |
|
||||
| **Subscribers** | How many users subscribe (hover for names). **orphaned** means nobody does — usually a departed user's feed |
|
||||
| **Downloaded** | Episodes on disk / episodes the feed has published |
|
||||
| **Disk** | Space the downloaded files occupy |
|
||||
| **Last fetch** | When the feed was last checked for new episodes |
|
||||
|
||||
## Actions, per feed
|
||||
|
||||
- **Refresh** — checks the feed for new episodes right now, instead of
|
||||
waiting for the automatic poll (its interval lives in Settings →
|
||||
Podcasts).
|
||||
- **Delete files** (only when episodes are downloaded) — frees the disk
|
||||
and resets those episodes to *pending*. They re-download on demand when
|
||||
someone plays them. The confirm says the important part: **a feed
|
||||
refresh will not re-materialize them** — deleting files is safe and
|
||||
reversible by playback, not by refresh.
|
||||
- **Remove** — the admin delete: removes the feed, its episode rows, its
|
||||
downloaded files, and everyone's subscriptions. This is instance-wide
|
||||
(the confirm says so); a user removing their own subscription from the
|
||||
listening side only affects that user.
|
||||
|
||||
## When the page is empty
|
||||
|
||||
"No podcast feeds exist yet." — feeds appear when users add them (or when
|
||||
you do, from the podcast section of the app).
|
||||
@@ -0,0 +1,89 @@
|
||||
<!-- Ground truth: internal/handlers/scanner.go (ScanLibrary, StartScanner,
|
||||
per-folder validation, coalescing), internal/services/media_scanner.go
|
||||
(forceRescan semantics, steady-skip path, watcher/poll/initial
|
||||
triggers, enqueueLibraryScan force:false), internal/services/worker.go
|
||||
(SetForce), templates/admin.templ + web/src/admin.ts (dashboard
|
||||
button, one job per library), templates/admin_library.templ +
|
||||
templates/utils.go (per-library Scan force:true, per-folder scan),
|
||||
templates/admin_settings.templ (Auto-Scan, Scan Interval).
|
||||
Re-verify against these when scanning behavior changes. -->
|
||||
|
||||
# Scanning
|
||||
|
||||
Scanning is how Bookhoard's server reads your library folders and keeps the
|
||||
catalog in step with the files on disk. This page covers every scan an
|
||||
admin can trigger, what each one actually does, and what it costs — so you
|
||||
can pick the right one before clicking.
|
||||
|
||||
## The one distinction: shallow vs. force
|
||||
|
||||
Every scan is one of two kinds:
|
||||
|
||||
- **Shallow** (the default). The scanner walks the folder and skips any
|
||||
file whose size and modification time are unchanged since it was last
|
||||
seen. New and changed files get processed; untouched files cost nothing.
|
||||
Fast, and the right choice for day-to-day keeping-up.
|
||||
- **Force**. Nothing is skipped. Every file is re-read and re-extracted
|
||||
with the scanner's current parsing, and each item's hash identifiers
|
||||
(the SHA-256 fingerprints used for duplicate detection and device book
|
||||
matching) are recomputed. This is the backfill tool: run it after
|
||||
scanner improvements you want applied to already-scanned books, after
|
||||
mass edits in Calibre or on disk, or when metadata looks stale anywhere
|
||||
in a library. It is deliberately slow on big libraries.
|
||||
|
||||
## Every scan control, and what it does
|
||||
|
||||
| Control | Where | Kind | Scope |
|
||||
| --- | --- | --- | --- |
|
||||
| **Force Rescan All Libraries** | Admin dashboard → Quick Actions | Force | Every library |
|
||||
| **Force Rescan** (library row) | Admin → Libraries | Force | That library |
|
||||
| **Scan this folder** (folder-row icon) | Admin → Libraries → a library's panel | Shallow | That folder's subtree |
|
||||
| **Auto-Scan** + **Scan Interval (seconds)** | Admin → Settings → Scanning | Shallow | Every library |
|
||||
| **Retry** / **Retry all** | Admin → Libraries → Processing Issues | Per-item | Failed items only |
|
||||
| **Rescan** / **Reset to Scanned** | A book's detail page | Per-item | That one item |
|
||||
|
||||
The details behind the table:
|
||||
|
||||
- **Force Rescan All Libraries** loops over every library and starts a
|
||||
force scan job for each. Libraries that already have a scan running are
|
||||
left alone — the button reports which libraries started and which were
|
||||
skipped because a scan was already in flight.
|
||||
- **Force Rescan** on a library row is the same force rescan, scoped to
|
||||
that one library. It shares the dashboard button's semantics exactly.
|
||||
- **Scan this folder** is the only folder-sized scan, and the only shallow
|
||||
one an admin triggers by hand: unchanged files are skipped, and only
|
||||
that folder's subtree is walked. Because a partial scan can't vouch for
|
||||
what it didn't look at, it never marks items from the library's *other*
|
||||
folders as missing. Podcast download folders refuse this button — they
|
||||
are managed by the feed system, not the scanner.
|
||||
- **Auto-Scan** arms the two automatic triggers, both shallow: a
|
||||
filesystem watcher that fires as files change, and a periodic poll that
|
||||
runs every **Scan Interval (seconds)** (default 60, allowed 1–3600).
|
||||
The same shallow pass also runs once at startup when auto-scan is on.
|
||||
- **Retry** and **Retry all** on a library's Processing Issues page send
|
||||
failed items back through the scanner one at a time — deliberately not
|
||||
a library-wide scan, so fixing a few bad files never re-reads the whole
|
||||
library.
|
||||
- **Rescan** on a book's page re-extracts that item from its file and
|
||||
recomputes its hashes. **Reset to Scanned** clears your manual metadata
|
||||
edits first, so what lands is purely what the scanner derives from the
|
||||
file.
|
||||
|
||||
## Rules worth knowing
|
||||
|
||||
- **One scan per library at a time.** Starting a second scan for a library
|
||||
that already has one pending or running is refused — the dashboard
|
||||
tells you a scan is active rather than stacking a duplicate. The
|
||||
automatic triggers skip silently instead.
|
||||
- **Scans run in the background.** The dashboard's Scanning panel shows
|
||||
per-library progress while jobs run; **Cancel all scans** stops the ones
|
||||
still queued or running, and you can keep working meanwhile.
|
||||
- **Missing files archive after two consecutive scans.** When a
|
||||
library-wide scan (shallow or force) can't find a file twice in a row,
|
||||
the item is archived — reading history is kept in case the file comes
|
||||
back. The Archived Items page in this section manages the results.
|
||||
- **Metadata comes from the files themselves.** The scanner reads sidecar
|
||||
files (Calibre's `metadata.opf` in particular), embedded tags, and
|
||||
filenames — [Calibre Integration](../user/calibre-integration.md) covers
|
||||
the sidecar story. What a scan extracts is exactly what a force rescan
|
||||
re-extracts.
|
||||
@@ -0,0 +1,234 @@
|
||||
<!-- Ground truth: templates/admin_settings.templ (page structure, save
|
||||
buttons, live/restart buckets), internal/database/settings_registry.go
|
||||
(SettingDefaults: keys, types, defaults, min/max, restart flags,
|
||||
groups — the source of truth), internal/handlers/system_settings.go
|
||||
(ApplySetting validates type+bounds then reloads; UpdateScanSettings
|
||||
validates 1-3600), internal/router/frontend.go (settings page handler,
|
||||
dedicatedUI exclusion list). Re-verify against these when settings
|
||||
change — a new key lands in SettingDefaults AND schema.sql AND here. -->
|
||||
|
||||
# System Settings
|
||||
|
||||
The Settings page is every server-wide knob in one place. Each setting
|
||||
below lists its **key** (the name shown on the page and stored in the
|
||||
database), its **default**, its **valid range**, and whether it applies
|
||||
live or needs a restart.
|
||||
|
||||
## How saving works
|
||||
|
||||
The page has three separate save paths, each with its own button:
|
||||
|
||||
- **Save Settings** (top card) — commits the Base URL and Default
|
||||
Timezone together.
|
||||
- **Save Scan Settings** — commits the two scanning controls.
|
||||
- A per-row **Save** for each tunable setting — you change one value and
|
||||
save exactly that one.
|
||||
|
||||
Live tunables apply immediately. The restart bucket saves your value
|
||||
right away but only takes effect the next time the server restarts — the
|
||||
page marks that bucket in amber so it can't be missed. Invalid values are
|
||||
refused with an error; nothing silently clamps.
|
||||
|
||||
---
|
||||
|
||||
## Base URL
|
||||
|
||||
**base_url** — string, no default. The public URL of your Bookhoard
|
||||
instance (e.g. `https://books.example.com`). This is the address Bookhoard
|
||||
hands out to devices and OPDS clients — device sync, OPDS, and API
|
||||
endpoint references are built from it — so it must be the URL your other
|
||||
machines can actually reach, not `localhost`. The **URL Paths** card
|
||||
underneath shows the three addresses derived from it (`/opds`, `/api`,
|
||||
`/api/sync`) as a sanity check.
|
||||
|
||||
## System Defaults
|
||||
|
||||
**default_timezone** — string, default `UTC`. The timezone used for any
|
||||
user who hasn't picked their own (each user's profile can override it).
|
||||
This governs how the server renders dates and times for that user —
|
||||
history, stats day boundaries, timestamps. The dropdown offers a fixed
|
||||
list of zones.
|
||||
|
||||
## Scanning
|
||||
|
||||
Two of these live in their own card with **Save Scan Settings**; the last
|
||||
two appear among the Live tunables under a "Scanning" heading. All four
|
||||
are documented together here.
|
||||
|
||||
**auto_scan_enabled** — bool, default on. **Auto-Scan** in the UI. The
|
||||
system-wide switch for automatic scanning: when on, a filesystem watcher
|
||||
fires scans as files change, a periodic poll runs on the interval below,
|
||||
and a shallow scan runs at startup. When off, libraries only change when
|
||||
you scan them by hand. See [Scanning](scanning.md) for shallow vs. force.
|
||||
|
||||
**scan_poll_interval_seconds** — int, default `60`, range 1–3600.
|
||||
**Scan Interval (seconds)** in the UI. How often the automatic poll
|
||||
re-checks every library for changes. Lower is more responsive, higher is
|
||||
less background work; every tick is a shallow scan, so unchanged files
|
||||
cost almost nothing.
|
||||
|
||||
**auto_playlist_collections** — bool, default on. Live tunable. When a
|
||||
book's sidecar metadata marks its series as a *reading order*, Bookhoard
|
||||
automatically creates a collection that gathers the series in reading
|
||||
order. Turn it off to stop creating these during future scans (existing
|
||||
collections stay).
|
||||
|
||||
**scan_extractor_version** — int, default `1`, range 1–1000000. Live
|
||||
tunable. The parser version stamped on each book's scan manifest. This is
|
||||
the gradual backfill lever: raise it after a scanner improvement, and
|
||||
books scanned under the older parser get re-extracted a bounded batch per
|
||||
scan until all are caught up — no manual force rescan required. Leave it
|
||||
alone unless you know a parser change shipped; don't set it far ahead of
|
||||
the real version.
|
||||
|
||||
## Session
|
||||
|
||||
**session_duration_seconds** — int, default `604800` (7 days), range 300
|
||||
(5 minutes)–31536000 (1 year). How long a login session stays valid.
|
||||
Shorter is safer for shared machines; longer means fewer re-logins on
|
||||
personal devices.
|
||||
|
||||
## Password Quality
|
||||
|
||||
Five settings that together define every new password — set at
|
||||
registration, created by an admin, or changed by a user. Existing
|
||||
passwords are never retroactively invalidated; the rules apply when a
|
||||
password is next set.
|
||||
|
||||
**password_min_length** — int, default `8`, range 1–128. Minimum
|
||||
character count.
|
||||
|
||||
**password_require_upper** — bool, default on. At least one uppercase
|
||||
letter (A-Z).
|
||||
|
||||
**password_require_lower** — bool, default on. At least one lowercase
|
||||
letter (a-z).
|
||||
|
||||
**password_require_number** — bool, default on. At least one digit (0-9).
|
||||
|
||||
**password_require_special** — bool, default on. At least one special
|
||||
character.
|
||||
|
||||
The Users page's create form and the change-password form build their
|
||||
live requirement checklists from these exact values.
|
||||
|
||||
## Public Registration
|
||||
|
||||
**registration_enabled** — bool, default on. Whether visitors can create
|
||||
their own accounts from the sign-up page. Off, the instance is
|
||||
invite-by-admin: accounts are created only from the
|
||||
[Users](users.md) page (admins can always create accounts).
|
||||
|
||||
## OPDS Catalog
|
||||
|
||||
Page-size controls for the OPDS catalog (what apps like KOReader use to
|
||||
browse your library over the network).
|
||||
|
||||
**opds_default_page_size** — int, default `50`, range 1–500. How many
|
||||
entries a catalog page contains when the client doesn't ask for a
|
||||
specific size.
|
||||
|
||||
**opds_max_page_size** — int, default `200`, range 1–1000. The ceiling
|
||||
on what a client may request — a protection against one app asking for
|
||||
enormous pages.
|
||||
|
||||
## Device Rate Limits
|
||||
|
||||
Per-device request ceilings, per minute, by kind of request. These
|
||||
protect the server from a misbehaving or runaway device; a device that
|
||||
hits its limit is throttled until the minute rolls over. Defaults are
|
||||
sized for normal reading — raise them for very large libraries whose
|
||||
initial device sync needs more headroom.
|
||||
|
||||
**device_rate_sync_per_min** — int, default `60`, range 1–10000. Sync
|
||||
requests per minute (annotations, bookmarks, reading-state sync).
|
||||
|
||||
**device_rate_progress_per_min** — int, default `120`, range 1–10000.
|
||||
Progress updates per minute (position saves as you read).
|
||||
|
||||
**device_rate_metadata_per_min** — int, default `30`, range 1–10000.
|
||||
Metadata requests per minute.
|
||||
|
||||
## Annotation Retention
|
||||
|
||||
**annotation_tombstone_ttl_days** — int, default `30`, range 1–3650.
|
||||
When an annotation (highlight, note, bookmark) is deleted, it isn't
|
||||
destroyed instantly — a tombstone records the deletion so every other
|
||||
device learns about it on their next sync. After this many days the
|
||||
tombstone is purged for good. Longer is safer if some of your devices
|
||||
stay offline for weeks; shorter reclaims rows sooner.
|
||||
|
||||
## Conversion Cache
|
||||
|
||||
**conversion_cache_ttl_hours** — int, default `24`, range 1–720. How
|
||||
long converted files (Kobo-optimized KEPUB, for example) are kept cached
|
||||
after conversion. Longer saves repeat conversion work at the cost of
|
||||
disk; the conversion is regenerated when the cache entry expires.
|
||||
|
||||
## Auth Rate Limiting
|
||||
|
||||
*Restart required.*
|
||||
|
||||
**auth_rate_limit_per_min** — int, default `10`, range 1–10000. Global
|
||||
cap on authentication requests per minute — the brute-force damper on
|
||||
the login endpoints, server-wide. Raise it only if legitimate logins are
|
||||
being throttled (many users behind one NAT, for example).
|
||||
|
||||
## Login Lockout
|
||||
|
||||
*Restart required.* Two settings that work as a pair: after
|
||||
**login_max_attempts** consecutive failed attempts, that account waits
|
||||
**login_lockout_minutes** before accepting more tries.
|
||||
|
||||
**login_max_attempts** — int, default `5`, range 1–100.
|
||||
|
||||
**login_lockout_minutes** — int, default `15`, range 1–10080 (up to one
|
||||
week).
|
||||
|
||||
## Sync Queue
|
||||
|
||||
*Restart required.* Device-originated sync changes flow through a
|
||||
background queue instead of being written the instant they arrive.
|
||||
|
||||
**sync_queue_interval_seconds** — int, default `5`, range 1–3600. How
|
||||
often the queue flushes.
|
||||
|
||||
**sync_queue_batch_size** — int, default `50`, range 1–10000. The
|
||||
maximum number of queued items processed per flush.
|
||||
|
||||
## Worker Pool
|
||||
|
||||
*Restart required.* Background jobs — scans, sync-queue flushes, and
|
||||
other async work — run on a fixed pool of workers.
|
||||
|
||||
**worker_pool_size** — int, default `3`, range 1–100. How many jobs run
|
||||
concurrently. More workers chew through queued work faster at the cost
|
||||
of CPU and database load; on a small home server, more is not better.
|
||||
|
||||
**worker_queue_cap** — int, default `100`, range 1–10000. How many jobs
|
||||
may wait in line when every worker is busy. Work offered beyond the cap
|
||||
is refused rather than queued — a large force rescan of many libraries
|
||||
wants headroom here.
|
||||
|
||||
## Podcasts
|
||||
|
||||
How often feeds are checked and how downloads behave server-wide.
|
||||
|
||||
**podcast_poll_interval_seconds** — int, default `3600` (one hour),
|
||||
range 300 (5 minutes)–604800 (one week). How often Bookhoard checks
|
||||
feeds for new episodes. Feeds marked *complete* (podcasts that have
|
||||
ended) back off instead of being checked hourly forever.
|
||||
|
||||
**podcast_download_concurrency** — int, default `4`, range 1–16. Episode
|
||||
downloads in flight server-wide; fair-share scheduling spreads them
|
||||
across feeds so one feed can't hog the slots.
|
||||
|
||||
**podcast_download_speed_mbps** — float, default `0` (unlimited), range
|
||||
0–10000. A shared bandwidth cap, in megabits per second, across all
|
||||
podcast downloads — useful when the server's connection is also serving
|
||||
readers.
|
||||
|
||||
## About
|
||||
|
||||
The version of Bookhoard this server is running. Worth checking before
|
||||
filing a problem — and after an update, to confirm the new build landed.
|
||||
@@ -0,0 +1,65 @@
|
||||
<!-- Ground truth: templates/admin_users.templ (create form, user cards,
|
||||
last-admin guards), templates/profile_form.templ + profile_modal.templ
|
||||
(admin edit fields), internal/router/auth.go (admin user endpoints),
|
||||
internal/router/frontend.go (profile-modal route), user delete cascade
|
||||
(reading data is owned by the account and goes with it). Re-verify
|
||||
against these when the page changes. -->
|
||||
|
||||
# Users
|
||||
|
||||
User Management is where accounts are created, roles are assigned, and
|
||||
accounts are removed. Changes here are immediate — there is no approval
|
||||
flow.
|
||||
|
||||
## Creating an account
|
||||
|
||||
**+ Create User** opens the creation form (it stays hidden until clicked,
|
||||
so the page doesn't shout account creation at you — and so admins can
|
||||
still create accounts even when public self-registration is switched off
|
||||
in [Settings](settings.md) → Public Registration).
|
||||
|
||||
Fields: **Username** (3–50 characters), **Email**, **Password** plus a
|
||||
confirm, optional **First name** / **Last name**, and a **Role** of User
|
||||
or Admin. The **Password Requirements** list is generated from the
|
||||
password rules currently configured in Settings — the same rules users
|
||||
face when changing their own passwords — and the **Create User** button
|
||||
stays disabled until everything validates.
|
||||
|
||||
## The user cards
|
||||
|
||||
Every account renders as a card: username (with a **You** badge on your
|
||||
own), email, the creation date shown in your timezone, and the controls:
|
||||
|
||||
### Role
|
||||
|
||||
A dropdown — **User** or **Admin** — that applies the moment you change
|
||||
it. One guard exists: the **last admin cannot be demoted** (the dropdown
|
||||
disables itself with that explanation while only one admin exists).
|
||||
|
||||
Admins see and use the entire Administration section — libraries,
|
||||
scans, settings, this page.
|
||||
|
||||
### Edit
|
||||
|
||||
Opens an **Edit User Profile** modal with the same fields the user
|
||||
controls on their own profile — username, email, first/last name,
|
||||
**Theme**, **Default Playback Speed**, **Resume Rewind** — plus the Role
|
||||
dropdown, and a **Change Password** section. The admin version doesn't
|
||||
ask for the user's current password (only the user's own profile does).
|
||||
|
||||
### Delete
|
||||
|
||||
Removes the account permanently — and with it everything the account
|
||||
owns: devices, sessions, and reading data (progress, annotations,
|
||||
collections). The confirm dialog makes the irreversibility explicit.
|
||||
Same last-admin guard as the role dropdown: the final admin cannot be
|
||||
deleted.
|
||||
|
||||
## Roles, briefly
|
||||
|
||||
- **User** — browses, reads, listens, syncs devices; sees only the
|
||||
libraries visible to them (visibility is set per-library on the
|
||||
[Libraries](libraries.md) page).
|
||||
- **Admin** — everything a user has, plus the Administration section:
|
||||
libraries and scanning, users, system settings, hash conflicts,
|
||||
podcasts maintenance.
|
||||
@@ -737,3 +737,33 @@ you see, chained back-to-back within the live briefing set.
|
||||
Pinned (TestAudioResumeRewind): default 0, PUT "5" rides the
|
||||
chapters payload, non-allow-listed rejections change nothing,
|
||||
explicit "0" resets everywhere, omitted key leaves it untouched.
|
||||
|
||||
## 2026-11-02 — pairing codes replace the registration broadcast
|
||||
|
||||
- `POST /api/devices/register` gained one ADDITIVE response key:
|
||||
`pairing_code` (string, 6 digits) — the claim credential the
|
||||
registering device displays. Existing keys (`registration_id`,
|
||||
`auth_url`, `qr_code`, `expires_in`, `poll_interval`,
|
||||
`setup_instructions`) unchanged, so the Android app's DTO is
|
||||
untouched. Request parse set unchanged.
|
||||
- The four pinned KOReader sync surfaces (progress push/pull, library,
|
||||
bookmarks push) and the Kobo surfaces are byte-identical — pairing
|
||||
codes only touch the registration/approval path, never sync.
|
||||
- `GET /api/devices/pending` keeps its response SHAPE but is
|
||||
deprecated-by-emptiness: unclaimed registrations return to no
|
||||
account (the broadcast was the first-approve-wins hole). The keys
|
||||
`registrations`, `pending_registrations`, `total` still emit.
|
||||
- New unpinned surfaces (no prior shape to freeze):
|
||||
`POST /api/devices/claim` (pairing-code → bind to caller's account;
|
||||
HTMX callers get `HX-Refresh` instead of JSON),
|
||||
`POST /api/devices/claim/lookup` (confirm-card data, no binding),
|
||||
`POST /api/devices/register/cancel` (the registering device ends its
|
||||
own attempt; registration UUID bearer).
|
||||
- `POST /api/devices/reject/:registration_id` REMOVED — the broadcast
|
||||
list was its only consumer; unclaimed registrations are invisible by
|
||||
design, so there is nothing left for anyone to reject. Removal
|
||||
recorded here per rule 4 (user-approved plan; the route was the
|
||||
theft vector's cleanup half).
|
||||
- `GET /api/devices/approve/:registration_id` (Android self-approval)
|
||||
keeps its response shape; the registration UUID is now only ever
|
||||
known to the device that created it, since no list publishes it.
|
||||
@@ -287,6 +287,15 @@ Test-created data rules (harness cleanup depends on them): users use
|
||||
`@tests.bookhoard.internal` / `@example.com` emails, libraries and
|
||||
collections carry "test"-prefixed names, and cleanups delete test
|
||||
libraries (cascades handle the rest) rather than table-wide deletes.
|
||||
Devices attach to the per-run regular user when it doesn't matter, or
|
||||
self-delete via `TestDeviceSetup.CreateDevice` / `createContractDevice`
|
||||
/ `cleanupDeviceByIdentifier` when they attach to the shared admin —
|
||||
names lacking "test" (device-type names like "koreader", the
|
||||
"Reinstall Device" label) escaped the name sweeps for weeks and piled
|
||||
476 rows onto the live dev account; every suite now cleans its own
|
||||
rows at test end, with the harness sweep's identifier/token patterns
|
||||
(`contract-%`, `opds-folders-%`, `repro-reinstall-%`,
|
||||
`dev_contract_*`) as the drift net.
|
||||
|
||||
### Media Fixtures (audio/books)
|
||||
|
||||
|
||||
@@ -1,92 +1,103 @@
|
||||
# Alpine.js Patterns in Bookhoard
|
||||
## Book Picker Modal Pattern (SSR + HTMX + Alpine.store)
|
||||
For modals that need state persistence across HTMX swaps:
|
||||
### TypeScript
|
||||
<!-- Ground truth: web/src/alpine.ts (the @alpinejs/csp build — this is
|
||||
load-bearing for every rule below), component registrations across
|
||||
web/src (Alpine.data in admin.ts, api.ts, audio.ts, book-detail.ts,
|
||||
bookPicker.ts), the bookPicker store (web/src/bookPicker.ts), the
|
||||
dynamic-attribute patterns in templates/header.templ
|
||||
(x-data="sidebarShell('…')" whole-expression values) and
|
||||
web/src/custom-section-builder.ts (data-attributes instead of inline
|
||||
handlers — the CSP build refuses them). Re-verify when the frontend
|
||||
stack changes. -->
|
||||
|
||||
# Alpine.js patterns in Bookhoard
|
||||
|
||||
Bookhoard's Alpine runs the **`@alpinejs/csp` build** — the CSP build, so
|
||||
the app's `script-src` can stay a hash allow-list with no
|
||||
`'unsafe-eval'`. That single fact dictates most of this page: inline
|
||||
JavaScript in HTML is not evaluated, so everything expressive lives in
|
||||
registered TypeScript components.
|
||||
|
||||
## Rule one: components are registered, not inlined
|
||||
|
||||
Every `x-data` references a component registered with `Alpine.data` in
|
||||
`web/src` — the attribute value is the component name (optionally with
|
||||
arguments), never an object literal:
|
||||
|
||||
```typescript
|
||||
// web/src/admin.ts
|
||||
Alpine.data("admin", () => ({ /* state + methods */ }));
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- templates/admin.templ -->
|
||||
<body x-data="admin" class="theme-tokyo-night">
|
||||
```
|
||||
|
||||
Arguments work, but the **whole attribute value must be the expression**
|
||||
— templ does not interpolate `{ expr }` inside quoted attribute values,
|
||||
so `x-data="sidebarShell('{ col.ID }')"` would ship a literal `{ col.ID }`
|
||||
to the browser:
|
||||
|
||||
```html
|
||||
<div x-data={ "sidebarShell('" + string(mode) + "')" }>
|
||||
```
|
||||
|
||||
Free-text arguments that could contain quotes go through
|
||||
`templates.JSONArg`, never string concatenation.
|
||||
|
||||
## Stores: state that survives htmx swaps
|
||||
|
||||
Alpine's reactivity is DOM-local; when htmx swaps a node, its `x-data`
|
||||
state dies with it. State that must survive swaps lives in a store —
|
||||
`web/src/bookPicker.ts` is the canonical example:
|
||||
|
||||
```typescript
|
||||
// web/src/bookPicker.ts
|
||||
Alpine.store("bookPicker", {
|
||||
isOpen: false,
|
||||
selectedBooks: new Set<string>(),
|
||||
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.selectedBooks.clear();
|
||||
this.loadBooks();
|
||||
},
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.selectedBooks.clear();
|
||||
},
|
||||
|
||||
toggleBook(id: string) {
|
||||
if (this.selectedBooks.has(id)) {
|
||||
this.selectedBooks.delete(id);
|
||||
} else {
|
||||
this.selectedBooks.add(id);
|
||||
}
|
||||
// Trigger reactivity
|
||||
this.selectedBooks = new Set(this.selectedBooks);
|
||||
},
|
||||
|
||||
isSelected(id: string): boolean {
|
||||
return this.selectedBooks.has(id);
|
||||
},
|
||||
|
||||
get selectedCount(): number {
|
||||
return this.selectedBooks.size;
|
||||
}
|
||||
open() { /* … */ },
|
||||
toggleBook(id: string) { /* … */ },
|
||||
});
|
||||
Template
|
||||
<!-- Open button -->
|
||||
```
|
||||
|
||||
```html
|
||||
<button @click="$store.bookPicker.open()">Add Books</button>
|
||||
<!-- Modal -->
|
||||
<div x-show="$store.bookPicker.isOpen"
|
||||
x-transition
|
||||
@click.self="$store.bookPicker.close()"
|
||||
style="display: none;">
|
||||
<span x-text="$store.bookPicker.selectedBooks.size"></span>
|
||||
```
|
||||
|
||||
<!-- Search with HTMX -->
|
||||
<input @input.debounce.300ms="searchBooks($el.value)"
|
||||
hx-get="/api/media-items/filtered"
|
||||
hx-target="#book-picker-grid"
|
||||
hx-include="#filter-form" />
|
||||
The server returns HTML (checked checkboxes included); the store tracks
|
||||
selection across swaps; nobody rebuilds markup from JSON.
|
||||
|
||||
<!-- Book grid with checkboxes -->
|
||||
<div id="book-picker-grid" x-init="loadBooks()">
|
||||
<!-- Server returns HTML with checkboxes -->
|
||||
</div>
|
||||
## Local state: plain `x-data` on the smallest element
|
||||
|
||||
<!-- Selected count -->
|
||||
<span x-text="$store.bookPicker.selectedCount"></span>
|
||||
Ephemeral UI (a dropdown, a panel toggle) needs no store and no
|
||||
round-trip:
|
||||
|
||||
<!-- Submit -->
|
||||
<button @click="$store.bookPicker.submit()">Add Selected</button>
|
||||
</div>
|
||||
Key Principles
|
||||
- ✅ Alpine.store persists state across HTMX DOM swaps
|
||||
- ✅ Server returns HTML with checkbox :checked attributes
|
||||
- ✅ No client-side rendering or manual DOM manipulation
|
||||
- ✅ HTMX handles dynamic content updates
|
||||
- ✅ Alpine manages UI state only
|
||||
Dropdown Pattern (Local x-data)
|
||||
```html
|
||||
<div x-data="{ dropdownOpen: false }">
|
||||
<button @click="dropdownOpen = !dropdownOpen">Toggle</button>
|
||||
<div x-show="dropdownOpen"
|
||||
@click.outside="dropdownOpen = false"
|
||||
x-transition
|
||||
style="display: none;">
|
||||
<!-- Dropdown content -->
|
||||
</div>
|
||||
<div x-show="dropdownOpen" @click.outside="dropdownOpen = false">…</div>
|
||||
</div>
|
||||
Best Practices
|
||||
- ✅ State lives in template (x-data)
|
||||
- ✅ UI updates automatically (x-show)
|
||||
- ✅ No manual DOM manipulation in TypeScript
|
||||
- ✅ Pure business logic only in TypeScript functions
|
||||
- ✅ Use Alpine.store for global state
|
||||
- ✅ Use local x-data for component state
|
||||
- ✅ Use x-init for setup only (no data fetch for SSR pages)
|
||||
- ❌ Never use classList in TypeScript
|
||||
- ❌ Never use getElementById for show/hide
|
||||
- ❌ Never fetch data in x-init (for SSR pages)
|
||||
```
|
||||
|
||||
## The banned list — all enforced by the CSP build
|
||||
|
||||
- **No inline handlers**, in any form: `onclick=`, `onerror=`,
|
||||
templ-brace variants — hash-mode CSP refuses them unconditionally.
|
||||
Rows generated in TypeScript carry `data-*` attributes and bind
|
||||
listeners after swap (see `custom-section-builder.ts`).
|
||||
- **No `hx-on*` attributes and no `js:`-prefixed `hx-vals`** — both
|
||||
evaluate strings, which the CSP build cannot do. Plain JSON `hx-vals`
|
||||
only (`templates/utils.go` has the helper).
|
||||
- **No inline `<script>` outside `theme_flash_guard.templ`** (the one
|
||||
sanctioned pre-paint script); everything else bundles from `web/src`.
|
||||
|
||||
## Division of labor
|
||||
|
||||
The full ownership rule lives in
|
||||
[Writing UI documentation without screenshots](writing-ui-docs.md) and
|
||||
the repo's AGENTS.md: **templ decides what things look like; TypeScript
|
||||
decides what shows.** Content derived from server data is rendered by
|
||||
templ (fetch a partial with `htmx.ajax` when it changes); TypeScript
|
||||
handles pure client-local state — open/closed panels, validation,
|
||||
toasts. `classList`/`getElementById` show-hide from TypeScript is a
|
||||
defect, not a shortcut.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Android App — Design & Roadmap
|
||||
|
||||
This document describes the planned native Android client for Bookhoard: a thin, offline-first reading app that treats the Bookhoard server as its backend. The relationship is the same as the audiobookshelf app to an audiobookshelf server, or the Kindle app to Kindle cloud — the server owns the library, sync, and conflict resolution; the app is a dedicated, mobile-first reading frontend with its own UI, designed independently of the web interface.
|
||||
This document describes the planned native Android client for Bookhoard: a thin, offline-first reading app that treats the Bookhoard server as its backend. The relationship is the same as any dedicated reader app to its server: the server owns the library, sync, and conflict resolution; the app is a dedicated, mobile-first reading frontend with its own UI, designed independently of the web interface.
|
||||
|
||||
**Status**: Planning / pre-development
|
||||
**Companion repo**: `bookhoard-app` (separate repository, AGPL-3.0)
|
||||
@@ -32,7 +32,7 @@ This document describes the planned native Android client for Bookhoard: a thin,
|
||||
|
||||
### Why native Android
|
||||
|
||||
- The quality bar is the Kindle app. Page-turn latency, text layout fidelity, PDF rendering (`PdfRenderer`), and comic/manga image pipelines are platform-level strengths — and they are the *hard* parts in a WebView, not the easy parts.
|
||||
- The quality bar is a first-class native reader app. Page-turn latency, text layout fidelity, PDF rendering (`PdfRenderer`), and comic/manga image pipelines are platform-level strengths — and they are the *hard* parts in a WebView, not the easy parts.
|
||||
- Android-first removes the "share one codebase across two platforms simultaneously" constraint that motivates hybrid stacks.
|
||||
- Solo, AI-assisted development compresses the cost of native (code volume), while native's failure modes (well-documented platform APIs) are far easier to debug — alone or with AI — than cross-framework bridge/plugin bugs.
|
||||
- The target audience is the self-hosted community, best reached via GitHub Releases and F-Droid rather than app-store optimization.
|
||||
|
||||
@@ -144,9 +144,11 @@ See [Device Registration & Sync](devices/get_devices.md)
|
||||
- GET /api/devices/:id - Get device
|
||||
- PUT /api/devices/:id - Update device
|
||||
- DELETE /api/devices/:id - Delete device
|
||||
- GET /api/devices/pending - List pending registrations (admin)
|
||||
- GET /api/devices/approve/:registration_id - Approve registration (admin)
|
||||
- POST /api/devices/reject/:registration_id - Reject registration (admin)
|
||||
- GET /api/devices/pending - List pending registrations (always empty — pairing codes replaced the broadcast list)
|
||||
- GET /api/devices/approve/:registration_id - Approve registration by UUID (Android self-approval path)
|
||||
- POST /api/devices/claim - Claim a registration by its pairing code ([Pairing codes](devices/claim_device.md))
|
||||
- POST /api/devices/claim/lookup - Resolve a pairing code without binding ([Lookup](devices/lookup_pairing_code.md))
|
||||
- POST /api/devices/register/cancel - The registering device cancels its own pairing attempt ([Cancel](devices/cancel_device_registration.md))
|
||||
- POST /api/devices/:id/shelves - Add to shelf (Kobo; used by native Kobo sync, coming soon)
|
||||
- GET /api/devices/:id/shelves - Get shelf contents
|
||||
- DELETE /api/devices/:id/shelves - Remove from shelf
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
method: POST
|
||||
path: /api/devices/register/cancel
|
||||
auth: none
|
||||
area: devices
|
||||
---
|
||||
<!-- source: internal/handlers/devices.go DeviceHandler.CancelRegistration -->
|
||||
|
||||
# Cancel Device Registration
|
||||
|
||||
Let the REGISTERING device end its own pairing attempt — the plugin calls this
|
||||
when the user dismisses its waiting dialog. The `registration_id` is the
|
||||
bearer credential (same trust as the status poll): a device can only ever
|
||||
cancel its own registration, and already-claimed registrations are left alone
|
||||
so their token delivery to the device is never disrupted. Idempotent: the
|
||||
response is the same whether or not the registration still exists.
|
||||
|
||||
**Endpoint**: `POST /api/devices/register/cancel`
|
||||
**Auth**: Not required (registration UUID bearer, like the status poll)
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
## Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --------------- | ------ | -------- | ------------------------------ |
|
||||
| registration_id | string | Yes | UUID from the register response |
|
||||
|
||||
### Example Request
|
||||
|
||||
```json example-request
|
||||
{
|
||||
"registration_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json example-response
|
||||
{
|
||||
"message": "registration cancelled"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
method: POST
|
||||
path: /api/devices/claim
|
||||
auth: jwt
|
||||
area: devices
|
||||
---
|
||||
<!-- source: internal/handlers/devices.go DeviceHandler.ClaimDevice -->
|
||||
|
||||
# Claim Device (Pairing Code)
|
||||
|
||||
Bind a pending device registration to the CALLER's account by entering the
|
||||
6-digit pairing code shown on the registering device's screen. This is the
|
||||
web-side replacement for the retired broadcast-list approve: unclaimed
|
||||
registrations appear in no account's pending list, and only whoever reads the
|
||||
code — the person holding the device — can claim it.
|
||||
|
||||
**Endpoint**: `POST /api/devices/claim`
|
||||
**Auth**: JWT
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
The code is normalized before matching: spaces and dashes typed around the
|
||||
digits ("583 912") are stripped. Lookups and claims share a per-account rate
|
||||
limit (12 attempts/minute).
|
||||
|
||||
## Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------ | ------ | -------- | -------------------------------------------- |
|
||||
| pairing_code | string | Yes | The 6-digit code shown on the device's screen |
|
||||
|
||||
### Example Request
|
||||
|
||||
```json example-request
|
||||
{
|
||||
"pairing_code": "583912"
|
||||
}
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json example-response
|
||||
{
|
||||
"message": "device paired successfully",
|
||||
"device_name": "My Kindle Paperwhite",
|
||||
"device_type": "koreader",
|
||||
"approved": true
|
||||
}
|
||||
```
|
||||
|
||||
HTMX callers (the Devices page confirm button) receive `HX-Refresh` instead of
|
||||
a JSON body. On success the registering device's status poll returns the fresh
|
||||
`auth_token` and sync endpoints, exactly as in the old approve flow.
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ------------------------------------------------------ |
|
||||
| 400 | Invalid pairing code (generic — never confirms whether a registration exists) |
|
||||
| 409 | Pairing code already used, or the device identifier is registered to another user |
|
||||
| 410 | Pairing code expired |
|
||||
| 429 | Too many pairing attempts |
|
||||
|
||||
## Related
|
||||
|
||||
- `POST /api/devices/claim/lookup` — the confirm card's data, without binding
|
||||
- `POST /api/devices/register` — starts the registration and mints the code
|
||||
@@ -4,21 +4,19 @@ path: /api/devices/pending
|
||||
auth: jwt
|
||||
area: devices
|
||||
---
|
||||
<!-- source: internal/handlers/devices.go:703 DeviceHandler.ListPendingRegistrations -->
|
||||
<!-- source: internal/handlers/devices.go DeviceHandler.ListPendingRegistrations -->
|
||||
|
||||
# List Pending Device Registrations:<|end|><|start|><|start|>assistant to=edit<|channel|>analysis <|constrain|>xml<|message|><rfl:parameter name="path">
|
||||
/home/nymusicman/Code/bookhoard/docs/developer/api/devices/reject_device_registration.md
|
||||
# List Pending Device Registrations
|
||||
|
||||
List all pending device registration requests.
|
||||
Always empty by design. Unclaimed device registrations are invisible to every
|
||||
account since pairing codes took over the approval flow: first-approve-wins on
|
||||
a broadcast list let any account claim (or reject) a device it could not even
|
||||
name. Instead, the registering device shows a 6-digit pairing code, and the
|
||||
person holding the device types it into their own account's Devices page —
|
||||
`POST /api/devices/claim`.
|
||||
|
||||
**Endpoint**: `GET /api/devices/pending`
|
||||
**Auth**: Required (Admin only)
|
||||
|
||||
## Request Headers
|
||||
|
||||
| Header | Type | Required | Description |
|
||||
| ------------- | ------ | -------- | ----------------------------------- |
|
||||
| Authorization | string | Yes | Bearer token (must have admin role) |
|
||||
**Auth**: Required (JWT)
|
||||
|
||||
### Example Request
|
||||
|
||||
@@ -31,23 +29,16 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
|
||||
```json example-response
|
||||
{
|
||||
"registrations": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"device_name": "My Kobo",
|
||||
"device_type": "kobo",
|
||||
"user_email": "user@example.com",
|
||||
"created_at": "2026-02-08T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 3,
|
||||
"pending_registrations": null
|
||||
"registrations": [],
|
||||
"pending_registrations": [],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
Both `registrations` and `pending_registrations` key spellings are emitted for
|
||||
API compatibility.
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------------------------------- |
|
||||
| 401 | Invalid or expired token |
|
||||
| 403 | User does not have admin privileges |
|
||||
## Related
|
||||
|
||||
- [Register Device](register_device.md) — mints the pairing code
|
||||
- [Claim Device](claim_device.md) — the code-driven replacement for list-and-approve
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
method: POST
|
||||
path: /api/devices/claim/lookup
|
||||
auth: jwt
|
||||
area: devices
|
||||
---
|
||||
<!-- source: internal/handlers/devices.go DeviceHandler.LookupPairingCode -->
|
||||
|
||||
# Look Up Pairing Code
|
||||
|
||||
Resolve a pairing code to its pending registration WITHOUT binding it — the
|
||||
data the Devices page confirm card shows ("Pair 'My Kindle Paperwhite'?").
|
||||
Shares the per-account attempt budget with `POST /api/devices/claim`.
|
||||
|
||||
**Endpoint**: `POST /api/devices/claim/lookup`
|
||||
**Auth**: JWT
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
## Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------ | ------ | -------- | -------------------------------------------- |
|
||||
| pairing_code | string | Yes | The 6-digit code shown on the device's screen |
|
||||
|
||||
### Example Request
|
||||
|
||||
```json example-request
|
||||
{
|
||||
"pairing_code": "583912"
|
||||
}
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json example-response
|
||||
{
|
||||
"device_name": "My Kindle Paperwhite",
|
||||
"device_type": "koreader",
|
||||
"device_identifier": "koreader-hostname",
|
||||
"expires_in": 245
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ------------------------------------------------------ |
|
||||
| 400 | Invalid pairing code (generic — never confirms whether a registration exists) |
|
||||
| 409 | Pairing code already used |
|
||||
| 410 | Pairing code expired |
|
||||
| 429 | Too many pairing attempts |
|
||||
@@ -40,6 +40,7 @@ Register a new device for sync.
|
||||
"registration_id": "registration-uuid",
|
||||
"auth_url": "https://bookhoard.com/devices/approve/abc123",
|
||||
"qr_code": "data:image/png;base64,iVBORw0KG...",
|
||||
"pairing_code": "583912",
|
||||
"expires_in": 300,
|
||||
"poll_interval": 3,
|
||||
"setup_instructions": {
|
||||
@@ -48,7 +49,14 @@ Register a new device for sync.
|
||||
}
|
||||
```
|
||||
|
||||
Open `auth_url` (or scan the QR code) while logged in to approve; the registration expires after 5 minutes. Poll `POST /api/devices/register/status` at `poll_interval` seconds until `status` is `approved`, at which point the response includes the device's `auth_token`, `device_id`, and `sync_endpoints`.
|
||||
`pairing_code` is the claim credential: the registering device displays it, and the
|
||||
only person who can pair the device is whoever reads it (they type it into the
|
||||
Devices page of THEIR account — `POST /api/devices/claim`). Unclaimed registrations
|
||||
appear in no account's pending list, so there is no first-approver race.
|
||||
Registrations expire after 5 minutes; re-registering the same `device_identifier`
|
||||
retires the earlier attempt. Poll `POST /api/devices/register/status` at
|
||||
`poll_interval` seconds until `status` is `approved`, at which point the response
|
||||
includes the device's `auth_token`, `device_id`, and `sync_endpoints`.
|
||||
|
||||
## Error Responses
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
method: POST
|
||||
path: /api/devices/reject/:registration_id
|
||||
auth: jwt
|
||||
area: devices
|
||||
---
|
||||
<!-- source: internal/handlers/devices.go:664 DeviceHandler.RejectDevice -->
|
||||
|
||||
# Reject Device Registration
|
||||
|
||||
Reject a pending device registration request.
|
||||
|
||||
**Endpoint**: `POST /api/devices/reject/:registration_id`
|
||||
**Auth**: Required (Admin only)
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------------- | ------------- | -------- | ------------------------- |
|
||||
| registration_id | string (UUID) | Yes | Registration request UUID |
|
||||
|
||||
## Request Headers
|
||||
|
||||
| Header | Type | Required | Description |
|
||||
| ------------- | ------ | -------- | ----------------------------------- |
|
||||
| Authorization | string | Yes | Bearer token (must have admin role) |
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
POST /api/devices/reject/550e8400-e29b-41d4-a716-446655440000
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json example-response
|
||||
{
|
||||
"message": "device registration rejected"
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------------------------------- |
|
||||
| 401 | Invalid or expired token |
|
||||
| 403 | User does not have admin privileges |
|
||||
| 404 | Registration not found |
|
||||
| 400 | Registration already processed |
|
||||
@@ -1,6 +1,7 @@
|
||||
# WebSocket Protocol
|
||||
|
||||
Real-time sync events broadcast to connected clients.
|
||||
Real-time sync events, delivered only to the acting user's connected
|
||||
devices.
|
||||
|
||||
## Connect to WebSocket
|
||||
|
||||
@@ -24,15 +25,16 @@ All messages are JSON objects with a `type` field.
|
||||
|
||||
### Client → Server Messages
|
||||
|
||||
#### Ping (Heartbeat)
|
||||
None are interpreted. The socket is a one-directional push feed — the
|
||||
server logs (but ignores) anything a client sends. All reads and writes
|
||||
go through the REST API.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ping"
|
||||
}
|
||||
```
|
||||
#### Keepalive
|
||||
|
||||
Keep connection alive. Server responds with `pong`.
|
||||
The server sends a WebSocket **ping frame every 30 seconds**; a
|
||||
connection that misses pongs for 90 seconds is removed. Standard
|
||||
WebSocket clients (browsers included) answer pongs automatically —
|
||||
there is nothing to implement.
|
||||
|
||||
### Server → Client Messages
|
||||
|
||||
|
||||
@@ -1,95 +1,90 @@
|
||||
# Developer Documentation
|
||||
|
||||
Welcome to the Bookhoard developer documentation. This section contains API references, protocol specifications, and technical documentation for integrating with Bookhoard.
|
||||
The developer tier of Bookhoard's docs: **how it works** — wire formats,
|
||||
data access, pipelines, and the rules the codebase enforces on you.
|
||||
(User docs tell you what things do; admin docs what the controls do;
|
||||
this section is the machinery itself.) Everything here is written
|
||||
against the source and kept honest by gates — if a doc and the code
|
||||
disagree, one of them fails `make test`.
|
||||
|
||||
## 📚 API Documentation
|
||||
## Start here: architecture, setup, testing
|
||||
|
||||
### Complete API Reference
|
||||
**[Development Guide](../contributing/development.md)** — the working
|
||||
developer's page: directory structure, backend components, database
|
||||
schema, tech stack, local setup, code generation (sqlc / templ /
|
||||
vite), build targets, and the full testing doctrine with its rules.
|
||||
|
||||
- **[API Reference](api/api-reference.md)** - Complete REST API reference
|
||||
- Authentication
|
||||
- User management
|
||||
- Libraries
|
||||
- Media items
|
||||
- Reading progress
|
||||
- Notes & highlights
|
||||
- Analytics
|
||||
- Book matching
|
||||
- OPDS
|
||||
- Sync protocols (KOReader, Kobo)
|
||||
- WebSocket
|
||||
## API Reference
|
||||
|
||||
### Split Endpoint Documentation
|
||||
Two hundred endpoint documents, one per route, generated-adjacent and
|
||||
gate-checked against the router (a route without a doc fails the build,
|
||||
and so does a doc whose request examples drift from the Bruno
|
||||
collection):
|
||||
|
||||
Browse individual endpoint documentation:
|
||||
- **[API Reference overview](api/api-reference.md)** — every area, auth
|
||||
model, and conventions
|
||||
- Entry points: [Authentication](api/authentication/login.md),
|
||||
[Users](api/users/get_profile.md),
|
||||
[Libraries](api/libraries/list_libraries.md),
|
||||
[Media Items](api/media-items/get_media_item.md),
|
||||
[Progress](api/progress/get_media_progress.md),
|
||||
[Notes](api/notes/get_notes.md) ·
|
||||
[Highlights](api/highlights/get_highlights.md) ·
|
||||
[Ratings](api/ratings/get_ratings.md),
|
||||
[Devices](api/devices/get_devices.md),
|
||||
[Collections](api/collections/list_collections.md),
|
||||
[Analytics](api/analytics/get_analytics.md),
|
||||
[Book Matching](api/book-matching/search_books.md),
|
||||
[OPDS](api/opds/feeds.md),
|
||||
[Scanner](api/scanner/overview.md),
|
||||
[Admin](api/admin/list_users.md)
|
||||
- The machine-readable mirror lives at `docs/developer/openapi.yaml`
|
||||
(regenerated by the parity test, never hand-edited).
|
||||
|
||||
- **[API Documentation](api/api-reference.md)** - Split endpoint docs
|
||||
- [Authentication](api/authentication/login.md) - Register, login, tokens
|
||||
- [Users](api/users/get_profile.md) - Profile management
|
||||
- [Libraries](api/libraries/list_libraries.md) - Library operations
|
||||
- [Media Items](api/media-items/get_media_item.md) - Book/ebook management
|
||||
- [Progress](api/progress/get_media_progress.md) - Reading progress tracking
|
||||
- [Notes](api/notes/get_notes.md) - User notes
|
||||
- [Highlights](api/highlights/get_highlights.md) - Book highlights
|
||||
- [Ratings](api/ratings/get_ratings.md) - Book ratings
|
||||
- [Devices](api/devices/get_devices.md) - Device management
|
||||
- [Analytics](api/analytics/get_analytics.md) - Usage statistics
|
||||
- [Book Matching](api/book-matching/search_books.md) - Search and link books
|
||||
- [Collections](api/collections/list_collections.md) - Collection management
|
||||
- [OPDS](api/opds/feeds.md) - Open Publication Distribution
|
||||
- [Sync Protocols](api/sync/koreader-protocol.md) - KOReader and Kobo sync
|
||||
- [WebSocket](api/websocket/protocol.md) - Real-time events
|
||||
## Protocols
|
||||
|
||||
### Collections API
|
||||
- **[KOReader Sync Protocol](api/sync/koreader-protocol.md)** — progress,
|
||||
metadata, conflicts; the plugin's server side
|
||||
- **[Kobo Sync Protocol](api/sync/kobo-protocol.md)** — native Kobo
|
||||
sync (server-complete; device rollout pending — use
|
||||
[KOReader](api/sync/koreader-protocol.md) on Kobo hardware today)
|
||||
- **[WebSocket wire format](websocket-api.md)** — real-time events:
|
||||
message catalog, keepalive, fan-out and backpressure contracts
|
||||
- **[WebSocket endpoint](api/websocket/protocol.md)** — connection
|
||||
parameters and endpoint-level reference
|
||||
|
||||
- **[Collections API](api/collections/list_collections.md)** - Complete collections API reference
|
||||
- Create and manage collections
|
||||
- Auto-assign rules
|
||||
- Test rules
|
||||
- Bulk operations
|
||||
- Device shelf mappings
|
||||
## Under the hood
|
||||
|
||||
## 🔌 Protocol Specifications
|
||||
- **[Library Visibility](library-visibility.md)** — how per-user data
|
||||
isolation actually works: Postgres RLS, the `app.user_id` GUC,
|
||||
ScopedDB, and the rules new code must follow (the database enforces;
|
||||
the app declares identity)
|
||||
- **[Scanner audio metadata](scanner/audio-metadata.md)** — the sidecar
|
||||
and tag extraction priority for audiobooks
|
||||
- **[Calibre OPF Implementation](../development/calibre-opf-implementation.md)** —
|
||||
how Calibre sidecar metadata becomes catalog rows
|
||||
- **[Frontend Integration Notes](../FRONTEND_INTEGRATION.md)** — the
|
||||
tag & contributor normalization contract (display fields vs. search
|
||||
fields, and why both exist)
|
||||
- **[Android App — Design & Roadmap](android-app.md)** — the native
|
||||
client's design goals and platform posture
|
||||
|
||||
### Sync Protocols
|
||||
## Standards this docs tree enforces
|
||||
|
||||
- **[Kobo Sync Protocol](api/sync/kobo-protocol.md)** - Kobo device sync protocol
|
||||
- Kobo markup sync
|
||||
- Library fetch
|
||||
- Authentication
|
||||
- **[Writing UI documentation without screenshots](writing-ui-docs.md)** —
|
||||
the standard for every UI page in this repo
|
||||
- **[UI control glossary](ui-controls.md)** — the canonical spelling of
|
||||
every control the docs cite (the label gate checks it against the
|
||||
templates)
|
||||
- **[Contract Pins](../contract_pins.md)** — the API contract changelog;
|
||||
loosening a pinned assertion requires an entry there
|
||||
|
||||
- **[KOReader Sync Protocol](api/sync/koreader-protocol.md)** - KOReader sync protocol
|
||||
- Progress sync
|
||||
- Metadata fetch
|
||||
- Conflict handling
|
||||
|
||||
### WebSocket Protocol
|
||||
|
||||
- **[WebSocket API](websocket-api.md)** - Real-time sync and events
|
||||
- Connection flow
|
||||
- Message format
|
||||
- Event types
|
||||
- Authentication
|
||||
|
||||
## 🛠 Implementation Guides
|
||||
|
||||
### Library Visibility
|
||||
|
||||
- **[Library Visibility](library-visibility.md)** - Per-user library
|
||||
visibility: the family-lens rule. New media endpoints MUST thread the
|
||||
visible-library set (helpers, SQL pattern, series per-library).
|
||||
|
||||
### Calibre Integration
|
||||
|
||||
- **[Calibre OPF Implementation](../development/calibre-opf-implementation.md)** - Technical implementation details
|
||||
- Architecture and design
|
||||
- Data structures and functions
|
||||
- Database schema mapping
|
||||
- Testing strategy
|
||||
- Error handling
|
||||
- Performance considerations
|
||||
The gates behind all of it: routes ↔ docs coverage, docs ↔ Bruno
|
||||
parity, relative-link resolution, UI-label existence, and the RLS
|
||||
invariant test. Adding a route without a doc — or renaming a button the
|
||||
docs quote — fails the suite, by design.
|
||||
|
||||
---
|
||||
|
||||
**Looking for user documentation?** See the [User Portal](../user/user-guide.md)
|
||||
**Need to deploy?** See the [Operations Portal](../operations/operations.md)
|
||||
**Looking for user documentation?** See the [User Portal](../user/user-guide.md).
|
||||
**Need to deploy or operate?** See the [Operations Portal](../operations/operations.md).
|
||||
@@ -1,54 +1,88 @@
|
||||
# Library Visibility (family-lens rule)
|
||||
<!-- Ground truth: database/schema/schema.sql (RLS policy block: p_media_items_*, p_podcast_feeds_*, app_can_read_collection / app_can_write_collection — labeled, idempotent, re-run every boot), internal/database/scoped.go (ScopedDB: per-statement app.user_id GUC, seed fallback = test-harness only), internal/rls (identity helpers), the three identity bridges (router JWT + cookie/device, middleware/device_auth.go), TestRlsInvariant (internal docs gate suite / API black-box). Re-verify against these when the data-access model changes. -->
|
||||
|
||||
Every endpoint that returns media metadata or content must respect
|
||||
per-user **library visibility**: the `library_visibility` table maps
|
||||
`(user_id, library_id, is_visible)` and defaults to **ALLOW** — every
|
||||
user sees every library unless an admin explicitly hides one from them.
|
||||
The point of the system is the family case: an admin hides the adult
|
||||
library from a child's account, and no trace of that library (titles,
|
||||
covers, series, page images) may reach the child's account.
|
||||
# Library Visibility — how per-user data isolation actually works
|
||||
|
||||
## The rule for new endpoints
|
||||
Every user sees every library **unless an admin hides it from them**
|
||||
(`library_visibility` table, default **ALLOW** — the family-lens case:
|
||||
hide the adult library from a child's account, and no trace of it —
|
||||
titles, covers, series, page images — may reach that account).
|
||||
|
||||
Any endpoint that lists, filters, or details media/series/books:
|
||||
The one thing to understand before writing any code that touches media:
|
||||
|
||||
1. Filter by the requesting user's visible-library set.
|
||||
2. Reject an explicit `library_id` outside that set with **403** —
|
||||
never silently ignore or fall back.
|
||||
3. Content endpoints (file bytes, page images) gate per item, like
|
||||
`ReaderHandler.ensureItemVisible`.
|
||||
> **Enforcement lives in the DATABASE, as Postgres row-level security.
|
||||
> The application declares *who* is asking; it never computes *what*
|
||||
> they may see.**
|
||||
|
||||
If you forget, hidden libraries leak. This exact leak shipped: the
|
||||
series endpoints were name-only across ALL libraries and the PDF
|
||||
thumbnail endpoint served page images of hidden books (fixed 2026-10-13,
|
||||
bookhoard `431fec0`).
|
||||
## The engine
|
||||
|
||||
## Helpers — do not hand-roll
|
||||
`database/schema/schema.sql` carries a labeled RLS block (re-run
|
||||
idempotently on every boot). Governed tables — `media_items`,
|
||||
`podcast_feeds`, `collections`, `collection_items` — have policies that
|
||||
filter every row against the `library_visibility` mapping using the
|
||||
`app.user_id` GUC: a per-transaction `SET LOCAL` declared by
|
||||
`ScopedDB` (`internal/database/scoped.go`), which wraps the pool so
|
||||
**every sqlc statement** runs inside a transaction that declares the
|
||||
acting user.
|
||||
|
||||
- `(*MediaHandler).visibleLibrarySet` /
|
||||
`(*SeriesHandler).visibleLibrarySet` — returns a membership map plus
|
||||
the id slice (map for param checks, slice for SQL `ANY()`).
|
||||
- `router.visibleLibraryIDsFor` — same for SSR routes.
|
||||
- `resolveLibrary` (router/helpers.go) only honors a requested
|
||||
`library_id` that is inside the caller's visible set.
|
||||
- SQL pattern (see `ListMediaItems`, `GetDistinctSeries`,
|
||||
`SearchMediaItems`):
|
||||
- A statement whose context carries an identity sees only that user's
|
||||
visible rows.
|
||||
- A statement with **no identity raises** on a governed table — the
|
||||
engine fails loud instead of leaking. Ungoverned tables (auth lookups,
|
||||
settings, sessions) are unaffected and keep working.
|
||||
- Collections use predicate functions (`app_can_read_collection`,
|
||||
`app_can_write_collection`) that are PURE over their arguments — reads
|
||||
are owner-or-admin, writes are owner (admin escape for SYSTEM
|
||||
collections only). Future sharing = one EXISTS branch inside the
|
||||
predicate, no policy migration.
|
||||
|
||||
```sql
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL
|
||||
OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
```
|
||||
## The rules for new code
|
||||
|
||||
## Known-safe surfaces
|
||||
1. **New media-listing queries need NO visibility clause and NO
|
||||
plumbing.** If the statement goes through `ScopedDB` with a declared
|
||||
identity, the policy already filters it.
|
||||
2. **The app declares identity; it never computes visibility.** Three
|
||||
bridges put the identity on the request context: the JWT middleware,
|
||||
the cookie/device middleware, and the KOReader/Kobo device auth
|
||||
middleware. Everything downstream just carries the context.
|
||||
3. **A media read on a detached `context.Background()` RAISES.** If you
|
||||
need detached work (background jobs, websocket fan-in), thread the
|
||||
acting identity explicitly — `rls.WithUser(ctx, userID)` — the way
|
||||
scan jobs carry the library owner (first-admin fallback) and the
|
||||
websocket `initial_state` carries the connecting user.
|
||||
4. **NEVER**: session-scoped GUCs (pooled-connection identity bleed), a
|
||||
bypass flag, or connecting the app as a superuser (superusers bypass
|
||||
RLS unconditionally — the app runs as `bookhoard_srv`, and the
|
||||
`db-init` compose service provisions that role at startup).
|
||||
5. **Schema changes go in `schema.sql` in place**, idempotent
|
||||
(`DROP .. IF EXISTS` + `CREATE`) — it re-runs on every boot. The
|
||||
migration transaction declares a synthetic identity because the
|
||||
schema's own dedup pass reads `media_items`.
|
||||
|
||||
- OPDS device catalogs filter by the device owner's visible libraries.
|
||||
- Search (`SearchMediaItems`) is visibility-scoped.
|
||||
- File download, reader page render, and page content gate per item.
|
||||
## How this gets audited
|
||||
|
||||
- **`TestRlsInvariant`** — API black-box: two users, one hidden library,
|
||||
every media-returning surface checked. New media-returning surfaces
|
||||
must be added to it.
|
||||
- **The Playwright sweep** (`tests/ui/`) — user-visible pages checked
|
||||
against the coverage list.
|
||||
- **`docs/contract_pins.md`** — contract-level pins (a loosening requires
|
||||
a changelog entry there).
|
||||
|
||||
## Why the rule is written in blood
|
||||
|
||||
The pre-RLS app-side approach shipped a real leak (2026-10-13,
|
||||
`431fec0`): series endpoints were name-only across ALL libraries and the
|
||||
PDF thumbnail endpoint served page images of hidden books — every
|
||||
hand-rolled filter had to be remembered at every call site, and one
|
||||
forgotten clause leaked. On 2026-10-29 enforcement moved into the
|
||||
database, and the invariant test went red → green to prove the old
|
||||
helpers were no longer needed. If you find yourself writing a
|
||||
`visible_library_ids` filter by hand, you are re-creating the bug.
|
||||
|
||||
## Series are per-library
|
||||
|
||||
Series are grouped by `(library_id, series)` — the same series name in
|
||||
two libraries is two entries (manga adaptations, LN splits). Do not
|
||||
re-merge them cross-library. `/api/series/books` REQUIRES a visible
|
||||
`library_id`; `/api/series` without one aggregates only the caller's
|
||||
visible libraries.
|
||||
Series group by `(library_id, series)` — the same series name in two
|
||||
libraries is two entries (manga adaptations, LN splits). Do not re-merge
|
||||
them cross-library. `/api/series/books` requires a visible `library_id`;
|
||||
`/api/series` without one aggregates only the caller's visible libraries
|
||||
(the policy makes "visible" automatic).
|
||||
+49
-11
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Bookhoard API
|
||||
version: '2026.10.27'
|
||||
version: '2026.11.02'
|
||||
description: |
|
||||
Generated from the canonical API docs (docs/developer/api/**) by
|
||||
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
|
||||
@@ -395,7 +395,7 @@ paths:
|
||||
}
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
description: Success
|
||||
"/api/conflicts":
|
||||
get:
|
||||
tags: [conflicts]
|
||||
@@ -557,6 +557,38 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
"/api/devices/claim":
|
||||
post:
|
||||
tags: [devices]
|
||||
summary: "Claim Device (Pairing Code)"
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
example: |-
|
||||
{
|
||||
"pairing_code": "583912"
|
||||
}
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
"/api/devices/claim/lookup":
|
||||
post:
|
||||
tags: [devices]
|
||||
summary: "Look Up Pairing Code"
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
example: |-
|
||||
{
|
||||
"pairing_code": "583912"
|
||||
}
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
"/api/devices/pending":
|
||||
get:
|
||||
tags: [devices]
|
||||
@@ -583,6 +615,21 @@ paths:
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
"/api/devices/register/cancel":
|
||||
post:
|
||||
tags: [devices]
|
||||
summary: "Cancel Device Registration"
|
||||
security: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
example: |-
|
||||
{
|
||||
"registration_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
"/api/devices/register/status":
|
||||
post:
|
||||
tags: [devices]
|
||||
@@ -598,15 +645,6 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
"/api/devices/reject/{registration_id}":
|
||||
post:
|
||||
tags: [devices]
|
||||
summary: "Reject Device Registration"
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
"/api/devices/{deviceId}/sync/link-book":
|
||||
post:
|
||||
tags: [book-matching]
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
# Testing
|
||||
|
||||
This document describes how to run and write tests for Bookhoard.
|
||||
|
||||
## Test Users and Email Domains
|
||||
|
||||
### Test Email Domain
|
||||
|
||||
All integration tests use the email domain **`@tests.bookhoard.internal`** for test users. This domain is reserved exclusively for testing and will never be used in production.
|
||||
|
||||
**Why this domain?**
|
||||
|
||||
- Clear indication it's for testing only
|
||||
- Won't conflict with real user emails (which use real domains like `@gmail.com`, `@example.com`, etc.)
|
||||
- Safe cleanup: Tests can delete all users with this domain without risk to production data
|
||||
- If a real user somehow uses `@tests.bookhoard.internal`, they are explicitly opting into test data deletion
|
||||
|
||||
### Standard Test Users
|
||||
|
||||
The test suite creates these standard users:
|
||||
|
||||
1. **Admin User**
|
||||
- Email: `testuser@tests.bookhoard.internal`
|
||||
- Username: `testuser`
|
||||
- Password: `Test@Pass123!`
|
||||
- Role: `admin`
|
||||
|
||||
2. **Regular User**
|
||||
- Email: `testregularuser@tests.bookhoard.internal`
|
||||
- Username: `testregularuser`
|
||||
- Password: `Test@Pass123!`
|
||||
- Role: `user`
|
||||
|
||||
### Test Lifecycle and Cleanup
|
||||
|
||||
Each test run:
|
||||
|
||||
1. **Deletes** all existing users with `@tests.bookhoard.internal` email
|
||||
2. **Creates** fresh standard test users
|
||||
3. **Runs** the test with clean state
|
||||
4. **Cleans up** via `t.Cleanup()` to delete users created during the test
|
||||
|
||||
This ensures complete test isolation - no state leaks between tests.
|
||||
|
||||
### Writing Tests
|
||||
|
||||
When writing new tests:
|
||||
|
||||
**✅ DO:**
|
||||
|
||||
- Use the standard test users from `setupTestServer(t)`
|
||||
- Use `createRegularUserOnce(t, setup.DB)` for additional test users
|
||||
- Let the test framework handle cleanup
|
||||
- Use the `@tests.bookhoard.internal` domain if creating custom test users
|
||||
|
||||
**❌ DON'T:**
|
||||
|
||||
- Use real email domains like `@example.com` or `@gmail.com`
|
||||
- Manually manage test user deletion (unless absolutely necessary)
|
||||
- Assume test users persist between test runs
|
||||
- Hardcode user IDs (always look up by email/username)
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run All Tests
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
### Run Integration Tests Only
|
||||
|
||||
```bash
|
||||
make test-integration
|
||||
```
|
||||
|
||||
### Run Specific Test
|
||||
|
||||
```bash
|
||||
go test -v -run TestUpdateProfile ./cmd/server/tests/
|
||||
```
|
||||
|
||||
### Run with Race Detection
|
||||
|
||||
```bash
|
||||
go test -race ./cmd/server/tests/
|
||||
```
|
||||
|
||||
## Test Organization
|
||||
|
||||
Tests are located in `cmd/server/tests/`:
|
||||
|
||||
- `test_helpers.go` - Test utilities and setup functions
|
||||
- `*_test.go` - Test files organized by feature
|
||||
- `user_test.go` - User management tests
|
||||
- `auth_test.go` - Authentication tests
|
||||
- `media_test.go` - Media item tests
|
||||
- etc.
|
||||
|
||||
### Key Test Helpers
|
||||
|
||||
- `setupTestServer(t *testing.T)` - Creates test server with clean DB state
|
||||
- `createTestUserOnce(t, db)` - Gets/creates standard admin user
|
||||
- `createRegularUserOnce(t, db)` - Gets/creates additional test users
|
||||
- `loginWithCredentials(t, server, email, password)` - Returns auth token
|
||||
|
||||
## Bruno API Tests
|
||||
|
||||
API integration tests in `bruno/` also use the test email domain:
|
||||
|
||||
- Login test: `testuser@tests.bookhoard.internal`
|
||||
- Register test: `testuser@tests.bookhoard.internal`
|
||||
- Profile update test: `updated@tests.bookhoard.internal`
|
||||
|
||||
See: [Bruno Documentation](https://docs.usebruno.com/)
|
||||
@@ -182,9 +182,13 @@ label: Filters
|
||||
where: All Books / Podcasts page → toolbar
|
||||
does: Opens the filter drawer for building and applying rules.
|
||||
|
||||
label: Register Device
|
||||
where: Devices page
|
||||
does: Submits a device registration request for the pending-approval flow.
|
||||
label: Find device
|
||||
where: Devices page → Add New Device modal
|
||||
does: Looks up the pairing code shown on the registering e-reader and renders the confirm card.
|
||||
|
||||
label: Pair device
|
||||
where: Devices page → Add New Device modal → confirm card
|
||||
does: Binds the device behind the pairing code to the signed-in account.
|
||||
|
||||
label: Download All
|
||||
where: Podcast page → episodes area
|
||||
@@ -212,6 +216,122 @@ label: Scan Interval (seconds)
|
||||
where: Settings page → Scanning section
|
||||
does: Sets the seconds between automatic scans.
|
||||
|
||||
label: Force Rescan All Libraries
|
||||
where: /admin dashboard → Quick Actions
|
||||
does: Force-rescans every library — re-reads each file, re-extracts metadata with original-scan semantics, and recomputes hash identifiers. Unlike the shallow per-library and per-folder scans, nothing unchanged is skipped; this is the admin's backfill tool.
|
||||
also: Scan All Libraries
|
||||
note: renamed 2026-11-02 — the old label under-promised a heavy operation
|
||||
|
||||
label: Force Rescan
|
||||
where: Admin → Libraries → a library's card row
|
||||
does: Force-rescans that one library — re-reads and re-extracts every file in it. Same semantics as Force Rescan All Libraries, scoped to a single library.
|
||||
also: Scan
|
||||
note: renamed 2026-11-02 per owner — the label now carries the force semantics instead of hiding them
|
||||
|
||||
label: Scan this folder
|
||||
where: Admin → Libraries → a library's panel → a folder row
|
||||
does: Shallow-scans only that folder's subtree — unchanged files are skipped, and items in the library's other folders are never marked missing by a partial scan.
|
||||
|
||||
label: Retry
|
||||
where: Admin → Libraries → a library's Processing Issues page → an issue card
|
||||
does: Re-runs that one item through the scanner (a per-item scan, not a library pass). Success closes the issue; failure shows the real error.
|
||||
|
||||
label: Retry all
|
||||
where: Admin → Libraries → a library's Processing Issues page → beside the severity cards
|
||||
does: Queues every issue on the page and re-runs them one at a time, showing done/total progress. Deliberately not a whole-library scan.
|
||||
|
||||
label: Dismiss
|
||||
where: Admin → Libraries → Processing Issues → warning and info cards only
|
||||
does: Closes the issue without re-processing, after a confirm. Error-severity issues cannot be dismissed.
|
||||
|
||||
label: Purge All Archived
|
||||
where: Admin → Archived Items → page header
|
||||
does: Permanently deletes every archived item and its reading history, after a confirm. The per-item alternative is Delete.
|
||||
|
||||
label: Merge
|
||||
where: Admin → Archived Items → rows with a matching active book
|
||||
does: Folds the archived copy's reading data (progress, annotations, collections) into the active copy and removes the archived entry; per-user collisions keep the active copy's data.
|
||||
|
||||
label: Delete
|
||||
where: Admin → Archived Items rows, and other admin destructive rows
|
||||
does: Permanently removes the item and its reading history — no archive, no undo. Every Delete is confirm-guarded and states the scope.
|
||||
|
||||
label: Create Library
|
||||
where: Admin → Libraries → page header
|
||||
does: Opens the create-library modal (name, optional description, library type). Type is fixed at creation — it cannot be edited later.
|
||||
|
||||
label: Manage
|
||||
where: Admin → Libraries → a library's card row
|
||||
does: Opens that library's panel: folders, user access, processing issues, edit/delete.
|
||||
|
||||
label: Edit Details
|
||||
where: Admin → Libraries → a library's panel
|
||||
does: Edits the library's name and description. Library type is not editable.
|
||||
|
||||
label: Delete Library
|
||||
where: Admin → Libraries → a library's panel
|
||||
does: Permanently deletes the library, its folder mappings, and all book records with reading progress, highlights, notes, bookmarks, and ratings. Files on disk are not touched.
|
||||
|
||||
label: View Archived
|
||||
where: Admin → Libraries → the archived banner
|
||||
does: Opens the Archived Items page.
|
||||
|
||||
label: Purge Archived Now
|
||||
where: Admin → Libraries → the archived banner
|
||||
does: Same permanent wipe as Purge All Archived on the Archived Items page, offered inline.
|
||||
|
||||
label: Browse
|
||||
where: Admin → Libraries → a library's panel → folder form
|
||||
does: Opens the server-side folder browser: walk the filesystem, Select This Folder fills the path input.
|
||||
|
||||
label: User Access
|
||||
where: Admin → Libraries → a library's panel → section heading
|
||||
does: Per-user visibility checkboxes — checked (default) shows the library to that user; unchecked hides it everywhere for them, enforced in the database.
|
||||
|
||||
label: Keep both
|
||||
where: Admin → Hash Conflicts → a conflict group card
|
||||
does: Records the resolution and stops flagging the group; every copy stays, nothing merged or removed. For intentional duplicates.
|
||||
|
||||
label: Keep this copy
|
||||
where: Admin → Hash Conflicts → a copy row
|
||||
does: Keeps that copy, folds the other copies' reading data into it, and removes their catalog rows. Files on disk are never touched.
|
||||
|
||||
label: Delete files
|
||||
where: Admin → All Podcasts → a feed row
|
||||
does: Frees the disk and resets downloaded episodes to pending; they re-download on demand. A feed refresh will NOT re-materialize them.
|
||||
|
||||
label: Remove
|
||||
where: Admin → All Podcasts → a feed row
|
||||
does: Admin-wide feed removal — episode rows, downloaded files, and every user's subscription are deleted. Instance-wide, unlike a user's own unsubscribe.
|
||||
|
||||
label: + Create User
|
||||
where: Admin → Users → page header
|
||||
does: Opens the account-creation form (hidden until clicked). Available even when public registration is disabled; password rules mirror the live Settings.
|
||||
|
||||
label: Edit User Profile
|
||||
where: Admin → Users → a user card → Edit
|
||||
does: The admin edit modal — profile fields, role, and Change Password without requiring the user's current password.
|
||||
|
||||
label: Save Settings
|
||||
where: Admin → Settings → top card
|
||||
does: Commits the Base URL and Default Timezone together.
|
||||
|
||||
label: Save Scan Settings
|
||||
where: Admin → Settings → Scanning card
|
||||
does: Commits Auto-Scan and the Scan Interval.
|
||||
|
||||
label: Tunable Settings — Live
|
||||
where: Admin → Settings → section heading
|
||||
does: Settings that apply immediately on save, grouped by area.
|
||||
|
||||
label: Tunable Settings — Restart Required
|
||||
where: Admin → Settings → section heading
|
||||
does: Settings whose saves take effect only after the server restarts; the bucket is marked in amber.
|
||||
|
||||
label: Default Timezone
|
||||
where: Admin → Settings → System Defaults card
|
||||
does: The timezone used for any user who hasn't chosen their own; governs how dates and times render for them.
|
||||
|
||||
label: Resume rewind
|
||||
where: audio player → player settings; profile settings
|
||||
does: Rewinds playback a configured few seconds when you resume.
|
||||
|
||||
+75
-685
@@ -1,700 +1,90 @@
|
||||
# WebSocket API Documentation
|
||||
<!-- Ground truth: internal/sync/websocket.go (ConnectionManager, message
|
||||
constants, broadcast fan-out + drop-on-full backpressure),
|
||||
internal/handlers/websocket.go (dual auth, initial_state on connect,
|
||||
30s ping / 90s read deadline, handleMessage logs-only),
|
||||
internal/router/sync.go (route group), internal/services/worker.go
|
||||
(scan event emitters). Re-verify against these when the wire
|
||||
changes. Rewritten 2026-11-02 from source; the Feb-era version
|
||||
documented client->server messages that the server never acted on. -->
|
||||
|
||||
**Base URL**: `ws://localhost:8765/ws/sync`
|
||||
**Protocol**: WebSocket (RFC 6455)
|
||||
**Authentication**: Required (JWT token or device token)
|
||||
# WebSocket API (real-time events)
|
||||
|
||||
---
|
||||
One channel, one direction: `/ws/sync` is a **server → client push feed**
|
||||
for sync and scan events. It is not a command channel — clients do all
|
||||
reading and writing through the REST API, and the server logs (but does
|
||||
not act on) anything a client sends. The one client obligation is the
|
||||
standard WebSocket pong, below.
|
||||
|
||||
## Table of Contents
|
||||
## Connecting
|
||||
|
||||
1. [Authentication](#authentication)
|
||||
2. [Connection](#connection)
|
||||
3. [Message Format](#message-format)
|
||||
4. [Message Types](#message-types)
|
||||
5. [Client Implementation Guide](#client-implementation-guide)
|
||||
6. [Examples](#examples)
|
||||
7. [Troubleshooting](#troubleshooting)
|
||||
`WS /ws/sync` — two accepted identities:
|
||||
|
||||
---
|
||||
| Method | How |
|
||||
| --- | --- |
|
||||
| User (JWT) | `?token=<jwt>` query parameter |
|
||||
| Device (KOReader, Kobo) | `Authorization: Bearer <device-token>` header |
|
||||
|
||||
## Authentication
|
||||
No token → `401`. On connect the server immediately sends one
|
||||
**`initial_state`** message: the connecting identity's media items for
|
||||
sync, read under that identity's own data visibility.
|
||||
|
||||
### Option 1: JWT Token (Web/Mobile Clients)
|
||||
## Message envelope
|
||||
|
||||
Pass JWT token as query parameter:
|
||||
|
||||
```
|
||||
ws://localhost:8765/ws/sync?token=<your_jwt_token>
|
||||
```
|
||||
|
||||
**How to get JWT token**:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8765/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"user@example.com","password":"password"}'
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"user": {
|
||||
"id": "user-uuid",
|
||||
"email": "user@example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Option 2: Device Token (Kobo, KOReader, etc.)
|
||||
|
||||
Pass device token in Authorization header:
|
||||
|
||||
```
|
||||
ws://localhost:8765/ws/sync?token=any-value
|
||||
Authorization: Bearer <device_token>
|
||||
```
|
||||
|
||||
**How to get device token**:
|
||||
Device tokens are generated when devices are registered via the API.
|
||||
|
||||
---
|
||||
|
||||
## Connection
|
||||
|
||||
### Step 1: Connect to WebSocket
|
||||
|
||||
**JavaScript Example**:
|
||||
|
||||
```javascript
|
||||
const token = "your-jwt-token";
|
||||
const ws = new WebSocket(`ws://localhost:8765/ws/sync?token=${token}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("Connected to Bookhoard WebSocket");
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log("WebSocket closed:", event.code, event.reason);
|
||||
};
|
||||
```
|
||||
|
||||
**Go Example**:
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
url := "ws://localhost:8765/ws/sync?token=" + token
|
||||
ws, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
if err != nil {
|
||||
log.Fatal("Dial error:", err)
|
||||
}
|
||||
defer ws.Close()
|
||||
```
|
||||
|
||||
**Python Example**:
|
||||
|
||||
```python
|
||||
import websocket
|
||||
|
||||
token = "your-jwt-token"
|
||||
url = f"ws://localhost:8765/ws/sync?token={token}"
|
||||
|
||||
def on_message(ws, message):
|
||||
print(f"Received: {message}")
|
||||
|
||||
def on_error(ws, error):
|
||||
print(f"Error: {error}")
|
||||
|
||||
def on_close(ws, close_status_code, close_msg):
|
||||
print("Closed")
|
||||
|
||||
def on_open(ws):
|
||||
print("Connected")
|
||||
|
||||
ws = websocket.WebSocketApp(
|
||||
url,
|
||||
on_open=on_open,
|
||||
on_message=on_message,
|
||||
on_error=on_error,
|
||||
on_close=on_close
|
||||
)
|
||||
|
||||
ws.run_forever()
|
||||
```
|
||||
|
||||
### Step 2: Handle Initial State
|
||||
|
||||
Immediately after connecting, you'll receive the initial state:
|
||||
|
||||
```javascript
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.type === "initial_state") {
|
||||
console.log("Initial progress:", message.data.progress);
|
||||
console.log("Connected devices:", message.data.devices);
|
||||
|
||||
// Store initial state
|
||||
initialState = message.data;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Step 3: Handle Real-time Updates
|
||||
|
||||
Listen for subsequent updates:
|
||||
|
||||
```javascript
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
switch (message.type) {
|
||||
case "progress_update":
|
||||
handleProgressUpdate(message);
|
||||
break;
|
||||
case "annotation_update":
|
||||
handleAnnotationUpdate(message);
|
||||
break;
|
||||
case "conflict":
|
||||
handleConflict(message);
|
||||
break;
|
||||
case "heartbeat":
|
||||
// Server ping, ignore
|
||||
break;
|
||||
default:
|
||||
console.log("Unknown message type:", message.type);
|
||||
}
|
||||
};
|
||||
|
||||
function handleProgressUpdate(message) {
|
||||
const { book_id, percentage } = message.data;
|
||||
const source = message.source_device;
|
||||
|
||||
console.log(`Progress update for ${book_id}: ${percentage * 100}%`);
|
||||
console.log(`Source: ${source.name} (${source.type})`);
|
||||
|
||||
// Update UI
|
||||
updateProgressBar(book_id, percentage);
|
||||
}
|
||||
|
||||
function handleAnnotationUpdate(message) {
|
||||
const { book_id, annotation_type, data } = message.data;
|
||||
const source = message.source_device;
|
||||
|
||||
console.log(`Annotation update for ${book_id}: ${annotation_type}`);
|
||||
console.log(`Source: ${source.name}`);
|
||||
|
||||
// Update UI
|
||||
showAnnotation(book_id, data);
|
||||
}
|
||||
|
||||
function handleConflict(message) {
|
||||
const { book_id, notification_type, conflict_id } = message.data;
|
||||
|
||||
console.log(`Conflict detected for ${book_id}: ${notification_type}`);
|
||||
|
||||
// Show conflict resolution UI
|
||||
showConflictDialog(book_id, conflict_id);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Message Format
|
||||
|
||||
All messages follow this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "message_type",
|
||||
"timestamp": "2026-02-01T12:00:00Z",
|
||||
"data": {
|
||||
// Message-specific data
|
||||
},
|
||||
"source_device": {
|
||||
"id": "device-uuid",
|
||||
"name": "Device Name",
|
||||
"type": "kobo|koreader|web|mobile"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `type` (string, required): Message type identifier
|
||||
- `timestamp` (string, required): ISO 8601 timestamp
|
||||
- `data` (object, required): Message payload
|
||||
- `source_device` (object, optional): Device that sent the update (not present for initial_state)
|
||||
|
||||
---
|
||||
|
||||
## Message Types
|
||||
|
||||
### 1. initial_state
|
||||
|
||||
**When**: Immediately after connection
|
||||
|
||||
**Purpose**: Send current progress for all user's books
|
||||
|
||||
**Data Structure**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "initial_state",
|
||||
"timestamp": "2026-02-01T12:00:00Z",
|
||||
"data": {
|
||||
"progress": {
|
||||
"<book_uuid>": {
|
||||
"percentage": 0.5,
|
||||
"current_page": 150,
|
||||
"total_pages": 300,
|
||||
"last_read": "2026-02-01T11:30:00Z"
|
||||
}
|
||||
},
|
||||
"devices": {
|
||||
"kobo": 2,
|
||||
"koreader": 1,
|
||||
"web": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `progress` (object): Map of book UUID → progress data
|
||||
- `percentage` (number): 0.0 to 1.0
|
||||
- `current_page` (number): Current page number
|
||||
- `total_pages` (number): Total pages in book
|
||||
- `last_read` (string): ISO 8601 timestamp of last read
|
||||
- `devices` (object): Connection statistics by device type
|
||||
|
||||
### 2. progress_update
|
||||
|
||||
**When**: Any device syncs reading progress
|
||||
|
||||
**Purpose**: Notify all connected clients of progress change
|
||||
|
||||
**Data Structure**:
|
||||
Every server message is a JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "progress_update",
|
||||
"timestamp": "2026-02-01T12:00:00Z",
|
||||
"data": {
|
||||
"book_id": "book-uuid-1",
|
||||
"percentage": 0.75
|
||||
},
|
||||
"source_device": {
|
||||
"id": "device-uuid-1",
|
||||
"name": "My Kobo Clara",
|
||||
"type": "kobo"
|
||||
}
|
||||
"timestamp": "2026-11-02T12:00:00Z",
|
||||
"data": { "...": "..." },
|
||||
"source_device": { "id": "…", "name": "Living-room Kobo", "type": "kobo" }
|
||||
}
|
||||
```
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `book_id` (string): UUID of book with updated progress
|
||||
- `percentage` (number): New progress value (0.0 to 1.0)
|
||||
- `source_device` (object): Device that sent the update
|
||||
|
||||
**Use Cases**:
|
||||
|
||||
- Update progress bar in real-time
|
||||
- Sync reading position across devices
|
||||
- Update "currently reading" lists
|
||||
|
||||
### 3. annotation_update
|
||||
|
||||
**When**: Any device syncs annotations, bookmarks, or highlights
|
||||
|
||||
**Purpose**: Share annotations across devices
|
||||
|
||||
**Data Structure**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "annotation_update",
|
||||
"timestamp": "2026-02-01T12:00:00Z",
|
||||
"data": {
|
||||
"book_id": "book-uuid-1",
|
||||
"annotation_type": "bookmark",
|
||||
"data": {
|
||||
"page": 150,
|
||||
"text": "Great quote on page 150",
|
||||
"chapter": 5,
|
||||
"created_at": "2026-02-01T12:00:00Z"
|
||||
}
|
||||
},
|
||||
"source_device": {
|
||||
"id": "device-uuid-1",
|
||||
"name": "My Kobo Clara",
|
||||
"type": "kobo"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `book_id` (string): UUID of book
|
||||
- `annotation_type` (string): Type of annotation (bookmark, highlight, note)
|
||||
- `data` (object): Annotation-specific data
|
||||
- `page` (number): Page number
|
||||
- `text` (string): Annotated text or note
|
||||
- `chapter` (number): Chapter number (optional)
|
||||
- `created_at` (string): ISO 8601 timestamp
|
||||
- `source_device` (object): Device that created the annotation
|
||||
|
||||
**Use Cases**:
|
||||
|
||||
- Show bookmarks on all devices
|
||||
- Share highlights between devices
|
||||
- Display reading notes
|
||||
|
||||
### 4. conflict
|
||||
|
||||
**When**: Sync conflict is detected
|
||||
|
||||
**Purpose**: Notify user of conflicting updates
|
||||
|
||||
**Data Structure**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "conflict",
|
||||
"timestamp": "2026-02-01T12:00:00Z",
|
||||
"data": {
|
||||
"book_id": "book-uuid-1",
|
||||
"notification_type": "progress_conflict",
|
||||
"conflict_id": "conflict-uuid-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `book_id` (string): UUID of book with conflict
|
||||
- `notification_type` (string): Type of conflict (progress_conflict, annotation_conflict)
|
||||
- `conflict_id` (string): UUID of conflict record
|
||||
|
||||
**Use Cases**:
|
||||
|
||||
- Prompt user to resolve conflict
|
||||
- Show conflict resolution UI
|
||||
- Log conflict for manual review
|
||||
|
||||
### 5. heartbeat
|
||||
|
||||
**When**: Server sends ping every 30 seconds
|
||||
|
||||
**Purpose**: Keep connection alive
|
||||
|
||||
**Data Structure**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "heartbeat",
|
||||
"timestamp": "2026-02-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Handling**: Clients should respond automatically (handled by WebSocket protocol)
|
||||
|
||||
---
|
||||
|
||||
## Client Implementation Guide
|
||||
|
||||
### Reconnection Strategy
|
||||
|
||||
Implement exponential backoff for reconnection:
|
||||
|
||||
```javascript
|
||||
let reconnectAttempts = 0;
|
||||
const maxReconnectAttempts = 10;
|
||||
const baseReconnectDelay = 1000; // 1 second
|
||||
|
||||
function connect() {
|
||||
const token = getAuthToken();
|
||||
const ws = new WebSocket(`ws://localhost:8765/ws/sync?token=${token}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("Connected");
|
||||
reconnectAttempts = 0; // Reset on successful connection
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (reconnectAttempts < maxReconnectAttempts) {
|
||||
const delay = baseReconnectDelay * Math.pow(2, reconnectAttempts);
|
||||
console.log(`Reconnecting in ${delay}ms...`);
|
||||
|
||||
setTimeout(() => {
|
||||
reconnectAttempts++;
|
||||
connect();
|
||||
}, delay);
|
||||
} else {
|
||||
console.error("Max reconnection attempts reached");
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = handleIncomingMessage;
|
||||
|
||||
return ws;
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Handle common errors:
|
||||
|
||||
```javascript
|
||||
ws.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
|
||||
// Check specific error types
|
||||
if (error.code === 1006) {
|
||||
console.error("Abnormal closure - server may be down");
|
||||
} else if (error.code === 1008) {
|
||||
console.error("Policy violation - check authentication");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Message Queueing
|
||||
|
||||
Queue messages while disconnected:
|
||||
|
||||
```javascript
|
||||
let messageQueue = [];
|
||||
let isConnected = false;
|
||||
|
||||
function queueMessage(message) {
|
||||
if (isConnected) {
|
||||
ws.send(JSON.stringify(message));
|
||||
} else {
|
||||
messageQueue.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
isConnected = true;
|
||||
|
||||
// Send queued messages
|
||||
while (messageQueue.length > 0) {
|
||||
const message = messageQueue.shift();
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
isConnected = false;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Complete JavaScript Client
|
||||
|
||||
```javascript
|
||||
class BookhoardWebSocket {
|
||||
constructor(token) {
|
||||
this.token = token;
|
||||
this.ws = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.messageHandlers = {};
|
||||
}
|
||||
|
||||
connect() {
|
||||
const url = `ws://localhost:8765/ws/sync?token=${this.token}`;
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log("Connected to Bookhoard");
|
||||
this.reconnectAttempts = 0;
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
this.handleMessage(message);
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.reconnect();
|
||||
};
|
||||
}
|
||||
|
||||
handleMessage(message) {
|
||||
const handler = this.messageHandlers[message.type];
|
||||
if (handler) {
|
||||
handler(message);
|
||||
}
|
||||
}
|
||||
|
||||
on(messageType, callback) {
|
||||
this.messageHandlers[messageType] = callback;
|
||||
}
|
||||
|
||||
reconnect() {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
const delay = 1000 * Math.pow(2, this.reconnectAttempts);
|
||||
console.log(`Reconnecting in ${delay}ms...`);
|
||||
|
||||
setTimeout(() => {
|
||||
this.reconnectAttempts++;
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const token = "your-jwt-token";
|
||||
const client = new BookhoardWebSocket(token);
|
||||
|
||||
client.on("initial_state", (message) => {
|
||||
console.log("Initial state received:", message.data);
|
||||
updateUIWithInitialState(message.data);
|
||||
});
|
||||
|
||||
client.on("progress_update", (message) => {
|
||||
console.log("Progress updated:", message.data);
|
||||
updateProgressBar(message.data.book_id, message.data.percentage);
|
||||
});
|
||||
|
||||
client.on("annotation_update", (message) => {
|
||||
console.log("Annotation updated:", message.data);
|
||||
showNotification("New annotation from " + message.source_device.name);
|
||||
});
|
||||
|
||||
client.on("conflict", (message) => {
|
||||
console.log("Conflict detected:", message.data);
|
||||
showConflictDialog(message.data);
|
||||
});
|
||||
|
||||
client.connect();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Fails
|
||||
|
||||
**Problem**: Can't connect to WebSocket
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check JWT token is valid
|
||||
2. Verify server is running: `curl http://localhost:8765/health`
|
||||
3. Check firewall settings
|
||||
4. Verify WebSocket URL format
|
||||
|
||||
### Connection Drops Frequently
|
||||
|
||||
**Problem**: WebSocket disconnects unexpectedly
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check network stability
|
||||
2. Verify server keepalive settings
|
||||
3. Implement reconnection logic (see above)
|
||||
4. Check server logs for errors
|
||||
|
||||
### No Messages Received
|
||||
|
||||
**Problem**: Connected but no messages
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check onmessage handler is registered
|
||||
2. Verify initial_state message received
|
||||
3. Test with manual progress update via API
|
||||
4. Check browser console for errors
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
**Problem**: 401 Unauthorized
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Verify JWT token is not expired
|
||||
2. Check token has correct claims (user_id)
|
||||
3. For device tokens, verify Authorization header format
|
||||
4. Regenerate token via `/api/auth/login`
|
||||
|
||||
### High Memory Usage
|
||||
|
||||
**Problem**: Client memory increases over time
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Clean up old messages
|
||||
2. Don't store entire message history
|
||||
3. Use weak references for large data
|
||||
4. Implement message pagination
|
||||
|
||||
---
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Debounce UI Updates**: Don't update DOM on every message
|
||||
|
||||
```javascript
|
||||
const debouncedUpdate = debounce(updateUI, 100);
|
||||
client.on("progress_update", (message) => {
|
||||
debouncedUpdate(message);
|
||||
});
|
||||
```
|
||||
|
||||
2. **Use Web Workers** for heavy processing
|
||||
3. **Limit Message History**: Keep only last N messages
|
||||
4. **Virtual Scrolling**: For large lists of updates
|
||||
5. **Connection Pooling**: Reuse connections across tabs
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Use WSS in Production**: Always use secure WebSocket (wss://) in production
|
||||
2. **Validate Tokens**: Always verify JWT tokens on server
|
||||
3. **Rate Limiting**: Implement message rate limiting per connection
|
||||
4. **Input Sanitization**: Sanitize all message data before displaying
|
||||
5. **Origin Checks**: Validate WebSocket origin on server
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
|
||||
- GitHub Issues: [Bookhoard Repository]
|
||||
- Documentation: [Bookhoard Docs]
|
||||
- API Reference: [Bookhoard API Docs]
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-02-01
|
||||
**Version**: 1.0.0
|
||||
**Protocol**: WebSocket (RFC 6455)
|
||||
`source_device` is present on events that originate from a device
|
||||
(progress and annotation updates), so clients can ignore their own echo.
|
||||
|
||||
## Message catalog
|
||||
|
||||
All ten types defined by `internal/sync/websocket.go`:
|
||||
|
||||
| Type | Emitted | `data` carries |
|
||||
| --- | --- | --- |
|
||||
| `initial_state` | Once, on connect | The connecting user's media items for sync |
|
||||
| `progress_update` | On any device's progress save | `book_id`, `percentage` |
|
||||
| `annotation_update` | On annotation changes (highlights, notes, bookmarks) | `book_id`, `annotation_type`, payload |
|
||||
| `conflict` | When a sync conflict is detected | `book_id`, `notification_type`, `conflict_id` |
|
||||
| `scan_progress` | During scan jobs | Job progress for the admin scan panel |
|
||||
| `scan_complete` | When a scan job finishes | Job summary |
|
||||
| `scan_error` | When a scan job fails | Job error |
|
||||
| `scan_cancelled` | When a scan job is cancelled | Job identity |
|
||||
| `collection_updated` | When books are added to / removed from one of *your* collections | `collection_id`, `action` (`books_added` / `book_removed` / `books_removed`), affected book ids |
|
||||
| `heartbeat` | *Defined, not currently emitted* | — |
|
||||
| `sync_complete` | *Defined, not currently emitted* | — |
|
||||
|
||||
(`collection_updated` is currently the only message type defined as a raw
|
||||
string in the collections handler rather than in the sync package's
|
||||
constants block.)
|
||||
|
||||
## Keepalive
|
||||
|
||||
The server sends a WebSocket **ping frame every 30 seconds** and expects
|
||||
the standard pong within a **90-second read deadline**. Browsers and
|
||||
every mainstream WebSocket library answer pings automatically — there is
|
||||
nothing to implement. A connection that stops answering is removed.
|
||||
|
||||
## Fan-out and backpressure — read this before building a client
|
||||
|
||||
- **Every event carries an explicit audience.** Progress, annotation,
|
||||
conflict, and collection events are delivered only to the acting
|
||||
user's connections — the ConnectionManager has no accidental
|
||||
fan-out, and the compiler forces every emitter to name its audience
|
||||
(2026-11-02, after cross-user delivery was found and fixed). The one
|
||||
sanctioned instance-wide fan-out is **scan events**
|
||||
(`BroadcastToAll`): they carry job ids and counts, never user data,
|
||||
and the sitewide scan indicator in every browser runs on them.
|
||||
- Every connection has a bounded send queue. **If a client falls behind,
|
||||
messages for that connection are skipped, not queued.** Clients must
|
||||
therefore treat the socket as a *hint* — on reconnect (or any gap),
|
||||
refetch real state over REST. The fresh `initial_state` on reconnect is
|
||||
designed to be that gap-healer.
|
||||
@@ -74,9 +74,24 @@ make the page followable even if that label is renamed next week.
|
||||
genuinely needs (say, *rule*) is explained once, in place, then used
|
||||
consistently. The reader is assumed to know books, while the app's own
|
||||
words are the only technical vocabulary allowed.
|
||||
- **Developer and Administration sections — tone is a separate discussion,
|
||||
not yet decided.** Write them plainly and hand-off-rich as well; do not
|
||||
import opinions about their voice until that pass happens.
|
||||
- **Administration (`docs/admin/`) — plain language, one layer deeper.**
|
||||
Same bones as the user docs: task-first, what to click for what, exact
|
||||
quoted labels. Added: every control also states what the click *actually
|
||||
does* — semantics, cost, and consequences — before the reader clicks it.
|
||||
The canonical example: **Force Rescan All Libraries** re-reads and
|
||||
re-extracts every file and recomputes hashes, while the other scan
|
||||
buttons are shallow scans that skip unchanged files — an admin should
|
||||
know that before clicking, not after. Every setting states what it is,
|
||||
what it does, what it means when that is confusing, and its valid range.
|
||||
No API-client detail: request and response schemas live in the developer
|
||||
docs. The calibration in one line: **user = what you see, admin = what
|
||||
it does, developer = how it works.**
|
||||
- **Developer section — source-pinned and wire-accurate.** The
|
||||
developer tier is *how it works*: wire formats, data access, pipeline
|
||||
mechanics, and enforced rules, every claim grounded in source the way
|
||||
admin docs ground behavior. External software (device installs,
|
||||
third-party tooling) is linked to its project's documentation, not
|
||||
paraphrased. (Decided 2026-11-02, closing the earlier open question.)
|
||||
|
||||
## Generate what has a registry
|
||||
|
||||
@@ -109,6 +124,18 @@ resolve, links still resolve, affected pages re-read against the new
|
||||
surface. The pattern layers keep users oriented between audits; the audit
|
||||
keeps layers 3–5 honest.
|
||||
|
||||
## Promises need approval
|
||||
|
||||
Docs never promise future behavior on the owner's behalf. No "coming soon",
|
||||
"under consideration", "in a future release", or any other forward-looking
|
||||
commitment gets written without the owner explicitly approving that exact
|
||||
promise — and anything speculative found in the docs gets reported, not
|
||||
quietly kept and not quietly deleted. (Rule origin, 2026-11-02: the owner
|
||||
ruled out "Kindle and reMarkable are under consideration" and a "Custom
|
||||
Calibre Columns (Future)" section — no such plans existed — while the
|
||||
Operations guide's pre-existing "Coming Soon" placeholders were reported
|
||||
and knowingly kept for the future operations-docs pass.)
|
||||
|
||||
## What this does not replace
|
||||
|
||||
The filter documentation (and the older filtering overview) is hands-off
|
||||
|
||||
+15
-1
@@ -32,7 +32,21 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
- [Themes](user/themes.md)
|
||||
- [Calibre Integration](user/calibre-integration.md)
|
||||
- [Profile Guide](user/profile-guide.md)
|
||||
- [Admin Guide](user/admin-guide.md)
|
||||
|
||||
### 🛡 For Administrators
|
||||
|
||||
**[Admin Guide](admin/admin-guide.md)** - running a Bookhoard instance: every admin page, what each control actually does
|
||||
|
||||
- **Administration**
|
||||
- [Admin Dashboard](admin/dashboard.md) - stats, Force Rescan All Libraries, scan progress
|
||||
- [Libraries](admin/libraries.md) - create libraries, folders, user access, scanning
|
||||
- [Processing Issues](admin/library-processing-issues.md) - items the scanner couldn't process
|
||||
- [Archived Items](admin/archived-items.md) - missing files, history-preserving archive
|
||||
- [All Podcasts](admin/podcasts.md) - the maintenance view for every feed
|
||||
- [Hash Conflicts](admin/hash-conflicts.md) - duplicate content resolution
|
||||
- [Users](admin/users.md) - accounts, roles, password resets
|
||||
- [System Settings](admin/settings.md) - every setting: key, default, range, meaning
|
||||
- [Scanning](admin/scanning.md) - shallow vs. force: what every scan button does
|
||||
|
||||
### 👨💻 For Developers
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Admin Guide
|
||||
|
||||
**Coming Soon**
|
||||
|
||||
This guide will cover:
|
||||
|
||||
- User management
|
||||
- Library management
|
||||
- System monitoring
|
||||
- Analytics and reporting
|
||||
- Security settings
|
||||
- Backup and restore
|
||||
|
||||
---
|
||||
|
||||
_In the meantime, check out our [Operations Portal](../operations/operations.md)_
|
||||
@@ -1,32 +0,0 @@
|
||||
# Admin Library Management
|
||||
|
||||
## Adding Library Folders
|
||||
|
||||
When creating or managing a library, you can add folders containing your media files (ebooks, comics, manga).
|
||||
|
||||
### Using the Folder Browser
|
||||
|
||||
The admin library page includes a folder browser to help you select folders on the server:
|
||||
|
||||
1. Open the **Administration** panel in the sidebar (admins only) and go to **Libraries**
|
||||
2. Click a library in the list to expand its panel
|
||||
3. Find the **Folders** section
|
||||
4. Click **Browse** next to the folder path input — this opens the **Browse Folders** dialog
|
||||
5. Navigate through the server's filesystem
|
||||
6. Select a folder; it fills the path input, then click **Add**
|
||||
|
||||
### Security
|
||||
|
||||
- The folder browser only shows directories (not files)
|
||||
- Path traversal is protected (cannot access parent directories with `..`)
|
||||
- Only admin users can browse the filesystem
|
||||
|
||||
### Manual Entry
|
||||
|
||||
Alternatively, you can manually type the full server path if you know it:
|
||||
|
||||
```
|
||||
/home/user/books
|
||||
/media/external/ebooks
|
||||
/var/lib/manga
|
||||
```
|
||||
@@ -248,16 +248,6 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Custom Calibre Columns (Future)
|
||||
|
||||
Bookhoard may support Calibre custom columns in a future release. This would allow you to import:
|
||||
- Read status
|
||||
- Favorite flags
|
||||
- Custom metadata fields
|
||||
- User-defined categories
|
||||
|
||||
Stay tuned for updates!
|
||||
|
||||
### OPDS Integration
|
||||
|
||||
You can access your Bookhoard library (including Calibre-imported books) via OPDS from OPDS-capable clients:
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<!-- Ground truth: bookhoard.koplugin main.lua (menu tree, defaults, approval
|
||||
<!-- Ground truth: bookhoard.koplugin main.lua (menu tree, defaults, pairing
|
||||
flow) + the server-side koreader endpoints. Re-verify against the plugin
|
||||
source when the plugin changes. -->
|
||||
source when the plugin changes. KOReader ITSELF is out of scope: this
|
||||
guide supports the plugin only, and installation is delegated to the
|
||||
KOReader project's own documentation (README #installation, verified
|
||||
2026-11-02 — per-device wiki pages for Android, Kindle, Kobo,
|
||||
PocketBook, reMarkable, and more). -->
|
||||
|
||||
# KOReader Device Setup Guide
|
||||
|
||||
This guide will help you set up KOReader on your e-reader device to sync with
|
||||
Bookhoard — reading position, bookmarks, highlights, notes, and even your
|
||||
ratings, reviews, and reading time.
|
||||
ratings, reviews, and reading time. One scope note up front: this guide is
|
||||
about the **Bookhoard sync plugin**. Installing KOReader itself is a
|
||||
different project's documentation, covered below.
|
||||
|
||||
## What is KOReader?
|
||||
|
||||
@@ -25,58 +31,33 @@ It also runs on Android tablets and phones — though on Android the dedicated
|
||||
Before you begin, make sure you have:
|
||||
|
||||
- ✅ A Bookhoard instance running and accessible on your network
|
||||
- ✅ A web browser logged in to your Bookhoard account (for device approval)
|
||||
- ✅ A KOReader-compatible e-reader device
|
||||
- ✅ A web browser logged in to your Bookhoard account (to enter the pairing code)
|
||||
- ✅ KOReader installed and running on your e-reader (see next section)
|
||||
- ✅ Your device connected to the same Wi-Fi network as your Bookhoard instance
|
||||
|
||||
## Installing KOReader
|
||||
|
||||
### Kindle Devices
|
||||
One honest note first: **Bookhoard supports the Bookhoard sync plugin —
|
||||
not KOReader itself.** Getting KOReader onto an e-reader usually involves
|
||||
device-specific steps (jailbreaks, alternative launchers, one-click
|
||||
packages) that vary by model and firmware. That's the KOReader project's
|
||||
domain, not ours — we can't tell you how to modify your particular device,
|
||||
and we won't pretend otherwise with half-remembered instructions.
|
||||
|
||||
1. **Install KOReader**
|
||||
- Download the latest KOReader release from [koreader.rocks](https://koreader.rocks/)
|
||||
- Extract the `koreader` folder to your Kindle's root directory
|
||||
- Safely eject your Kindle from your computer
|
||||
The good news: KOReader documents this properly. Their installation guide
|
||||
routes you to model-specific steps for Android, Kindle, Kobo, PocketBook,
|
||||
reMarkable, and more:
|
||||
|
||||
2. **Launch KOReader**
|
||||
- On your Kindle, go to Home → Settings → Device Options → Personalize Your Kindle
|
||||
- Select `koreader` from the launcher options
|
||||
- Alternatively, you can use the `linkjail` method to access KOReader directly
|
||||
**→ [KOReader installation guide](https://github.com/koreader/koreader#installation)**
|
||||
|
||||
3. **Enable Wi-Fi**
|
||||
- In KOReader, tap the network icon in the top menu
|
||||
- Connect to your Wi-Fi network
|
||||
|
||||
### Kobo Devices
|
||||
|
||||
1. **Install KOReader**
|
||||
- Download the latest KOReader Kobo package from [koreader.rocks](https://koreader.rocks/)
|
||||
- Copy the `koreader` folder to your Kobo's `.adds/` directory
|
||||
- Safely eject your Kobo from your computer
|
||||
|
||||
2. **Launch KOReader**
|
||||
- Eject and disconnect your Kobo
|
||||
- Kobo will restart and you'll see KOReader as an option
|
||||
- Alternatively, create a shortcut on your home screen
|
||||
|
||||
3. **Enable Wi-Fi**
|
||||
- In KOReader, tap the network icon
|
||||
- Connect to your Wi-Fi network
|
||||
|
||||
### PocketBook Devices
|
||||
|
||||
1. **Install KOReader**
|
||||
- Download the PocketBook version from [koreader.rocks](https://koreader.rocks/)
|
||||
- Copy to your device and install via the package manager
|
||||
|
||||
2. **Launch KOReader**
|
||||
- Open KOReader from your apps menu
|
||||
- Enable Wi-Fi in the network settings
|
||||
Once KOReader itself is installed and running, everything below applies,
|
||||
whatever device you're on.
|
||||
|
||||
## Connecting KOReader to Bookhoard
|
||||
|
||||
Setup is done **on the server**: you approve the device from the Bookhoard
|
||||
web interface — no usernames, passwords, or tokens to type on the device.
|
||||
Setup is done **on the server**: your e-reader shows a pairing code, and you
|
||||
type it into your own account's Devices page — no usernames, passwords, or
|
||||
tokens to type on the device.
|
||||
|
||||
### Step 1: Install the Bookhoard Plugin
|
||||
|
||||
@@ -108,23 +89,27 @@ Then restart KOReader.
|
||||
### Step 3: Register the Device
|
||||
|
||||
1. In the same **Bookhoard sync** menu, tap **Register device**
|
||||
2. On your computer or phone, open Bookhoard and go to the **Devices** page
|
||||
(sidebar navigation)
|
||||
3. Your device appears under **Pending Device Registrations** — click
|
||||
**Approve** to connect it
|
||||
2. Your e-reader displays a **6-digit pairing code**
|
||||
3. On your computer or phone, open Bookhoard — logged into **your** account —
|
||||
and go to the **Devices** page (sidebar navigation)
|
||||
4. Type the code into the **Pair a Device** field and click **Find device**,
|
||||
then confirm on the card that appears
|
||||
|
||||
The plugin waits on the device and finishes by itself the moment you
|
||||
approve. Once approved, its credentials are stored automatically, sync
|
||||
events are armed, and Bookhoard's OPDS catalog is added to your KOReader —
|
||||
no further configuration is needed.
|
||||
The code is the only way to claim the registration, and only someone holding
|
||||
the device can read it — so the device always lands in the account of the
|
||||
person who registered it. The plugin waits on the device and finishes by
|
||||
itself the moment you confirm. Once paired, its credentials are stored
|
||||
automatically, sync events are armed, and Bookhoard's OPDS catalog is added to
|
||||
your KOReader — no further configuration is needed.
|
||||
|
||||
> **Note:** Pending registrations expire after 5 minutes. If yours expires
|
||||
> ("Registration expired"), just tap **Register device** again and approve.
|
||||
> **Note:** Pairing codes expire after 5 minutes. If yours expires
|
||||
> ("Registration expired"), just tap **Register device** again for a fresh
|
||||
> one. Dismissing the waiting dialog on the device cancels the attempt.
|
||||
|
||||
### Auth Token (Advanced)
|
||||
|
||||
The Devices page shows each KOReader device's **Auth Token**. You normally
|
||||
never need it (the plugin receives it automatically during approval), but it
|
||||
never need it (the plugin receives it automatically during pairing), but it
|
||||
can be re-entered manually in the plugin settings if you're moving a setup
|
||||
between devices or debugging.
|
||||
|
||||
@@ -136,7 +121,7 @@ sync**. The menu changes shape depending on whether you are connected.
|
||||
### Before you're connected
|
||||
|
||||
- **Server URL** — your server's address
|
||||
- **Register device** — starts the approval flow above
|
||||
- **Register device** — starts the pairing flow above
|
||||
|
||||
### Once connected — one-tap actions
|
||||
|
||||
@@ -152,7 +137,7 @@ sync**. The menu changes shape depending on whether you are connected.
|
||||
### Automatically push progress
|
||||
|
||||
This toggle is the on/off switch for background sync — **it starts off**, so
|
||||
after approval, turn it on to get automatic syncing. With it on, progress
|
||||
after pairing, turn it on to get automatic syncing. With it on, progress
|
||||
pushes as you read and on close/suspend; the plugin may prompt about turning
|
||||
Wi-Fi on at those moments depending on your device.
|
||||
|
||||
@@ -236,14 +221,26 @@ their progress syncs from the first page.
|
||||
|
||||
### Pending Registration Never Appears
|
||||
|
||||
**Problem**: You entered the Server URL, but no pending registration shows in Bookhoard
|
||||
**Problem**: You entered the Server URL, but no pairing code shows on the device
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Verify the Server URL is correct (no trailing path — just the base address)
|
||||
2. Make sure KOReader is connected to Wi-Fi
|
||||
3. Check the Bookhoard server is reachable from the device's network
|
||||
4. Registrations expire after 5 minutes — tap **Register device** again and approve quickly
|
||||
4. Registrations expire after 5 minutes — tap **Register device** again
|
||||
|
||||
### Pairing Code Not Accepted
|
||||
|
||||
**Problem**: The Devices page says "No waiting device matches that code"
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Re-read the code on the device — it refreshes on every **Register device** tap
|
||||
2. Check you typed all 6 digits (spacing doesn't matter)
|
||||
3. Codes expire after 5 minutes — register again on the device and type the new code
|
||||
4. "Pairing code already used" means the device already paired (check the device list); otherwise register again
|
||||
5. Too many attempts lock new tries for a minute — wait and retry
|
||||
|
||||
### Connection Refused
|
||||
|
||||
@@ -256,9 +253,9 @@ their progress syncs from the first page.
|
||||
- Ensure the device is on the same Wi-Fi network as the server
|
||||
- Use the server's LAN IP instead of `localhost`
|
||||
|
||||
### Sync Not Working After Approval
|
||||
### Sync Not Working After Pairing
|
||||
|
||||
**Problem**: Device shows as approved but changes don't appear in Bookhoard
|
||||
**Problem**: Device shows as paired but changes don't appear in Bookhoard
|
||||
|
||||
**Solutions**:
|
||||
|
||||
@@ -291,12 +288,14 @@ For day-to-day control of the same question on the device itself, use the
|
||||
|
||||
1. **Use HTTPS**: If exposing Bookhoard beyond your LAN, configure SSL/TLS
|
||||
2. **Network Security**: Ensure your Wi-Fi network is secure (WPA2/WPA3)
|
||||
3. **Device Authorization**: Only approve pending registrations you initiated
|
||||
3. **Claim only codes you can see**: a pairing code is readable only on the
|
||||
registering device's screen — never claim a code someone merely told you
|
||||
if you can't see the device showing it
|
||||
4. **Revoke lost devices**: Remove devices you no longer use from the Devices page
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [KOReader Documentation](https://github.com/koreader/koreader)
|
||||
- [KOReader Forum](https://www.mobileread.com/forums/forumdisplay.php?f=271)
|
||||
- [KOReader](https://github.com/koreader/koreader) — [installation](https://github.com/koreader/koreader#installation) · [user guide](https://koreader.rocks/user_guide/)
|
||||
- [KOReader Forum](https://www.mobileread.com/forums/forumdisplay.php?f=276) — the project's official Mobileread subforum
|
||||
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
||||
- [Bookhoard KOReader Plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
|
||||
+11
-9
@@ -14,15 +14,17 @@ notes — and every registered device pushes and pulls against it.
|
||||
| **KOReader** (Kindle, Kobo, PocketBook) | ✅ Fully supported | The Bookhoard KOReader plugin, over Wi-Fi |
|
||||
| **Kobo, stock firmware** | 🚧 Server support complete | The native Kobo protocol is implemented and documented server-side; device-side enablement is the remaining piece — [Kobo Setup](devices/kobo-setup.md) has the details |
|
||||
|
||||
Kindle and reMarkable are under consideration; KOReader already covers
|
||||
several of those devices today.
|
||||
|
||||
## Getting a device into the circle
|
||||
|
||||
1. Connect the device to your server (each guide above walks its own path)
|
||||
2. Approve the pending registration on the **Devices** page — the Android
|
||||
app registers itself on first login; KOReader asks from its plugin
|
||||
3. Done. Sync starts as soon as a device is approved
|
||||
2. Pair it on the **Devices** page — the Android app registers itself on
|
||||
first login; KOReader shows a 6-digit pairing code that you type into
|
||||
**Pair a Device** on the page
|
||||
3. Done. Sync starts as soon as a device is paired
|
||||
|
||||
Only the person holding the device can read its pairing code, so a device
|
||||
always lands in the account of whoever registered it — even on a family
|
||||
server where more than one person is logged in.
|
||||
|
||||
The **Devices** page is mission control: see every registered device and
|
||||
when it was last seen, remove a device (revoking its access instantly), and
|
||||
@@ -64,7 +66,7 @@ phone lands in the right place on an e-reader with different pagination.
|
||||
**Bookmarks** — location, title, and when you made them.
|
||||
|
||||
Everything above is per-user and private: your devices see your data
|
||||
because you approved them, other users see theirs, and revoking a device
|
||||
because you paired them, other users see theirs, and revoking a device
|
||||
ends its access on the spot.
|
||||
|
||||
## Conflicts
|
||||
@@ -101,13 +103,13 @@ alone.
|
||||
## Troubleshooting
|
||||
|
||||
**Progress not updating**
|
||||
Check the device is still on the **Devices** page and was approved; check
|
||||
Check the device is still on the **Devices** page and was paired; check
|
||||
the server URL in the device's settings; confirm the device can reach the
|
||||
server (open the server URL in the device's browser as a test).
|
||||
|
||||
**"Device not found"**
|
||||
The device was removed or its token revoked — register it again and
|
||||
approve it.
|
||||
enter the fresh pairing code.
|
||||
|
||||
**A conflict keeps coming back**
|
||||
Two devices are genuinely fighting over the same book. Decide which device
|
||||
|
||||
@@ -40,8 +40,8 @@ comes when you add a [device](devices/android-app.md).
|
||||
## A note on admins
|
||||
|
||||
Some screens (user management, server settings, library folders) are admin
|
||||
territory; [Admin Guide](admin-guide.md) is the door, and the admin docs are
|
||||
being expanded separately.
|
||||
territory; [Admin Guide](../admin/admin-guide.md) is the door, and the
|
||||
Administration docs section covers every admin page.
|
||||
|
||||
**Looking for developer documentation?** See the
|
||||
[Developer Portal](../developer/development.md).
|
||||
@@ -26,7 +26,7 @@ const docsRoot = "../../docs/developer/api"
|
||||
|
||||
// brunoKeepDir / brunoKeepSub name the hand-authored parts of the
|
||||
// collection that generation must never touch or delete.
|
||||
var brunoKeepDir = map[string]bool{"NewDevDBSetup": true, "environments": true}
|
||||
var brunoKeepDir = map[string]bool{"NewDevDBSetup": true, "environments": true, "podcasts": true}
|
||||
|
||||
var brunoKeepSub = []string{"/scenarios/", "/filters/", "/search/"}
|
||||
|
||||
|
||||
@@ -18,6 +18,25 @@ type HTTPHandler struct {
|
||||
docs *DocsHandler
|
||||
}
|
||||
|
||||
// docRedirects maps retired doc paths (as passed to ShowDocumentation —
|
||||
// no leading /docs, .md suffix optional) to their current paths. Docs move
|
||||
// rarely; when one moves, add it here so old links 301 to the new home
|
||||
// instead of 404ing. Checked BEFORE .md canonicalization so a moved page's
|
||||
// .md spelling also one-hops straight to the new clean URL.
|
||||
// 2026-11-02: admin guides moved from docs/user/ to docs/admin/, and
|
||||
// admin-library-management.md became admin/libraries.md (it will be
|
||||
// rebuilt as the Libraries page).
|
||||
var docRedirects = map[string]string{
|
||||
"user/admin-guide": "admin/admin-guide",
|
||||
"user/admin-library-management": "admin/libraries",
|
||||
// developer/testing.md (Feb-era) was deleted 2026-11-02: its
|
||||
// wipe-test-users lifecycle contradicted current doctrine (live
|
||||
// testuser is protected, cleanups are scoped) and its recipes were
|
||||
// superseded by docs/contributing/development.md (architecture,
|
||||
// setup, testing, project rules — kept fresh).
|
||||
"developer/testing": "contributing/development",
|
||||
}
|
||||
|
||||
// NewHTTPHandler creates a new docs HTTP handler
|
||||
func NewHTTPHandler(docsPath string) *HTTPHandler {
|
||||
return &HTTPHandler{
|
||||
@@ -76,6 +95,16 @@ func (h *HTTPHandler) ShowDocumentation(c *echo.Context) error {
|
||||
// Get the document path from URL parameter
|
||||
docPath := c.Param("*")
|
||||
|
||||
// Retired doc paths 301 to their current homes (see docRedirects)
|
||||
// before any load attempt.
|
||||
if target, moved := docRedirects[strings.TrimSuffix(docPath, ".md")]; moved {
|
||||
loc := "/docs/" + target
|
||||
if c.Request().URL.RawQuery != "" {
|
||||
loc += "?" + c.Request().URL.RawQuery
|
||||
}
|
||||
return c.Redirect(http.StatusMovedPermanently, loc)
|
||||
}
|
||||
|
||||
// Canonicalize ".md" URLs to the clean form. Markdown links across the
|
||||
// docs tree point at `area/file.md`; the handler loads them fine, but
|
||||
// the sidebar logic keys on the URL path (contains-URL expansion, the
|
||||
|
||||
@@ -28,6 +28,47 @@ var sectionOrder = []struct {
|
||||
{Title: "Contributing", Icon: "heart"},
|
||||
}
|
||||
|
||||
// usabilityOrder pins an explicit, usability-first display order for a
|
||||
// section's direct nav items, overriding the alphabetical fallback (owner
|
||||
// request 2026-11-02: order the menu by how people work, not by letter —
|
||||
// the User Guide list mirrors the "Where to go" table on user-guide.md).
|
||||
// Values are doc paths without the .md suffix; anything in the section not
|
||||
// listed sorts alphabetically AFTER the listed items. Display-only: the
|
||||
// open/highlight machinery keys on URL membership (containsURL) and is
|
||||
// order-blind, so URL-driven expansion is unaffected. Administration lists
|
||||
// its planned pages up front so each lands in place as it is written.
|
||||
var usabilityOrder = map[string][]string{
|
||||
"User Guide": {
|
||||
"user/dashboard",
|
||||
"user/library-browsing",
|
||||
"user/collections",
|
||||
"user/searching-and-filtering",
|
||||
"user/podcasts",
|
||||
"user/reader-guide",
|
||||
"user/themes",
|
||||
"user/profile-guide",
|
||||
"user/sync-guide",
|
||||
"user/calibre-integration",
|
||||
},
|
||||
"Devices": {
|
||||
"user/devices/android-app",
|
||||
"user/devices/koreader-setup",
|
||||
"user/devices/kobo-setup",
|
||||
},
|
||||
"Administration": {
|
||||
"admin/admin-guide",
|
||||
"admin/dashboard",
|
||||
"admin/libraries",
|
||||
"admin/library-processing-issues",
|
||||
"admin/archived-items",
|
||||
"admin/podcasts",
|
||||
"admin/hash-conflicts",
|
||||
"admin/users",
|
||||
"admin/settings",
|
||||
"admin/scanning",
|
||||
},
|
||||
}
|
||||
|
||||
// groupTitleOverrides fixes casing that naive title-casing gets wrong.
|
||||
var groupTitleOverrides = map[string]string{
|
||||
"admin": "Admin",
|
||||
@@ -104,12 +145,16 @@ func placeDoc(docPath string) navPlacement {
|
||||
if rest == "user-guide.md" {
|
||||
return navPlacement{section: "User Guide", pin: -50}
|
||||
}
|
||||
// Admin-facing guides are their own top-level section too.
|
||||
if strings.HasPrefix(rest, "admin-") {
|
||||
return navPlacement{section: "Administration"}
|
||||
}
|
||||
return navPlacement{section: "User Guide"}
|
||||
|
||||
case strings.HasPrefix(docPath, "admin/"):
|
||||
// Admin docs live in their own area (docs/admin/); the guide is
|
||||
// the section landing page.
|
||||
if docPath == "admin/admin-guide.md" {
|
||||
return navPlacement{section: "Administration", pin: -50}
|
||||
}
|
||||
return navPlacement{section: "Administration"}
|
||||
|
||||
case strings.HasPrefix(docPath, "developer/api/"):
|
||||
rest := strings.TrimPrefix(docPath, "developer/api/")
|
||||
if dir, _, ok := strings.Cut(rest, "/"); ok {
|
||||
@@ -256,14 +301,27 @@ func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation
|
||||
build := builds[spec.Title]
|
||||
section := templates.NavSection{Title: spec.Title, Icon: spec.Icon}
|
||||
|
||||
// Direct items: pinned landing pages lead, then alphabetical
|
||||
// (case-insensitive — KOReader must not sort before Kobo).
|
||||
// Direct items: pinned landing pages lead, then usability order
|
||||
// (see usabilityOrder), then alphabetical among anything unlisted.
|
||||
order := usabilityOrder[spec.Title]
|
||||
rankOf := func(url string) int {
|
||||
for i, p := range order {
|
||||
if docURL(p) == url {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(order)
|
||||
}
|
||||
items := append([]templates.NavItem(nil), build.items...)
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
pi, pj := build.pinned[items[i].URL], build.pinned[items[j].URL]
|
||||
if pi != pj {
|
||||
return pi
|
||||
}
|
||||
ri, rj := rankOf(items[i].URL), rankOf(items[j].URL)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return strings.ToLower(items[i].Title) < strings.ToLower(items[j].Title)
|
||||
})
|
||||
section.Items = items
|
||||
|
||||
@@ -3,6 +3,7 @@ package docs
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"bookhoard/templates"
|
||||
@@ -18,7 +19,8 @@ func TestPlaceDoc(t *testing.T) {
|
||||
{"index.md", "Getting Started", "", true},
|
||||
{"user/user-guide.md", "User Guide", "", true},
|
||||
{"user/dashboard.md", "User Guide", "", false},
|
||||
{"user/admin-guide.md", "Administration", "", false},
|
||||
{"admin/admin-guide.md", "Administration", "", true},
|
||||
{"admin/libraries.md", "Administration", "", false},
|
||||
{"user/devices/kobo-setup.md", "Devices", "", false},
|
||||
{"developer/api/api-reference.md", "API Reference", "", true},
|
||||
{"developer/api/dashboard.md", "API Reference", "", false},
|
||||
@@ -52,6 +54,7 @@ func TestDocTitle(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"index.md": "Overview",
|
||||
"user/user-guide.md": "User Guide",
|
||||
"admin/admin-guide.md": "Admin Guide",
|
||||
"user/searching-and-filtering.md": "Searching and Filtering",
|
||||
"user/devices/koreader-setup.md": "KOReader Setup",
|
||||
"developer/development.md": "Developer Portal",
|
||||
@@ -86,7 +89,7 @@ func buildTestDocs(t *testing.T) *DocsHandler {
|
||||
"index.md",
|
||||
"user/user-guide.md",
|
||||
"user/dashboard.md",
|
||||
"user/admin-guide.md",
|
||||
"admin/admin-guide.md",
|
||||
"user/devices/kobo-setup.md",
|
||||
"user/devices/koreader-setup.md",
|
||||
"developer/api/api-reference.md",
|
||||
@@ -148,8 +151,11 @@ func TestBuildNavigation(t *testing.T) {
|
||||
t.Errorf("User Guide direct items = %+v, want User Guide pinned first", userGuide.Items)
|
||||
}
|
||||
|
||||
// Devices: own top-level section, expanded, no groups. (Administration
|
||||
// sits above it in the order; covered by the collapsed sweep below.)
|
||||
// Devices: own top-level section, expanded, no groups. KOReader sorts
|
||||
// ahead of Kobo because usabilityOrder ranks the device guides by how
|
||||
// the docs front door presents them (Android → KOReader → Kobo), not
|
||||
// alphabetically. (Administration sits above it in the section order;
|
||||
// covered by the collapsed sweep below.)
|
||||
devices := nav.Sections[3]
|
||||
if devices.Collapsed {
|
||||
t.Error("Devices section should be expanded: it contains the current page")
|
||||
@@ -157,8 +163,8 @@ func TestBuildNavigation(t *testing.T) {
|
||||
if len(devices.Groups) != 0 {
|
||||
t.Errorf("Devices groups = %+v, want none", devices.Groups)
|
||||
}
|
||||
if len(devices.Items) != 2 || devices.Items[0].Title != "Kobo Setup" {
|
||||
t.Errorf("Devices items = %+v, want Kobo Setup + KOReader Setup", devices.Items)
|
||||
if len(devices.Items) != 2 || devices.Items[0].Title != "KOReader Setup" {
|
||||
t.Errorf("Devices items = %+v, want KOReader Setup before Kobo Setup (usability order)", devices.Items)
|
||||
}
|
||||
|
||||
// Everything else starts collapsed (Administration included).
|
||||
@@ -194,6 +200,73 @@ func TestBuildNavigation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildNavigationUsabilityOrder (2026-11-02): the REAL docs tree must
|
||||
// present its direct nav items in usability order (usabilityOrder), not
|
||||
// alphabetically — User Guide mirrors the "Where to go" table on
|
||||
// user-guide.md, Devices orders Android → KOReader → Kobo, Administration
|
||||
// mirrors the app's own admin sidebar. Display-order only: expansion and
|
||||
// highlighting stay URL-driven (order-blind) by construction.
|
||||
func TestBuildNavigationUsabilityOrder(t *testing.T) {
|
||||
handler := NewDocsHandler("../../docs")
|
||||
nav := handler.BuildNavigation("/docs")
|
||||
|
||||
find := func(title string) *templates.NavSection {
|
||||
for i := range nav.Sections {
|
||||
if nav.Sections[i].Title == title {
|
||||
return &nav.Sections[i]
|
||||
}
|
||||
}
|
||||
t.Fatalf("section %q missing", title)
|
||||
return nil
|
||||
}
|
||||
|
||||
titles := func(s *templates.NavSection) []string {
|
||||
out := make([]string, 0, len(s.Items))
|
||||
for _, item := range s.Items {
|
||||
out = append(out, item.Title)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
wantUserGuide := []string{
|
||||
"User Guide", "Dashboard", "Library Browsing", "Collections",
|
||||
"Searching and Filtering", "Podcasts", "Reader Guide", "Themes",
|
||||
"Profile Guide", "Sync Guide", "Calibre Integration",
|
||||
}
|
||||
if got := titles(find("User Guide")); !reflect.DeepEqual(got, wantUserGuide) {
|
||||
t.Errorf("User Guide order = %v, want %v", got, wantUserGuide)
|
||||
}
|
||||
|
||||
wantDevices := []string{"Android App", "KOReader Setup", "Kobo Setup"}
|
||||
if got := titles(find("Devices")); !reflect.DeepEqual(got, wantDevices) {
|
||||
t.Errorf("Devices order = %v, want %v", got, wantDevices)
|
||||
}
|
||||
|
||||
// Administration grows as its pages are written; pin its shape, not
|
||||
// its size: the guide leads (pinned landing), and every other item
|
||||
// must appear in usabilityOrder's declared relative order.
|
||||
admin := find("Administration")
|
||||
if len(admin.Items) < 1 || admin.Items[0].Title != "Admin Guide" {
|
||||
t.Fatalf("Administration must lead with the Admin Guide, got %+v", admin.Items)
|
||||
}
|
||||
pos := map[string]int{}
|
||||
for i, p := range usabilityOrder["Administration"] {
|
||||
pos[docURL(p)] = i
|
||||
}
|
||||
last := -1
|
||||
for _, item := range admin.Items[1:] {
|
||||
r, ok := pos[item.URL]
|
||||
if !ok {
|
||||
t.Errorf("Administration item %q (%s) is not in usabilityOrder", item.Title, item.URL)
|
||||
continue
|
||||
}
|
||||
if r <= last {
|
||||
t.Errorf("Administration item %q breaks usabilityOrder sequence", item.Title)
|
||||
}
|
||||
last = r
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNavigationNothingCurrent(t *testing.T) {
|
||||
h := buildTestDocs(t)
|
||||
nav := h.BuildNavigation("/docs/nonexistent")
|
||||
|
||||
+24
-5
@@ -443,6 +443,25 @@ var Schemas = map[string]Schema{
|
||||
Request: handlers.DeviceAuthStatusRequest{},
|
||||
Response: handlers.DeviceAuthStatusResponse{},
|
||||
},
|
||||
"POST /api/devices/register/cancel": {
|
||||
// The registering device ends its own pairing attempt; idempotent
|
||||
// (same body whether or not the registration still exists).
|
||||
Request: handlers.CancelRegistrationRequest{},
|
||||
Response: json.RawMessage(`{"message": ""}`),
|
||||
},
|
||||
"POST /api/devices/claim": {
|
||||
// Pairing-code claim: binds the registration behind the code to
|
||||
// the caller's account. HTMX callers get HX-Refresh, not JSON.
|
||||
Request: handlers.PairingCodeRequest{},
|
||||
Response: json.RawMessage(`{
|
||||
"message": "", "device_name": "", "device_type": "", "approved": false
|
||||
}`),
|
||||
},
|
||||
"POST /api/devices/claim/lookup": {
|
||||
// Confirm-card data for a pairing code, without binding.
|
||||
Request: handlers.PairingCodeRequest{},
|
||||
Response: handlers.PairingLookupResponse{},
|
||||
},
|
||||
|
||||
// --- Beam batch 4: media-items core ---
|
||||
"GET /api/media-items": {
|
||||
@@ -664,8 +683,9 @@ var Schemas = map[string]Schema{
|
||||
|
||||
// --- Beam batch 8: devices remaining + auth remainder ---
|
||||
"GET /api/devices/pending": {
|
||||
// Device registrations live in an in-memory map; both key spellings
|
||||
// are emitted for compatibility.
|
||||
// Deprecated-by-emptiness: unclaimed registrations are invisible
|
||||
// since pairing codes took over approval (see POST
|
||||
// /api/devices/claim). Both key spellings kept for compatibility.
|
||||
Response: json.RawMessage(`{
|
||||
"registrations": [], "pending_registrations": [], "total": 0
|
||||
}`),
|
||||
@@ -673,14 +693,13 @@ var Schemas = map[string]Schema{
|
||||
"GET /api/devices/approve/:registration_id": {
|
||||
// Already-approved variant omits registration_id. Credentials ride
|
||||
// the registration map polled elsewhere — NOT this JSON body.
|
||||
// Android self-approval path: the app registers, then approves its
|
||||
// OWN registration UUID with its JWT.
|
||||
Response: json.RawMessage(`{
|
||||
"message": "", "device_name": "", "device_type": "",
|
||||
"registration_id": "", "approved": false
|
||||
}`),
|
||||
},
|
||||
"POST /api/devices/reject/:registration_id": {
|
||||
Response: json.RawMessage(`{"message": ""}`),
|
||||
},
|
||||
"POST /api/auth/users": {
|
||||
Request: handlers.RegisterRequest{},
|
||||
Response: json.RawMessage(`{
|
||||
|
||||
+407
-138
@@ -3,6 +3,8 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/config"
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/middleware"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -47,6 +50,7 @@ type DeviceRegistrationResponse struct {
|
||||
RegistrationID string `json:"registration_id"`
|
||||
AuthURL string `json:"auth_url"`
|
||||
QRCode string `json:"qr_code"`
|
||||
PairingCode string `json:"pairing_code"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
PollInterval int `json:"poll_interval"`
|
||||
SetupInstructions map[string]string `json:"setup_instructions"`
|
||||
@@ -97,11 +101,33 @@ type DeviceApprovalRequest struct {
|
||||
Approve bool `json:"approve"`
|
||||
}
|
||||
|
||||
// PairingCodeRequest carries the 6-digit code shown on a registering
|
||||
// device's screen — the claim credential for the pairing flow.
|
||||
type PairingCodeRequest struct {
|
||||
// form tag mirrors json: htmx posts urlencoded, the API posts JSON —
|
||||
// echo v5's form binder maps by `form:` tag only (auth.go convention).
|
||||
PairingCode string `form:"pairing_code" json:"pairing_code"`
|
||||
}
|
||||
|
||||
// PairingLookupResponse describes a live registration without binding it —
|
||||
// the confirm card's data ("Pair 'My Kindle'?").
|
||||
type PairingLookupResponse struct {
|
||||
DeviceName string `json:"device_name"`
|
||||
DeviceType string `json:"device_type"`
|
||||
DeviceIdentifier string `json:"device_identifier"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
type CancelRegistrationRequest struct {
|
||||
RegistrationID string `form:"registration_id" json:"registration_id" validate:"required"`
|
||||
}
|
||||
|
||||
type PendingRegistration struct {
|
||||
RegistrationID string
|
||||
DeviceName string
|
||||
DeviceType string
|
||||
DeviceIdentifier string
|
||||
PairingCode string
|
||||
UserID uuid.UUID
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
@@ -120,6 +146,119 @@ var (
|
||||
pendingMu sync.Mutex
|
||||
)
|
||||
|
||||
// pairingRateLimiter throttles claim-code lookups per account: the code is
|
||||
// the only secret guarding a registration, so guessing must not be free.
|
||||
// The per-registration 5-minute TTL bounds each code's exposure; this
|
||||
// bounds the guessing rate against the whole live-code space.
|
||||
var pairingRateLimiter = middleware.NewDeviceRateLimiter()
|
||||
|
||||
const (
|
||||
// pairingCodeDigits: six, the Netflix/YouTube TV pairing shape —
|
||||
// nothing to type on e-ink hardware, and a million-code space that a
|
||||
// rate-limited guesser cannot walk inside the 5-minute TTL.
|
||||
pairingCodeDigits = 6
|
||||
)
|
||||
|
||||
// pairingRateLimitConfig caps lookup/claim attempts per account per
|
||||
// minute (lookups and claims share the budget — a pairing is two calls:
|
||||
// lookup, then confirm).
|
||||
var pairingRateLimitConfig = middleware.DeviceRateLimitConfig{
|
||||
ClaimAttemptsPerMinute: 12,
|
||||
}
|
||||
|
||||
// generatePairingCode returns a uniformly random pairingCodeDigits-digit
|
||||
// code, unique among the currently live registrations. The code is a claim
|
||||
// credential — never log it.
|
||||
func generatePairingCode() (string, error) {
|
||||
space := 1
|
||||
for i := 0; i < pairingCodeDigits; i++ {
|
||||
space *= 10
|
||||
}
|
||||
// Rejection-sample: n%space over the full 24-bit range would bias low
|
||||
// codes (16777216 is not a multiple of 10^6), so values at or above
|
||||
// the largest whole multiple of the code space are discarded.
|
||||
max := ((1 << 24) / space) * space
|
||||
for attempt := 0; attempt < 10; attempt++ {
|
||||
b := make([]byte, 3)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
n := int(b[0])<<16 | int(b[1])<<8 | int(b[2])
|
||||
if n >= max {
|
||||
continue
|
||||
}
|
||||
code := fmt.Sprintf("%0*d", pairingCodeDigits, n%space)
|
||||
if !pairingCodeInUse(code) {
|
||||
return code, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("pairing code space exhausted")
|
||||
}
|
||||
|
||||
// pairingCodeInUse reports whether any live registration already holds the
|
||||
// code. Callers race on mint→insert; the next mint's collision check
|
||||
// covers the gap well enough for a family-scale server.
|
||||
func pairingCodeInUse(code string) bool {
|
||||
pendingMu.Lock()
|
||||
defer pendingMu.Unlock()
|
||||
for _, reg := range pendingRegistrations {
|
||||
if reg.PairingCode == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizePairingCode reduces human-typed input to its digits — the
|
||||
// device shows "583 912", users type "583 912", "583912", "583-912".
|
||||
func normalizePairingCode(raw string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range raw {
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// errPairingInvalid marks a wrong code (generic by design — no hint
|
||||
// whether the code exists), errPairingExpired a live-registration TTL
|
||||
// miss, errPairingClaimed a code whose registration was already bound.
|
||||
var (
|
||||
errPairingInvalid = errors.New("invalid pairing code")
|
||||
errPairingExpired = errors.New("pairing code expired")
|
||||
errPairingClaimed = errors.New("pairing code already used")
|
||||
)
|
||||
|
||||
// registrationByPairingCode resolves a normalized code to its live,
|
||||
// unclaimed registration, reaping expired entries on sight.
|
||||
func registrationByPairingCode(code string) (*PendingRegistration, error) {
|
||||
pendingMu.Lock()
|
||||
defer pendingMu.Unlock()
|
||||
|
||||
for _, reg := range pendingRegistrations {
|
||||
if reg.PairingCode != code {
|
||||
continue
|
||||
}
|
||||
if time.Now().After(reg.ExpiresAt) {
|
||||
delete(pendingRegistrations, reg.RegistrationID)
|
||||
return nil, errPairingExpired
|
||||
}
|
||||
if reg.Approved {
|
||||
return nil, errPairingClaimed
|
||||
}
|
||||
return reg, nil
|
||||
}
|
||||
return nil, errPairingInvalid
|
||||
}
|
||||
|
||||
// claimAllowed applies the per-account pairing attempt budget. userID
|
||||
// keys the window; IP is folded in so a token shared across browsers
|
||||
// still bounds per-origin abuse.
|
||||
func claimAllowed(userID string, ip string) bool {
|
||||
return pairingRateLimiter.CheckRateLimit(userID+"|"+ip, "claim", pairingRateLimitConfig)
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
|
||||
req := DeviceRegistrationRequest{}
|
||||
if err := c.Bind(&req); err != nil {
|
||||
@@ -133,16 +272,31 @@ func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
|
||||
registrationID := uuid.New().String()
|
||||
expiresAt := time.Now().Add(5 * time.Minute)
|
||||
|
||||
pairingCode, err := generatePairingCode()
|
||||
if err != nil {
|
||||
slog.Error("pairing code generation failed", "error", err)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate pairing code"})
|
||||
}
|
||||
|
||||
registration := &PendingRegistration{
|
||||
RegistrationID: registrationID,
|
||||
DeviceName: req.DeviceName,
|
||||
DeviceType: req.DeviceType,
|
||||
DeviceIdentifier: req.DeviceIdentifier,
|
||||
PairingCode: pairingCode,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
pendingMu.Lock()
|
||||
// One live registration per device: a re-register (cancel-then-retry,
|
||||
// double-tap) retires the previous unclaimed attempt so exactly one
|
||||
// code is ever claimable for this identifier.
|
||||
for id, existing := range pendingRegistrations {
|
||||
if existing.DeviceIdentifier == req.DeviceIdentifier && !existing.Approved {
|
||||
delete(pendingRegistrations, id)
|
||||
}
|
||||
}
|
||||
pendingRegistrations[registrationID] = registration
|
||||
pendingMu.Unlock()
|
||||
|
||||
@@ -167,6 +321,7 @@ func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
|
||||
RegistrationID: registrationID,
|
||||
AuthURL: authURL,
|
||||
QRCode: "data:image/png;base64," + qrCodeBase64,
|
||||
PairingCode: pairingCode,
|
||||
ExpiresIn: 300,
|
||||
PollInterval: 3,
|
||||
SetupInstructions: setupInstructions,
|
||||
@@ -519,6 +674,95 @@ func (h *DeviceHandler) RegenerateDeviceToken(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// errIdentifierOwnedByOther marks the cross-account case: the device
|
||||
// identifier's existing row belongs to a different user, so neither
|
||||
// approval nor claim may touch it.
|
||||
var errIdentifierOwnedByOther = errors.New("device identifier registered to another user")
|
||||
|
||||
// bindRegistrationToUser creates or rotates the devices row for a pending
|
||||
// registration and returns the row, its fresh token, and the sync
|
||||
// endpoints for the device type. Shared by ApproveDevice (Android
|
||||
// self-approval by registration UUID) and the claim-by-code endpoint;
|
||||
// callers publish the result under pendingMu themselves.
|
||||
func (h *DeviceHandler) bindRegistrationToUser(ctx context.Context, registration *PendingRegistration, pgUserID pgtype.UUID) (database.Devices, string, map[string]string, error) {
|
||||
authToken, err := generateDeviceToken()
|
||||
if err != nil {
|
||||
return database.Devices{}, "", nil, fmt.Errorf("generate token: %w", err)
|
||||
}
|
||||
|
||||
// Re-approval: app reinstalls that preserve data (e.g. Android Studio
|
||||
// installDebug over an existing install) re-register with the SAME
|
||||
// device_identifier, but the devices row from the previous install
|
||||
// still exists — device_identifier is UNIQUE, so a blind INSERT fails
|
||||
// with a unique violation (the historical "approve returns 500 after
|
||||
// reinstall" bug). Look the device up first and rotate its token
|
||||
// instead; a fresh install MUST get fresh credentials, so the old
|
||||
// token is invalidated either way.
|
||||
rotateExisting := func(existing database.Devices) (database.Devices, bool, error) {
|
||||
// The identifier is globally unique; a row owned by another user
|
||||
// means cross-account identifier reuse — refuse it.
|
||||
if !existing.UserID.Valid || existing.UserID.Bytes != pgUserID.Bytes {
|
||||
return existing, false, errIdentifierOwnedByOther
|
||||
}
|
||||
updated, err := h.db.UpdateDeviceAuthToken(ctx,
|
||||
database.UpdateDeviceAuthTokenParams{
|
||||
ID: pgtype.UUID{Bytes: existing.ID.Bytes, Valid: true},
|
||||
AuthToken: authToken,
|
||||
})
|
||||
return updated, true, err
|
||||
}
|
||||
|
||||
existing, err := h.db.GetDeviceByIdentifier(ctx, registration.DeviceIdentifier)
|
||||
var device database.Devices
|
||||
switch {
|
||||
case err == nil:
|
||||
// Known device: rotate the token, keep the row (id unchanged —
|
||||
// highlights/bookmarks/progress anchored to it stay valid).
|
||||
device, _, err = rotateExisting(existing)
|
||||
if err != nil {
|
||||
return database.Devices{}, "", nil, err
|
||||
}
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
device, err = h.db.CreateDevice(ctx, database.CreateDeviceParams{
|
||||
UserID: pgUserID,
|
||||
DeviceName: registration.DeviceName,
|
||||
DeviceType: registration.DeviceType,
|
||||
DeviceIdentifier: registration.DeviceIdentifier,
|
||||
AuthToken: authToken,
|
||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
if err != nil {
|
||||
// Concurrent approve racing us onto the unique index: fall
|
||||
// through to the rotate path for the winner's row.
|
||||
var pgErr *pgconn.PgError
|
||||
if asErr := errors.As(err, &pgErr); asErr && pgErr.Code == "23505" {
|
||||
if winner, lerr := h.db.GetDeviceByIdentifier(ctx, registration.DeviceIdentifier); lerr == nil {
|
||||
device, _, err = rotateExisting(winner)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return database.Devices{}, "", nil, fmt.Errorf("create device: %w", err)
|
||||
}
|
||||
default:
|
||||
return database.Devices{}, "", nil, fmt.Errorf("look up device: %w", err)
|
||||
}
|
||||
|
||||
syncEndpoints := map[string]string{}
|
||||
switch registration.DeviceType {
|
||||
case "koreader":
|
||||
syncEndpoints = koreaderSyncEndpoints(h.cfg.BaseURL)
|
||||
case "kobo":
|
||||
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
|
||||
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
||||
}
|
||||
|
||||
return device, authToken, syncEndpoints, nil
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
@@ -552,102 +796,17 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
||||
}
|
||||
pendingMu.Unlock()
|
||||
|
||||
authToken, err := generateDeviceToken()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
|
||||
}
|
||||
|
||||
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
||||
|
||||
// Re-approval: app reinstalls that preserve data (e.g. Android Studio
|
||||
// installDebug over an existing install) re-register with the SAME
|
||||
// device_identifier, but the devices row from the previous install
|
||||
// still exists — device_identifier is UNIQUE, so a blind INSERT fails
|
||||
// with a unique violation (the historical "approve returns 500 after
|
||||
// reinstall" bug). Look the device up first and rotate its token
|
||||
// instead; a fresh install MUST get fresh credentials, so the old
|
||||
// token is invalidated either way.
|
||||
rotateExisting := func(existing database.Devices) (database.Devices, bool, error) {
|
||||
// The identifier is globally unique; a row owned by another user
|
||||
// means cross-account identifier reuse — refuse it.
|
||||
if !existing.UserID.Valid || existing.UserID.Bytes != pgUserID.Bytes {
|
||||
return existing, false, nil
|
||||
}
|
||||
updated, err := h.db.UpdateDeviceAuthToken(c.Request().Context(),
|
||||
database.UpdateDeviceAuthTokenParams{
|
||||
ID: pgtype.UUID{Bytes: existing.ID.Bytes, Valid: true},
|
||||
AuthToken: authToken,
|
||||
})
|
||||
return updated, true, err
|
||||
}
|
||||
|
||||
existing, err := h.db.GetDeviceByIdentifier(c.Request().Context(),
|
||||
registration.DeviceIdentifier)
|
||||
var device database.Devices
|
||||
switch {
|
||||
case err == nil:
|
||||
// Known device: rotate the token, keep the row (id unchanged —
|
||||
// highlights/bookmarks/progress anchored to it stay valid).
|
||||
var ok bool
|
||||
device, ok, err = rotateExisting(existing)
|
||||
if err != nil {
|
||||
slog.Error("device re-approval failed",
|
||||
"registration_id", registrationID, "error", err)
|
||||
return c.JSON(http.StatusInternalServerError,
|
||||
map[string]string{"error": "failed to approve device"})
|
||||
}
|
||||
if !ok {
|
||||
device, authToken, syncEndpoints, err := h.bindRegistrationToUser(c.Request().Context(), registration, pgUserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, errIdentifierOwnedByOther) {
|
||||
return c.JSON(http.StatusConflict,
|
||||
map[string]string{"error": "device identifier already registered to another user"})
|
||||
}
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
device, err = h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
||||
UserID: pgUserID,
|
||||
DeviceName: registration.DeviceName,
|
||||
DeviceType: registration.DeviceType,
|
||||
DeviceIdentifier: registration.DeviceIdentifier,
|
||||
AuthToken: authToken,
|
||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
if err != nil {
|
||||
// Concurrent approve racing us onto the unique index: fall
|
||||
// through to the rotate path for the winner's row.
|
||||
var pgErr *pgconn.PgError
|
||||
if asErr := errors.As(err, &pgErr); asErr && pgErr.Code == "23505" {
|
||||
if winner, lerr := h.db.GetDeviceByIdentifier(
|
||||
c.Request().Context(), registration.DeviceIdentifier); lerr == nil {
|
||||
var ok bool
|
||||
device, ok, err = rotateExisting(winner)
|
||||
if err == nil && !ok {
|
||||
return c.JSON(http.StatusConflict,
|
||||
map[string]string{"error": "device identifier already registered to another user"})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("device creation failed",
|
||||
"registration_id", registrationID, "error", err)
|
||||
return c.JSON(http.StatusInternalServerError,
|
||||
map[string]string{"error": "failed to create device"})
|
||||
}
|
||||
default:
|
||||
slog.Error("device lookup failed",
|
||||
slog.Error("device approval bind failed",
|
||||
"registration_id", registrationID, "error", err)
|
||||
return c.JSON(http.StatusInternalServerError,
|
||||
map[string]string{"error": "failed to look up device"})
|
||||
}
|
||||
|
||||
syncEndpoints := map[string]string{}
|
||||
switch registration.DeviceType {
|
||||
case "koreader":
|
||||
syncEndpoints = koreaderSyncEndpoints(h.cfg.BaseURL)
|
||||
case "kobo":
|
||||
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
|
||||
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
||||
map[string]string{"error": "failed to approve device"})
|
||||
}
|
||||
|
||||
// Publish the credentials under the map lock: the status poller
|
||||
@@ -670,73 +829,183 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
|
||||
registrationID := c.Param("registration_id")
|
||||
// errPairingRateLimited marks an exhausted per-account attempt budget.
|
||||
var errPairingRateLimited = errors.New("too many pairing attempts")
|
||||
|
||||
pendingMu.Lock()
|
||||
_, exists := pendingRegistrations[registrationID]
|
||||
if !exists {
|
||||
pendingMu.Unlock()
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
||||
// resolvePairingCode is the shared lookup core for the JSON API and the
|
||||
// SSR confirm-card partial: apply the per-account budget, normalize the
|
||||
// typed code, resolve to a live unclaimed registration.
|
||||
func resolvePairingCode(userID, ip, rawCode string) (*PendingRegistration, error) {
|
||||
if !claimAllowed(userID, ip) {
|
||||
return nil, errPairingRateLimited
|
||||
}
|
||||
return registrationByPairingCode(normalizePairingCode(rawCode))
|
||||
}
|
||||
|
||||
// pairingHTTPError maps a resolvePairingCode error to status + message.
|
||||
// The invalid-code case is deliberately generic: it must not reveal
|
||||
// whether any registration is waiting.
|
||||
func pairingHTTPError(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, errPairingRateLimited):
|
||||
return http.StatusTooManyRequests, "too many pairing attempts; try again in a minute"
|
||||
case errors.Is(err, errPairingExpired):
|
||||
return http.StatusGone, "pairing code expired — register the device again"
|
||||
case errors.Is(err, errPairingClaimed):
|
||||
return http.StatusConflict, "pairing code already used"
|
||||
default:
|
||||
return http.StatusBadRequest, "invalid pairing code"
|
||||
}
|
||||
}
|
||||
|
||||
// ResolvePairingCode is the exported lookup core for the router's SSR
|
||||
// confirm-card partial (the router renders templ; handlers stay JSON).
|
||||
func ResolvePairingCode(userID, ip, rawCode string) (*PendingRegistration, error) {
|
||||
return resolvePairingCode(userID, ip, rawCode)
|
||||
}
|
||||
|
||||
// PairingErrorStatus maps a ResolvePairingCode error to the status key
|
||||
// consumed by templates.PairingLookupResult.
|
||||
func PairingErrorStatus(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, errPairingRateLimited):
|
||||
return "rate_limited"
|
||||
case errors.Is(err, errPairingExpired):
|
||||
return "expired"
|
||||
case errors.Is(err, errPairingClaimed):
|
||||
return "claimed"
|
||||
default:
|
||||
return "invalid"
|
||||
}
|
||||
}
|
||||
|
||||
// LookupPairingCode resolves a pairing code to its pending registration
|
||||
// WITHOUT binding it — the confirm card's data. Lookups and claims share
|
||||
// the per-account attempt budget.
|
||||
func (h *DeviceHandler) LookupPairingCode(c *echo.Context) error {
|
||||
userID := c.Get("user_id").(string)
|
||||
|
||||
req := PairingCodeRequest{}
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
|
||||
}
|
||||
|
||||
delete(pendingRegistrations, registrationID)
|
||||
pendingMu.Unlock()
|
||||
registration, err := resolvePairingCode(userID, c.RealIP(), req.PairingCode)
|
||||
if err != nil {
|
||||
status, msg := pairingHTTPError(err)
|
||||
return c.JSON(status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{
|
||||
"message": "device registration rejected",
|
||||
return c.JSON(http.StatusOK, PairingLookupResponse{
|
||||
DeviceName: registration.DeviceName,
|
||||
DeviceType: registration.DeviceType,
|
||||
DeviceIdentifier: registration.DeviceIdentifier,
|
||||
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
|
||||
registrations := []map[string]interface{}{}
|
||||
pendingMu.Lock()
|
||||
for _, reg := range pendingRegistrations {
|
||||
if reg.UserID == (uuid.UUID{}) {
|
||||
registrations = append(registrations, map[string]interface{}{
|
||||
"registration_id": reg.RegistrationID,
|
||||
"device_name": reg.DeviceName,
|
||||
"device_type": reg.DeviceType,
|
||||
"device_identifier": reg.DeviceIdentifier,
|
||||
"expires_at": reg.ExpiresAt,
|
||||
"created_at": reg.CreatedAt,
|
||||
"is_approved": false,
|
||||
})
|
||||
}
|
||||
}
|
||||
pendingMu.Unlock()
|
||||
|
||||
return registrations, nil
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) ListPendingRegistrations(c *echo.Context) error {
|
||||
// ClaimDevice binds the registration behind a pairing code to the
|
||||
// CALLER's account — the web-side replacement for the broadcast-list
|
||||
// approve. The device's own poll (registration UUID bearer) then delivers
|
||||
// the fresh token to the plugin, exactly as in the old approve flow.
|
||||
func (h *DeviceHandler) ClaimDevice(c *echo.Context) error {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID"})
|
||||
}
|
||||
|
||||
registrations := []map[string]interface{}{}
|
||||
pendingMu.Lock()
|
||||
for _, reg := range pendingRegistrations {
|
||||
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
|
||||
registrations = append(registrations, map[string]interface{}{
|
||||
"registration_id": reg.RegistrationID,
|
||||
"device_name": reg.DeviceName,
|
||||
"device_type": reg.DeviceType,
|
||||
"device_identifier": reg.DeviceIdentifier,
|
||||
"expires_at": reg.ExpiresAt,
|
||||
"created_at": reg.CreatedAt,
|
||||
"is_approved": reg.UserID != (uuid.UUID{}),
|
||||
})
|
||||
if !claimAllowed(userID, c.RealIP()) {
|
||||
return c.JSON(http.StatusTooManyRequests, map[string]string{"error": "too many pairing attempts; try again in a minute"})
|
||||
}
|
||||
|
||||
req := PairingCodeRequest{}
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
|
||||
}
|
||||
|
||||
registration, err := resolvePairingCode(userID, c.RealIP(), req.PairingCode)
|
||||
if err != nil {
|
||||
status, msg := pairingHTTPError(err)
|
||||
return c.JSON(status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
||||
device, authToken, syncEndpoints, err := h.bindRegistrationToUser(c.Request().Context(), registration, pgUserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, errIdentifierOwnedByOther) {
|
||||
return c.JSON(http.StatusConflict,
|
||||
map[string]string{"error": "device identifier already registered to another user"})
|
||||
}
|
||||
slog.Error("device claim bind failed", "error", err)
|
||||
return c.JSON(http.StatusInternalServerError,
|
||||
map[string]string{"error": "failed to pair device"})
|
||||
}
|
||||
|
||||
// Publish under the map lock: the device's status poller snapshots
|
||||
// these fields only after Approved flips, so the token can never be
|
||||
// read half-written.
|
||||
pendingMu.Lock()
|
||||
registration.UserID = userUUID
|
||||
registration.Approved = true
|
||||
registration.AuthToken = authToken
|
||||
registration.DeviceID = device.ID.Bytes
|
||||
registration.SyncEndpoints = syncEndpoints
|
||||
pendingMu.Unlock()
|
||||
|
||||
// HTMX confirm button: htmx reloads the page natively (CSP — no
|
||||
// inline scripts in the partial).
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
c.Response().Header().Set("HX-Refresh", "true")
|
||||
return c.HTML(http.StatusOK, `<div class="text-green-500">Paired! Reloading …</div>`)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"message": "device paired successfully",
|
||||
"device_name": registration.DeviceName,
|
||||
"device_type": registration.DeviceType,
|
||||
"approved": true,
|
||||
})
|
||||
}
|
||||
|
||||
// CancelRegistration lets the REGISTERING device end its own pairing
|
||||
// attempt (dismissed waiting dialog). The registration UUID is the
|
||||
// bearer — same trust as the status poll — so a device can only ever
|
||||
// cancel its own registration, and a claimed one is left alone (its
|
||||
// poller is mid-delivery of the token).
|
||||
func (h *DeviceHandler) CancelRegistration(c *echo.Context) error {
|
||||
req := CancelRegistrationRequest{}
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
|
||||
}
|
||||
|
||||
pendingMu.Lock()
|
||||
if reg, exists := pendingRegistrations[req.RegistrationID]; exists && !reg.Approved {
|
||||
delete(pendingRegistrations, req.RegistrationID)
|
||||
}
|
||||
pendingMu.Unlock()
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "registration cancelled"})
|
||||
}
|
||||
|
||||
// GetPendingRegistrationsData returns nothing, deliberately. Unclaimed
|
||||
// registrations are invisible since pairing codes took over the approval
|
||||
// flow: first-approve-wins on a broadcast list let any account claim (or
|
||||
// reject) a device it could not even name. Claiming happens by typing the
|
||||
// code shown on the registering device's screen. Kept as an always-empty
|
||||
// shape so API consumers (and the Android app's docs) keep compiling.
|
||||
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
|
||||
return []map[string]interface{}{}, nil
|
||||
}
|
||||
|
||||
// ListPendingRegistrations — see GetPendingRegistrationsData: the
|
||||
// broadcast is dead; unclaimed registrations belong to no one's list.
|
||||
func (h *DeviceHandler) ListPendingRegistrations(c *echo.Context) error {
|
||||
registrations := []map[string]interface{}{}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"registrations": registrations,
|
||||
"pending_registrations": registrations, // Fixed: Add for test compatibility and API clarity
|
||||
"total": len(registrations),
|
||||
"pending_registrations": registrations, // test/API-shape compatibility
|
||||
"total": 0,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGeneratePairingCodeShape(t *testing.T) {
|
||||
for i := 0; i < 50; i++ {
|
||||
code, err := generatePairingCode()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, code, pairingCodeDigits)
|
||||
assert.Regexp(t, `^\d{6}$`, code, "codes are 6 digits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePairingCodeAvoidsLiveCollisions(t *testing.T) {
|
||||
reg := &PendingRegistration{RegistrationID: uuid.NewString(), PairingCode: "000000"}
|
||||
pendingMu.Lock()
|
||||
pendingRegistrations[reg.RegistrationID] = reg
|
||||
pendingMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
pendingMu.Lock()
|
||||
delete(pendingRegistrations, reg.RegistrationID)
|
||||
pendingMu.Unlock()
|
||||
})
|
||||
|
||||
for i := 0; i < 200; i++ {
|
||||
code, err := generatePairingCode()
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, "000000", code, "mint must not collide with a live registration's code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePairingCode(t *testing.T) {
|
||||
assert.Equal(t, "583912", normalizePairingCode("583 912"), "device shows spaced digits")
|
||||
assert.Equal(t, "583912", normalizePairingCode("583-912"))
|
||||
assert.Equal(t, "583912", normalizePairingCode("583912"))
|
||||
assert.Equal(t, "583912", normalizePairingCode(" 58 39 12 "), "stray whitespace tolerated")
|
||||
assert.Equal(t, "", normalizePairingCode("no digits here"))
|
||||
}
|
||||
|
||||
func TestRegistrationByPairingCodeScopes(t *testing.T) {
|
||||
live := &PendingRegistration{
|
||||
RegistrationID: uuid.NewString(),
|
||||
PairingCode: "111111",
|
||||
DeviceName: "Live Device",
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
}
|
||||
expired := &PendingRegistration{
|
||||
RegistrationID: uuid.NewString(),
|
||||
PairingCode: "222222",
|
||||
ExpiresAt: time.Now().Add(-time.Minute),
|
||||
}
|
||||
claimed := &PendingRegistration{
|
||||
RegistrationID: uuid.NewString(),
|
||||
PairingCode: "333333",
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
Approved: true,
|
||||
}
|
||||
pendingMu.Lock()
|
||||
for _, reg := range []*PendingRegistration{live, expired, claimed} {
|
||||
pendingRegistrations[reg.RegistrationID] = reg
|
||||
}
|
||||
pendingMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
pendingMu.Lock()
|
||||
for _, reg := range []*PendingRegistration{live, expired, claimed} {
|
||||
delete(pendingRegistrations, reg.RegistrationID)
|
||||
}
|
||||
pendingMu.Unlock()
|
||||
})
|
||||
|
||||
// Live code resolves.
|
||||
got, err := registrationByPairingCode("111111")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Live Device", got.DeviceName)
|
||||
|
||||
// Expired code is reaped on sight.
|
||||
_, err = registrationByPairingCode("222222")
|
||||
assert.ErrorIs(t, err, errPairingExpired)
|
||||
pendingMu.Lock()
|
||||
_, stillThere := pendingRegistrations[expired.RegistrationID]
|
||||
pendingMu.Unlock()
|
||||
assert.False(t, stillThere, "expired registration must be reaped")
|
||||
|
||||
// Claimed code is refused.
|
||||
_, err = registrationByPairingCode("333333")
|
||||
assert.ErrorIs(t, err, errPairingClaimed)
|
||||
|
||||
// Unknown code is the generic miss.
|
||||
_, err = registrationByPairingCode("999999")
|
||||
assert.ErrorIs(t, err, errPairingInvalid)
|
||||
}
|
||||
@@ -21,12 +21,14 @@ type DeviceRateLimitConfig struct {
|
||||
SyncRequestsPerMinute int
|
||||
ProgressUpdatesPerMinute int
|
||||
MetadataRequestsPerMinute int
|
||||
ClaimAttemptsPerMinute int
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultSyncRequestsPerMinute = 60
|
||||
DefaultProgressUpdatesPerMinute = 120
|
||||
DefaultMetadataRequestsPerMinute = 30
|
||||
DefaultClaimAttemptsPerMinute = 12
|
||||
CleanupInterval = 5 * time.Minute
|
||||
RequestWindow = 1 * time.Minute
|
||||
)
|
||||
@@ -67,12 +69,23 @@ func (drl *DeviceRateLimiter) CheckRateLimit(deviceID string, requestType string
|
||||
limit = config.ProgressUpdatesPerMinute
|
||||
case "metadata":
|
||||
limit = config.MetadataRequestsPerMinute
|
||||
case "claim":
|
||||
limit = config.ClaimAttemptsPerMinute
|
||||
default:
|
||||
limit = DefaultSyncRequestsPerMinute
|
||||
}
|
||||
|
||||
if limit == 0 {
|
||||
limit = DefaultSyncRequestsPerMinute
|
||||
switch requestType {
|
||||
case "progress":
|
||||
limit = DefaultProgressUpdatesPerMinute
|
||||
case "metadata":
|
||||
limit = DefaultMetadataRequestsPerMinute
|
||||
case "claim":
|
||||
limit = DefaultClaimAttemptsPerMinute
|
||||
default:
|
||||
limit = DefaultSyncRequestsPerMinute
|
||||
}
|
||||
}
|
||||
|
||||
info.Requests = append(info.Requests, now)
|
||||
|
||||
@@ -13,6 +13,10 @@ func registerDeviceRoutes(cfg *Config) {
|
||||
// Public device registration routes (no auth required)
|
||||
e.POST("/api/devices/register", cfg.DeviceHandler.InitiateRegistration)
|
||||
e.POST("/api/devices/register/status", cfg.DeviceHandler.CheckRegistrationStatus)
|
||||
// The REGISTERING device may end its own pairing attempt (dismissed
|
||||
// waiting dialog); bearer is the registration UUID it holds, same
|
||||
// trust as the status poll.
|
||||
e.POST("/api/devices/register/cancel", cfg.DeviceHandler.CancelRegistration)
|
||||
|
||||
// Device management routes (protected)
|
||||
devices.GET("", cfg.DeviceHandler.ListDevices)
|
||||
@@ -25,7 +29,10 @@ func registerDeviceRoutes(cfg *Config) {
|
||||
|
||||
devices.GET("/pending", cfg.DeviceHandler.ListPendingRegistrations)
|
||||
devices.GET("/approve/:registration_id", cfg.DeviceHandler.ApproveDevice)
|
||||
devices.POST("/reject/:registration_id", cfg.DeviceHandler.RejectDevice)
|
||||
// Pairing-code claim flow (JWT): lookup is the confirm-card data,
|
||||
// claim binds the device to the caller's account.
|
||||
devices.POST("/claim/lookup", cfg.DeviceHandler.LookupPairingCode)
|
||||
devices.POST("/claim", cfg.DeviceHandler.ClaimDevice)
|
||||
|
||||
// Device sidecar configuration routes
|
||||
devices.GET("/:id/sidecar", cfg.SidecarHandler.GetSidecarConfig)
|
||||
|
||||
+31
-26
@@ -1152,7 +1152,6 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
|
||||
var errorMsg string
|
||||
var devices []handlers.DeviceInfo
|
||||
var pendingList []templates.PendingRegistrationData
|
||||
|
||||
devices, err = cfg.DeviceHandler.GetDevicesData(c)
|
||||
if err != nil {
|
||||
@@ -1161,22 +1160,40 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
errorMsg = "Error loading devices"
|
||||
}
|
||||
|
||||
pendingMaps, err := cfg.DeviceHandler.GetPendingRegistrationsData(c)
|
||||
if err != nil {
|
||||
log.Printf("GetPendingRegistrationsData failed: %v", err)
|
||||
pendingList = []templates.PendingRegistrationData{}
|
||||
if errorMsg == "" {
|
||||
errorMsg = "Error loading pending registrations"
|
||||
}
|
||||
} else {
|
||||
pendingList = convertPending(pendingMaps)
|
||||
}
|
||||
|
||||
// Get base URL from database config with fallback to config/env var
|
||||
baseURL := cfg.getBaseURL(c.Request().Context())
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
||||
err = templates.Devices(user, devices, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
// Pairing-code lookup partial: renders the confirm card (or the
|
||||
// reason nothing matched) into #pairing-lookup-result. Errors render
|
||||
// as content — htmx only swaps 2xx bodies, and the pairing section is
|
||||
// a web-only surface (the JSON API route keeps real status codes).
|
||||
frontendProtected.POST("/devices/pairing/lookup", func(c *echo.Context) error {
|
||||
userID, ok := c.Get("user_id").(string)
|
||||
if !ok {
|
||||
return c.HTML(http.StatusUnauthorized, "")
|
||||
}
|
||||
|
||||
status := "found"
|
||||
var deviceName, deviceType, pairingCode string
|
||||
registration, err := handlers.ResolvePairingCode(userID, c.RealIP(), c.FormValue("pairing_code"))
|
||||
if err != nil {
|
||||
status = handlers.PairingErrorStatus(err)
|
||||
} else {
|
||||
deviceName = registration.DeviceName
|
||||
deviceType = registration.DeviceType
|
||||
pairingCode = registration.PairingCode
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.PairingLookupResult(deviceName, deviceType, pairingCode, status).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1834,7 +1851,6 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
var errorMsg string
|
||||
var devices []handlers.DeviceInfo
|
||||
var pendingList []templates.PendingRegistrationData
|
||||
|
||||
devices, err = cfg.DeviceHandler.GetDevicesData(c)
|
||||
if err != nil {
|
||||
@@ -1843,22 +1859,11 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
errorMsg = "Error loading devices"
|
||||
}
|
||||
|
||||
pendingMaps, err := cfg.DeviceHandler.GetPendingRegistrationsData(c)
|
||||
if err != nil {
|
||||
log.Printf("GetPendingRegistrationsData failed: %v", err)
|
||||
pendingList = []templates.PendingRegistrationData{}
|
||||
if errorMsg == "" {
|
||||
errorMsg = "Error loading pending registrations"
|
||||
}
|
||||
} else {
|
||||
pendingList = convertPending(pendingMaps)
|
||||
}
|
||||
|
||||
// Get base URL from database config with fallback to config/env var
|
||||
baseURL := cfg.getBaseURL(c.Request().Context())
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
||||
err = templates.Devices(user, devices, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -82,19 +82,6 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
||||
}, nil
|
||||
}
|
||||
|
||||
func convertPending(pending []map[string]interface{}) []templates.PendingRegistrationData {
|
||||
result := make([]templates.PendingRegistrationData, len(pending))
|
||||
for i, p := range pending {
|
||||
result[i] = templates.PendingRegistrationData{
|
||||
RegistrationID: p["registration_id"].(string),
|
||||
DeviceName: p["device_name"].(string),
|
||||
DeviceType: p["device_type"].(string),
|
||||
ExpiresAt: p["expires_at"].(time.Time).Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func pingDB(cfg *Config, ctx context.Context) error {
|
||||
if cfg.DBPool != nil {
|
||||
if pool, ok := cfg.DBPool.(*pgxpool.Pool); ok {
|
||||
|
||||
@@ -402,15 +402,18 @@ func (w *Worker) processJob(job *Job) {
|
||||
Errors: errors,
|
||||
}
|
||||
|
||||
if job.Type == JobTypeScan && w.connManager != nil && job.UserID != "" {
|
||||
if job.Type == JobTypeScan && w.connManager != nil {
|
||||
// Cancelled scans announce scan_cancelled so clients reset
|
||||
// their scanning indicators; a "scan_complete" would read as
|
||||
// a finished scan.
|
||||
// Scan events are instance activity, not user data (job ids and
|
||||
// counts only) — they fan out to every client so the sitewide
|
||||
// scan indicator works for everyone. See BroadcastToAll.
|
||||
msgType := wsync.MessageTypeScanComplete
|
||||
if status == JobStatusCancelled {
|
||||
msgType = wsync.MessageTypeScanCancelled
|
||||
}
|
||||
w.connManager.BroadcastToUser(job.UserID, wsync.BroadcastMessage{
|
||||
w.connManager.BroadcastToAll(wsync.BroadcastMessage{
|
||||
Type: msgType,
|
||||
Data: map[string]interface{}{
|
||||
"job_id": job.ID,
|
||||
@@ -464,8 +467,10 @@ func (w *Worker) processScanJob(job *Job) (interface{}, error) {
|
||||
result.NewItems = newItems
|
||||
result.Errors = errors
|
||||
}
|
||||
// Broadcast via WebSocket to user
|
||||
if w.connManager != nil && job.UserID != "" {
|
||||
// Broadcast scan progress to every connected client — instance
|
||||
// activity, no user data (see BroadcastToAll). The sitewide
|
||||
// header indicator runs on these events.
|
||||
if w.connManager != nil {
|
||||
msg := wsync.BroadcastMessage{
|
||||
Type: wsync.MessageTypeScanProgress,
|
||||
Data: map[string]interface{}{
|
||||
@@ -477,7 +482,7 @@ func (w *Worker) processScanJob(job *Job) (interface{}, error) {
|
||||
},
|
||||
}
|
||||
|
||||
w.connManager.BroadcastToUser(job.UserID, msg)
|
||||
w.connManager.BroadcastToAll(msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -985,6 +985,7 @@ func (s *AnnotationService) recordConflict(
|
||||
conflictIDStr = uuid.UUID(conflict.ID.Bytes).String()
|
||||
}
|
||||
s.connMgr.BroadcastConflictNotification(
|
||||
uuid.UUID(userID.Bytes).String(),
|
||||
uuid.UUID(mediaItemID.Bytes),
|
||||
"annotation_conflict",
|
||||
conflictIDStr,
|
||||
@@ -1001,6 +1002,7 @@ func (s *AnnotationService) broadcast(
|
||||
}
|
||||
src := SourceDevice{Type: source}
|
||||
s.connMgr.BroadcastAnnotationUpdate(
|
||||
uuid.UUID(userID.Bytes).String(),
|
||||
uuid.UUID(mediaItemID.Bytes),
|
||||
annotationType,
|
||||
map[string]interface{}{
|
||||
|
||||
@@ -598,9 +598,11 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to record sync conflict: %v", err)
|
||||
} else if s.connManager != nil {
|
||||
s.connManager.BroadcastConflictNotification(req.MediaItemID.Bytes, "detection", "")
|
||||
}
|
||||
} else if s.connManager != nil {
|
||||
s.connManager.BroadcastConflictNotification(
|
||||
uuid.UUID(req.UserID.Bytes).String(),
|
||||
req.MediaItemID.Bytes, "detection", "")
|
||||
}
|
||||
}
|
||||
|
||||
if s.connManager != nil && req.Broadcast {
|
||||
@@ -613,6 +615,7 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
||||
pct = result.Percentage.Float64
|
||||
}
|
||||
s.connManager.BroadcastProgressUpdate(
|
||||
uuid.UUID(req.UserID.Bytes).String(),
|
||||
uuid.UUID(req.MediaItemID.Bytes),
|
||||
pct,
|
||||
SourceDevice{
|
||||
|
||||
+40
-39
@@ -51,52 +51,48 @@ type DeviceConnection struct {
|
||||
}
|
||||
|
||||
// ConnectionManager manages WebSocket connections
|
||||
//
|
||||
// CHOKEPOINT (2026-11-02): there is deliberately NO untargeted broadcast
|
||||
// method. Every send must name its audience — the user whose data the
|
||||
// event carries. The pre-fix Broadcast fanned progress, annotation, and
|
||||
// conflict events out to every connected client on the instance,
|
||||
// crossing user boundaries (a family-lens violation: hidden-library
|
||||
// book progress and annotation payloads reached unrelated clients).
|
||||
// The compiler now enforces what review used to.
|
||||
type ConnectionManager struct {
|
||||
connections map[string]*DeviceConnection
|
||||
broadcast chan BroadcastMessage
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewConnectionManager creates a new connection manager
|
||||
func NewConnectionManager() *ConnectionManager {
|
||||
cm := &ConnectionManager{
|
||||
return &ConnectionManager{
|
||||
connections: make(map[string]*DeviceConnection),
|
||||
broadcast: make(chan BroadcastMessage, 100),
|
||||
}
|
||||
|
||||
// Start broadcast goroutine
|
||||
go cm.broadcastLoop()
|
||||
|
||||
return cm
|
||||
}
|
||||
|
||||
// broadcastLoop handles broadcasting messages to all connected clients
|
||||
func (m *ConnectionManager) broadcastLoop() {
|
||||
for {
|
||||
// BroadcastToAll delivers an event to every connected client on the
|
||||
// instance. This is the ONE sanctioned instance-wide fan-out, and using
|
||||
// it is a decision: the payload must contain no user-derived data. Its
|
||||
// only caller is the scan-event emitter — scan activity is instance
|
||||
// activity (job ids, file counts, progress percentages), and the
|
||||
// sitewide scan indicator in web/src/header.ts is built on receiving it
|
||||
// in every browser. Anything that carries a user's books, notes, or
|
||||
// annotations belongs in BroadcastToUser.
|
||||
func (m *ConnectionManager) BroadcastToAll(msg BroadcastMessage) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, conn := range m.connections {
|
||||
select {
|
||||
case msg := <-m.broadcast:
|
||||
m.mu.RLock()
|
||||
for _, conn := range m.connections {
|
||||
select {
|
||||
case conn.Send <- msg:
|
||||
default:
|
||||
// Channel full, skip this connection
|
||||
}
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
case conn.Send <- msg:
|
||||
default:
|
||||
// Channel full, skip this connection
|
||||
log.Printf("WebSocket: Channel full for %s, skipping broadcast", conn.DeviceName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast sends a message to all connected clients
|
||||
func (m *ConnectionManager) Broadcast(msg BroadcastMessage) {
|
||||
select {
|
||||
case m.broadcast <- msg:
|
||||
default:
|
||||
log.Println("Broadcast channel full, message dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastToUser sends a message to all connections for a specific user
|
||||
func (m *ConnectionManager) BroadcastToUser(userID string, msg BroadcastMessage) {
|
||||
m.mu.RLock()
|
||||
@@ -114,8 +110,10 @@ func (m *ConnectionManager) BroadcastToUser(userID string, msg BroadcastMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastProgressUpdate broadcasts a progress update to all connected clients
|
||||
func (m *ConnectionManager) BroadcastProgressUpdate(bookID uuid.UUID, percentage float64, source SourceDevice) {
|
||||
// BroadcastProgressUpdate broadcasts a progress update to the acting
|
||||
// user's connections. Progress is per-user data — nobody else's client
|
||||
// receives it.
|
||||
func (m *ConnectionManager) BroadcastProgressUpdate(userID string, bookID uuid.UUID, percentage float64, source SourceDevice) {
|
||||
msg := BroadcastMessage{
|
||||
Type: MessageTypeProgressUpdate,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
@@ -125,11 +123,13 @@ func (m *ConnectionManager) BroadcastProgressUpdate(bookID uuid.UUID, percentage
|
||||
},
|
||||
Source: &source,
|
||||
}
|
||||
m.Broadcast(msg)
|
||||
m.BroadcastToUser(userID, msg)
|
||||
}
|
||||
|
||||
// BroadcastAnnotationUpdate broadcasts an annotation update to all connected clients
|
||||
func (m *ConnectionManager) BroadcastAnnotationUpdate(bookID uuid.UUID, annotationType string, data interface{}, source SourceDevice) {
|
||||
// BroadcastAnnotationUpdate broadcasts an annotation update to the acting
|
||||
// user's connections. Annotation payloads can carry note and highlight
|
||||
// content — strictly per-user.
|
||||
func (m *ConnectionManager) BroadcastAnnotationUpdate(userID string, bookID uuid.UUID, annotationType string, data interface{}, source SourceDevice) {
|
||||
msg := BroadcastMessage{
|
||||
Type: MessageTypeAnnotationUpdate,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
@@ -140,11 +140,12 @@ func (m *ConnectionManager) BroadcastAnnotationUpdate(bookID uuid.UUID, annotati
|
||||
},
|
||||
Source: &source,
|
||||
}
|
||||
m.Broadcast(msg)
|
||||
m.BroadcastToUser(userID, msg)
|
||||
}
|
||||
|
||||
// BroadcastConflictNotification broadcasts a conflict notification to all connected clients
|
||||
func (m *ConnectionManager) BroadcastConflictNotification(bookID [16]byte, notificationType string, conflictID string) {
|
||||
// BroadcastConflictNotification broadcasts a conflict notification to the
|
||||
// acting user's connections.
|
||||
func (m *ConnectionManager) BroadcastConflictNotification(userID string, bookID [16]byte, notificationType string, conflictID string) {
|
||||
msg := BroadcastMessage{
|
||||
Type: MessageTypeConflict,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
@@ -154,7 +155,7 @@ func (m *ConnectionManager) BroadcastConflictNotification(bookID [16]byte, notif
|
||||
"conflict_id": conflictID,
|
||||
},
|
||||
}
|
||||
m.Broadcast(msg)
|
||||
m.BroadcastToUser(userID, msg)
|
||||
}
|
||||
|
||||
// AddConnection adds a new WebSocket connection
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestBroadcastIsPerUser pins the 2026-11-02 chokepoint: sync events are
|
||||
// private to the acting user. The pre-fix helpers fanned out through the
|
||||
// untargeted Broadcast, delivering one user's book progress — and
|
||||
// annotation payloads, which can carry note text — to every connected
|
||||
// client on the instance. The untargeted method no longer exists (the
|
||||
// compiler enforces an audience on every emitter); this test enforces the
|
||||
// routing at runtime, in the spirit of TestRlsInvariant: no event may
|
||||
// cross a user boundary.
|
||||
func TestBroadcastIsPerUser(t *testing.T) {
|
||||
cm := NewConnectionManager()
|
||||
|
||||
mkConn := func(user string) *DeviceConnection {
|
||||
conn := &DeviceConnection{
|
||||
ID: uuid.NewString(),
|
||||
UserID: user,
|
||||
DeviceType: "test",
|
||||
DeviceName: user + "-device",
|
||||
Send: make(chan BroadcastMessage, 8),
|
||||
}
|
||||
cm.AddConnection(conn)
|
||||
return conn
|
||||
}
|
||||
|
||||
// Alice has two devices connected; Bob has one.
|
||||
a1 := mkConn("alice")
|
||||
a2 := mkConn("alice")
|
||||
b1 := mkConn("bob")
|
||||
|
||||
source := SourceDevice{ID: "dev-1", Name: "alice-kobo", Type: "kobo"}
|
||||
|
||||
cm.BroadcastProgressUpdate("alice", uuid.New(), 42.5, source)
|
||||
cm.BroadcastAnnotationUpdate("alice", uuid.New(), "highlight", map[string]any{"highlight_id": "h1"}, source)
|
||||
cm.BroadcastConflictNotification("alice", uuid.New(), "detection", "conflict-1")
|
||||
|
||||
drain := func(conn *DeviceConnection) []BroadcastMessage {
|
||||
var got []BroadcastMessage
|
||||
for {
|
||||
select {
|
||||
case msg := <-conn.Send:
|
||||
got = append(got, msg)
|
||||
default:
|
||||
return got
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alice's devices each received all three of her events...
|
||||
for _, conn := range []*DeviceConnection{a1, a2} {
|
||||
got := drain(conn)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("%s got %d messages, want 3", conn.DeviceName, len(got))
|
||||
}
|
||||
for _, msg := range got {
|
||||
// Conflict events are server-detected and carry no source;
|
||||
// progress and annotation events identify the originating
|
||||
// device so clients can ignore their own echo.
|
||||
if msg.Type != "conflict" && (msg.Source == nil || msg.Source.Name != "alice-kobo") {
|
||||
t.Errorf("%s: unexpected event source on %q: %+v", conn.DeviceName, msg.Type, msg.Source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ...and Bob received nothing user-derived from Alice's sync
|
||||
// activity. Scan events are the one sanctioned instance-wide fan-out
|
||||
// (BroadcastToAll — job ids and counts, no user data); everything
|
||||
// carrying a user's books or annotations stops at that user's edge.
|
||||
if leaked := drain(b1); len(leaked) != 0 {
|
||||
t.Fatalf("bob's client received %d message(s) from alice's sync activity — cross-user leak", len(leaked))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcastToAllReachesEveryone pins the sanctioned exception: scan
|
||||
// activity is instance-wide (the sitewide header indicator runs on it),
|
||||
// while everything user-derived stays per-user (TestBroadcastIsPerUser).
|
||||
func TestBroadcastToAllReachesEveryone(t *testing.T) {
|
||||
cm := NewConnectionManager()
|
||||
|
||||
var conns []*DeviceConnection
|
||||
for _, user := range []string{"alice", "bob", "carol"} {
|
||||
conn := &DeviceConnection{
|
||||
ID: uuid.NewString(),
|
||||
UserID: user,
|
||||
DeviceType: "test",
|
||||
DeviceName: user + "-device",
|
||||
Send: make(chan BroadcastMessage, 4),
|
||||
}
|
||||
cm.AddConnection(conn)
|
||||
conns = append(conns, conn)
|
||||
}
|
||||
|
||||
cm.BroadcastToAll(BroadcastMessage{Type: MessageTypeScanProgress})
|
||||
|
||||
for _, conn := range conns {
|
||||
select {
|
||||
case msg := <-conn.Send:
|
||||
if msg.Type != MessageTypeScanProgress {
|
||||
t.Errorf("%s: got %q, want scan_progress", conn.DeviceName, msg.Type)
|
||||
}
|
||||
default:
|
||||
t.Errorf("%s: received nothing from BroadcastToAll", conn.DeviceName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,9 +77,9 @@ templ Admin(user User, stats AdminStats) {
|
||||
<button @click="scanAllLibraries()" class="btn btn-primary py-4 flex-col items-start gap-1">
|
||||
<span class="flex items-center gap-2 font-medium">
|
||||
@Icon("refresh", "h-5 w-5")
|
||||
Scan All Libraries
|
||||
Force Rescan All Libraries
|
||||
</span>
|
||||
<span class="text-xs font-normal opacity-80">Re-scan existing files and detect new items</span>
|
||||
<span class="text-xs font-normal opacity-80">Re-reads every file and re-extracts metadata and hashes — slow but thorough</span>
|
||||
</button>
|
||||
<a href="/admin/library" class="btn btn-secondary py-4 flex-col items-start gap-1">
|
||||
<span class="flex items-center gap-2 font-medium">
|
||||
|
||||
@@ -236,7 +236,7 @@ templ LibraryList(user User, libraries []LibraryData, users []User) {
|
||||
class="btn btn-secondary text-xs px-3 py-1.5"
|
||||
>
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
Scan
|
||||
Force Rescan
|
||||
</button>
|
||||
<button
|
||||
data-lib-id={ library.ID }
|
||||
|
||||
@@ -397,7 +397,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "Scan</button> <button data-lib-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "Force Rescan</button> <button data-lib-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Scan All Libraries</span> <span class=\"text-xs font-normal opacity-80\">Re-scan existing files and detect new items</span></button> <a href=\"/admin/library\" class=\"btn btn-secondary py-4 flex-col items-start gap-1\"><span class=\"flex items-center gap-2 font-medium\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Force Rescan All Libraries</span> <span class=\"text-xs font-normal opacity-80\">Re-reads every file and re-extracts metadata and hashes — slow but thorough</span></button> <a href=\"/admin/library\" class=\"btn btn-secondary py-4 flex-col items-start gap-1\"><span class=\"flex items-center gap-2 font-medium\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+68
-75
@@ -2,7 +2,7 @@ package templates
|
||||
|
||||
import "bookhoard/internal/handlers"
|
||||
|
||||
templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []PendingRegistrationData, errorMessage string, baseURL string) {
|
||||
templ Devices(user User, devices []handlers.DeviceInfo, errorMessage string, baseURL string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -157,56 +157,16 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
if len(pendingRegistrations) > 0 {
|
||||
<div id="pending-section" class="mt-12">
|
||||
<h2 class="text-lg font-bold tracking-tight mb-4" style="color: var(--text-primary)">Pending Device Registrations</h2>
|
||||
<div class="space-y-4">
|
||||
for _, reg := range pendingRegistrations {
|
||||
<div class="card p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h3 class="font-semibold" style="color: var(--text-primary)">{ reg.DeviceName }</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">
|
||||
{ reg.DeviceType } - Expires in { reg.ExpiresAt }
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button @click={ "approveDevice('" + reg.RegistrationID + "')" } class="btn btn-primary text-sm">
|
||||
Approve
|
||||
</button>
|
||||
<button @click={ "rejectDevice('" + reg.RegistrationID + "')" } class="btn btn-danger text-sm">
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div id="add-device-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" x-data="{ selectedType: '' }" style="background-color: var(--surface-overlay);">
|
||||
<div id="add-device-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add New Device</h2>
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Connect a KOReader Device</h2>
|
||||
<button @click="hideAddDeviceModal()" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Type</label>
|
||||
<select
|
||||
id="device-type"
|
||||
x-model="selectedType"
|
||||
required
|
||||
class="input"
|
||||
>
|
||||
<option value="">Select device type...</option>
|
||||
<option value="koreader">KOReader (Kindle, Kobo, PocketBook)</option>
|
||||
<option value="kobo">Kobo E-Reader</option>
|
||||
<!-- <option value="web">Web Browser</option> -->
|
||||
<!-- <option value="mobile">Mobile App</option> -->
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="selectedType === 'koreader'" x-cloak class="space-y-4">
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg p-4 space-y-4" style="background-color: var(--surface-secondary);">
|
||||
<h3 class="text-sm font-semibold flex items-center gap-2" style="color: var(--text-primary)">
|
||||
@Icon("book-open", "h-4 w-4")
|
||||
@@ -242,47 +202,41 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold" style="background-color: var(--accent-muted); color: var(--accent);">5</span>
|
||||
<span>Return to this page and refresh — you'll see a <strong>pending registration</strong>. Click <strong>Approve</strong> to connect the device.</span>
|
||||
<span>Tap <strong>Register device</strong> — your e-reader shows a <strong>6-digit pairing code</strong>.</span>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold" style="background-color: var(--accent-muted); color: var(--accent);">6</span>
|
||||
<span>Enter the code below and click <strong>Find device</strong>, then confirm on the card that appears — the device finishes connecting by itself.</span>
|
||||
</li>
|
||||
</ol>
|
||||
<form hx-post="/devices/pairing/lookup" hx-target="#pairing-lookup-result" hx-swap="innerHTML" class="flex items-center gap-2 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
name="pairing_code"
|
||||
inputmode="numeric"
|
||||
maxlength="7"
|
||||
pattern="[0-9 ]*"
|
||||
autocomplete="off"
|
||||
placeholder="e.g. 583 912"
|
||||
required
|
||||
class="input max-w-44 font-mono text-base tracking-widest"
|
||||
aria-label="Pairing code"
|
||||
/>
|
||||
<button type="submit" class="btn btn-primary text-sm">
|
||||
@Icon("search", "h-4 w-4")
|
||||
Find device
|
||||
</button>
|
||||
</form>
|
||||
<div id="pairing-lookup-result" aria-live="polite"></div>
|
||||
<div class="rounded-md p-3 text-xs flex items-start gap-2" style="background-color: var(--accent-muted); color: var(--text-secondary);">
|
||||
@Icon("info", "h-4 w-4 flex-shrink-0 mt-0.5")
|
||||
<span>Once approved, reading progress sync and OPDS catalog access are set up automatically.</span>
|
||||
<span>Only someone holding the device can read the code, so only the right account can claim it. Codes expire after 5 minutes.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" @click="hideAddDeviceModal()" class="btn btn-primary">Got it</button>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="selectedType === 'kobo'" x-cloak>
|
||||
<form id="add-device-form" @submit="handleAddDevice($event)">
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="device-name"
|
||||
required
|
||||
class="input"
|
||||
placeholder="My Kobo"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Identifier</label>
|
||||
<input
|
||||
type="text"
|
||||
id="device-identifier"
|
||||
required
|
||||
class="input"
|
||||
placeholder="Hardware ID or Serial Number"
|
||||
/>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Enter your device's unique identifier</p>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="hideAddDeviceModal()" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Register Device</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="device-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||
@@ -489,3 +443,42 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
// PairingLookupResult renders inside #pairing-lookup-result after a
|
||||
// pairing-code lookup — the confirm card on a hit, otherwise why not.
|
||||
// Swapped by htmx from POST /devices/pairing/lookup.
|
||||
templ PairingLookupResult(deviceName string, deviceType string, pairingCode string, status string) {
|
||||
if status == "found" {
|
||||
<div class="card p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h3 class="font-semibold" style="color: var(--text-primary)">Found: { deviceName }</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">
|
||||
{ deviceType } device · Pair it with your account? Its reading progress will sync here.
|
||||
</p>
|
||||
</div>
|
||||
<form hx-post="/api/devices/claim" hx-target="this" hx-swap="outerHTML">
|
||||
<input type="hidden" name="pairing_code" value={ pairingCode }/>
|
||||
<button type="submit" class="btn btn-primary text-sm">
|
||||
@Icon("device", "h-4 w-4")
|
||||
Pair device
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
} else if status == "invalid" {
|
||||
<p class="text-sm" style="color: var(--error)">
|
||||
No waiting device matches that code — check the digits on your e-reader's screen.
|
||||
</p>
|
||||
} else if status == "expired" {
|
||||
<p class="text-sm" style="color: var(--status-warning)">
|
||||
That code expired. Tap <strong>Register device</strong> on the device again for a fresh one.
|
||||
</p>
|
||||
} else if status == "claimed" {
|
||||
<p class="text-sm" style="color: var(--status-warning)">
|
||||
That code was already used. If the device isn't listed above, register it again from the device.
|
||||
</p>
|
||||
} else if status == "rate_limited" {
|
||||
<p class="text-sm" style="color: var(--status-warning)">
|
||||
Too many pairing attempts — wait a minute and try again.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
+130
-106
@@ -10,7 +10,7 @@ import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import "bookhoard/internal/handlers"
|
||||
|
||||
func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []PendingRegistrationData, errorMessage string, baseURL string) templ.Component {
|
||||
func Devices(user User, devices []handlers.DeviceInfo, errorMessage string, baseURL string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
@@ -406,92 +406,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(pendingRegistrations) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<div id=\"pending-section\" class=\"mt-12\"><h2 class=\"text-lg font-bold tracking-tight mb-4\" style=\"color: var(--text-primary)\">Pending Device Registrations</h2><div class=\"space-y-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, reg := range pendingRegistrations {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div class=\"card p-4 flex items-center justify-between gap-4 flex-wrap\"><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 167, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 169, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " - Expires in ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 169, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</p></div><div class=\"flex space-x-2\"><button @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("approveDevice('" + reg.RegistrationID + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 173, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" class=\"btn btn-primary text-sm\">Approve</button> <button @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("rejectDevice('" + reg.RegistrationID + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 176, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" class=\"btn btn-danger text-sm\">Reject</button></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div><div id=\"add-device-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" x-data=\"{ selectedType: '' }\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add New Device</h2><button @click=\"hideAddDeviceModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div></div><div id=\"add-device-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Connect a KOReader Device</h2><button @click=\"hideAddDeviceModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -499,7 +414,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</button></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Type</label> <select id=\"device-type\" x-model=\"selectedType\" required class=\"input\"><option value=\"\">Select device type...</option> <option value=\"koreader\">KOReader (Kindle, Kobo, PocketBook)</option> <option value=\"kobo\">Kobo E-Reader</option><!-- <option value=\"web\">Web Browser</option> --><!-- <option value=\"mobile\">Mobile App</option> --></select></div><div x-show=\"selectedType === 'koreader'\" x-cloak class=\"space-y-4\"><div class=\"rounded-lg p-4 space-y-4\" style=\"background-color: var(--surface-secondary);\"><h3 class=\"text-sm font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</button></div><div class=\"space-y-4\"><div class=\"rounded-lg p-4 space-y-4\" style=\"background-color: var(--surface-secondary);\"><h3 class=\"text-sm font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -507,33 +422,33 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "KOReader Plugin Setup</h3><ol class=\"space-y-3 text-sm\" style=\"color: var(--text-secondary)\"><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">1</span> <span>Clone the <a href=\"https://git.linuxhg.com/Bookhoard/bookhoard.koplugin\" class=\"underline\" style=\"color: var(--accent);\">Bookhoard plugin</a> to your KOReader <code class=\"px-1 py-0.5 rounded\" style=\"background-color: var(--bg-primary);\">plugins/</code> directory</span></li><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">2</span> <span>Open KOReader, tap the <strong>wrench icon</strong> at the top</span></li><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">3</span> <span>Find and tap <strong>Bookhoard sync</strong></span></li><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">4</span> <span>Tap <strong>Server URL</strong>, enter your server address, then tap <strong>OK</strong>:</span></li><li class=\"ml-8\"><div class=\"flex items-center gap-2\"><code class=\"text-xs px-2 py-1 rounded font-mono flex-1\" style=\"background-color: var(--bg-primary); color: var(--text-primary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "KOReader Plugin Setup</h3><ol class=\"space-y-3 text-sm\" style=\"color: var(--text-secondary)\"><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">1</span> <span>Clone the <a href=\"https://git.linuxhg.com/Bookhoard/bookhoard.koplugin\" class=\"underline\" style=\"color: var(--accent);\">Bookhoard plugin</a> to your KOReader <code class=\"px-1 py-0.5 rounded\" style=\"background-color: var(--bg-primary);\">plugins/</code> directory</span></li><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">2</span> <span>Open KOReader, tap the <strong>wrench icon</strong> at the top</span></li><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">3</span> <span>Find and tap <strong>Bookhoard sync</strong></span></li><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">4</span> <span>Tap <strong>Server URL</strong>, enter your server address, then tap <strong>OK</strong>:</span></li><li class=\"ml-8\"><div class=\"flex items-center gap-2\"><code class=\"text-xs px-2 py-1 rounded font-mono flex-1\" style=\"background-color: var(--bg-primary); color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(baseURL)
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(baseURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 234, Col: 148}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 194, Col: 148}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</code> <button @click=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</code> <button @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard('" + baseURL + "', 'Server URL')")
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard('" + baseURL + "', 'Server URL')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 236, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 196, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" class=\"btn btn-secondary text-xs px-3\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" class=\"btn btn-secondary text-xs px-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -541,7 +456,15 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</button></div></li><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">5</span> <span>Return to this page and refresh — you'll see a <strong>pending registration</strong>. Click <strong>Approve</strong> to connect the device.</span></li></ol><div class=\"rounded-md p-3 text-xs flex items-start gap-2\" style=\"background-color: var(--accent-muted); color: var(--text-secondary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</button></div></li><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">5</span> <span>Tap <strong>Register device</strong> — your e-reader shows a <strong>6-digit pairing code</strong>.</span></li><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">6</span> <span>Enter the code below and click <strong>Find device</strong>, then confirm on the card that appears — the device finishes connecting by itself.</span></li></ol><form hx-post=\"/devices/pairing/lookup\" hx-target=\"#pairing-lookup-result\" hx-swap=\"innerHTML\" class=\"flex items-center gap-2 flex-wrap\"><input type=\"text\" name=\"pairing_code\" inputmode=\"numeric\" maxlength=\"7\" pattern=\"[0-9 ]*\" autocomplete=\"off\" placeholder=\"e.g. 583 912\" required class=\"input max-w-44 font-mono text-base tracking-widest\" aria-label=\"Pairing code\"> <button type=\"submit\" class=\"btn btn-primary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "Find device</button></form><div id=\"pairing-lookup-result\" aria-live=\"polite\"></div><div class=\"rounded-md p-3 text-xs flex items-start gap-2\" style=\"background-color: var(--accent-muted); color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -549,7 +472,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<span>Once approved, reading progress sync and OPDS catalog access are set up automatically.</span></div></div><div class=\"flex justify-end\"><button type=\"button\" @click=\"hideAddDeviceModal()\" class=\"btn btn-primary\">Got it</button></div></div><div x-show=\"selectedType === 'kobo'\" x-cloak><form id=\"add-device-form\" @submit=\"handleAddDevice($event)\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Name</label> <input type=\"text\" id=\"device-name\" required class=\"input\" placeholder=\"My Kobo\"></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Identifier</label> <input type=\"text\" id=\"device-identifier\" required class=\"input\" placeholder=\"Hardware ID or Serial Number\"><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Enter your device's unique identifier</p></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideAddDeviceModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Register Device</button></div></form></div></div></div><div id=\"device-settings-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Device Settings</h2><button @click=\"hideDeviceSettingsModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span>Only someone holding the device can read the code, so only the right account can claim it. Codes expire after 5 minutes.</span></div></div><div class=\"flex justify-end\"><button type=\"button\" @click=\"hideAddDeviceModal()\" class=\"btn btn-primary\">Got it</button></div></div></div></div><div id=\"device-settings-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Device Settings</h2><button @click=\"hideDeviceSettingsModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -557,7 +480,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</button></div><form id=\"device-settings-form\" @submit=\"handleSaveDeviceSettings($event)\"><input type=\"hidden\" id=\"settings-device-id\"> <input type=\"hidden\" id=\"settings-device-type\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Name</label> <input type=\"text\" id=\"settings-device-name\" required class=\"input\"></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-sync-enabled\" checked class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Enable Sync</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Allow this device to sync reading progress</p></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-auto-sync\" checked class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Auto Sync</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Automatically sync changes</p></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Sync Frequency</label> <select id=\"settings-sync-frequency\" class=\"input\"><option value=\"1\">Every 1 minute</option> <option value=\"5\" selected>Every 5 minutes</option> <option value=\"15\">Every 15 minutes</option> <option value=\"30\">Every 30 minutes</option> <option value=\"60\">Every hour</option></select></div><div class=\"border-t pt-6 mb-6\" style=\"border-color: var(--border);\"><h3 class=\"text-base font-semibold mb-1\" style=\"color: var(--text-primary)\">Collection View Settings</h3><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Configure how collections are displayed on this device</p><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Default View Mode</label> <select id=\"settings-view-mode\" class=\"input\"><option value=\"grid\">Grid View</option> <option value=\"list\">List View</option> <option value=\"compact\">Compact View</option></select></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Sort Collections By</label> <select id=\"settings-sort-order\" class=\"input\"><option value=\"name\">Collection Name</option> <option value=\"created\">Date Created</option> <option value=\"book_count\">Book Count</option> <option value=\"recent\">Recently Added</option></select></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Items Per Page</label> <select id=\"settings-items-per-page\" class=\"input\"><option value=\"12\">12 items</option> <option value=\"24\" selected>24 items</option> <option value=\"48\">48 items</option> <option value=\"96\">96 items</option></select></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-show-covers\" checked class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Show Cover Images</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Display book covers in collection views</p></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-show-progress\" class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Show Reading Progress</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Display progress indicators for books</p></div></div><div class=\"flex justify-end space-x-3 flex-wrap\"><button type=\"button\" @click=\"hideDeviceSettingsModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"handleRevokeDevice()\" class=\"btn btn-danger\">Revoke Device</button> <button type=\"submit\" class=\"btn btn-primary\">Save Settings</button></div></form></div></div><div id=\"shelf-mappings-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Collection to Shelf Mappings</h2><button @click=\"hideShelfMappingsModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</button></div><form id=\"device-settings-form\" @submit=\"handleSaveDeviceSettings($event)\"><input type=\"hidden\" id=\"settings-device-id\"> <input type=\"hidden\" id=\"settings-device-type\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Name</label> <input type=\"text\" id=\"settings-device-name\" required class=\"input\"></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-sync-enabled\" checked class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Enable Sync</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Allow this device to sync reading progress</p></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-auto-sync\" checked class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Auto Sync</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Automatically sync changes</p></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Sync Frequency</label> <select id=\"settings-sync-frequency\" class=\"input\"><option value=\"1\">Every 1 minute</option> <option value=\"5\" selected>Every 5 minutes</option> <option value=\"15\">Every 15 minutes</option> <option value=\"30\">Every 30 minutes</option> <option value=\"60\">Every hour</option></select></div><div class=\"border-t pt-6 mb-6\" style=\"border-color: var(--border);\"><h3 class=\"text-base font-semibold mb-1\" style=\"color: var(--text-primary)\">Collection View Settings</h3><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Configure how collections are displayed on this device</p><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Default View Mode</label> <select id=\"settings-view-mode\" class=\"input\"><option value=\"grid\">Grid View</option> <option value=\"list\">List View</option> <option value=\"compact\">Compact View</option></select></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Sort Collections By</label> <select id=\"settings-sort-order\" class=\"input\"><option value=\"name\">Collection Name</option> <option value=\"created\">Date Created</option> <option value=\"book_count\">Book Count</option> <option value=\"recent\">Recently Added</option></select></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Items Per Page</label> <select id=\"settings-items-per-page\" class=\"input\"><option value=\"12\">12 items</option> <option value=\"24\" selected>24 items</option> <option value=\"48\">48 items</option> <option value=\"96\">96 items</option></select></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-show-covers\" checked class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Show Cover Images</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Display book covers in collection views</p></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-show-progress\" class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Show Reading Progress</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Display progress indicators for books</p></div></div><div class=\"flex justify-end space-x-3 flex-wrap\"><button type=\"button\" @click=\"hideDeviceSettingsModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"handleRevokeDevice()\" class=\"btn btn-danger\">Revoke Device</button> <button type=\"submit\" class=\"btn btn-primary\">Save Settings</button></div></form></div></div><div id=\"shelf-mappings-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Collection to Shelf Mappings</h2><button @click=\"hideShelfMappingsModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -565,7 +488,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</button></div><input type=\"hidden\" id=\"mappings-device-id\"><div id=\"shelf-mappings-container\" class=\"space-y-4 mb-6\"><p style=\"color: var(--text-secondary)\">Loading mappings...</p></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideShelfMappingsModal()\" class=\"btn btn-secondary\">Close</button> <button type=\"button\" @click=\"showAddMappingModal()\" class=\"btn btn-primary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button></div><input type=\"hidden\" id=\"mappings-device-id\"><div id=\"shelf-mappings-container\" class=\"space-y-4 mb-6\"><p style=\"color: var(--text-secondary)\">Loading mappings...</p></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideShelfMappingsModal()\" class=\"btn btn-secondary\">Close</button> <button type=\"button\" @click=\"showAddMappingModal()\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -573,7 +496,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "Add Mapping</button></div></div></div><div id=\"add-mapping-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add Collection Mapping</h2><button @click=\"hideAddMappingModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "Add Mapping</button></div></div></div><div id=\"add-mapping-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add Collection Mapping</h2><button @click=\"hideAddMappingModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -581,7 +504,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</button></div><form id=\"mapping-form\" @submit=\"handleSaveMapping($event)\"><input type=\"hidden\" id=\"mapping-device-id\"> <input type=\"hidden\" id=\"mapping-id\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Collection</label> <select id=\"mapping-collection\" required class=\"input\"><option value=\"\">Select collection...</option></select></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Shelf Name</label> <input type=\"text\" id=\"mapping-shelf-name\" required class=\"input\" placeholder=\"e.g., Sci-Fi\"><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Name of the shelf on this device</p></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Sync Direction</label> <select id=\"mapping-sync-direction\" required class=\"input\"><option value=\"bidirectional\">Bidirectional (sync both ways)</option> <option value=\"book_to_device\">Bookhoard → Device</option> <option value=\"device_to_book\">Device → Bookhoard</option> <option value=\"none\">No sync</option></select></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideAddMappingModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save Mapping</button></div></form></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</button></div><form id=\"mapping-form\" @submit=\"handleSaveMapping($event)\"><input type=\"hidden\" id=\"mapping-device-id\"> <input type=\"hidden\" id=\"mapping-id\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Collection</label> <select id=\"mapping-collection\" required class=\"input\"><option value=\"\">Select collection...</option></select></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Shelf Name</label> <input type=\"text\" id=\"mapping-shelf-name\" required class=\"input\" placeholder=\"e.g., Sci-Fi\"><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Name of the shelf on this device</p></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Sync Direction</label> <select id=\"mapping-sync-direction\" required class=\"input\"><option value=\"bidirectional\">Bidirectional (sync both ways)</option> <option value=\"book_to_device\">Bookhoard → Device</option> <option value=\"device_to_book\">Device → Bookhoard</option> <option value=\"none\">No sync</option></select></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideAddMappingModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save Mapping</button></div></form></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -589,7 +512,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -597,4 +520,105 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
})
|
||||
}
|
||||
|
||||
// PairingLookupResult renders inside #pairing-lookup-result after a
|
||||
// pairing-code lookup — the confirm card on a hit, otherwise why not.
|
||||
// Swapped by htmx from POST /devices/pairing/lookup.
|
||||
func PairingLookupResult(deviceName string, deviceType string, pairingCode string, status string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var19 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var19 == nil {
|
||||
templ_7745c5c3_Var19 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if status == "found" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div class=\"card p-4 flex items-center justify-between gap-4 flex-wrap\"><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Found: ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(deviceName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 454, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(deviceType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 456, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " device · Pair it with your account? Its reading progress will sync here.</p></div><form hx-post=\"/api/devices/claim\" hx-target=\"this\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"pairing_code\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(pairingCode)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 460, Col: 64}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\"> <button type=\"submit\" class=\"btn btn-primary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("device", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "Pair device</button></form></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if status == "invalid" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<p class=\"text-sm\" style=\"color: var(--error)\">No waiting device matches that code — check the digits on your e-reader's screen.</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if status == "expired" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<p class=\"text-sm\" style=\"color: var(--status-warning)\">That code expired. Tap <strong>Register device</strong> on the device again for a fresh one.</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if status == "claimed" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<p class=\"text-sm\" style=\"color: var(--status-warning)\">That code was already used. If the device isn't listed above, register it again from the device.</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if status == "rate_limited" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<p class=\"text-sm\" style=\"color: var(--status-warning)\">Too many pairing attempts — wait a minute and try again.</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -185,13 +185,6 @@ type SeriesPageState struct {
|
||||
Status string
|
||||
}
|
||||
|
||||
type PendingRegistrationData struct {
|
||||
RegistrationID string
|
||||
DeviceName string
|
||||
DeviceType string
|
||||
ExpiresAt string
|
||||
}
|
||||
|
||||
type ConflictData struct {
|
||||
ID string
|
||||
MediaItemID string
|
||||
|
||||
@@ -28,6 +28,7 @@ Gate: every line below executes for BOTH personas unless marked
|
||||
| 21 | Podcasts: cross-user invisibility — own feed only (RLS §20 surfaces) | 🔲 | 🔲 |
|
||||
| 22 | Podcasts: order selector re-renders episode list; serial suggestion shown only for serial/audiobook feeds | 🔲 | 🔲 |
|
||||
| 23 | Podcasts: audio reader "Ep N/M" + prev/next episode; chain-advance to next episode | 🔲 | 🔲 |
|
||||
| 24 | Devices: Add New Device modal — pairing-code lookup renders templ confirm card in the modal, claim binds + page refresh; wrong/expired code renders inline error partial (not a toast-only path) | 🔲 | 🔲 (registration invisible by design) |
|
||||
|
||||
### Podcast-specific sweep notes (plan task 1.10)
|
||||
|
||||
|
||||
@@ -19,48 +19,7 @@ function showAddDeviceModal(): void {
|
||||
|
||||
function hideAddDeviceModal(): void {
|
||||
const modal = document.getElementById("add-device-modal");
|
||||
const form = document.getElementById("add-device-form");
|
||||
if (modal) modal.classList.add("hidden");
|
||||
if (form && form instanceof HTMLFormElement) form.reset();
|
||||
}
|
||||
|
||||
async function handleAddDevice(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
|
||||
// cookie auth carries the call; a null bearer must not strand it
|
||||
|
||||
const deviceNameInput = document.getElementById("device-name") as HTMLInputElement;
|
||||
const deviceTypeInput = document.getElementById("device-type") as HTMLSelectElement;
|
||||
const deviceIdentifierInput = document.getElementById("device-identifier") as HTMLInputElement;
|
||||
|
||||
if (!deviceNameInput || !deviceTypeInput || !deviceIdentifierInput) return;
|
||||
|
||||
const data = {
|
||||
device_name: deviceNameInput.value,
|
||||
device_type: deviceTypeInput.value,
|
||||
device_identifier: deviceIdentifierInput.value,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/devices/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast("Device registered! Check your device for sync instructions.", "success");
|
||||
hideAddDeviceModal();
|
||||
window.location.reload();
|
||||
} else {
|
||||
showToast("Failed to register device", "error");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to register device", error);
|
||||
showToast("Failed to register device", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function showDeviceSettings(deviceId: string): void {
|
||||
@@ -483,48 +442,6 @@ async function handleRevokeDevice(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function approveDevice(registrationId: string): Promise<void> {
|
||||
// cookie auth carries the call; a null bearer must not strand it
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/devices/approve/${registrationId}`, {
|
||||
method: "GET",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast("Device approved successfully", "success");
|
||||
window.location.reload();
|
||||
} else {
|
||||
showToast("Failed to approve device", "error");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to approve device", error);
|
||||
showToast("Failed to approve device", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectDevice(registrationId: string): Promise<void> {
|
||||
// cookie auth carries the call; a null bearer must not strand it
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/devices/reject/${registrationId}`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast("Device registration rejected", "info");
|
||||
window.location.reload();
|
||||
} else {
|
||||
showToast("Failed to reject device", "error");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to reject device", error);
|
||||
showToast("Failed to reject device", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function setupEventDelegation(): void {
|
||||
document.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -545,10 +462,20 @@ function setupEventDelegation(): void {
|
||||
deleteMapping(button.dataset.mappingId || "");
|
||||
}
|
||||
});
|
||||
|
||||
// Pairing (Add Device modal): htmx only swaps 2xx bodies, so claim/lookup
|
||||
// failures (wrong code, rate limit, expired) would otherwise fail
|
||||
// silently. Content-level answers come from the templ partial; this
|
||||
// toast covers the non-swap error path.
|
||||
document.body.addEventListener("htmx:responseError", (e) => {
|
||||
const detail = (e as CustomEvent<{ requestConfig?: { elt?: Element } }>).detail;
|
||||
if (detail?.requestConfig?.elt?.closest("#add-device-modal")) {
|
||||
showToast("Pairing failed — check the code and try again.", "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Alpine.data("devices", () => ({
|
||||
approveDevice,
|
||||
copyToClipboard,
|
||||
copyFromId(id: string, label: string) {
|
||||
const el = document.getElementById(id) as HTMLInputElement | null;
|
||||
@@ -557,7 +484,6 @@ Alpine.data("devices", () => ({
|
||||
deleteMapping,
|
||||
editMapping,
|
||||
getDeviceIcon,
|
||||
handleAddDevice,
|
||||
handleRevokeDevice,
|
||||
handleSaveDeviceSettings,
|
||||
handleSaveMapping,
|
||||
@@ -567,7 +493,6 @@ Alpine.data("devices", () => ({
|
||||
hideShelfMappingsModal,
|
||||
loadCollections,
|
||||
loadShelfMappings,
|
||||
rejectDevice,
|
||||
setupEventDelegation,
|
||||
showAddDeviceModal,
|
||||
showAddMappingModal,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||
Reference in new issue
Block a user