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>
This commit is contained in:
parent
83a26ef0cf
commit
de353f0218
65 changed files with 4178 additions and 0 deletions
123
server/sync.go
Normal file
123
server/sync.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue