/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors * * This file is part of Dnote. * * Dnote is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Dnote is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Dnote. If not, see . */ package controllers import ( "fmt" "net/http" "net/url" "sort" "strconv" "time" "github.com/dnote/dnote/pkg/server/context" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/log" "github.com/dnote/dnote/pkg/server/middleware" "github.com/pkg/errors" "github.com/dnote/dnote/pkg/server/app" ) // NewSync creates a new Sync controller func NewSync(app *app.App) *Sync { return &Sync{ app: app, } } // Sync is a sync controller. type Sync struct { app *app.App } // fullSyncBefore is the system-wide timestamp that represents the point in time // before which clients must perform a full-sync rather than incremental sync. const fullSyncBefore = 0 // SyncFragment contains a piece of information about the server's state. // It is used to transfer the server's state to the client gradually without having to // transfer the whole state at once. type SyncFragment struct { FragMaxUSN int `json:"frag_max_usn"` UserMaxUSN int `json:"user_max_usn"` CurrentTime int64 `json:"current_time"` Notes []SyncFragNote `json:"notes"` Books []SyncFragBook `json:"books"` ExpungedNotes []string `json:"expunged_notes"` ExpungedBooks []string `json:"expunged_books"` } // SyncFragNote represents a note in a sync fragment and contains only the necessary information // for the client to sync the note locally type SyncFragNote struct { UUID string `json:"uuid"` BookUUID string `json:"book_uuid"` USN int `json:"usn"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` AddedOn int64 `json:"added_on"` EditedOn int64 `json:"edited_on"` Body string `json:"content"` Deleted bool `json:"deleted"` } // NewFragNote presents the given note as a SyncFragNote func NewFragNote(note database.Note) SyncFragNote { return SyncFragNote{ UUID: note.UUID, USN: note.USN, CreatedAt: note.CreatedAt, UpdatedAt: note.UpdatedAt, AddedOn: note.AddedOn, EditedOn: note.EditedOn, Body: note.Body, Deleted: note.Deleted, BookUUID: note.BookUUID, } } // SyncFragBook represents a book in a sync fragment and contains only the necessary information // for the client to sync the note locally type SyncFragBook struct { UUID string `json:"uuid"` USN int `json:"usn"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` AddedOn int64 `json:"added_on"` Label string `json:"label"` Deleted bool `json:"deleted"` } // NewFragBook presents the given book as a SyncFragBook func NewFragBook(book database.Book) SyncFragBook { return SyncFragBook{ UUID: book.UUID, USN: book.USN, CreatedAt: book.CreatedAt, UpdatedAt: book.UpdatedAt, AddedOn: book.AddedOn, Label: book.Label, Deleted: book.Deleted, } } type usnItem struct { usn int val interface{} } type queryParamError struct { key string value string message string } func (e *queryParamError) Error() string { return fmt.Sprintf("invalid query param %s=%s. %s", e.key, e.value, e.message) } func (s *Sync) newFragment(userID, userMaxUSN, afterUSN, limit int) (SyncFragment, error) { var notes []database.Note if err := s.app.DB.Where("user_id = ? AND usn > ? AND usn <= ?", userID, afterUSN, userMaxUSN).Order("usn ASC").Limit(limit).Find(¬es).Error; err != nil { return SyncFragment{}, nil } var books []database.Book if err := s.app.DB.Where("user_id = ? AND usn > ? AND usn <= ?", userID, afterUSN, userMaxUSN).Order("usn ASC").Limit(limit).Find(&books).Error; err != nil { return SyncFragment{}, nil } var items []usnItem for _, note := range notes { i := usnItem{ usn: note.USN, val: note, } items = append(items, i) } for _, book := range books { i := usnItem{ usn: book.USN, val: book, } items = append(items, i) } // order by usn in ascending order sort.Slice(items, func(i, j int) bool { return items[i].usn < items[j].usn }) fragNotes := []SyncFragNote{} fragBooks := []SyncFragBook{} fragExpungedNotes := []string{} fragExpungedBooks := []string{} fragMaxUSN := 0 for i := 0; i < limit; i++ { if i > len(items)-1 { break } item := items[i] fragMaxUSN = item.usn switch v := item.val.(type) { case database.Note: note := item.val.(database.Note) if note.Deleted { fragExpungedNotes = append(fragExpungedNotes, note.UUID) } else { fragNotes = append(fragNotes, NewFragNote(note)) } case database.Book: book := item.val.(database.Book) if book.Deleted { fragExpungedBooks = append(fragExpungedBooks, book.UUID) } else { fragBooks = append(fragBooks, NewFragBook(book)) } default: return SyncFragment{}, errors.Errorf("unknown internal item type %s", v) } } ret := SyncFragment{ FragMaxUSN: fragMaxUSN, UserMaxUSN: userMaxUSN, CurrentTime: s.app.Clock.Now().Unix(), Notes: fragNotes, Books: fragBooks, ExpungedNotes: fragExpungedNotes, ExpungedBooks: fragExpungedBooks, } return ret, nil } func parseGetSyncFragmentQuery(q url.Values) (afterUSN, limit int, err error) { afterUSNStr := q.Get("after_usn") limitStr := q.Get("limit") if len(afterUSNStr) > 0 { afterUSN, err = strconv.Atoi(afterUSNStr) if err != nil { err = errors.Wrap(err, "invalid after_usn") return } } else { afterUSN = 0 } if len(limitStr) > 0 { l, e := strconv.Atoi(limitStr) if e != nil { err = errors.Wrap(e, "invalid limit") return } if l > 100 { err = &queryParamError{ key: "limit", value: limitStr, message: "maximum value is 100", } return } limit = l } else { limit = 100 } return } // GetSyncFragmentResp represents a response from GetSyncFragment handler type GetSyncFragmentResp struct { Fragment SyncFragment `json:"fragment"` } // GetSyncFragment responds with a sync fragment func (s *Sync) GetSyncFragment(w http.ResponseWriter, r *http.Request) { user := context.User(r.Context()) if user == nil { middleware.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } afterUSN, limit, err := parseGetSyncFragmentQuery(r.URL.Query()) if err != nil { middleware.DoError(w, "parsing query params", err, http.StatusInternalServerError) return } fragment, err := s.newFragment(user.ID, user.MaxUSN, afterUSN, limit) if err != nil { middleware.DoError(w, "getting fragment", err, http.StatusInternalServerError) return } response := GetSyncFragmentResp{ Fragment: fragment, } respondJSON(w, http.StatusOK, response) } // GetSyncStateResp represents a response from GetSyncFragment handler type GetSyncStateResp struct { FullSyncBefore int `json:"full_sync_before"` MaxUSN int `json:"max_usn"` CurrentTime int64 `json:"current_time"` } // GetSyncState responds with a sync fragment func (s *Sync) GetSyncState(w http.ResponseWriter, r *http.Request) { user := context.User(r.Context()) if user == nil { middleware.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } response := GetSyncStateResp{ FullSyncBefore: fullSyncBefore, MaxUSN: user.MaxUSN, // TODO: exposing server time means we probably shouldn't seed random generator with time? CurrentTime: s.app.Clock.Now().Unix(), } log.WithFields(log.Fields{ "user_id": user.ID, "resp": response, }).Info("getting sync state") respondJSON(w, http.StatusOK, response) }