package main import ( "fmt" "log" "time" "golang.org/x/crypto/bcrypt" "zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite/sqlitex" ) // Database handles all database operations for the login server type Database struct { conn *sqlite.Conn } // DatabaseConfig holds database connection settings type DatabaseConfig struct { FilePath string `json:"file_path"` MaxConnections int `json:"max_connections"` BusyTimeout int `json:"busy_timeout_ms"` } // NewDatabase creates a new database connection func NewDatabase(config DatabaseConfig) (*Database, error) { // Open SQLite database db, err := sqlite.OpenConn(config.FilePath, sqlite.OpenReadWrite|sqlite.OpenCreate) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } // Enable foreign keys if err := sqlitex.ExecuteTransient(db, "PRAGMA foreign_keys = ON", nil); err != nil { return nil, fmt.Errorf("failed to enable foreign keys: %w", err) } // Set busy timeout if config.BusyTimeout > 0 { query := fmt.Sprintf("PRAGMA busy_timeout = %d", config.BusyTimeout) if err := sqlitex.ExecuteTransient(db, query, nil); err != nil { return nil, fmt.Errorf("failed to set busy timeout: %w", err) } } log.Println("SQLite database connection established") return &Database{conn: db}, nil } // Close closes the database connection func (d *Database) Close() error { return d.conn.Close() } // AuthenticateAccount verifies user credentials and returns account info func (d *Database) AuthenticateAccount(username, password string) (*Account, error) { query := ` SELECT id, username, password_hash, ls_admin, world_admin, created_date, last_login, client_version FROM accounts WHERE username = ? AND active = 1` stmt, err := d.conn.Prepare(query) if err != nil { return nil, fmt.Errorf("prepare statement failed: %w", err) } defer stmt.Finalize() stmt.BindText(1, username) hasRow, err := stmt.Step() if err != nil { return nil, fmt.Errorf("query failed: %w", err) } if !hasRow { return nil, nil // Account not found } var account Account var passwordHash string var createdDate, lastLogin string var clientVersion int64 account.ID = int32(stmt.ColumnInt64(0)) account.Username = stmt.ColumnText(1) passwordHash = stmt.ColumnText(2) account.LSAdmin = stmt.ColumnInt(3) != 0 account.WorldAdmin = stmt.ColumnInt(4) != 0 createdDate = stmt.ColumnText(5) lastLogin = stmt.ColumnText(6) clientVersion = stmt.ColumnInt64(7) // Verify password if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil { return nil, nil // Invalid password } // Parse timestamps if lastLogin != "" { if t, err := time.Parse("2006-01-02 15:04:05", lastLogin); err == nil { account.LastLogin = t } } account.ClientVersion = uint16(clientVersion) return &account, nil } // UpdateAccountLogin updates account login timestamp and IP func (d *Database) UpdateAccountLogin(account *Account) error { query := ` UPDATE accounts SET last_login = ?, last_ip = ?, client_version = ? WHERE id = ?` return sqlitex.Execute(d.conn, query, &sqlitex.ExecOptions{ Args: []any{ account.LastLogin.Format("2006-01-02 15:04:05"), account.IPAddress, account.ClientVersion, account.ID, }, }) } // LoadCharacters loads all characters for an account func (d *Database) LoadCharacters(accountID int32, version uint16) ([]*Character, error) { query := ` SELECT id, server_id, name, level, race, gender, class, created_date, deleted FROM characters WHERE account_id = ? ORDER BY created_date ASC` stmt, err := d.conn.Prepare(query) if err != nil { return nil, fmt.Errorf("prepare failed: %w", err) } defer stmt.Finalize() stmt.BindInt64(1, int64(accountID)) var characters []*Character for { hasRow, err := stmt.Step() if err != nil { return nil, fmt.Errorf("query failed: %w", err) } if !hasRow { break } char := &Character{AccountID: accountID} char.ID = int32(stmt.ColumnInt64(0)) char.ServerID = int32(stmt.ColumnInt64(1)) char.Name = stmt.ColumnText(2) char.Level = int8(stmt.ColumnInt(3)) char.Race = int8(stmt.ColumnInt(4)) char.Gender = int8(stmt.ColumnInt(5)) char.Class = int8(stmt.ColumnInt(6)) if dateStr := stmt.ColumnText(7); dateStr != "" { if t, err := time.Parse("2006-01-02 15:04:05", dateStr); err == nil { char.CreatedDate = t } } char.Deleted = stmt.ColumnInt(8) != 0 characters = append(characters, char) } return characters, stmt.Err() } // CharacterNameExists checks if a character name is already taken func (d *Database) CharacterNameExists(name string, serverID int32) (bool, error) { query := ` SELECT COUNT(*) FROM characters WHERE name = ? AND server_id = ? AND deleted = 0` stmt, err := d.conn.Prepare(query) if err != nil { return false, err } defer stmt.Finalize() stmt.BindText(1, name) stmt.BindInt64(2, int64(serverID)) hasRow, err := stmt.Step() if err != nil { return false, err } if hasRow { return stmt.ColumnInt(0) > 0, nil } return false, nil } // CreateCharacter creates a new character in the database func (d *Database) CreateCharacter(char *Character) (int32, error) { query := ` INSERT INTO characters (account_id, server_id, name, level, race, gender, class, created_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` err := sqlitex.Execute(d.conn, query, &sqlitex.ExecOptions{ Args: []any{ char.AccountID, char.ServerID, char.Name, char.Level, char.Race, char.Gender, char.Class, char.CreatedDate.Format("2006-01-02 15:04:05"), }, }) if err != nil { return 0, fmt.Errorf("failed to create character: %w", err) } id := int32(d.conn.LastInsertRowID()) log.Printf("Created character %s (ID: %d) for account %d", char.Name, id, char.AccountID) return id, nil } // DeleteCharacter marks a character as deleted func (d *Database) DeleteCharacter(charID, accountID int32) error { query := ` UPDATE characters SET deleted = 1, deleted_date = CURRENT_TIMESTAMP WHERE id = ? AND account_id = ?` err := sqlitex.Execute(d.conn, query, &sqlitex.ExecOptions{ Args: []any{charID, accountID}, }) if err != nil { return fmt.Errorf("failed to delete character: %w", err) } // Check if any rows were affected stmt, _ := d.conn.Prepare("SELECT changes()") defer stmt.Finalize() if stmt.Step() && stmt.ColumnInt(0) == 0 { return fmt.Errorf("character not found or not owned by account") } log.Printf("Deleted character %d for account %d", charID, accountID) return nil } // GetWorldServers returns all configured world servers func (d *Database) GetWorldServers() ([]*WorldServer, error) { query := ` SELECT id, name, description, ip_address, port, status, population, locked, hidden, created_date FROM world_servers WHERE active = 1 ORDER BY sort_order, name` stmt, err := d.conn.Prepare(query) if err != nil { return nil, fmt.Errorf("prepare failed: %w", err) } defer stmt.Finalize() var servers []*WorldServer for { hasRow, err := stmt.Step() if err != nil { return nil, fmt.Errorf("query failed: %w", err) } if !hasRow { break } server := &WorldServer{} server.ID = int32(stmt.ColumnInt64(0)) server.Name = stmt.ColumnText(1) server.Description = stmt.ColumnText(2) server.IPAddress = stmt.ColumnText(3) server.Port = stmt.ColumnInt(4) server.Status = stmt.ColumnText(5) server.Population = int32(stmt.ColumnInt64(6)) server.Locked = stmt.ColumnInt(7) != 0 server.Hidden = stmt.ColumnInt(8) != 0 if dateStr := stmt.ColumnText(9); dateStr != "" { if t, err := time.Parse("2006-01-02 15:04:05", dateStr); err == nil { server.CreatedDate = t } } server.Online = server.Status == "online" server.PopulationLevel = d.calculatePopulationLevel(server.Population) servers = append(servers, server) } return servers, stmt.Err() } // UpdateWorldServerStats updates world server statistics func (d *Database) UpdateWorldServerStats(serverID int32, stats *WorldServerStats) error { query := ` INSERT OR REPLACE INTO world_server_stats (server_id, timestamp, population, zones_active, players_online, uptime_seconds) VALUES (?, CURRENT_TIMESTAMP, ?, ?, ?, ?)` return sqlitex.Execute(d.conn, query, &sqlitex.ExecOptions{ Args: []any{ serverID, stats.Population, stats.ZonesActive, stats.PlayersOnline, stats.UptimeSeconds, }, }) } // CleanupOldEntries removes old log entries and statistics func (d *Database) CleanupOldEntries() error { queries := []string{ "DELETE FROM login_attempts WHERE timestamp < datetime('now', '-30 days')", "DELETE FROM world_server_stats WHERE timestamp < datetime('now', '-7 days')", "DELETE FROM client_logs WHERE timestamp < datetime('now', '-14 days')", } for _, query := range queries { if err := sqlitex.ExecuteTransient(d.conn, query, nil); err != nil { log.Printf("Cleanup query failed: %v", err) } } return nil } // LogLoginAttempt records a login attempt for security monitoring func (d *Database) LogLoginAttempt(username, ipAddress string, success bool) error { query := ` INSERT INTO login_attempts (username, ip_address, success, timestamp) VALUES (?, ?, ?, CURRENT_TIMESTAMP)` return sqlitex.Execute(d.conn, query, &sqlitex.ExecOptions{ Args: []any{username, ipAddress, success}, }) } // GetMaxCharsSetting returns the maximum characters per account func (d *Database) GetMaxCharsSetting() int32 { var maxChars int32 = 7 // Default query := "SELECT value FROM server_settings WHERE name = 'max_characters_per_account'" stmt, err := d.conn.Prepare(query) if err != nil { return maxChars } defer stmt.Finalize() hasRow, err := stmt.Step() if err != nil { return maxChars } if hasRow { if val := stmt.ColumnText(0); val != "" { if parsed := stmt.ColumnInt64(0); parsed > 0 { maxChars = int32(parsed) } } } return maxChars } // GetAccountBonus returns veteran bonus flags for an account func (d *Database) GetAccountBonus(accountID int32) uint8 { var bonus uint8 = 0 query := "SELECT veteran_bonus FROM accounts WHERE id = ?" stmt, err := d.conn.Prepare(query) if err != nil { return bonus } defer stmt.Finalize() stmt.BindInt64(1, int64(accountID)) hasRow, err := stmt.Step() if err != nil { return bonus } if hasRow { bonus = uint8(stmt.ColumnInt(0)) } return bonus }