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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue