feat: optional MySQL/MariaDB database driver for deployments that cannot use local SQLite

This commit is contained in:
fsylvain committed 2026-10-06 04:08:33 +02:00
1 parent 81e299f09a
commit d752a61e52
35 files changed
+2896 -76

No files matched your search

@@ -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 {