diff --git a/AGENTS.md b/AGENTS.md index 8380b14..7b99abe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,22 +7,26 @@ Wails v2 desktop app (`ayo`): Go 1.24 backend + React 18 / TypeScript / Vite 3 / The Go backend is tiered under `internal/`: - `internal/features/` — business logic, one package per feature: - - `auth/` — Register/Login/ResetPassword/Logout + in-memory session (`MasterKey`). bcrypt + Argon2 + AES-GCM. Layered as `dto.go` / `model.go` / `repository.go` / `service.go`. - - `settings/` — per-user settings stored in the OS keyring (`zalando/go-keyring`), encrypted with the session master key. Keyring persistence is in `repository.go`; cloud-key types in `cloud.go`; validated Wails-bound input in `dto.go`. + - `auth/` — Register/Login/ResetPassword/Logout + in-memory session (`MasterKey`). bcrypt + Argon2 + AES-GCM. Layered as `dto.go` / `model.go` / `repository.go` / `service.go`. The service owns the active per-user database connection (via `internal/clients/db`'s `Connection`) and opens/closes it on login/logout; the full DB config (incl. PostgreSQL password) stays on the service, never in `Session` (which is serialized to the frontend). + - `dbconfig/` — dual-encrypted (password-KEK + recovery-KEK) per-user database credentials stored in the OS keyring under `ayo`/`dbcreds_{username}`. `model.go` / `crypto.go` / `repository.go`. + - `settings/` — per-user settings stored in the OS keyring (`zalando/go-keyring`), encrypted with the session master key. Keyring persistence is in `repository.go`; cloud-key types in `cloud.go`; validated Wails-bound input in `dto.go`. `GetDatabaseInfo()` returns sanitized (no password) DB info for the read-only Database tab. - `recovery/` — save-file dialog for downloading the recovery key (shown after register/reset). - `queue/` — dead code: not wired into `main.go`, and its SQL is MySQL-flavored (`AUTO_INCREMENT`, `JSON` type, `ON UPDATE CURRENT_TIMESTAMP`) and will not run on SQLite. Don't build on it. +- `internal/clients/` — driver-backed client abstractions: + - `db/` — dialect-aware database client (`sqlite` via `modernc.org/sqlite`, `postgresql` via `github.com/lib/pq`). `Config` + `NewClient`/`Validate`, `Client` embeds `*sql.DB` and carries its `Dialect`, `Rebind()` rewrites `?`→`$N` for PostgreSQL, and `Connection` is the shared per-session connection holder that repositories resolve per operation (tables created lazily via `initializeTable`). + - `storage/` — storage provider clients and dispatch (see below). - `internal/platform/` — infrastructure (never imported by features' business logic directly beyond what the feature's own repository wraps): - - `database/` — SQLite via `modernc.org/sqlite` (pure Go, no cgo). Tables are created idempotently with `CREATE TABLE IF NOT EXISTS` inside each repository's `initializeTable`; there is **no migration tool**. - `keyring/` — thin wrapper over `zalando/go-keyring`. - `dialog/` — native Wails save-file dialog wrapper. - - `queue/` — persistent SQLite-backed job queue (one `Job` per queued file, with status + progress). Wired in `main.go` and consumed by the `upload` feature's business logic through a narrow interface; prefer keeping that dependency behind the feature's own repository if possible. + - `queue/` — job queue (one `Job` per queued file, with status + progress) backed by the signed-in user's database. Wired in `main.go` and consumed by the `upload` feature's business logic through a narrow interface; prefer keeping that dependency behind the feature's own repository if possible. - `internal/shared/` — cross-cutting code: - - `errors/` — sentinel errors with user-facing messages; return these (not wrapped fmt errors) so the frontend can display them. Also the `InternalServerError` type. + - `errors/` — sentinel errors with user-facing messages; return these (not wrapped fmt errors) so the frontend can display them. Also the `InternalServerError` type, `ErrDatabaseUnavailable` and `ErrNoStorageProvider`. - `crypto/` — Argon2 KEK derivation + AES-256-GCM encrypt/decrypt primitives. -- `main.go` — entrypoint. Wires `auth`, `settings`, `recovery`, `upload` services and binds them to the frontend via `wails.Run`. + - `paths/` — `GetAppDataDir()` for the OS app data directory where per-user SQLite files live. +- `main.go` — entrypoint. Wires `auth`, `settings`, `recovery`, `upload` services and binds them to the frontend via `wails.Run`. No global database: a shared `dbclient.Connection` is created and passed to auth/queue/upload. - `assets.go` — `//go:embed all:frontend/dist`; the compiled frontend is embedded into the Go binary. - `frontend/` — React SPA. Calls Go through generated bindings (below). `@/` aliases `frontend/src`. -- `data/` — gitignored runtime data (`ayo.db`, `chunks/`, `input/`, `output/`). DB is hardcoded to `data/ayo.db` in `main.go`. +- `data/` — gitignored runtime data (`encrypted/`, `downloads/`). Per-user databases live in the OS app data directory, not here. - `explore/` — stray experiment dir, not part of the build. ## Commands @@ -43,5 +47,5 @@ The Go backend is tiered under `internal/`: ## Conventions - Auth validation uses `go-playground/validator` with a custom `password_strength` rule: passwords must contain upper + lower + digit + symbol. -- SQLite queries use `?` placeholders (see `internal/features/auth/repository.go`). +- Repositories write queries with `?` placeholders and run them through the client's `Rebind()` (a no-op on SQLite, `?`→`$N` on PostgreSQL). `initializeTable` and insert-ID retrieval (`LastInsertId` vs `RETURNING id`) branch on the dialect. - Frontend: TypeScript, ESLint, Prettier; commit formatted/linted code (`format` + `lint` pass in CI). diff --git a/README.md b/README.md index 928b4ea..d8c61da 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,23 @@ flowchart TB Ayo is in the early stages of development. +## Database selection + +Each account picks its database at registration (tabbed choice in the sign-up form): + +- **SQLite**: a local database file per user, auto-created in the OS app data + directory (`~/Library/Application Support/ayo/.db` on macOS). +- **PostgreSQL**: connect to your own server with user-provided credentials. + +The credentials are encrypted in the OS keyring (dual-wrapped with the +password-derived and recovery-key-derived KEKs, the same pattern as the master +key), so password reset re-encrypts them automatically. The database choice is +permanent and shown read-only in Settings → Database. + +> **Breaking change**: accounts created before this feature have no encrypted +> database-credentials entry in the keyring and cannot log in after upgrading. +> No automatic migration is provided. + ## Getting started - Dev: `wails dev` (Vite hot reload; browser dev server on http://localhost:34115) diff --git a/frontend/src/components/items/DatabaseConfig.tsx b/frontend/src/components/items/DatabaseConfig.tsx new file mode 100644 index 0000000..b60c8d6 --- /dev/null +++ b/frontend/src/components/items/DatabaseConfig.tsx @@ -0,0 +1,211 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Database, Server } from 'lucide-react'; +import TextInput from '@/components/bits/Input'; +import Button from '@/components/bits/Button'; + +export type DatabaseType = 'sqlite' | 'postgresql'; + +// The database configuration chosen by the user during registration. SQLite +// needs no fields (the path is auto-generated in the OS app data directory); +// PostgreSQL requires connection details. +export type DatabaseConfigData = { + type: DatabaseType; + host?: string; + port?: number; + database?: string; + username?: string; + password?: string; +}; + +const postgresSchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z + .string() + .regex(/^\d+$/, 'Port must be a number') + .refine((v) => { + const n = Number(v); + return n >= 1 && n <= 65535; + }, 'Port must be between 1 and 65535'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), +}); + +type PostgresFormData = z.infer; + +export default function DatabaseConfig({ + onComplete, + onBack, +}: { + onComplete: (data: DatabaseConfigData) => void; + onBack?: () => void; +}) { + const [type, setType] = useState('sqlite'); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(postgresSchema), + defaultValues: { + host: 'localhost', + port: '5432', + database: '', + username: '', + password: '', + }, + }); + + const submitPostgres = (data: PostgresFormData) => { + onComplete({ + type: 'postgresql', + host: data.host, + port: Number(data.port), + database: data.database, + username: data.username, + password: data.password, + }); + }; + + const submitSQLite = () => { + onComplete({ type: 'sqlite' }); + }; + + const tabClass = (id: DatabaseType) => + `px-5 py-3 text-sm font-semibold transition-all duration-200 border-b-2 flex items-center gap-2 ${ + type === id + ? 'border-sky-500 text-sky-600 dark:border-sky-400 dark:text-sky-400' + : 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:text-slate-400 dark:hover:text-slate-300 dark:hover:border-slate-600' + }`; + + return ( +
+
+

+ Database Configuration +

+

+ Choose where your account and encrypted data will be stored. +

+
+ + {/* Tabs */} +
+ + +
+ +
+ {type === 'sqlite' ? ( +
+
+
+ +
+
+

+ Local storage +

+

+ Your data will be stored locally. The database file will be created automatically + in the app data directory. No setup required. +

+
+
+
+ ) : ( +
+
+ + +
+ + + + +
+

+ Ayo will verify the database is reachable before creating your account. Your + connection details are encrypted and stored securely on this device. +

+
+ +
+ {onBack && ( + + )} + +
+ + )} +
+ + {type === 'sqlite' && ( +
+ {onBack && ( + + )} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/items/DatabaseSettings.tsx b/frontend/src/components/items/DatabaseSettings.tsx new file mode 100644 index 0000000..03426c0 --- /dev/null +++ b/frontend/src/components/items/DatabaseSettings.tsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from 'react'; +import toast from 'react-hot-toast'; +import { AlertTriangle, Database, Server } from 'lucide-react'; +import { GetDatabaseInfo } from '../../../wailsjs/go/settings/Service'; +import { settings } from '../../../wailsjs/go/models'; + +// Read-only display of the signed-in user's database configuration. The choice +// of database is permanent and cannot be edited here; the password is never +// exposed (the backend returns sanitized information only). +export default function DatabaseSettings() { + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + GetDatabaseInfo() + .then((dbInfo) => setInfo(dbInfo)) + .catch((err) => { + console.error('Failed to load database info:', err); + toast.error('Failed to load database information'); + }) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
+
+

+ Database Settings +

+
+

+ Loading database information... +

+
+ ); + } + + const isPostgres = info?.Type === 'postgresql'; + + return ( +
+
+

Database Settings

+

+ Your account data is stored in the database you chose during registration. +

+
+ +
+
+
+ {isPostgres ? ( + + ) : ( + + )} +
+
+

+ Database Type +

+

+ {isPostgres ? 'PostgreSQL' : 'SQLite'} +

+
+
+ +
+ {isPostgres ? ( + <> + + + + + + ) : ( + + )} +
+
+ +
+
+
+ +
+
+

+ {isPostgres + ? 'Remote metadata is stored on your database server' + : 'Data loss risk on this device'} +

+

+ {isPostgres + ? 'Your encrypted data is stored in the configured PostgreSQL server. The database itself is not encrypted by ayo, so the server operator could see storage and access metadata.' + : 'Your data is stored in a local SQLite database file on this device. If the file is lost or the device fails, your data may be unrecoverable. Back up your recovery key.'} +

+
+
+
+ +

+ The database choice is permanent and cannot be changed. +

+
+ ); +} + +function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+

+ {label} +

+

+ {value} +

+
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 852efb9..cbed2f6 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -37,7 +37,14 @@ export default function Login() { } } catch (err) { console.error('Login error:', err); - toast.error(String(err) || 'An unexpected error occurred. Please try again.'); + const message = String(err); + if (message.toLowerCase().includes('database')) { + toast.error( + 'Unable to connect to your database. Please check that the database is accessible and try again.' + ); + } else { + toast.error(message || 'An unexpected error occurred. Please try again.'); + } } }; diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index c4026e5..68ecc84 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -7,13 +7,17 @@ import PageSection from '@/components/bits/Section'; import AuthCard from '@/components/items/AuthCard'; import TextInput from '@/components/bits/Input'; import Button from '@/components/bits/Button'; +import DatabaseConfig, { type DatabaseConfigData } from '@/components/items/DatabaseConfig'; import { useAuth } from '@/context/AuthContext'; import { SaveRecoveryKey } from '../../wailsjs/go/recovery/Service'; +import { auth } from '../../wailsjs/go/models'; import { registerSchema, type RegisterFormData } from '@/lib/validations'; export default function Register() { const navigate = useNavigate(); const { register: registerUser } = useAuth(); + const [step, setStep] = useState<1 | 2>(1); + const [accountData, setAccountData] = useState(null); const [recoveryKey, setRecoveryKey] = useState(null); const [isSaving, setIsSaving] = useState(false); @@ -21,7 +25,6 @@ export default function Register() { register, handleSubmit, formState: { errors, isSubmitting }, - getValues, } = useForm({ resolver: zodResolver(registerSchema), defaultValues: { @@ -30,9 +33,33 @@ export default function Register() { }, }); - const onSubmit = async (data: RegisterFormData) => { + // Step 1: account details. On success, move to database configuration. + const onAccountSubmit = (data: RegisterFormData) => { + setAccountData(data); + setStep(2); + }; + + // Step 2: database configuration. Combine with the account details and call + // the backend; SQLite paths are auto-generated so Path is left empty. + const onDatabaseComplete = async (dbData: DatabaseConfigData) => { + if (!accountData) return; + try { - const result = await registerUser({ Username: data.username, Password: data.password }); + const result = await registerUser( + new auth.RegisterInput({ + Username: accountData.username, + Password: accountData.password, + DBConfig: { + Type: dbData.type, + Path: '', + Host: dbData.host || '', + Port: dbData.port || 0, + Database: dbData.database || '', + Username: dbData.username || '', + Password: dbData.password || '', + }, + }) + ); if (result) { setRecoveryKey(result.RecoveryKey); toast.success('Account created successfully! Please download your recovery key.'); @@ -46,11 +73,11 @@ export default function Register() { }; const handleDownloadRecoveryKey = async () => { - if (!recoveryKey) return; + if (!recoveryKey || !accountData) return; setIsSaving(true); try { - const username = getValues('username'); + const username = accountData.username; await SaveRecoveryKey(username, recoveryKey); toast.success('Recovery key saved successfully! Redirecting to login...'); navigate('/auth/login'); @@ -62,6 +89,18 @@ export default function Register() { } }; + const stepIndicator = ( +
+ + 1. Account Details + + + + 2. Database Configuration + +
+ ); + return ( Already have an account? ) : ( -
- - - - - - -
- -
- + <> + {stepIndicator} + + {step === 1 && ( +
+ + + + + + +
+ +
+ + )} + + {step === 2 && ( + setStep(1)} /> + )} + )}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 9d94c5f..17b47b3 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,12 +1,14 @@ import { useState } from 'react'; -import { HardDrive, Settings as SettingsIcon, UserCog } from 'lucide-react'; +import { Database, HardDrive, Settings as SettingsIcon, UserCog } from 'lucide-react'; import SettingsLayout, { type SettingsSection } from '@/components/items/SettingsLayout'; import StorageSettings from '@/components/items/StorageSettings'; import AccountSettings from '@/components/items/AccountSettings'; import ApplicationSettings from '@/components/items/ApplicationSettings'; +import DatabaseSettings from '@/components/items/DatabaseSettings'; const sections: SettingsSection[] = [ { id: 'storage', label: 'Storage Settings', icon: }, + { id: 'database', label: 'Database Settings', icon: }, { id: 'account', label: 'Account Settings', icon: }, { id: 'application', label: 'Application Settings', icon: }, ]; @@ -21,6 +23,7 @@ export default function Settings() { onSectionChange={setActiveSection} > {activeSection === 'storage' && } + {activeSection === 'database' && } {activeSection === 'account' && } {activeSection === 'application' && } diff --git a/frontend/wailsjs/go/auth/Service.d.ts b/frontend/wailsjs/go/auth/Service.d.ts index fbef8ae..78b4584 100755 --- a/frontend/wailsjs/go/auth/Service.d.ts +++ b/frontend/wailsjs/go/auth/Service.d.ts @@ -1,7 +1,12 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +import {db} from '../models'; import {auth} from '../models'; +export function CurrentClient():Promise; + +export function DatabaseConfig():Promise; + export function GetSession():Promise; export function Login(arg1:auth.LoginInput):Promise; diff --git a/frontend/wailsjs/go/auth/Service.js b/frontend/wailsjs/go/auth/Service.js index 12ff976..7a566f1 100755 --- a/frontend/wailsjs/go/auth/Service.js +++ b/frontend/wailsjs/go/auth/Service.js @@ -2,6 +2,14 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function CurrentClient() { + return window['go']['auth']['Service']['CurrentClient'](); +} + +export function DatabaseConfig() { + return window['go']['auth']['Service']['DatabaseConfig'](); +} + export function GetSession() { return window['go']['auth']['Service']['GetSession'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 5401ef0..06fcb51 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -17,6 +17,7 @@ export namespace auth { export class RegisterInput { Username: string; Password: string; + DBConfig: db.Config; static createFrom(source: any = {}) { return new RegisterInput(source); @@ -26,7 +27,26 @@ export namespace auth { if ('string' === typeof source) source = JSON.parse(source); this.Username = source["Username"]; this.Password = source["Password"]; + this.DBConfig = this.convertValues(source["DBConfig"], db.Config); } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } } export class User { ID: number; @@ -125,8 +145,71 @@ export namespace auth { } +export namespace db { + + export class Client { + Dialect: string; + + static createFrom(source: any = {}) { + return new Client(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.Dialect = source["Dialect"]; + } + } + export class Config { + Type: string; + Path?: string; + Host?: string; + Port?: number; + Database?: string; + Username?: string; + Password?: string; + + static createFrom(source: any = {}) { + return new Config(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.Type = source["Type"]; + this.Path = source["Path"]; + this.Host = source["Host"]; + this.Port = source["Port"]; + this.Database = source["Database"]; + this.Username = source["Username"]; + this.Password = source["Password"]; + } + } + +} + export namespace settings { + export class DatabaseInfo { + Type: string; + Path?: string; + Host?: string; + Port?: number; + Database?: string; + Username?: string; + + static createFrom(source: any = {}) { + return new DatabaseInfo(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.Type = source["Type"]; + this.Path = source["Path"]; + this.Host = source["Host"]; + this.Port = source["Port"]; + this.Database = source["Database"]; + this.Username = source["Username"]; + } + } export class Settings { StorageMode: string; CloudKeys: any[]; diff --git a/frontend/wailsjs/go/settings/Service.d.ts b/frontend/wailsjs/go/settings/Service.d.ts index 6204e68..481d1bc 100755 --- a/frontend/wailsjs/go/settings/Service.d.ts +++ b/frontend/wailsjs/go/settings/Service.d.ts @@ -3,6 +3,8 @@ import {settings} from '../models'; import {context} from '../models'; +export function GetDatabaseInfo():Promise; + export function GetSettings():Promise; export function PickFolder():Promise; diff --git a/frontend/wailsjs/go/settings/Service.js b/frontend/wailsjs/go/settings/Service.js index 4f7e4cb..ad1b38b 100755 --- a/frontend/wailsjs/go/settings/Service.js +++ b/frontend/wailsjs/go/settings/Service.js @@ -2,6 +2,10 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function GetDatabaseInfo() { + return window['go']['settings']['Service']['GetDatabaseInfo'](); +} + export function GetSettings() { return window['go']['settings']['Service']['GetSettings'](); } diff --git a/go.mod b/go.mod index 7bb954f..e800e98 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module ayo -go 1.24.0 +go 1.25.0 require ( github.com/aws/aws-sdk-go-v2 v1.43.4 @@ -10,10 +10,11 @@ require ( github.com/go-playground/validator/v10 v10.30.1 github.com/google/uuid v1.6.0 github.com/klauspost/reedsolomon v1.13.3 + github.com/lib/pq v1.12.3 github.com/wailsapp/wails/v2 v2.11.0 github.com/zalando/go-keyring v0.2.6 golang.org/x/crypto v0.48.0 - modernc.org/sqlite v1.45.0 + modernc.org/sqlite v1.56.0 ) require ( @@ -45,7 +46,7 @@ require ( github.com/leaanthony/u v1.1.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect @@ -57,11 +58,10 @@ require ( github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.41.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.34.0 // indirect - modernc.org/libc v1.67.6 // indirect + modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index d8ce399..63fc4bd 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0 github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -78,14 +78,16 @@ github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -121,56 +123,53 @@ github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8u github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= -modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= -modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= -modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= -modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= -modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= -modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs= -modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/internal/clients/db/client.go b/internal/clients/db/client.go new file mode 100644 index 0000000..24878aa --- /dev/null +++ b/internal/clients/db/client.go @@ -0,0 +1,151 @@ +// Package db provides a dialect-aware wrapper around database/sql. It is the +// database counterpart to the storage client abstraction: a config-based +// factory (NewClient) dispatches to the right driver, and the returned Client +// carries its dialect so repositories can write one set of queries and adapt +// placeholder/DDL/insert-ID behavior per database type. +// +// The layout mirrors internal/clients/storage: shared types and dispatch live +// here, and each driver has its own file (sqlite.go, postgresql.go). Adding a +// new database type (e.g. MySQL) means adding a Dialect, an open path +// and extending Rebind. +package db + +import ( + "database/sql" + "errors" + "fmt" + "sync" +) + +// ErrNoConnection is returned by Connection.Current when no database connection +// is active (i.e. nobody is signed in). +var ErrNoConnection = errors.New("no active database connection") + +// Dialect identifies a supported database engine. +type Dialect string + +const ( + // SQLite is the embedded file-based database (modernc.org/sqlite). + SQLite Dialect = "sqlite" + // PostgreSQL is a remote server database (github.com/lib/pq). + PostgreSQL Dialect = "postgresql" +) + +// Config describes how to connect to a database. Type determines which fields +// apply: SQLite uses Path; PostgreSQL uses Host, Port, Database, Username and +// Password. +type Config struct { + Type Dialect `json:"Type"` + Path string `json:"Path,omitempty"` + Host string `json:"Host,omitempty"` + Port int `json:"Port,omitempty"` + Database string `json:"Database,omitempty"` + Username string `json:"Username,omitempty"` + Password string `json:"Password,omitempty"` +} + +// Client is a live database connection plus the dialect it was opened with. It +// embeds *sql.DB so all existing database/sql calls keep working unchanged. +type Client struct { + *sql.DB + Dialect Dialect +} + +// 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. +func NewClient(config Config) (*Client, error) { + switch config.Type { + case SQLite: + return openSQLite(config) + case PostgreSQL: + return openPostgreSQL(config) + default: + return nil, fmt.Errorf("unsupported database type %q", config.Type) + } +} + +// Validate verifies a database described by config is reachable before the +// credentials are persisted. It is used by registration to surface bad +// PostgreSQL credentials (or an unwritable SQLite location) up front. +func Validate(config Config) error { + client, err := NewClient(config) + if err != nil { + return err + } + return client.Close() +} + +// IsPostgres reports whether the client is connected to PostgreSQL. Repositories +// branch on this to pick dialect-specific DDL, RETURNING clauses and error +// matching. +func (c *Client) IsPostgres() bool { + return c.Dialect == PostgreSQL +} + +// Rebind adapts a query written with "?" placeholders to the client's dialect. +// It is a no-op for SQLite and rewrites "?" to "$1, $2, ..." for PostgreSQL. +func (c *Client) Rebind(query string) string { + return Rebind(query, c.Dialect) +} + +// LastInsertID returns the last inserted row ID for the given result. SQLite +// exposes it via database/sql; PostgreSQL has no equivalent, so callers on that +// dialect must use an INSERT ... RETURNING id query instead. The returned error +// forces that path rather than silently returning a bogus 0. +func (c *Client) LastInsertID(result sql.Result) (int64, error) { + if c.IsPostgres() { + return 0, fmt.Errorf("postgresql does not support LastInsertId; use a RETURNING clause") + } + return result.LastInsertId() +} + +// Connection is a shared holder for the active per-user database client. With +// per-user databases there is no single connection for the app's lifetime: the +// auth service opens a user's database on login and clears it on logout. +// Repositories constructed with a Connection resolve the current client on each +// operation, so one repository instance safely serves whichever user is signed +// in, and tables are initialized lazily against that user's database. +type Connection struct { + mu sync.RWMutex + client *Client +} + +// NewConnection returns an empty connection holder with no active client. +func NewConnection() *Connection { + return &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. +func (c *Connection) Set(client *Client) { + c.mu.Lock() + defer c.mu.Unlock() + if c.client != nil && c.client != client { + _ = c.client.Close() + } + c.client = client +} + +// Current returns the active client, or ErrNoConnection when none is set (no +// user signed in). +func (c *Connection) Current() (*Client, error) { + c.mu.RLock() + defer c.mu.RUnlock() + if c.client == nil { + return nil, ErrNoConnection + } + return c.client, nil +} + +// Close closes the active client (if any) and clears the connection. Called on +// logout. +func (c *Connection) Close() { + c.mu.Lock() + defer c.mu.Unlock() + if c.client != nil { + _ = c.client.Close() + c.client = nil + } +} diff --git a/internal/clients/db/postgresql.go b/internal/clients/db/postgresql.go new file mode 100644 index 0000000..16f0760 --- /dev/null +++ b/internal/clients/db/postgresql.go @@ -0,0 +1,45 @@ +package db + +import ( + "database/sql" + "fmt" + "net" + "net/url" + "strconv" + + _ "github.com/lib/pq" +) + +// openPostgreSQL opens a PostgreSQL connection from the host/port/database/ +// username/password fields and verifies it is reachable. The DSN is built as a +// URL so special characters in the password are escaped correctly. +func openPostgreSQL(config Config) (*Client, error) { + if config.Host == "" || config.Database == "" || config.Username == "" { + return nil, fmt.Errorf("postgresql requires host, database and username") + } + + port := config.Port + if port == 0 { + port = 5432 + } + + u := url.URL{ + Scheme: "postgres", + User: url.UserPassword(config.Username, config.Password), + Host: net.JoinHostPort(config.Host, strconv.Itoa(port)), + Path: config.Database, + } + q := u.Query() + q.Set("sslmode", "disable") + u.RawQuery = q.Encode() + + db, err := sql.Open("postgres", u.String()) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("failed to ping database: %w", err) + } + return &Client{DB: db, Dialect: PostgreSQL}, nil +} diff --git a/internal/clients/db/rebind.go b/internal/clients/db/rebind.go new file mode 100644 index 0000000..7beff60 --- /dev/null +++ b/internal/clients/db/rebind.go @@ -0,0 +1,30 @@ +package db + +import ( + "strconv" + "strings" +) + +// Rebind adapts a query written with "?" placeholders to the given dialect. +// SQLite keeps "?"; PostgreSQL uses numbered "$1, $2, ..." placeholders. The +// rewrite is a simple scan, which is safe for the controlled queries in this +// codebase (none contain a literal "?" inside a string). +func Rebind(query string, dialect Dialect) string { + if dialect != PostgreSQL { + return query + } + + var sb strings.Builder + sb.Grow(len(query) + 8) + n := 0 + for _, r := range query { + if r == '?' { + n++ + sb.WriteByte('$') + sb.WriteString(strconv.Itoa(n)) + } else { + sb.WriteRune(r) + } + } + return sb.String() +} diff --git a/internal/clients/db/sqlite.go b/internal/clients/db/sqlite.go new file mode 100644 index 0000000..17cb4bc --- /dev/null +++ b/internal/clients/db/sqlite.go @@ -0,0 +1,44 @@ +package db + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +// openSQLite opens (creating if needed) a SQLite database at config.Path and +// verifies the connection is live. Missing parent directories are created +// automatically. DSN pragmas are applied to every new connection by the driver. +func openSQLite(config Config) (*Client, error) { + if config.Path == "" { + return nil, fmt.Errorf("sqlite database path is required") + } + + dir := filepath.Dir(config.Path) + if dir != "." && dir != "/" { + if err := os.MkdirAll(dir, 0750); err != nil { + return nil, fmt.Errorf("failed to create database directory: %w", err) + } + } + + // WAL mode lets readers run concurrently with a single writer (the upload + // processor updates job status in the background while the frontend polls), + // the busy timeout makes writers wait for the lock instead of failing + // immediately with SQLITE_BUSY, and foreign_keys enforcement backs the + // chunks → uploads relationship. + dsn := "file:" + filepath.ToSlash(config.Path) + + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)" + + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("failed to ping database: %w", err) + } + return &Client{DB: db, Dialect: SQLite}, nil +} diff --git a/internal/clients/storage/dispatch.go b/internal/clients/storage/dispatch.go index 72976fc..6d26b44 100644 --- a/internal/clients/storage/dispatch.go +++ b/internal/clients/storage/dispatch.go @@ -13,17 +13,12 @@ import ( // OpenShardWriter opens a writer for a new shard named chunkID, picking a // configured provider at random and dispatching to that provider's own write // path. It returns the open writer and the provider's ID to record on the chunk -// row. With no providers configured (e.g. Ayo-managed storage) shards are -// written to legacyDir and get an empty storage ID, preserving the original -// pipeline behaviour. The switch is the extension point for future providers +// row. Every upload must have at least one configured provider; without one it +// returns an error. The switch is the extension point for future providers // (Azure, GCP): each adds a case plus its own openShard. -func OpenShardWriter(providers []settings.CloudKey, chunkID, legacyDir string) (io.WriteCloser, string, error) { +func OpenShardWriter(providers []settings.CloudKey, chunkID string) (io.WriteCloser, string, error) { if len(providers) == 0 { - w, err := (&LocalFilesystem{}).OpenWriter(filepath.Join(legacyDir, chunkID)) - if err != nil { - return nil, "", fmt.Errorf("create shard file: %w", err) - } - return w, "", nil + return nil, "", fmt.Errorf("no storage provider configured") } key := providers[rand.IntN(len(providers))] @@ -41,12 +36,11 @@ func OpenShardWriter(providers []settings.CloudKey, chunkID, legacyDir string) ( // ResolveShard resolves the storage client and object key for one chunk row so // it can be read (download) or removed (delete). It dispatches on the provider -// recorded in the chunk's storage ID; empty storage IDs (legacy/Ayo data) -// resolve to the local filesystem's legacyDir. The switch is the extension -// point for future providers (Azure, GCP). -func ResolveShard(providers []settings.CloudKey, storageID, chunkID, legacyDir string) (Client, string, error) { +// recorded in the chunk's storage ID. The switch is the extension point for +// future providers (Azure, GCP). +func ResolveShard(providers []settings.CloudKey, storageID, chunkID string) (Client, string, error) { if storageID == "" { - return &LocalFilesystem{}, filepath.Join(legacyDir, chunkID), nil + return nil, "", fmt.Errorf("shard %q has no storage provider recorded", chunkID) } prefix, _, _ := strings.Cut(storageID, "_") diff --git a/internal/features/auth/dto.go b/internal/features/auth/dto.go index 802f579..a2bf33a 100644 --- a/internal/features/auth/dto.go +++ b/internal/features/auth/dto.go @@ -1,10 +1,16 @@ package auth +import ( + dbclient "ayo/internal/clients/db" +) + // RegisterInput is the payload expected when creating a new account. Validation -// tags are enforced by go-playground/validator in Service.Register. +// tags are enforced by go-playground/validator in Service.Register; the DB +// config is validated separately (type-specific fields). type RegisterInput struct { - Username string `validate:"required,min=3,max=50,lowercase,alpha"` - Password string `validate:"required,min=8,password_strength"` + Username string `validate:"required,min=3,max=50,lowercase,alpha"` + Password string `validate:"required,min=8,password_strength"` + DBConfig dbclient.Config `json:"DBConfig"` } // LoginInput is the payload expected when signing in an existing account. diff --git a/internal/features/auth/repository.go b/internal/features/auth/repository.go index cec298c..9874fb4 100644 --- a/internal/features/auth/repository.go +++ b/internal/features/auth/repository.go @@ -3,9 +3,12 @@ package auth import ( "context" "database/sql" + stderrors "errors" "fmt" "strings" + "sync" + dbclient "ayo/internal/clients/db" "ayo/internal/shared/errors" ) @@ -39,24 +42,50 @@ type Repository interface { } type repository struct { - db *sql.DB + conn *dbclient.Connection + initMu sync.Mutex + initClient *dbclient.Client } -// NewRepository opens the users table (creating it if needed) and returns a -// ready-to-use repository. -func NewRepository(db *sql.DB) (Repository, error) { - if err := initializeTable(db); err != nil { - return nil, errors.NewInternalServerError("initialize users table", err) +// NewRepository returns a repository bound to the shared connection holder. The +// users table is created lazily on the active client (see resolve), since there +// is no database connection before a user signs in. +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). +func (r *repository) resolve() (*dbclient.Client, error) { + c, err := r.conn.Current() + if err != nil { + return nil, err } - return &repository{db: db}, nil + 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. -func initializeTable(db *sql.DB) error { +// 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 ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + ` + idColumn + `, username VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, recovery_key VARCHAR(255) NOT NULL, @@ -95,23 +124,39 @@ func (r *repository) CreateUser( `password_nonce, password_master_key, recovery_salt, recovery_nonce, ` + `recovery_master_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - result, err := r.db.ExecContext( - ctx, - query, + args := []any{ username, passwordHash, recoveryKey, passwordSalt, passwordNonce, passwordMasterKey, recoverySalt, recoveryNonce, recoveryMasterKey, - ) - if err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") || strings.Contains(err.Error(), "Duplicate entry") { - return nil, errors.ErrUserAlreadyExists - } - return nil, fmt.Errorf("failed to create user: %w", err) } - id, err := result.LastInsertId() + client, err := r.resolve() if err != nil { - return nil, fmt.Errorf("failed to get last insert id: %w", err) + return nil, err + } + + var id int64 + if client.IsPostgres() { + // PostgreSQL has no LastInsertId; fetch the assigned ID via RETURNING. + err := client.QueryRowContext(ctx, client.Rebind(query)+" RETURNING id", args...).Scan(&id) + if err != nil { + if strings.Contains(err.Error(), "duplicate key value violates unique constraint") { + return nil, errors.ErrUserAlreadyExists + } + return nil, fmt.Errorf("failed to create user: %w", err) + } + } else { + result, err := client.ExecContext(ctx, client.Rebind(query), args...) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint failed") || strings.Contains(err.Error(), "Duplicate entry") { + return nil, errors.ErrUserAlreadyExists + } + return nil, fmt.Errorf("failed to create user: %w", err) + } + id, err = client.LastInsertID(result) + if err != nil { + return nil, fmt.Errorf("failed to get last insert id: %w", err) + } } user := &User{ @@ -130,16 +175,21 @@ func (r *repository) GetUserByUsername(ctx context.Context, username string) (*U query := `SELECT id, username, password_hash, recovery_key, password_salt, ` + `password_master_key, password_nonce, recovery_salt, recovery_master_key, ` + `recovery_nonce FROM users WHERE username = ?` - row := r.db.QueryRowContext(ctx, query, username) + + client, err := r.resolve() + if err != nil { + return nil, err + } + row := client.QueryRowContext(ctx, client.Rebind(query), username) var user User - err := row.Scan( + err = row.Scan( &user.ID, &user.Username, &user.PasswordHash, &user.RecoveryKey, &user.PasswordSalt, &user.PasswordMasterKey, &user.PasswordNonce, &user.RecoverySalt, &user.RecoveryMasterKey, &user.RecoveryNonce, ) if err != nil { - if err == sql.ErrNoRows { + if stderrors.Is(err, sql.ErrNoRows) { return nil, errors.ErrUserNotFound } return nil, fmt.Errorf("failed to get user: %w", err) @@ -162,9 +212,14 @@ func (r *repository) UpdateUserPassword( query := `UPDATE users SET password_hash = ?, recovery_key = ?, ` + `password_master_key = ?, password_nonce = ?, recovery_master_key = ?, ` + `recovery_nonce = ? WHERE id = ?` - _, err := r.db.ExecContext( + + client, err := r.resolve() + if err != nil { + return err + } + _, err = client.ExecContext( ctx, - query, + client.Rebind(query), passwordHash, recoveryKey, passwordMasterKey, passwordNonce, recoveryMasterKey, recoveryNonce, id, ) diff --git a/internal/features/auth/service.go b/internal/features/auth/service.go index 16c98e1..e5e7c29 100644 --- a/internal/features/auth/service.go +++ b/internal/features/auth/service.go @@ -3,10 +3,14 @@ package auth import ( "context" stderrors "errors" + "path/filepath" "regexp" + dbclient "ayo/internal/clients/db" + "ayo/internal/features/dbconfig" "ayo/internal/shared/crypto" "ayo/internal/shared/errors" + "ayo/internal/shared/paths" "github.com/go-playground/validator/v10" "golang.org/x/crypto/bcrypt" @@ -20,6 +24,11 @@ import ( // MasterKey is the decrypted key that encrypts all of the user's data. It is // kept alongside the session so services like settings can encrypt/decrypt // without re-deriving it from the password. +// +// The user's database configuration is deliberately NOT stored here: Session is +// serialized to the frontend via GetSession, and exposing the PostgreSQL +// password would leak it to the webview. The config lives on the Service +// (unexported) and is only exposed in sanitized form via DatabaseConfig. type Session struct { UserId int64 Username string @@ -30,13 +39,21 @@ type Session struct { // for the current session. It is bound to the frontend via Wails, so every // exported method is callable from JavaScript. // -// Methods return user-facing sentinel errors from ayo/internal/shared/errors rather -// than wrapped fmt errors. Internal causes are logged via slog and replaced -// with the vague *errors.InternalServerError so that no implementation detail -// ever leaks to the UI. +// Each account has its own database. The Service owns the shared +// dbclient.Connection: it opens (and stores) the signed-in user's database on +// login, and closes it on logout. Repositories for queue/upload share the same +// connection and therefore serve whichever user is active. +// +// Methods return user-facing sentinel errors from ayo/internal/shared/errors +// rather than wrapped fmt errors. Internal causes are logged via slog and +// replaced with the vague *errors.InternalServerError so that no implementation +// detail ever leaks to the UI. type Service struct { - session *Session + conn *dbclient.Connection + dbCreds dbconfig.Repository repo Repository + session *Session + dbConfig dbclient.Config validate *validator.Validate } @@ -58,29 +75,74 @@ func validatePasswordStrength(fl validator.FieldLevel) bool { return hasUpper && hasLower && hasDigit && hasSymbol } -// NewService wires a repository and a validator with the custom password -// strength rule into a ready-to-use auth Service. -func NewService(repo Repository) *Service { +// NewService wires a shared connection holder, the database-credentials +// keyring repository and a validator with the custom password strength rule +// into a ready-to-use auth Service. +func NewService(conn *dbclient.Connection, dbCreds dbconfig.Repository) *Service { validate := validator.New() // Register custom password strength validator _ = validate.RegisterValidation("password_strength", validatePasswordStrength) return &Service{ - repo: repo, + conn: conn, + dbCreds: dbCreds, + repo: NewRepository(conn), validate: validate, } } -// Register creates a new account and its master key. The key is wrapped twice - -// once with a KEK derived from the password and once with a KEK derived from a -// freshly generated recovery key - so that a forgotten password can be reset -// later without losing any encrypted data. The plaintext recovery key is -// returned (and must be shown to the user) exactly once. +// Register creates a new account, its master key and its own database. The key +// is wrapped twice - once with a KEK derived from the password and once with a +// KEK derived from a freshly generated recovery key - so that a forgotten +// password can be reset later without losing any encrypted data. The chosen +// database configuration is validated up front (the PostgreSQL server is pinged +// or the SQLite location is writable), then the credentials are dual-encrypted +// and persisted in the OS keyring. The plaintext recovery key is returned (and +// must be shown to the user) exactly once. func (s *Service) Register(input RegisterInput) (*RegisterResult, error) { if err := s.validate.Struct(input); err != nil { return nil, errors.ErrInvalidInput } + if err := validateDBConfig(input.DBConfig); err != nil { + return nil, err + } + + // Registration targets the new user's own database, which would disconnect + // any active session. Refuse while signed in. + if s.session != nil { + return nil, errors.ErrInvalidInput + } + + config, err := resolveSQLitePath(input.DBConfig, input.Username) + if err != nil { + return nil, errors.AsInternalServerError("register: resolve sqlite path", err) + } + + // Ping the database before creating anything, so bad PostgreSQL credentials + // are reported to the user immediately rather than failing later. + if err := dbclient.Validate(config); err != nil { + return nil, errors.ErrDatabaseUnavailable + } + + client, err := dbclient.NewClient(config) + if err != nil { + return nil, errors.ErrDatabaseUnavailable + } + s.conn.Set(client) + // Registration does not sign the user in, so the temporary connection is + // always closed before returning. + defer s.conn.Close() + + // Reject duplicate usernames up front: the users table lives in the target + // database, which is now connected. + _, err = s.repo.GetUserByUsername(context.Background(), input.Username) + if err == nil { + return nil, errors.ErrUserAlreadyExists + } + if !stderrors.Is(err, errors.ErrUserNotFound) { + return nil, errors.AsInternalServerError("register: check existing user", err) + } recoveryKey, err := crypto.GenerateRecoveryKey() if err != nil { @@ -129,6 +191,17 @@ func (s *Service) Register(input RegisterInput) (*RegisterResult, error) { return nil, errors.AsInternalServerError("register: encrypt master key with recovery key", err) } + // Dual-encrypt the database credentials and persist them in the keyring so + // login can re-open the user's database and reset can re-wrap them. + creds := dbconfig.FromConfig(config) + encryptedCreds, err := dbconfig.EncryptDBCredentials(input.Password, recoveryKey, creds) + if err != nil { + return nil, errors.AsInternalServerError("register: encrypt database credentials", err) + } + if err := s.dbCreds.Save(input.Username, encryptedCreds); err != nil { + return nil, errors.AsInternalServerError("register: save database credentials", err) + } + // creating the user user, err := s.repo.CreateUser( context.Background(), @@ -154,15 +227,43 @@ func (s *Service) Register(input RegisterInput) (*RegisterResult, error) { } // Login verifies the password, unwraps the master key with the password-derived -// KEK, and stores the resulting session in memory. A session is not persisted, -// so the user must log in again after every app restart. +// KEK, opens the user's database and stores the resulting session in memory. A +// session is not persisted, so the user must log in again after every app +// restart. func (s *Service) Login(input LoginInput) (bool, error) { if err := s.validate.Struct(input); err != nil { return false, errors.ErrInvalidInput } + // Load the user's encrypted database credentials from the keyring. A + // missing entry means no such account exists. + blob, err := s.dbCreds.Load(input.Username) + if err != nil { + if stderrors.Is(err, dbconfig.ErrCredentialsNotFound) { + return false, errors.ErrUserNotFound + } + return false, errors.AsInternalServerError("login: load database credentials", err) + } + + // Decrypt the credentials with the password-derived KEK. A wrong password + // fails GCM authentication, which maps to the same user-facing error as the + // bcrypt check below. + creds, err := dbconfig.DecryptDBCredentials(input.Password, blob) + if err != nil { + return false, errors.ErrInvalidPassword + } + config := creds.ToConfig() + + // Connect to the user's database before touching its tables. + client, err := dbclient.NewClient(config) + if err != nil { + return false, errors.ErrDatabaseUnavailable + } + s.conn.Set(client) + user, err := s.repo.GetUserByUsername(context.Background(), input.Username) if err != nil { + s.conn.Close() if stderrors.Is(err, errors.ErrUserNotFound) { return false, errors.ErrUserNotFound } @@ -171,6 +272,7 @@ func (s *Service) Login(input LoginInput) (bool, error) { // comparing the password if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(input.Password)); err != nil { + s.conn.Close() return false, errors.ErrInvalidPassword } @@ -183,6 +285,7 @@ func (s *Service) Login(input LoginInput) (bool, error) { // decrypting the master key masterKey, err := crypto.DecryptMasterKey(kek, user.PasswordMasterKey, user.PasswordNonce) if err != nil { + s.conn.Close() return false, errors.AsInternalServerError("login: decrypt master key", err) } @@ -192,6 +295,7 @@ func (s *Service) Login(input LoginInput) (bool, error) { Username: user.Username, MasterKey: masterKey, } + s.dbConfig = config return true, nil } @@ -199,15 +303,47 @@ func (s *Service) Login(input LoginInput) (bool, error) { // ResetPassword lets a user who forgot their password regain access by proving // ownership of the recovery key. The existing master key is unwrapped with the // recovery-key-derived KEK (so no data is lost), then re-wrapped with the new -// password and a brand-new recovery key. The new recovery key is returned and -// must be shown to the user exactly once. +// password and a brand-new recovery key. The database credentials are likewise +// unwrapped with the recovery key and re-encrypted with the new keys, so the +// account keeps its database. The new recovery key is returned and must be +// shown to the user exactly once. func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, error) { if err := s.validate.Struct(input); err != nil { return nil, errors.ErrInvalidInput } + blob, err := s.dbCreds.Load(input.Username) + if err != nil { + if stderrors.Is(err, dbconfig.ErrCredentialsNotFound) { + return nil, errors.ErrUserNotFound + } + return nil, errors.AsInternalServerError("reset password: load database credentials", err) + } + + // The recovery key unwraps both the master key and the database credentials. + creds, err := dbconfig.DecryptDBCredentialsWithRecovery(input.RecoveryKey, blob) + if err != nil { + return nil, errors.ErrInvalidRecoveryKey + } + config := creds.ToConfig() + + // Remember an active session so its connection can be re-established after + // the reset (which temporarily takes over the shared connection). + var restore *dbclient.Config + if s.session != nil { + c := s.dbConfig + restore = &c + } + + client, err := dbclient.NewClient(config) + if err != nil { + return nil, errors.ErrDatabaseUnavailable + } + s.conn.Set(client) + user, err := s.repo.GetUserByUsername(context.Background(), input.Username) if err != nil { + s.conn.Close() if stderrors.Is(err, errors.ErrUserNotFound) { return nil, errors.ErrUserNotFound } @@ -215,24 +351,28 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro } if err := bcrypt.CompareHashAndPassword([]byte(user.RecoveryKey), []byte(input.RecoveryKey)); err != nil { + s.conn.Close() return nil, errors.ErrInvalidRecoveryKey } // generate new recovery key newRecoveryKey, err := crypto.GenerateRecoveryKey() if err != nil { + s.conn.Close() return nil, errors.AsInternalServerError("reset password: generate recovery key", err) } // hash the new password to store hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost) if err != nil { + s.conn.Close() return nil, errors.AsInternalServerError("reset password: hash password", err) } // hash the new recovery key to store hashedRecoveryKey, err := bcrypt.GenerateFromPassword([]byte(newRecoveryKey), bcrypt.DefaultCost) if err != nil { + s.conn.Close() return nil, errors.AsInternalServerError("reset password: hash recovery key", err) } @@ -240,6 +380,7 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro recoveryKek := crypto.DeriveKEK(input.RecoveryKey, user.RecoverySalt) masterKey, err := crypto.DecryptMasterKey(recoveryKek, user.RecoveryMasterKey, user.RecoveryNonce) if err != nil { + s.conn.Close() return nil, errors.AsInternalServerError("reset password: decrypt master key", err) } @@ -247,6 +388,7 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro passwordKek := crypto.DeriveKEK(input.NewPassword, user.PasswordSalt) passwordEncryptedMasterKey, passwordNonce, err := crypto.EncryptMasterKey(passwordKek, masterKey) if err != nil { + s.conn.Close() return nil, errors.AsInternalServerError("reset password: encrypt master key with password", err) } @@ -254,6 +396,7 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro recoveryKek = crypto.DeriveKEK(newRecoveryKey, user.RecoverySalt) recoveryEncryptedMasterKey, recoveryNonce, err := crypto.EncryptMasterKey(recoveryKek, masterKey) if err != nil { + s.conn.Close() return nil, errors.AsInternalServerError("reset password: encrypt master key with recovery key", err) } @@ -269,15 +412,40 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro recoveryNonce, ) if err != nil { + s.conn.Close() return nil, errors.AsInternalServerError("reset password: update user", err) } + // Re-encrypt the database credentials with the new password and recovery + // key so the account keeps its database. + encryptedCreds, err := dbconfig.EncryptDBCredentials(input.NewPassword, newRecoveryKey, creds) + if err != nil { + s.conn.Close() + return nil, errors.AsInternalServerError("reset password: re-encrypt database credentials", err) + } + if err := s.dbCreds.Save(input.Username, encryptedCreds); err != nil { + s.conn.Close() + return nil, errors.AsInternalServerError("reset password: save database credentials", err) + } + + // Release the temporary connection and re-establish any previous session's + // database connection. + s.conn.Close() + if restore != nil { + if c, err := dbclient.NewClient(*restore); err == nil { + s.conn.Set(c) + } + } + return &RegisterResult{User: user, RecoveryKey: newRecoveryKey}, nil } -// Logout clears the in-memory session, ending the current user's access. +// Logout clears the in-memory session and closes the user's database +// connection, ending the current user's access. func (s *Service) Logout() { s.session = nil + s.dbConfig = dbclient.Config{} + s.conn.Close() } // GetSession returns the current in-memory session, or nil when signed out. @@ -294,3 +462,54 @@ func (s *Service) RequireSession() (*Session, error) { } return s.session, nil } + +// CurrentClient returns the signed-in user's active database connection, or +// ErrUnauthorized when signed out. Other DB-backed services use it to resolve +// the active client's dialect/connection when needed. +func (s *Service) CurrentClient() (*dbclient.Client, error) { + if s.session == nil { + return nil, errors.ErrUnauthorized + } + return s.conn.Current() +} + +// DatabaseConfig returns the signed-in user's database configuration, or +// ErrUnauthorized when signed out. Used by the settings service for the +// read-only database display. +func (s *Service) DatabaseConfig() (dbclient.Config, error) { + if s.session == nil { + return dbclient.Config{}, errors.ErrUnauthorized + } + return s.dbConfig, nil +} + +// validateDBConfig enforces type-specific field requirements on the chosen +// database configuration. +func validateDBConfig(config dbclient.Config) error { + switch config.Type { + case dbclient.SQLite: + return nil // the SQLite path is auto-generated + case dbclient.PostgreSQL: + if config.Host == "" || config.Port == 0 || config.Database == "" || + config.Username == "" || config.Password == "" { + return errors.ErrInvalidInput + } + return nil + default: + return errors.ErrInvalidInput + } +} + +// resolveSQLitePath fills in the app-data-directory path for SQLite databases +// when the caller did not supply one, producing "{AppDataDir}/ayo/.db". +func resolveSQLitePath(config dbclient.Config, username string) (dbclient.Config, error) { + if config.Type != dbclient.SQLite || config.Path != "" { + return config, nil + } + dir, err := paths.GetAppDataDir() + if err != nil { + return config, err + } + config.Path = filepath.Join(dir, username+".db") + return config, nil +} diff --git a/internal/features/dbconfig/crypto.go b/internal/features/dbconfig/crypto.go new file mode 100644 index 0000000..679996e --- /dev/null +++ b/internal/features/dbconfig/crypto.go @@ -0,0 +1,97 @@ +package dbconfig + +import ( + "encoding/json" + + "ayo/internal/shared/crypto" +) + +// encryptedBlob is the JSON shape persisted in the keyring. The credentials are +// wrapped twice, mirroring the master key pattern: once with a KEK derived from +// the password and once with a KEK derived from the recovery key, each with its +// own random salt. Each ciphertext carries its own embedded nonce (see +// crypto.EncryptData), so a password reset can re-wrap credentials using the +// recovery-key copy without the old password. +type encryptedBlob struct { + PasswordSalt []byte `json:"PasswordSalt"` + PasswordEncrypted []byte `json:"PasswordEncrypted"` + RecoverySalt []byte `json:"RecoverySalt"` + RecoveryEncrypted []byte `json:"RecoveryEncrypted"` +} + +// EncryptDBCredentials serializes creds and wraps them with both the +// password-derived and recovery-key-derived KEKs. The returned blob is the JSON +// form ready to persist in the keyring. +func EncryptDBCredentials(password, recoveryKey string, creds DBCredentials) ([]byte, error) { + plaintext, err := json.Marshal(creds) + if err != nil { + return nil, err + } + + passwordSalt, err := crypto.GenerateSalt() + if err != nil { + return nil, err + } + passwordEncrypted, err := crypto.EncryptData(crypto.DeriveKEK(password, passwordSalt), plaintext) + if err != nil { + return nil, err + } + + recoverySalt, err := crypto.GenerateSalt() + if err != nil { + return nil, err + } + recoveryEncrypted, err := crypto.EncryptData(crypto.DeriveKEK(recoveryKey, recoverySalt), plaintext) + if err != nil { + return nil, err + } + + return json.Marshal(encryptedBlob{ + PasswordSalt: passwordSalt, + PasswordEncrypted: passwordEncrypted, + RecoverySalt: recoverySalt, + RecoveryEncrypted: recoveryEncrypted, + }) +} + +// DecryptDBCredentials unwraps a blob previously produced by +// EncryptDBCredentials using the password-derived KEK. A wrong password fails +// GCM authentication and returns an error. +func DecryptDBCredentials(password string, blob []byte) (DBCredentials, error) { + return decrypt(password, blob, true) +} + +// DecryptDBCredentialsWithRecovery unwraps a blob using the recovery-key-derived +// KEK. Used by the password-reset flow to recover credentials without the old +// password. +func DecryptDBCredentialsWithRecovery(recoveryKey string, blob []byte) (DBCredentials, error) { + return decrypt(recoveryKey, blob, false) +} + +func decrypt(secret string, blob []byte, fromPassword bool) (DBCredentials, error) { + var e encryptedBlob + if err := json.Unmarshal(blob, &e); err != nil { + return DBCredentials{}, err + } + + var kek []byte + var encrypted []byte + if fromPassword { + kek = crypto.DeriveKEK(secret, e.PasswordSalt) + encrypted = e.PasswordEncrypted + } else { + kek = crypto.DeriveKEK(secret, e.RecoverySalt) + encrypted = e.RecoveryEncrypted + } + + plaintext, err := crypto.DecryptData(kek, encrypted) + if err != nil { + return DBCredentials{}, err + } + + var creds DBCredentials + if err := json.Unmarshal(plaintext, &creds); err != nil { + return DBCredentials{}, err + } + return creds, nil +} diff --git a/internal/features/dbconfig/crypto_test.go b/internal/features/dbconfig/crypto_test.go new file mode 100644 index 0000000..261682b --- /dev/null +++ b/internal/features/dbconfig/crypto_test.go @@ -0,0 +1,57 @@ +package dbconfig + +import ( + "reflect" + "testing" + + dbclient "ayo/internal/clients/db" +) + +func TestEncryptDecryptRoundTrip(t *testing.T) { + creds := DBCredentials{ + Type: dbclient.PostgreSQL, + Host: "localhost", + Port: 5432, + Database: "ayo", + Username: "alice", + Password: "s3cret!Pass", + } + const password = "Sup3r&secure" + const recoveryKey = "recovery-key-123" + + blob, err := EncryptDBCredentials(password, recoveryKey, creds) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + + got, err := DecryptDBCredentials(password, blob) + if err != nil { + t.Fatalf("decrypt with password: %v", err) + } + if !reflect.DeepEqual(got, creds) { + t.Fatalf("password round-trip mismatch:\n got %+v\nwant %+v", got, creds) + } + + got, err = DecryptDBCredentialsWithRecovery(recoveryKey, blob) + if err != nil { + t.Fatalf("decrypt with recovery key: %v", err) + } + if !reflect.DeepEqual(got, creds) { + t.Fatalf("recovery round-trip mismatch:\n got %+v\nwant %+v", got, creds) + } +} + +func TestDecryptWrongSecretFails(t *testing.T) { + creds := DBCredentials{Type: dbclient.SQLite, Path: "/tmp/alice.db"} + blob, err := EncryptDBCredentials("Right#Pass1", "right-recovery", creds) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + + if _, err := DecryptDBCredentials("Wrong#Pass1", blob); err == nil { + t.Fatal("expected error decrypting with wrong password") + } + if _, err := DecryptDBCredentialsWithRecovery("wrong-recovery", blob); err == nil { + t.Fatal("expected error decrypting with wrong recovery key") + } +} diff --git a/internal/features/dbconfig/model.go b/internal/features/dbconfig/model.go new file mode 100644 index 0000000..28d06e8 --- /dev/null +++ b/internal/features/dbconfig/model.go @@ -0,0 +1,47 @@ +package dbconfig + +import ( + dbclient "ayo/internal/clients/db" +) + +// DBCredentials is the plaintext database configuration for one account. It is +// serialized to JSON, dual-encrypted (password-KEK + recovery-KEK) and stored +// in the OS keyring; only the encrypted blob ever persists. The password is +// stored here too (it is needed to open the connection at login) but is never +// exposed to the frontend. +type DBCredentials struct { + Type dbclient.Dialect `json:"Type"` + Path string `json:"Path,omitempty"` + Host string `json:"Host,omitempty"` + Port int `json:"Port,omitempty"` + Database string `json:"Database,omitempty"` + Username string `json:"Username,omitempty"` + Password string `json:"Password,omitempty"` +} + +// ToConfig converts the stored credentials into a client config usable with +// dbclient.NewClient / dbclient.Validate. +func (d DBCredentials) ToConfig() dbclient.Config { + return dbclient.Config{ + Type: d.Type, + Path: d.Path, + Host: d.Host, + Port: d.Port, + Database: d.Database, + Username: d.Username, + Password: d.Password, + } +} + +// FromConfig builds stored credentials from a client config. +func FromConfig(c dbclient.Config) DBCredentials { + return DBCredentials{ + Type: c.Type, + Path: c.Path, + Host: c.Host, + Port: c.Port, + Database: c.Database, + Username: c.Username, + Password: c.Password, + } +} diff --git a/internal/features/dbconfig/repository.go b/internal/features/dbconfig/repository.go new file mode 100644 index 0000000..31b3804 --- /dev/null +++ b/internal/features/dbconfig/repository.go @@ -0,0 +1,79 @@ +package dbconfig + +import ( + "encoding/base64" + "errors" + "fmt" + "strings" + + "ayo/internal/platform/keyring" +) + +// ErrCredentialsNotFound is returned by Load when no database-credentials entry +// exists for the user. It is an internal marker (mapped by the auth service to +// ErrUserNotFound) rather than a user-facing message. +var ErrCredentialsNotFound = errors.New("database credentials not found in keyring") + +// Repository abstracts persistence of the encrypted database-credentials blob +// in the OS keyring. It mirrors the settings feature's keyring repository: the +// blob is base64-encoded and stored under the "ayo" service, keyed by user. +type Repository interface { + // Load returns the encrypted credentials blob, or ErrCredentialsNotFound + // when nothing has been saved yet. + Load(username string) ([]byte, error) + // Save replaces the encrypted credentials blob for the given user. + Save(username string, data []byte) error +} + +type repository struct{} + +// NewRepository returns a ready-to-use keyring repository. +func NewRepository() Repository { + return &repository{} +} + +// keyringUser maps an account username to the keyring entry holding its +// database credentials, keeping it separate from the "ayo" entries used by +// settings. +func keyringUser(username string) string { + return "dbcreds_" + username +} + +func (r *repository) Load(username string) ([]byte, error) { + encoded, err := keyring.Get("ayo", keyringUser(username)) + if err != nil { + if isKeyringNotFound(err) { + return nil, ErrCredentialsNotFound + } + return nil, fmt.Errorf("load database credentials from keyring: %w", err) + } + + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("decode database credentials blob: %w", err) + } + return decoded, nil +} + +func (r *repository) Save(username string, data []byte) error { + encoded := base64.StdEncoding.EncodeToString(data) + if err := keyring.Set("ayo", keyringUser(username), encoded); err != nil { + return fmt.Errorf("save database credentials to keyring: %w", err) + } + return nil +} + +// isKeyringNotFound reports whether a keyring lookup failed because nothing is +// stored for the given user. The not-found marker differs across platforms and +// OS versions, so this matches both the library's sentinel error and the +// platform error text (e.g. macOS `security` prints "could not be found"). +func isKeyringNotFound(err error) bool { + if errors.Is(err, keyring.ErrNotFound) { + return true + } + msg := err.Error() + return strings.Contains(msg, "could not be found") || + strings.Contains(msg, "item not found") || + strings.Contains(msg, "no entry") || + strings.Contains(msg, "not exist") +} diff --git a/internal/features/settings/service.go b/internal/features/settings/service.go index 0e101f3..4457a9b 100644 --- a/internal/features/settings/service.go +++ b/internal/features/settings/service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" + dbclient "ayo/internal/clients/db" "ayo/internal/features/auth" "ayo/internal/platform/dialog" "ayo/internal/shared/crypto" @@ -17,6 +18,13 @@ type SessionProvider interface { RequireSession() (*auth.Session, error) } +// DatabaseConfigProvider exposes the signed-in user's database configuration so +// the read-only Database settings page can display it. It is implemented by the +// auth service and injected here to keep settings decoupled from auth internals. +type DatabaseConfigProvider interface { + DatabaseConfig() (dbclient.Config, error) +} + // ProviderValidator validates a configured storage provider is usable before // settings are saved (e.g. a local folder is creatable or an AWS bucket is // reachable). It is implemented outside this package by the storage client and @@ -25,17 +33,31 @@ type ProviderValidator interface { Validate(key CloudKey) error } +// DatabaseInfo is the sanitized, read-only description of the signed-in user's +// database. It deliberately excludes the database password. +type DatabaseInfo struct { + Type dbclient.Dialect `json:"Type"` + Path string `json:"Path,omitempty"` + Host string `json:"Host,omitempty"` + Port int `json:"Port,omitempty"` + Database string `json:"Database,omitempty"` + Username string `json:"Username,omitempty"` +} + type Service struct { ctx context.Context sessionProvider SessionProvider + dbConfigProvider DatabaseConfigProvider providerValidator ProviderValidator repo Repository validate *validator.Validate } -func NewService(sessionProvider SessionProvider, providerValidator ProviderValidator, repo Repository) *Service { +func NewService(sessionProvider SessionProvider, dbConfigProvider DatabaseConfigProvider, + providerValidator ProviderValidator, repo Repository) *Service { return &Service{ sessionProvider: sessionProvider, + dbConfigProvider: dbConfigProvider, providerValidator: providerValidator, repo: repo, validate: validator.New(), @@ -84,6 +106,23 @@ func (s *Service) GetSettings() (*Settings, error) { return &parsedSettings, nil } +// GetDatabaseInfo returns the signed-in user's database configuration for the +// read-only Database settings page. The password is never included. +func (s *Service) GetDatabaseInfo() (*DatabaseInfo, error) { + config, err := s.dbConfigProvider.DatabaseConfig() + if err != nil { + return nil, err + } + return &DatabaseInfo{ + Type: config.Type, + Path: config.Path, + Host: config.Host, + Port: config.Port, + Database: config.Database, + Username: config.Username, + }, nil +} + // UpdateSettings validates, encrypts and persists the given settings for the // signed-in user. func (s *Service) UpdateSettings(input UpdateSettingsInput) error { diff --git a/internal/features/upload/model.go b/internal/features/upload/model.go index 453b5a5..6e60c8b 100644 --- a/internal/features/upload/model.go +++ b/internal/features/upload/model.go @@ -60,8 +60,7 @@ type Upload struct { // one row of the `chunks` table. ChunkID is the globally unique shard filename // (a UUID); ShardIndex preserves the reconstruction order (data shards 0..D-1, // then parity shards). StorageID identifies the provider the shard was uploaded -// to (e.g. "local_ab12cd34"); it is empty for legacy rows stored under -// data/chunks. +// to (e.g. "local_ab12cd34"). type Chunk struct { ID int64 FileID int64 diff --git a/internal/features/upload/processor.go b/internal/features/upload/processor.go index cb96cc9..df57504 100644 --- a/internal/features/upload/processor.go +++ b/internal/features/upload/processor.go @@ -28,10 +28,6 @@ const ( // encryptedDir is where encrypted files are written. It lives under data/ // which is gitignored runtime data. encryptedDir = "data/encrypted" - // chunksDir is where Reed-Solomon shards are written, one subfolder per - // job. Their reconstruction metadata lives on the uploads table, not in the - // chunk folder. - chunksDir = "data/chunks" // downloadsDir is where reconstructed download jobs are staged until the // user picks a final destination via the native save dialog. Files are // named by job ID and cleaned up on finalize (or swept at startup). @@ -51,8 +47,7 @@ const ( // written to data/encrypted/.enc. // 2. The blob is encoded into data + parity shards (the layout comes from the // user's erasure-coding settings). Each shard is uploaded to a randomly -// chosen configured provider (a local folder or an S3 bucket); when no -// provider is configured it falls back to data/chunks//.bin. +// chosen configured provider (a local folder or an S3 bucket). // 3. An uploads record (carrying the reconstruction metadata) and one chunks // record per shard are persisted, then the job is marked completed. // @@ -82,7 +77,7 @@ type Processor struct { // NewProcessor wires the session provider, settings provider, queue, upload // repository and local filesystem client into a ready-to-use Processor. The // local client backs the app's own runtime files (encrypted staging, downloads) -// and the local/legacy shard paths; S3 clients are created on demand from the +// and local provider shard paths; S3 clients are created on demand from the // configured AWS keys. Workers are spawned by Start. func NewProcessor(sessionProvider SessionProvider, settingsProvider SettingsProvider, queue QueueService, uploadRepository UploadRepository, local *storage.LocalFilesystem) *Processor { @@ -347,9 +342,8 @@ func (p *Processor) processDelete(job *queue.Job) { if got, err := p.settingsProvider.GetSettings(); err == nil { s = got } - legacyDir := filepath.Join(chunksDir, fmt.Sprintf("job_%d", upload.JobID)) for _, chunk := range chunks { - client, key, err := storage.ResolveShard(s.CloudKeys, chunk.StorageID, chunk.ChunkID, legacyDir) + client, key, err := storage.ResolveShard(s.CloudKeys, chunk.StorageID, chunk.ChunkID) if err != nil { slog.Error("delete: resolve shard", "job", id, "chunk", chunk.ChunkID, "error", err) continue @@ -358,9 +352,6 @@ func (p *Processor) processDelete(job *queue.Job) { slog.Error("delete: remove shard", "job", id, "chunk", chunk.ChunkID, "error", err) } } - if err := p.local.RemoveAll(legacyDir); err != nil { - slog.Error("delete: clean legacy shard folder", "job", id, "error", err) - } if err := p.local.Remove(filepath.Join(encryptedDir, fmt.Sprintf("%d.enc", upload.JobID))); err != nil { slog.Error("delete: remove encrypted blob", "job", id, "error", err) } @@ -428,10 +419,9 @@ func (p *Processor) processDownload(job *queue.Job) { return } - legacyDir := filepath.Join(chunksDir, fmt.Sprintf("job_%d", upload.JobID)) shardRefs := make([]shardRef, 0, len(chunks)) for _, chunk := range chunks { - client, key, err := storage.ResolveShard(s.CloudKeys, chunk.StorageID, chunk.ChunkID, legacyDir) + client, key, err := storage.ResolveShard(s.CloudKeys, chunk.StorageID, chunk.ChunkID) if err != nil { slog.Error("download: resolve shard", "job", id, "chunk", chunk.ChunkID, "error", err) _ = p.queue.UpdateStatusAndProgress(id, queue.StatusFailed, 10) @@ -541,9 +531,7 @@ func (p *Processor) processDownload(job *queue.Job) { // [minShardSize, maxShardSize] even for small files. Each shard is opened // through storage.OpenShardWriter, which randomly picks a configured provider // and dispatches to that provider's own write function; the provider's ID is -// recorded on the chunk row so the shard can be read back later. When no -// provider is configured (e.g. Ayo mode) shards fall back to -// data/chunks/job_/ with an empty storage ID. +// recorded on the chunk row so the shard can be read back later. // // Each shard file is named with a globally unique UUID. It updates job progress // from 30% (after encryption) up to 90% as blocks are encoded, and returns the @@ -593,10 +581,9 @@ func (p *Processor) chunk(id int64, s *settings.Settings, encryptedPath string, } }() - legacyDir := filepath.Join(chunksDir, fmt.Sprintf("job_%d", id)) for i := 0; i < cfg.totalShards(); i++ { chunkID := uuid.NewString() + ".bin" - w, storageID, err := storage.OpenShardWriter(s.CloudKeys, chunkID, legacyDir) + w, storageID, err := storage.OpenShardWriter(s.CloudKeys, chunkID) if err != nil { return nil, shardManifest{}, fmt.Errorf("open shard %d: %w", i, err) } diff --git a/internal/features/upload/repository.go b/internal/features/upload/repository.go index f650739..b5c06b7 100644 --- a/internal/features/upload/repository.go +++ b/internal/features/upload/repository.go @@ -5,8 +5,9 @@ import ( "database/sql" "encoding/json" "fmt" + "sync" - "ayo/internal/shared/errors" + dbclient "ayo/internal/clients/db" ) // Repository abstracts persistence for stored files and their shards. Keeping @@ -44,54 +45,107 @@ type Repository interface { } type repository struct { - db *sql.DB + conn *dbclient.Connection + initMu sync.Mutex + initClient *dbclient.Client } -// NewRepository opens the uploads and chunks tables (creating them if needed) -// and returns a ready-to-use repository. -func NewRepository(db *sql.DB) (Repository, error) { - if err := initializeTable(db); err != nil { - return nil, errors.NewInternalServerError("initialize uploads tables", err) +// NewRepository returns a repository bound to the shared connection holder. The +// uploads/chunks tables are created lazily on the active client (see resolve), +// since there is no database connection before a user signs in. +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. +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 &repository{db: db}, nil + return c, nil } // initializeTable idempotently ensures the uploads and chunks tables exist. -// chunks.file_id references uploads.id (via the foreign_keys pragma), 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. +// 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. The DDL branches on the client's dialect (AUTOINCREMENT +// vs IDENTITY, DATETIME vs TIMESTAMP, BIGINT for size columns). // // Migration: adds format_version column if it doesn't exist (for existing DBs). -func initializeTable(db *sql.DB) error { - 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 '[]', - format_version INTEGER NOT NULL DEFAULT 1, - 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, - 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)`, +func initializeTable(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 '[]', + format_version INTEGER NOT NULL DEFAULT 1, + 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, + 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 '[]', + format_version INTEGER NOT NULL DEFAULT 1, + 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, + 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 { @@ -101,11 +155,45 @@ func initializeTable(db *sql.DB) error { } // Migration: add format_version column to existing uploads table if missing. - // Check if the column exists by querying table_info. - var hasFormatVersion bool + hasFormatVersion, err := hasFormatVersionColumn(db) + if err != nil { + return err + } + if !hasFormatVersion { + alter := "ALTER TABLE uploads ADD COLUMN format_version INTEGER NOT NULL DEFAULT 1" + if db.IsPostgres() { + // IF NOT EXISTS guards against races on PostgreSQL. + alter = "ALTER TABLE uploads ADD COLUMN IF NOT EXISTS format_version INTEGER NOT NULL DEFAULT 1" + } + if _, err := db.Exec(alter); err != nil { + return fmt.Errorf("add format_version column: %w", err) + } + } + + return nil +} + +// hasFormatVersionColumn reports whether the uploads table already carries the +// format_version column. SQLite inspects its table_info pragma; PostgreSQL +// queries information_schema. +func hasFormatVersionColumn(db *dbclient.Client) (bool, error) { + if db.IsPostgres() { + query := `SELECT column_name FROM information_schema.columns + WHERE table_name = 'uploads' AND column_name = 'format_version'` + var column string + err := db.QueryRow(query).Scan(&column) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("check format_version column: %w", err) + } + return true, nil + } + rows, err := db.Query("PRAGMA table_info(uploads)") if err != nil { - return fmt.Errorf("check format_version column: %w", err) + return false, fmt.Errorf("check format_version column: %w", err) } defer rows.Close() @@ -117,22 +205,13 @@ func initializeTable(db *sql.DB) error { var dfltValue sql.NullString var pk int if err := rows.Scan(&cid, &name, &typ, ¬Null, &dfltValue, &pk); err != nil { - return fmt.Errorf("scan table_info: %w", err) + return false, fmt.Errorf("scan table_info: %w", err) } if name == "format_version" { - hasFormatVersion = true - break + return true, nil } } - - if !hasFormatVersion { - // Add format_version column with default value 1 for existing rows. - if _, err := db.Exec("ALTER TABLE uploads ADD COLUMN format_version INTEGER NOT NULL DEFAULT 1"); err != nil { - return fmt.Errorf("add format_version column: %w", err) - } - } - - return nil + return false, nil } // CreateUpload inserts a stored-file record and returns it populated with its @@ -160,14 +239,42 @@ func (r *repository) CreateUpload( parity_shards, shard_size, block_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - result, err := r.db.ExecContext(ctx, query, jobID, file, customName, size, string(encodedTags), - formatVersion, manifest.EncryptedSize, manifest.DataShards, manifest.ParityShards, - manifest.ShardSize, manifest.BlockCount) + client, err := r.resolve() + if err != nil { + return nil, err + } + + if client.IsPostgres() { + // PostgreSQL has no INSERT OR IGNORE or LastInsertId; use an upsert that + // does nothing on conflict and return the row ID directly. + query = `INSERT INTO uploads + (job_id, file, custom_name, size, tags, format_version, encrypted_size, data_shards, + parity_shards, shard_size, block_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (job_id) DO NOTHING + RETURNING id` + + var id int64 + err := client.QueryRowContext(ctx, client.Rebind(query), jobID, file, customName, size, + string(encodedTags), formatVersion, manifest.EncryptedSize, manifest.DataShards, + manifest.ParityShards, manifest.ShardSize, manifest.BlockCount).Scan(&id) + if err == sql.ErrNoRows { + return r.getUploadByJob(ctx, jobID) + } + if err != nil { + return nil, fmt.Errorf("failed to create upload: %w", err) + } + return r.getUpload(ctx, id) + } + + result, err := client.ExecContext(ctx, client.Rebind(query), jobID, file, customName, size, + string(encodedTags), formatVersion, manifest.EncryptedSize, manifest.DataShards, + manifest.ParityShards, manifest.ShardSize, manifest.BlockCount) if err != nil { return nil, fmt.Errorf("failed to create upload: %w", err) } - id, err := result.LastInsertId() + id, err := client.LastInsertID(result) if err != nil { return nil, fmt.Errorf("failed to get last insert id: %w", err) } @@ -184,9 +291,14 @@ func (r *repository) getUpload(ctx context.Context, id int64) (*Upload, error) { encrypted_size, data_shards, parity_shards, shard_size, block_count, created_at, updated_at FROM uploads WHERE id = ?` + client, err := r.resolve() + if err != nil { + return nil, err + } + var upload Upload var tags string - err := r.db.QueryRowContext(ctx, query, id).Scan( + err = client.QueryRowContext(ctx, client.Rebind(query), id).Scan( &upload.ID, &upload.JobID, &upload.File, @@ -216,9 +328,14 @@ func (r *repository) getUploadByJob(ctx context.Context, jobID int64) (*Upload, encrypted_size, data_shards, parity_shards, shard_size, block_count, created_at, updated_at FROM uploads WHERE job_id = ?` + client, err := r.resolve() + if err != nil { + return nil, err + } + var upload Upload var tags string - err := r.db.QueryRowContext(ctx, query, jobID).Scan( + err = client.QueryRowContext(ctx, client.Rebind(query), jobID).Scan( &upload.ID, &upload.JobID, &upload.File, @@ -257,7 +374,12 @@ func decodeTags(data string) []string { // CreateChunks inserts one row per shard for the given file in a single // transaction, so a partial failure leaves no dangling chunk rows. func (r *repository) CreateChunks(ctx context.Context, fileID int64, chunks []ChunkInput) error { - tx, err := r.db.BeginTx(ctx, nil) + client, err := r.resolve() + if err != nil { + return err + } + + tx, err := client.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin chunk insert: %w", err) } @@ -267,7 +389,12 @@ func (r *repository) CreateChunks(ctx context.Context, fileID int64, chunks []Ch VALUES (?, ?, ?, ?)` for _, chunk := range chunks { - if _, err := tx.ExecContext(ctx, query, fileID, chunk.ShardIndex, chunk.ChunkID, chunk.StorageID); err != nil { + _, err := tx.ExecContext( + ctx, + client.Rebind(query), + fileID, chunk.ShardIndex, chunk.ChunkID, chunk.StorageID, + ) + if err != nil { return fmt.Errorf("failed to create chunk: %w", err) } } @@ -284,7 +411,12 @@ func (r *repository) GetAll(ctx context.Context) ([]*Upload, error) { encrypted_size, data_shards, parity_shards, shard_size, block_count, created_at, updated_at FROM uploads ORDER BY created_at DESC` - rows, err := r.db.QueryContext(ctx, query) + client, err := r.resolve() + if err != nil { + return nil, err + } + + rows, err := client.QueryContext(ctx, query) if err != nil { return nil, fmt.Errorf("failed to list uploads: %w", err) } @@ -330,8 +462,14 @@ func (r *repository) GetUpload(ctx context.Context, id int64) (*Upload, error) { // no rows yet (or an empty table) report 0. func (r *repository) GetTotalSize(ctx context.Context) (int64, error) { query := `SELECT COALESCE(SUM(size), 0) FROM uploads` + + client, err := r.resolve() + if err != nil { + return 0, err + } + var total int64 - if err := r.db.QueryRowContext(ctx, query).Scan(&total); err != nil { + if err := client.QueryRowContext(ctx, query).Scan(&total); err != nil { return 0, fmt.Errorf("failed to sum upload sizes: %w", err) } return total, nil @@ -341,7 +479,13 @@ func (r *repository) GetTotalSize(ctx context.Context) (int64, error) { // removed by the chunks → uploads foreign key cascade. func (r *repository) DeleteUpload(ctx context.Context, id int64) error { query := `DELETE FROM uploads WHERE id = ?` - if _, err := r.db.ExecContext(ctx, query, id); err != nil { + + client, err := r.resolve() + if err != nil { + return err + } + + if _, err := client.ExecContext(ctx, client.Rebind(query), id); err != nil { return fmt.Errorf("failed to delete upload: %w", err) } return nil @@ -353,7 +497,12 @@ func (r *repository) GetChunks(ctx context.Context, fileID int64) ([]Chunk, erro query := `SELECT id, file_id, shard_index, chunk_id, storage_id, created_at FROM chunks WHERE file_id = ? ORDER BY shard_index ASC` - rows, err := r.db.QueryContext(ctx, query, fileID) + client, err := r.resolve() + if err != nil { + return nil, err + } + + rows, err := client.QueryContext(ctx, client.Rebind(query), fileID) if err != nil { return nil, fmt.Errorf("failed to list chunks: %w", err) } diff --git a/internal/features/upload/service.go b/internal/features/upload/service.go index b31bb4a..d537e5d 100644 --- a/internal/features/upload/service.go +++ b/internal/features/upload/service.go @@ -7,6 +7,7 @@ import ( "path/filepath" "time" + dbclient "ayo/internal/clients/db" "ayo/internal/clients/storage" "ayo/internal/features/auth" "ayo/internal/features/settings" @@ -82,7 +83,8 @@ type Service struct { } func NewService(sessionProvider SessionProvider, settingsProvider SettingsProvider, - queueService QueueService, repo Repository, local *storage.LocalFilesystem) *Service { + queueService QueueService, conn *dbclient.Connection, local *storage.LocalFilesystem) *Service { + repo := NewRepository(conn) return &Service{ sessionProvider: sessionProvider, settingsProvider: settingsProvider, @@ -184,6 +186,15 @@ func (s *Service) EnqueueFiles(input EnqueueFilesInput) ([]EnqueuedJob, error) { return nil, err } + // Uploads require at least one configured storage provider to hold shards. + settings, err := s.settingsProvider.GetSettings() + if err != nil { + return nil, errors.AsInternalServerError("enqueue files: get settings", err) + } + if len(settings.CloudKeys) == 0 { + return nil, errors.ErrNoStorageProvider + } + jobs := make([]EnqueuedJob, 0, len(input.Files)) for _, file := range input.Files { customName := file.CustomName diff --git a/internal/platform/database/database.go b/internal/platform/database/database.go deleted file mode 100644 index b5a6f15..0000000 --- a/internal/platform/database/database.go +++ /dev/null @@ -1,59 +0,0 @@ -// Package database provides the shared SQLite connection for the app. -// -// It belongs to the platform tier: it wraps a third-party driver -// (modernc.org/sqlite, a pure-Go driver with no cgo requirement) so that -// feature packages never deal with driver details. Features create their own -// tables idempotently via their repository's initializeTable - there is no -// central migration tool. -package database - -import ( - "database/sql" - "fmt" - "os" - "path/filepath" - - _ "modernc.org/sqlite" -) - -// NewDatabase opens (creating if needed) a SQLite database at dbPath and -// verifies the connection is live. -// -// dbPath may include directories; any missing parent directories are created -// automatically, so the caller does not need to set them up beforehand. -func NewDatabase(dbPath string) (*sql.DB, error) { - // Ensure parent directory exists - dir := filepath.Dir(dbPath) - if dir != "." && dir != "/" { - if err := os.MkdirAll(dir, 0750); err != nil { - return nil, fmt.Errorf("failed to create database directory: %w", err) - } - } - - // DSN pragmas are applied to every new connection by the driver. WAL mode - // lets readers run concurrently with a single writer (the upload processor - // updates job status in the background while the frontend polls), the busy - // timeout makes writers wait for the lock instead of failing immediately - // with SQLITE_BUSY, and foreign_keys enforcement backs the chunks → uploads - // relationship. - dsn := "file:" + filepath.ToSlash(dbPath) + - "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)" - - db, err := sql.Open("sqlite", dsn) - if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) - } - - // Ping forces the driver to actually connect, surfacing problems like a - // missing/corrupt file at startup instead of on the first query. - if err := db.Ping(); err != nil { - return nil, fmt.Errorf("failed to ping database: %w", err) - } - - // Table creation is intentionally NOT done here. Each feature repository - // owns its own schema via initializeTable, which keeps feature migrations - // close to the feature code rather than centralized (see e.g. - // internal/features/auth/repository.go). - - return db, nil -} diff --git a/internal/platform/queue/repository.go b/internal/platform/queue/repository.go index 1e768b9..b2aba22 100644 --- a/internal/platform/queue/repository.go +++ b/internal/platform/queue/repository.go @@ -6,7 +6,9 @@ import ( "encoding/json" stderrors "errors" "fmt" + "sync" + dbclient "ayo/internal/clients/db" "ayo/internal/shared/errors" ) @@ -35,37 +37,63 @@ type Repository interface { } type repository struct { - db *sql.DB + conn *dbclient.Connection + initMu sync.Mutex + initClient *dbclient.Client } -// NewRepository opens the queue table (creating it if needed) and returns a -// ready-to-use repository. -func NewRepository(db *sql.DB) (Repository, error) { - if err := initializeTable(db); err != nil { - return nil, errors.NewInternalServerError("initialize queue table", err) +// NewRepository returns a repository bound to the shared connection holder. The +// queue table is created lazily on the active client (see resolve), since there +// is no database connection before a user signs in. +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. +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 &repository{db: db}, nil + return c, nil } // initializeTable idempotently ensures the queue table exists and has all // expected columns. Type is the operation kind (upload/download/delete), stored // as TEXT and constrained to the enum values; status and timestamps are stored -// as TEXT/DATETIME, progress as an INTEGER (0-100), and tags as a JSON-encoded -// TEXT array. -func initializeTable(db *sql.DB) error { +// as TEXT/DATETIME (TIMESTAMP on PostgreSQL), progress as an INTEGER (0-100), +// and tags as a JSON-encoded TEXT array. The id column and timestamp type +// branch on the client's dialect. +func initializeTable(db *dbclient.Client) error { + idColumn := "id INTEGER PRIMARY KEY AUTOINCREMENT" + timestampType := "DATETIME" + if db.IsPostgres() { + idColumn = "id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY" + timestampType = "TIMESTAMP" + } + query := `CREATE TABLE IF NOT EXISTS queue ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + ` + idColumn + `, type TEXT NOT NULL DEFAULT 'upload' CHECK (type IN ('upload', 'download', 'delete')), - file_id INTEGER NOT NULL DEFAULT 0, + file_id BIGINT NOT NULL DEFAULT 0, file TEXT NOT NULL, custom_name TEXT NOT NULL DEFAULT '', path TEXT NOT NULL, - size INTEGER NOT NULL, + size BIGINT NOT NULL, status TEXT NOT NULL, progress INTEGER NOT NULL DEFAULT 0, tags TEXT NOT NULL DEFAULT '[]', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + created_at ` + timestampType + ` NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at ` + timestampType + ` NOT NULL DEFAULT CURRENT_TIMESTAMP )` if _, err := db.Exec(query); err != nil { @@ -85,9 +113,7 @@ func (r *repository) Add(ctx context.Context, job *Job) (*Job, error) { query := `INSERT INTO queue (type, file_id, file, custom_name, path, size, status, progress, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - result, err := r.db.ExecContext( - ctx, - query, + args := []any{ job.Type, job.FileID, job.File, @@ -97,12 +123,29 @@ func (r *repository) Add(ctx context.Context, job *Job) (*Job, error) { job.Status, job.Progress, string(tags), - ) + } + + client, err := r.resolve() + if err != nil { + return nil, err + } + + if client.IsPostgres() { + // PostgreSQL has no LastInsertId; fetch the assigned ID via RETURNING. + var id int64 + err := client.QueryRowContext(ctx, client.Rebind(query)+" RETURNING id", args...).Scan(&id) + if err != nil { + return nil, fmt.Errorf("failed to add job: %w", err) + } + return r.Get(ctx, id) + } + + result, err := client.ExecContext(ctx, client.Rebind(query), args...) if err != nil { return nil, fmt.Errorf("failed to add job: %w", err) } - id, err := result.LastInsertId() + id, err := client.LastInsertID(result) if err != nil { return nil, fmt.Errorf("failed to get last insert id: %w", err) } @@ -115,7 +158,12 @@ func (r *repository) Get(ctx context.Context, id int64) (*Job, error) { query := `SELECT id, type, file_id, file, custom_name, path, size, status, progress, tags, created_at, updated_at FROM queue WHERE id = ?` - job, err := scanJob(r.db.QueryRowContext(ctx, query, id)) + client, err := r.resolve() + if err != nil { + return nil, err + } + + job, err := scanJob(client.QueryRowContext(ctx, client.Rebind(query), id)) if err != nil { if stderrors.Is(err, sql.ErrNoRows) { return nil, errors.ErrJobNotFound @@ -131,7 +179,12 @@ func (r *repository) GetAll(ctx context.Context) ([]*Job, error) { query := `SELECT id, type, file_id, file, custom_name, path, size, status, progress, tags, created_at, updated_at FROM queue ORDER BY id ASC` - rows, err := r.db.QueryContext(ctx, query) + client, err := r.resolve() + if err != nil { + return nil, err + } + + rows, err := client.QueryContext(ctx, query) if err != nil { return nil, fmt.Errorf("failed to list jobs: %w", err) } @@ -196,7 +249,12 @@ func decodeTags(data string) []string { func (r *repository) Update(ctx context.Context, id int64, status string, progress int) error { query := `UPDATE queue SET status = ?, progress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?` - result, err := r.db.ExecContext(ctx, query, status, progress, id) + client, err := r.resolve() + if err != nil { + return err + } + + result, err := client.ExecContext(ctx, client.Rebind(query), status, progress, id) if err != nil { return fmt.Errorf("failed to update job: %w", err) } @@ -219,7 +277,12 @@ func (r *repository) GetIncompleteByType(ctx context.Context, jobType string) ([ created_at, updated_at FROM queue WHERE type = ? AND status IN (?, ?) ORDER BY id ASC` - rows, err := r.db.QueryContext(ctx, query, jobType, StatusPending, StatusProcessing) + client, err := r.resolve() + if err != nil { + return nil, err + } + + rows, err := client.QueryContext(ctx, client.Rebind(query), jobType, StatusPending, StatusProcessing) if err != nil { return nil, fmt.Errorf("failed to list incomplete jobs: %w", err) } @@ -245,7 +308,12 @@ func (r *repository) GetIncompleteByType(ctx context.Context, jobType string) ([ func (r *repository) Delete(ctx context.Context, id int64) error { query := `DELETE FROM queue WHERE id = ?` - result, err := r.db.ExecContext(ctx, query, id) + client, err := r.resolve() + if err != nil { + return err + } + + result, err := client.ExecContext(ctx, client.Rebind(query), id) if err != nil { return fmt.Errorf("failed to delete job: %w", err) } diff --git a/internal/platform/queue/service.go b/internal/platform/queue/service.go index f704fd0..7c3fc45 100644 --- a/internal/platform/queue/service.go +++ b/internal/platform/queue/service.go @@ -4,6 +4,7 @@ import ( "context" stderrors "errors" + dbclient "ayo/internal/clients/db" "ayo/internal/shared/errors" ) @@ -17,8 +18,11 @@ type Service struct { repo Repository } -func NewService(repo Repository) *Service { - return &Service{repo: repo} +// NewService wires the shared connection holder into a ready-to-use queue +// service. The repository resolves the signed-in user's database per operation, +// so the same service serves whichever user is active. +func NewService(conn *dbclient.Connection) *Service { + return &Service{repo: NewRepository(conn)} } // AddInput describes a file to enqueue. File and Path must be present. Type is diff --git a/internal/shared/errors/errors.go b/internal/shared/errors/errors.go index 0aa22dd..637a83a 100644 --- a/internal/shared/errors/errors.go +++ b/internal/shared/errors/errors.go @@ -58,6 +58,18 @@ var ( ErrJobNotFound = errors.New( "the file entry you are looking for no longer exists", ) + + // ErrDatabaseUnavailable means the user's database could not be reached + // (e.g. the PostgreSQL server is down or the SQLite file is inaccessible). + ErrDatabaseUnavailable = errors.New( + "unable to connect to your database. Please check that the database is accessible and try again", + ) + + // ErrNoStorageProvider means the signed-in user has no storage provider + // configured. Uploads require at least one provider to hold shards. + ErrNoStorageProvider = errors.New( + "no storage provider is configured. Add a provider in Settings > Storage before uploading", + ) ) // InternalServerError is the typed representation of an unexpected failure diff --git a/internal/shared/paths/paths.go b/internal/shared/paths/paths.go new file mode 100644 index 0000000..c019eee --- /dev/null +++ b/internal/shared/paths/paths.go @@ -0,0 +1,24 @@ +// Package paths provides OS-specific filesystem paths used by the app, such as +// the per-user app data directory where SQLite databases are stored. +package paths + +import ( + "os" + "path/filepath" +) + +// GetAppDataDir returns the OS-appropriate app data directory for ayo: +// +// macOS: ~/Library/Application Support/ayo +// Linux: ~/.config/ayo +// Windows: %APPDATA%\ayo +// +// It is derived from os.UserConfigDir plus the "/ayo" app segment. The +// directory is not created here; callers create it when needed. +func GetAppDataDir() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(configDir, "ayo"), nil +} diff --git a/main.go b/main.go index fcd7e9c..6e6db43 100644 --- a/main.go +++ b/main.go @@ -4,12 +4,13 @@ import ( "context" "fmt" + dbclient "ayo/internal/clients/db" "ayo/internal/clients/storage" "ayo/internal/features/auth" + "ayo/internal/features/dbconfig" "ayo/internal/features/recovery" "ayo/internal/features/settings" "ayo/internal/features/upload" - "ayo/internal/platform/database" "ayo/internal/platform/queue" "github.com/wailsapp/wails/v2" @@ -53,22 +54,20 @@ func main() { // Create an instance of the app structure app := NewApp() - // Open the SQLite database (data/ayo.db). For development simplicity the - // path is relative to the current working directory; data/ is gitignored. - db, err := database.NewDatabase("data/ayo.db") - if err != nil { - panic(err) - } + // There is no global database: each account has its own database (SQLite + // file or PostgreSQL server), stored encrypted in the OS keyring and opened + // by the auth service on login. The shared connection holder lets the + // queue/upload repositories serve whichever user is currently signed in. + conn := dbclient.NewConnection() // Wire up the internal services. The auth service is the keystone: it owns - // the in-memory session and master key, and is injected into the settings - // service (which needs the session to gate access and the master key to - // encrypt/decrypt stored settings). - authRepository, err := auth.NewRepository(db) - if err != nil { - panic(err) - } - authService := auth.NewService(authRepository) + // the in-memory session, the master key and the active database connection, + // and is injected into the settings service (which needs the session to + // gate access and the master key to encrypt/decrypt stored settings). + // Database credentials are persisted in the OS keyring through the dbconfig + // feature. + dbconfigRepository := dbconfig.NewRepository() + authService := auth.NewService(conn, dbconfigRepository) // Recovery service: native save dialogs for downloading the recovery key. recoveryService := recovery.NewService() @@ -77,14 +76,11 @@ func main() { // with the session master key. Provider configs are validated through the // storage package before saving. settingsRepository := settings.NewRepository() - settingsService := settings.NewService(authService, storageValidator{}, settingsRepository) + settingsService := settings.NewService(authService, authService, storageValidator{}, settingsRepository) // Queue service: persistent SQLite-backed job queue shared across features. - queueRepository, err := queue.NewRepository(db) - if err != nil { - panic(err) - } - queueService := queue.NewService(queueRepository) + // It resolves the signed-in user's database connection per operation. + queueService := queue.NewService(conn) // Storage client: the local filesystem backend the upload feature reads and // writes its own runtime files (encrypted staging, downloads) and local @@ -96,18 +92,15 @@ func main() { // Upload service: native file selection + enqueues one job per uploaded // file into the queue. The processor encrypts each file, splits it into // Reed-Solomon shards using the erasure-coding settings, and persists the - // stored-file record and its shards to the uploads/chunks tables. - uploadRepository, err := upload.NewRepository(db) - if err != nil { - panic(err) - } - uploadService := upload.NewService(authService, settingsService, queueService, uploadRepository, fileClient) + // stored-file record and its shards to the uploads/chunks tables of the + // signed-in user's database. + uploadService := upload.NewService(authService, settingsService, queueService, conn, fileClient) // Create application with options. Anything passed to Bind is exposed to // the frontend as generated JavaScript bindings under // frontend/wailsjs/go/, so changing a bound method requires a // wails dev / wails build to regenerate them. - err = wails.Run(&options.App{ + err := wails.Run(&options.App{ Title: "ayo", Width: 1100, Height: 768,