• 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

91.14
/app/webapi/config.go
1
package webapi
2

3
import (
4
        "context"
5
        "fmt"
6
        "net/http"
7
        "strconv"
8
        "strings"
9
        "time"
10

11
        log "github.com/go-pkgz/lgr"
12
        "github.com/go-pkgz/rest"
13

14
        "github.com/umputun/tg-spam/app/config"
15
)
16

17
//go:generate moq --out config_store_mock.go --with-resets --skip-ensure . SettingsStore
18

19
// SettingsStore provides access to configuration stored in database
20
type SettingsStore interface {
21
        Load(ctx context.Context) (*config.Settings, error)
22
        Save(ctx context.Context, settings *config.Settings) error
23
        Delete(ctx context.Context) error
24
        LastUpdated(ctx context.Context) (time.Time, error)
25
        Exists(ctx context.Context) (bool, error)
26
}
27

28
// saveConfigHandler handles POST /config request.
29
// It saves the current configuration to the database.
30
func (s *Server) saveConfigHandler(w http.ResponseWriter, r *http.Request) {
4✔
31
        if s.SettingsStore == nil {
5✔
32
                http.Error(w, "Configuration storage not available", http.StatusInternalServerError)
1✔
33
                return
1✔
34
        }
1✔
35

36
        // save current settings to database
37
        err := s.SettingsStore.Save(r.Context(), s.AppSettings)
3✔
38
        if err != nil {
4✔
39
                log.Printf("[ERROR] failed to save configuration: %v", err)
1✔
40
                http.Error(w, fmt.Sprintf("Failed to save configuration: %v", err), http.StatusInternalServerError)
1✔
41
                return
1✔
42
        }
1✔
43

44
        if r.Header.Get("HX-Request") == "true" {
3✔
45
                // return a success message for HTMX
1✔
46
                if _, err := w.Write([]byte(`<div class="alert alert-success">Configuration saved successfully</div>`)); err != nil {
1✔
NEW
47
                        log.Printf("[ERROR] failed to write response: %v", err)
×
NEW
48
                }
×
49
                return
1✔
50
        }
51

52
        // return JSON response for API calls
53
        rest.RenderJSON(w, rest.JSON{"status": "ok", "message": "Configuration saved successfully"})
1✔
54
}
55

56
// loadConfigHandler handles GET /config request.
57
// It loads configuration from the database.
58
func (s *Server) loadConfigHandler(w http.ResponseWriter, r *http.Request) {
5✔
59
        if s.SettingsStore == nil {
6✔
60
                http.Error(w, "Configuration storage not available", http.StatusInternalServerError)
1✔
61
                return
1✔
62
        }
1✔
63

64
        // load settings from database
65
        settings, err := s.SettingsStore.Load(r.Context())
4✔
66
        if err != nil {
5✔
67
                log.Printf("[ERROR] failed to load configuration: %v", err)
1✔
68
                http.Error(w, fmt.Sprintf("Failed to load configuration: %v", err), http.StatusInternalServerError)
1✔
69
                return
1✔
70
        }
1✔
71

72
        // preserve CLI-provided credentials and transient settings
73
        // CLI credentials have precedence over database values if provided
74
        transient := s.AppSettings.Transient
3✔
75
        telegramToken := s.AppSettings.Telegram.Token
3✔
76
        openAIToken := s.AppSettings.OpenAI.Token
3✔
77
        webAuthHash := s.AppSettings.Server.AuthHash
3✔
78
        webAuthPasswd := s.AppSettings.Transient.WebAuthPasswd
3✔
79

3✔
80
        // restore transient values
3✔
81
        settings.Transient = transient
3✔
82

3✔
83
        // restore CLI-provided credentials if they were set
3✔
84
        // these override database values because CLI parameters take precedence
3✔
85
        if telegramToken != "" {
4✔
86
                settings.Telegram.Token = telegramToken
1✔
87
        }
1✔
88
        if openAIToken != "" {
4✔
89
                settings.OpenAI.Token = openAIToken
1✔
90
        }
1✔
91
        if webAuthHash != "" {
3✔
NEW
92
                settings.Server.AuthHash = webAuthHash
×
NEW
93
        }
×
94
        if webAuthPasswd != "" {
3✔
NEW
95
                settings.Transient.WebAuthPasswd = webAuthPasswd
×
NEW
96
        }
×
97

98
        // update current settings
99
        s.AppSettings = settings
3✔
100

3✔
101
        if r.Header.Get("HX-Request") == "true" {
4✔
102
                // return a success message for HTMX with reload
1✔
103
                w.Header().Set("HX-Refresh", "true") // force page reload to reflect new settings
1✔
104
                if _, err := w.Write([]byte(`<div class="alert alert-success">Configuration loaded successfully. Refreshing page...</div>`)); err != nil {
1✔
NEW
105
                        log.Printf("[ERROR] failed to write response: %v", err)
×
NEW
106
                }
×
107
                return
1✔
108
        }
109

110
        // return JSON response for API calls
111
        rest.RenderJSON(w, rest.JSON{"status": "ok", "message": "Configuration loaded successfully"})
2✔
112
}
113

114
// updateConfigHandler handles PUT /config request.
115
// It updates specific configuration settings in memory and optionally saves to database.
116
func (s *Server) updateConfigHandler(w http.ResponseWriter, r *http.Request) {
6✔
117
        if err := r.ParseForm(); err != nil {
7✔
118
                http.Error(w, fmt.Sprintf("Failed to parse form: %v", err), http.StatusBadRequest)
1✔
119
                return
1✔
120
        }
1✔
121

122
        log.Printf("[DEBUG] updateConfigHandler: saveToDb=%s, SettingsStore=%v", r.FormValue("saveToDb"), s.SettingsStore != nil)
5✔
123

5✔
124
        // update settings based on form values - auth settings are never modified
5✔
125
        updateSettingsFromForm(s.AppSettings, r)
5✔
126

5✔
127
        // save changes to database if requested
5✔
128
        if r.FormValue("saveToDb") == "true" && s.SettingsStore != nil {
7✔
129
                log.Printf("[DEBUG] Saving settings to database")
2✔
130
                err := s.SettingsStore.Save(r.Context(), s.AppSettings)
2✔
131
                if err != nil {
3✔
132
                        log.Printf("[ERROR] failed to save updated configuration: %v", err)
1✔
133
                        http.Error(w, fmt.Sprintf("Failed to save configuration: %v", err), http.StatusInternalServerError)
1✔
134
                        return
1✔
135
                }
1✔
136
                log.Printf("[DEBUG] Settings saved successfully")
1✔
137
        }
138

139
        if r.Header.Get("HX-Request") == "true" {
5✔
140
                // return a success message for HTMX
1✔
141
                if _, err := w.Write([]byte(`<div class="alert alert-success">Configuration updated successfully</div>`)); err != nil {
1✔
NEW
142
                        log.Printf("[ERROR] failed to write response: %v", err)
×
NEW
143
                }
×
144
                return
1✔
145
        }
146

147
        // return JSON response for API calls
148
        rest.RenderJSON(w, rest.JSON{"status": "ok", "message": "Configuration updated successfully"})
3✔
149
}
150

151
// deleteConfigHandler handles DELETE /config request.
152
// It deletes the saved configuration from the database.
153
func (s *Server) deleteConfigHandler(w http.ResponseWriter, r *http.Request) {
4✔
154
        if s.SettingsStore == nil {
5✔
155
                http.Error(w, "Configuration storage not available", http.StatusInternalServerError)
1✔
156
                return
1✔
157
        }
1✔
158

159
        // delete configuration from database
160
        err := s.SettingsStore.Delete(r.Context())
3✔
161
        if err != nil {
4✔
162
                log.Printf("[ERROR] failed to delete configuration: %v", err)
1✔
163
                http.Error(w, fmt.Sprintf("Failed to delete configuration: %v", err), http.StatusInternalServerError)
1✔
164
                return
1✔
165
        }
1✔
166

167
        if r.Header.Get("HX-Request") == "true" {
3✔
168
                // return a success message for HTMX
1✔
169
                if _, err := w.Write([]byte(`<div class="alert alert-success">Configuration deleted successfully</div>`)); err != nil {
1✔
NEW
170
                        log.Printf("[ERROR] failed to write response: %v", err)
×
NEW
171
                }
×
172
                return
1✔
173
        }
174

175
        // return JSON response for API calls
176
        rest.RenderJSON(w, rest.JSON{"status": "ok", "message": "Configuration deleted successfully"})
1✔
177
}
178

179
// updateSettingsFromForm updates settings from form values
180
func updateSettingsFromForm(settings *config.Settings, r *http.Request) {
15✔
181
        // general settings
15✔
182
        if val := r.FormValue("primaryGroup"); val != "" {
19✔
183
                settings.Telegram.Group = val
4✔
184
        }
4✔
185
        if val := r.FormValue("adminGroup"); val != "" {
15✔
NEW
186
                settings.Admin.AdminGroup = val
×
NEW
187
        }
×
188
        settings.Admin.DisableAdminSpamForward = r.FormValue("disableAdminSpamForward") == "on"
15✔
189
        settings.Logger.Enabled = r.FormValue("loggerEnabled") == "on"
15✔
190
        settings.NoSpamReply = r.FormValue("noSpamReply") == "on"
15✔
191

15✔
192
        // handle CasEnabled separately because we need to set CAS.API
15✔
193
        casEnabled := r.FormValue("casEnabled") == "on"
15✔
194
        if casEnabled && settings.CAS.API == "" {
17✔
195
                settings.CAS.API = "https://api.cas.chat" // default CAS API endpoint
2✔
196
        } else if !casEnabled {
28✔
197
                settings.CAS.API = ""
13✔
198
        }
13✔
199

200
        // parse super users from comma-separated string
201
        if superUsers := r.FormValue("superUsers"); superUsers != "" {
17✔
202
                users := strings.Split(superUsers, ",")
2✔
203
                settings.Admin.SuperUsers = make([]string, 0, len(users))
2✔
204
                for _, user := range users {
8✔
205
                        trimmed := strings.TrimSpace(user)
6✔
206
                        if trimmed != "" {
12✔
207
                                settings.Admin.SuperUsers = append(settings.Admin.SuperUsers, trimmed)
6✔
208
                        }
6✔
209
                }
210
        }
211

212
        // meta checks
213
        metaEnabled := r.FormValue("metaEnabled") == "on"
15✔
214

15✔
215
        if val := r.FormValue("metaLinksLimit"); val != "" {
18✔
216
                if limit, err := strconv.Atoi(val); err == nil {
6✔
217
                        settings.Meta.LinksLimit = limit
3✔
218
                }
3✔
219
        } else if !metaEnabled {
24✔
220
                settings.Meta.LinksLimit = -1
12✔
221
        }
12✔
222

223
        if val := r.FormValue("metaMentionsLimit"); val != "" {
17✔
224
                if limit, err := strconv.Atoi(val); err == nil {
4✔
225
                        settings.Meta.MentionsLimit = limit
2✔
226
                }
2✔
227
        } else if !metaEnabled {
26✔
228
                settings.Meta.MentionsLimit = -1
13✔
229
        }
13✔
230

231
        settings.Meta.LinksOnly = r.FormValue("metaLinksOnly") == "on"
15✔
232
        settings.Meta.ImageOnly = r.FormValue("metaImageOnly") == "on"
15✔
233
        settings.Meta.VideosOnly = r.FormValue("metaVideoOnly") == "on"
15✔
234
        settings.Meta.AudiosOnly = r.FormValue("metaAudioOnly") == "on"
15✔
235
        settings.Meta.Forward = r.FormValue("metaForwarded") == "on"
15✔
236
        settings.Meta.Keyboard = r.FormValue("metaKeyboard") == "on"
15✔
237

15✔
238
        if val := r.FormValue("metaUsernameSymbols"); val != "" {
17✔
239
                settings.Meta.UsernameSymbols = val
2✔
240
        } else if !metaEnabled {
28✔
241
                settings.Meta.UsernameSymbols = ""
13✔
242
        }
13✔
243

244
        // openAI settings
245
        openAIEnabled := r.FormValue("openAIEnabled") == "on"
15✔
246
        if !openAIEnabled {
27✔
247
                settings.OpenAI.APIBase = ""
12✔
248
        }
12✔
249

250
        settings.OpenAI.Veto = r.FormValue("openAIVeto") == "on"
15✔
251

15✔
252
        if val := r.FormValue("openAIHistorySize"); val != "" {
17✔
253
                if size, err := strconv.Atoi(val); err == nil {
4✔
254
                        settings.OpenAI.HistorySize = size
2✔
255
                }
2✔
256
        }
257

258
        if val := r.FormValue("openAIModel"); val != "" {
17✔
259
                settings.OpenAI.Model = val
2✔
260
        }
2✔
261

262
        // lua plugins
263
        settings.LuaPlugins.Enabled = r.FormValue("luaPluginsEnabled") == "on"
15✔
264
        settings.LuaPlugins.DynamicReload = r.FormValue("luaDynamicReload") == "on"
15✔
265

15✔
266
        if val := r.FormValue("luaPluginsDir"); val != "" {
17✔
267
                settings.LuaPlugins.PluginsDir = val
2✔
268
        }
2✔
269

270
        // get selected Lua plugins
271
        settings.LuaPlugins.EnabledPlugins = r.Form["luaEnabledPlugins"]
15✔
272

15✔
273
        // spam detection
15✔
274
        if val := r.FormValue("similarityThreshold"); val != "" {
18✔
275
                if threshold, err := strconv.ParseFloat(val, 64); err == nil {
6✔
276
                        settings.SimilarityThreshold = threshold
3✔
277
                }
3✔
278
        }
279

280
        if val := r.FormValue("minMsgLen"); val != "" {
17✔
281
                if msgLen, err := strconv.Atoi(val); err == nil {
4✔
282
                        settings.MinMsgLen = msgLen
2✔
283
                }
2✔
284
        }
285

286
        if val := r.FormValue("maxEmoji"); val != "" {
17✔
287
                if count, err := strconv.Atoi(val); err == nil {
4✔
288
                        settings.MaxEmoji = count
2✔
289
                }
2✔
290
        }
291

292
        if val := r.FormValue("minSpamProbability"); val != "" {
17✔
293
                if prob, err := strconv.ParseFloat(val, 64); err == nil {
4✔
294
                        settings.MinSpamProbability = prob
2✔
295
                }
2✔
296
        }
297

298
        settings.ParanoidMode = r.FormValue("paranoidMode") == "on"
15✔
299

15✔
300
        if val := r.FormValue("firstMessagesCount"); val != "" {
16✔
301
                if count, err := strconv.Atoi(val); err == nil {
2✔
302
                        settings.FirstMessagesCount = count
1✔
303
                }
1✔
304
        }
305

306
        // startupMessageEnabled controls Message.Startup
307
        startupMessageEnabled := r.FormValue("startupMessageEnabled") == "on"
15✔
308
        if startupMessageEnabled && settings.Message.Startup == "" {
17✔
309
                settings.Message.Startup = "Bot started"
2✔
310
        } else if !startupMessageEnabled {
28✔
311
                settings.Message.Startup = ""
13✔
312
        }
13✔
313

314
        settings.Training = r.FormValue("trainingEnabled") == "on"
15✔
315
        settings.SoftBan = r.FormValue("softBanEnabled") == "on"
15✔
316
        settings.AbnormalSpace.Enabled = r.FormValue("abnormalSpacingEnabled") == "on"
15✔
317

15✔
318
        if val := r.FormValue("multiLangLimit"); val != "" {
15✔
NEW
319
                if limit, err := strconv.Atoi(val); err == nil {
×
NEW
320
                        settings.MultiLangWords = limit
×
NEW
321
                }
×
322
        }
323

324
        if val := r.FormValue("historySize"); val != "" {
16✔
325
                if size, err := strconv.Atoi(val); err == nil {
2✔
326
                        settings.History.Size = size
1✔
327
                }
1✔
328
        }
329

330
        // data storage
331
        if val := r.FormValue("samplesDataPath"); val != "" {
15✔
NEW
332
                settings.Files.SamplesDataPath = val
×
NEW
333
        }
×
334

335
        if val := r.FormValue("dynamicDataPath"); val != "" {
15✔
NEW
336
                settings.Files.DynamicDataPath = val
×
NEW
337
        }
×
338

339
        if val := r.FormValue("watchIntervalSecs"); val != "" {
16✔
340
                if secs, err := strconv.Atoi(val); err == nil {
2✔
341
                        settings.Files.WatchInterval = secs
1✔
342
                }
1✔
343
        }
344

345
        // debug modes - they're primarily CLI settings but we still update them here
346
        settings.Transient.Dbg = r.FormValue("debugModeEnabled") == "on"
15✔
347
        settings.Dry = r.FormValue("dryModeEnabled") == "on"
15✔
348
        settings.Transient.TGDbg = r.FormValue("tgDebugModeEnabled") == "on"
15✔
349
}
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