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:
commit
54896cac8e
15 changed files with 708 additions and 0 deletions
4
.env.example
Normal file
4
.env.example
Normal 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
13
Dockerfile
Normal 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
34
README.md
Normal 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
45
cmd/server/main.go
Normal 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
29
docker-compose.yml
Normal 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
19
go.mod
Normal 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
34
go.sum
Normal 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
64
internal/auth/auth.go
Normal 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)
|
||||
}
|
||||
26
internal/auth/auth_test.go
Normal file
26
internal/auth/auth_test.go
Normal 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
41
internal/config/config.go
Normal 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
|
||||
}
|
||||
99
internal/database/database.go
Normal file
99
internal/database/database.go
Normal 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()
|
||||
}
|
||||
184
internal/handlers/handlers.go
Normal file
184
internal/handlers/handlers.go
Normal 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
40
internal/models/models.go
Normal 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
44
internal/websocket/hub.go
Normal 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)
|
||||
}
|
||||
}
|
||||
32
migrations/001_initial.sql
Normal file
32
migrations/001_initial.sql
Normal 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);
|
||||
Loading…
Reference in a new issue