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
|
|
@ -31,3 +31,13 @@ SESSION_SECRET=change-me-to-a-long-random-string
|
|||
|
||||
# How long an issued desktop-app token stays valid (in days).
|
||||
TOKEN_TTL_DAYS=90
|
||||
|
||||
# --- Library-Sync (optional) -------------------------------------------
|
||||
# PostgreSQL-DSN für den verschlüsselten Bibliotheks-Sync. Leer lassen, um
|
||||
# den Sync zu deaktivieren (dann läuft der Server als reiner TMDB-Proxy).
|
||||
# Der Server speichert nur Chiffretext (Ende-zu-Ende, Zero-Knowledge).
|
||||
# Bei Nutzung von docker-compose.yml wird dies automatisch gesetzt.
|
||||
# DATABASE_URL=postgres://umt:change-me@db:5432/umt?sslmode=disable
|
||||
|
||||
# Passwort des Postgres-Containers (nur für docker-compose.yml relevant).
|
||||
POSTGRES_PASSWORD=change-me
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ type Config struct {
|
|||
SessionKey []byte // HMAC secret for signing access tokens / state cookies
|
||||
AllowGroups []string // if set, the user must be in one of these Authentik groups
|
||||
TokenTTL time.Duration // lifetime of issued desktop-app tokens
|
||||
DatabaseURL string // PostgreSQL DSN for library sync; empty disables sync
|
||||
}
|
||||
|
||||
func getenv(key, def string) string {
|
||||
|
|
@ -41,6 +42,7 @@ func loadConfig() (*Config, error) {
|
|||
ClientID: getenv("OIDC_CLIENT_ID", ""),
|
||||
ClientSecret: getenv("OIDC_CLIENT_SECRET", ""),
|
||||
RedirectURL: getenv("OIDC_REDIRECT_URL", ""),
|
||||
DatabaseURL: getenv("DATABASE_URL", ""),
|
||||
}
|
||||
|
||||
if c.RedirectURL == "" {
|
||||
|
|
|
|||
44
server/docker-compose.yml
Normal file
44
server/docker-compose.yml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Kombi-Server: TMDB-Proxy + verschlüsselter Bibliotheks-Sync.
|
||||
# Auf Unraid: dieses Compose-File über "Compose Manager" einbinden oder die
|
||||
# beiden Container (db + app) einzeln anlegen. Werte aus .env beziehen.
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: umt
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
|
||||
POSTGRES_DB: umt
|
||||
volumes:
|
||||
- umt_pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U umt -d umt"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
app:
|
||||
build: .
|
||||
image: umt-combo-server:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
LISTEN_ADDR: ":8080"
|
||||
PUBLIC_URL: ${PUBLIC_URL}
|
||||
TMDB_API_KEY: ${TMDB_API_KEY}
|
||||
OIDC_ISSUER: ${OIDC_ISSUER}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
|
||||
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL}
|
||||
SESSION_SECRET: ${SESSION_SECRET}
|
||||
ALLOWED_GROUPS: ${ALLOWED_GROUPS:-}
|
||||
TOKEN_TTL_DAYS: ${TOKEN_TTL_DAYS:-90}
|
||||
# Library-Sync: leer lassen, um den Sync zu deaktivieren.
|
||||
DATABASE_URL: postgres://umt:${POSTGRES_PASSWORD:-change-me}@db:5432/umt?sslmode=disable
|
||||
|
||||
volumes:
|
||||
umt_pgdata:
|
||||
|
|
@ -4,10 +4,16 @@ go 1.22
|
|||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.11.0
|
||||
github.com/jackc/pgx/v5 v5.6.0
|
||||
golang.org/x/oauth2 v0.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
golang.org/x/crypto v0.25.0 // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,36 @@
|
|||
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ type server struct {
|
|||
verifier *oidc.IDTokenVerifier
|
||||
oauth oauth2.Config
|
||||
client *http.Client
|
||||
store *store // nil when DATABASE_URL is unset (sync disabled)
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
|
@ -55,6 +56,18 @@ func main() {
|
|||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
|
||||
if cfg.DatabaseURL != "" {
|
||||
st, err := openStore(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("database connection failed: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
s.store = st
|
||||
log.Printf("library sync enabled (PostgreSQL)")
|
||||
} else {
|
||||
log.Printf("library sync disabled (DATABASE_URL unset)")
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
|
|
@ -63,6 +76,7 @@ func main() {
|
|||
mux.HandleFunc("/login", s.handleLogin)
|
||||
mux.HandleFunc("/callback", s.handleCallback)
|
||||
mux.HandleFunc("/3/", s.handleProxy)
|
||||
mux.HandleFunc("/sync/library", s.handleSyncLibrary)
|
||||
|
||||
log.Printf("umt-tmdb-proxy listening on %s (public %s)", cfg.ListenAddr, cfg.PublicURL)
|
||||
srv := &http.Server{
|
||||
|
|
|
|||
136
server/store.go
Normal file
136
server/store.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// errRevisionConflict is returned by putBlob when the caller's baseRevision no
|
||||
// longer matches the revision currently stored for that user. The client must
|
||||
// re-pull and resolve before pushing again (optimistic concurrency).
|
||||
var errRevisionConflict = errors.New("revision conflict")
|
||||
|
||||
// libraryBlob is the encrypted, opaque library snapshot stored for one user.
|
||||
// The server never sees the plaintext or the passphrase — payload is ciphertext.
|
||||
type libraryBlob struct {
|
||||
Revision int64
|
||||
UpdatedAt time.Time
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
// store wraps the PostgreSQL connection used for the zero-knowledge library
|
||||
// sync. It is optional: when DATABASE_URL is unset the server runs as a plain
|
||||
// TMDB proxy and store is nil.
|
||||
type store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// openStore connects to PostgreSQL and ensures the schema exists.
|
||||
func openStore(dsn string) (*store, error) {
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(10)
|
||||
db.SetConnMaxIdleTime(5 * time.Minute)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := migrate(ctx, db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &store{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *store) Close() error { return s.db.Close() }
|
||||
|
||||
func migrate(ctx context.Context, db *sql.DB) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS library_blobs (
|
||||
user_subject TEXT PRIMARY KEY,
|
||||
user_email TEXT,
|
||||
revision BIGINT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
payload BYTEA NOT NULL
|
||||
);`)
|
||||
return err
|
||||
}
|
||||
|
||||
// getBlob loads the stored snapshot for a user. It returns (nil, nil) when the
|
||||
// user has never pushed a library yet.
|
||||
func (s *store) getBlob(ctx context.Context, subject string) (*libraryBlob, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT revision, updated_at, payload FROM library_blobs WHERE user_subject = $1`,
|
||||
subject)
|
||||
var b libraryBlob
|
||||
switch err := row.Scan(&b.Revision, &b.UpdatedAt, &b.Payload); err {
|
||||
case nil:
|
||||
return &b, nil
|
||||
case sql.ErrNoRows:
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// putBlob writes a new snapshot using optimistic concurrency: the write only
|
||||
// succeeds when the stored revision equals baseRevision (or the row is absent
|
||||
// and baseRevision is 0). On success the revision is incremented and returned.
|
||||
// On mismatch it returns the current revision and errRevisionConflict.
|
||||
func (s *store) putBlob(ctx context.Context, subject, email string, baseRevision int64, payload []byte) (int64, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var current int64
|
||||
var exists bool
|
||||
row := tx.QueryRowContext(ctx,
|
||||
`SELECT revision FROM library_blobs WHERE user_subject = $1 FOR UPDATE`, subject)
|
||||
switch err := row.Scan(¤t); err {
|
||||
case nil:
|
||||
exists = true
|
||||
case sql.ErrNoRows:
|
||||
exists = false
|
||||
default:
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if exists {
|
||||
if current != baseRevision {
|
||||
return current, errRevisionConflict
|
||||
}
|
||||
} else if baseRevision != 0 {
|
||||
// Client thinks it is updating an existing snapshot, but none exists.
|
||||
return 0, errRevisionConflict
|
||||
}
|
||||
|
||||
next := baseRevision + 1
|
||||
now := time.Now().UTC()
|
||||
if exists {
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`UPDATE library_blobs SET user_email = $2, revision = $3, updated_at = $4, payload = $5 WHERE user_subject = $1`,
|
||||
subject, email, next, now, payload)
|
||||
} else {
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`INSERT INTO library_blobs (user_subject, user_email, revision, updated_at, payload) VALUES ($1, $2, $3, $4, $5)`,
|
||||
subject, email, next, now, payload)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
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