Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cd8557b58 | ||
|
|
b4c956aed4 | ||
|
|
7caa46c2da |
@@ -20,6 +20,9 @@ logs/
|
|||||||
|
|
||||||
# Dependencies (install fresh in container)
|
# Dependencies (install fresh in container)
|
||||||
node_modules/
|
node_modules/
|
||||||
|
# Local-only lockfile: a stale one pins old foliate-js commits into builds
|
||||||
|
# (npm reuses name@version without re-resolving the git pin)
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -252,6 +252,88 @@ func TestListPendingRegistrations(t *testing.T) {
|
|||||||
assert.NotNil(t, pending, "Pending registrations should not be nil")
|
assert.NotNil(t, pending, "Pending registrations should not be nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression test for the "approve returns 500 after app reinstall" bug:
|
||||||
|
// an app reinstall that preserves data (e.g. Android Studio installDebug
|
||||||
|
// over an existing install) re-registers with the SAME device_identifier,
|
||||||
|
// and the blind INSERT in ApproveDevice hit the UNIQUE(device_identifier)
|
||||||
|
// constraint. Re-approval must be idempotent: same row (id unchanged),
|
||||||
|
// rotated token, old token invalidated.
|
||||||
|
func TestApproveDeviceReapprovalRotatesToken(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
userID := getTestUserID(t, setup.DB)
|
||||||
|
|
||||||
|
identifier := fmt.Sprintf("repro-reinstall-%s", uuid.New().String())
|
||||||
|
|
||||||
|
registerAndApprove := func() string {
|
||||||
|
regRequest := map[string]interface{}{
|
||||||
|
"device_name": "Reinstall Device",
|
||||||
|
"device_type": "mobile",
|
||||||
|
"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)
|
||||||
|
registrationID, ok := regResponse["registration_id"].(string)
|
||||||
|
require.True(t, ok, "Should have registration_id")
|
||||||
|
|
||||||
|
req = httptest.NewRequest("GET", fmt.Sprintf("/api/devices/approve/%s", registrationID), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code,
|
||||||
|
"Approve must succeed even when the identifier already exists (was the reinstall 500)")
|
||||||
|
|
||||||
|
// The status response is single-use and carries the credentials.
|
||||||
|
statusBody, _ := json.Marshal(map[string]string{"registration_id": registrationID})
|
||||||
|
req = httptest.NewRequest("POST", "/api/devices/register/status", bytes.NewReader(statusBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
|
var statusResponse map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &statusResponse)
|
||||||
|
require.Equal(t, "approved", statusResponse["status"])
|
||||||
|
token, ok := statusResponse["auth_token"].(string)
|
||||||
|
require.True(t, ok, "Should have auth_token")
|
||||||
|
require.NotEmpty(t, token)
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
token1 := registerAndApprove()
|
||||||
|
token2 := registerAndApprove()
|
||||||
|
|
||||||
|
assert.NotEqual(t, token1, token2, "Re-approval must rotate the auth token (fresh install = fresh credentials)")
|
||||||
|
|
||||||
|
devices, err := setup.DB.ListDevicesByUser(context.Background(),
|
||||||
|
pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
rows := 0
|
||||||
|
for _, d := range devices {
|
||||||
|
if d.DeviceIdentifier == identifier {
|
||||||
|
rows++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, rows, "Re-approval must reuse the existing devices row, not duplicate it")
|
||||||
|
|
||||||
|
// The rotated-in token must be the live one.
|
||||||
|
var live *database.Devices
|
||||||
|
for i := range devices {
|
||||||
|
if devices[i].DeviceIdentifier == identifier {
|
||||||
|
live = &devices[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, live)
|
||||||
|
assert.Equal(t, token2, live.AuthToken, "The devices row must carry the newly rotated token")
|
||||||
|
}
|
||||||
|
|
||||||
func TestApproveDeviceRegistration(t *testing.T) {
|
func TestApproveDeviceRegistration(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
| **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) |
|
| **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) |
|
||||||
| **Contribute code** | [Contributing Portal → Development Guide](contributing/development.md) |
|
| **Contribute code** | [Contributing Portal → Development Guide](contributing/development.md) |
|
||||||
| **Understand sync** | [User Portal → Sync Guide](user/sync-guide.md) |
|
| **Understand sync** | [User Portal → Sync Guide](user/sync-guide.md) |
|
||||||
|
| **Read in the browser** | [User Portal → Web Reader Guide](user/reader-guide.md) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -90,6 +91,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
| ...set up KOReader? | [KOReader Setup Guide](user/devices/koreader-setup.md) |
|
| ...set up KOReader? | [KOReader Setup Guide](user/devices/koreader-setup.md) |
|
||||||
| ...use a Kobo? | [Kobo Setup Guide](user/devices/kobo-setup.md) - native sync coming soon; KOReader works today |
|
| ...use a Kobo? | [Kobo Setup Guide](user/devices/kobo-setup.md) - native sync coming soon; KOReader works today |
|
||||||
| ...understand sync? | [Sync Guide](user/sync-guide.md) |
|
| ...understand sync? | [Sync Guide](user/sync-guide.md) |
|
||||||
|
| ...read in the browser? | [Web Reader Guide](user/reader-guide.md) - reading, highlights, notes |
|
||||||
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
|
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
|
||||||
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) |
|
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) |
|
||||||
| ...use the API? | [API Reference](developer/api-reference.md) |
|
| ...use the API? | [API Reference](developer/api-reference.md) |
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Web Reader Guide
|
||||||
|
|
||||||
|
Bookhoard's built-in web reader works in any modern browser — desktop or
|
||||||
|
mobile — and keeps your position, highlights, bookmarks, and notes in sync
|
||||||
|
with the Android app and KOReader.
|
||||||
|
|
||||||
|
## Opening a Book
|
||||||
|
|
||||||
|
From your library or book page, click **Read**. The reader opens in an
|
||||||
|
immersive view with the book's cover, title, and chapter in the top bar
|
||||||
|
(EPUB) or page number (PDF/comics).
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
### On a phone or tablet (touch)
|
||||||
|
|
||||||
|
| Gesture | Action |
|
||||||
|
|---|---|
|
||||||
|
| **Swipe** left / right | Turn the page |
|
||||||
|
| **Tap** once | Toggle the reader menu (headers, settings, drawers) |
|
||||||
|
| **Tap** in a corner zone | With the menu open: jump back / forward by chapter |
|
||||||
|
|
||||||
|
Paging is swipe-only by design: taps are reserved for the menu so a page
|
||||||
|
turn can never fire while you're trying to select text.
|
||||||
|
|
||||||
|
### On desktop
|
||||||
|
|
||||||
|
- Scroll wheel, trackpad, arrow keys, or **Page Up / Page Down** to page
|
||||||
|
- Click the progress bar (bottom, with the menu open) to jump
|
||||||
|
- Contents drawer for the table of contents; Pages grid for PDF/comics
|
||||||
|
|
||||||
|
## Text Selection & Highlights
|
||||||
|
|
||||||
|
### Selecting text (touch)
|
||||||
|
|
||||||
|
1. **Press and hold** a word (~half a second) until it highlights
|
||||||
|
2. **Drag** to extend the selection word by word — it stays on the
|
||||||
|
visible page
|
||||||
|
3. **Drag the round handles** (EPUB) to fine-tune the start and end
|
||||||
|
points
|
||||||
|
4. Lift your finger and pause — the highlight menu appears **below** the
|
||||||
|
selection, underneath the browser's own copy menu
|
||||||
|
|
||||||
|
The browser's address bar hides while a selection is active to give the
|
||||||
|
page room; it returns as soon as the selection is cleared.
|
||||||
|
|
||||||
|
### Creating a highlight or note
|
||||||
|
|
||||||
|
With the selection menu open:
|
||||||
|
|
||||||
|
- **Color dots** — tap one to highlight immediately in that color
|
||||||
|
- **Pencil** — opens a note; write it and tap **Highlight with note**
|
||||||
|
to save the highlight and its note together
|
||||||
|
- **Copy** — copies the selected text (works even over plain HTTP on
|
||||||
|
your LAN)
|
||||||
|
- While the note editor is open, you can still change the color — the
|
||||||
|
popover stays put until you save or cancel
|
||||||
|
|
||||||
|
### Editing or removing a highlight
|
||||||
|
|
||||||
|
Tap anywhere on a painted highlight to reopen it in the menu. From there
|
||||||
|
you can change its color, edit the note (pencil → **Save note**), or
|
||||||
|
delete it (trash icon). Tapping anywhere else closes the menu.
|
||||||
|
|
||||||
|
### PDFs and comics
|
||||||
|
|
||||||
|
Text selection works the same way on PDFs. Highlights are stored as
|
||||||
|
page-anchored rectangles and sync to the Android app. Comics/manga
|
||||||
|
libraries disable annotation (bookmarks still work).
|
||||||
|
|
||||||
|
## Notes & Bookmarks
|
||||||
|
|
||||||
|
The **annotations drawer** (right side, from the open menu) lists your
|
||||||
|
highlights, standalone notes, and bookmarks for the book — tap any entry
|
||||||
|
to jump to it.
|
||||||
|
|
||||||
|
## Where Reading Position Is Stored
|
||||||
|
|
||||||
|
Position, highlights, bookmarks, and notes sync automatically through the
|
||||||
|
server (see the [Sync Guide](sync-guide.md)). Close the tab whenever you
|
||||||
|
like — the reader resumes exactly where you left off on any device.
|
||||||
@@ -36,6 +36,14 @@ Learn how to configure your e-reader devices to sync with Bookhoard:
|
|||||||
- Troubleshooting
|
- Troubleshooting
|
||||||
- Best practices
|
- Best practices
|
||||||
|
|
||||||
|
## 📖 Reading
|
||||||
|
|
||||||
|
- **[Web Reader Guide](reader-guide.md)** - Reading and annotating in the browser
|
||||||
|
- Touch navigation (swipe to page, tap for menu)
|
||||||
|
- Selecting text: long-press, drag, handles
|
||||||
|
- Highlights, notes, copy, and editing
|
||||||
|
- PDFs and comics
|
||||||
|
|
||||||
## 🎨 Frontend Guide
|
## 🎨 Frontend Guide
|
||||||
|
|
||||||
**[Frontend Guide](frontend-guide.md)** - Learn how to use the Bookhoard web interface
|
**[Frontend Guide](frontend-guide.md)** - Learn how to use the Bookhoard web interface
|
||||||
|
|||||||
+114
-12
@@ -6,11 +6,16 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/skip2/go-qrcode"
|
"github.com/skip2/go-qrcode"
|
||||||
@@ -106,7 +111,14 @@ type PendingRegistration struct {
|
|||||||
SyncEndpoints map[string]string
|
SyncEndpoints map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
var pendingRegistrations = make(map[string]*PendingRegistration)
|
// pendingRegistrations holds in-flight (unapproved) device registrations.
|
||||||
|
// HTTP handlers touch it from multiple goroutines — every access must hold
|
||||||
|
// pendingMu (Go maps are not safe for concurrent use; a racing write is a
|
||||||
|
// runtime fatal, not an error).
|
||||||
|
var (
|
||||||
|
pendingRegistrations = make(map[string]*PendingRegistration)
|
||||||
|
pendingMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
|
func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
|
||||||
req := DeviceRegistrationRequest{}
|
req := DeviceRegistrationRequest{}
|
||||||
@@ -130,7 +142,9 @@ func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
|
|||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingMu.Lock()
|
||||||
pendingRegistrations[registrationID] = registration
|
pendingRegistrations[registrationID] = registration
|
||||||
|
pendingMu.Unlock()
|
||||||
|
|
||||||
authURL := fmt.Sprintf("%s/devices/approve/%s", h.cfg.BaseURL, registrationID)
|
authURL := fmt.Sprintf("%s/devices/approve/%s", h.cfg.BaseURL, registrationID)
|
||||||
|
|
||||||
@@ -167,25 +181,32 @@ func (h *DeviceHandler) CheckRegistrationStatus(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingMu.Lock()
|
||||||
registration, exists := pendingRegistrations[req.RegistrationID]
|
registration, exists := pendingRegistrations[req.RegistrationID]
|
||||||
if !exists {
|
if !exists {
|
||||||
|
pendingMu.Unlock()
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if time.Now().After(registration.ExpiresAt) {
|
if time.Now().After(registration.ExpiresAt) {
|
||||||
delete(pendingRegistrations, req.RegistrationID)
|
delete(pendingRegistrations, req.RegistrationID)
|
||||||
|
pendingMu.Unlock()
|
||||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if registration.Approved {
|
if registration.Approved {
|
||||||
delete(pendingRegistrations, req.RegistrationID)
|
delete(pendingRegistrations, req.RegistrationID)
|
||||||
|
// Snapshot under the lock: the approver wrote these fields, and
|
||||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
// the row is gone from the map — no other reader/writer remains.
|
||||||
|
resp := DeviceAuthStatusResponse{
|
||||||
Status: "approved",
|
Status: "approved",
|
||||||
AuthToken: registration.AuthToken,
|
AuthToken: registration.AuthToken,
|
||||||
DeviceID: registration.DeviceID,
|
DeviceID: registration.DeviceID,
|
||||||
SyncEndpoints: registration.SyncEndpoints,
|
SyncEndpoints: registration.SyncEndpoints,
|
||||||
})
|
}
|
||||||
|
pendingMu.Unlock()
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||||
@@ -552,17 +573,21 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
|||||||
|
|
||||||
registrationID := c.Param("registration_id")
|
registrationID := c.Param("registration_id")
|
||||||
|
|
||||||
|
pendingMu.Lock()
|
||||||
registration, exists := pendingRegistrations[registrationID]
|
registration, exists := pendingRegistrations[registrationID]
|
||||||
if !exists {
|
if !exists {
|
||||||
|
pendingMu.Unlock()
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if time.Now().After(registration.ExpiresAt) {
|
if time.Now().After(registration.ExpiresAt) {
|
||||||
delete(pendingRegistrations, registrationID)
|
delete(pendingRegistrations, registrationID)
|
||||||
|
pendingMu.Unlock()
|
||||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if registration.Approved {
|
if registration.Approved {
|
||||||
|
pendingMu.Unlock()
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"message": "device already approved",
|
"message": "device already approved",
|
||||||
"device_name": registration.DeviceName,
|
"device_name": registration.DeviceName,
|
||||||
@@ -570,6 +595,7 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
|||||||
"approved": true,
|
"approved": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
pendingMu.Unlock()
|
||||||
|
|
||||||
authToken, err := generateDeviceToken()
|
authToken, err := generateDeviceToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -577,23 +603,87 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
||||||
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
|
|
||||||
autoSync := pgtype.Bool{Bool: true, Valid: true}
|
|
||||||
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
|
|
||||||
|
|
||||||
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
// 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 {
|
||||||
|
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,
|
UserID: pgUserID,
|
||||||
DeviceName: registration.DeviceName,
|
DeviceName: registration.DeviceName,
|
||||||
DeviceType: registration.DeviceType,
|
DeviceType: registration.DeviceType,
|
||||||
DeviceIdentifier: registration.DeviceIdentifier,
|
DeviceIdentifier: registration.DeviceIdentifier,
|
||||||
AuthToken: authToken,
|
AuthToken: authToken,
|
||||||
SyncEnabled: syncEnabled,
|
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||||
AutoSync: autoSync,
|
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||||
SyncFrequencyMinutes: syncFreq,
|
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||||
DeviceMetadata: []byte("{}"),
|
DeviceMetadata: []byte("{}"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
|
// 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",
|
||||||
|
"registration_id", registrationID, "error", err)
|
||||||
|
return c.JSON(http.StatusInternalServerError,
|
||||||
|
map[string]string{"error": "failed to look up device"})
|
||||||
}
|
}
|
||||||
|
|
||||||
syncEndpoints := map[string]string{}
|
syncEndpoints := map[string]string{}
|
||||||
@@ -607,11 +697,16 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
|||||||
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Publish the credentials under the map lock: the status poller
|
||||||
|
// snapshots these fields only after Approved flips, so the token can
|
||||||
|
// never be read half-written.
|
||||||
|
pendingMu.Lock()
|
||||||
registration.UserID = userUUID
|
registration.UserID = userUUID
|
||||||
registration.Approved = true
|
registration.Approved = true
|
||||||
registration.AuthToken = authToken
|
registration.AuthToken = authToken
|
||||||
registration.DeviceID = device.ID.Bytes
|
registration.DeviceID = device.ID.Bytes
|
||||||
registration.SyncEndpoints = syncEndpoints
|
registration.SyncEndpoints = syncEndpoints
|
||||||
|
pendingMu.Unlock()
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"message": "device approved successfully",
|
"message": "device approved successfully",
|
||||||
@@ -625,12 +720,15 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
|||||||
func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
|
func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
|
||||||
registrationID := c.Param("registration_id")
|
registrationID := c.Param("registration_id")
|
||||||
|
|
||||||
|
pendingMu.Lock()
|
||||||
_, exists := pendingRegistrations[registrationID]
|
_, exists := pendingRegistrations[registrationID]
|
||||||
if !exists {
|
if !exists {
|
||||||
|
pendingMu.Unlock()
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(pendingRegistrations, registrationID)
|
delete(pendingRegistrations, registrationID)
|
||||||
|
pendingMu.Unlock()
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]string{
|
return c.JSON(http.StatusOK, map[string]string{
|
||||||
"message": "device registration rejected",
|
"message": "device registration rejected",
|
||||||
@@ -639,6 +737,7 @@ func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
|
|||||||
|
|
||||||
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
|
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
|
||||||
registrations := []map[string]interface{}{}
|
registrations := []map[string]interface{}{}
|
||||||
|
pendingMu.Lock()
|
||||||
for _, reg := range pendingRegistrations {
|
for _, reg := range pendingRegistrations {
|
||||||
if reg.UserID == (uuid.UUID{}) {
|
if reg.UserID == (uuid.UUID{}) {
|
||||||
registrations = append(registrations, map[string]interface{}{
|
registrations = append(registrations, map[string]interface{}{
|
||||||
@@ -652,6 +751,7 @@ func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[stri
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pendingMu.Unlock()
|
||||||
|
|
||||||
return registrations, nil
|
return registrations, nil
|
||||||
}
|
}
|
||||||
@@ -664,6 +764,7 @@ func (h *DeviceHandler) ListPendingRegistrations(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registrations := []map[string]interface{}{}
|
registrations := []map[string]interface{}{}
|
||||||
|
pendingMu.Lock()
|
||||||
for _, reg := range pendingRegistrations {
|
for _, reg := range pendingRegistrations {
|
||||||
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
|
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
|
||||||
registrations = append(registrations, map[string]interface{}{
|
registrations = append(registrations, map[string]interface{}{
|
||||||
@@ -677,6 +778,7 @@ func (h *DeviceHandler) ListPendingRegistrations(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pendingMu.Unlock()
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"registrations": registrations,
|
"registrations": registrations,
|
||||||
|
|||||||
Reference in New Issue
Block a user