diff --git a/.env.example b/.env.example index ee70683..4f3de60 100644 --- a/.env.example +++ b/.env.example @@ -6,13 +6,20 @@ API_DOMAIN=api.esempio.com ACME_EMAIL=tua@email.com MYSQL_ROOT_PASSWORD=password-sicura -# DB Settings (usato per sviluppo locale; in Docker il DSN è costruito dal compose) +# DB Settings +# DB_DRIVER: "mysql" (default) o "sqlite" +DB_DRIVER=mysql + +# MySQL DB_DSN=root:secret@tcp(127.0.0.1:3306)/emly?parseTime=true&loc=UTC DB_MAX_OPEN_CONNS=25 DB_MAX_IDLE_CONNS=5 DB_CONN_MAX_LIFETIME=5 DATABASE_NAME=emly +# SQLite (usare invece di MySQL: DB_DRIVER=sqlite, DB_DSN=./data.db, DATABASE_NAME non necessario) +# DB_DSN=./data.db + # API Keys API_KEY=key-one ADMIN_KEY=admin-key-one diff --git a/.gitignore b/.gitignore index 1f3fb2e..f9987ca 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,7 @@ go.work.sum tmp/ -build/ \ No newline at end of file +build/ + +# Database files +*.db \ No newline at end of file diff --git a/go.mod b/go.mod index 909c0d0..791d40b 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,18 @@ require ( golang.org/x/crypto v0.49.0 ) -require golang.org/x/sys v0.42.0 // indirect +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.47.0 // indirect +) require ( filippo.io/edwards25519 v1.1.1 // indirect diff --git a/go.sum b/go.sum index 0822857..05a4f34 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs= @@ -9,17 +11,34 @@ github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5c github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= diff --git a/internal/config/config.go b/internal/config/config.go index 7222c00..25ded1e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,7 @@ type RateLimitConfig struct { type Config struct { Port string + Driver string DSN string Database string APIKey string @@ -80,9 +81,19 @@ func load() *Config { connMaxLifetime = 5 } - dbName := os.Getenv("DATABASE_NAME") - if dbName == "" { - panic("DATABASE_NAME environment variable is required") + driver := os.Getenv("DB_DRIVER") + if driver == "" { + driver = "mysql" + } + + var dbName string + if driver == "sqlite" { + dbName = "main" + } else { + dbName = os.Getenv("DATABASE_NAME") + if dbName == "" { + panic("DATABASE_NAME environment variable is required") + } } if os.Getenv("DB_DSN") == "" { @@ -91,6 +102,7 @@ func load() *Config { return &Config{ Port: port, + Driver: driver, DSN: os.Getenv("DB_DSN"), Database: dbName, APIKey: apiKey, diff --git a/internal/database/database.go b/internal/database/database.go index 92ee18b..345cf9a 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -1,23 +1,41 @@ package database import ( + "fmt" "time" _ "github.com/go-sql-driver/mysql" "github.com/jmoiron/sqlx" + _ "modernc.org/sqlite" "emly-api-go/internal/config" ) func Connect(cfg *config.Config) (*sqlx.DB, error) { - db, err := sqlx.Connect("mysql", cfg.DSN) - if err != nil { - return nil, err - } + var db *sqlx.DB + var err error - db.SetMaxOpenConns(cfg.MaxOpenConns) - db.SetMaxIdleConns(cfg.MaxIdleConns) - db.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Minute) + switch cfg.Driver { + case "sqlite": + db, err = sqlx.Connect("sqlite", cfg.DSN) + if err != nil { + return nil, err + } + // Enable foreign key support (disabled by default in SQLite) + if _, err = db.Exec("PRAGMA foreign_keys = ON"); err != nil { + return nil, fmt.Errorf("sqlite: enable foreign_keys: %w", err) + } + case "mysql": + db, err = sqlx.Connect("mysql", cfg.DSN) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(cfg.MaxOpenConns) + db.SetMaxIdleConns(cfg.MaxIdleConns) + db.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Minute) + default: + return nil, fmt.Errorf("unsupported DB_DRIVER %q: must be mysql or sqlite", cfg.Driver) + } return db, nil } diff --git a/internal/database/schema/migrator.go b/internal/database/schema/migrator.go index e53b6bf..332474f 100644 --- a/internal/database/schema/migrator.go +++ b/internal/database/schema/migrator.go @@ -10,7 +10,7 @@ import ( "github.com/jmoiron/sqlx" ) -//go:embed init.sql migrations/*.json migrations/*.sql +//go:embed mysql sqlite var migrationsFS embed.FS type taskFile struct { @@ -31,62 +31,43 @@ type condition struct { Index string `json:"index,omitempty"` } -// Migrate reads migrations/tasks.json and executes every task whose -// conditions are ALL satisfied (i.e. logical AND). -func Migrate(db *sqlx.DB, dbName string) error { - // If the database has no tables at all, bootstrap with init.sql. - empty, err := schemaIsEmpty(db, dbName) +// Migrate reads the driver-specific migrations and applies them. +func Migrate(db *sqlx.DB, dbName string, driver string) error { + empty, err := schemaIsEmpty(db, dbName, driver) if err != nil { return fmt.Errorf("schema: check empty: %w", err) } if empty { log.Println("[migrate] empty schema detected – running init.sql") - initSQL, err := migrationsFS.ReadFile("init.sql") - if err != nil { - return fmt.Errorf("schema: read init.sql: %w", err) + if err := runInitSQL(db, driver); err != nil { + return err } - for _, stmt := range splitStatements(string(initSQL)) { - if _, err := db.Exec(stmt); err != nil { - return fmt.Errorf("schema: exec init.sql: %w\nSQL: %s", err, stmt) - } - } - log.Println("[migrate] init.sql applied – base schema created") } else { log.Println("[migrate] checking if tables exist") - // Check if the tables are there or not - var tableNames []string + tableNames := []string{"bug_reports", "bug_report_files", "rate_limit_hwid", "user", "session"} var foundTables []string - tableNames = append(tableNames, "bug_reports", "bug_report_files", "rate_limit_hwid", "user", "session") for _, tableName := range tableNames { - found, err := tableExists(db, dbName, tableName) + found, err := tableExists(db, dbName, tableName, driver) if err != nil { return fmt.Errorf("schema: check table %s: %w", tableName, err) } if !found { - log.Printf("[migrate] warning: expected table %s not found – schema may be in an inconsistent state", tableName) + log.Printf("[migrate] warning: expected table %s not found", tableName) continue } foundTables = append(foundTables, tableName) } if len(foundTables) != len(tableNames) { - log.Printf("[migrate] warning: expected %d tables, found %d", len(tableNames), len(foundTables)) - log.Printf("[migrate] info: running init.sql") - initSQL, err := migrationsFS.ReadFile("init.sql") - if err != nil { - return fmt.Errorf("schema: read init.sql: %w", err) + log.Printf("[migrate] warning: expected %d tables, found %d – running init.sql", len(tableNames), len(foundTables)) + if err := runInitSQL(db, driver); err != nil { + return err } - for _, stmt := range splitStatements(string(initSQL)) { - if _, err := db.Exec(stmt); err != nil { - return fmt.Errorf("schema: exec init.sql: %w\nSQL: %s", err, stmt) - } - } - log.Println("[migrate] init.sql applied – base schema created") } else { log.Println("[migrate] all expected tables found – skipping init.sql") } } - raw, err := migrationsFS.ReadFile("migrations/tasks.json") + raw, err := migrationsFS.ReadFile(driver + "/migrations/tasks.json") if err != nil { return fmt.Errorf("schema: read tasks.json: %w", err) } @@ -97,7 +78,7 @@ func Migrate(db *sqlx.DB, dbName string) error { } for _, t := range tf.Tasks { - needed, err := shouldRun(db, dbName, t.Conditions) + needed, err := shouldRun(db, dbName, t.Conditions, driver) if err != nil { return fmt.Errorf("schema: evaluate conditions for %s: %w", t.ID, err) } @@ -106,7 +87,7 @@ func Migrate(db *sqlx.DB, dbName string) error { continue } - sqlBytes, err := migrationsFS.ReadFile("migrations/" + t.SQLFile) + sqlBytes, err := migrationsFS.ReadFile(driver + "/migrations/" + t.SQLFile) if err != nil { return fmt.Errorf("schema: read %s: %w", t.SQLFile, err) } @@ -122,11 +103,25 @@ func Migrate(db *sqlx.DB, dbName string) error { return nil } +func runInitSQL(db *sqlx.DB, driver string) error { + initSQL, err := migrationsFS.ReadFile(driver + "/init.sql") + if err != nil { + return fmt.Errorf("schema: read init.sql: %w", err) + } + for _, stmt := range splitStatements(string(initSQL)) { + if _, err := db.Exec(stmt); err != nil { + return fmt.Errorf("schema: exec init.sql: %w\nSQL: %s", err, stmt) + } + } + log.Println("[migrate] init.sql applied – base schema created") + return nil +} + // ---------- Condition evaluator ---------- -func shouldRun(db *sqlx.DB, dbName string, conds []condition) (bool, error) { +func shouldRun(db *sqlx.DB, dbName string, conds []condition, driver string) (bool, error) { for _, c := range conds { - met, err := evaluate(db, dbName, c) + met, err := evaluate(db, dbName, c, driver) if err != nil { return false, err } @@ -137,81 +132,186 @@ func shouldRun(db *sqlx.DB, dbName string, conds []condition) (bool, error) { return false, nil } -func evaluate(db *sqlx.DB, dbName string, c condition) (bool, error) { +func evaluate(db *sqlx.DB, dbName string, c condition, driver string) (bool, error) { switch c.Type { case "column_not_exists": - exists, err := columnExists(db, dbName, c.Table, c.Column) + exists, err := columnExists(db, dbName, c.Table, c.Column, driver) return !exists, err case "column_exists": - return columnExists(db, dbName, c.Table, c.Column) + return columnExists(db, dbName, c.Table, c.Column, driver) case "index_not_exists": - exists, err := indexExists(db, dbName, c.Table, c.Index) + exists, err := indexExists(db, dbName, c.Table, c.Index, driver) return !exists, err case "index_exists": - return indexExists(db, dbName, c.Table, c.Index) + return indexExists(db, dbName, c.Table, c.Index, driver) case "table_not_exists": - exists, err := tableExists(db, dbName, c.Table) + exists, err := tableExists(db, dbName, c.Table, driver) return !exists, err case "table_exists": - return tableExists(db, dbName, c.Table) + return tableExists(db, dbName, c.Table, driver) default: return false, fmt.Errorf("unknown condition type: %s", c.Type) } } -func columnExists(db *sqlx.DB, dbName, table, column string) (bool, error) { +// ---------- MySQL condition checks ---------- + +func columnExistsMySQL(db *sqlx.DB, dbName, table, column string) (bool, error) { var count int err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = ? - AND TABLE_NAME = ? - AND COLUMN_NAME = ?`, dbName, table, column) + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?`, + dbName, table, column) return count > 0, err } -func indexExists(db *sqlx.DB, dbName, table, index string) (bool, error) { +func indexExistsMySQL(db *sqlx.DB, dbName, table, index string) (bool, error) { var count int err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.STATISTICS - WHERE TABLE_SCHEMA = ? - AND TABLE_NAME = ? - AND INDEX_NAME = ?`, dbName, table, index) + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = ?`, + dbName, table, index) return count > 0, err } -func tableExists(db *sqlx.DB, dbName, table string) (bool, error) { +func tableExistsMySQL(db *sqlx.DB, dbName, table string) (bool, error) { var count int err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.TABLES - WHERE TABLE_SCHEMA = ? - AND TABLE_NAME = ?`, dbName, table) + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`, + dbName, table) return count > 0, err } -func schemaIsEmpty(db *sqlx.DB, dbName string) (bool, error) { +func schemaIsEmptyMySQL(db *sqlx.DB, dbName string) (bool, error) { var count int err := db.Get(&count, - `SELECT COUNT(*) FROM information_schema.TABLES - WHERE TABLE_SCHEMA = ?`, dbName) + `SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?`, dbName) return count == 0, err } -// splitStatements splits a SQL blob on ";" respecting only top-level -// semicolons (good enough for simple ALTER / CREATE statements). +// ---------- SQLite condition checks ---------- + +func columnExistsSQLite(db *sqlx.DB, table, column string) (bool, error) { + var count int + // pragma_table_info is a table-valued function available since SQLite 3.16.0 + err := db.Get(&count, + fmt.Sprintf("SELECT COUNT(*) FROM pragma_table_info('%s') WHERE name = ?", table), + column) + return count > 0, err +} + +func indexExistsSQLite(db *sqlx.DB, table, index string) (bool, error) { + var count int + err := db.Get(&count, + `SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND tbl_name=? AND name=?`, + table, index) + return count > 0, err +} + +func tableExistsSQLite(db *sqlx.DB, table string) (bool, error) { + var count int + err := db.Get(&count, + `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table) + return count > 0, err +} + +func schemaIsEmptySQLite(db *sqlx.DB) (bool, error) { + var count int + err := db.Get(&count, `SELECT COUNT(*) FROM sqlite_master WHERE type='table'`) + return count == 0, err +} + +// ---------- Driver-dispatched wrappers ---------- + +func columnExists(db *sqlx.DB, dbName, table, column, driver string) (bool, error) { + if driver == "sqlite" { + return columnExistsSQLite(db, table, column) + } + return columnExistsMySQL(db, dbName, table, column) +} + +func indexExists(db *sqlx.DB, dbName, table, index, driver string) (bool, error) { + if driver == "sqlite" { + return indexExistsSQLite(db, table, index) + } + return indexExistsMySQL(db, dbName, table, index) +} + +func tableExists(db *sqlx.DB, dbName, table, driver string) (bool, error) { + if driver == "sqlite" { + return tableExistsSQLite(db, table) + } + return tableExistsMySQL(db, dbName, table) +} + +func schemaIsEmpty(db *sqlx.DB, dbName, driver string) (bool, error) { + if driver == "sqlite" { + return schemaIsEmptySQLite(db) + } + return schemaIsEmptyMySQL(db, dbName) +} + +// splitStatements splits a SQL blob on top-level ";" only, respecting +// BEGIN...END blocks (e.g. triggers) so their inner semicolons are not split. func splitStatements(sql string) []string { - raw := strings.Split(sql, ";") - out := make([]string, 0, len(raw)) - for _, s := range raw { - s = strings.TrimSpace(s) - if s != "" { - out = append(out, s) + var out []string + var buf strings.Builder + depth := 0 + n := len(sql) + + for i := 0; i < n; { + c := sql[i] + + // Collect whole identifier tokens to detect BEGIN / END keywords. + if isIdentStart(c) { + j := i + for j < n && isIdentChar(sql[j]) { + j++ + } + word := strings.ToUpper(sql[i:j]) + switch word { + case "BEGIN": + depth++ + case "END": + if depth > 0 { + depth-- + } + } + buf.WriteString(sql[i:j]) + i = j + continue } + + if c == ';' && depth == 0 { + if stmt := strings.TrimSpace(buf.String()); stmt != "" { + out = append(out, stmt) + } + buf.Reset() + i++ + continue + } + + buf.WriteByte(c) + i++ + } + + if stmt := strings.TrimSpace(buf.String()); stmt != "" { + out = append(out, stmt) } return out } + +func isIdentStart(c byte) bool { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' +} + +func isIdentChar(c byte) bool { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' +} diff --git a/internal/database/schema/init.sql b/internal/database/schema/mysql/init.sql similarity index 100% rename from internal/database/schema/init.sql rename to internal/database/schema/mysql/init.sql diff --git a/internal/database/schema/migrations/1_bug_reports.sql b/internal/database/schema/mysql/migrations/1_bug_reports.sql similarity index 100% rename from internal/database/schema/migrations/1_bug_reports.sql rename to internal/database/schema/mysql/migrations/1_bug_reports.sql diff --git a/internal/database/schema/migrations/2_users.sql b/internal/database/schema/mysql/migrations/2_users.sql similarity index 100% rename from internal/database/schema/migrations/2_users.sql rename to internal/database/schema/mysql/migrations/2_users.sql diff --git a/internal/database/schema/migrations/tasks.json b/internal/database/schema/mysql/migrations/tasks.json similarity index 100% rename from internal/database/schema/migrations/tasks.json rename to internal/database/schema/mysql/migrations/tasks.json diff --git a/internal/database/schema/sqlite/init.sql b/internal/database/schema/sqlite/init.sql new file mode 100644 index 0000000..9082d53 --- /dev/null +++ b/internal/database/schema/sqlite/init.sql @@ -0,0 +1,65 @@ +CREATE TABLE IF NOT EXISTS bug_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL, + description TEXT NOT NULL, + hwid TEXT NOT NULL DEFAULT '', + hostname TEXT NOT NULL DEFAULT '', + os_user TEXT NOT NULL DEFAULT '', + submitter_ip TEXT NOT NULL DEFAULT '', + system_info TEXT NULL, + status TEXT NOT NULL DEFAULT 'new' CHECK(status IN ('new','in_review','resolved','closed')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_status ON bug_reports(status); +CREATE INDEX IF NOT EXISTS idx_hwid ON bug_reports(hwid); +CREATE INDEX IF NOT EXISTS idx_hostname ON bug_reports(hostname); +CREATE INDEX IF NOT EXISTS idx_os_user ON bug_reports(os_user); +CREATE INDEX IF NOT EXISTS idx_created_at ON bug_reports(created_at); + +CREATE TRIGGER IF NOT EXISTS trg_bug_reports_updated_at + AFTER UPDATE ON bug_reports + FOR EACH ROW + WHEN NEW.updated_at = OLD.updated_at +BEGIN + UPDATE bug_reports SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; +END; + +CREATE TABLE IF NOT EXISTS bug_report_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + report_id INTEGER NOT NULL, + file_role TEXT NOT NULL CHECK(file_role IN ('screenshot','mail_file','localstorage','config','system_info')), + filename TEXT NOT NULL, + mime_type TEXT NOT NULL DEFAULT 'application/octet-stream', + file_size INTEGER NOT NULL DEFAULT 0, + data BLOB NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (report_id) REFERENCES bug_reports(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_report_id ON bug_report_files(report_id); + +CREATE TABLE IF NOT EXISTS rate_limit_hwid ( + hwid TEXT PRIMARY KEY, + window_start DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + count INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','user')), + enabled INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + displayname TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS session ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at DATETIME NOT NULL, + FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE +); diff --git a/internal/database/schema/sqlite/migrations/tasks.json b/internal/database/schema/sqlite/migrations/tasks.json new file mode 100644 index 0000000..50ffbb9 --- /dev/null +++ b/internal/database/schema/sqlite/migrations/tasks.json @@ -0,0 +1,3 @@ +{ + "tasks": [] +} diff --git a/main.go b/main.go index 7e54239..16b432d 100644 --- a/main.go +++ b/main.go @@ -42,7 +42,7 @@ func main() { }(db) // Run conditional schema migrations - if err := schema.Migrate(db, cfg.Database); err != nil { + if err := schema.Migrate(db, cfg.Database, cfg.Driver); err != nil { log.Fatalf("schema migration failed: %v", err) }