Add native NixOS deployment and UI-managed provider credentials
This commit is contained in:
+120
-39
@@ -1,10 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -30,36 +32,39 @@ type Status struct {
|
||||
AIConfigured bool `json:"ai_configured"`
|
||||
}
|
||||
type State struct {
|
||||
Data domain.Dataset `json:"data"`
|
||||
Revision string `json:"revision"`
|
||||
Status Status `json:"status"`
|
||||
Settings Settings `json:"settings"`
|
||||
Sessions []banking.Session `json:"sessions"`
|
||||
CallbackURL string `json:"callback_url"`
|
||||
Connections []Connection `json:"connections"`
|
||||
Data domain.Dataset `json:"data"`
|
||||
Revision string `json:"revision"`
|
||||
Status Status `json:"status"`
|
||||
Settings Settings `json:"settings"`
|
||||
Sessions []banking.Session `json:"sessions"`
|
||||
CallbackURL string `json:"callback_url"`
|
||||
BankingAppID string `json:"banking_app_id"`
|
||||
Connections []Connection `json:"connections"`
|
||||
}
|
||||
type operational struct {
|
||||
Sessions []banking.Session `json:"sessions"`
|
||||
LastSync string `json:"last_sync"`
|
||||
SyncError string `json:"sync_error"`
|
||||
Consents map[string]Consent `json:"consents"`
|
||||
AccountSync map[string]string `json:"account_sync"`
|
||||
Sessions []banking.Session `json:"sessions"`
|
||||
LastSync string `json:"last_sync"`
|
||||
SyncError string `json:"sync_error"`
|
||||
Consents map[string]Consent `json:"consents"`
|
||||
AccountSync map[string]string `json:"account_sync"`
|
||||
BankingScope string `json:"banking_scope"`
|
||||
}
|
||||
type App struct {
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
journal *journal.Store
|
||||
index *analytics.Store
|
||||
indexed string
|
||||
indexError string
|
||||
settings Settings
|
||||
ops operational
|
||||
bank banking.Provider
|
||||
classifier classification.Client
|
||||
previews map[string]Preview
|
||||
authStates map[string]authorization
|
||||
callbackURL string
|
||||
syncRequested chan struct{}
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
journal *journal.Store
|
||||
index *analytics.Store
|
||||
indexed string
|
||||
indexError string
|
||||
settings Settings
|
||||
ops operational
|
||||
bank banking.Provider
|
||||
classifier classification.Client
|
||||
previews map[string]Preview
|
||||
authStates map[string]authorization
|
||||
callbackURL string
|
||||
bankingSettings bankingSettings
|
||||
syncRequested chan struct{}
|
||||
}
|
||||
|
||||
func Open(dir string) (*App, error) {
|
||||
@@ -115,17 +120,13 @@ func Open(dir string) (*App, error) {
|
||||
if a.ops.AccountSync == nil {
|
||||
a.ops.AccountSync = make(map[string]string)
|
||||
}
|
||||
a.classifier = classification.Client{APIKey: os.Getenv("OPENROUTER_API_KEY"), Model: a.settings.Model, IncludeAmount: a.settings.IncludeAmount}
|
||||
appID, key, redirect := os.Getenv("ENABLEBANKING_APP_ID"), os.Getenv("ENABLEBANKING_KEY_FILE"), os.Getenv("ENABLEBANKING_REDIRECT_URL")
|
||||
a.callbackURL = redirect
|
||||
if appID != "" || key != "" || redirect != "" {
|
||||
if appID == "" || key == "" || redirect == "" {
|
||||
return fail(errors.New("Enable Banking requires APP_ID, KEY_FILE and REDIRECT_URL environment variables"))
|
||||
}
|
||||
a.bank, err = banking.NewEnableBanking(appID, key, redirect)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
apiKey, err := loadOpenRouterKey(filepath.Join(dir, "state", "openrouter.json"))
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
a.classifier = classification.Client{APIKey: apiKey, Model: a.settings.Model, IncludeAmount: a.settings.IncludeAmount}
|
||||
if err = a.loadBankingSettings(); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
a.index, err = analytics.Open(filepath.Join(dir, "cache", "finance.duckdb"))
|
||||
if err != nil {
|
||||
@@ -155,7 +156,7 @@ func (a *App) snapshot(ctx context.Context) (State, error) {
|
||||
a.indexError = ""
|
||||
}
|
||||
}
|
||||
return State{Data: d, Revision: rev, Settings: a.settings, Sessions: copySessions(a.ops.Sessions), CallbackURL: a.callbackURL, Connections: a.connections(d), Status: Status{SyncError: a.ops.SyncError, LastSync: a.ops.LastSync, IndexError: a.indexError, BankingConfigured: a.bank != nil, AIConfigured: a.classifier.APIKey != ""}}, nil
|
||||
return State{Data: d, Revision: rev, Settings: a.settings, Sessions: copySessions(a.ops.Sessions), CallbackURL: a.callbackURL, BankingAppID: a.bankingSettings.AppID, Connections: a.connections(d), Status: Status{SyncError: a.ops.SyncError, LastSync: a.ops.LastSync, IndexError: a.indexError, BankingConfigured: a.bank != nil, AIConfigured: a.classifier.APIKey != ""}}, nil
|
||||
}
|
||||
func (a *App) Snapshot(ctx context.Context) (State, error) {
|
||||
a.mu.Lock()
|
||||
@@ -237,6 +238,86 @@ func (a *App) saveOps() error {
|
||||
}
|
||||
return atomicFile(filepath.Join(a.dir, "state", "sync-state.json"), append(b, '\n'))
|
||||
}
|
||||
|
||||
func normalizeOpenRouterKey(key string) (string, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
const invalid = "OpenRouter API key must be at most 4096 bytes and contain only non-whitespace ASCII characters"
|
||||
if len(key) > 4096 {
|
||||
return "", errors.New(invalid)
|
||||
}
|
||||
for i := range len(key) {
|
||||
if key[i] < 0x21 || key[i] > 0x7e {
|
||||
return "", errors.New(invalid)
|
||||
}
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func loadOpenRouterKey(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
return normalizeOpenRouterKey(os.Getenv("OPENROUTER_API_KEY"))
|
||||
}
|
||||
if err != nil {
|
||||
return "", errors.New("cannot read saved OpenRouter credential")
|
||||
}
|
||||
defer f.Close()
|
||||
// Bound encoded storage too, allowing JSON escapes for a maximum-size key.
|
||||
b, err := io.ReadAll(io.LimitReader(f, 32*1024+1))
|
||||
if err != nil {
|
||||
return "", errors.New("cannot read saved OpenRouter credential")
|
||||
}
|
||||
invalid := errors.New("invalid saved OpenRouter credential")
|
||||
if len(b) > 32*1024 {
|
||||
return "", invalid
|
||||
}
|
||||
// Require exactly one case-sensitive string field, rejecting duplicates,
|
||||
// unknown fields, null, and trailing JSON rather than silently disabling AI.
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
if token, err := dec.Token(); err != nil || token != json.Delim('{') {
|
||||
return "", invalid
|
||||
}
|
||||
if token, err := dec.Token(); err != nil || token != "api_key" {
|
||||
return "", invalid
|
||||
}
|
||||
token, err := dec.Token()
|
||||
key, ok := token.(string)
|
||||
if err != nil || !ok {
|
||||
return "", invalid
|
||||
}
|
||||
if token, err := dec.Token(); err != nil || token != json.Delim('}') {
|
||||
return "", invalid
|
||||
}
|
||||
if _, err := dec.Token(); err != io.EOF {
|
||||
return "", invalid
|
||||
}
|
||||
key, err = normalizeOpenRouterKey(key)
|
||||
if err != nil {
|
||||
return "", invalid
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (a *App) SaveOpenRouterKey(ctx context.Context, key string) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
key, err := normalizeOpenRouterKey(key)
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
b, err := json.Marshal(struct {
|
||||
APIKey string `json:"api_key"`
|
||||
}{APIKey: key})
|
||||
if err != nil {
|
||||
return State{}, errors.New("cannot encode OpenRouter credential")
|
||||
}
|
||||
if err := atomicFile(filepath.Join(a.dir, "state", "openrouter.json"), append(b, '\n')); err != nil {
|
||||
return State{}, errors.New("cannot save OpenRouter credential")
|
||||
}
|
||||
a.classifier.APIKey = key
|
||||
return a.snapshot(ctx)
|
||||
}
|
||||
|
||||
func (a *App) SaveSettings(ctx context.Context, s Settings) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
@@ -244,7 +325,7 @@ func (a *App) SaveSettings(ctx context.Context, s Settings) (State, error) {
|
||||
if len(s.Model) > 200 {
|
||||
return State{}, errors.New("model name is too long")
|
||||
}
|
||||
b := []byte("# Secrets belong in environment variables, never this file.\nclassification_model = " + strconv.Quote(s.Model) + "\ninclude_amount = " + strconv.FormatBool(s.IncludeAmount) + "\n")
|
||||
b := []byte("# Preferences only. Manage secrets in Settings or environment variables, never this file.\nclassification_model = " + strconv.Quote(s.Model) + "\ninclude_amount = " + strconv.FormatBool(s.IncludeAmount) + "\n")
|
||||
if err := atomicFile(filepath.Join(a.dir, "config.toml"), b); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user