mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Three things kept the sql tier from being the tier it is described as. Settings encryption refused to boot. The ciphertext is base64 and the column is jsonb, so Postgres rejected the insert while parsing it as JSON. It is marshalled as a JSON string now and unwrapped on read. This changes the stored shape: a legacy row is read as-is and rewritten on the first settings write, after which an older binary refuses to start rather than misreading it. Forward-only, and loud in the one direction that is not. Every schema change caused a burst of failures. A pooled connection holds a prepared plan whose result type the change invalidates, so the next read on that connection fails once. Reads across twenty add-a-field rounds went from 83 of 160 to 160 of 160. The exec mode is describe_exec, which pgx documents as the one that stays safe when the schema changes underneath it; it costs a parse per execution, worst measured on a local socket and pipelined into the same round trip over a real network. The write pool was capped at one connection on every engine. That is SQLite's single-writer rule and Postgres does not need it. The cap is asked of the connection's own driver now, so the aux database answers for itself. Lifting it makes lock-order deadlocks reachable in one process, where a single connection had been serialising them away. Retrying a serialization failure is what turns concurrent writes from possible into correct. Co-authored-by: Hanzo Dev <[email protected]>
147 lines
3.8 KiB
Go
147 lines
3.8 KiB
Go
package core
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/hanzoai/base/tools/security"
|
|
"github.com/hanzoai/base/tools/types"
|
|
)
|
|
|
|
type Param struct {
|
|
BaseModel
|
|
|
|
Created types.DateTime `db:"created" json:"created"`
|
|
Updated types.DateTime `db:"updated" json:"updated"`
|
|
Value types.JSONRaw `db:"value" json:"value"`
|
|
}
|
|
|
|
func (m *Param) TableName() string {
|
|
return paramsTable
|
|
}
|
|
|
|
// ReloadSettings initializes and reloads the stored application settings.
|
|
//
|
|
// If no settings were stored it will persist the current app ones.
|
|
func (app *BaseApp) ReloadSettings() error {
|
|
param := &Param{}
|
|
err := app.ModelQuery(param).Model(paramsKeySettings, param)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
|
|
// no settings were previously stored -> save
|
|
// (ReloadSettings() will be invoked again by a system hook after successful save)
|
|
if param.Id == "" {
|
|
// force insert in case the param entry was deleted manually after application start
|
|
app.Settings().MarkAsNew()
|
|
return app.Save(app.Settings())
|
|
}
|
|
|
|
event := new(SettingsReloadEvent)
|
|
event.App = app
|
|
|
|
return app.OnSettingsReload().Trigger(event, func(e *SettingsReloadEvent) error {
|
|
return e.App.Settings().loadParam(e.App, param)
|
|
})
|
|
}
|
|
|
|
// loadParam loads the settings from the stored param into the app ones.
|
|
//
|
|
// @todo note that the encryption may get removed in the future since it doesn't
|
|
// really accomplish much and it might be better to find a way to encrypt the backups
|
|
// or implement support for resolving env variables.
|
|
func (s *Settings) loadParam(app App, param *Param) error {
|
|
// try first without decryption
|
|
s.mu.Lock()
|
|
plainDecodeErr := json.Unmarshal(param.Value, s)
|
|
s.mu.Unlock()
|
|
|
|
// failed, try to decrypt
|
|
if plainDecodeErr != nil {
|
|
encryptionKey := os.Getenv(app.EncryptionEnv())
|
|
|
|
// load without decryption has failed and there is no encryption key to use for decrypt
|
|
if encryptionKey == "" {
|
|
return fmt.Errorf("invalid settings db data or missing encryption key %q", app.EncryptionEnv())
|
|
}
|
|
|
|
// decrypt
|
|
decrypted, decryptErr := security.Decrypt(cipherText(param.Value), encryptionKey)
|
|
if decryptErr != nil {
|
|
return decryptErr
|
|
}
|
|
|
|
// decode again
|
|
s.mu.Lock()
|
|
decryptedDecodeErr := json.Unmarshal(decrypted, s)
|
|
s.mu.Unlock()
|
|
if decryptedDecodeErr != nil {
|
|
return decryptedDecodeErr
|
|
}
|
|
}
|
|
|
|
if err := s.PostScan(); err != nil {
|
|
return err
|
|
}
|
|
s.applyStorageEnv()
|
|
return nil
|
|
}
|
|
|
|
// cipherText reads the encrypted settings out of a stored param value, which
|
|
// carries them as a JSON string. A value that is not one is the ciphertext.
|
|
func cipherText(raw []byte) string {
|
|
var quoted string
|
|
if err := json.Unmarshal(raw, "ed); err == nil {
|
|
return quoted
|
|
}
|
|
|
|
return string(raw)
|
|
}
|
|
|
|
// applyStorageEnv lets deployment env drive the S3 blob backend so the single
|
|
// selection point (BaseApp.NewFilesystem) stays the one switch — env supplies
|
|
// the value the same way DATA_DIR feeds the data dir. No-op unless S3_ENABLED,
|
|
// so local-disk dev is unchanged. Mirrors the ecosystem S3_* contract.
|
|
func (s *Settings) applyStorageEnv() {
|
|
if strings.ToLower(os.Getenv("S3_ENABLED")) != "true" {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.S3.Enabled = true
|
|
if v := os.Getenv("S3_ENDPOINT"); v != "" {
|
|
s.S3.Endpoint = v
|
|
}
|
|
if v := firstEnv("S3_BUCKET_NAME", "S3_BUCKET"); v != "" {
|
|
s.S3.Bucket = v
|
|
}
|
|
if v := os.Getenv("S3_REGION"); v != "" {
|
|
s.S3.Region = v
|
|
}
|
|
if v := os.Getenv("S3_ACCESS_KEY"); v != "" {
|
|
s.S3.AccessKey = v
|
|
}
|
|
if v := os.Getenv("S3_SECRET_KEY"); v != "" {
|
|
s.S3.Secret = v
|
|
}
|
|
if v := os.Getenv("S3_FORCE_PATH_STYLE"); v != "" {
|
|
s.S3.ForcePathStyle, _ = strconv.ParseBool(v)
|
|
}
|
|
}
|
|
|
|
// firstEnv returns the first non-empty environment variable among names.
|
|
func firstEnv(names ...string) string {
|
|
for _, n := range names {
|
|
if v := os.Getenv(n); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|