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) }