• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / tg-spam / 24966212061

26 Apr 2026 08:23PM UTC coverage: 83.078% (+0.1%) from 82.966%
24966212061

Pull #294

github

web-flow
fix: preserve InstanceID across loadConfigFromDB swap; rename collided subtests (#397)

* fix: preserve InstanceID across loadConfigFromDB swap; rename collided subtests

`loadConfigFromDB` does `*settings = *dbSettings` which clobbers the
CLI/env-supplied `InstanceID` with whatever the persisted blob carries.
External orchestrators that write per-instance config blobs without
embedding `instance_id` (e.g. tg-spam-manager) leave it empty, so after
the swap `settings.InstanceID == ""`. Initial load works because the
short-lived store inside loadConfigFromDB was already keyed by the
still-CLI value, but every subsequent `makeDB` call in `activateServer`
opens with `gid=""` — including the runtime SettingsStore.

Symptoms: `POST /config/reload` returns
`500 "no settings found in database: sql: no rows in result set"` from a
clean state. The first UI Save with `saveToDb=true` then writes a fresh
row under `gid=""`, so subsequent reloads succeed against a
manager-orphaned row, leaving manager-side updates and bot-side updates
on different rows.

Fix: snapshot `settings.InstanceID` before the swap and restore it when
the loaded blob's value is empty. A blob that does carry its own
non-empty `InstanceID` (saved by tg-spam itself) is still trusted, so
existing single-binary deployments are unaffected.

Same patch also gives unique names to the three subtests in
`TestDetector_CheckOpenAI` that collided post-master-merge — Go was
running them under `#01` suffixes which made `-run` filtering ambiguous.
Two are byte-identical to their earlier siblings; the third differs
slightly (uses `spam` instead of `viagra` and asserts the LoadStopWords
result), and the new name reflects that.

* add WARN on InstanceID divergence + regression test for empty-blob branch

Address review:

- log a [WARN] when the persisted blob carries a non-empty InstanceID
  that differs from the CLI/env value. Behaviour is unchanged (blob
  still wins) but the divergence shifts the runtime gi... (continued)
Pull Request #294: Implement database configuration support

1290 of 1591 new or added lines in 7 files covered. (81.08%)

10 existing lines in 2 files now uncovered.

8174 of 9839 relevant lines covered (83.08%)

232.02 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.42
/app/settings.go
1
package main
2

3
import (
4
        "context"
5
        "fmt"
6
        "log"
7
        "reflect"
8
        "strconv"
9
        "time"
10

11
        "github.com/umputun/tg-spam/app/config"
12
)
13

14
// optToSettings converts CLI options to the domain settings model
15
func optToSettings(opts options) *config.Settings {
9✔
16
        // create settings model from options
9✔
17
        settings := &config.Settings{
9✔
18
                InstanceID: opts.InstanceID,
9✔
19

9✔
20
                Telegram: config.TelegramSettings{
9✔
21
                        Group:        opts.Telegram.Group,
9✔
22
                        IdleDuration: opts.Telegram.IdleDuration,
9✔
23
                        Timeout:      opts.Telegram.Timeout,
9✔
24
                },
9✔
25

9✔
26
                Admin: config.AdminSettings{
9✔
27
                        AdminGroup:              opts.AdminGroup,
9✔
28
                        DisableAdminSpamForward: opts.DisableAdminSpamForward,
9✔
29
                        TestingIDs:              opts.TestingIDs,
9✔
30
                        SuperUsers:              opts.SuperUsers,
9✔
31
                },
9✔
32

9✔
33
                History: config.HistorySettings{
9✔
34
                        Duration: opts.HistoryDuration,
9✔
35
                        MinSize:  opts.HistoryMinSize,
9✔
36
                        Size:     opts.HistorySize,
9✔
37
                },
9✔
38

9✔
39
                Logger: config.LoggerSettings{
9✔
40
                        Enabled:    opts.Logger.Enabled,
9✔
41
                        FileName:   opts.Logger.FileName,
9✔
42
                        MaxSize:    opts.Logger.MaxSize,
9✔
43
                        MaxBackups: opts.Logger.MaxBackups,
9✔
44
                },
9✔
45

9✔
46
                CAS: config.CASSettings{
9✔
47
                        API:       opts.CAS.API,
9✔
48
                        Timeout:   opts.CAS.Timeout,
9✔
49
                        UserAgent: opts.CAS.UserAgent,
9✔
50
                },
9✔
51

9✔
52
                Meta: config.MetaSettings{
9✔
53
                        LinksLimit:      opts.Meta.LinksLimit,
9✔
54
                        MentionsLimit:   opts.Meta.MentionsLimit,
9✔
55
                        ImageOnly:       opts.Meta.ImageOnly,
9✔
56
                        LinksOnly:       opts.Meta.LinksOnly,
9✔
57
                        VideosOnly:      opts.Meta.VideosOnly,
9✔
58
                        AudiosOnly:      opts.Meta.AudiosOnly,
9✔
59
                        Forward:         opts.Meta.Forward,
9✔
60
                        Keyboard:        opts.Meta.Keyboard,
9✔
61
                        UsernameSymbols: opts.Meta.UsernameSymbols,
9✔
62
                        ContactOnly:     opts.Meta.ContactOnly,
9✔
63
                        Giveaway:        opts.Meta.Giveaway,
9✔
64
                },
9✔
65

9✔
66
                OpenAI: config.OpenAISettings{
9✔
67
                        APIBase:            opts.OpenAI.APIBase,
9✔
68
                        Veto:               opts.OpenAI.Veto,
9✔
69
                        Prompt:             opts.OpenAI.Prompt,
9✔
70
                        CustomPrompts:      opts.OpenAI.CustomPrompts,
9✔
71
                        Model:              opts.OpenAI.Model,
9✔
72
                        MaxTokensResponse:  opts.OpenAI.MaxTokensResponse,
9✔
73
                        MaxTokensRequest:   opts.OpenAI.MaxTokensRequest,
9✔
74
                        MaxSymbolsRequest:  opts.OpenAI.MaxSymbolsRequest,
9✔
75
                        RetryCount:         opts.OpenAI.RetryCount,
9✔
76
                        HistorySize:        opts.OpenAI.HistorySize,
9✔
77
                        ReasoningEffort:    opts.OpenAI.ReasoningEffort,
9✔
78
                        CheckShortMessages: opts.OpenAI.CheckShortMessages,
9✔
79
                },
9✔
80

9✔
81
                Gemini: config.GeminiSettings{
9✔
82
                        Veto:               opts.Gemini.Veto,
9✔
83
                        Prompt:             opts.Gemini.Prompt,
9✔
84
                        CustomPrompts:      opts.Gemini.CustomPrompts,
9✔
85
                        Model:              opts.Gemini.Model,
9✔
86
                        MaxTokensResponse:  opts.Gemini.MaxTokensResponse,
9✔
87
                        MaxSymbolsRequest:  opts.Gemini.MaxSymbolsRequest,
9✔
88
                        RetryCount:         opts.Gemini.RetryCount,
9✔
89
                        HistorySize:        opts.Gemini.HistorySize,
9✔
90
                        CheckShortMessages: opts.Gemini.CheckShortMessages,
9✔
91
                },
9✔
92

9✔
93
                LLM: config.LLMSettings{
9✔
94
                        Consensus:      opts.LLM.Consensus,
9✔
95
                        RequestTimeout: opts.LLM.RequestTimeout,
9✔
96
                },
9✔
97

9✔
98
                Delete: config.DeleteSettings{
9✔
99
                        JoinMessages:  opts.Delete.JoinMessages,
9✔
100
                        LeaveMessages: opts.Delete.LeaveMessages,
9✔
101
                },
9✔
102

9✔
103
                Duplicates: config.DuplicatesSettings{
9✔
104
                        Threshold: opts.Duplicates.Threshold,
9✔
105
                        Window:    opts.Duplicates.Window,
9✔
106
                },
9✔
107

9✔
108
                Reactions: config.ReactionsSettings{
9✔
109
                        MaxReactions: opts.Reactions.MaxReactions,
9✔
110
                        Window:       opts.Reactions.Window,
9✔
111
                },
9✔
112

9✔
113
                Report: config.ReportSettings{
9✔
114
                        Enabled:          opts.Report.Enabled,
9✔
115
                        Threshold:        opts.Report.Threshold,
9✔
116
                        AutoBanThreshold: opts.Report.AutoBanThreshold,
9✔
117
                        RateLimit:        opts.Report.RateLimit,
9✔
118
                        RatePeriod:       opts.Report.RatePeriod,
9✔
119
                },
9✔
120

9✔
121
                LuaPlugins: config.LuaPluginsSettings{
9✔
122
                        Enabled:        opts.LuaPlugins.Enabled,
9✔
123
                        PluginsDir:     opts.LuaPlugins.PluginsDir,
9✔
124
                        EnabledPlugins: opts.LuaPlugins.EnabledPlugins,
9✔
125
                        DynamicReload:  opts.LuaPlugins.DynamicReload,
9✔
126
                },
9✔
127

9✔
128
                AbnormalSpace: config.AbnormalSpaceSettings{
9✔
129
                        Enabled:                 opts.AbnormalSpacing.Enabled,
9✔
130
                        SpaceRatioThreshold:     opts.AbnormalSpacing.SpaceRatioThreshold,
9✔
131
                        ShortWordRatioThreshold: opts.AbnormalSpacing.ShortWordRatioThreshold,
9✔
132
                        ShortWordLen:            opts.AbnormalSpacing.ShortWordLen,
9✔
133
                        MinWords:                opts.AbnormalSpacing.MinWords,
9✔
134
                },
9✔
135

9✔
136
                Files: config.FilesSettings{
9✔
137
                        SamplesDataPath: opts.Files.SamplesDataPath,
9✔
138
                        DynamicDataPath: opts.Files.DynamicDataPath,
9✔
139
                        WatchInterval:   int(opts.Files.WatchInterval.Seconds()),
9✔
140
                },
9✔
141

9✔
142
                Message: config.MessageSettings{
9✔
143
                        Startup: opts.Message.Startup,
9✔
144
                        Spam:    opts.Message.Spam,
9✔
145
                        Dry:     opts.Message.Dry,
9✔
146
                        Warn:    opts.Message.Warn,
9✔
147
                },
9✔
148

9✔
149
                Server: config.ServerSettings{
9✔
150
                        Enabled:    opts.Server.Enabled,
9✔
151
                        ListenAddr: opts.Server.ListenAddr,
9✔
152
                },
9✔
153

9✔
154
                SimilarityThreshold:    opts.SimilarityThreshold,
9✔
155
                MinMsgLen:              opts.MinMsgLen,
9✔
156
                MaxEmoji:               opts.MaxEmoji,
9✔
157
                MinSpamProbability:     opts.MinSpamProbability,
9✔
158
                MultiLangWords:         opts.MultiLangWords,
9✔
159
                NoSpamReply:            opts.NoSpamReply,
9✔
160
                SuppressJoinMessage:    opts.SuppressJoinMessage,
9✔
161
                AggressiveCleanup:      opts.AggressiveCleanup,
9✔
162
                AggressiveCleanupLimit: opts.AggressiveCleanupLimit,
9✔
163
                ParanoidMode:           opts.ParanoidMode,
9✔
164
                FirstMessagesCount:     opts.FirstMessagesCount,
9✔
165
                Training:               opts.Training,
9✔
166
                SoftBan:                opts.SoftBan,
9✔
167
                Convert:                opts.Convert,
9✔
168
                MaxBackups:             opts.MaxBackups,
9✔
169
                Dry:                    opts.Dry,
9✔
170
        }
9✔
171

9✔
172
        // set transient settings (not persisted to database)
9✔
173
        settings.Transient = config.TransientSettings{
9✔
174
                DataBaseURL:        opts.DataBaseURL,
9✔
175
                StorageTimeout:     opts.StorageTimeout,
9✔
176
                ConfigDB:           opts.ConfigDB,
9✔
177
                Dbg:                opts.Dbg,
9✔
178
                TGDbg:              opts.TGDbg,
9✔
179
                WebAuthPasswd:      opts.Server.AuthPasswd,
9✔
180
                ConfigDBEncryptKey: opts.ConfigDBEncryptKey,
9✔
181
        }
9✔
182

9✔
183
        // set credentials in their respective domain structures
9✔
184
        settings.Telegram.Token = opts.Telegram.Token
9✔
185
        settings.OpenAI.Token = opts.OpenAI.Token
9✔
186
        settings.Gemini.Token = opts.Gemini.Token
9✔
187
        settings.Server.AuthHash = opts.Server.AuthHash
9✔
188

9✔
189
        return settings
9✔
190
}
9✔
191

192
// defaultSettingsTemplate builds a *config.Settings populated with the canonical
193
// CLI defaults expressed as `default:` struct tags on the options type. Used as
194
// a single source of truth by both applyCLIOverrides (for "is this value the
195
// CLI default?" comparisons) and (*config.Settings).ApplyDefaults (for filling
196
// missing keys in DB-loaded blobs).
197
//
198
// Pure reflection is used instead of go-flags.Parse([]string{}) on purpose:
199
// go-flags reads os.LookupEnv during default-fill, which would let ambient
200
// environment values like SERVER_LISTEN leak into the template and silently
201
// break both override comparisons and defaults-fill semantics.
202
func defaultSettingsTemplate() (*config.Settings, error) {
7✔
203
        var opts options
7✔
204
        if err := applyStructTagDefaults(reflect.ValueOf(&opts).Elem()); err != nil {
7✔
NEW
205
                return nil, fmt.Errorf("failed to derive defaults from struct tags: %w", err)
×
NEW
206
        }
×
207
        return optToSettings(opts), nil
7✔
208
}
209

210
// durationType is reused by applyStructTagDefaults to identify time.Duration
211
// fields, whose reflect.Kind is Int64 but which require time.ParseDuration.
212
var durationType = reflect.TypeFor[time.Duration]()
213

214
// applyStructTagDefaults walks v recursively, parses each leaf field's
215
// `default:"..."` struct tag, and assigns a typed value via the dispatcher
216
// below. Nested struct fields are recursed into; fields without a default tag
217
// keep their Go zero value (matching CLI behavior when the flag is omitted
218
// and no env var is set).
219
//
220
// Returns an error for any field type the dispatcher does not handle, so a
221
// future contributor adding a new kind (e.g., uint) gets caught by tests
222
// rather than producing a silently-wrong template.
223
func applyStructTagDefaults(v reflect.Value) error {
442✔
224
        if v.Kind() != reflect.Struct {
442✔
NEW
225
                return nil
×
NEW
226
        }
×
227
        t := v.Type()
442✔
228
        for i := 0; i < v.NumField(); i++ {
3,666✔
229
                ft := t.Field(i)
3,224✔
230
                if !ft.IsExported() {
3,224✔
NEW
231
                        continue
×
232
                }
233
                f := v.Field(i)
3,224✔
234
                // recurse into plain nested structs; time.Duration has Kind=Int64 so it
3,224✔
235
                // falls through to the leaf branch below
3,224✔
236
                if f.Kind() == reflect.Struct && f.Type() != durationType {
3,640✔
237
                        if err := applyStructTagDefaults(f); err != nil {
416✔
NEW
238
                                return fmt.Errorf("%s: %w", ft.Name, err)
×
NEW
239
                        }
×
240
                        continue
416✔
241
                }
242
                tag, ok := ft.Tag.Lookup("default")
2,808✔
243
                if !ok {
4,030✔
244
                        continue
1,222✔
245
                }
246
                if !f.CanSet() {
1,586✔
NEW
247
                        continue
×
248
                }
249
                if err := setFieldFromDefaultTag(f, tag); err != nil {
1,586✔
NEW
250
                        return fmt.Errorf("%s: %w", ft.Name, err)
×
NEW
251
                }
×
252
        }
253
        return nil
442✔
254
}
255

256
// setFieldFromDefaultTag parses tag according to f's type and assigns the
257
// resulting typed value to f. Supports the field types currently used by the
258
// options struct: string, bool, int family, uint family, float family, and
259
// time.Duration. Slice/map/pointer fields are not used with default tags in
260
// the options struct and will return an error if added without dispatcher
261
// support.
262
func setFieldFromDefaultTag(f reflect.Value, tag string) error {
1,601✔
263
        // time.Duration (Kind=Int64) must be checked before the generic int branch
1,601✔
264
        if f.Type() == durationType {
1,864✔
265
                d, err := time.ParseDuration(tag)
263✔
266
                if err != nil {
264✔
267
                        return fmt.Errorf("invalid duration default %q: %w", tag, err)
1✔
268
                }
1✔
269
                f.SetInt(int64(d))
262✔
270
                return nil
262✔
271
        }
272
        switch f.Kind() {
1,338✔
273
        case reflect.String:
522✔
274
                f.SetString(tag)
522✔
275
        case reflect.Bool:
3✔
276
                b, err := strconv.ParseBool(tag)
3✔
277
                if err != nil {
4✔
278
                        return fmt.Errorf("invalid bool default %q: %w", tag, err)
1✔
279
                }
1✔
280
                f.SetBool(b)
2✔
281
        case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
707✔
282
                i, err := strconv.ParseInt(tag, 10, f.Type().Bits())
707✔
283
                if err != nil {
708✔
284
                        return fmt.Errorf("invalid int default %q: %w", tag, err)
1✔
285
                }
1✔
286
                f.SetInt(i)
706✔
NEW
287
        case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
×
NEW
288
                u, err := strconv.ParseUint(tag, 10, f.Type().Bits())
×
NEW
289
                if err != nil {
×
NEW
290
                        return fmt.Errorf("invalid uint default %q: %w", tag, err)
×
NEW
291
                }
×
NEW
292
                f.SetUint(u)
×
293
        case reflect.Float32, reflect.Float64:
105✔
294
                fl, err := strconv.ParseFloat(tag, f.Type().Bits())
105✔
295
                if err != nil {
105✔
NEW
296
                        return fmt.Errorf("invalid float default %q: %w", tag, err)
×
NEW
297
                }
×
298
                f.SetFloat(fl)
105✔
299
        default:
1✔
300
                return fmt.Errorf("unsupported field kind %s for default tag %q", f.Kind(), tag)
1✔
301
        }
302
        return nil
1,335✔
303
}
304

305
// applyCLIOverrides applies explicit CLI overrides to settings loaded from database.
306
// Only overrides values that differ from their zero/default values — the DB remains
307
// the source of truth for any field the operator did not explicitly pass on the CLI.
308
//
309
// The defaults template is used to distinguish "operator passed the default value
310
// explicitly" from "operator did not pass the flag at all" for fields that have a
311
// `default:` struct tag. For fields without a default tag, an empty/zero CLI value
312
// is treated as "not passed".
313
//
314
// How to add new overrides:
315
//  1. Check if the CLI option was explicitly provided (not using default value)
316
//  2. Compare with the default value from the template (or "" / zero for fields
317
//     without a default tag)
318
//  3. Apply the override only if the value differs from the default
319
func applyCLIOverrides(settings *config.Settings, opts options, defaults *config.Settings) {
17✔
320
        // override credentials if provided on CLI. This lets an operator rotate tokens
17✔
321
        // without touching the DB — empty CLI value leaves the DB-stored token in place.
17✔
322
        if opts.Telegram.Token != "" {
18✔
323
                settings.Telegram.Token = opts.Telegram.Token
1✔
324
        }
1✔
325
        if opts.OpenAI.Token != "" {
18✔
326
                settings.OpenAI.Token = opts.OpenAI.Token
1✔
327
        }
1✔
328
        if opts.Gemini.Token != "" {
18✔
329
                settings.Gemini.Token = opts.Gemini.Token
1✔
330
        }
1✔
331

332
        // override auth password if explicitly provided (not using default "auto")
333
        if opts.Server.AuthPasswd != "auto" {
20✔
334
                settings.Transient.WebAuthPasswd = opts.Server.AuthPasswd
3✔
335
                settings.Transient.AuthFromCLI = true
3✔
336
                // clear auth hash since we have a new password
3✔
337
                settings.Server.AuthHash = ""
3✔
338
        }
3✔
339

340
        // override auth hash if explicitly provided
341
        if opts.Server.AuthHash != "" {
19✔
342
                settings.Server.AuthHash = opts.Server.AuthHash
2✔
343
                settings.Transient.AuthFromCLI = true
2✔
344
                // clear password since hash takes precedence
2✔
345
                settings.Transient.WebAuthPasswd = ""
2✔
346
        }
2✔
347

348
        // operational CLI overrides (dry-run, listen addr, file paths) live in a
349
        // separate helper because they must also be reapplied after POST
350
        // /config/reload — credentials/auth above intentionally are not reapplied
351
        // (DB rotation wins on reload), so the split keeps reload semantics narrow.
352
        applyOperationalCLIOverrides(settings, opts, defaults)
17✔
353
}
354

355
// applyOperationalCLIOverrides reapplies the subset of CLI overrides that
356
// must survive POST /config/reload: dry-run, listen address, dynamic and
357
// samples data paths. These are operational knobs an operator chose at
358
// startup and the DB's persisted value should never silently override them
359
// just because the operator clicked Reload.
360
func applyOperationalCLIOverrides(settings *config.Settings, opts options, defaults *config.Settings) {
19✔
361
        // override dry-run if explicitly enabled via CLI (default is false).
19✔
362
        // false never overrides the DB value; to disable dry-run after enabling it,
19✔
363
        // use the settings UI or save-config.
19✔
364
        if opts.Dry {
21✔
365
                settings.Dry = true
2✔
366
        }
2✔
367

368
        // override server listen address if operator passed a non-default value;
369
        // preserves DB value when CLI is left at the ":8080" default
370
        if opts.Server.ListenAddr != defaults.Server.ListenAddr {
21✔
371
                settings.Server.ListenAddr = opts.Server.ListenAddr
2✔
372
        }
2✔
373

374
        // override dynamic data path if operator passed a non-default value;
375
        // preserves DB value when CLI is left at the "data" default
376
        if opts.Files.DynamicDataPath != defaults.Files.DynamicDataPath {
21✔
377
                settings.Files.DynamicDataPath = opts.Files.DynamicDataPath
2✔
378
        }
2✔
379

380
        // override samples data path when operator passes a non-empty value;
381
        // SamplesDataPath has no default tag — empty means "use DynamicDataPath",
382
        // so empty CLI never overrides the DB value
383
        if opts.Files.SamplesDataPath != "" {
21✔
384
                settings.Files.SamplesDataPath = opts.Files.SamplesDataPath
2✔
385
        }
2✔
386
}
387

388
// applyAutoAuthFallback enables auto-generated password mode as a safety net
389
// when --confdb leaves the web UI without any auth material. It only fires
390
// when the server is enabled and no hash/password is present anywhere AND
391
// the operator did not deliberately opt out via --server.auth=. The
392
// !AuthFromCLI guard preserves the explicit opt-out semantics set by
393
// applyCLIOverrides when the operator passes any --server.auth= value other
394
// than "auto" (including empty, which disables auth on purpose).
395
//
396
// Without this fallback, a fresh DB row missing AuthHash would silently
397
// expose the web UI without authentication. With it, the legacy CLI behavior
398
// (default --server.auth=auto generates a random password) is preserved
399
// even when settings come from the database.
400
//
401
// Setting AuthFromCLI=true is load-bearing: the hash that activateServer
402
// later derives from "auto" is held only in memory (the DB row is empty by
403
// definition when the fallback fires). loadConfigHandler must then preserve
404
// the in-memory AuthHash across /config/reload, otherwise the next reload
405
// would silently drop the generated hash and a subsequent /config save
406
// would persist an empty hash to the DB.
407
func applyAutoAuthFallback(settings *config.Settings) {
9✔
408
        if settings.Server.Enabled &&
9✔
409
                settings.Server.AuthHash == "" &&
9✔
410
                settings.Transient.WebAuthPasswd == "" &&
9✔
411
                !settings.Transient.AuthFromCLI {
11✔
412
                log.Print("[WARN] no auth configured (DB empty, no CLI override) — generating random password")
2✔
413
                settings.Transient.WebAuthPasswd = "auto"
2✔
414
                settings.Transient.AuthFromCLI = true
2✔
415
        }
2✔
416
}
417

418
// loadConfigFromDB loads configuration from the database. Any field that was
419
// absent in the persisted JSON blob (and therefore loaded as Go zero) is filled
420
// from defaults, so legacy or partial blobs still yield a fully-populated
421
// settings value. Fields whose zero is a meaningful operator choice
422
// (zeroAwarePaths in the config package) are preserved regardless.
423
//
424
// Passing a nil defaults template disables the fill step — used by a few tests
425
// that care only about round-trip behavior.
426
func loadConfigFromDB(ctx context.Context, settings, defaults *config.Settings) error {
12✔
427
        log.Print("[INFO] loading configuration from database")
12✔
428

12✔
429
        // create database connection using the same logic as main data DB
12✔
430
        db, err := makeDB(ctx, settings)
12✔
431
        if err != nil {
12✔
NEW
432
                return fmt.Errorf("failed to connect to database for config: %w", err)
×
NEW
433
        }
×
434
        defer func() {
24✔
435
                if closeErr := db.Close(); closeErr != nil {
12✔
NEW
436
                        log.Printf("[WARN] failed to close config database: %v", closeErr)
×
NEW
437
                }
×
438
        }()
439

440
        // create settings store with encryption if key provided
441
        var storeOpts []config.StoreOption
12✔
442
        if settings.Transient.ConfigDBEncryptKey != "" {
14✔
443
                crypter, cryptErr := config.NewCrypter(settings.Transient.ConfigDBEncryptKey, settings.InstanceID)
2✔
444
                if cryptErr != nil {
2✔
NEW
445
                        return fmt.Errorf("invalid encryption key: %w", cryptErr)
×
NEW
446
                }
×
447
                storeOpts = append(storeOpts, config.WithCrypter(crypter))
2✔
448
                log.Print("[INFO] configuration encryption enabled for database access")
2✔
449
        }
450

451
        settingsStore, err := config.NewStore(ctx, db, storeOpts...)
12✔
452
        if err != nil {
12✔
NEW
453
                return fmt.Errorf("failed to create settings store: %w", err)
×
NEW
454
        }
×
455

456
        // load settings
457
        dbSettings, err := settingsStore.Load(ctx)
12✔
458
        if err != nil {
13✔
459
                return fmt.Errorf("failed to load settings from database: %w", err)
1✔
460
        }
1✔
461

462
        // save original transient values only (non-functional values)
463
        transient := settings.Transient
11✔
464

11✔
465
        // preserve InstanceID supplied by --instance-id / INSTANCE_ID. This field
11✔
466
        // is the storage gid for every per-instance store (settings, samples,
11✔
467
        // detected spam, locator, etc.) and the Argon2 salt for sensitive-field
11✔
468
        // encryption. Letting an empty value from the DB blob overwrite it
11✔
469
        // leaves activateServer constructing engines with gid="" and the runtime
11✔
470
        // SettingsStore bound to the wrong gid, so POST /config/reload fails with
11✔
471
        // "no settings found in database". Affects deployments that write the
11✔
472
        // per-instance config blob from outside tg-spam (the wrapper builds the
11✔
473
        // JSON itself and may not embed instance_id).
11✔
474
        instanceID := settings.InstanceID
11✔
475

11✔
476
        // replace settings with loaded values including credentials
11✔
477
        *settings = *dbSettings
11✔
478

11✔
479
        // restore transient values
11✔
480
        settings.Transient = transient
11✔
481

11✔
482
        // restore InstanceID from CLI when the DB blob doesn't carry one. If the
11✔
483
        // blob has its own non-empty InstanceID (e.g. saved by tg-spam itself),
11✔
484
        // trust the persisted value to avoid silently rebinding storage. Warn
11✔
485
        // on divergence: a mismatch shifts both the runtime gid and the Argon2
11✔
486
        // salt derived in app/config/crypt.go, so the next save would re-encrypt
11✔
487
        // sensitive fields under a different key.
11✔
488
        if settings.InstanceID == "" {
12✔
489
                settings.InstanceID = instanceID
1✔
490
        } else if instanceID != "" && settings.InstanceID != instanceID {
11✔
NEW
491
                log.Printf("[WARN] persisted instance_id %q differs from CLI value %q; "+
×
NEW
492
                        "using persisted value, but next save will re-encrypt sensitive fields under a different Argon2 salt",
×
NEW
493
                        settings.InstanceID, instanceID)
×
NEW
494
        }
×
495

496
        // fill any field left zero by a partial/legacy blob from the CLI-default template
497
        settings.ApplyDefaults(defaults)
11✔
498

11✔
499
        log.Printf("[INFO] configuration loaded from database successfully")
11✔
500
        return nil
11✔
501
}
502

503
// saveConfigToDB saves the current configuration to the database
504
func saveConfigToDB(ctx context.Context, settings *config.Settings) error {
10✔
505
        log.Print("[INFO] saving configuration to database")
10✔
506

10✔
507
        // create database connection
10✔
508
        db, err := makeDB(ctx, settings)
10✔
509
        if err != nil {
10✔
NEW
510
                return fmt.Errorf("failed to connect to database for config: %w", err)
×
NEW
511
        }
×
512
        defer func() {
20✔
513
                if closeErr := db.Close(); closeErr != nil {
10✔
NEW
514
                        log.Printf("[WARN] failed to close config database: %v", closeErr)
×
NEW
515
                }
×
516
        }()
517

518
        // create settings store with encryption if key provided
519
        var storeOpts []config.StoreOption
10✔
520
        if settings.Transient.ConfigDBEncryptKey != "" {
12✔
521
                crypter, cryptErr := config.NewCrypter(settings.Transient.ConfigDBEncryptKey, settings.InstanceID)
2✔
522
                if cryptErr != nil {
2✔
NEW
523
                        return fmt.Errorf("invalid encryption key: %w", cryptErr)
×
NEW
524
                }
×
525
                storeOpts = append(storeOpts, config.WithCrypter(crypter))
2✔
526
                log.Print("[INFO] configuration encryption enabled for database storage")
2✔
527
        }
528

529
        settingsStore, err := config.NewStore(ctx, db, storeOpts...)
10✔
530
        if err != nil {
10✔
NEW
531
                return fmt.Errorf("failed to create settings store: %w", err)
×
NEW
532
        }
×
533

534
        // generate auth hash if password is provided but hash isn't
535
        if settings.Transient.WebAuthPasswd != "" && settings.Server.AuthHash == "" {
11✔
536
                // generate bcrypt hash from the password
1✔
537
                hash, hashErr := generateAuthHash(settings.Transient.WebAuthPasswd)
1✔
538
                if hashErr != nil {
1✔
NEW
539
                        return fmt.Errorf("failed to generate auth hash: %w", hashErr)
×
NEW
540
                }
×
541

542
                // update the hash directly in the Server settings domain
543
                settings.Server.AuthHash = hash
1✔
544
        }
545

546
        // save settings to database
547
        if err := settingsStore.Save(ctx, settings); err != nil {
10✔
NEW
548
                return fmt.Errorf("failed to save configuration to database: %w", err)
×
NEW
549
        }
×
550

551
        log.Printf("[INFO] configuration saved to database successfully")
10✔
552
        return nil
10✔
553
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc