182 lines
4.9 KiB
Go
182 lines
4.9 KiB
Go
package config
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Smail/internal/configstate"
|
|
"git.pcux.cn/xuans/Smail/utils/credential"
|
|
"git.pcux.cn/xuans/Smail/utils/runtimepath"
|
|
_ "github.com/lib/pq"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
var ErrStateDatabaseUnavailable = errors.New("配置历史数据库不可用,请检查所选数据库连接;不会回退到独立 SQLite 数据库")
|
|
|
|
type stateDatabase struct {
|
|
*sql.DB
|
|
driver string
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func (d *stateDatabase) Close() error {
|
|
d.cancel()
|
|
return d.DB.Close()
|
|
}
|
|
|
|
func (d *stateDatabase) Begin() (*sql.Tx, error) {
|
|
return d.DB.BeginTx(d.ctx, nil)
|
|
}
|
|
|
|
func (d *stateDatabase) bind(query string) string {
|
|
if d.driver != "postgres" {
|
|
return query
|
|
}
|
|
var result strings.Builder
|
|
index := 0
|
|
for _, value := range query {
|
|
if value == '?' {
|
|
index++
|
|
fmt.Fprintf(&result, "$%d", index)
|
|
} else {
|
|
result.WriteRune(value)
|
|
}
|
|
}
|
|
return result.String()
|
|
}
|
|
|
|
func (d *stateDatabase) Exec(query string, args ...any) (sql.Result, error) {
|
|
ctx, cancel := context.WithTimeout(d.ctx, 10*time.Second)
|
|
defer cancel()
|
|
return d.DB.ExecContext(ctx, d.bind(query), args...)
|
|
}
|
|
|
|
func (d *stateDatabase) Query(query string, args ...any) (*sql.Rows, error) {
|
|
return d.DB.QueryContext(d.ctx, d.bind(query), args...)
|
|
}
|
|
|
|
func (d *stateDatabase) QueryRow(query string, args ...any) *sql.Row {
|
|
return d.DB.QueryRowContext(d.ctx, d.bind(query), args...)
|
|
}
|
|
|
|
func (d *stateDatabase) upsert() string {
|
|
query := "INSERT INTO config_records(kind,id,payload,digest) VALUES(?,?,?,?) "
|
|
if d.driver == "mysql" {
|
|
return query + "ON DUPLICATE KEY UPDATE payload=VALUES(payload),digest=VALUES(digest)"
|
|
}
|
|
return query + "ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,digest=excluded.digest"
|
|
}
|
|
|
|
func (s *TransactionStore) stateDatabaseConfig() (DatabaseConfig, error) {
|
|
if s.database != nil {
|
|
return *s.database, ValidateDatabaseConfig(*s.database)
|
|
}
|
|
directory, err := secureManagerConfigDirectory(s.root)
|
|
if err != nil {
|
|
return DatabaseConfig{}, err
|
|
}
|
|
data, err := readManagerConfigFile(directory, "config.json")
|
|
if err != nil {
|
|
return DatabaseConfig{}, err
|
|
}
|
|
var root RootConfig
|
|
if err := ParseStrict(data, &root); err != nil {
|
|
return DatabaseConfig{}, err
|
|
}
|
|
database := NormalizeDatabaseConfig(root.Database)
|
|
return database, ValidateDatabaseConfig(database)
|
|
}
|
|
|
|
func (s *TransactionStore) openState() (*stateDatabase, error) {
|
|
var driver, dsn string
|
|
if s.legacyState {
|
|
driver = "sqlite"
|
|
dsn = filepath.Join(s.root, "data", "smail-state.db")
|
|
} else {
|
|
database, err := s.stateDatabaseConfig()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
vault, err := credential.NewVault(s.root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
driver, dsn, err = compatibilityDatabaseAt(s.root, database, vault)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
filename := ""
|
|
if driver == "sqlite" {
|
|
if _, err := runtimepath.Directory(s.root); err != nil {
|
|
return nil, err
|
|
}
|
|
filename = dsn
|
|
relative, err := filepath.Rel(filepath.Join(s.root, "data"), filepath.Dir(filename))
|
|
if err != nil || relative != "." && !filepath.IsLocal(relative) {
|
|
return nil, ErrManagerStorage
|
|
}
|
|
parent := filepath.Join(s.root, "data")
|
|
if relative != "." {
|
|
for _, part := range strings.Split(filepath.ToSlash(relative), "/") {
|
|
parent, err = runtimepath.SecureDirectory(parent, part)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
info, statErr := os.Lstat(filename)
|
|
if statErr == nil && !info.Mode().IsRegular() {
|
|
return nil, ErrManagerStorage
|
|
}
|
|
if statErr != nil && (!os.IsNotExist(statErr) || s.legacyState) {
|
|
return nil, statErr
|
|
}
|
|
path := filepath.ToSlash(filename)
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = "/" + path
|
|
}
|
|
uri := url.URL{Scheme: "file", Path: path}
|
|
query := url.Values{"_pragma": {"busy_timeout(5000)", "synchronous(FULL)"}}
|
|
if s.legacyState {
|
|
query.Set("mode", "rw")
|
|
}
|
|
uri.RawQuery = query.Encode()
|
|
dsn = uri.String()
|
|
}
|
|
connection, err := sql.Open(driver, dsn)
|
|
if err != nil {
|
|
return nil, ErrStateDatabaseUnavailable
|
|
}
|
|
operationContext, operationCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
database := &stateDatabase{DB: connection, driver: driver, ctx: operationContext, cancel: operationCancel}
|
|
database.SetMaxOpenConns(1)
|
|
database.SetMaxIdleConns(1)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := database.PingContext(ctx); err != nil {
|
|
closeConfigResource(database)
|
|
return nil, ErrStateDatabaseUnavailable
|
|
}
|
|
if !s.legacyState {
|
|
if _, err := database.Exec(configstate.Schema(driver)); err != nil {
|
|
closeConfigResource(database)
|
|
return nil, ErrStateDatabaseUnavailable
|
|
}
|
|
}
|
|
if filename != "" {
|
|
if err := os.Chmod(filename, 0600); err != nil {
|
|
return nil, errors.Join(err, database.Close())
|
|
}
|
|
}
|
|
return database, nil
|
|
}
|