feat: add encrypted sync backend MVP

Add the PostgreSQL-backed authentication, opaque blob synchronization, WebSocket notifications, and local development stack.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Murilo Scolari 2026-10-21 20:38:52 -03:00
commit 54896cac8e
15 changed files with 708 additions and 0 deletions

4
.env.example Normal file
View file

@ -0,0 +1,4 @@
PORT=8080
DATABASE_URL=postgres://simpsync:simpsync@postgres:5432/simpsync?sslmode=disable
SESSION_TTL=720h
AUTH_PEPPER=replace-with-a-long-random-secret

13
Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/simpsync ./cmd/server
FROM alpine:3.20
RUN adduser -D -H -u 10001 app
USER app
COPY --from=build /out/simpsync /simpsync
EXPOSE 8080
ENTRYPOINT ["/simpsync"]

34
README.md Normal file
View file

@ -0,0 +1,34 @@
# SimpSync
SimpSync is the backend for encrypted SimpMusic synchronization. The server stores
opaque encrypted envelopes and synchronization metadata; encryption and key
derivation happen in the client.
## MVP endpoints
- `GET /healthz`
- `POST /v1/auth/register`
- `POST /v1/auth/login`
- `POST /v1/sync`
- `GET /v1/ws`
`authProof` is a client-derived authentication proof, not the master password.
The server never receives the master password or plaintext entity data. `envelope`
is an opaque, versioned client-side encrypted payload.
## Development
```sh
cp .env.example .env
docker compose up --build
```
Without PostgreSQL, run the unit tests:
```sh
go test ./...
```
The current MVP intentionally does not implement password recovery, conflict
merging, or device listing/revocation endpoints yet. Those are required before
production deployment.

45
cmd/server/main.go Normal file
View file

@ -0,0 +1,45 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/muriloscolari/SimpSync/internal/config"
"github.com/muriloscolari/SimpSync/internal/database"
"github.com/muriloscolari/SimpSync/internal/handlers"
syncws "github.com/muriloscolari/SimpSync/internal/websocket"
)
func main() {
cfg := config.Load()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
store, err := database.Open(ctx, cfg.DatabaseURL)
if err != nil {
log.Fatalf("database: %v", err)
}
defer store.Pool.Close()
server := &http.Server{
Addr: ":" + cfg.Port,
Handler: (&handlers.Handler{Store: store, Config: cfg, Hub: syncws.NewHub()}).Routes(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
_ = server.Shutdown(shutdownCtx)
}()
log.Printf("SimpSync listening on %s", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}

29
docker-compose.yml Normal file
View file

@ -0,0 +1,29 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: simpsync
POSTGRES_PASSWORD: simpsync
POSTGRES_DB: simpsync
volumes:
- postgres-data:/var/lib/postgresql/data
- ./migrations/001_initial.sql:/docker-entrypoint-initdb.d/001_initial.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U simpsync -d simpsync"]
interval: 5s
timeout: 5s
retries: 10
server:
build: .
environment:
PORT: 8080
DATABASE_URL: postgres://simpsync:simpsync@postgres:5432/simpsync?sslmode=disable
SESSION_TTL: 720h
AUTH_PEPPER: ${AUTH_PEPPER:-replace-with-a-long-random-secret}
depends_on:
postgres:
condition: service_healthy
ports:
- "8080:8080"
volumes:
postgres-data:

19
go.mod Normal file
View file

@ -0,0 +1,19 @@
module github.com/muriloscolari/SimpSync
go 1.23
require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.7.2
golang.org/x/crypto v0.31.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
)

34
go.sum Normal file
View file

@ -0,0 +1,34 @@
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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/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.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
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=

64
internal/auth/auth.go Normal file
View file

@ -0,0 +1,64 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"golang.org/x/crypto/argon2"
)
const (
saltSize = 16
keySize = 32
)
func Verifier(proof, pepper string) (string, error) {
if len(proof) < 32 {
return "", errors.New("auth proof is too short")
}
salt := make([]byte, saltSize)
if _, err := rand.Read(salt); err != nil {
return "", err
}
input := []byte(proof + pepper)
hash := argon2.IDKey(input, salt, 1, 64*1024, 2, keySize)
return fmt.Sprintf("argon2id$v=19$m=65536,t=1,p=2$%s$%s",
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash)), nil
}
func Verify(proof, encoded, pepper string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 5 || parts[0] != "argon2id" || parts[1] != "v=19" {
return false
}
var salt, expected []byte
var err error
if salt, err = base64.RawStdEncoding.DecodeString(parts[3]); err != nil {
return false
}
if expected, err = base64.RawStdEncoding.DecodeString(parts[4]); err != nil {
return false
}
hash := argon2.IDKey([]byte(proof+pepper), salt, 1, 64*1024, 2, uint32(len(expected)))
return strings.EqualFold(base64.RawStdEncoding.EncodeToString(hash), base64.RawStdEncoding.EncodeToString(expected))
}
func Token() (plain string, hash []byte, err error) {
raw := make([]byte, 32)
if _, err = rand.Read(raw); err != nil {
return "", nil, err
}
plain = base64.RawURLEncoding.EncodeToString(raw)
digest := sha256.Sum256([]byte(plain))
return plain, digest[:], nil
}
func ExpiresAt(now time.Time, ttl time.Duration) time.Time {
return now.Add(ttl)
}

View file

@ -0,0 +1,26 @@
package auth
import "testing"
func TestVerifierRoundTrip(t *testing.T) {
verifier, err := Verifier("proof-derived-from-a-master-password", "pepper")
if err != nil {
t.Fatal(err)
}
if !Verify("proof-derived-from-a-master-password", verifier, "pepper") {
t.Fatal("expected verifier to accept the original proof")
}
if Verify("wrong-proof-derived-from-password", verifier, "pepper") {
t.Fatal("expected verifier to reject a different proof")
}
}
func TestTokenIsRandomAndHashable(t *testing.T) {
plain, hash, err := Token()
if err != nil {
t.Fatal(err)
}
if plain == "" || len(hash) != 32 {
t.Fatalf("unexpected token output: %q %d", plain, len(hash))
}
}

41
internal/config/config.go Normal file
View file

@ -0,0 +1,41 @@
package config
import (
"os"
"strconv"
"time"
)
type Config struct {
Port string
DatabaseURL string
SessionTTL time.Duration
AuthPepper string
}
func Load() Config {
ttl := 30 * 24 * time.Hour
if raw := os.Getenv("SESSION_TTL"); raw != "" {
if parsed, err := time.ParseDuration(raw); err == nil {
ttl = parsed
}
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
return Config{
Port: port,
DatabaseURL: os.Getenv("DATABASE_URL"),
SessionTTL: ttl,
AuthPepper: os.Getenv("AUTH_PEPPER"),
}
}
func IntEnv(name string, fallback int) int {
value, err := strconv.Atoi(os.Getenv(name))
if err != nil {
return fallback
}
return value
}

View file

@ -0,0 +1,99 @@
package database
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("not found")
type Store struct{ Pool *pgxpool.Pool }
func Open(ctx context.Context, url string) (*Store, error) {
pool, err := pgxpool.New(ctx, url)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
return &Store{Pool: pool}, nil
}
func (s *Store) UserByEmail(ctx context.Context, email string) (uuid.UUID, string, error) {
var id uuid.UUID
var verifier string
err := s.Pool.QueryRow(ctx, `SELECT id, auth_verifier FROM app_user WHERE email=$1`, email).Scan(&id, &verifier)
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, "", ErrNotFound
}
return id, verifier, err
}
func (s *Store) CreateUser(ctx context.Context, email, verifier string) (uuid.UUID, error) {
id := uuid.New()
_, err := s.Pool.Exec(ctx, `INSERT INTO app_user(id,email,auth_verifier) VALUES($1,$2,$3)`, id, email, verifier)
return id, err
}
func (s *Store) CreateSession(ctx context.Context, userID uuid.UUID, deviceName string, tokenHash []byte, expires time.Time) (uuid.UUID, error) {
id := uuid.New()
_, err := s.Pool.Exec(ctx, `INSERT INTO device_session(id,user_id,device_name,token_hash) VALUES($1,$2,$3,$4)`, id, userID, deviceName, tokenHash)
return id, err
}
func (s *Store) SessionUser(ctx context.Context, tokenHash []byte) (uuid.UUID, error) {
var userID uuid.UUID
err := s.Pool.QueryRow(ctx, `UPDATE device_session SET last_seen_at=now() WHERE token_hash=$1 AND revoked_at IS NULL RETURNING user_id`, tokenHash).Scan(&userID)
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, ErrNotFound
}
return userID, err
}
func (s *Store) UpsertBlob(ctx context.Context, userID uuid.UUID, entityType, entityID string, envelope []byte, deleted bool) (int64, error) {
var version int64
err := s.Pool.QueryRow(ctx, `
INSERT INTO sync_blob(user_id,entity_type,entity_id,envelope,version,deleted_at)
VALUES($1,$2,$3,$4,nextval('sync_revision_seq'),CASE WHEN $5 THEN now() ELSE NULL END)
ON CONFLICT(user_id,entity_type,entity_id) DO UPDATE SET
envelope=EXCLUDED.envelope,
version=EXCLUDED.version,
deleted_at=EXCLUDED.deleted_at,
updated_at=now()
RETURNING version`, userID, entityType, entityID, envelope, deleted).Scan(&version)
return version, err
}
func (s *Store) Changes(ctx context.Context, userID uuid.UUID, cursor int64) ([][]any, int64, error) {
rows, err := s.Pool.Query(ctx, `
SELECT entity_type, entity_id, envelope, version, deleted_at, updated_at
FROM sync_blob WHERE user_id=$1 AND version>$2 ORDER BY version ASC LIMIT 1000`, userID, cursor)
if err != nil {
return nil, cursor, err
}
defer rows.Close()
var changes [][]any
var newest = cursor
for rows.Next() {
var entityType, entityID string
var envelope []byte
var version int64
var deletedAt *time.Time
var updatedAt time.Time
if err := rows.Scan(&entityType, &entityID, &envelope, &version, &deletedAt, &updatedAt); err != nil {
return nil, cursor, err
}
changes = append(changes, []any{entityType, entityID, envelope, version, deletedAt != nil, updatedAt})
if version > newest {
newest = version
}
}
return changes, newest, rows.Err()
}

View file

@ -0,0 +1,184 @@
package handlers
import (
"crypto/sha256"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/muriloscolari/SimpSync/internal/auth"
"github.com/muriloscolari/SimpSync/internal/config"
"github.com/muriloscolari/SimpSync/internal/database"
"github.com/muriloscolari/SimpSync/internal/models"
syncws "github.com/muriloscolari/SimpSync/internal/websocket"
)
type Handler struct {
Store *database.Store
Config config.Config
Hub *syncws.Hub
}
func (h *Handler) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", h.health)
mux.HandleFunc("/v1/auth/register", h.register)
mux.HandleFunc("/v1/auth/login", h.login)
mux.HandleFunc("/v1/sync", h.sync)
mux.HandleFunc("/v1/ws", h.ws)
return logging(mux)
}
func (h *Handler) health(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
h.authenticate(w, r, true)
}
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
h.authenticate(w, r, false)
}
func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request, create bool) {
var input models.Credentials
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request")
return
}
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
input.DeviceName = strings.TrimSpace(input.DeviceName)
if input.Email == "" || input.AuthProof == "" || input.DeviceName == "" {
writeError(w, http.StatusBadRequest, "email, authProof and deviceName are required")
return
}
var userID uuid.UUID
var err error
if create {
verifier, verifierErr := auth.Verifier(input.AuthProof, h.Config.AuthPepper)
if verifierErr != nil {
writeError(w, http.StatusBadRequest, verifierErr.Error())
return
}
userID, err = h.Store.CreateUser(r.Context(), input.Email, verifier)
} else {
var verifier string
userID, verifier, err = h.Store.UserByEmail(r.Context(), input.Email)
if err == nil && !auth.Verify(input.AuthProof, verifier, h.Config.AuthPepper) {
err = database.ErrNotFound
}
}
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
token, tokenHash, err := auth.Token()
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create session")
return
}
if _, err = h.Store.CreateSession(r.Context(), userID, input.DeviceName, tokenHash, time.Now().Add(h.Config.SessionTTL)); err != nil {
writeError(w, http.StatusInternalServerError, "could not create session")
return
}
writeJSON(w, http.StatusOK, models.Session{Token: token, ExpiresAt: time.Now().Add(h.Config.SessionTTL)})
}
func (h *Handler) sync(w http.ResponseWriter, r *http.Request) {
userID, ok := h.user(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
var input models.SyncRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<20)).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request")
return
}
for _, change := range input.Changes {
if change.EntityType == "" || change.EntityID == "" || change.Envelope == "" {
writeError(w, http.StatusBadRequest, "invalid change")
return
}
if len(change.Envelope) > 4<<20 {
writeError(w, http.StatusRequestEntityTooLarge, "envelope too large")
return
}
version, err := h.Store.UpsertBlob(r.Context(), userID, change.EntityType, change.EntityID, []byte(change.Envelope), change.Deleted)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not store change")
return
}
change.Version = version
notification, _ := json.Marshal(map[string]any{
"entityType": change.EntityType,
"entityId": change.EntityID,
"version": version,
})
h.Hub.Broadcast(userID, notification, nil)
}
rows, cursor, err := h.Store.Changes(r.Context(), userID, input.Cursor)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not read changes")
return
}
response := models.SyncResponse{Cursor: cursor}
for _, row := range rows {
response.Changes = append(response.Changes, models.Blob{
EntityType: row[0].(string), EntityID: row[1].(string), Envelope: string(row[2].([]byte)),
Version: row[3].(int64), Deleted: row[4].(bool), UpdatedAt: row[5].(time.Time).UTC().Format(time.RFC3339Nano),
})
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) ws(w http.ResponseWriter, r *http.Request) {
userID, ok := h.user(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
h.Hub.Join(userID, conn)
defer func() { h.Hub.Leave(userID, conn); _ = conn.Close() }()
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}
func (h *Handler) user(r *http.Request) (uuid.UUID, bool) {
raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if raw == "" {
return uuid.Nil, false
}
digest := sha256.Sum256([]byte(raw))
id, err := h.Store.SessionUser(r.Context(), digest[:])
return id, err == nil
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
})
}

40
internal/models/models.go Normal file
View file

@ -0,0 +1,40 @@
package models
import "time"
type Credentials struct {
Email string `json:"email"`
AuthProof string `json:"authProof"`
DeviceName string `json:"deviceName"`
}
type Session struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expiresAt"`
}
type Blob struct {
EntityType string `json:"entityType"`
EntityID string `json:"entityId"`
Envelope string `json:"envelope"`
Version int64 `json:"version"`
Deleted bool `json:"deleted"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
type SyncRequest struct {
Changes []Blob `json:"changes"`
Cursor int64 `json:"cursor"`
}
type SyncResponse struct {
Changes []Blob `json:"changes"`
Cursor int64 `json:"cursor"`
}
type Device struct {
ID string `json:"id"`
DeviceName string `json:"deviceName"`
CreatedAt time.Time `json:"createdAt"`
LastSeenAt time.Time `json:"lastSeenAt"`
}

44
internal/websocket/hub.go Normal file
View file

@ -0,0 +1,44 @@
package websocket
import (
"sync"
"github.com/gorilla/websocket"
"github.com/google/uuid"
)
type Hub struct {
mu sync.RWMutex
rooms map[uuid.UUID]map[*websocket.Conn]struct{}
}
func NewHub() *Hub { return &Hub{rooms: make(map[uuid.UUID]map[*websocket.Conn]struct{})} }
func (h *Hub) Join(userID uuid.UUID, conn *websocket.Conn) {
h.mu.Lock()
defer h.mu.Unlock()
if h.rooms[userID] == nil {
h.rooms[userID] = make(map[*websocket.Conn]struct{})
}
h.rooms[userID][conn] = struct{}{}
}
func (h *Hub) Leave(userID uuid.UUID, conn *websocket.Conn) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.rooms[userID], conn)
if len(h.rooms[userID]) == 0 {
delete(h.rooms, userID)
}
}
func (h *Hub) Broadcast(userID uuid.UUID, payload []byte, except *websocket.Conn) {
h.mu.RLock()
defer h.mu.RUnlock()
for conn := range h.rooms[userID] {
if conn == except {
continue
}
_ = conn.WriteMessage(websocket.TextMessage, payload)
}
}

View file

@ -0,0 +1,32 @@
CREATE TABLE IF NOT EXISTS app_user (
id UUID PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
auth_verifier TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS device_session (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
device_name TEXT NOT NULL,
token_hash BYTEA NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS sync_blob (
user_id UUID NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
envelope BYTEA NOT NULL,
version BIGINT NOT NULL,
deleted_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, entity_type, entity_id)
);
CREATE SEQUENCE IF NOT EXISTS sync_revision_seq;
CREATE INDEX IF NOT EXISTS sync_blob_changes
ON sync_blob (user_id, version);