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>
123 lines
3.4 KiB
Go
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)
|
|
}
|