ultimate-media-tracker/server/sync.go
Tronax de353f0218
feat: add Android app and end-to-end encrypted library sync
Introduce the Kotlin/Compose Android companion app and extend the TMDB
proxy into a combo server that synchronizes the whole library as an
encrypted snapshot, with all three components (Go server, Android,
desktop) sharing one zero-knowledge crypto envelope and JSON schema.

- server: add PostgreSQL-backed /sync/library (GET/PUT) with optimistic
  concurrency (revision + 409 on conflict); sync stays disabled unless
  DATABASE_URL is set. Add docker-compose with Postgres.
- desktop: add libsodium CryptoEnvelope, LibrarySerializer and SyncClient;
  storage-mode and passphrase settings; a "Sync now" toolbar action with
  conflict resolution.
- android: Room-backed library with local/cloud storage modes, encrypted
  sync client, and settings UI. The proxy server (URL + token) can be
  configured as a TMDB proxy even in local-only mode.

Crypto is identical across platforms: Argon2id (libsodium crypto_pwhash,
INTERACTIVE) + XChaCha20-Poly1305 in a versioned "UMTS" envelope, so a
snapshot encrypted on one client decrypts on the other.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-21 00:35:54 +02:00

123 lines
3.4 KiB
Go

package main
import (
"encoding/base64"
"encoding/json"
"net/http"
"strings"
"time"
)
// Maximum accepted snapshot size (encrypted). Generous for a personal library.
const maxSyncPayload = 16 << 20 // 16 MiB
type syncGetResponse struct {
Revision int64 `json:"revision"`
UpdatedAt string `json:"updatedAt"`
Payload string `json:"payload"` // base64 ciphertext
}
type syncPutRequest struct {
BaseRevision int64 `json:"baseRevision"`
Payload string `json:"payload"` // base64 ciphertext
}
type syncPutResponse struct {
Revision int64 `json:"revision"`
}
type syncConflictResponse struct {
Error string `json:"error"`
Revision int64 `json:"revision"`
}
// authSubject validates the Bearer token and returns the user's subject/email.
func (s *server) authSubject(r *http.Request) (sub, email string, ok bool) {
bearer := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
claims, err := verifyToken(s.cfg.SessionKey, bearer)
if err != nil {
return "", "", false
}
return claims.Subject, claims.Email, true
}
// handleSyncLibrary dispatches GET/PUT for the encrypted library snapshot.
func (s *server) handleSyncLibrary(w http.ResponseWriter, r *http.Request) {
if s.store == nil {
http.Error(w, "sync is not configured on this server", http.StatusNotImplemented)
return
}
sub, email, ok := s.authSubject(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
switch r.Method {
case http.MethodGet:
s.handleSyncGet(w, r, sub)
case http.MethodPut:
s.handleSyncPut(w, r, sub, email)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *server) handleSyncGet(w http.ResponseWriter, r *http.Request, sub string) {
blob, err := s.store.getBlob(r.Context(), sub)
if err != nil {
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
if blob == nil {
http.Error(w, "no snapshot yet", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, syncGetResponse{
Revision: blob.Revision,
UpdatedAt: blob.UpdatedAt.UTC().Format(time.RFC3339),
Payload: base64.StdEncoding.EncodeToString(blob.Payload),
})
}
func (s *server) handleSyncPut(w http.ResponseWriter, r *http.Request, sub, email string) {
var req syncPutRequest
body := http.MaxBytesReader(w, r.Body, maxSyncPayload+(1<<16))
if err := json.NewDecoder(body).Decode(&req); err != nil {
http.Error(w, "bad request body", http.StatusBadRequest)
return
}
payload, err := base64.StdEncoding.DecodeString(req.Payload)
if err != nil {
http.Error(w, "payload is not valid base64", http.StatusBadRequest)
return
}
if len(payload) == 0 {
http.Error(w, "empty payload", http.StatusBadRequest)
return
}
if len(payload) > maxSyncPayload {
http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
return
}
next, err := s.store.putBlob(r.Context(), sub, email, req.BaseRevision, payload)
if err == errRevisionConflict {
writeJSON(w, http.StatusConflict, syncConflictResponse{
Error: "revision conflict",
Revision: next,
})
return
}
if err != nil {
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, syncPutResponse{Revision: next})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}