396 lines
10 KiB
Go
396 lines
10 KiB
Go
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
|
|
}
|