Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions internal/clients/db/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
package db

import (
"context"
"database/sql"
"errors"
"fmt"
Expand Down Expand Up @@ -53,10 +54,18 @@ type Client struct {
Dialect Dialect
}

// MigrationRunner is the interface Connection.SetAndMigrate uses to apply
// pending database migrations. The concrete implementation lives in
// internal/migrations; this interface breaks the potential import cycle because
// migrations imports db and db therefore cannot import migrations.
type MigrationRunner interface {
Run(ctx context.Context, db *Client) error
}

// NewClient opens a connection to the database described by config, verifies
// the connection is live and returns a dialect-aware Client. It dispatches to
// the driver-specific open functions. Table creation is intentionally NOT done
// here; each feature repository owns its schema via initializeTable.
// the driver-specific open functions. Schema management is handled separately
// by the migrations runner, not here.
func NewClient(config Config) (*Client, error) {
switch config.Type {
case SQLite:
Expand Down Expand Up @@ -138,7 +147,8 @@ func NewConnection() *Connection {
}

// Set replaces the active client with a new one, closing the previous if any.
// It is called by the auth service after opening a user's database.
// Prefer SetAndMigrate when opening a user's database so that pending
// migrations are applied before any repository code runs.
func (c *Connection) Set(client *Client) {
c.mu.Lock()
defer c.mu.Unlock()
Expand All @@ -148,6 +158,19 @@ func (c *Connection) Set(client *Client) {
c.client = client
}

// SetAndMigrate runs pending migrations on client via runner and, if they all
// succeed, atomically replaces the active connection by calling Set. If
// migration fails the new client is closed and the error is returned — the
// caller never receives a partially-initialised database.
func (c *Connection) SetAndMigrate(ctx context.Context, client *Client, runner MigrationRunner) error {
if err := runner.Run(ctx, client); err != nil {
_ = client.Close()
return fmt.Errorf("database migration failed: %w", err)
}
c.Set(client)
return nil
}

// Current returns the active client, or ErrNoConnection when none is set (no
// user signed in).
func (c *Connection) Current() (*Client, error) {
Expand Down
59 changes: 6 additions & 53 deletions internal/features/auth/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
stderrors "errors"
"fmt"
"strings"
"sync"

dbclient "ayo/internal/clients/db"
"ayo/internal/platform/keyring"
Expand Down Expand Up @@ -53,9 +52,7 @@ type Repository interface {
}

type repository struct {
conn *dbclient.Connection
initMu sync.Mutex
initClient *dbclient.Client
conn *dbclient.Connection
}

// NewRepository returns a repository bound to the shared connection holder. The
Expand All @@ -65,56 +62,12 @@ func NewRepository(conn *dbclient.Connection) Repository {
return &repository{conn: conn}
}

// resolve returns the active client for the current session, creating the
// feature's tables on it the first time it is seen. Each user's database is
// initialized once, on first access after login (or registration).
// resolve returns the active database client for the current session. The
// schema is guaranteed to be up-to-date before any repository method is
// called, because migrations run inside Connection.SetAndMigrate at login and
// registration time.
func (r *repository) resolve() (*dbclient.Client, error) {
c, err := r.conn.Current()
if err != nil {
return nil, err
}
r.initMu.Lock()
defer r.initMu.Unlock()
if r.initClient != c {
if err := initializeTable(c); err != nil {
return nil, err
}
r.initClient = c
}
return c, nil
}

// initializeTable idempotently ensures the users table exists. It stores only
// hashes and encrypted material - never plaintext credentials. Column types use
// BYTEA notation but SQLite is untyped, so []byte values are stored as blobs;
// PostgreSQL stores them in native BYTEA columns. The id column and DDL differ
// per dialect.
func initializeTable(db *dbclient.Client) error {
idColumn := "id INTEGER PRIMARY KEY AUTOINCREMENT"
if db.IsPostgres() {
idColumn = "id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY"
}

query := `CREATE TABLE IF NOT EXISTS users (
` + idColumn + `,
username VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
recovery_key VARCHAR(255) NOT NULL,

password_salt BYTEA NOT NULL,
password_nonce BYTEA NOT NULL,
password_master_key BYTEA NOT NULL,

recovery_salt BYTEA NOT NULL,
recovery_nonce BYTEA NOT NULL,
recovery_master_key BYTEA NOT NULL
)`

_, err := db.Exec(query)
if err != nil {
return err
}
return nil
return r.conn.Current()
}

// CreateUser inserts a new account row and returns the created User populated
Expand Down
29 changes: 21 additions & 8 deletions internal/features/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ type Service struct {
validate *validator.Validate
mu sync.Mutex
inactivityTimeoutMinutes int
migrationRunner dbclient.MigrationRunner
}

// Startup stores the Wails application context, which native dialogs (e.g.
Expand Down Expand Up @@ -103,10 +104,11 @@ func validatePasswordStrength(fl validator.FieldLevel) bool {
return hasUpper && hasLower && hasDigit && hasSymbol
}

// NewService wires a shared connection holder, the database-credentials
// keyring repository, the master-key keyring repository and a validator with
// the custom validation rules into a ready-to-use auth Service.
func NewService(conn *dbclient.Connection) *Service {
// NewService wires a shared connection holder, a migration runner, the
// database-credentials keyring repository, the master-key keyring repository
// and a validator with the custom validation rules into a ready-to-use auth
// Service.
func NewService(conn *dbclient.Connection, migrationRunner dbclient.MigrationRunner) *Service {
validate := validator.New()

// Register custom validators
Expand All @@ -118,6 +120,7 @@ func NewService(conn *dbclient.Connection) *Service {
repo: NewRepository(conn),
validate: validate,
inactivityTimeoutMinutes: 15,
migrationRunner: migrationRunner,
}
}

Expand Down Expand Up @@ -171,7 +174,12 @@ func (s *Service) Register(input RegisterInput) (*RegisterResult, error) {
if err != nil {
return nil, errors.ErrDatabaseUnavailable
}
s.conn.Set(client)
// Run migrations so the full schema exists before CreateUser is called.
// Registration does not sign the user in, so the connection is always
// closed before returning (see defer below).
if err := s.conn.SetAndMigrate(context.Background(), client, s.migrationRunner); err != nil {
return nil, errors.ErrDatabaseUnavailable
}
// Registration does not sign the user in, so the temporary connection is
// always closed before returning.
defer s.conn.Close()
Expand Down Expand Up @@ -300,12 +308,15 @@ func (s *Service) Login(input LoginInput) (bool, error) {
}
config := creds.ToConfig()

// Connect to the user's database before touching its tables.
// Connect to the user's database and run any pending migrations before
// touching its tables.
client, err := dbclient.NewClient(config)
if err != nil {
return false, errors.ErrDatabaseUnavailable
}
s.conn.Set(client)
if err := s.conn.SetAndMigrate(context.Background(), client, s.migrationRunner); err != nil {
return false, errors.ErrDatabaseUnavailable
}

user, err := s.repo.GetUserByUsername(context.Background(), input.Username)
if err != nil {
Expand Down Expand Up @@ -395,7 +406,9 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro
if err != nil {
return nil, errors.ErrDatabaseUnavailable
}
s.conn.Set(client)
if err := s.conn.SetAndMigrate(context.Background(), client, s.migrationRunner); err != nil {
return nil, errors.ErrDatabaseUnavailable
}

user, err := s.repo.GetUserByUsername(context.Background(), input.Username)
if err != nil {
Expand Down
25 changes: 5 additions & 20 deletions internal/features/home/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"database/sql"
"encoding/json"
"fmt"
"sync"

dbclient "ayo/internal/clients/db"
"ayo/internal/features/upload"
Expand All @@ -20,8 +19,6 @@ import (
type repository struct {
conn *dbclient.Connection
uploadRepo upload.Repository
initMu sync.Mutex
initClient *dbclient.Client
}

// NewRepository returns a repository bound to the shared connection holder and
Expand All @@ -32,24 +29,12 @@ func NewRepository(conn *dbclient.Connection, uploadRepo upload.Repository) *rep
return &repository{conn: conn, uploadRepo: uploadRepo}
}

// resolve returns the active client for the current session, ensuring the
// shared uploads/chunks schema exists on it the first time it is seen. The DDL
// lives only in the upload feature; this repository reuses it rather than
// duplicating it.
// resolve returns the active database client for the current session. The
// schema is guaranteed to be up-to-date before any repository method is
// called, because migrations run inside Connection.SetAndMigrate at login and
// registration time.
func (r *repository) resolve() (*dbclient.Client, error) {
c, err := r.conn.Current()
if err != nil {
return nil, err
}
r.initMu.Lock()
defer r.initMu.Unlock()
if r.initClient != c {
if err := upload.InitializeSchema(c); err != nil {
return nil, err
}
r.initClient = c
}
return c, nil
return r.conn.Current()
}

// GetRecentFiles returns the most recently uploaded stored files, newest
Expand Down
115 changes: 6 additions & 109 deletions internal/features/upload/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"database/sql"
"encoding/json"
"fmt"
"sync"

dbclient "ayo/internal/clients/db"
)
Expand Down Expand Up @@ -44,9 +43,7 @@ type Repository interface {
}

type repository struct {
conn *dbclient.Connection
initMu sync.Mutex
initClient *dbclient.Client
conn *dbclient.Connection
}

// NewRepository returns a repository bound to the shared connection holder. The
Expand All @@ -59,112 +56,12 @@ func NewRepository(conn *dbclient.Connection) *repository {
return &repository{conn: conn}
}

// resolve returns the active client for the current session, creating the
// feature's tables on it the first time it is seen.
// resolve returns the active database client for the current session. The
// schema is guaranteed to be up-to-date before any repository method is
// called, because migrations run inside Connection.SetAndMigrate at login and
// registration time.
func (r *repository) resolve() (*dbclient.Client, error) {
c, err := r.conn.Current()
if err != nil {
return nil, err
}
r.initMu.Lock()
defer r.initMu.Unlock()
if r.initClient != c {
if err := InitializeSchema(c); err != nil {
return nil, err
}
r.initClient = c
}
return c, nil
}

// InitializeSchema idempotently ensures the uploads and chunks tables exist.
// The upload feature owns the shared schema (it is the writer); the home
// feature bootstraps the same tables through this function so both read and
// write the same storage without duplicating the DDL. chunks.file_id references
// uploads.id (via the foreign_keys pragma on SQLite / a native FK on
// PostgreSQL), and chunks.chunk_id is globally unique so shard names can never
// collide even across users or uploads. The uploads table also carries the
// reconstruction metadata (encrypted size, shard layout, block count) that a
// local manifest used to hold, so a stored file can always be rebuilt from its
// row. It also carries the envelope-encryption metadata (the per-file DEK
// wrapped by the master key and its two nonces) so a stored file can be
// unwrapped and decrypted from its row alone. The DDL branches on the client's
// dialect (AUTOINCREMENT vs IDENTITY, DATETIME vs TIMESTAMP, BIGINT for size
// columns).
func InitializeSchema(db *dbclient.Client) error {
var queries []string

if db.IsPostgres() {
queries = []string{
`CREATE TABLE IF NOT EXISTS uploads (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
job_id BIGINT NOT NULL UNIQUE,
file TEXT NOT NULL,
custom_name TEXT NOT NULL DEFAULT '',
size BIGINT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
encrypted_size BIGINT NOT NULL,
data_shards INTEGER NOT NULL,
parity_shards INTEGER NOT NULL,
shard_size BIGINT NOT NULL,
block_count INTEGER NOT NULL,
file_nonce BYTEA NOT NULL,
encrypted_file_key BYTEA NOT NULL,
key_nonce BYTEA NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS chunks (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
file_id BIGINT NOT NULL,
shard_index INTEGER NOT NULL,
chunk_id TEXT NOT NULL UNIQUE,
storage_id TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (file_id) REFERENCES uploads(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS idx_chunks_file_id ON chunks(file_id)`,
}
} else {
queries = []string{
`CREATE TABLE IF NOT EXISTS uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL UNIQUE,
file TEXT NOT NULL,
custom_name TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
encrypted_size INTEGER NOT NULL,
data_shards INTEGER NOT NULL,
parity_shards INTEGER NOT NULL,
shard_size INTEGER NOT NULL,
block_count INTEGER NOT NULL,
file_nonce BLOB NOT NULL,
encrypted_file_key BLOB NOT NULL,
key_nonce BLOB NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_id INTEGER NOT NULL,
shard_index INTEGER NOT NULL,
chunk_id TEXT NOT NULL UNIQUE,
storage_id TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (file_id) REFERENCES uploads(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS idx_chunks_file_id ON chunks(file_id)`,
}
}

for _, query := range queries {
if _, err := db.Exec(query); err != nil {
return err
}
}

return nil
return r.conn.Current()
}

// CreateUpload inserts a stored-file record and returns it populated with its
Expand Down
Loading
Loading