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

umputun / tg-spam / 15672803049

16 Jun 2025 05:42AM UTC coverage: 79.352% (-2.1%) from 81.499%
15672803049

Pull #294

github

umputun
Add CLI override functionality for auth credentials in database mode

- Created applyCLIOverrides function to handle selective CLI parameter overrides
- Currently handles --server.auth and --server.auth-hash overrides
- Only applies overrides when values differ from defaults
- Auth hash takes precedence over password when both are provided
- Added comprehensive unit tests covering all override scenarios
- Function is extensible for future CLI override needs (documented in comments)

This fixes the issue where users couldn't change auth credentials when using
database configuration mode (--confdb), as the save-config command would
overwrite all settings rather than just the auth credentials.
Pull Request #294: Implement database configuration support

891 of 1298 new or added lines in 9 files covered. (68.64%)

174 existing lines in 4 files now uncovered.

5734 of 7226 relevant lines covered (79.35%)

57.45 hits per line

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

52.7
/app/main.go
1
package main
2

3
import (
4
        "context"
5
        "encoding/json"
6
        "errors"
7
        "fmt"
8
        "io"
9
        "log"
10
        "math"
11
        "net/http"
12
        "os"
13
        "os/exec"
14
        "os/signal"
15
        "path/filepath"
16
        "sort"
17
        "strconv"
18
        "strings"
19
        "syscall"
20
        "time"
21

22
        tbapi "github.com/OvyFlash/telegram-bot-api"
23
        "github.com/fatih/color"
24
        "github.com/go-pkgz/fileutils"
25
        "github.com/go-pkgz/lgr"
26
        "github.com/go-pkgz/rest"
27
        "github.com/jessevdk/go-flags"
28
        "github.com/sashabaranov/go-openai"
29
        "gopkg.in/natefinch/lumberjack.v2"
30

31
        "github.com/umputun/tg-spam/app/bot"
32
        "github.com/umputun/tg-spam/app/config"
33
        "github.com/umputun/tg-spam/app/events"
34
        "github.com/umputun/tg-spam/app/storage"
35
        "github.com/umputun/tg-spam/app/storage/engine"
36
        "github.com/umputun/tg-spam/app/webapi"
37
        "github.com/umputun/tg-spam/lib/tgspam"
38
        "github.com/umputun/tg-spam/lib/tgspam/plugin"
39
)
40

41
type options struct {
42
        InstanceID         string `long:"instance-id" env:"INSTANCE_ID" default:"tg-spam" description:"instance id"`
43
        DataBaseURL        string `long:"db" env:"DB" default:"tg-spam.db" description:"database URL, if empty uses sqlite"`
44
        ConfigDB           bool   `long:"confdb" env:"CONFDB" description:"load configuration from database"`
45
        ConfigDBEncryptKey string `long:"confdb-encrypt-key" env:"CONFDB_ENCRYPT_KEY" description:"encryption key for sensitive config values in database"`
46

47
        Telegram struct {
48
                Token        string        `long:"token" env:"TOKEN" description:"telegram bot token"`
49
                Group        string        `long:"group" env:"GROUP" description:"group name/id"`
50
                Timeout      time.Duration `long:"timeout" env:"TIMEOUT" default:"30s" description:"http client timeout for telegram" `
51
                IdleDuration time.Duration `long:"idle" env:"IDLE" default:"30s" description:"idle duration"`
52
        } `group:"telegram" namespace:"telegram" env-namespace:"TELEGRAM"`
53

54
        AdminGroup              string `long:"admin.group" env:"ADMIN_GROUP" description:"admin group name, or channel id"`
55
        DisableAdminSpamForward bool   `long:"disable-admin-spam-forward" env:"DISABLE_ADMIN_SPAM_FORWARD" description:"disable handling messages forwarded to admin group as spam"`
56

57
        TestingIDs []int64 `long:"testing-id" env:"TESTING_ID" env-delim:"," description:"testing ids, allow bot to reply to them"`
58

59
        HistoryDuration time.Duration `long:"history-duration" env:"HISTORY_DURATION" default:"24h" description:"history duration"`
60
        HistoryMinSize  int           `long:"history-min-size" env:"HISTORY_MIN_SIZE" default:"1000" description:"history minimal size to keep"`
61
        StorageTimeout  time.Duration `long:"storage-timeout" env:"STORAGE_TIMEOUT" default:"0s" description:"storage timeout"`
62

63
        Logger struct {
64
                Enabled    bool   `long:"enabled" env:"ENABLED" description:"enable spam rotated logs"`
65
                FileName   string `long:"file" env:"FILE"  default:"tg-spam.log" description:"location of spam log"`
66
                MaxSize    string `long:"max-size" env:"MAX_SIZE" default:"100M" description:"maximum size before it gets rotated"`
67
                MaxBackups int    `long:"max-backups" env:"MAX_BACKUPS" default:"10" description:"maximum number of old log files to retain"`
68
        } `group:"logger" namespace:"logger" env-namespace:"LOGGER"`
69

70
        SuperUsers          events.SuperUsers `long:"super" env:"SUPER_USER" env-delim:"," description:"super-users"`
71
        NoSpamReply         bool              `long:"no-spam-reply" env:"NO_SPAM_REPLY" description:"do not reply to spam messages"`
72
        SuppressJoinMessage bool              `long:"suppress-join-message" env:"SUPPRESS_JOIN_MESSAGE" description:"delete join message if user is kicked out"`
73

74
        CAS struct {
75
                API       string        `long:"api" env:"API" default:"https://api.cas.chat" description:"CAS API"`
76
                Timeout   time.Duration `long:"timeout" env:"TIMEOUT" default:"5s" description:"CAS timeout"`
77
                UserAgent string        `long:"user-agent" env:"USER_AGENT" description:"User-Agent header for CAS API requests"`
78
        } `group:"cas" namespace:"cas" env-namespace:"CAS"`
79

80
        Meta struct {
81
                LinksLimit      int    `long:"links-limit" env:"LINKS_LIMIT" default:"-1" description:"max links in message, disabled by default"`
82
                MentionsLimit   int    `long:"mentions-limit" env:"MENTIONS_LIMIT" default:"-1" description:"max mentions in message, disabled by default"`
83
                ImageOnly       bool   `long:"image-only" env:"IMAGE_ONLY" description:"enable image only check"`
84
                LinksOnly       bool   `long:"links-only" env:"LINKS_ONLY" description:"enable links only check"`
85
                VideosOnly      bool   `long:"video-only" env:"VIDEO_ONLY" description:"enable video only check"`
86
                AudiosOnly      bool   `long:"audio-only" env:"AUDIO_ONLY" description:"enable audio only check"`
87
                Forward         bool   `long:"forward" env:"FORWARD" description:"enable forward check"`
88
                Keyboard        bool   `long:"keyboard" env:"KEYBOARD" description:"enable keyboard check"`
89
                UsernameSymbols string `long:"username-symbols" env:"USERNAME_SYMBOLS" description:"prohibited symbols in username, disabled by default"`
90
        } `group:"meta" namespace:"meta" env-namespace:"META"`
91

92
        OpenAI struct {
93
                Token             string   `long:"token" env:"TOKEN" description:"openai token, disabled if not set"`
94
                APIBase           string   `long:"apibase" env:"API_BASE" description:"custom openai API base, default is https://api.openai.com/v1"`
95
                Veto              bool     `long:"veto" env:"VETO" description:"veto mode, confirm detected spam"`
96
                Prompt            string   `long:"prompt" env:"PROMPT" default:"" description:"openai system prompt, if empty uses builtin default"`
97
                CustomPrompts     []string `long:"custom-prompt" env:"CUSTOM_PROMPTS" env-delim:"," description:"custom prompts for special cases"`
98
                ReasoningEffort   string   `long:"reasoning-effort" env:"REASONING_EFFORT" default:"" description:"reasoning effort level (low, medium, high)"`
99
                Model             string   `long:"model" env:"MODEL" default:"gpt-4o-mini" description:"openai model"`
100
                MaxTokensResponse int      `long:"max-tokens-response" env:"MAX_TOKENS_RESPONSE" default:"1024" description:"openai max tokens in response"`
101
                MaxTokensRequest  int      `long:"max-tokens-request" env:"MAX_TOKENS_REQUEST" default:"2048" description:"openai max tokens in request"`
102
                MaxSymbolsRequest int      `long:"max-symbols-request" env:"MAX_SYMBOLS_REQUEST" default:"16000" description:"openai max symbols in request, failback if tokenizer failed"`
103
                RetryCount        int      `long:"retry-count" env:"RETRY_COUNT" default:"1" description:"openai retry count"`
104
                HistorySize       int      `long:"history-size" env:"HISTORY_SIZE" default:"0" description:"openai history size"`
105
        } `group:"openai" namespace:"openai" env-namespace:"OPENAI"`
106

107
        LuaPlugins struct {
108
                Enabled        bool     `long:"enabled" env:"ENABLED" description:"enable Lua plugins"`
109
                PluginsDir     string   `long:"plugins-dir" env:"PLUGINS_DIR" description:"directory with Lua plugins"`
110
                EnabledPlugins []string `long:"enabled-plugins" env:"ENABLED_PLUGINS" env-delim:"," description:"list of enabled plugins (by name, without .lua extension)"`
111
                DynamicReload  bool     `long:"dynamic-reload" env:"DYNAMIC_RELOAD" description:"dynamically reload plugins when they change"`
112
        } `group:"lua-plugins" namespace:"lua-plugins" env-namespace:"LUA_PLUGINS"`
113

114
        AbnormalSpacing struct {
115
                Enabled                 bool    `long:"enabled" env:"ENABLED" description:"enable abnormal words check"`
116
                SpaceRatioThreshold     float64 `long:"ratio" env:"RATIO" default:"0.3" description:"the ratio of spaces to all characters in the message"`
117
                ShortWordRatioThreshold float64 `long:"short-ratio" env:"SHORT_RATIO" default:"0.7" description:"the ratio of short words to all words in the message"`
118
                ShortWordLen            int     `long:"short-word" env:"SHORT_WORD" default:"3" description:"the length of the word to be considered short"`
119
                MinWords                int     `long:"min-words" env:"MIN_WORDS" default:"5" description:"the minimum number of words in the message to check"`
120
        } `group:"space" namespace:"space" env-namespace:"SPACE"`
121

122
        Files struct {
123
                SamplesDataPath string        `long:"samples" env:"SAMPLES" default:"preset" description:"samples data path, deprecated"`
124
                DynamicDataPath string        `long:"dynamic" env:"DYNAMIC" default:"data" description:"dynamic data path"`
125
                WatchInterval   time.Duration `long:"watch-interval" env:"WATCH_INTERVAL" default:"5s" description:"watch interval for dynamic files, deprecated"`
126
        } `group:"files" namespace:"files" env-namespace:"FILES"`
127

128
        SimilarityThreshold float64 `long:"similarity-threshold" env:"SIMILARITY_THRESHOLD" default:"0.5" description:"spam threshold"`
129
        MinMsgLen           int     `long:"min-msg-len" env:"MIN_MSG_LEN" default:"50" description:"min message length to check"`
130
        MaxEmoji            int     `long:"max-emoji" env:"MAX_EMOJI" default:"2" description:"max emoji count in message, -1 to disable check"`
131
        MinSpamProbability  float64 `long:"min-probability" env:"MIN_PROBABILITY" default:"50" description:"min spam probability percent to ban"`
132
        MultiLangWords      int     `long:"multi-lang" env:"MULTI_LANG" default:"0" description:"number of words in different languages to consider as spam"`
133

134
        ParanoidMode       bool `long:"paranoid" env:"PARANOID" description:"paranoid mode, check all messages"`
135
        FirstMessagesCount int  `long:"first-messages-count" env:"FIRST_MESSAGES_COUNT" default:"1" description:"number of first messages to check"`
136

137
        Message struct {
138
                Startup string `long:"startup" env:"STARTUP" default:"" description:"startup message"`
139
                Spam    string `long:"spam" env:"SPAM" default:"this is spam" description:"spam message"`
140
                Dry     string `long:"dry" env:"DRY" default:"this is spam (dry mode)" description:"spam dry message"`
141
                Warn    string `long:"warn" env:"WARN" default:"You've violated our rules and this is your first and last warning. Further violations will lead to permanent access denial. Stay compliant or face the consequences!" description:"warning message"`
142
        } `group:"message" namespace:"message" env-namespace:"MESSAGE"`
143

144
        Server struct {
145
                Enabled    bool   `long:"enabled" env:"ENABLED" description:"enable web server"`
146
                ListenAddr string `long:"listen" env:"LISTEN" default:":8080" description:"listen address"`
147
                AuthUser   string `long:"auth-user" env:"AUTH_USER" default:"tg-spam" description:"basic auth username"`
148
                AuthPasswd string `long:"auth" env:"AUTH" default:"auto" description:"basic auth password"`
149
                AuthHash   string `long:"auth-hash" env:"AUTH_HASH" default:"" description:"basic auth password hash"`
150
        } `group:"server" namespace:"server" env-namespace:"SERVER"`
151

152
        Training bool `long:"training" env:"TRAINING" description:"training mode, passive spam detection only"`
153
        SoftBan  bool `long:"soft-ban" env:"SOFT_BAN" description:"soft ban mode, restrict user actions but not ban"`
154

155
        HistorySize int    `long:"history-size" env:"LAST_MSGS_HISTORY_SIZE" default:"100" description:"history size"`
156
        Convert     string `long:"convert" choice:"only" choice:"enabled" choice:"disabled" default:"enabled" description:"convert mode for txt samples and other storage files to DB"`
157

158
        MaxBackups int `long:"max-backups" env:"MAX_BACKUPS" default:"10" description:"maximum number of backups to keep, set 0 to disable"`
159

160
        Dry   bool `long:"dry" env:"DRY" description:"dry mode, no bans"`
161
        Dbg   bool `long:"dbg" env:"DEBUG" description:"debug mode"`
162
        TGDbg bool `long:"tg-dbg" env:"TG_DEBUG" description:"telegram debug mode"`
163
}
164

165
// default file names
166
const (
167
        samplesSpamFile   = "spam-samples.txt"
168
        samplesHamFile    = "ham-samples.txt"
169
        excludeTokensFile = "exclude-tokens.txt" //nolint:gosec // false positive
170
        stopWordsFile     = "stop-words.txt"     //nolint:gosec // false positive
171
        dynamicSpamFile   = "spam-dynamic.txt"
172
        dynamicHamFile    = "ham-dynamic.txt"
173
        dataFile          = "tg-spam.db"
174
)
175

176
var revision = "local"
177

UNCOV
178
func main() {
×
UNCOV
179
        fmt.Printf("tg-spam %s\n", revision)
×
180
        var opts options
×
181
        p := flags.NewParser(&opts, flags.PrintErrors|flags.PassDoubleDash|flags.HelpFlag)
×
182
        p.SubcommandsOptional = true
×
NEW
183

×
NEW
184
        // add save-config command
×
NEW
185
        if _, err := p.AddCommand("save-config", "Save current configuration to database",
×
NEW
186
                "Saves all current settings to the database for future use with --confdb",
×
NEW
187
                &struct{}{}); err != nil {
×
NEW
188
                log.Printf("[ERROR] failed to add save-config command: %v", err)
×
NEW
189
                os.Exit(1)
×
NEW
190
        }
×
191
        if _, err := p.Parse(); err != nil {
×
192
                if !errors.Is(err.(*flags.Error).Type, flags.ErrHelp) {
×
193
                        log.Printf("[ERROR] cli error: %v", err)
×
194
                        os.Exit(1)
×
195
                }
×
196
                os.Exit(2)
×
197
        }
198

199
        // determine configuration source based on --confdb flag
NEW
200
        var appSettings *config.Settings
×
NEW
201

×
NEW
202
        if opts.ConfigDB {
×
NEW
203
                // database configuration mode - load from database first
×
NEW
204
                ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
×
NEW
205
                defer cancel()
×
NEW
206

×
NEW
207
                log.Printf("[INFO] loading configuration from database")
×
NEW
208

×
NEW
209
                // create a new settings instance
×
NEW
210
                appSettings = config.New()
×
NEW
211

×
NEW
212
                // set transient values needed for database connection BEFORE loading
×
NEW
213
                appSettings.Transient.ConfigDB = opts.ConfigDB
×
NEW
214
                appSettings.Transient.ConfigDBEncryptKey = opts.ConfigDBEncryptKey
×
NEW
215
                appSettings.Transient.DataBaseURL = opts.DataBaseURL
×
NEW
216
                appSettings.InstanceID = opts.InstanceID
×
NEW
217
                // set DynamicDataPath from opts so makeDB can properly construct the database path
×
NEW
218
                appSettings.Files.DynamicDataPath = opts.Files.DynamicDataPath
×
NEW
219

×
NEW
220
                // load settings from database
×
NEW
221
                if err := loadConfigFromDB(ctx, appSettings); err != nil {
×
NEW
222
                        log.Printf("[ERROR] failed to load configuration from database: %v", err)
×
NEW
223
                        cancel()
×
NEW
224
                        os.Exit(1) //nolint:gocritic // cancel is called before exit
×
NEW
225
                }
×
226

227
                // apply remaining transient values from CLI (these are never stored in DB)
NEW
228
                appSettings.Transient.Dbg = opts.Dbg
×
NEW
229
                appSettings.Transient.TGDbg = opts.TGDbg
×
NEW
230
                appSettings.Dry = opts.Dry
×
NEW
231

×
NEW
232
                // apply explicit CLI overrides for non-transient values
×
NEW
233
                applyCLIOverrides(appSettings, opts)
×
NEW
234
        } else {
×
NEW
235
                // traditional mode - CLI is source of truth
×
NEW
236
                appSettings = optToSettings(opts)
×
NEW
237
        }
×
238

239
        // handle save-config command
NEW
240
        if p.Active != nil && p.Active.Name == "save-config" {
×
NEW
241
                ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
×
NEW
242
                exitCode := 0
×
NEW
243
                if err := saveConfigToDB(ctx, appSettings); err != nil {
×
NEW
244
                        log.Printf("[ERROR] failed to save configuration to database: %v", err)
×
NEW
245
                        exitCode = 1
×
NEW
246
                }
×
NEW
247
                cancel()
×
NEW
248
                os.Exit(exitCode)
×
249
        }
250

251
        // setup logger with masked secrets, accessing them directly from domain models
NEW
252
        masked := []string{}
×
NEW
253

×
NEW
254
        // add tokens from domain models
×
NEW
255
        if appSettings.Telegram.Token != "" {
×
NEW
256
                masked = append(masked, appSettings.Telegram.Token)
×
NEW
257
        }
×
NEW
258
        if appSettings.OpenAI.Token != "" {
×
NEW
259
                masked = append(masked, appSettings.OpenAI.Token)
×
260
        }
×
261

262
        // add temporary web password if not "auto"
NEW
263
        if appSettings.Transient.WebAuthPasswd != "auto" && appSettings.Transient.WebAuthPasswd != "" {
×
NEW
264
                // auto passwd should not be masked as we print it
×
NEW
265
                masked = append(masked, appSettings.Transient.WebAuthPasswd)
×
NEW
266
        }
×
267

268
        // add auth hash
NEW
269
        if appSettings.Server.AuthHash != "" {
×
NEW
270
                masked = append(masked, appSettings.Server.AuthHash)
×
NEW
271
        }
×
272

NEW
273
        setupLog(appSettings.Transient.Dbg, masked...)
×
NEW
274
        log.Printf("[DEBUG] settings: %+v", appSettings)
×
275

×
276
        ctx, cancel := context.WithCancel(context.Background())
×
277

×
278
        go func() {
×
279
                // catch signal and invoke graceful termination
×
280
                stop := make(chan os.Signal, 1)
×
281
                signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
×
282
                <-stop
×
283
                log.Printf("[WARN] interrupt signal")
×
284
                cancel()
×
285
        }()
×
286

287
        // expand, make absolute paths
NEW
288
        appSettings.Files.DynamicDataPath = expandPath(appSettings.Files.DynamicDataPath)
×
NEW
289
        appSettings.Files.SamplesDataPath = expandPath(appSettings.Files.SamplesDataPath)
×
290

×
NEW
291
        if err := execute(ctx, appSettings); err != nil {
×
292
                log.Printf("[ERROR] %v", err)
×
293
                os.Exit(1)
×
294
        }
×
295
}
296

297
func execute(ctx context.Context, settings *config.Settings) error {
1✔
298
        if settings.Dry {
1✔
UNCOV
299
                log.Print("[WARN] dry mode, no actual bans")
×
UNCOV
300
        }
×
301

302
        convertOnly := settings.Convert == "only"
1✔
303
        if !settings.Server.Enabled && !convertOnly && (settings.Telegram.Token == "" || settings.Telegram.Group == "") {
1✔
UNCOV
304
                return errors.New("telegram token and group are required")
×
UNCOV
305
        }
×
306

307
        checkVolumeMount(settings) // show warning if dynamic files dir not mounted
1✔
308

1✔
309
        // make samples and dynamic data dirs
1✔
310
        if err := os.MkdirAll(settings.Files.SamplesDataPath, 0o700); err != nil {
1✔
UNCOV
311
                return fmt.Errorf("can't make samples dir, %w", err)
×
UNCOV
312
        }
×
313

314
        dataDB, err := makeDB(ctx, settings)
1✔
315
        if err != nil {
1✔
UNCOV
316
                return fmt.Errorf("can't make db, %w", err)
×
UNCOV
317
        }
×
318

319
        // make detector with all sample files loaded
320
        detector := makeDetector(settings)
1✔
321

1✔
322
        // make spam bot
1✔
323
        spamBot, err := makeSpamBot(ctx, settings, dataDB, detector)
1✔
324
        if err != nil {
1✔
UNCOV
325
                return fmt.Errorf("can't make spam bot, %w", err)
×
UNCOV
326
        }
×
327
        if settings.Convert == "only" {
1✔
328
                log.Print("[WARN] convert only mode, converting text samples and exit")
×
UNCOV
329
                return nil
×
330
        }
×
331

332
        // make store and load approved users
333
        approvedUsersStore, auErr := storage.NewApprovedUsers(ctx, dataDB)
1✔
334
        if auErr != nil {
1✔
UNCOV
335
                return fmt.Errorf("can't make approved users store, %w", auErr)
×
UNCOV
336
        }
×
337

338
        count, err := detector.WithUserStorage(approvedUsersStore)
1✔
339
        if err != nil {
1✔
UNCOV
340
                return fmt.Errorf("can't load approved users, %w", err)
×
UNCOV
341
        }
×
342
        log.Printf("[DEBUG] approved users loaded: %d", count)
1✔
343

1✔
344
        // make locator
1✔
345
        locator, err := storage.NewLocator(ctx, settings.History.Duration, settings.History.MinSize, dataDB)
1✔
346
        if err != nil {
1✔
UNCOV
347
                return fmt.Errorf("can't make locator, %w", err)
×
UNCOV
348
        }
×
349

350
        // activate web server if enabled
351
        if settings.Server.Enabled {
2✔
352
                // server starts in background goroutine
1✔
353
                if srvErr := activateServer(ctx, settings, spamBot, locator, dataDB); srvErr != nil {
1✔
UNCOV
354
                        return fmt.Errorf("can't activate web server, %w", srvErr)
×
UNCOV
355
                }
×
356
                // if no telegram token and group set, just run the server
357
                if settings.Telegram.Token == "" || settings.Telegram.Group == "" {
2✔
358
                        log.Printf("[WARN] no telegram token and group set, web server only mode")
1✔
359
                        <-ctx.Done()
1✔
360
                        return nil
1✔
361
                }
1✔
362
        }
363

364
        // make telegram bot
NEW
365
        tbAPI, err := tbapi.NewBotAPI(settings.Telegram.Token)
×
UNCOV
366
        if err != nil {
×
367
                return fmt.Errorf("can't make telegram bot, %w", err)
×
368
        }
×
NEW
369
        tbAPI.Debug = settings.Transient.TGDbg
×
370

×
371
        // make spam logger writer
×
NEW
372
        loggerWr, err := makeSpamLogWriter(settings)
×
373
        if err != nil {
×
374
                return fmt.Errorf("can't make spam log writer, %w", err)
×
375
        }
×
376
        defer loggerWr.Close()
×
377

×
378
        // make spam logger
×
NEW
379
        spamLogger, err := makeSpamLogger(ctx, settings.InstanceID, loggerWr, dataDB)
×
380
        if err != nil {
×
381
                return fmt.Errorf("can't make spam logger, %w", err)
×
382
        }
×
383

384
        // make telegram listener
UNCOV
385
        tgListener := events.TelegramListener{
×
UNCOV
386
                TbAPI:                   tbAPI,
×
NEW
387
                Group:                   settings.Telegram.Group,
×
NEW
388
                IdleDuration:            settings.Telegram.IdleDuration,
×
NEW
389
                SuperUsers:              settings.Admin.SuperUsers,
×
390
                Bot:                     spamBot,
×
NEW
391
                StartupMsg:              settings.Message.Startup,
×
NEW
392
                WarnMsg:                 settings.Message.Warn,
×
NEW
393
                NoSpamReply:             settings.NoSpamReply,
×
NEW
394
                SuppressJoinMessage:     settings.SuppressJoinMessage,
×
395
                SpamLogger:              spamLogger,
×
NEW
396
                AdminGroup:              settings.Admin.AdminGroup,
×
NEW
397
                TestingIDs:              settings.Admin.TestingIDs,
×
398
                Locator:                 locator,
×
NEW
399
                TrainingMode:            settings.Training,
×
NEW
400
                SoftBanMode:             settings.SoftBan,
×
NEW
401
                DisableAdminSpamForward: settings.Admin.DisableAdminSpamForward,
×
NEW
402
                Dry:                     settings.Dry,
×
403
        }
×
404

×
405
        log.Printf("[DEBUG] telegram listener config: {group: %s, idle: %v, super: %v, admin: %s, testing: %v, no-reply: %v,"+
×
406
                " suppress: %v, dry: %v, training: %v}", tgListener.Group, tgListener.IdleDuration, tgListener.SuperUsers,
×
407
                tgListener.AdminGroup, tgListener.TestingIDs, tgListener.NoSpamReply, tgListener.SuppressJoinMessage, tgListener.Dry,
×
408
                tgListener.TrainingMode)
×
409

×
410
        // run telegram listener and event processor loop
×
411
        if err := tgListener.Do(ctx); err != nil {
×
412
                return fmt.Errorf("telegram listener failed, %w", err)
×
413
        }
×
414
        return nil
×
415
}
416

417
// makeDB creates database connection based on the settings model
418
func makeDB(ctx context.Context, settings *config.Settings) (*engine.SQL, error) {
10✔
419
        if settings.Transient.DataBaseURL == "" {
10✔
UNCOV
420
                return nil, errors.New("empty database URL")
×
UNCOV
421
        }
×
422
        dbURL := settings.Transient.DataBaseURL
10✔
423

10✔
424
        // if dbURL has no path separator, assume it is a file name and add dynamic data path for sqlite
10✔
425
        if !strings.Contains(dbURL, "/") && !strings.Contains(dbURL, "\\") {
10✔
NEW
426
                dbURL = filepath.Join(settings.Files.DynamicDataPath, dbURL)
×
UNCOV
427
        }
×
428
        log.Printf("[DEBUG] data db: %s", dbURL)
10✔
429

10✔
430
        db, err := engine.New(ctx, dbURL, settings.InstanceID)
10✔
431
        if err != nil {
10✔
NEW
432
                return nil, fmt.Errorf("can't make db %s, %w", settings.Transient.DataBaseURL, err)
×
UNCOV
433
        }
×
434

435
        // backup db on version change for sqlite
436
        if db.Type() == engine.Sqlite {
20✔
437
                // get file name from dbURL for sqlite
10✔
438
                dbFile := dbURL
10✔
439
                dbFile = strings.TrimPrefix(dbFile, "file://")
10✔
440
                dbFile = strings.TrimPrefix(dbFile, "file:")
10✔
441

10✔
442
                // make backup of db on version change for sqlite
10✔
443
                if settings.MaxBackups > 0 {
10✔
NEW
444
                        if err := backupDB(dbFile, revision, settings.MaxBackups); err != nil {
×
445
                                return nil, fmt.Errorf("backup on version change failed, %w", err)
×
446
                        }
×
447
                } else {
10✔
448
                        log.Print("[WARN] database backups disabled")
10✔
449
                }
10✔
450
        }
451
        return db, nil
10✔
452
}
453

454
// checkVolumeMount checks if dynamic files location mounted in docker and shows warning if not
455
// returns true if running not in docker or dynamic files dir mounted
456
func checkVolumeMount(settings *config.Settings) (ok bool) {
6✔
457
        if os.Getenv("TGSPAM_IN_DOCKER") != "1" {
9✔
458
                return true
3✔
459
        }
3✔
460
        log.Printf("[DEBUG] running in docker")
3✔
461
        warnMsg := fmt.Sprintf("dynamic files dir %q is not mounted, changes will be lost on container restart", settings.Files.DynamicDataPath)
3✔
462

3✔
463
        // check if dynamic files dir not present. This means it is not mounted
3✔
464
        _, err := os.Stat(settings.Files.DynamicDataPath)
3✔
465
        if err != nil {
4✔
466
                log.Printf("[WARN] %s", warnMsg)
1✔
467
                // no dynamic files dir, no need to check further
1✔
468
                return false
1✔
469
        }
1✔
470

471
        // check if .not_mounted file missing, this means it is mounted
472
        if _, err = os.Stat(filepath.Join(settings.Files.DynamicDataPath, ".not_mounted")); err != nil {
3✔
473
                return true
1✔
474
        }
1✔
475

476
        // if .not_mounted file present, it can be mounted anyway with docker named volumes
477
        output, err := exec.Command("mount").Output()
1✔
478
        if err != nil {
1✔
UNCOV
479
                log.Printf("[WARN] %s, can't check mount: %v", warnMsg, err)
×
UNCOV
480
                return true
×
481
        }
×
482
        // check if the output contains the specified directory
483
        for _, line := range strings.Split(string(output), "\n") {
26✔
484
                if strings.Contains(line, settings.Files.DynamicDataPath) {
25✔
UNCOV
485
                        return true
×
UNCOV
486
                }
×
487
        }
488

489
        log.Printf("[WARN] %s", warnMsg)
1✔
490
        return false
1✔
491
}
492

493
func activateServer(ctx context.Context, settings *config.Settings, sf *bot.SpamFilter, loc *storage.Locator, db *engine.SQL) (err error) {
1✔
494
        // handle authentication - always use bcrypt hash for security
1✔
495
        authPasswd := settings.Transient.WebAuthPasswd
1✔
496
        authHash := settings.Server.AuthHash
1✔
497

1✔
498
        // if hash is provided, use it directly
1✔
499
        if authHash != "" {
1✔
NEW
500
                log.Printf("[INFO] using provided bcrypt hash for authentication")
×
501
        } else if authPasswd != "" {
2✔
502
                // generate hash from password if no hash but password is provided
1✔
503
                authHash, err = generateAuthHash(authPasswd)
1✔
504
                if err != nil {
1✔
NEW
505
                        return fmt.Errorf("can't handle authentication setup: %w", err)
×
UNCOV
506
                }
×
507
                // store the hash directly in the Server settings domain
508
                settings.Server.AuthHash = authHash
1✔
509
        }
510
        // when neither hash nor password is provided, auth will be disabled
511

512
        // make store and load approved users
513
        detectedSpamStore, dsErr := storage.NewDetectedSpam(ctx, db)
1✔
514
        if dsErr != nil {
1✔
UNCOV
515
                return fmt.Errorf("can't make detected spam store, %w", dsErr)
×
UNCOV
516
        }
×
517

518
        // create settings store for database access if config DB mode is enabled
519
        var settingsStore *config.Store
1✔
520
        if settings.Transient.ConfigDB {
1✔
NEW
521
                store, err := config.NewStore(ctx, db)
×
NEW
522
                if err != nil {
×
NEW
523
                        return fmt.Errorf("failed to create settings store: %w", err)
×
NEW
524
                }
×
NEW
525
                settingsStore = store
×
526
        }
527

528
        srv := webapi.Server{Config: webapi.Config{
1✔
529
                ListenAddr:    settings.Server.ListenAddr,
1✔
530
                Detector:      sf.Detector,
1✔
531
                SpamFilter:    sf,
1✔
532
                Locator:       loc,
1✔
533
                DetectedSpam:  detectedSpamStore,
1✔
534
                StorageEngine: db,            // add database engine for backup functionality
1✔
535
                SettingsStore: settingsStore, // may be nil if ConfigDB is false
1✔
536
                AuthUser:      settings.Server.AuthUser,
1✔
537
                AuthHash:      authHash, // use the hash (either from options or generated)
1✔
538
                Version:       revision,
1✔
539
                Dbg:           settings.Transient.Dbg,
1✔
540
                AppSettings:   settings,
1✔
541
                ConfigDBMode:  settings.Transient.ConfigDB, // indicate we're running with database config
1✔
542
        }}
1✔
543

1✔
544
        go func() {
2✔
545
                if err := srv.Run(ctx); err != nil {
1✔
UNCOV
546
                        log.Printf("[ERROR] web server failed, %v", err)
×
UNCOV
547
                }
×
548
        }()
549
        return nil
1✔
550
}
551

552
// makeDetector creates spam detector with all checkers and updaters
553
// it loads samples and dynamic files
554
func makeDetector(settings *config.Settings) *tgspam.Detector {
7✔
555
        detectorConfig := tgspam.Config{
7✔
556
                MaxAllowedEmoji:     settings.MaxEmoji,
7✔
557
                MinMsgLen:           settings.MinMsgLen,
7✔
558
                SimilarityThreshold: settings.SimilarityThreshold,
7✔
559
                MinSpamProbability:  settings.MinSpamProbability,
7✔
560
                CasAPI:              settings.CAS.API,
7✔
561
                CasUserAgent:        settings.CAS.UserAgent,
7✔
562
                HTTPClient:          &http.Client{Timeout: settings.CAS.Timeout},
7✔
563
                FirstMessageOnly:    !settings.ParanoidMode,
7✔
564
                FirstMessagesCount:  settings.FirstMessagesCount,
7✔
565
                OpenAIVeto:          settings.OpenAI.Veto,
7✔
566
                OpenAIHistorySize:   settings.OpenAI.HistorySize, // how many last requests sent to openai
7✔
567
                MultiLangWords:      settings.MultiLangWords,
7✔
568
                HistorySize:         settings.History.Size, // how many last request stored in memory
7✔
569
        }
7✔
570

7✔
571
        // FirstMessagesCount and ParanoidMode are mutually exclusive.
7✔
572
        // ParanoidMode still here for backward compatibility only.
7✔
573
        if settings.FirstMessagesCount > 0 { // if FirstMessagesCount is set, FirstMessageOnly is enforced
9✔
574
                detectorConfig.FirstMessageOnly = true
2✔
575
        }
2✔
576
        if settings.ParanoidMode { // if ParanoidMode is set, FirstMessagesCount is ignored
8✔
577
                detectorConfig.FirstMessageOnly = false
1✔
578
                detectorConfig.FirstMessagesCount = 0
1✔
579
        }
1✔
580
        if settings.Transient.StorageTimeout > 0 { // if StorageTimeout is non-zero, set it. If zero, storage timeout is disabled
7✔
NEW
581
                detectorConfig.StorageTimeout = settings.Transient.StorageTimeout
×
UNCOV
582
        }
×
583

584
        detector := tgspam.NewDetector(detectorConfig)
7✔
585

7✔
586
        if settings.IsOpenAIEnabled() {
9✔
587
                log.Printf("[WARN] openai enabled")
2✔
588
                openAIConfig := tgspam.OpenAIConfig{
2✔
589
                        SystemPrompt:      settings.OpenAI.Prompt,
2✔
590
                        CustomPrompts:     settings.OpenAI.CustomPrompts,
2✔
591
                        ReasoningEffort:   settings.OpenAI.ReasoningEffort,
2✔
592
                        Model:             settings.OpenAI.Model,
2✔
593
                        MaxTokensResponse: settings.OpenAI.MaxTokensResponse,
2✔
594
                        MaxTokensRequest:  settings.OpenAI.MaxTokensRequest,
2✔
595
                        MaxSymbolsRequest: settings.OpenAI.MaxSymbolsRequest,
2✔
596
                        RetryCount:        settings.OpenAI.RetryCount,
2✔
597
                }
2✔
598

2✔
599
                openaiConfig := openai.DefaultConfig(settings.OpenAI.Token)
2✔
600
                if settings.OpenAI.APIBase != "" {
2✔
NEW
601
                        openaiConfig.BaseURL = settings.OpenAI.APIBase
×
UNCOV
602
                }
×
603
                log.Printf("[DEBUG] openai config: %+v", openAIConfig)
2✔
604

2✔
605
                detector.WithOpenAIChecker(openai.NewClientWithConfig(openaiConfig), openAIConfig)
2✔
606
        }
607

608
        if settings.AbnormalSpace.Enabled {
7✔
UNCOV
609
                log.Printf("[INFO] words spacing check enabled")
×
UNCOV
610
                detector.AbnormalSpacing.Enabled = true
×
NEW
611
                detector.AbnormalSpacing.ShortWordLen = settings.AbnormalSpace.ShortWordLen
×
NEW
612
                detector.AbnormalSpacing.ShortWordRatioThreshold = settings.AbnormalSpace.ShortWordRatioThreshold
×
NEW
613
                detector.AbnormalSpacing.SpaceRatioThreshold = settings.AbnormalSpace.SpaceRatioThreshold
×
NEW
614
                detector.AbnormalSpacing.MinWordsCount = settings.AbnormalSpace.MinWords
×
615
        }
×
616

617
        metaChecks := []tgspam.MetaCheck{}
7✔
618
        if settings.Meta.ImageOnly {
7✔
619
                log.Printf("[INFO] image only check enabled")
×
620
                metaChecks = append(metaChecks, tgspam.ImagesCheck())
×
UNCOV
621
        }
×
622
        if settings.Meta.VideosOnly {
7✔
UNCOV
623
                log.Printf("[INFO] videos only check enabled")
×
624
                metaChecks = append(metaChecks, tgspam.VideosCheck())
×
625
        }
×
626
        if settings.Meta.LinksLimit >= 0 {
14✔
627
                log.Printf("[INFO] links check enabled, limit: %d", settings.Meta.LinksLimit)
7✔
628
                metaChecks = append(metaChecks, tgspam.LinksCheck(settings.Meta.LinksLimit))
7✔
629
        }
7✔
630
        if settings.Meta.MentionsLimit >= 0 {
14✔
631
                log.Printf("[INFO] mentions check enabled, limit: %d", settings.Meta.MentionsLimit)
7✔
632
                metaChecks = append(metaChecks, tgspam.MentionsCheck(settings.Meta.MentionsLimit))
7✔
633
        }
7✔
634
        if settings.Meta.LinksOnly {
7✔
UNCOV
635
                log.Printf("[INFO] links only check enabled")
×
UNCOV
636
                metaChecks = append(metaChecks, tgspam.LinkOnlyCheck())
×
UNCOV
637
        }
×
638
        if settings.Meta.Forward {
7✔
UNCOV
639
                log.Printf("[INFO] forward check enabled")
×
640
                metaChecks = append(metaChecks, tgspam.ForwardedCheck())
×
641
        }
×
642
        if settings.Meta.Keyboard {
7✔
UNCOV
643
                log.Printf("[INFO] keyboard check enabled")
×
644
                metaChecks = append(metaChecks, tgspam.KeyboardCheck())
×
645
        }
×
646
        if settings.Meta.UsernameSymbols != "" {
7✔
NEW
647
                log.Printf("[INFO] username symbols check enabled, prohibited symbols: %q", settings.Meta.UsernameSymbols)
×
NEW
648
                metaChecks = append(metaChecks, tgspam.UsernameSymbolsCheck(settings.Meta.UsernameSymbols))
×
649
        }
×
650
        detector.WithMetaChecks(metaChecks...)
7✔
651

7✔
652
        log.Printf("[DEBUG] detector config: %+v", detectorConfig)
7✔
653

7✔
654
        // initialize Lua plugins if enabled
7✔
655
        if settings.LuaPlugins.Enabled {
7✔
NEW
656
                initLuaPlugins(detector, settings)
×
657
        }
×
658

659
        return detector
7✔
660
}
661

662
// initLuaPlugins initializes Lua plugin engine and configures it
663
func initLuaPlugins(detector *tgspam.Detector, settings *config.Settings) {
2✔
664
        // copy Lua plugin settings to detector config
2✔
665
        detector.LuaPlugins.Enabled = true
2✔
666
        detector.LuaPlugins.PluginsDir = settings.LuaPlugins.PluginsDir
2✔
667
        detector.LuaPlugins.EnabledPlugins = settings.LuaPlugins.EnabledPlugins
2✔
668
        detector.LuaPlugins.DynamicReload = settings.LuaPlugins.DynamicReload
2✔
669

2✔
670
        // create and initialize the plugin engine
2✔
671
        luaEngine := plugin.NewChecker()
2✔
672
        if err := detector.WithLuaEngine(luaEngine); err != nil {
3✔
673
                log.Printf("[WARN] failed to initialize Lua plugins: %v", err)
1✔
674
                return
1✔
675
        }
1✔
676

677
        // log successful initialization
678
        log.Printf("[INFO] lua plugins enabled from directory: %s", settings.LuaPlugins.PluginsDir)
1✔
679

1✔
680
        // log which plugins are enabled
1✔
681
        if len(settings.LuaPlugins.EnabledPlugins) > 0 {
1✔
NEW
682
                log.Printf("[INFO] enabled Lua plugins: %v", settings.LuaPlugins.EnabledPlugins)
×
683
        } else {
1✔
684
                log.Print("[INFO] all Lua plugins from directory are enabled")
1✔
685
        }
1✔
686

687
        // log if dynamic reloading is enabled
688
        if settings.LuaPlugins.DynamicReload {
1✔
NEW
689
                log.Print("[INFO] dynamic reloading of Lua plugins enabled")
×
NEW
690
        }
×
691
}
692

693
func makeSpamBot(ctx context.Context, settings *config.Settings, dataDB *engine.SQL, detector *tgspam.Detector) (*bot.SpamFilter, error) {
3✔
694
        if dataDB == nil || detector == nil {
4✔
695
                return nil, errors.New("nil datadb or detector")
1✔
696
        }
1✔
697

698
        // make samples store
699
        samplesStore, err := storage.NewSamples(ctx, dataDB)
2✔
700
        if err != nil {
2✔
UNCOV
701
                return nil, fmt.Errorf("can't make samples store, %w", err)
×
UNCOV
702
        }
×
703
        if err = migrateSamples(ctx, settings, samplesStore); err != nil {
2✔
UNCOV
704
                return nil, fmt.Errorf("can't migrate samples, %w", err)
×
UNCOV
705
        }
×
706

707
        // make dictionary store
708
        dictionaryStore, err := storage.NewDictionary(ctx, dataDB)
2✔
709
        if err != nil {
2✔
710
                return nil, fmt.Errorf("can't make dictionary store, %w", err)
×
UNCOV
711
        }
×
712
        if err := migrateDicts(ctx, settings, dictionaryStore); err != nil {
2✔
UNCOV
713
                return nil, fmt.Errorf("can't migrate dictionary, %w", err)
×
UNCOV
714
        }
×
715

716
        spamBotParams := bot.SpamConfig{
2✔
717
                GroupID:      settings.InstanceID,
2✔
718
                SamplesStore: samplesStore,
2✔
719
                DictStore:    dictionaryStore,
2✔
720
                SpamMsg:      settings.Message.Spam,
2✔
721
                SpamDryMsg:   settings.Message.Dry,
2✔
722
                Dry:          settings.Dry,
2✔
723
        }
2✔
724
        spamBot := bot.NewSpamFilter(detector, spamBotParams)
2✔
725
        log.Printf("[DEBUG] spam bot config: %+v", spamBotParams)
2✔
726

2✔
727
        if err := spamBot.ReloadSamples(); err != nil {
2✔
UNCOV
728
                return nil, fmt.Errorf("can't reload samples, %w", err)
×
UNCOV
729
        }
×
730

731
        // set detector samples updaters
732
        detector.WithSpamUpdater(storage.NewSampleUpdater(samplesStore, storage.SampleTypeSpam, settings.Transient.StorageTimeout))
2✔
733
        detector.WithHamUpdater(storage.NewSampleUpdater(samplesStore, storage.SampleTypeHam, settings.Transient.StorageTimeout))
2✔
734

2✔
735
        return spamBot, nil
2✔
736
}
737

738
// expandPath expands ~ to home dir and makes the absolute path
739
func expandPath(path string) string {
10✔
740
        if path == "" {
11✔
741
                return ""
1✔
742
        }
1✔
743
        if path[0] == '~' {
12✔
744
                home, err := os.UserHomeDir()
3✔
745
                if err != nil {
3✔
UNCOV
746
                        return ""
×
UNCOV
747
                }
×
748
                return filepath.Join(home, path[1:])
3✔
749
        }
750
        ep, err := filepath.Abs(path)
6✔
751
        if err != nil {
6✔
752
                return path
×
UNCOV
753
        }
×
754
        return ep
6✔
755
}
756

757
type nopWriteCloser struct{ io.Writer }
758

UNCOV
759
func (n nopWriteCloser) Close() error { return nil }
×
760

761
// makeSpamLogger creates spam logger to keep reports about spam messages
762
// it writes json lines to the provided writer
763
func makeSpamLogger(ctx context.Context, gid string, wr io.Writer, dataDB *engine.SQL) (events.SpamLogger, error) {
1✔
764
        // make store and load approved users
1✔
765
        detectedSpamStore, auErr := storage.NewDetectedSpam(ctx, dataDB)
1✔
766
        if auErr != nil {
1✔
UNCOV
767
                return nil, fmt.Errorf("can't make approved users store, %w", auErr)
×
UNCOV
768
        }
×
769

770
        logWr := events.SpamLoggerFunc(func(msg *bot.Message, response *bot.Response) {
2✔
771
                userName := msg.From.Username
1✔
772
                if userName == "" {
1✔
773
                        userName = msg.From.DisplayName
×
UNCOV
774
                }
×
775
                // write to log file
776
                text := strings.ReplaceAll(msg.Text, "\n", " ")
1✔
777
                text = strings.TrimSpace(text)
1✔
778
                log.Printf("[DEBUG] spam detected from %v, text: %s", msg.From, text)
1✔
779
                m := struct {
1✔
780
                        TimeStamp   string `json:"ts"`
1✔
781
                        DisplayName string `json:"display_name"`
1✔
782
                        UserName    string `json:"user_name"`
1✔
783
                        UserID      int64  `json:"user_id"`
1✔
784
                        Text        string `json:"text"`
1✔
785
                }{
1✔
786
                        TimeStamp:   time.Now().In(time.Local).Format(time.RFC3339),
1✔
787
                        DisplayName: msg.From.DisplayName,
1✔
788
                        UserName:    msg.From.Username,
1✔
789
                        UserID:      msg.From.ID,
1✔
790
                        Text:        text,
1✔
791
                }
1✔
792
                line, err := json.Marshal(&m)
1✔
793
                if err != nil {
1✔
UNCOV
794
                        log.Printf("[WARN] can't marshal json, %v", err)
×
UNCOV
795
                        return
×
UNCOV
796
                }
×
797
                if _, err := wr.Write(append(line, '\n')); err != nil {
1✔
UNCOV
798
                        log.Printf("[WARN] can't write to log, %v", err)
×
799
                }
×
800

801
                // write to db store
802
                rec := storage.DetectedSpamInfo{
1✔
803
                        Text:      text,
1✔
804
                        UserID:    msg.From.ID,
1✔
805
                        UserName:  userName,
1✔
806
                        Timestamp: time.Now().In(time.Local),
1✔
807
                        GID:       gid,
1✔
808
                }
1✔
809
                if err := detectedSpamStore.Write(ctx, rec, response.CheckResults); err != nil {
1✔
UNCOV
810
                        log.Printf("[WARN] can't write to db, %v", err)
×
UNCOV
811
                }
×
812
        })
813

814
        return logWr, nil
1✔
815
}
816

817
// makeSpamLogWriter creates spam log writer to keep reports about spam messages
818
// it parses settings and makes lumberjack logger with rotation
819
func makeSpamLogWriter(settings *config.Settings) (accessLog io.WriteCloser, err error) {
3✔
820
        if !settings.Logger.Enabled {
4✔
821
                return nopWriteCloser{io.Discard}, nil
1✔
822
        }
1✔
823

824
        sizeParse := func(inp string) (uint64, error) {
4✔
825
                if inp == "" {
2✔
UNCOV
826
                        return 0, errors.New("empty value")
×
UNCOV
827
                }
×
828
                for i, sfx := range []string{"k", "m", "g", "t"} {
8✔
829
                        if strings.HasSuffix(inp, strings.ToUpper(sfx)) || strings.HasSuffix(inp, strings.ToLower(sfx)) {
7✔
830
                                val, err := strconv.Atoi(inp[:len(inp)-1])
1✔
831
                                if err != nil {
1✔
832
                                        return 0, fmt.Errorf("can't parse %s: %w", inp, err)
×
UNCOV
833
                                }
×
834
                                return uint64(float64(val) * math.Pow(float64(1024), float64(i+1))), nil
1✔
835
                        }
836
                }
837
                return strconv.ParseUint(inp, 10, 64)
1✔
838
        }
839

840
        maxSize, perr := sizeParse(settings.Logger.MaxSize)
2✔
841
        if perr != nil {
3✔
842
                return nil, fmt.Errorf("can't parse logger MaxSize: %w", perr)
1✔
843
        }
1✔
844

845
        maxSize /= 1048576
1✔
846

1✔
847
        log.Printf("[INFO] logger enabled for %s, max size %dM", settings.Logger.FileName, maxSize)
1✔
848
        return &lumberjack.Logger{
1✔
849
                Filename:   settings.Logger.FileName,
1✔
850
                MaxSize:    int(maxSize), //nolint:gosec // size in MB not that big to cause overflow
1✔
851
                MaxBackups: settings.Logger.MaxBackups,
1✔
852
                Compress:   true,
1✔
853
                LocalTime:  true,
1✔
854
        }, nil
1✔
855
}
856

857
// migrateSamples runs migrations from legacy text files samples to db, if such files found
858
func migrateSamples(ctx context.Context, settings *config.Settings, samplesDB *storage.Samples) error {
7✔
859
        if settings.Convert == "disabled" {
7✔
UNCOV
860
                log.Print("[DEBUG] samples migration disabled")
×
UNCOV
861
                return nil
×
UNCOV
862
        }
×
863
        migrateSamples := func(file string, sampleType storage.SampleType, origin storage.SampleOrigin) (*storage.SamplesStats, error) {
31✔
864
                if _, err := os.Stat(file); err != nil {
39✔
865
                        log.Printf("[DEBUG] samples file %s not found, skip", file)
15✔
866
                        return &storage.SamplesStats{}, nil
15✔
867
                }
15✔
868
                fh, err := os.Open(file) //nolint:gosec // file path is controlled by the app
9✔
869
                if err != nil {
9✔
UNCOV
870
                        return nil, fmt.Errorf("can't open samples file, %w", err)
×
UNCOV
871
                }
×
872
                defer fh.Close()
9✔
873
                stats, err := samplesDB.Import(ctx, sampleType, origin, fh, true) // clean records before import
9✔
874
                if err != nil {
9✔
875
                        return nil, fmt.Errorf("can't load samples, %w", err)
×
876
                }
×
877
                if err := fh.Close(); err != nil {
9✔
UNCOV
878
                        return nil, fmt.Errorf("can't close samples file, %w", err)
×
UNCOV
879
                }
×
880
                if err := os.Rename(file, file+".loaded"); err != nil {
9✔
881
                        return nil, fmt.Errorf("can't rename samples file, %w", err)
×
UNCOV
882
                }
×
883
                return stats, nil
9✔
884
        }
885

886
        if samplesDB == nil {
8✔
887
                return errors.New("samples db is nil")
1✔
888
        }
1✔
889

890
        // migrate preset spam samples if files exist
891
        spamPresetFile := filepath.Join(settings.Files.SamplesDataPath, samplesSpamFile)
6✔
892
        s, err := migrateSamples(spamPresetFile, storage.SampleTypeSpam, storage.SampleOriginPreset)
6✔
893
        if err != nil {
6✔
UNCOV
894
                return fmt.Errorf("can't migrate spam preset samples, %w", err)
×
UNCOV
895
        }
×
896
        if s.PresetHam > 0 {
6✔
UNCOV
897
                log.Printf("[DEBUG] spam preset samples loaded: %s", s)
×
UNCOV
898
        }
×
899

900
        // migrate preset ham samples if files exist
901
        hamPresetFile := filepath.Join(settings.Files.SamplesDataPath, samplesHamFile)
6✔
902
        s, err = migrateSamples(hamPresetFile, storage.SampleTypeHam, storage.SampleOriginPreset)
6✔
903
        if err != nil {
6✔
UNCOV
904
                return fmt.Errorf("can't migrate ham preset samples, %w", err)
×
UNCOV
905
        }
×
906
        if s.PresetHam > 0 {
8✔
907
                log.Printf("[DEBUG] ham preset samples loaded: %s", s)
2✔
908
        }
2✔
909

910
        // migrate dynamic spam samples if files exist
911
        dynSpamFile := filepath.Join(settings.Files.DynamicDataPath, dynamicSpamFile)
6✔
912
        s, err = migrateSamples(dynSpamFile, storage.SampleTypeSpam, storage.SampleOriginUser)
6✔
913
        if err != nil {
6✔
UNCOV
914
                return fmt.Errorf("can't migrate spam dynamic samples, %w", err)
×
UNCOV
915
        }
×
916
        if s.UserSpam > 0 {
7✔
917
                log.Printf("[DEBUG] spam dynamic samples loaded: %s", s)
1✔
918
        }
1✔
919

920
        // migrate dynamic ham samples if files exist
921
        dynHamFile := filepath.Join(settings.Files.DynamicDataPath, dynamicHamFile)
6✔
922
        s, err = migrateSamples(dynHamFile, storage.SampleTypeHam, storage.SampleOriginUser)
6✔
923
        if err != nil {
6✔
UNCOV
924
                return fmt.Errorf("can't migrate ham dynamic samples, %w", err)
×
UNCOV
925
        }
×
926
        if s.UserHam > 0 {
8✔
927
                log.Printf("[DEBUG] ham dynamic samples loaded: %s", s)
2✔
928
        }
2✔
929

930
        if s.TotalHam > 0 || s.TotalSpam > 0 {
8✔
931
                log.Printf("[INFO] samples migration done: %s", s)
2✔
932
        }
2✔
933
        return nil
6✔
934
}
935

936
// migrateDicts runs migrations from legacy dictionary text files to db, if needed
937
func migrateDicts(ctx context.Context, settings *config.Settings, dictDB *storage.Dictionary) error {
7✔
938
        if settings.Convert == "disabled" {
7✔
UNCOV
939
                log.Print("[DEBUG] dictionary migration disabled")
×
UNCOV
940
                return nil
×
UNCOV
941
        }
×
942

943
        migrateDict := func(file string, dictType storage.DictionaryType) (*storage.DictionaryStats, error) {
19✔
944
                if _, err := os.Stat(file); err != nil {
17✔
945
                        log.Printf("[DEBUG] dictionary file %s not found, skip", file)
5✔
946
                        return &storage.DictionaryStats{}, nil
5✔
947
                }
5✔
948
                fh, err := os.Open(file) //nolint:gosec // file path is controlled by the app
7✔
949
                if err != nil {
7✔
UNCOV
950
                        return nil, fmt.Errorf("can't open dictionary file, %w", err)
×
UNCOV
951
                }
×
952
                defer fh.Close()
7✔
953
                stats, err := dictDB.Import(ctx, dictType, fh, true) // clean records before import
7✔
954
                if err != nil {
7✔
955
                        return nil, fmt.Errorf("can't load dictionary, %w", err)
×
956
                }
×
957
                if err := fh.Close(); err != nil {
7✔
UNCOV
958
                        return nil, fmt.Errorf("can't close dictionary file, %w", err)
×
UNCOV
959
                }
×
960
                if err := os.Rename(file, file+".loaded"); err != nil {
7✔
961
                        return nil, fmt.Errorf("can't rename dictionary file, %w", err)
×
UNCOV
962
                }
×
963
                return stats, nil
7✔
964
        }
965

966
        if dictDB == nil {
8✔
967
                return errors.New("dictionary db is nil")
1✔
968
        }
1✔
969

970
        // migrate stop-words if files exist
971
        stopWordsFile := filepath.Join(settings.Files.SamplesDataPath, stopWordsFile)
6✔
972
        s, err := migrateDict(stopWordsFile, storage.DictionaryTypeStopPhrase)
6✔
973
        if err != nil {
6✔
UNCOV
974
                return fmt.Errorf("can't migrate stop words, %w", err)
×
UNCOV
975
        }
×
976
        if s.TotalStopPhrases > 0 {
8✔
977
                log.Printf("[INFO] stop words loaded: %s", s)
2✔
978
        }
2✔
979

980
        // migrate excluded tokens if files exist
981
        excludeTokensFile := filepath.Join(settings.Files.SamplesDataPath, excludeTokensFile)
6✔
982
        s, err = migrateDict(excludeTokensFile, storage.DictionaryTypeIgnoredWord)
6✔
983
        if err != nil {
6✔
UNCOV
984
                return fmt.Errorf("can't migrate excluded tokens, %w", err)
×
UNCOV
985
        }
×
986
        if s.TotalIgnoredWords > 0 {
9✔
987
                log.Printf("[INFO] excluded tokens loaded: %s", s)
3✔
988
        }
3✔
989

990
        if s.TotalIgnoredWords > 0 || s.TotalStopPhrases > 0 {
9✔
991
                log.Printf("[DEBUG] dictionaries migration done: %s", s)
3✔
992
        }
3✔
993
        return nil
6✔
994
}
995

996
// backupDB creates a backup of the db file if the version has changed. It copies the db file to a new db file
997
// named as the original file with a version suffix, e.g., tg-spam.db.master-77e0bfd-20250107T23:17:34.
998
// The file is created only if the version has changed and a backup file with the name tg-spam.db.<version> does not exist.
999
// It keeps up to maxBackups files; if maxBackups is 0, no backups are made.
1000
// Files are removed based on the final part of the version, i.e., 20250107T23:17:34, with the oldest backups removed first.
1001
// If the backup file extension suffix with the timestamp is not found, the modification time of the file is used instead.
1002
func backupDB(dbFile, version string, maxBackups int) error {
6✔
1003
        if maxBackups == 0 {
7✔
1004
                return nil
1✔
1005
        }
1✔
1006
        backupFile := dbFile + "." + strings.ReplaceAll(version, ".", "_") // replace dots with underscores for file name
5✔
1007
        if _, err := os.Stat(backupFile); err == nil {
6✔
1008
                // backup file for the version already exists, no need to make it again
1✔
1009
                return nil
1✔
1010
        }
1✔
1011
        if _, err := os.Stat(dbFile); err != nil {
5✔
1012
                // db file not found, no need to backup. This is legit if the db is not created yet on the first run
1✔
1013
                log.Printf("[WARN] db file not found: %s, skip backup", dbFile)
1✔
1014
                return nil
1✔
1015
        }
1✔
1016

1017
        log.Printf("[DEBUG] db backup: %s -> %s", dbFile, backupFile)
3✔
1018
        // copy current db to the backup file
3✔
1019
        if err := fileutils.CopyFile(dbFile, backupFile); err != nil {
3✔
UNCOV
1020
                return fmt.Errorf("failed to copy db file: %w", err)
×
UNCOV
1021
        }
×
1022
        log.Printf("[INFO] db backup created: %s", backupFile)
3✔
1023

3✔
1024
        // cleanup old backups if needed
3✔
1025
        files, err := filepath.Glob(dbFile + ".*")
3✔
1026
        if err != nil {
3✔
UNCOV
1027
                return fmt.Errorf("failed to list backup files: %w", err)
×
UNCOV
1028
        }
×
1029

1030
        if len(files) <= maxBackups {
4✔
1031
                return nil
1✔
1032
        }
1✔
1033

1034
        // sort files by timestamp in version suffix or mod time if suffix not formatted as timestamp
1035
        sort.Slice(files, func(i, j int) bool {
8✔
1036
                getTime := func(f string) time.Time {
18✔
1037
                        base := filepath.Base(f) // file name like this: tg-spam.db.master-77e0bfd-20250107T23:17:34
12✔
1038
                        // try to get timestamp from version suffix first
12✔
1039
                        parts := strings.Split(base, "-")
12✔
1040
                        if len(parts) >= 3 {
22✔
1041
                                suffix := parts[len(parts)-1]
10✔
1042
                                if t, err := time.ParseInLocation("20060102T15:04:05", suffix, time.Local); err == nil {
20✔
1043
                                        return t
10✔
1044
                                }
10✔
1045
                        }
1046
                        // fallback to modification time for non-versioned files
1047
                        fi, err := os.Stat(f)
2✔
1048
                        if err != nil {
2✔
UNCOV
1049
                                log.Printf("[WARN] can't stat file %s: %v", f, err)
×
UNCOV
1050
                                return time.Now().Local() // treat errored files as newest to avoid deleting them
×
UNCOV
1051
                        }
×
1052
                        return fi.ModTime().Local() // convert to local for consistent comparison
2✔
1053
                }
1054
                return getTime(files[i]).Before(getTime(files[j]))
6✔
1055
        })
1056

1057
        // remove oldest files
1058
        for i := 0; i < len(files)-maxBackups; i++ {
5✔
1059
                if err := os.Remove(files[i]); err != nil {
3✔
UNCOV
1060
                        return fmt.Errorf("failed to remove old backup %s: %w", files[i], err)
×
UNCOV
1061
                }
×
1062
                log.Printf("[DEBUG] db backup removed: %s", files[i])
3✔
1063
        }
1064
        return nil
2✔
1065
}
1066

1067
// generateAuthHash creates a bcrypt hash from the given password
1068
// If the password is "auto", generates a random password first
1069
func generateAuthHash(password string) (string, error) {
2✔
1070
        var hashSource string
2✔
1071
        var randomPassword string
2✔
1072
        var err error
2✔
1073

2✔
1074
        // generate random password if needed
2✔
1075
        if password == "auto" {
3✔
1076
                randomPassword, err = webapi.GenerateRandomPassword(20)
1✔
1077
                if err != nil {
1✔
NEW
1078
                        return "", fmt.Errorf("can't generate random password: %w", err)
×
NEW
1079
                }
×
1080
                hashSource = randomPassword
1✔
1081
        } else {
1✔
1082
                hashSource = password
1✔
1083
        }
1✔
1084

1085
        // generate hash from the password or random password
1086
        hash, err := rest.GenerateBcryptHash(hashSource)
2✔
1087
        if err != nil {
2✔
NEW
1088
                return "", fmt.Errorf("can't generate bcrypt hash: %w", err)
×
NEW
1089
        }
×
1090

1091
        // log the appropriate message
1092
        if password == "auto" {
3✔
1093
                log.Printf("[WARN] generated basic auth password for user tg-spam: %q, bcrypt hash: %s", randomPassword, hash)
1✔
1094
        } else {
2✔
1095
                log.Printf("[INFO] generated bcrypt hash from provided password")
1✔
1096
        }
1✔
1097

1098
        return hash, nil
2✔
1099
}
1100

1101
// optToSettings converts CLI options to the domain settings model
NEW
1102
func optToSettings(opts options) *config.Settings {
×
NEW
1103
        // create settings model from options
×
NEW
1104
        settings := &config.Settings{
×
NEW
1105
                InstanceID: opts.InstanceID,
×
NEW
1106

×
NEW
1107
                Telegram: config.TelegramSettings{
×
NEW
1108
                        Group:        opts.Telegram.Group,
×
NEW
1109
                        IdleDuration: opts.Telegram.IdleDuration,
×
NEW
1110
                        Timeout:      opts.Telegram.Timeout,
×
NEW
1111
                },
×
NEW
1112

×
NEW
1113
                Admin: config.AdminSettings{
×
NEW
1114
                        AdminGroup:              opts.AdminGroup,
×
NEW
1115
                        DisableAdminSpamForward: opts.DisableAdminSpamForward,
×
NEW
1116
                        TestingIDs:              opts.TestingIDs,
×
NEW
1117
                        SuperUsers:              opts.SuperUsers,
×
NEW
1118
                },
×
NEW
1119

×
NEW
1120
                History: config.HistorySettings{
×
NEW
1121
                        Duration: opts.HistoryDuration,
×
NEW
1122
                        MinSize:  opts.HistoryMinSize,
×
NEW
1123
                        Size:     opts.HistorySize,
×
NEW
1124
                },
×
NEW
1125

×
NEW
1126
                Logger: config.LoggerSettings{
×
NEW
1127
                        Enabled:    opts.Logger.Enabled,
×
NEW
1128
                        FileName:   opts.Logger.FileName,
×
NEW
1129
                        MaxSize:    opts.Logger.MaxSize,
×
NEW
1130
                        MaxBackups: opts.Logger.MaxBackups,
×
NEW
1131
                },
×
NEW
1132

×
NEW
1133
                CAS: config.CASSettings{
×
NEW
1134
                        API:       opts.CAS.API,
×
NEW
1135
                        Timeout:   opts.CAS.Timeout,
×
NEW
1136
                        UserAgent: opts.CAS.UserAgent,
×
NEW
1137
                },
×
NEW
1138

×
NEW
1139
                Meta: config.MetaSettings{
×
NEW
1140
                        LinksLimit:      opts.Meta.LinksLimit,
×
NEW
1141
                        MentionsLimit:   opts.Meta.MentionsLimit,
×
NEW
1142
                        ImageOnly:       opts.Meta.ImageOnly,
×
NEW
1143
                        LinksOnly:       opts.Meta.LinksOnly,
×
NEW
1144
                        VideosOnly:      opts.Meta.VideosOnly,
×
NEW
1145
                        AudiosOnly:      opts.Meta.AudiosOnly,
×
NEW
1146
                        Forward:         opts.Meta.Forward,
×
NEW
1147
                        Keyboard:        opts.Meta.Keyboard,
×
NEW
1148
                        UsernameSymbols: opts.Meta.UsernameSymbols,
×
NEW
1149
                },
×
NEW
1150

×
NEW
1151
                OpenAI: config.OpenAISettings{
×
NEW
1152
                        APIBase:           opts.OpenAI.APIBase,
×
NEW
1153
                        Veto:              opts.OpenAI.Veto,
×
NEW
1154
                        Prompt:            opts.OpenAI.Prompt,
×
NEW
1155
                        CustomPrompts:     opts.OpenAI.CustomPrompts,
×
NEW
1156
                        Model:             opts.OpenAI.Model,
×
NEW
1157
                        MaxTokensResponse: opts.OpenAI.MaxTokensResponse,
×
NEW
1158
                        MaxTokensRequest:  opts.OpenAI.MaxTokensRequest,
×
NEW
1159
                        MaxSymbolsRequest: opts.OpenAI.MaxSymbolsRequest,
×
NEW
1160
                        RetryCount:        opts.OpenAI.RetryCount,
×
NEW
1161
                        HistorySize:       opts.OpenAI.HistorySize,
×
NEW
1162
                        ReasoningEffort:   opts.OpenAI.ReasoningEffort,
×
NEW
1163
                },
×
NEW
1164

×
NEW
1165
                LuaPlugins: config.LuaPluginsSettings{
×
NEW
1166
                        Enabled:        opts.LuaPlugins.Enabled,
×
NEW
1167
                        PluginsDir:     opts.LuaPlugins.PluginsDir,
×
NEW
1168
                        EnabledPlugins: opts.LuaPlugins.EnabledPlugins,
×
NEW
1169
                        DynamicReload:  opts.LuaPlugins.DynamicReload,
×
NEW
1170
                },
×
NEW
1171

×
NEW
1172
                AbnormalSpace: config.AbnormalSpaceSettings{
×
NEW
1173
                        Enabled:                 opts.AbnormalSpacing.Enabled,
×
NEW
1174
                        SpaceRatioThreshold:     opts.AbnormalSpacing.SpaceRatioThreshold,
×
NEW
1175
                        ShortWordRatioThreshold: opts.AbnormalSpacing.ShortWordRatioThreshold,
×
NEW
1176
                        ShortWordLen:            opts.AbnormalSpacing.ShortWordLen,
×
NEW
1177
                        MinWords:                opts.AbnormalSpacing.MinWords,
×
NEW
1178
                },
×
NEW
1179

×
NEW
1180
                Files: config.FilesSettings{
×
NEW
1181
                        SamplesDataPath: opts.Files.SamplesDataPath,
×
NEW
1182
                        DynamicDataPath: opts.Files.DynamicDataPath,
×
NEW
1183
                        WatchInterval:   int(opts.Files.WatchInterval.Seconds()),
×
NEW
1184
                },
×
NEW
1185

×
NEW
1186
                Message: config.MessageSettings{
×
NEW
1187
                        Startup: opts.Message.Startup,
×
NEW
1188
                        Spam:    opts.Message.Spam,
×
NEW
1189
                        Dry:     opts.Message.Dry,
×
NEW
1190
                        Warn:    opts.Message.Warn,
×
NEW
1191
                },
×
NEW
1192

×
NEW
1193
                Server: config.ServerSettings{
×
NEW
1194
                        Enabled:    opts.Server.Enabled,
×
NEW
1195
                        ListenAddr: opts.Server.ListenAddr,
×
NEW
1196
                        AuthUser:   opts.Server.AuthUser,
×
NEW
1197
                },
×
NEW
1198

×
NEW
1199
                SimilarityThreshold: opts.SimilarityThreshold,
×
NEW
1200
                MinMsgLen:           opts.MinMsgLen,
×
NEW
1201
                MaxEmoji:            opts.MaxEmoji,
×
NEW
1202
                MinSpamProbability:  opts.MinSpamProbability,
×
NEW
1203
                MultiLangWords:      opts.MultiLangWords,
×
NEW
1204
                NoSpamReply:         opts.NoSpamReply,
×
NEW
1205
                SuppressJoinMessage: opts.SuppressJoinMessage,
×
NEW
1206
                ParanoidMode:        opts.ParanoidMode,
×
NEW
1207
                FirstMessagesCount:  opts.FirstMessagesCount,
×
NEW
1208
                Training:            opts.Training,
×
NEW
1209
                SoftBan:             opts.SoftBan,
×
NEW
1210
                Convert:             opts.Convert,
×
NEW
1211
                MaxBackups:          opts.MaxBackups,
×
NEW
1212
                Dry:                 opts.Dry,
×
NEW
1213
        }
×
NEW
1214

×
NEW
1215
        // set transient settings (not persisted to database)
×
NEW
1216
        settings.Transient = config.TransientSettings{
×
NEW
1217
                DataBaseURL:        opts.DataBaseURL,
×
NEW
1218
                StorageTimeout:     opts.StorageTimeout,
×
NEW
1219
                ConfigDB:           opts.ConfigDB,
×
NEW
1220
                Dbg:                opts.Dbg,
×
NEW
1221
                TGDbg:              opts.TGDbg,
×
NEW
1222
                WebAuthPasswd:      opts.Server.AuthPasswd,
×
NEW
1223
                ConfigDBEncryptKey: opts.ConfigDBEncryptKey,
×
NEW
1224
        }
×
NEW
1225

×
NEW
1226
        // set credentials in their respective domain structures
×
NEW
1227
        settings.Telegram.Token = opts.Telegram.Token
×
NEW
1228
        settings.OpenAI.Token = opts.OpenAI.Token
×
NEW
1229
        settings.Server.AuthHash = opts.Server.AuthHash
×
NEW
1230

×
NEW
1231
        return settings
×
NEW
1232
}
×
1233

1234
// applyCLIOverrides applies explicit CLI overrides to settings loaded from database
1235
// Only overrides values that were explicitly set on the command line (not defaults)
1236
//
1237
// How to add new overrides:
1238
// 1. Check if the CLI option was explicitly provided (not using default value)
1239
// 2. Compare with the default value from the options struct definition
1240
// 3. Apply the override only if the value differs from the default
1241
//
1242
// Example for adding telegram token override:
1243
//
1244
//        if opts.Telegram.Token != "" {  // "" is the default
1245
//            settings.Telegram.Token = opts.Telegram.Token
1246
//        }
1247
func applyCLIOverrides(settings *config.Settings, opts options) {
4✔
1248
        // override auth password if explicitly provided (not using default "auto")
4✔
1249
        if opts.Server.AuthPasswd != "auto" {
6✔
1250
                settings.Transient.WebAuthPasswd = opts.Server.AuthPasswd
2✔
1251
                // clear auth hash since we have a new password
2✔
1252
                settings.Server.AuthHash = ""
2✔
1253
        }
2✔
1254

1255
        // override auth hash if explicitly provided
1256
        if opts.Server.AuthHash != "" {
6✔
1257
                settings.Server.AuthHash = opts.Server.AuthHash
2✔
1258
                // clear password since hash takes precedence
2✔
1259
                settings.Transient.WebAuthPasswd = ""
2✔
1260
        }
2✔
1261
}
1262

1263
// loadConfigFromDB loads configuration from the database
1264
func loadConfigFromDB(ctx context.Context, settings *config.Settings) error {
5✔
1265
        if !settings.Transient.ConfigDB {
5✔
NEW
1266
                return nil // skip if not enabled
×
NEW
1267
        }
×
1268

1269
        log.Print("[INFO] loading configuration from database")
5✔
1270

5✔
1271
        // create database connection using the same logic as main data DB
5✔
1272
        db, err := makeDB(ctx, settings)
5✔
1273
        if err != nil {
5✔
NEW
1274
                return fmt.Errorf("failed to connect to database for config: %w", err)
×
NEW
1275
        }
×
1276

1277
        // create settings store with encryption if key provided
1278
        var storeOpts []config.StoreOption
5✔
1279
        if settings.Transient.ConfigDBEncryptKey != "" {
5✔
NEW
1280
                crypter, cryptErr := config.NewCrypter(settings.Transient.ConfigDBEncryptKey, settings.InstanceID)
×
NEW
1281
                if cryptErr != nil {
×
NEW
1282
                        log.Fatalf("[FATAL] invalid encryption key: %v", cryptErr)
×
NEW
1283
                }
×
NEW
1284
                storeOpts = append(storeOpts, config.WithCrypter(crypter))
×
NEW
1285
                log.Print("[INFO] configuration encryption enabled for database access")
×
1286
        }
1287

1288
        settingsStore, err := config.NewStore(ctx, db, storeOpts...)
5✔
1289
        if err != nil {
5✔
NEW
1290
                return fmt.Errorf("failed to create settings store: %w", err)
×
NEW
1291
        }
×
1292

1293
        // load settings
1294
        dbSettings, err := settingsStore.Load(ctx)
5✔
1295
        if err != nil {
6✔
1296
                return fmt.Errorf("failed to load settings from database: %w", err)
1✔
1297
        }
1✔
1298

1299
        // save original transient values only (non-functional values)
1300
        transient := settings.Transient
4✔
1301

4✔
1302
        // replace settings with loaded values including credentials
4✔
1303
        *settings = *dbSettings
4✔
1304

4✔
1305
        // restore transient values
4✔
1306
        settings.Transient = transient
4✔
1307

4✔
1308
        log.Printf("[INFO] configuration loaded from database successfully")
4✔
1309
        return nil
4✔
1310
}
1311

1312
// saveConfigToDB saves the current configuration to the database
1313
func saveConfigToDB(ctx context.Context, settings *config.Settings) error {
4✔
1314
        log.Print("[INFO] saving configuration to database")
4✔
1315

4✔
1316
        // create database connection
4✔
1317
        db, err := makeDB(ctx, settings)
4✔
1318
        if err != nil {
4✔
NEW
1319
                return fmt.Errorf("failed to connect to database for config: %w", err)
×
NEW
1320
        }
×
1321

1322
        // create settings store with encryption if key provided
1323
        var storeOpts []config.StoreOption
4✔
1324
        if settings.Transient.ConfigDBEncryptKey != "" {
4✔
NEW
1325
                crypter, cryptErr := config.NewCrypter(settings.Transient.ConfigDBEncryptKey, settings.InstanceID)
×
NEW
1326
                if cryptErr != nil {
×
NEW
1327
                        log.Fatalf("[FATAL] invalid encryption key: %v", cryptErr)
×
NEW
1328
                }
×
NEW
1329
                storeOpts = append(storeOpts, config.WithCrypter(crypter))
×
NEW
1330
                log.Print("[INFO] configuration encryption enabled for database storage")
×
1331
        }
1332

1333
        settingsStore, err := config.NewStore(ctx, db, storeOpts...)
4✔
1334
        if err != nil {
4✔
NEW
1335
                return fmt.Errorf("failed to create settings store: %w", err)
×
NEW
1336
        }
×
1337

1338
        // generate auth hash if password is provided but hash isn't
1339
        if settings.Transient.WebAuthPasswd != "" && settings.Server.AuthHash == "" {
5✔
1340
                // generate bcrypt hash from the password
1✔
1341
                hash, hashErr := generateAuthHash(settings.Transient.WebAuthPasswd)
1✔
1342
                if hashErr != nil {
1✔
NEW
1343
                        return fmt.Errorf("failed to generate auth hash: %w", hashErr)
×
NEW
1344
                }
×
1345

1346
                // update the hash directly in the Server settings domain
1347
                settings.Server.AuthHash = hash
1✔
1348
        }
1349

1350
        // save settings to database
1351
        if err := settingsStore.Save(ctx, settings); err != nil {
4✔
NEW
1352
                return fmt.Errorf("failed to save configuration to database: %w", err)
×
NEW
1353
        }
×
1354

1355
        log.Printf("[INFO] configuration saved to database successfully")
4✔
1356
        return nil
4✔
1357
}
1358

1359
func setupLog(dbg bool, secrets ...string) {
2✔
1360
        logOpts := []lgr.Option{lgr.Msec, lgr.LevelBraces, lgr.StackTraceOnError}
2✔
1361
        if dbg {
4✔
1362
                logOpts = []lgr.Option{lgr.Debug, lgr.CallerFile, lgr.CallerFunc, lgr.Msec, lgr.LevelBraces, lgr.StackTraceOnError}
2✔
1363
        }
2✔
1364

1365
        colorizer := lgr.Mapper{
2✔
1366
                ErrorFunc:  func(s string) string { return color.New(color.FgHiRed).Sprint(s) },
2✔
1367
                WarnFunc:   func(s string) string { return color.New(color.FgRed).Sprint(s) },
36✔
1368
                InfoFunc:   func(s string) string { return color.New(color.FgYellow).Sprint(s) },
120✔
1369
                DebugFunc:  func(s string) string { return color.New(color.FgWhite).Sprint(s) },
194✔
1370
                CallerFunc: func(s string) string { return color.New(color.FgBlue).Sprint(s) },
175✔
1371
                TimeFunc:   func(s string) string { return color.New(color.FgCyan).Sprint(s) },
175✔
1372
        }
1373
        logOpts = append(logOpts, lgr.Map(colorizer))
2✔
1374

2✔
1375
        if len(secrets) > 0 {
3✔
1376
                logOpts = append(logOpts, lgr.Secret(secrets...))
1✔
1377
        }
1✔
1378
        lgr.SetupStdLogger(logOpts...)
2✔
1379
        lgr.Setup(logOpts...)
2✔
1380
}
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

© 2025 Coveralls, Inc