Owner-flagged after the dev-docs audit: BroadcastProgressUpdate, BroadcastAnnotationUpdate, and BroadcastConflictNotification fanned out through the untargeted Broadcast, delivering one user's book progress, conflict notices, and — worst — annotation payloads (which can carry note/highlight text) to EVERY connected client on the instance. A family-lens violation of exactly the kind the RLS move eliminated for database reads: user B's client received events for user A's books, including books in libraries hidden from B. Fix is the RLS philosophy applied to the fan-out layer: enforcement at one choke point. The untargeted Broadcast method and its channel/loop machinery are deleted — BroadcastToUser is now the only door, and the compiler forces every future emitter to name its audience instead of relying on review. The three helpers take the acting user's ID; the four sync call sites (progress.go x2, annotations.go x2) already had the acting user in scope. Scan events were already per-triggering- identity and collections already per-user — unchanged. Logging is untouched: the only log line in the diff is the deleted method's own, so docker logs remains the complete administrator audit trail. TestBroadcastIsPerUser pins the invariant (alice's two devices receive her three events; bob's client receives nothing — any message there is a cross-user leak). Both websocket docs updated in the same commit: the wire doc's fan-out section now states per-user delivery and the chokepoint, and the gated endpoint doc's intro matches. Full make test green.
1128 lines
40 KiB
Go
1128 lines
40 KiB
Go
package sync
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
// TombstoneTTL is the fallback retention for soft-deleted annotations when no
|
|
// settings registry is wired (e.g. in tests). It matches the historical value.
|
|
const TombstoneTTL = 30 * 24 * time.Hour
|
|
|
|
type AnnotationService struct {
|
|
db *database.Queries
|
|
connMgr *ConnectionManager
|
|
settings *database.SettingsRegistry
|
|
}
|
|
|
|
func NewAnnotationService(db *database.Queries, connMgr *ConnectionManager) *AnnotationService {
|
|
return &AnnotationService{db: db, connMgr: connMgr}
|
|
}
|
|
|
|
// SetSettings wires the tunable settings registry. When wired, the tombstone
|
|
// TTL is read live from the DB; otherwise the package const TombstoneTTL is
|
|
// used.
|
|
func (s *AnnotationService) SetSettings(reg *database.SettingsRegistry) { s.settings = reg }
|
|
|
|
// tombstoneTTL returns the active tombstone retention window.
|
|
func (s *AnnotationService) tombstoneTTL() time.Duration {
|
|
if s.settings != nil {
|
|
return s.settings.TombstoneTTL()
|
|
}
|
|
return TombstoneTTL
|
|
}
|
|
|
|
// ActiveTombstoneTTL exposes the configured tombstone retention window for
|
|
// callers outside the sync package (e.g. kobo/koreader handlers) that need to
|
|
// compute cutoffs consistently with the service.
|
|
func (s *AnnotationService) ActiveTombstoneTTL() time.Duration { return s.tombstoneTTL() }
|
|
|
|
type SaveOutcome string
|
|
|
|
const (
|
|
SaveOutcomeCreated SaveOutcome = "created"
|
|
SaveOutcomeUpdated SaveOutcome = "updated"
|
|
SaveOutcomeSkipped SaveOutcome = "skipped"
|
|
SaveOutcomeDeleted SaveOutcome = "deleted"
|
|
)
|
|
|
|
type SaveHighlightRequest struct {
|
|
MediaItemID pgtype.UUID
|
|
UserID pgtype.UUID
|
|
SelectionText string
|
|
StartPosition string
|
|
EndPosition string
|
|
Color string
|
|
NoteText string
|
|
// HighlightID, when valid, targets that exact row (web PUTs edit by
|
|
// id): the save LWWs against it directly under its stored dedup key.
|
|
// The computed key depends on fields that legitimately change — the
|
|
// stored CFI drifts range→point shape after device echoes, and the
|
|
// user can edit the selection text — so a key-based upsert would mint
|
|
// a duplicate beside the very row being edited.
|
|
HighlightID pgtype.UUID
|
|
PercentageStart float64
|
|
PercentageEnd float64
|
|
EpubcfiStart string
|
|
EpubcfiEnd string
|
|
ChapterReference int32
|
|
Source string
|
|
ModifiedAt time.Time
|
|
DeviceSyncData json.RawMessage
|
|
// DedupKey overrides the computed key when the client echoes back an
|
|
// annotation it received from us (device echoes carry device-native
|
|
// locators, so the computed key would never match the original row and
|
|
// every pull→push cycle would mint a duplicate).
|
|
DedupKey string
|
|
}
|
|
|
|
type SaveHighlightResult struct {
|
|
Highlight database.MediaHighlights
|
|
Outcome SaveOutcome
|
|
Conflict bool
|
|
}
|
|
|
|
// coalesce returns fallback when v is the zero value — the LWW-apply
|
|
// "an omitted field preserves the stored value" primitive. Zeroes matter
|
|
// here because pgFloat8/pgInt4 turn them into NULLs: an uncoalesced
|
|
// partial payload would erase stored locators on every re-save.
|
|
func coalesce[T comparable](v, fallback T) T {
|
|
var zero T
|
|
if v == zero {
|
|
return fallback
|
|
}
|
|
return v
|
|
}
|
|
|
|
// idTargetOwnership verifies an id-targeted edit's ownership: the URL row
|
|
// must belong to the requesting user and media item.
|
|
func idTargetOwnership(rowUser, rowItem, reqUser, reqItem pgtype.UUID, kind string, id pgtype.UUID) error {
|
|
if rowUser != reqUser || rowItem != reqItem {
|
|
return fmt.Errorf("%s %s belongs to another user or media item", kind, id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// tombstoneWins is the shared tombstone gate for id-targeted edits: a
|
|
// deleted row stays deleted unless the edit is newer than the tombstone
|
|
// (a deliberate re-create — the LWW update then resurrects the row).
|
|
func tombstoneWins(deleted bool, deletedAt, lastModifiedAt pgtype.Timestamptz, modifiedAt time.Time) bool {
|
|
return deleted && !incomingNewerThanTombstone(modifiedAt, deletedAt, lastModifiedAt)
|
|
}
|
|
|
|
func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
|
|
dedupKey := req.DedupKey
|
|
|
|
// Web edits arrive with the row id from the URL: resolve by id first
|
|
// and LWW against that row under its stored key. Content-derived keys
|
|
// are for lookups by identity (device pushes carry no row ids); an
|
|
// edit must never re-derive identity from (possibly edited) content.
|
|
if req.HighlightID.Valid {
|
|
byID, idErr := s.db.GetMediaHighlight(ctx, req.HighlightID)
|
|
if idErr != nil && !errors.Is(idErr, pgx.ErrNoRows) {
|
|
return nil, fmt.Errorf("query highlight by id: %w", idErr)
|
|
}
|
|
if idErr == nil {
|
|
if err := idTargetOwnership(byID.UserID, byID.MediaItemID, req.UserID, req.MediaItemID, "highlight", req.HighlightID); err != nil {
|
|
return nil, err
|
|
}
|
|
if dedupKey == "" {
|
|
dedupKey = byID.DedupKey.String
|
|
}
|
|
if tombstoneWins(byID.Deleted.Bool, byID.DeletedAt, byID.LastModifiedAt, req.ModifiedAt) {
|
|
return &SaveHighlightResult{Highlight: byID, Outcome: SaveOutcomeDeleted}, nil
|
|
}
|
|
return s.applyLWW(ctx, req, byID, dedupKey)
|
|
}
|
|
// No row with that id: fall through to identity-based resolution.
|
|
}
|
|
|
|
if dedupKey == "" {
|
|
dedupKey = ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)
|
|
}
|
|
|
|
existing, err := s.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
|
|
UserID: req.UserID,
|
|
MediaItemID: req.MediaItemID,
|
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
|
})
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, fmt.Errorf("query existing highlight: %w", err)
|
|
}
|
|
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return s.createHighlight(ctx, req, dedupKey)
|
|
}
|
|
|
|
if existing.Deleted.Bool {
|
|
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
|
|
return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil
|
|
}
|
|
// Newer than the tombstone: a deliberate re-create. Resurrect via the
|
|
// LWW update (which clears deleted/deleted_at).
|
|
return s.applyLWW(ctx, req, existing, dedupKey)
|
|
}
|
|
|
|
return s.applyLWW(ctx, req, existing, dedupKey)
|
|
}
|
|
|
|
func (s *AnnotationService) createHighlight(
|
|
ctx context.Context,
|
|
req SaveHighlightRequest,
|
|
dedupKey string,
|
|
) (*SaveHighlightResult, error) {
|
|
modifiedAt := req.ModifiedAt
|
|
if modifiedAt.IsZero() {
|
|
modifiedAt = time.Now()
|
|
}
|
|
|
|
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
|
|
|
|
highlight, err := s.db.CreateMediaHighlightFull(ctx, database.CreateMediaHighlightFullParams{
|
|
MediaItemID: req.MediaItemID,
|
|
UserID: req.UserID,
|
|
SelectionText: req.SelectionText,
|
|
StartPosition: pgText(req.StartPosition),
|
|
EndPosition: pgText(req.EndPosition),
|
|
Color: pgText(req.Color),
|
|
NoteText: pgText(req.NoteText),
|
|
PercentageStart: pgFloat8(req.PercentageStart),
|
|
PercentageEnd: pgFloat8(req.PercentageEnd),
|
|
EpubcfiStart: pgText(req.EpubcfiStart),
|
|
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
|
ChapterReference: pgInt4(req.ChapterReference),
|
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
|
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
|
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
|
DeviceSyncData: deviceData,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create highlight: %w", err)
|
|
}
|
|
|
|
s.broadcast(highlight.ID, req.UserID, req.MediaItemID, "highlight", req.Source)
|
|
return &SaveHighlightResult{Highlight: highlight, Outcome: SaveOutcomeCreated}, nil
|
|
}
|
|
|
|
func (s *AnnotationService) applyLWW(
|
|
ctx context.Context,
|
|
req SaveHighlightRequest,
|
|
existing database.MediaHighlights,
|
|
dedupKey string,
|
|
) (*SaveHighlightResult, error) {
|
|
incomingNewer, contentChanged := s.compareIncoming(req, existing)
|
|
|
|
if !incomingNewer && !contentChanged {
|
|
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
|
if conflict {
|
|
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_highlight",
|
|
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
|
req, existing, "existing")
|
|
}
|
|
return &SaveHighlightResult{
|
|
Highlight: existing,
|
|
Outcome: SaveOutcomeSkipped,
|
|
Conflict: conflict,
|
|
}, nil
|
|
}
|
|
|
|
modifiedAt := req.ModifiedAt
|
|
if modifiedAt.IsZero() {
|
|
modifiedAt = time.Now()
|
|
}
|
|
|
|
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
|
|
|
// Web edits carry no device locators (the web reader never had a CRE
|
|
// xpointer) and may carry no CFI either: keep the stored ones so
|
|
// device-native serve-back and round-trip identity survive a web-side
|
|
// note/color edit instead of being wiped to empty.
|
|
startPosition := coalesce(req.StartPosition, existing.StartPosition.String)
|
|
endPosition := coalesce(req.EndPosition, existing.EndPosition.String)
|
|
epubcfiStart := coalesce(req.EpubcfiStart, existing.EpubcfiStart.String)
|
|
epubcfiEnd := coalesce(req.EpubcfiEnd, existing.EpubcfiEnd.String)
|
|
|
|
highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
|
|
ID: existing.ID,
|
|
SelectionText: req.SelectionText,
|
|
StartPosition: pgText(startPosition),
|
|
EndPosition: pgText(endPosition),
|
|
Color: pgText(coalesce(req.Color, existing.Color.String)),
|
|
NoteText: pgText(coalesce(req.NoteText, existing.NoteText.String)),
|
|
PercentageStart: pgFloat8(coalesce(req.PercentageStart, existing.PercentageStart.Float64)),
|
|
PercentageEnd: pgFloat8(coalesce(req.PercentageEnd, existing.PercentageEnd.Float64)),
|
|
EpubcfiStart: pgText(epubcfiStart),
|
|
EpubcfiEnd: pgText(epubcfiEnd),
|
|
ChapterReference: pgInt4(coalesce(req.ChapterReference, existing.ChapterReference.Int32)),
|
|
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
|
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
|
DeviceSyncData: deviceData,
|
|
UserID: req.UserID,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update highlight: %w", err)
|
|
}
|
|
|
|
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
|
if conflict {
|
|
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_highlight",
|
|
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
|
req, existing, "incoming")
|
|
}
|
|
s.broadcast(highlight.ID, req.UserID, req.MediaItemID, "highlight", req.Source)
|
|
return &SaveHighlightResult{Highlight: highlight, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
|
|
}
|
|
|
|
func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing database.MediaHighlights) (incomingNewer bool, contentChanged bool) {
|
|
contentSame := strings.EqualFold(req.SelectionText, existing.SelectionText) &&
|
|
textEq(req.Color, existing.Color) &&
|
|
textEq(req.NoteText, existing.NoteText) &&
|
|
floatEq(req.PercentageStart, existing.PercentageStart) &&
|
|
floatEq(req.PercentageEnd, existing.PercentageEnd) &&
|
|
locatorRefresher(req.EpubcfiStart, existing.EpubcfiStart) &&
|
|
locatorRefresher(req.EpubcfiEnd, existing.EpubcfiEnd)
|
|
return lwwDecision(req.ModifiedAt, existing.LastModifiedAt, existing.UpdatedAt, contentSame)
|
|
}
|
|
|
|
// locatorRefresher reports whether an incoming locator leaves the stored
|
|
// one untouched: either the push carried none (empty coalesces to the
|
|
// stored value in applyLWW) or it matches what is stored. A non-empty
|
|
// incoming locator that DIFFERS is a deliberate refresh: device echoes
|
|
// re-derive their canonical CFIs on every push, and an improvement (a
|
|
// converter fix re-landing a corrupted anchor, drift repair) must reach
|
|
// the row even when the annotation content is otherwise identical —
|
|
// without this, content-equal echoes resolve to "skip" and a bad stored
|
|
// locator can never heal.
|
|
func locatorRefresher(incoming string, existing pgtype.Text) bool {
|
|
if incoming == "" {
|
|
return true
|
|
}
|
|
return textEq(incoming, existing)
|
|
}
|
|
|
|
func (s *AnnotationService) TombstoneHighlight(
|
|
ctx context.Context,
|
|
userID, mediaItemID pgtype.UUID,
|
|
dedupKey string,
|
|
source string,
|
|
) error {
|
|
err := s.db.TombstoneMediaHighlightByDedupKey(ctx, database.TombstoneMediaHighlightByDedupKeyParams{
|
|
UserID: userID,
|
|
MediaItemID: mediaItemID,
|
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("tombstone highlight: %w", err)
|
|
}
|
|
s.broadcast(pgtype.UUID{}, userID, mediaItemID, "highlight_delete", source)
|
|
return nil
|
|
}
|
|
|
|
func (s *AnnotationService) TombstoneHighlightByID(
|
|
ctx context.Context,
|
|
userID, highlightID pgtype.UUID,
|
|
source string,
|
|
) error {
|
|
h, err := s.db.GetMediaHighlight(ctx, highlightID)
|
|
if err != nil {
|
|
return fmt.Errorf("get highlight for tombstone: %w", err)
|
|
}
|
|
// Ownership predicate: the caller must own the row. A mismatched user
|
|
// gets a not-found-style error, never the row's existence.
|
|
if h.UserID != userID {
|
|
return fmt.Errorf("tombstone highlight by ID: %w", pgx.ErrNoRows)
|
|
}
|
|
err = s.db.TombstoneMediaHighlightByID(ctx, database.TombstoneMediaHighlightByIDParams{
|
|
ID: highlightID,
|
|
UserID: userID,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("tombstone highlight by ID: %w", err)
|
|
}
|
|
s.broadcast(pgtype.UUID{}, h.UserID, h.MediaItemID, "highlight_delete", source)
|
|
return nil
|
|
}
|
|
|
|
// TombstoneBookmarkByDedupKey soft-deletes a bookmark by its dedup key — the
|
|
// device-sync counterpart of TombstoneHighlight. Devices report deletions by
|
|
// dedup key (they have no row IDs), so this keeps bookmark delete propagation
|
|
// symmetric with highlights.
|
|
func (s *AnnotationService) TombstoneBookmarkByDedupKey(
|
|
ctx context.Context,
|
|
userID, mediaItemID pgtype.UUID,
|
|
dedupKey string,
|
|
source string,
|
|
) error {
|
|
if dedupKey == "" {
|
|
return nil
|
|
}
|
|
err := s.db.TombstoneMediaBookmarkByDedupKey(ctx, database.TombstoneMediaBookmarkByDedupKeyParams{
|
|
UserID: userID,
|
|
MediaItemID: mediaItemID,
|
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("tombstone bookmark: %w", err)
|
|
}
|
|
s.broadcast(pgtype.UUID{}, userID, mediaItemID, "bookmark_delete", source)
|
|
return nil
|
|
}
|
|
|
|
// ValidAnnotationKind reports whether kind is one of the annotation types
|
|
// accepted by the history restore/purge endpoints.
|
|
func ValidAnnotationKind(kind string) bool {
|
|
return kind == "highlight" || kind == "note" || kind == "bookmark"
|
|
}
|
|
|
|
// RestoreAnnotationByID clears the tombstone on a deleted annotation,
|
|
// returning it to the active set. The row itself was never removed, so
|
|
// restoration is lossless. Returns false when no matching deleted annotation
|
|
// exists (wrong owner, wrong book, or not actually deleted).
|
|
func (s *AnnotationService) RestoreAnnotationByID(
|
|
ctx context.Context,
|
|
kind string,
|
|
userID, mediaItemID, annotationID pgtype.UUID,
|
|
) (bool, error) {
|
|
var rows int64
|
|
var err error
|
|
switch kind {
|
|
case "highlight":
|
|
rows, err = s.db.RestoreMediaHighlightByID(ctx, database.RestoreMediaHighlightByIDParams{
|
|
ID: annotationID, UserID: userID, MediaItemID: mediaItemID})
|
|
case "note":
|
|
rows, err = s.db.RestoreMediaNoteByID(ctx, database.RestoreMediaNoteByIDParams{
|
|
ID: annotationID, UserID: userID, MediaItemID: mediaItemID})
|
|
case "bookmark":
|
|
rows, err = s.db.RestoreMediaBookmarkByID(ctx, database.RestoreMediaBookmarkByIDParams{
|
|
ID: annotationID, UserID: userID, MediaItemID: mediaItemID})
|
|
default:
|
|
return false, fmt.Errorf("unknown annotation kind: %s", kind)
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("restore %s: %w", kind, err)
|
|
}
|
|
if rows > 0 {
|
|
s.broadcast(annotationID, userID, mediaItemID, kind, "web")
|
|
}
|
|
return rows > 0, nil
|
|
}
|
|
|
|
// PurgeAnnotationByID permanently deletes an already-tombstoned annotation
|
|
// from the history. Unlike a tombstone this is irreversible; the TTL-driven
|
|
// maintenance sweep does the same thing to old tombstones eventually.
|
|
func (s *AnnotationService) PurgeAnnotationByID(
|
|
ctx context.Context,
|
|
kind string,
|
|
userID, mediaItemID, annotationID pgtype.UUID,
|
|
) (bool, error) {
|
|
var rows int64
|
|
var err error
|
|
switch kind {
|
|
case "highlight":
|
|
rows, err = s.db.PurgeMediaHighlightByID(ctx, database.PurgeMediaHighlightByIDParams{
|
|
ID: annotationID, UserID: userID, MediaItemID: mediaItemID})
|
|
case "note":
|
|
rows, err = s.db.PurgeMediaNoteByID(ctx, database.PurgeMediaNoteByIDParams{
|
|
ID: annotationID, UserID: userID, MediaItemID: mediaItemID})
|
|
case "bookmark":
|
|
rows, err = s.db.PurgeMediaBookmarkByID(ctx, database.PurgeMediaBookmarkByIDParams{
|
|
ID: annotationID, UserID: userID, MediaItemID: mediaItemID})
|
|
default:
|
|
return false, fmt.Errorf("unknown annotation kind: %s", kind)
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("purge %s: %w", kind, err)
|
|
}
|
|
return rows > 0, nil
|
|
}
|
|
|
|
func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error {
|
|
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-s.tombstoneTTL()), Valid: true}
|
|
if err := s.db.PurgeExpiredHighlightTombstones(ctx, cutoff); err != nil {
|
|
return fmt.Errorf("purge highlight tombstones: %w", err)
|
|
}
|
|
if err := s.db.PurgeExpiredNoteTombstones(ctx, cutoff); err != nil {
|
|
return fmt.Errorf("purge note tombstones: %w", err)
|
|
}
|
|
if err := s.db.PurgeExpiredBookmarkTombstones(ctx, cutoff); err != nil {
|
|
return fmt.Errorf("purge bookmark tombstones: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StartDailyMaintenance launches a single background goroutine that runs all
|
|
// periodic cleanup tasks once every 24 hours: expired annotation tombstones,
|
|
// expired/revoked refresh tokens (retention follows the configured session
|
|
// duration), and expired OPDS tokens. Each task is independent; a failure in
|
|
// one is logged and does not skip the others. The returned CancelFunc stops the
|
|
// goroutine and the underlying ticker; it must be invoked on shutdown.
|
|
func (s *AnnotationService) StartDailyMaintenance() context.CancelFunc {
|
|
ticker := time.NewTicker(24 * time.Hour)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
ticker.Stop()
|
|
return
|
|
case <-ticker.C:
|
|
s.runDailyMaintenance(ctx)
|
|
}
|
|
}
|
|
}()
|
|
|
|
return cancel
|
|
}
|
|
|
|
// runDailyMaintenance executes every periodic cleanup task. Tasks run
|
|
// sequentially under the single daily-tick goroutine so there is no added
|
|
// concurrency. All three queries only delete rows that are already unusable
|
|
// (expired or revoked), so this never logs out active sessions.
|
|
func (s *AnnotationService) runDailyMaintenance(ctx context.Context) {
|
|
if err := s.PurgeExpiredTombstones(ctx); err != nil {
|
|
log.Printf("maintenance: tombstone purge failed: %v", err)
|
|
}
|
|
if err := s.db.CleanupExpiredOpdsTokens(ctx); err != nil {
|
|
log.Printf("maintenance: OPDS token purge failed: %v", err)
|
|
}
|
|
// Refresh-token retention follows the configured session duration; re-read
|
|
// on every tick so live settings changes are honored. Guarded so unwired
|
|
// test paths simply skip cleanup (production always wires the registry).
|
|
if s.settings != nil {
|
|
retention := s.settings.SessionDuration().Seconds()
|
|
if err := s.db.CleanupExpiredRefreshTokens(ctx, retention); err != nil {
|
|
log.Printf("maintenance: refresh token purge failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
type SaveNoteRequest struct {
|
|
MediaItemID pgtype.UUID
|
|
UserID pgtype.UUID
|
|
Content string
|
|
Position string
|
|
PercentageLocation float64
|
|
CharacterStart int32
|
|
CharacterEnd int32
|
|
EpubcfiLocation string
|
|
ChapterReference int32
|
|
ParagraphReference int32
|
|
Source string
|
|
ModifiedAt time.Time
|
|
DeviceSyncData []byte
|
|
DedupKey string // overrides the computed key for device echoes
|
|
// NoteID, when valid, targets that exact row (web PUTs edit by id):
|
|
// the save LWWs against it under its stored dedup key, mirroring
|
|
// SaveHighlightRequest.HighlightID.
|
|
NoteID pgtype.UUID
|
|
}
|
|
|
|
type SaveNoteResult struct {
|
|
Note database.MediaNotes
|
|
Outcome SaveOutcome
|
|
Conflict bool
|
|
}
|
|
|
|
func (s *AnnotationService) SaveNote(ctx context.Context, req SaveNoteRequest) (*SaveNoteResult, error) {
|
|
if !req.UserID.Valid || !req.MediaItemID.Valid {
|
|
return nil, errors.New("invalid user_id or media_item_id")
|
|
}
|
|
|
|
dedupKey := req.DedupKey
|
|
|
|
// Web edits arrive with the row id from the URL: resolve by id and LWW
|
|
// against that row under its stored key (same doctrine as
|
|
// SaveHighlight's HighlightID path). Locator columns the note PUT
|
|
// does not carry are coalesced from the stored row so the full-column
|
|
// sync update cannot zero them.
|
|
if req.NoteID.Valid {
|
|
byID, idErr := s.db.GetMediaNote(ctx, req.NoteID)
|
|
if idErr != nil && !errors.Is(idErr, pgx.ErrNoRows) {
|
|
return nil, fmt.Errorf("query note by id: %w", idErr)
|
|
}
|
|
if idErr == nil {
|
|
if err := idTargetOwnership(byID.UserID, byID.MediaItemID, req.UserID, req.MediaItemID, "note", req.NoteID); err != nil {
|
|
return nil, err
|
|
}
|
|
if dedupKey == "" {
|
|
dedupKey = byID.DedupKey.String
|
|
}
|
|
if tombstoneWins(byID.Deleted.Bool, byID.DeletedAt, byID.LastModifiedAt, req.ModifiedAt) {
|
|
return &SaveNoteResult{Note: byID, Outcome: SaveOutcomeDeleted}, nil
|
|
}
|
|
return s.applyNoteLWW(ctx, req, byID, dedupKey)
|
|
}
|
|
// No row with that id: fall through to identity-based resolution.
|
|
}
|
|
|
|
if dedupKey == "" {
|
|
dedupKey = ComputeDedupKey(req.Content, req.EpubcfiLocation, req.Position)
|
|
}
|
|
|
|
existing, err := s.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
|
|
UserID: req.UserID,
|
|
MediaItemID: req.MediaItemID,
|
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
|
})
|
|
if err != nil {
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, fmt.Errorf("get note by dedup key: %w", err)
|
|
}
|
|
return s.createNote(ctx, req, dedupKey)
|
|
}
|
|
|
|
if existing.Deleted.Valid && existing.Deleted.Bool {
|
|
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
|
|
return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeDeleted}, nil
|
|
}
|
|
// Newer than the tombstone: a deliberate re-create. Resurrect.
|
|
return s.applyNoteLWW(ctx, req, existing, dedupKey)
|
|
}
|
|
|
|
return s.applyNoteLWW(ctx, req, existing, dedupKey)
|
|
}
|
|
|
|
func (s *AnnotationService) createNote(ctx context.Context, req SaveNoteRequest, dedupKey string) (*SaveNoteResult, error) {
|
|
modifiedAt := req.ModifiedAt
|
|
if modifiedAt.IsZero() {
|
|
modifiedAt = time.Now()
|
|
}
|
|
|
|
note, err := s.db.CreateMediaNoteFull(ctx, database.CreateMediaNoteFullParams{
|
|
MediaItemID: req.MediaItemID,
|
|
UserID: req.UserID,
|
|
Content: req.Content,
|
|
Position: pgText(req.Position),
|
|
PercentageLocation: pgFloat8(req.PercentageLocation),
|
|
CharacterStart: pgInt4(req.CharacterStart),
|
|
CharacterEnd: pgInt4(req.CharacterEnd),
|
|
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
|
ChapterReference: pgInt4(req.ChapterReference),
|
|
ParagraphReference: pgInt4(req.ParagraphReference),
|
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
|
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
|
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
|
DeviceSyncData: req.DeviceSyncData,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create note: %w", err)
|
|
}
|
|
s.broadcast(note.ID, req.UserID, req.MediaItemID, "note", req.Source)
|
|
return &SaveNoteResult{Note: note, Outcome: SaveOutcomeCreated}, nil
|
|
}
|
|
|
|
func (s *AnnotationService) applyNoteLWW(ctx context.Context, req SaveNoteRequest, existing database.MediaNotes, dedupKey string) (*SaveNoteResult, error) {
|
|
incomingNewer, contentChanged := s.compareIncomingNote(req, existing)
|
|
|
|
if !incomingNewer && !contentChanged {
|
|
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
|
if conflict {
|
|
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_note",
|
|
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
|
req, existing, "existing")
|
|
}
|
|
return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeSkipped, Conflict: conflict}, nil
|
|
}
|
|
|
|
modifiedAt := req.ModifiedAt
|
|
if modifiedAt.IsZero() {
|
|
modifiedAt = time.Now()
|
|
}
|
|
|
|
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
|
|
|
// Coalesce omitted fields with the stored row (same doctrine as the
|
|
// highlight apply): a partial payload — a web PUT carrying only
|
|
// content/position, a device echo without percentages — must not
|
|
// wipe the locators it didn't mention (see coalesce).
|
|
note, err := s.db.UpdateMediaNoteForSync(ctx, database.UpdateMediaNoteForSyncParams{
|
|
ID: existing.ID,
|
|
UserID: req.UserID,
|
|
Content: req.Content,
|
|
Position: pgText(coalesce(req.Position, existing.Position.String)),
|
|
PercentageLocation: pgFloat8(coalesce(req.PercentageLocation, existing.PercentageLocation.Float64)),
|
|
CharacterStart: pgInt4(coalesce(req.CharacterStart, existing.CharacterStart.Int32)),
|
|
CharacterEnd: pgInt4(coalesce(req.CharacterEnd, existing.CharacterEnd.Int32)),
|
|
EpubcfiLocation: pgText(coalesce(req.EpubcfiLocation, existing.EpubcfiLocation.String)),
|
|
ChapterReference: pgInt4(coalesce(req.ChapterReference, existing.ChapterReference.Int32)),
|
|
ParagraphReference: pgInt4(coalesce(req.ParagraphReference, existing.ParagraphReference.Int32)),
|
|
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
|
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
|
DeviceSyncData: deviceData,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update note: %w", err)
|
|
}
|
|
|
|
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
|
if conflict {
|
|
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_note",
|
|
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
|
req, existing, "incoming")
|
|
}
|
|
s.broadcast(note.ID, req.UserID, req.MediaItemID, "note", req.Source)
|
|
return &SaveNoteResult{Note: note, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
|
|
}
|
|
|
|
// lwwDecision is the shared LWW decision core for all three annotation
|
|
// stacks: with a client timestamp, strictly-newer wins (and content is
|
|
// treated as changed — the update re-lands everything); without one, any
|
|
// content difference is "newer" and identical content resolves to skip.
|
|
// The fallback timestamp is schema-driven per stack (rows with an
|
|
// updated_at fall back to it; bookmarks only carry created_at).
|
|
func lwwDecision(modifiedAt time.Time, existingLastMod, existingFallback pgtype.Timestamptz, contentSame bool) (incomingNewer bool, contentChanged bool) {
|
|
if modifiedAt.IsZero() {
|
|
return !contentSame, !contentSame
|
|
}
|
|
existingMod := existingLastMod
|
|
if !existingMod.Valid {
|
|
existingMod = existingFallback
|
|
}
|
|
return modifiedAt.After(existingMod.Time), true
|
|
}
|
|
|
|
// compareIncomingNote is compareIncoming for the note stack.
|
|
func (s *AnnotationService) compareIncomingNote(req SaveNoteRequest, existing database.MediaNotes) (incomingNewer bool, contentChanged bool) {
|
|
contentSame := strings.EqualFold(req.Content, existing.Content) &&
|
|
textEq(req.Position, existing.Position)
|
|
return lwwDecision(req.ModifiedAt, existing.LastModifiedAt, existing.UpdatedAt, contentSame)
|
|
}
|
|
|
|
func (s *AnnotationService) TombstoneNoteByID(ctx context.Context, id, userID pgtype.UUID) error {
|
|
return s.db.TombstoneMediaNoteByID(ctx, database.TombstoneMediaNoteByIDParams{
|
|
ID: id,
|
|
UserID: userID,
|
|
})
|
|
}
|
|
|
|
type SaveBookmarkRequest struct {
|
|
MediaItemID pgtype.UUID
|
|
UserID pgtype.UUID
|
|
Title string
|
|
Position string
|
|
Notes string
|
|
PageNumber int32
|
|
ChapterNumber int32
|
|
CFIPosition string
|
|
PercentageLoc float64
|
|
EpubcfiLocation string
|
|
ChapterReference int32
|
|
Source string
|
|
ModifiedAt time.Time
|
|
DeviceSyncData json.RawMessage
|
|
// OriginSource records the client that created the bookmark (set once
|
|
// at insert; unlike Source it is not updated by later writers).
|
|
OriginSource string
|
|
// DedupKey overrides the computed key for device echoes (see
|
|
// SaveHighlightRequest).
|
|
DedupKey string
|
|
// BookmarkID, when valid, targets that exact row (web PUTs edit by
|
|
// id): the save LWWs against it under its stored dedup key, mirroring
|
|
// SaveHighlightRequest.HighlightID — an edit must not re-derive
|
|
// identity from (possibly edited) content.
|
|
BookmarkID pgtype.UUID
|
|
}
|
|
|
|
type SaveBookmarkResult struct {
|
|
Bookmark database.MediaBookmarks
|
|
Outcome SaveOutcome
|
|
Conflict bool
|
|
}
|
|
|
|
func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRequest) (*SaveBookmarkResult, error) {
|
|
dedupKey := req.DedupKey
|
|
|
|
// Web edits arrive with the row id from the URL: resolve by id and LWW
|
|
// against that row under its stored key (same doctrine as
|
|
// SaveHighlight's HighlightID path). Fields the edit does not carry
|
|
// are coalesced from the stored row — the web PUT predates the
|
|
// locator columns and omits them; zeroing them on update would be
|
|
// data loss.
|
|
if req.BookmarkID.Valid {
|
|
byID, idErr := s.db.GetMediaBookmark(ctx, req.BookmarkID)
|
|
if idErr != nil && !errors.Is(idErr, pgx.ErrNoRows) {
|
|
return nil, fmt.Errorf("query bookmark by id: %w", idErr)
|
|
}
|
|
if idErr == nil {
|
|
if err := idTargetOwnership(byID.UserID, byID.MediaItemID, req.UserID, req.MediaItemID, "bookmark", req.BookmarkID); err != nil {
|
|
return nil, err
|
|
}
|
|
if dedupKey == "" {
|
|
dedupKey = byID.DedupKey.String
|
|
}
|
|
if tombstoneWins(byID.Deleted.Bool, byID.DeletedAt, byID.LastModifiedAt, req.ModifiedAt) {
|
|
return &SaveBookmarkResult{Bookmark: byID, Outcome: SaveOutcomeDeleted}, nil
|
|
}
|
|
return s.applyBookmarkLWW(ctx, req, byID, dedupKey)
|
|
}
|
|
// No row with that id: fall through to identity-based resolution.
|
|
}
|
|
|
|
if dedupKey == "" {
|
|
dedupKey = ComputeDedupKey(req.Title, req.EpubcfiLocation, req.Position)
|
|
}
|
|
|
|
existing, err := s.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
|
|
UserID: req.UserID,
|
|
MediaItemID: req.MediaItemID,
|
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
|
})
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, fmt.Errorf("query existing bookmark: %w", err)
|
|
}
|
|
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return s.createBookmark(ctx, req, dedupKey)
|
|
}
|
|
|
|
if existing.Deleted.Bool {
|
|
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
|
|
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
|
|
}
|
|
// Newer than the tombstone: a deliberate re-create at the same
|
|
// location. Resurrect via the LWW update so the row keeps its id
|
|
// and origin.
|
|
return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
|
|
}
|
|
|
|
return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
|
|
}
|
|
|
|
func (s *AnnotationService) createBookmark(ctx context.Context, req SaveBookmarkRequest, dedupKey string) (*SaveBookmarkResult, error) {
|
|
modifiedAt := req.ModifiedAt
|
|
if modifiedAt.IsZero() {
|
|
modifiedAt = time.Now()
|
|
}
|
|
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
|
|
|
|
bm, err := s.db.CreateMediaBookmarkFull(ctx, database.CreateMediaBookmarkFullParams{
|
|
MediaItemID: req.MediaItemID,
|
|
UserID: req.UserID,
|
|
PageNumber: pgInt4(req.PageNumber),
|
|
ChapterNumber: pgInt4(req.ChapterNumber),
|
|
CfiPosition: pgText(req.CFIPosition),
|
|
Title: req.Title,
|
|
Position: pgText(req.Position),
|
|
Notes: pgText(req.Notes),
|
|
PercentageLocation: pgFloat8(req.PercentageLoc),
|
|
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
|
ChapterReference: pgInt4(req.ChapterReference),
|
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
|
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
|
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
|
DeviceSyncData: deviceData,
|
|
OriginSource: pgText(req.OriginSource),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create bookmark: %w", err)
|
|
}
|
|
s.broadcast(bm.ID, req.UserID, req.MediaItemID, "bookmark", req.Source)
|
|
return &SaveBookmarkResult{Bookmark: bm, Outcome: SaveOutcomeCreated}, nil
|
|
}
|
|
|
|
func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookmarkRequest, existing database.MediaBookmarks, dedupKey string) (*SaveBookmarkResult, error) {
|
|
incomingNewer, contentChanged := s.compareIncomingBookmark(req, existing)
|
|
|
|
if !incomingNewer && !contentChanged {
|
|
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
|
if conflict {
|
|
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_bookmark",
|
|
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
|
req, existing, "existing")
|
|
}
|
|
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeSkipped, Conflict: conflict}, nil
|
|
}
|
|
|
|
modifiedAt := req.ModifiedAt
|
|
if modifiedAt.IsZero() {
|
|
modifiedAt = time.Now()
|
|
}
|
|
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
|
|
|
// Web saves carry no device locators: keep the stored ones so a web
|
|
// title/note edit never wipes the device-native position (mirrors the
|
|
// highlight path's coalescing).
|
|
cfiPosition := req.CFIPosition
|
|
if cfiPosition == "" {
|
|
cfiPosition = existing.CfiPosition.String
|
|
}
|
|
position := req.Position
|
|
if position == "" {
|
|
position = existing.Position.String
|
|
}
|
|
|
|
bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
|
|
ID: existing.ID,
|
|
PageNumber: pgInt4(coalesce(req.PageNumber, existing.PageNumber.Int32)),
|
|
ChapterNumber: pgInt4(coalesce(req.ChapterNumber, existing.ChapterNumber.Int32)),
|
|
CfiPosition: pgText(cfiPosition),
|
|
Title: req.Title,
|
|
Position: pgText(position),
|
|
Notes: pgText(req.Notes),
|
|
// Locator fields a partial payload omits must survive: zeroes
|
|
// become NULLs (pgFloat8/pgInt4) and would erase stored values.
|
|
PercentageLocation: pgFloat8(coalesce(req.PercentageLoc, existing.PercentageLocation.Float64)),
|
|
EpubcfiLocation: pgText(coalesce(req.EpubcfiLocation, existing.EpubcfiLocation.String)),
|
|
ChapterReference: pgInt4(coalesce(req.ChapterReference, existing.ChapterReference.Int32)),
|
|
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
|
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
|
DeviceSyncData: deviceData,
|
|
UserID: req.UserID,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update bookmark: %w", err)
|
|
}
|
|
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
|
if conflict {
|
|
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_bookmark",
|
|
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
|
req, existing, "incoming")
|
|
}
|
|
s.broadcast(bm.ID, req.UserID, req.MediaItemID, "bookmark", req.Source)
|
|
return &SaveBookmarkResult{Bookmark: bm, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
|
|
}
|
|
|
|
func (s *AnnotationService) compareIncomingBookmark(req SaveBookmarkRequest, existing database.MediaBookmarks) (incomingNewer bool, contentChanged bool) {
|
|
contentSame := strings.EqualFold(req.Title, existing.Title) &&
|
|
textEq(req.Notes, existing.Notes) &&
|
|
locatorRefresher(req.CFIPosition, existing.CfiPosition) &&
|
|
locatorRefresher(req.Position, existing.Position)
|
|
return lwwDecision(req.ModifiedAt, existing.LastModifiedAt, existing.CreatedAt, contentSame)
|
|
}
|
|
|
|
func (s *AnnotationService) TombstoneBookmarkByID(ctx context.Context, userID, bookmarkID pgtype.UUID, source string) error {
|
|
bm, err := s.db.GetMediaBookmark(ctx, bookmarkID)
|
|
if err != nil {
|
|
return fmt.Errorf("get bookmark for tombstone: %w", err)
|
|
}
|
|
// Ownership predicate: the caller must own the row.
|
|
if bm.UserID != userID {
|
|
return fmt.Errorf("tombstone bookmark by ID: %w", pgx.ErrNoRows)
|
|
}
|
|
err = s.db.TombstoneMediaBookmarkByID(ctx, database.TombstoneMediaBookmarkByIDParams{
|
|
ID: bookmarkID,
|
|
UserID: userID,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("tombstone bookmark by ID: %w", err)
|
|
}
|
|
s.broadcast(pgtype.UUID{}, bm.UserID, bm.MediaItemID, "bookmark_delete", source)
|
|
return nil
|
|
}
|
|
|
|
func (s *AnnotationService) recordConflict(
|
|
ctx context.Context,
|
|
userID, mediaItemID pgtype.UUID,
|
|
conflictType, dedupKey string,
|
|
incomingSource, existingSource string,
|
|
incoming any,
|
|
existing any,
|
|
winner string,
|
|
) {
|
|
if s.connMgr == nil || !userID.Valid || !mediaItemID.Valid {
|
|
return
|
|
}
|
|
|
|
incomingJSON, _ := json.Marshal(incoming)
|
|
existingJSON, _ := json.Marshal(existing)
|
|
|
|
var incomingMap, existingMap map[string]interface{}
|
|
json.Unmarshal(incomingJSON, &incomingMap)
|
|
json.Unmarshal(existingJSON, &existingMap)
|
|
if incomingMap == nil {
|
|
incomingMap = map[string]interface{}{}
|
|
}
|
|
if existingMap == nil {
|
|
existingMap = map[string]interface{}{}
|
|
}
|
|
incomingMap["dedup_key"] = dedupKey
|
|
existingMap["dedup_key"] = dedupKey
|
|
|
|
conflictData, _ := json.Marshal(map[string]interface{}{
|
|
"incoming": map[string]interface{}{
|
|
"source": incomingSource,
|
|
"data": incomingMap,
|
|
},
|
|
"existing": map[string]interface{}{
|
|
"source": existingSource,
|
|
"data": existingMap,
|
|
},
|
|
})
|
|
|
|
resolutionData, _ := json.Marshal(map[string]interface{}{
|
|
"winner": winner,
|
|
"reason": "last_modified_at_wins",
|
|
})
|
|
|
|
conflict, err := s.db.CreateAutoResolvedSyncConflict(ctx, database.CreateAutoResolvedSyncConflictParams{
|
|
MediaItemID: mediaItemID,
|
|
UserID: userID,
|
|
ConflictType: conflictType,
|
|
ConflictData: conflictData,
|
|
ResolutionData: resolutionData,
|
|
})
|
|
if err != nil {
|
|
log.Printf("AnnotationService: failed to record conflict: %v", err)
|
|
return
|
|
}
|
|
|
|
var conflictIDStr string
|
|
if conflict.ID.Valid {
|
|
conflictIDStr = uuid.UUID(conflict.ID.Bytes).String()
|
|
}
|
|
s.connMgr.BroadcastConflictNotification(
|
|
uuid.UUID(userID.Bytes).String(),
|
|
uuid.UUID(mediaItemID.Bytes),
|
|
"annotation_conflict",
|
|
conflictIDStr,
|
|
)
|
|
}
|
|
|
|
func (s *AnnotationService) broadcast(
|
|
highlightID, userID, mediaItemID pgtype.UUID,
|
|
annotationType string,
|
|
source string,
|
|
) {
|
|
if s.connMgr == nil || !userID.Valid || !mediaItemID.Valid {
|
|
return
|
|
}
|
|
src := SourceDevice{Type: source}
|
|
s.connMgr.BroadcastAnnotationUpdate(
|
|
uuid.UUID(userID.Bytes).String(),
|
|
uuid.UUID(mediaItemID.Bytes),
|
|
annotationType,
|
|
map[string]interface{}{
|
|
"highlight_id": uuid.UUID(highlightID.Bytes),
|
|
},
|
|
src,
|
|
)
|
|
}
|
|
|
|
func ComputeDedupKey(selectionText, epubcfiStart, startPosition string) string {
|
|
normalized := normalizeText(selectionText)
|
|
posBucket := bucketPosition(epubcfiStart)
|
|
if posBucket == "" {
|
|
posBucket = bucketPosition(startPosition)
|
|
}
|
|
|
|
h := sha1.New()
|
|
h.Write([]byte(normalized))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(posBucket))
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// incomingNewerThanTombstone reports whether an incoming save should
|
|
// resurrect a tombstoned annotation. A save carrying a modification time
|
|
// newer than the tombstone (e.g. the user deliberately re-adding on the web,
|
|
// or a device that genuinely re-created it) wins; a save with a missing or
|
|
// older timestamp is treated as a stale replay from a client that still has
|
|
// the deleted annotation, and the tombstone stands.
|
|
func incomingNewerThanTombstone(incoming time.Time, deletedAt, lastModifiedAt pgtype.Timestamptz) bool {
|
|
if incoming.IsZero() {
|
|
return false
|
|
}
|
|
tombstone := deletedAt.Time
|
|
if lastModifiedAt.Valid && lastModifiedAt.Time.After(tombstone) {
|
|
tombstone = lastModifiedAt.Time
|
|
}
|
|
return incoming.After(tombstone)
|
|
}
|
|
|
|
func normalizeText(s string) string {
|
|
fields := strings.Fields(strings.ToLower(s))
|
|
return strings.Join(fields, " ")
|
|
}
|
|
|
|
func bucketPosition(pos string) string {
|
|
if pos == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(pos, "epubcfi(") {
|
|
if idx := strings.LastIndex(pos, ":"); idx > 0 {
|
|
return pos[:idx]
|
|
}
|
|
}
|
|
if len(pos) > 50 {
|
|
return pos[:50]
|
|
}
|
|
return pos
|
|
}
|
|
|
|
func mergeDeviceSyncData(existing []byte, source string, data json.RawMessage) []byte {
|
|
if source == "" && len(data) == 0 {
|
|
return existing
|
|
}
|
|
m := make(map[string]interface{})
|
|
if len(existing) > 0 {
|
|
_ = json.Unmarshal(existing, &m)
|
|
}
|
|
if source != "" {
|
|
if len(data) > 0 {
|
|
var val interface{}
|
|
_ = json.Unmarshal(data, &val)
|
|
m[source] = val
|
|
} else {
|
|
m[source] = map[string]interface{}{"synced_at": time.Now().UTC().Format(time.RFC3339)}
|
|
}
|
|
}
|
|
result, _ := json.Marshal(m)
|
|
return result
|
|
}
|
|
|
|
func isCrossSource(incoming string, existing pgtype.Text) bool {
|
|
if incoming == "" || !existing.Valid {
|
|
return false
|
|
}
|
|
return incoming != existing.String
|
|
}
|
|
|
|
func pgText(s string) pgtype.Text {
|
|
if s == "" {
|
|
return pgtype.Text{Valid: false}
|
|
}
|
|
return pgtype.Text{String: s, Valid: true}
|
|
}
|
|
|
|
func pgFloat8(f float64) pgtype.Float8 {
|
|
if f == 0 {
|
|
return pgtype.Float8{Valid: false}
|
|
}
|
|
return pgtype.Float8{Float64: f, Valid: true}
|
|
}
|
|
|
|
func pgInt4(i int32) pgtype.Int4 {
|
|
if i == 0 {
|
|
return pgtype.Int4{Valid: false}
|
|
}
|
|
return pgtype.Int4{Int32: i, Valid: true}
|
|
}
|
|
|
|
func textEq(a string, b pgtype.Text) bool {
|
|
if !b.Valid {
|
|
return a == ""
|
|
}
|
|
return a == b.String
|
|
}
|
|
|
|
func floatEq(a float64, b pgtype.Float8) bool {
|
|
if !b.Valid {
|
|
return a == 0
|
|
}
|
|
return math.Abs(a-b.Float64) < 0.001
|
|
}
|