package companion import ( "context" "database/sql" "fmt" "os" "strings" "time" _ "modernc.org/sqlite" ) var openSQLite = sql.Open type SCUMSQLiteSource struct{ db *sql.DB } func NewSCUMSQLiteSource(db *sql.DB) (*SCUMSQLiteSource, error) { if db == nil { return nil, fmt.Errorf("SCUM database source is required") } db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) return &SCUMSQLiteSource{db: db}, nil } func OpenSCUMSQLiteSourceFromEnv(envName string) (*SCUMSQLiteSource, error) { name := strings.TrimSpace(envName) if name == "" { name = SCUMDatabaseFileEnvironment } databaseFile := strings.TrimSpace(os.Getenv(name)) if databaseFile == "" { return nil, fmt.Errorf("%s is required for SCUM database collection", name) } info, err := os.Stat(databaseFile) if err != nil || info.IsDir() { return nil, fmt.Errorf("%s must reference a readable SCUM database file", name) } db, err := openSQLite("sqlite", databaseFile) if err != nil { return nil, fmt.Errorf("open SCUM database source: %w", err) } if _, err := db.Exec("PRAGMA query_only = ON"); err != nil { _ = db.Close() return nil, fmt.Errorf("prepare SCUM database source for read-only collection: %w", err) } if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil { _ = db.Close() return nil, fmt.Errorf("prepare SCUM database source timeout: %w", err) } return NewSCUMSQLiteSource(db) } func (source *SCUMSQLiteSource) Close() error { if source == nil || source.db == nil { return nil } return source.db.Close() } func (source *SCUMSQLiteSource) ReadPositionRows(ctx context.Context, limit int) ([]map[string]any, error) { return source.readRows(ctx, scumPositionRowsSQL, limit, sql.Named("subjectType", nil), sql.Named("subjectId", nil), sql.Named("limit", boundedTrajectoryLimit(limit)), ) } func (source *SCUMSQLiteSource) ReadVehicleRows(ctx context.Context, limit int) ([]map[string]any, error) { return source.readRows(ctx, scumVehicleRowsSQL, limit, sql.Named("vehicleId", nil), sql.Named("search", nil), sql.Named("limit", boundedTrajectoryLimit(limit)), ) } func (source *SCUMSQLiteSource) readRows(ctx context.Context, query string, limit int, args ...any) ([]map[string]any, error) { if source == nil || source.db == nil { return nil, fmt.Errorf("SCUM database source is not configured") } rows, err := source.db.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("read SCUM database rows: %w", err) } defer rows.Close() columns, err := rows.Columns() if err != nil { return nil, fmt.Errorf("read SCUM database columns: %w", err) } maxRows := boundedTrajectoryLimit(limit) result := make([]map[string]any, 0, maxRows) values := make([]any, len(columns)) scanTargets := make([]any, len(columns)) for index := range values { scanTargets[index] = &values[index] } for rows.Next() { if len(result) >= maxRows { break } if err := rows.Scan(scanTargets...); err != nil { return nil, fmt.Errorf("scan SCUM database rows: %w", err) } row := make(map[string]any, len(columns)) for index, column := range columns { row[column] = normalizeSQLiteValue(values[index]) } result = append(result, row) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("read SCUM database rows: %w", err) } return result, nil } func boundedTrajectoryLimit(limit int) int { if limit <= 0 { return DefaultTrajectoryCollectionMaxRows } if limit > 5000 { return 5000 } return limit } func normalizeSQLiteValue(value any) any { switch typed := value.(type) { case []byte: return string(typed) case time.Time: return typed.UTC().Format(time.RFC3339Nano) default: return typed } } const scumPositionRowsSQL = `SELECT 'player' AS subjectType, account.id AS subjectId, CAST(profile.id AS TEXT) AS userProfileId, CAST(prisoner.id AS TEXT) AS gamePlayerId, NULL AS vehicleId, CAST(entity.id AS TEXT) AS entityId, NULL AS baseId, entity.location_x AS x, entity.location_y AS y, entity.location_z AS z, strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS observedAt FROM user_profile profile JOIN user account ON account.id = profile.user_id JOIN prisoner ON prisoner.id = profile.prisoner_id JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id JOIN entity ON entity.id = prisoner_entity.entity_id WHERE (:subjectType IS NULL OR :subjectType = 'player') AND (:subjectId IS NULL OR account.id = :subjectId) UNION ALL SELECT 'vehicle', CAST(spawner.vehicle_entity_id AS TEXT), NULL, NULL, CAST(spawner.vehicle_entity_id AS TEXT), CAST(entity.id AS TEXT), NULL, entity.location_x, entity.location_y, entity.location_z, strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch') FROM vehicle_spawner spawner JOIN entity ON entity.id = spawner.vehicle_entity_id WHERE (:subjectType IS NULL OR :subjectType = 'vehicle') AND (:subjectId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :subjectId) LIMIT COALESCE(:limit, 500)` const scumVehicleRowsSQL = `SELECT CAST(spawner.vehicle_entity_id AS TEXT) AS vehicleId, CAST(spawner.vehicle_entity_id AS TEXT) AS entityId, entity.class AS className, spawner.vehicle_alias AS label, entity.location_x AS x, entity.location_y AS y, entity.location_z AS z, strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch') AS lastAccessTime, spawner.is_vehicle_functional AS isFunctional FROM vehicle_spawner spawner JOIN entity ON entity.id = spawner.vehicle_entity_id WHERE (:vehicleId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :vehicleId) AND (:search IS NULL OR spawner.vehicle_alias LIKE '%' || :search || '%' OR entity.class LIKE '%' || :search || '%') ORDER BY spawner.vehicle_last_access_time DESC LIMIT COALESCE(:limit, 500)`