feat: optional MySQL/MariaDB database driver for deployments that cannot use local SQLite
This commit is contained in:
1 parent
81e299f09a
commit
d752a61e52
35 files changed
+2896
-76
No files matched your search
@@ -0,0 +1,122 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DatabaseDriverSQLite = "sqlite"
|
||||
DatabaseDriverMySQL = "mysql"
|
||||
|
||||
DefaultDatabaseDriver = DatabaseDriverSQLite
|
||||
DefaultDatabasePort = "3306"
|
||||
DefaultDatabaseTLS = "false"
|
||||
DefaultMaxOpenConns = "10"
|
||||
DefaultMaxIdleConns = "5"
|
||||
DefaultConnMaxLifetimeSeconds = "300"
|
||||
EnvDatabaseDriver = "DB_DRIVER"
|
||||
envDatabaseHost = "DB_HOST"
|
||||
envDatabasePort = "DB_PORT"
|
||||
envDatabaseUser = "DB_USER"
|
||||
envDatabasePassword = "DB_PASSWORD"
|
||||
envDatabaseName = "DB_NAME"
|
||||
envDatabaseTLS = "DB_TLS"
|
||||
envDatabaseMaxOpenConns = "DB_MAX_OPEN_CONNS"
|
||||
envDatabaseMaxIdleConns = "DB_MAX_IDLE_CONNS"
|
||||
envDatabaseConnMaxLifetimeSecs = "DB_CONN_MAX_LIFETIME_SECONDS"
|
||||
)
|
||||
|
||||
var mysqlRequiredEnvVars = []string{envDatabaseHost, envDatabaseUser, envDatabasePassword, envDatabaseName}
|
||||
|
||||
var DatabaseTLSModes = []string{"false", "true", "skip-verify", "preferred"}
|
||||
|
||||
type DatabaseSettings struct {
|
||||
Driver string
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
Name string
|
||||
TLS string
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetime time.Duration
|
||||
}
|
||||
|
||||
func DatabaseRequiredEnvVars(driver string) []string {
|
||||
if driver == DatabaseDriverMySQL {
|
||||
return slices.Clone(mysqlRequiredEnvVars)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MissingEnvVarError(names []string, lookup func(string) (string, bool)) error {
|
||||
for _, name := range names {
|
||||
if _, isSet := lookup(name); !isSet {
|
||||
return fmt.Errorf("Environment variable %s is not set", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func databaseDriverFromEnv() string {
|
||||
return envOrDefault(EnvDatabaseDriver, DefaultDatabaseDriver)
|
||||
}
|
||||
|
||||
func databaseSettingsFromEnv() (DatabaseSettings, error) {
|
||||
driver := databaseDriverFromEnv()
|
||||
if driver != DatabaseDriverMySQL {
|
||||
return DatabaseSettings{Driver: driver}, nil
|
||||
}
|
||||
|
||||
tls := envOrDefault(envDatabaseTLS, DefaultDatabaseTLS)
|
||||
if !slices.Contains(DatabaseTLSModes, tls) {
|
||||
return DatabaseSettings{}, fmt.Errorf("%s must be one of %s, got %q", envDatabaseTLS, strings.Join(DatabaseTLSModes, ", "), tls)
|
||||
}
|
||||
maxOpen, err := nonNegativeIntEnv(envDatabaseMaxOpenConns, DefaultMaxOpenConns)
|
||||
if err != nil {
|
||||
return DatabaseSettings{}, err
|
||||
}
|
||||
maxIdle, err := nonNegativeIntEnv(envDatabaseMaxIdleConns, DefaultMaxIdleConns)
|
||||
if err != nil {
|
||||
return DatabaseSettings{}, err
|
||||
}
|
||||
lifetimeSeconds, err := nonNegativeIntEnv(envDatabaseConnMaxLifetimeSecs, DefaultConnMaxLifetimeSeconds)
|
||||
if err != nil {
|
||||
return DatabaseSettings{}, err
|
||||
}
|
||||
|
||||
return DatabaseSettings{
|
||||
Driver: driver,
|
||||
Host: os.Getenv(envDatabaseHost),
|
||||
Port: envOrDefault(envDatabasePort, DefaultDatabasePort),
|
||||
User: os.Getenv(envDatabaseUser),
|
||||
Password: os.Getenv(envDatabasePassword),
|
||||
Name: os.Getenv(envDatabaseName),
|
||||
TLS: tls,
|
||||
MaxOpenConns: maxOpen,
|
||||
MaxIdleConns: maxIdle,
|
||||
ConnMaxLifetime: time.Duration(lifetimeSeconds) * time.Second,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func envOrDefault(name string, fallback string) string {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func nonNegativeIntEnv(name string, fallback string) (int, error) {
|
||||
raw := envOrDefault(name, fallback)
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 0 {
|
||||
return 0, fmt.Errorf("%s must be a non-negative whole number, got %q", name, raw)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -28,6 +28,8 @@ type Settings struct {
|
||||
UploadDir string
|
||||
JWTSecret string
|
||||
DBFile string
|
||||
Database DatabaseSettings
|
||||
DataDirPath string
|
||||
Port string
|
||||
Smtp SmtpSettings
|
||||
AuthRateLimit AuthRateLimitSettings
|
||||
@@ -38,6 +40,9 @@ type Settings struct {
|
||||
}
|
||||
|
||||
func (s Settings) DataDir() string {
|
||||
if s.DataDirPath != "" {
|
||||
return s.DataDirPath
|
||||
}
|
||||
return filepath.Dir(s.DBFile)
|
||||
}
|
||||
|
||||
@@ -61,6 +66,11 @@ func NewSettingsFromEnv() (Settings, error) {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
database, err := databaseSettingsFromEnv()
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
return Settings{
|
||||
Environment: os.Getenv("ENVIRONMENT"),
|
||||
CorsAllowedOrigins: parseSemicolonSeparatedList(os.Getenv("CORS_ALLOWED_ORIGINS")),
|
||||
@@ -68,6 +78,8 @@ func NewSettingsFromEnv() (Settings, error) {
|
||||
UploadDir: os.Getenv("UPLOAD_DIR"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
DBFile: os.Getenv("DB_FILE"),
|
||||
Database: database,
|
||||
DataDirPath: os.Getenv("DATA_DIR"),
|
||||
Port: os.Getenv("PORT"),
|
||||
Smtp: SmtpSettings{
|
||||
Login: os.Getenv("SMTP_LOGIN"),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type backupFile struct {
|
||||
path string
|
||||
modified time.Time
|
||||
}
|
||||
|
||||
func newBackupTarget(directory string, now time.Time, extension string) (string, error) {
|
||||
if err := os.MkdirAll(directory, os.ModePerm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
target := filepath.Join(directory, backupFilePrefix+now.UTC().Format(backupTimeLayout)+extension)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return "", errBackupExists
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func pruneBackups(directory string, extension string) error {
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files := make([]backupFile, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), extension) {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, backupFile{path: filepath.Join(directory, entry.Name()), modified: info.ModTime()})
|
||||
}
|
||||
slices.SortFunc(files, func(a, b backupFile) int {
|
||||
if byTime := b.modified.Compare(a.modified); byTime != 0 {
|
||||
return byTime
|
||||
}
|
||||
return strings.Compare(b.path, a.path)
|
||||
})
|
||||
for _, stale := range files[min(backupsToKeep, len(files)):] {
|
||||
if err := os.Remove(stale.path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -8,9 +8,16 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PoolSettings struct {
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetime time.Duration
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Driver string
|
||||
Source string
|
||||
Pool *PoolSettings
|
||||
}
|
||||
|
||||
type Driver interface {
|
||||
@@ -20,6 +27,7 @@ type Driver interface {
|
||||
|
||||
var drivers = map[string]Driver{
|
||||
DriverSQLite: sqliteDriver{},
|
||||
DriverMySQL: mysqlDriver{},
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
@@ -36,9 +44,25 @@ func Connect(config Config) (*Connection, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config.Pool != nil {
|
||||
if err := applyPool(db, *config.Pool); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Connection{DB: db, driver: driver}, nil
|
||||
}
|
||||
|
||||
func applyPool(db *gorm.DB, pool PoolSettings) error {
|
||||
sqlDb, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDb.SetMaxOpenConns(pool.MaxOpenConns)
|
||||
sqlDb.SetMaxIdleConns(pool.MaxIdleConns)
|
||||
sqlDb.SetConnMaxLifetime(pool.ConnMaxLifetime)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Connection) NewBackup(directory string, now func() time.Time) domainUpdate.DatabaseBackup {
|
||||
return c.driver.NewBackup(c.DB, directory, now)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"bufio"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
dumpFileExtension = ".sql"
|
||||
dumpPartialFileExtension = ".partial"
|
||||
)
|
||||
|
||||
type DumpBackup struct {
|
||||
open func() (DumpSource, error)
|
||||
directory string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
var _ domain.DatabaseBackup = &DumpBackup{}
|
||||
|
||||
func NewDumpBackup(open func() (DumpSource, error), directory string, now func() time.Time) *DumpBackup {
|
||||
return &DumpBackup{open: open, directory: directory, now: now}
|
||||
}
|
||||
|
||||
func (b *DumpBackup) Backup() (string, error) {
|
||||
target, err := newBackupTarget(b.directory, b.now(), dumpFileExtension)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := b.writeAtomically(target); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := pruneBackups(b.directory, dumpFileExtension); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (b *DumpBackup) writeAtomically(target string) error {
|
||||
partial := target + dumpPartialFileExtension
|
||||
if err := b.writePartial(partial); err != nil {
|
||||
_ = os.Remove(partial)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(partial, target); err != nil {
|
||||
_ = os.Remove(partial)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *DumpBackup) writePartial(partial string) error {
|
||||
source, err := b.open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
file, err := os.OpenFile(partial, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffered := bufio.NewWriter(file)
|
||||
if err := WriteDump(buffered, source); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if err := buffered.Flush(); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
return file.Close()
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func Migrate(db *gorm.DB) error {
|
||||
err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{})
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
domainUpdate "RenewCMS/internal/domain/update"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
DriverMySQL = "mysql"
|
||||
|
||||
mysqlCharset = "utf8mb4"
|
||||
mysqlLocation = "UTC"
|
||||
mysqlCollation = "utf8mb4_bin"
|
||||
mysqlConnectTimeout = 5 * time.Second
|
||||
mysqlSessionSQLMode = "'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'"
|
||||
mysqlTableOptionsKey = "gorm:table_options"
|
||||
redactedPassword = "***"
|
||||
)
|
||||
|
||||
type mysqlDriver struct{}
|
||||
|
||||
type MySQLConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
Name string
|
||||
TLS string
|
||||
}
|
||||
|
||||
func MySQLDSN(config MySQLConfig) string {
|
||||
dsn := mysqldriver.NewConfig()
|
||||
dsn.User = config.User
|
||||
dsn.Passwd = config.Password
|
||||
dsn.Net = "tcp"
|
||||
dsn.Addr = net.JoinHostPort(config.Host, config.Port)
|
||||
dsn.DBName = config.Name
|
||||
dsn.TLSConfig = config.TLS
|
||||
dsn.ParseTime = true
|
||||
dsn.ClientFoundRows = true
|
||||
dsn.Collation = mysqlCollation
|
||||
dsn.Timeout = mysqlConnectTimeout
|
||||
dsn.Params = map[string]string{
|
||||
"charset": mysqlCharset,
|
||||
"loc": mysqlLocation,
|
||||
"sql_mode": mysqlSessionSQLMode,
|
||||
}
|
||||
return dsn.FormatDSN()
|
||||
}
|
||||
|
||||
func (mysqlDriver) Open(source string) (*gorm.DB, error) {
|
||||
parsed, err := mysqldriver.ParseDSN(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the mysql connection settings are invalid")
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(source), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot use the mysql server at %s: %s", parsed.Addr, redact(err.Error(), parsed.Passwd))
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (mysqlDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup {
|
||||
return NewMySQLBackup(db, directory, now)
|
||||
}
|
||||
|
||||
func redact(message string, secret string) string {
|
||||
if secret == "" {
|
||||
return message
|
||||
}
|
||||
return strings.ReplaceAll(message, secret, redactedPassword)
|
||||
}
|
||||
|
||||
func tableOptionsFor(db *gorm.DB) *gorm.DB {
|
||||
if db.Dialector.Name() == DriverMySQL {
|
||||
return db.Set(mysqlTableOptionsKey, "DEFAULT CHARSET="+mysqlCharset+" COLLATE="+mysqlCollation)
|
||||
}
|
||||
return db
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
dumpTimeLayout = "2006-01-02 15:04:05.000000"
|
||||
zeroDumpTime = "0000-00-00 00:00:00"
|
||||
)
|
||||
|
||||
func NewMySQLBackup(db *gorm.DB, directory string, now func() time.Time) *DumpBackup {
|
||||
return NewDumpBackup(func() (DumpSource, error) { return openMySQLDumpSource(db) }, directory, now)
|
||||
}
|
||||
|
||||
type mysqlDumpSource struct {
|
||||
tx *gorm.DB
|
||||
}
|
||||
|
||||
var _ DumpSource = &mysqlDumpSource{}
|
||||
|
||||
func openMySQLDumpSource(db *gorm.DB) (*mysqlDumpSource, error) {
|
||||
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
return &mysqlDumpSource{tx: tx}, nil
|
||||
}
|
||||
|
||||
func (s *mysqlDumpSource) Tables() ([]string, error) {
|
||||
var tables []string
|
||||
err := s.tx.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE' ORDER BY table_name").Scan(&tables).Error
|
||||
return tables, err
|
||||
}
|
||||
|
||||
func (s *mysqlDumpSource) CreateStatement(table string) (string, error) {
|
||||
var name, statement string
|
||||
row := s.tx.Raw("SHOW CREATE TABLE " + quoteIdentifier(table)).Row()
|
||||
if err := row.Scan(&name, &statement); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return statement, nil
|
||||
}
|
||||
|
||||
func (s *mysqlDumpSource) Rows(table string) (DumpRows, error) {
|
||||
rows, err := s.tx.Raw("SELECT * FROM " + quoteIdentifier(table)).Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mysqlDumpRows{rows: rows}, nil
|
||||
}
|
||||
|
||||
func (s *mysqlDumpSource) Close() error {
|
||||
return s.tx.Rollback().Error
|
||||
}
|
||||
|
||||
type mysqlDumpRows struct {
|
||||
rows *sql.Rows
|
||||
}
|
||||
|
||||
var _ DumpRows = &mysqlDumpRows{}
|
||||
|
||||
func (r *mysqlDumpRows) Columns() ([]DumpColumn, error) {
|
||||
types, err := r.rows.ColumnTypes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columns := make([]DumpColumn, len(types))
|
||||
for index, columnType := range types {
|
||||
columns[index] = DumpColumn{Name: columnType.Name(), Type: columnType.DatabaseTypeName()}
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (r *mysqlDumpRows) Next() bool {
|
||||
return r.rows.Next()
|
||||
}
|
||||
|
||||
func (r *mysqlDumpRows) Values() ([][]byte, error) {
|
||||
columnCount, err := r.rows.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scanned := make([]any, len(columnCount))
|
||||
targets := make([]any, len(columnCount))
|
||||
for index := range scanned {
|
||||
targets[index] = &scanned[index]
|
||||
}
|
||||
if err := r.rows.Scan(targets...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := make([][]byte, len(scanned))
|
||||
for index, value := range scanned {
|
||||
values[index] = dumpBytes(value)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (r *mysqlDumpRows) Err() error {
|
||||
return r.rows.Err()
|
||||
}
|
||||
|
||||
func (r *mysqlDumpRows) Close() error {
|
||||
return r.rows.Close()
|
||||
}
|
||||
|
||||
func dumpBytes(value any) []byte {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case []byte:
|
||||
return append([]byte{}, typed...)
|
||||
case string:
|
||||
return []byte(typed)
|
||||
case time.Time:
|
||||
return []byte(formatDumpTime(typed))
|
||||
default:
|
||||
return []byte(fmt.Sprint(typed))
|
||||
}
|
||||
}
|
||||
|
||||
func formatDumpTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return zeroDumpTime
|
||||
}
|
||||
return value.UTC().Format(dumpTimeLayout)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DumpColumn struct {
|
||||
Name string
|
||||
Type string
|
||||
}
|
||||
|
||||
type DumpRows interface {
|
||||
Columns() ([]DumpColumn, error)
|
||||
Next() bool
|
||||
Values() ([][]byte, error)
|
||||
Err() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type DumpSource interface {
|
||||
Tables() ([]string, error)
|
||||
CreateStatement(table string) (string, error)
|
||||
Rows(table string) (DumpRows, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
var dumpPreamble = []string{
|
||||
"SET NAMES utf8mb4;",
|
||||
"SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO';",
|
||||
"SET foreign_key_checks = 0;",
|
||||
}
|
||||
|
||||
var dumpPostamble = []string{
|
||||
"SET foreign_key_checks = 1;",
|
||||
}
|
||||
|
||||
var plainNumber = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$`)
|
||||
|
||||
var textEscapes = strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
`'`, `\'`,
|
||||
"\x00", `\0`,
|
||||
"\n", `\n`,
|
||||
"\r", `\r`,
|
||||
"\x1a", `\Z`,
|
||||
)
|
||||
|
||||
func WriteDump(output io.Writer, source DumpSource) error {
|
||||
if err := writeStatements(output, dumpPreamble); err != nil {
|
||||
return err
|
||||
}
|
||||
tables, err := source.Tables()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, table := range tables {
|
||||
if err := writeTable(output, source, table); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writeStatements(output, dumpPostamble)
|
||||
}
|
||||
|
||||
func writeStatements(output io.Writer, statements []string) error {
|
||||
for _, statement := range statements {
|
||||
if _, err := fmt.Fprintln(output, statement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeTable(output io.Writer, source DumpSource, table string) error {
|
||||
create, err := source.CreateStatement(table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(output, "%s;\n", create); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := source.Rows(table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
return writeRows(output, table, rows)
|
||||
}
|
||||
|
||||
func writeRows(output io.Writer, table string, rows DumpRows) error {
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
insertPrefix := insertStatementPrefix(table, columns)
|
||||
for rows.Next() {
|
||||
values, err := rows.Values()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(output, "%s%s);\n", insertPrefix, joinLiterals(columns, values)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func insertStatementPrefix(table string, columns []DumpColumn) string {
|
||||
names := make([]string, len(columns))
|
||||
for index, column := range columns {
|
||||
names[index] = quoteIdentifier(column.Name)
|
||||
}
|
||||
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (", quoteIdentifier(table), strings.Join(names, ","))
|
||||
}
|
||||
|
||||
func joinLiterals(columns []DumpColumn, values [][]byte) string {
|
||||
literals := make([]string, len(columns))
|
||||
for index, column := range columns {
|
||||
literals[index] = sqlLiteral(column.Type, values[index])
|
||||
}
|
||||
return strings.Join(literals, ",")
|
||||
}
|
||||
|
||||
func quoteIdentifier(name string) string {
|
||||
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
|
||||
}
|
||||
|
||||
func sqlLiteral(databaseType string, value []byte) string {
|
||||
if value == nil {
|
||||
return "NULL"
|
||||
}
|
||||
switch {
|
||||
case isNumericType(databaseType) && plainNumber.Match(value):
|
||||
return string(value)
|
||||
case isBinaryType(databaseType):
|
||||
return hexLiteral(value)
|
||||
default:
|
||||
return "'" + textEscapes.Replace(string(value)) + "'"
|
||||
}
|
||||
}
|
||||
|
||||
func hexLiteral(value []byte) string {
|
||||
if len(value) == 0 {
|
||||
return "''"
|
||||
}
|
||||
return "0x" + hex.EncodeToString(value)
|
||||
}
|
||||
|
||||
func isNumericType(databaseType string) bool {
|
||||
upper := strings.ToUpper(databaseType)
|
||||
for _, suffix := range []string{"INT", "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "YEAR"} {
|
||||
if strings.HasSuffix(upper, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isBinaryType(databaseType string) bool {
|
||||
upper := strings.ToUpper(databaseType)
|
||||
for _, suffix := range []string{"BLOB", "BINARY", "BIT", "GEOMETRY"} {
|
||||
if strings.HasSuffix(upper, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -4,9 +4,6 @@ import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -34,54 +31,16 @@ func NewSQLiteBackup(db *gorm.DB, directory string, now func() time.Time) *SQLit
|
||||
}
|
||||
|
||||
func (b *SQLiteBackup) Backup() (string, error) {
|
||||
if err := os.MkdirAll(b.directory, os.ModePerm); err != nil {
|
||||
target, err := newBackupTarget(b.directory, b.now(), backupFileExtension)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
target := filepath.Join(b.directory, backupFilePrefix+b.now().UTC().Format(backupTimeLayout)+backupFileExtension)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return "", errBackupExists
|
||||
}
|
||||
if err := b.db.Exec("VACUUM INTO ?", target).Error; err != nil {
|
||||
_ = os.Remove(target)
|
||||
return "", err
|
||||
}
|
||||
if err := b.prune(); err != nil {
|
||||
if err := pruneBackups(b.directory, backupFileExtension); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
type backupFile struct {
|
||||
path string
|
||||
modified time.Time
|
||||
}
|
||||
|
||||
func (b *SQLiteBackup) prune() error {
|
||||
entries, err := os.ReadDir(b.directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files := make([]backupFile, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), backupFileExtension) {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, backupFile{path: filepath.Join(b.directory, entry.Name()), modified: info.ModTime()})
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
if files[i].modified.Equal(files[j].modified) {
|
||||
return files[i].path > files[j].path
|
||||
}
|
||||
return files[i].modified.After(files[j].modified)
|
||||
})
|
||||
for _, stale := range files[min(backupsToKeep, len(files)):] {
|
||||
if err := os.Remove(stale.path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -7,7 +7,7 @@ type APIKey struct {
|
||||
UserID uint32 `gorm:"index;not null"`
|
||||
Name string `gorm:"not null"`
|
||||
Prefix string `gorm:"not null"`
|
||||
HashedKey string `gorm:"uniqueIndex;not null"`
|
||||
HashedKey string `gorm:"uniqueIndex;not null;size:64"`
|
||||
ExpiresAt *time.Time
|
||||
RevokedAt *time.Time
|
||||
LastUsedAt *time.Time
|
||||
|
||||
@@ -15,7 +15,7 @@ type Image struct {
|
||||
ContentType string
|
||||
Width int
|
||||
Height int
|
||||
SHA256 string `gorm:"column:sha256;index"`
|
||||
SHA256 string `gorm:"column:sha256;index;size:64"`
|
||||
UploadedByID uint32
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
|
||||
@@ -4,7 +4,7 @@ import "time"
|
||||
|
||||
type Invite struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
Email string `gorm:"index;not null"`
|
||||
Email string `gorm:"index;not null;size:255"`
|
||||
HashedCode string `gorm:"not null"`
|
||||
ExpiresAt time.Time
|
||||
InvitedBy uint32 `gorm:"not null"`
|
||||
|
||||
@@ -6,14 +6,14 @@ import (
|
||||
|
||||
type User struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
Username string `gorm:"unique;not null"`
|
||||
Username string `gorm:"unique;not null;size:255"`
|
||||
Password string `gorm:"not null"`
|
||||
PasswordResetCode *string `gorm:"unique"`
|
||||
Email string `gorm:"unique;not null"`
|
||||
DisplayName string `gorm:"not null;default:''"`
|
||||
PasswordResetCode *string `gorm:"unique;size:255"`
|
||||
Email string `gorm:"unique;not null;size:255"`
|
||||
DisplayName string `gorm:"not null;default:'';size:255"`
|
||||
IsVerified bool `gorm:"default=false;not null"`
|
||||
Role string `gorm:"not null;default:''"`
|
||||
VerificationCode *string `gorm:"unique"`
|
||||
Role string `gorm:"not null;default:'';size:32"`
|
||||
VerificationCode *string `gorm:"unique;size:255"`
|
||||
VerificationExpiration time.Time
|
||||
PasswordResetExpiration time.Time
|
||||
TokenVersion uint32 `gorm:"default:0;not null"`
|
||||
|
||||
@@ -28,7 +28,7 @@ func (u *UserRepository) Get(id uint32) (domainUser.User, error) {
|
||||
}
|
||||
|
||||
func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
|
||||
creationResult := u.db.Create(&entity.User{
|
||||
createdUser := entity.User{
|
||||
Username: user.Username,
|
||||
Password: user.Password,
|
||||
PasswordResetCode: mappers.DomainToNullableString(user.PasswordResetCode),
|
||||
@@ -37,16 +37,12 @@ func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
|
||||
Role: string(user.Role),
|
||||
VerificationCode: mappers.DomainToNullableString(user.VerificationCode),
|
||||
VerificationExpiration: user.VerificationExpiration,
|
||||
})
|
||||
if creationResult.Error != nil {
|
||||
return domainUser.User{}, creationResult.Error
|
||||
}
|
||||
if err := u.db.Create(&createdUser).Error; err != nil {
|
||||
return domainUser.User{}, err
|
||||
}
|
||||
|
||||
var createdUser entity.User
|
||||
creationResult.Scan(&createdUser)
|
||||
|
||||
return mappers.UserToDomain(createdUser),
|
||||
nil
|
||||
return mappers.UserToDomain(createdUser), nil
|
||||
}
|
||||
|
||||
func (u *UserRepository) Delete(id uint32) error {
|
||||
|
||||
Reference in new issue
Block a user