// Copyright (c) 2012-2014 Jeremy Latt // Copyright (c) 2016 Daniel Oaks // released under the MIT license package irc import ( "encoding/json" "fmt" "log" "os" "strings" "time" "github.com/oragono/oragono/irc/modes" "github.com/oragono/oragono/irc/utils" "github.com/tidwall/buntdb" ) const ( // 'version' of the database schema keySchemaVersion = "db.version" // latest schema of the db latestDbSchema = "8" ) type SchemaChanger func(*Config, *buntdb.Tx) error type SchemaChange struct { InitialVersion string // the change will take this version TargetVersion string // and transform it into this version Changer SchemaChanger } // maps an initial version to a schema change capable of upgrading it var schemaChanges map[string]SchemaChange type incompatibleSchemaError struct { currentVersion string requiredVersion string } func IncompatibleSchemaError(currentVersion string) (result *incompatibleSchemaError) { return &incompatibleSchemaError{ currentVersion: currentVersion, requiredVersion: latestDbSchema, } } func (err *incompatibleSchemaError) Error() string { return fmt.Sprintf("Database requires update. Expected schema v%s, got v%s", err.requiredVersion, err.currentVersion) } // InitDB creates the database, implementing the `oragono initdb` command. func InitDB(path string) { _, err := os.Stat(path) if err == nil { log.Fatal("Datastore already exists (delete it manually to continue): ", path) } else if !os.IsNotExist(err) { log.Fatal("Datastore path is inaccessible: ", err.Error()) } err = initializeDB(path) if err != nil { log.Fatal("Could not save datastore: ", err.Error()) } } // internal database initialization code func initializeDB(path string) error { store, err := buntdb.Open(path) if err != nil { return err } defer store.Close() err = store.Update(func(tx *buntdb.Tx) error { // set schema version tx.Set(keySchemaVersion, latestDbSchema, nil) return nil }) return err } // OpenDatabase returns an existing database, performing a schema version check. func OpenDatabase(config *Config) (*buntdb.DB, error) { return openDatabaseInternal(config, config.Datastore.AutoUpgrade) } // open the database, giving it at most one chance to auto-upgrade the schema func openDatabaseInternal(config *Config, allowAutoupgrade bool) (db *buntdb.DB, err error) { db, err = buntdb.Open(config.Datastore.Path) if err != nil { return } defer func() { if err != nil && db != nil { db.Close() db = nil } }() // read the current version string var version string err = db.View(func(tx *buntdb.Tx) error { version, err = tx.Get(keySchemaVersion) return err }) if err != nil { return } if version == latestDbSchema { // success return } // XXX quiesce the DB so we can be sure it's safe to make a backup copy db.Close() db = nil if allowAutoupgrade { err = performAutoUpgrade(version, config) if err != nil { return } // successful autoupgrade, let's try this again: return openDatabaseInternal(config, false) } else { err = IncompatibleSchemaError(version) return } } func performAutoUpgrade(currentVersion string, config *Config) (err error) { path := config.Datastore.Path log.Printf("attempting to auto-upgrade schema from version %s to %s\n", currentVersion, latestDbSchema) timestamp := time.Now().UTC().Format("2006-01-02-15:04:05.000Z") backupPath := fmt.Sprintf("%s.v%s.%s.bak", path, currentVersion, timestamp) log.Printf("making a backup of current database at %s\n", backupPath) err = utils.CopyFile(path, backupPath) if err != nil { return err } err = UpgradeDB(config) if err != nil { // database upgrade is a single transaction, so we don't need to restore the backup; // we can just delete it os.Remove(backupPath) } return err } // UpgradeDB upgrades the datastore to the latest schema. func UpgradeDB(config *Config) (err error) { store, err := buntdb.Open(config.Datastore.Path) if err != nil { return err } defer store.Close() var version string err = store.Update(func(tx *buntdb.Tx) error { for { version, _ = tx.Get(keySchemaVersion) change, schemaNeedsChange := schemaChanges[version] if !schemaNeedsChange { if version == latestDbSchema { // success! break } // unable to upgrade to the desired version, roll back return IncompatibleSchemaError(version) } log.Println("attempting to update schema from version " + version) err := change.Changer(config, tx) if err != nil { return err } _, _, err = tx.Set(keySchemaVersion, change.TargetVersion, nil) if err != nil { return err } log.Println("successfully updated schema to version " + change.TargetVersion) } return nil }) if err != nil { log.Printf("database upgrade failed and was rolled back: %v\n", err) } return err } func schemaChangeV1toV2(config *Config, tx *buntdb.Tx) error { // == version 1 -> 2 == // account key changes and account.verified key bugfix. var keysToRemove []string newKeys := make(map[string]string) tx.AscendKeys("account *", func(key, value string) bool { keysToRemove = append(keysToRemove, key) splitkey := strings.Split(key, " ") // work around bug if splitkey[2] == "exists" { // manually create new verified key newVerifiedKey := fmt.Sprintf("%s.verified %s", splitkey[0], splitkey[1]) newKeys[newVerifiedKey] = "1" } else if splitkey[1] == "%s" { return true } newKey := fmt.Sprintf("%s.%s %s", splitkey[0], splitkey[2], splitkey[1]) newKeys[newKey] = value return true }) for _, key := range keysToRemove { tx.Delete(key) } for key, value := range newKeys { tx.Set(key, value, nil) } return nil } // 1. channel founder names should be casefolded // 2. founder should be explicitly granted the ChannelFounder user mode // 3. explicitly initialize stored channel modes to the server default values func schemaChangeV2ToV3(config *Config, tx *buntdb.Tx) error { var channels []string prefix := "channel.exists " tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { if !strings.HasPrefix(key, prefix) { return false } chname := strings.TrimPrefix(key, prefix) channels = append(channels, chname) return true }) // founder names should be casefolded // founder should be explicitly granted the ChannelFounder user mode for _, channel := range channels { founderKey := "channel.founder " + channel founder, _ := tx.Get(founderKey) if founder != "" { founder, err := CasefoldName(founder) if err == nil { tx.Set(founderKey, founder, nil) accountToUmode := map[string]modes.Mode{ founder: modes.ChannelFounder, } atustr, _ := json.Marshal(accountToUmode) tx.Set("channel.accounttoumode "+channel, string(atustr), nil) } } } // explicitly store the channel modes defaultModes := config.Channels.defaultModes modeStrings := make([]string, len(defaultModes)) for i, mode := range defaultModes { modeStrings[i] = string(mode) } defaultModeString := strings.Join(modeStrings, "") for _, channel := range channels { tx.Set("channel.modes "+channel, defaultModeString, nil) } return nil } // 1. ban info format changed (from `legacyBanInfo` below to `IPBanInfo`) // 2. dlines against individual IPs are normalized into dlines against the appropriate /128 network func schemaChangeV3ToV4(config *Config, tx *buntdb.Tx) error { type ipRestrictTime struct { Duration time.Duration Expires time.Time } type legacyBanInfo struct { Reason string `json:"reason"` OperReason string `json:"oper_reason"` OperName string `json:"oper_name"` Time *ipRestrictTime `json:"time"` } now := time.Now() legacyToNewInfo := func(old legacyBanInfo) (new_ IPBanInfo) { new_.Reason = old.Reason new_.OperReason = old.OperReason new_.OperName = old.OperName if old.Time == nil { new_.TimeCreated = now new_.Duration = 0 } else { new_.TimeCreated = old.Time.Expires.Add(-1 * old.Time.Duration) new_.Duration = old.Time.Duration } return } var keysToDelete []string prefix := "bans.dline " dlines := make(map[string]IPBanInfo) tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { if !strings.HasPrefix(key, prefix) { return false } keysToDelete = append(keysToDelete, key) var lbinfo legacyBanInfo id := strings.TrimPrefix(key, prefix) err := json.Unmarshal([]byte(value), &lbinfo) if err != nil { log.Printf("error unmarshaling legacy dline: %v\n", err) return true } // legacy keys can be either an IP or a CIDR hostNet, err := utils.NormalizedNetFromString(id) if err != nil { log.Printf("error unmarshaling legacy dline network: %v\n", err) return true } dlines[utils.NetToNormalizedString(hostNet)] = legacyToNewInfo(lbinfo) return true }) setOptions := func(info IPBanInfo) *buntdb.SetOptions { if info.Duration == 0 { return nil } ttl := info.TimeCreated.Add(info.Duration).Sub(now) return &buntdb.SetOptions{Expires: true, TTL: ttl} } // store the new dlines for id, info := range dlines { b, err := json.Marshal(info) if err != nil { log.Printf("error marshaling migrated dline: %v\n", err) continue } tx.Set(fmt.Sprintf("bans.dlinev2 %s", id), string(b), setOptions(info)) } // same operations against klines prefix = "bans.kline " klines := make(map[string]IPBanInfo) tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { if !strings.HasPrefix(key, prefix) { return false } keysToDelete = append(keysToDelete, key) mask := strings.TrimPrefix(key, prefix) var lbinfo legacyBanInfo err := json.Unmarshal([]byte(value), &lbinfo) if err != nil { log.Printf("error unmarshaling legacy kline: %v\n", err) return true } klines[mask] = legacyToNewInfo(lbinfo) return true }) for mask, info := range klines { b, err := json.Marshal(info) if err != nil { log.Printf("error marshaling migrated kline: %v\n", err) continue } tx.Set(fmt.Sprintf("bans.klinev2 %s", mask), string(b), setOptions(info)) } // clean up all the old entries for _, key := range keysToDelete { tx.Delete(key) } return nil } // create new key tracking channels that belong to an account func schemaChangeV4ToV5(config *Config, tx *buntdb.Tx) error { founderToChannels := make(map[string][]string) prefix := "channel.founder " tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { if !strings.HasPrefix(key, prefix) { return false } channel := strings.TrimPrefix(key, prefix) founderToChannels[value] = append(founderToChannels[value], channel) return true }) for founder, channels := range founderToChannels { tx.Set(fmt.Sprintf("account.channels %s", founder), strings.Join(channels, ","), nil) } return nil } // custom nick enforcement was a separate db key, now it's part of settings func schemaChangeV5ToV6(config *Config, tx *buntdb.Tx) error { accountToEnforcement := make(map[string]NickEnforcementMethod) prefix := "account.customenforcement " tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { if !strings.HasPrefix(key, prefix) { return false } account := strings.TrimPrefix(key, prefix) method, err := nickReservationFromString(value) if err == nil { accountToEnforcement[account] = method } else { log.Printf("skipping corrupt custom enforcement value for %s\n", account) } return true }) for account, method := range accountToEnforcement { var settings AccountSettings settings.NickEnforcement = method text, err := json.Marshal(settings) if err != nil { return err } tx.Delete(prefix + account) tx.Set(fmt.Sprintf("account.settings %s", account), string(text), nil) } return nil } type maskInfoV7 struct { TimeCreated time.Time CreatorNickmask string CreatorAccount string } func schemaChangeV6ToV7(config *Config, tx *buntdb.Tx) error { now := time.Now().UTC() var channels []string prefix := "channel.exists " tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { if !strings.HasPrefix(key, prefix) { return false } channels = append(channels, strings.TrimPrefix(key, prefix)) return true }) converter := func(key string) { oldRawValue, err := tx.Get(key) if err != nil { return } var masks []string err = json.Unmarshal([]byte(oldRawValue), &masks) if err != nil { return } newCookedValue := make(map[string]maskInfoV7) for _, mask := range masks { normalizedMask, err := CanonicalizeMaskWildcard(mask) if err != nil { continue } newCookedValue[normalizedMask] = maskInfoV7{ TimeCreated: now, CreatorNickmask: "*", CreatorAccount: "*", } } newRawValue, err := json.Marshal(newCookedValue) if err != nil { return } tx.Set(key, string(newRawValue), nil) } prefixes := []string{ "channel.banlist %s", "channel.exceptlist %s", "channel.invitelist %s", } for _, channel := range channels { for _, prefix := range prefixes { converter(fmt.Sprintf(prefix, channel)) } } return nil } type accountSettingsLegacyV7 struct { AutoreplayLines *int NickEnforcement NickEnforcementMethod AllowBouncer BouncerAllowedSetting AutoreplayJoins bool } type accountSettingsLegacyV8 struct { AutoreplayLines *int NickEnforcement NickEnforcementMethod AllowBouncer BouncerAllowedSetting ReplayJoins ReplayJoinsSetting } // #616: change autoreplay-joins to replay-joins func schemaChangeV7ToV8(config *Config, tx *buntdb.Tx) error { prefix := "account.settings " var accounts, blobs []string tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { var legacy accountSettingsLegacyV7 var current accountSettingsLegacyV8 if !strings.HasPrefix(key, prefix) { return false } account := strings.TrimPrefix(key, prefix) err := json.Unmarshal([]byte(value), &legacy) if err != nil { log.Printf("corrupt record for %s: %v\n", account, err) return true } current.AutoreplayLines = legacy.AutoreplayLines current.NickEnforcement = legacy.NickEnforcement current.AllowBouncer = legacy.AllowBouncer if legacy.AutoreplayJoins { current.ReplayJoins = ReplayJoinsAlways } else { current.ReplayJoins = ReplayJoinsCommandsOnly } blob, err := json.Marshal(current) if err != nil { log.Printf("could not marshal record for %s: %v\n", account, err) return true } accounts = append(accounts, account) blobs = append(blobs, string(blob)) return true }) for i, account := range accounts { tx.Set(prefix+account, blobs[i], nil) } return nil } func init() { allChanges := []SchemaChange{ { InitialVersion: "1", TargetVersion: "2", Changer: schemaChangeV1toV2, }, { InitialVersion: "2", TargetVersion: "3", Changer: schemaChangeV2ToV3, }, { InitialVersion: "3", TargetVersion: "4", Changer: schemaChangeV3ToV4, }, { InitialVersion: "4", TargetVersion: "5", Changer: schemaChangeV4ToV5, }, { InitialVersion: "5", TargetVersion: "6", Changer: schemaChangeV5ToV6, }, { InitialVersion: "6", TargetVersion: "7", Changer: schemaChangeV6ToV7, }, { InitialVersion: "7", TargetVersion: "8", Changer: schemaChangeV7ToV8, }, } // build the index schemaChanges = make(map[string]SchemaChange) for _, change := range allChanges { schemaChanges[change.InitialVersion] = change } }