DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,779 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
const (
|
||||
usePeriodOffset = 0x0B
|
||||
legacyExpireOffset = 0x2B
|
||||
itemExpireOffset = 0x38
|
||||
)
|
||||
|
||||
var (
|
||||
safeIdentifier = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
|
||||
timeSections = map[string]struct{}{
|
||||
"expiration date": {},
|
||||
"expiration info": {},
|
||||
"usable datetime": {},
|
||||
"usable period": {},
|
||||
"usable period after unsealing": {},
|
||||
}
|
||||
expirationExtraKeys = []string{"expire_time", "expire_unix", "usable_period_days", "expiration_source"}
|
||||
)
|
||||
|
||||
type instanceConfig struct {
|
||||
Database struct {
|
||||
Mode string `json:"mode"`
|
||||
Path string `json:"path"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Name string `json:"name"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
} `json:"database"`
|
||||
}
|
||||
|
||||
type itemKinds struct {
|
||||
Stackable bool
|
||||
Equipment bool
|
||||
}
|
||||
|
||||
type containerReport struct {
|
||||
Container string `json:"container"`
|
||||
RowsScanned int `json:"rows_scanned"`
|
||||
TargetRows int `json:"target_rows"`
|
||||
RowsChanged int `json:"rows_changed"`
|
||||
RuntimePreserved int `json:"runtime_expiration_preserved"`
|
||||
}
|
||||
|
||||
type report struct {
|
||||
Mode string `json:"mode"`
|
||||
Database string `json:"database"`
|
||||
PVFPath string `json:"pvf_path"`
|
||||
PVFSHA256 string `json:"pvf_sha256"`
|
||||
PVFItemFilesTimed int `json:"pvf_item_files_timed"`
|
||||
PVFItemIDsTimed int `json:"pvf_item_ids_timed"`
|
||||
Containers []containerReport `json:"containers"`
|
||||
RowsChanged int `json:"rows_changed"`
|
||||
RuntimePreserved int `json:"runtime_expiration_preserved"`
|
||||
Apply bool `json:"apply"`
|
||||
Committed bool `json:"committed"`
|
||||
ExpirationKeysPurged []string `json:"expiration_keys_purged"`
|
||||
}
|
||||
|
||||
type mysqlTableSpec struct {
|
||||
itemSuffix string
|
||||
extraSuffix string
|
||||
keyColumns []string
|
||||
}
|
||||
|
||||
type mysqlItemRow struct {
|
||||
keys []string
|
||||
itemID int64
|
||||
expireAt sql.NullTime
|
||||
raw []byte
|
||||
}
|
||||
|
||||
type patchResult struct {
|
||||
changed bool
|
||||
preserved bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
instancePath := flag.String("instance", `D:/DNF/runtime/config/instance.json`, "runtime instance configuration")
|
||||
pvfPath := flag.String("pvf", `D:/DNF/runtime/data/dnf/Script.pvf`, "original PVF containing item time-limit definitions")
|
||||
apply := flag.Bool("apply", false, "commit the migration; default is a read-only audit")
|
||||
flag.Parse()
|
||||
|
||||
result, err := run(*instancePath, *pvfPath, *apply)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "removeitemexpirydb:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
encoded, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "removeitemexpirydb: encode report:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(string(encoded))
|
||||
}
|
||||
|
||||
func run(instancePath, pvfPath string, apply bool) (report, error) {
|
||||
var result report
|
||||
instancePath, err := filepath.Abs(filepath.Clean(strings.TrimSpace(instancePath)))
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("resolve instance path: %w", err)
|
||||
}
|
||||
pvfPath, err = filepath.Abs(filepath.Clean(strings.TrimSpace(pvfPath)))
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("resolve PVF path: %w", err)
|
||||
}
|
||||
configurationBytes, err := os.ReadFile(instancePath)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("read instance config: %w", err)
|
||||
}
|
||||
var configuration instanceConfig
|
||||
if err := json.Unmarshal(configurationBytes, &configuration); err != nil {
|
||||
return result, fmt.Errorf("decode instance config: %w", err)
|
||||
}
|
||||
targets, timedFiles, pvfHash, err := timedItemIDs(pvfPath)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result = report{
|
||||
Mode: strings.ToLower(strings.TrimSpace(configuration.Database.Mode)),
|
||||
PVFPath: pvfPath,
|
||||
PVFSHA256: pvfHash,
|
||||
PVFItemFilesTimed: timedFiles,
|
||||
PVFItemIDsTimed: len(targets),
|
||||
Apply: apply,
|
||||
ExpirationKeysPurged: append([]string(nil), expirationExtraKeys...),
|
||||
}
|
||||
if result.Mode == "" {
|
||||
result.Mode = "portable"
|
||||
}
|
||||
switch result.Mode {
|
||||
case "portable", "mysql":
|
||||
result.Database = configuration.Database.Name
|
||||
result.Containers, result.Committed, err = migrateMySQL(configuration, targets, apply)
|
||||
case "sqlite":
|
||||
sqlitePath := strings.TrimSpace(configuration.Database.Path)
|
||||
if sqlitePath == "" {
|
||||
sqlitePath = "data/dnf90.db"
|
||||
}
|
||||
if !filepath.IsAbs(sqlitePath) {
|
||||
runtimeRoot := filepath.Dir(filepath.Dir(instancePath))
|
||||
sqlitePath = filepath.Join(runtimeRoot, filepath.FromSlash(sqlitePath))
|
||||
}
|
||||
sqlitePath = filepath.Clean(sqlitePath)
|
||||
result.Database = sqlitePath
|
||||
result.Containers, result.Committed, err = migrateSQLite(sqlitePath, targets, apply)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported database mode %q", result.Mode)
|
||||
}
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, container := range result.Containers {
|
||||
result.RowsChanged += container.RowsChanged
|
||||
result.RuntimePreserved += container.RuntimePreserved
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func timedItemIDs(pvfPath string) (map[int64]itemKinds, int, string, error) {
|
||||
hash, err := hashFile(pvfPath)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
archive, err := platformpvf.Open(pvfPath)
|
||||
if err != nil {
|
||||
return nil, 0, "", fmt.Errorf("open source PVF: %w", err)
|
||||
}
|
||||
timedPaths := make(map[string]struct{})
|
||||
for _, file := range archive.Files() {
|
||||
archivePath := normalizeArchivePath(file.ArchivePath)
|
||||
if file.DataType != 1 || (!strings.HasPrefix(archivePath, "stackable/") && !strings.HasPrefix(archivePath, "equipment/")) {
|
||||
continue
|
||||
}
|
||||
extension := strings.ToLower(filepath.Ext(archivePath))
|
||||
if extension != ".stk" && extension != ".equ" {
|
||||
continue
|
||||
}
|
||||
raw, readErr := archive.FileRaw(file.Index)
|
||||
if readErr != nil {
|
||||
return nil, 0, "", fmt.Errorf("read item definition %s: %w", file.ArchivePath, readErr)
|
||||
}
|
||||
if hasItemTimeSection(raw, archive.ResolveString) {
|
||||
timedPaths[archivePath] = struct{}{}
|
||||
}
|
||||
}
|
||||
targets := make(map[int64]itemKinds)
|
||||
for _, list := range []struct {
|
||||
kind string
|
||||
path string
|
||||
root string
|
||||
}{
|
||||
{kind: "stackable", path: "stackable/stackable.lst", root: "stackable"},
|
||||
{kind: "equipment", path: "equipment/equipment.lst", root: "equipment"},
|
||||
} {
|
||||
text, readErr := archive.ReadText(list.path)
|
||||
if readErr != nil {
|
||||
return nil, 0, "", fmt.Errorf("read %s: %w", list.path, readErr)
|
||||
}
|
||||
document, parseErr := dnfpvf.Parse(list.path, text)
|
||||
if parseErr != nil {
|
||||
return nil, 0, "", fmt.Errorf("parse %s: %w", list.path, parseErr)
|
||||
}
|
||||
for _, entry := range dnfpvf.ParseList(document) {
|
||||
actual := resolveListedPath(archive, list.root, entry.Path)
|
||||
if _, timed := timedPaths[normalizeArchivePath(actual)]; !timed {
|
||||
continue
|
||||
}
|
||||
kind := targets[entry.ID]
|
||||
if list.kind == "stackable" {
|
||||
kind.Stackable = true
|
||||
} else {
|
||||
kind.Equipment = true
|
||||
}
|
||||
targets[entry.ID] = kind
|
||||
}
|
||||
}
|
||||
return targets, len(timedPaths), hash, nil
|
||||
}
|
||||
|
||||
func hasItemTimeSection(raw []byte, resolve func(int) string) bool {
|
||||
if len(raw)%5 != 0 {
|
||||
return false
|
||||
}
|
||||
for offset := 0; offset < len(raw); offset += 5 {
|
||||
if raw[offset] != 3 {
|
||||
continue
|
||||
}
|
||||
value := int(int32(binary.LittleEndian.Uint32(raw[offset+1 : offset+5])))
|
||||
if _, found := timeSections[normalizeSection(resolve(value))]; found {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeSection(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if len(value) >= 2 && value[0] == '[' && value[len(value)-1] == ']' {
|
||||
value = strings.TrimSpace(value[1 : len(value)-1])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeArchivePath(value string) string {
|
||||
return strings.ToLower(strings.TrimPrefix(filepath.ToSlash(filepath.Clean(strings.TrimSpace(value))), "./"))
|
||||
}
|
||||
|
||||
func resolveListedPath(archive *platformpvf.Archive, root, listed string) string {
|
||||
listed = strings.Trim(strings.TrimSpace(listed), "`\"'")
|
||||
for _, candidate := range []string{listed, filepath.ToSlash(filepath.Join(root, filepath.FromSlash(listed)))} {
|
||||
if file, found := archive.FindFile(candidate); found {
|
||||
return file.ArchivePath
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func migrateMySQL(configuration instanceConfig, targets map[int64]itemKinds, apply bool) ([]containerReport, bool, error) {
|
||||
if !safeIdentifier.MatchString(configuration.Database.Name) {
|
||||
return nil, false, fmt.Errorf("unsafe database name %q", configuration.Database.Name)
|
||||
}
|
||||
mysqlConfig := mysql.NewConfig()
|
||||
mysqlConfig.User = configuration.Database.User
|
||||
mysqlConfig.Passwd = configuration.Database.Password
|
||||
mysqlConfig.Net = "tcp"
|
||||
mysqlConfig.Addr = net.JoinHostPort(configuration.Database.Host, strconv.Itoa(configuration.Database.Port))
|
||||
mysqlConfig.DBName = configuration.Database.Name
|
||||
mysqlConfig.ParseTime = true
|
||||
mysqlConfig.Loc = time.UTC
|
||||
database, err := sql.Open("mysql", mysqlConfig.FormatDSN())
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("open MySQL: %w", err)
|
||||
}
|
||||
defer database.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if err := database.PingContext(ctx); err != nil {
|
||||
return nil, false, fmt.Errorf("ping MySQL: %w", err)
|
||||
}
|
||||
tables, err := mysqlTables(ctx, database, configuration.Database.Name)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
tx, err := database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("begin MySQL item expiration migration: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
specs := []mysqlTableSpec{
|
||||
{itemSuffix: "account_inventory_items", extraSuffix: "account_inventory_item_extra", keyColumns: []string{"account_id", "entry_key"}},
|
||||
{itemSuffix: "inventory_items", extraSuffix: "inventory_item_extra", keyColumns: []string{"character_id", "collection_name", "entry_key"}},
|
||||
{itemSuffix: "equipment_entries", extraSuffix: "equipment_entry_extra", keyColumns: []string{"character_id", "entry_key"}},
|
||||
{itemSuffix: "pet_artifacts", extraSuffix: "pet_artifact_extra", keyColumns: []string{"character_id", "entry_key"}},
|
||||
{itemSuffix: "mail_attachments", extraSuffix: "mail_attachment_extra", keyColumns: []string{"character_id", "mail_id", "attachment_index"}},
|
||||
}
|
||||
reports := make([]containerReport, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
itemTable := tables[spec.itemSuffix]
|
||||
extraTable := tables[spec.extraSuffix]
|
||||
if itemTable == "" || extraTable == "" {
|
||||
continue
|
||||
}
|
||||
rowReport, migrateErr := migrateMySQLTable(ctx, tx, itemTable, extraTable, spec.keyColumns, targets, apply)
|
||||
if migrateErr != nil {
|
||||
return nil, false, migrateErr
|
||||
}
|
||||
reports = append(reports, rowReport)
|
||||
}
|
||||
if !apply {
|
||||
return reports, false, nil
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, false, fmt.Errorf("commit MySQL item expiration migration: %w", err)
|
||||
}
|
||||
return reports, true, nil
|
||||
}
|
||||
|
||||
func mysqlTables(ctx context.Context, database *sql.DB, databaseName string) (map[string]string, error) {
|
||||
wanted := map[string]struct{}{
|
||||
"account_inventory_items": {}, "account_inventory_item_extra": {},
|
||||
"inventory_items": {}, "inventory_item_extra": {},
|
||||
"equipment_entries": {}, "equipment_entry_extra": {},
|
||||
"pet_artifacts": {}, "pet_artifact_extra": {},
|
||||
"mail_attachments": {}, "mail_attachment_extra": {},
|
||||
}
|
||||
rows, err := database.QueryContext(ctx, `SELECT table_name FROM information_schema.tables WHERE table_schema = ?`, databaseName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list MySQL tables: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var table string
|
||||
if err := rows.Scan(&table); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !safeIdentifier.MatchString(table) {
|
||||
return nil, fmt.Errorf("unsafe table name %q", table)
|
||||
}
|
||||
for suffix := range wanted {
|
||||
if table == suffix || strings.HasSuffix(table, "_"+suffix) {
|
||||
result[suffix] = table
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func migrateMySQLTable(ctx context.Context, tx *sql.Tx, itemTable, extraTable string, keyColumns []string, targets map[int64]itemKinds, apply bool) (containerReport, error) {
|
||||
result := containerReport{Container: itemTable}
|
||||
quotedKeys := make([]string, len(keyColumns))
|
||||
for index, column := range keyColumns {
|
||||
if !safeIdentifier.MatchString(column) {
|
||||
return result, fmt.Errorf("unsafe key column %q", column)
|
||||
}
|
||||
quotedKeys[index] = "`" + column + "`"
|
||||
}
|
||||
query := "SELECT " + strings.Join(quotedKeys, ", ") + ", item_id, expire_at, raw_entry FROM `" + itemTable + "`"
|
||||
rows, err := tx.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("scan %s: %w", itemTable, err)
|
||||
}
|
||||
var candidates []mysqlItemRow
|
||||
for rows.Next() {
|
||||
result.RowsScanned++
|
||||
row := mysqlItemRow{keys: make([]string, len(keyColumns))}
|
||||
destinations := make([]any, 0, len(keyColumns)+3)
|
||||
for index := range row.keys {
|
||||
destinations = append(destinations, &row.keys[index])
|
||||
}
|
||||
destinations = append(destinations, &row.itemID, &row.expireAt, &row.raw)
|
||||
if err := rows.Scan(destinations...); err != nil {
|
||||
rows.Close()
|
||||
return result, fmt.Errorf("read %s row: %w", itemTable, err)
|
||||
}
|
||||
if _, target := targets[row.itemID]; target {
|
||||
result.TargetRows++
|
||||
row.raw = append([]byte(nil), row.raw...)
|
||||
candidates = append(candidates, row)
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
where := make([]string, len(keyColumns))
|
||||
for index, column := range quotedKeys {
|
||||
where[index] = column + " = ?"
|
||||
}
|
||||
for _, row := range candidates {
|
||||
extra, err := mysqlExtras(ctx, tx, extraTable, where, row.keys)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
patchedRaw, patch := makePermanent(row.raw, row.expireAt, extra, targets[row.itemID])
|
||||
if patch.preserved {
|
||||
result.RuntimePreserved++
|
||||
continue
|
||||
}
|
||||
if !patch.changed {
|
||||
continue
|
||||
}
|
||||
result.RowsChanged++
|
||||
if !apply {
|
||||
continue
|
||||
}
|
||||
args := []any{patchedRaw}
|
||||
for _, key := range row.keys {
|
||||
args = append(args, key)
|
||||
}
|
||||
update := "UPDATE `" + itemTable + "` SET expire_at = NULL, raw_entry = ? WHERE " + strings.Join(where, " AND ")
|
||||
if _, err := tx.ExecContext(ctx, update, args...); err != nil {
|
||||
return result, fmt.Errorf("update %s expiration: %w", itemTable, err)
|
||||
}
|
||||
deleteArgs := make([]any, 0, len(row.keys)+len(expirationExtraKeys))
|
||||
for _, key := range row.keys {
|
||||
deleteArgs = append(deleteArgs, key)
|
||||
}
|
||||
placeholders := make([]string, len(expirationExtraKeys))
|
||||
for index, key := range expirationExtraKeys {
|
||||
placeholders[index] = "?"
|
||||
deleteArgs = append(deleteArgs, key)
|
||||
}
|
||||
deleteQuery := "DELETE FROM `" + extraTable + "` WHERE " + strings.Join(where, " AND ") + " AND LOWER(extra_key) IN (" + strings.Join(placeholders, ",") + ")"
|
||||
if _, err := tx.ExecContext(ctx, deleteQuery, deleteArgs...); err != nil {
|
||||
return result, fmt.Errorf("delete %s expiration metadata: %w", extraTable, err)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mysqlExtras(ctx context.Context, tx *sql.Tx, table string, where []string, keys []string) (map[string]string, error) {
|
||||
args := make([]any, len(keys))
|
||||
for index, key := range keys {
|
||||
args[index] = key
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, "SELECT extra_key, extra_value FROM `"+table+"` WHERE "+strings.Join(where, " AND "), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s metadata: %w", table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var key, value string
|
||||
if err := rows.Scan(&key, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[strings.ToLower(strings.TrimSpace(key))] = value
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func makePermanent(raw []byte, expireAt sql.NullTime, extra map[string]string, kinds itemKinds) ([]byte, patchResult) {
|
||||
if preserveRuntimeExpiration(extra) {
|
||||
return raw, patchResult{preserved: true}
|
||||
}
|
||||
changed := expireAt.Valid
|
||||
oldUnix := expirationUnixCandidates(raw, expireAt, extra)
|
||||
patched := append([]byte(nil), raw...)
|
||||
hadRawExpiry := false
|
||||
if len(patched) >= itemExpireOffset+4 {
|
||||
hadRawExpiry = binary.LittleEndian.Uint32(patched[itemExpireOffset:itemExpireOffset+4]) != 0
|
||||
if hadRawExpiry {
|
||||
binary.LittleEndian.PutUint32(patched[itemExpireOffset:itemExpireOffset+4], 0)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if len(patched) >= legacyExpireOffset+4 {
|
||||
legacy := binary.LittleEndian.Uint32(patched[legacyExpireOffset : legacyExpireOffset+4])
|
||||
if legacy != 0 && oldUnix[legacy] {
|
||||
binary.LittleEndian.PutUint32(patched[legacyExpireOffset:legacyExpireOffset+4], 0)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
stackable := strings.EqualFold(strings.TrimSpace(extra["item_kind"]), "stackable") || (kinds.Stackable && !kinds.Equipment)
|
||||
hadMetadata := false
|
||||
for _, key := range expirationExtraKeys {
|
||||
if _, found := extra[key]; found {
|
||||
hadMetadata = true
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if stackable && len(patched) >= usePeriodOffset+2 && (expireAt.Valid || hadRawExpiry || hadMetadata) && binary.LittleEndian.Uint16(patched[usePeriodOffset:usePeriodOffset+2]) != 0 {
|
||||
binary.LittleEndian.PutUint16(patched[usePeriodOffset:usePeriodOffset+2], 0)
|
||||
changed = true
|
||||
}
|
||||
return patched, patchResult{changed: changed}
|
||||
}
|
||||
|
||||
func preserveRuntimeExpiration(extra map[string]string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(extra["source"]), "pvf_rental") {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(extra["expiration_source"]), "runtime_pvf_cera_shop_duration_grant")
|
||||
}
|
||||
|
||||
func expirationUnixCandidates(raw []byte, expireAt sql.NullTime, extra map[string]string) map[uint32]bool {
|
||||
result := make(map[uint32]bool)
|
||||
if expireAt.Valid && expireAt.Time.Unix() > 0 && expireAt.Time.Unix() <= int64(^uint32(0)) {
|
||||
result[uint32(expireAt.Time.Unix())] = true
|
||||
}
|
||||
for _, key := range []string{"expire_time", "expire_unix"} {
|
||||
value, err := strconv.ParseUint(strings.TrimSpace(extra[key]), 10, 32)
|
||||
if err == nil && value != 0 {
|
||||
result[uint32(value)] = true
|
||||
}
|
||||
}
|
||||
if len(raw) >= itemExpireOffset+4 {
|
||||
if value := binary.LittleEndian.Uint32(raw[itemExpireOffset : itemExpireOffset+4]); value != 0 {
|
||||
result[value] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func migrateSQLite(path string, targets map[int64]itemKinds, apply bool) ([]containerReport, bool, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, false, fmt.Errorf("inspect SQLite database: %w", err)
|
||||
}
|
||||
database, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("open SQLite: %w", err)
|
||||
}
|
||||
defer database.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
tx, err := database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("begin SQLite item expiration migration: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
kinds := []string{"account_inventory", "inventory", "equipment", "pet", "mailbox"}
|
||||
reports := make([]containerReport, 0, len(kinds))
|
||||
for _, recordKind := range kinds {
|
||||
rowReport, migrateErr := migrateSQLiteKind(ctx, tx, recordKind, targets, apply)
|
||||
if migrateErr != nil {
|
||||
return nil, false, migrateErr
|
||||
}
|
||||
reports = append(reports, rowReport)
|
||||
}
|
||||
if !apply {
|
||||
return reports, false, nil
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, false, fmt.Errorf("commit SQLite item expiration migration: %w", err)
|
||||
}
|
||||
return reports, true, nil
|
||||
}
|
||||
|
||||
type sqliteRecord struct {
|
||||
key string
|
||||
payload []byte
|
||||
checksum string
|
||||
}
|
||||
|
||||
func migrateSQLiteKind(ctx context.Context, tx *sql.Tx, recordKind string, targets map[int64]itemKinds, apply bool) (containerReport, error) {
|
||||
result := containerReport{Container: "dnf_repository_records:" + recordKind}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT record_key, payload, checksum FROM dnf_repository_records WHERE record_kind = ? ORDER BY record_key`, recordKind)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("scan SQLite %s records: %w", recordKind, err)
|
||||
}
|
||||
var records []sqliteRecord
|
||||
for rows.Next() {
|
||||
var record sqliteRecord
|
||||
if err := rows.Scan(&record.key, &record.payload, &record.checksum); err != nil {
|
||||
rows.Close()
|
||||
return result, err
|
||||
}
|
||||
result.RowsScanned++
|
||||
sum := sha256.Sum256(record.payload)
|
||||
if !strings.EqualFold(hex.EncodeToString(sum[:]), record.checksum) {
|
||||
rows.Close()
|
||||
return result, fmt.Errorf("SQLite checksum mismatch for %s %q", recordKind, record.key)
|
||||
}
|
||||
record.payload = append([]byte(nil), record.payload...)
|
||||
records = append(records, record)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, record := range records {
|
||||
patched, targetRows, changedRows, preservedRows, err := patchSQLitePayload(recordKind, record.payload, targets)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("patch SQLite %s %q: %w", recordKind, record.key, err)
|
||||
}
|
||||
result.TargetRows += targetRows
|
||||
result.RowsChanged += changedRows
|
||||
result.RuntimePreserved += preservedRows
|
||||
if !apply || changedRows == 0 {
|
||||
continue
|
||||
}
|
||||
sum := sha256.Sum256(patched)
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE dnf_repository_records SET payload = ?, checksum = ?, updated_at = ? WHERE record_kind = ? AND record_key = ?`,
|
||||
patched, hex.EncodeToString(sum[:]), time.Now().UTC().Format(time.RFC3339Nano), recordKind, record.key); err != nil {
|
||||
return result, fmt.Errorf("update SQLite %s %q: %w", recordKind, record.key, err)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func patchSQLitePayload(recordKind string, payload []byte, targets map[int64]itemKinds) ([]byte, int, int, int, error) {
|
||||
targetRows, changedRows, preservedRows := 0, 0, 0
|
||||
encode := func(value any) error {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err == nil {
|
||||
payload = encoded
|
||||
}
|
||||
return err
|
||||
}
|
||||
patchStack := func(stack *dnfrepo.ItemStack) {
|
||||
kinds, target := targets[stack.ItemID]
|
||||
if !target {
|
||||
return
|
||||
}
|
||||
targetRows++
|
||||
patchedRaw, result := makePermanent(stack.RawEntry, nullTime(stack.ExpireAt), stack.Extra, kinds)
|
||||
if result.preserved {
|
||||
preservedRows++
|
||||
return
|
||||
}
|
||||
if !result.changed {
|
||||
return
|
||||
}
|
||||
stack.ExpireAt = time.Time{}
|
||||
stack.RawEntry = patchedRaw
|
||||
removeExpirationExtra(stack.Extra)
|
||||
changedRows++
|
||||
}
|
||||
switch recordKind {
|
||||
case "account_inventory":
|
||||
var record dnfrepo.AccountInventoryRecord
|
||||
if err := json.Unmarshal(payload, &record); err != nil {
|
||||
return nil, 0, 0, 0, err
|
||||
}
|
||||
for key, stack := range record.Slots {
|
||||
patchStack(&stack)
|
||||
record.Slots[key] = stack
|
||||
}
|
||||
if err := encode(record); err != nil {
|
||||
return nil, 0, 0, 0, err
|
||||
}
|
||||
case "inventory":
|
||||
var record dnfrepo.InventoryRecord
|
||||
if err := json.Unmarshal(payload, &record); err != nil {
|
||||
return nil, 0, 0, 0, err
|
||||
}
|
||||
for key, stack := range record.Slots {
|
||||
patchStack(&stack)
|
||||
record.Slots[key] = stack
|
||||
}
|
||||
for key, stack := range record.Warehouse {
|
||||
patchStack(&stack)
|
||||
record.Warehouse[key] = stack
|
||||
}
|
||||
if err := encode(record); err != nil {
|
||||
return nil, 0, 0, 0, err
|
||||
}
|
||||
case "equipment":
|
||||
var record dnfrepo.EquipmentRecord
|
||||
if err := json.Unmarshal(payload, &record); err != nil {
|
||||
return nil, 0, 0, 0, err
|
||||
}
|
||||
for key, entry := range record.Entries {
|
||||
kinds, target := targets[entry.ItemID]
|
||||
if !target {
|
||||
continue
|
||||
}
|
||||
targetRows++
|
||||
patchedRaw, result := makePermanent(entry.RawEntry, nullTime(entry.ExpireAt), entry.Extra, kinds)
|
||||
if result.preserved {
|
||||
preservedRows++
|
||||
continue
|
||||
}
|
||||
if result.changed {
|
||||
entry.ExpireAt = time.Time{}
|
||||
entry.RawEntry = patchedRaw
|
||||
removeExpirationExtra(entry.Extra)
|
||||
record.Entries[key] = entry
|
||||
changedRows++
|
||||
}
|
||||
}
|
||||
if err := encode(record); err != nil {
|
||||
return nil, 0, 0, 0, err
|
||||
}
|
||||
case "pet":
|
||||
var record dnfrepo.PetRecord
|
||||
if err := json.Unmarshal(payload, &record); err != nil {
|
||||
return nil, 0, 0, 0, err
|
||||
}
|
||||
for key, stack := range record.Artifacts {
|
||||
patchStack(&stack)
|
||||
record.Artifacts[key] = stack
|
||||
}
|
||||
if err := encode(record); err != nil {
|
||||
return nil, 0, 0, 0, err
|
||||
}
|
||||
case "mailbox":
|
||||
var record dnfrepo.MailboxRecord
|
||||
if err := json.Unmarshal(payload, &record); err != nil {
|
||||
return nil, 0, 0, 0, err
|
||||
}
|
||||
for mailKey, mail := range record.Mails {
|
||||
for index := range mail.Attachments {
|
||||
attachment := &mail.Attachments[index]
|
||||
kinds, target := targets[attachment.ItemID]
|
||||
if !target {
|
||||
continue
|
||||
}
|
||||
targetRows++
|
||||
patchedRaw, result := makePermanent(attachment.RawEntry, nullTime(attachment.ExpireAt), attachment.Extra, kinds)
|
||||
if result.preserved {
|
||||
preservedRows++
|
||||
continue
|
||||
}
|
||||
if result.changed {
|
||||
attachment.ExpireAt = time.Time{}
|
||||
attachment.RawEntry = patchedRaw
|
||||
removeExpirationExtra(attachment.Extra)
|
||||
changedRows++
|
||||
}
|
||||
}
|
||||
record.Mails[mailKey] = mail
|
||||
}
|
||||
if err := encode(record); err != nil {
|
||||
return nil, 0, 0, 0, err
|
||||
}
|
||||
default:
|
||||
return nil, 0, 0, 0, fmt.Errorf("unsupported record kind %q", recordKind)
|
||||
}
|
||||
return payload, targetRows, changedRows, preservedRows, nil
|
||||
}
|
||||
|
||||
func nullTime(value time.Time) sql.NullTime {
|
||||
return sql.NullTime{Time: value, Valid: !value.IsZero()}
|
||||
}
|
||||
|
||||
func removeExpirationExtra(extra map[string]string) {
|
||||
for _, key := range expirationExtraKeys {
|
||||
delete(extra, key)
|
||||
}
|
||||
}
|
||||
|
||||
func hashFile(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s for hashing: %w", path, err)
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return strings.ToUpper(hex.EncodeToString(sum[:])), nil
|
||||
}
|
||||
Reference in new issue
Block a user