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

umputun / tg-spam / 28850477820

07 Jul 2026 07:51AM UTC coverage: 83.461% (-0.09%) from 83.554%
28850477820

push

github

umputun
fix: scope locator primary keys by gid to prevent cross-group clobbering

messages.hash and spam.user_id were single-column primary keys while the
upserts overwrite gid on conflict, so two instances with different
--instance-id sharing one database destroy each other's rows: the same text
(or a spam flag for the same user id) posted in group B clobbers group A's
row, silently breaking /spam reply-matching and the spam-info/unban buttons.

Keys are now composite: (gid, hash) and (gid, user_id). Existing databases
upgrade at startup inside the existing migration transaction - sqlite rebuilds
the tables (indexes recreated by InitTable right after), postgres swaps the PK
constraint in place. Both paths are idempotent; old data can't violate the new
keys since uniqueness on the single column implies uniqueness on the pair. The
gid columns are checked per table via catalog metadata, the spam gid backfill
goes through Adopt, and the postgres constraint name is quoted with
pq.QuoteIdentifier.

Tests: shared-DB isolation for both engines, postgres legacy-PK migration
(with nullable gid to exercise the implicit SET NOT NULL from ADD PRIMARY KEY),
partial-schema migration, and a full-row assertion on the sqlite rebuild copy.
The generic migration test now runs on postgres too via engine-aware helpers.
README documents that instances sharing one postgres database must upgrade
together.

128 of 177 new or added lines in 1 file covered. (72.32%)

2 existing lines in 2 files now uncovered.

8700 of 10424 relevant lines covered (83.46%)

251.01 hits per line

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

85.54
/app/webapi/webapi.go
1
// Package webapi provides a web API spam detection service.
2
package webapi
3

4
import (
5
        "bytes"
6
        "compress/gzip"
7
        "context"
8
        "crypto/rand"
9
        "crypto/sha1" //nolint
10
        "database/sql"
11
        "embed"
12
        "encoding/json"
13
        "errors"
14
        "fmt"
15
        "html/template"
16
        "io"
17
        "io/fs"
18
        "math/big"
19
        "net/http"
20
        "path"
21
        "strconv"
22
        "strings"
23
        "sync"
24
        "time"
25

26
        "github.com/didip/tollbooth/v8"
27
        log "github.com/go-pkgz/lgr"
28
        "github.com/go-pkgz/rest"
29
        "github.com/go-pkgz/rest/logger"
30
        "github.com/go-pkgz/routegroup"
31
        "golang.org/x/crypto/bcrypt"
32

33
        "github.com/umputun/tg-spam/app/config"
34
        "github.com/umputun/tg-spam/app/events"
35
        "github.com/umputun/tg-spam/app/storage"
36
        "github.com/umputun/tg-spam/app/storage/engine"
37
        "github.com/umputun/tg-spam/lib/approved"
38
        "github.com/umputun/tg-spam/lib/spamcheck"
39
)
40

41
//go:generate moq --out mocks/detector.go --pkg mocks --with-resets --skip-ensure . Detector
42
//go:generate moq --out mocks/spam_filter.go --pkg mocks --with-resets --skip-ensure . SpamFilter
43
//go:generate moq --out mocks/locator.go --pkg mocks --with-resets --skip-ensure . Locator
44
//go:generate moq --out mocks/detected_spam.go --pkg mocks --with-resets --skip-ensure . DetectedSpam
45
//go:generate moq --out mocks/storage_engine.go --pkg mocks --with-resets --skip-ensure . StorageEngine
46
//go:generate moq --out mocks/dictionary.go --pkg mocks --with-resets --skip-ensure . Dictionary
47
//go:generate moq --out mocks/dm_users_provider.go --pkg mocks --with-resets --skip-ensure . DMUsersProvider
48

49
//go:embed assets/* assets/components/*
50
var templateFS embed.FS
51
var tmpl = template.Must(template.ParseFS(templateFS, "assets/*.html", "assets/components/*.html"))
52

53
// startTime tracks when the server started
54
var startTime = time.Now()
55

56
// Server is a web API server.
57
type Server struct {
58
        Config
59
        // appSettingsMu guards AppSettings against data races between the config
60
        // handlers (load/update/save) and read paths (settings pages, /settings API).
61
        appSettingsMu sync.RWMutex
62
}
63

64
// Config defines server parameters
65
type Config struct {
66
        Version         string           // version to show in /ping
67
        ListenAddr      string           // listen address
68
        Detector        Detector         // spam detector
69
        SpamFilter      SpamFilter       // spam filter (bot)
70
        DetectedSpam    DetectedSpam     // detected spam accessor
71
        Locator         Locator          // locator for user info
72
        Dictionary      Dictionary       // dictionary for stop phrases and ignored words
73
        StorageEngine   StorageEngine    // database engine access for backups
74
        DMUsersProvider DMUsersProvider  // provider for recent DM users
75
        SettingsStore   SettingsStore    // configuration storage interface
76
        AuthUser        string           // basic auth user; empty falls back to AppSettings.Server.AuthUser, then "tg-spam"
77
        AuthHash        string           // basic auth bcrypt hash
78
        Dbg             bool             // debug mode
79
        BotUsername     string           // resolved telegram bot username
80
        AppSettings     *config.Settings // application settings (domain model)
81
        ConfigDBMode    bool             // indicates if app is running with database config
82
        // ReloadNormalize, when non-nil, is invoked by loadConfigHandler on the
83
        // freshly loaded *config.Settings before transient/auth preservation. It
84
        // must perform the same defaults-fill and operational CLI override
85
        // reapplication that startup performs (ApplyDefaults + path/listen/dry
86
        // CLI overrides) so a partial/legacy DB blob and operator-supplied
87
        // --files.dynamic / --files.samples / --server.listen / --dry survive
88
        // POST /config/reload. Credentials (Telegram/OpenAI/Gemini tokens) are
89
        // intentionally NOT reapplied here — DB rotation wins on reload.
90
        ReloadNormalize func(*config.Settings)
91
}
92

93
// Detector is a spam detector interface.
94
type Detector interface {
95
        Check(req spamcheck.Request) (spam bool, cr []spamcheck.Response)
96
        ApprovedUsers() []approved.UserInfo
97
        AddApprovedUser(user approved.UserInfo) error
98
        RemoveApprovedUser(id string) error
99
        GetLuaPluginNames() []string // Returns the list of available Lua plugin names
100
}
101

102
// SpamFilter is a spam filter, bot interface.
103
type SpamFilter interface {
104
        UpdateSpam(msg string) error
105
        UpdateHam(msg string) error
106
        ReloadSamples() (err error)
107
        DynamicSamples() (spam, ham []string, err error)
108
        RemoveDynamicSpamSample(sample string) error
109
        RemoveDynamicHamSample(sample string) error
110
}
111

112
// Locator is a storage interface used to get user id by name and vice versa.
113
type Locator interface {
114
        UserIDByName(ctx context.Context, userName string) int64
115
        UserNameByID(ctx context.Context, userID int64) string
116
}
117

118
// DetectedSpam is a storage interface used to get detected spam messages and set added flag.
119
type DetectedSpam interface {
120
        Read(ctx context.Context) ([]storage.DetectedSpamInfo, error)
121
        SetAddedToSamplesFlag(ctx context.Context, id int64) error
122
        FindByUserID(ctx context.Context, userID int64) (*storage.DetectedSpamInfo, error)
123
}
124

125
// StorageEngine provides access to the database engine for operations like backup
126
type StorageEngine interface {
127
        Backup(ctx context.Context, w io.Writer) error
128
        Type() engine.Type
129
        BackupSqliteAsPostgres(ctx context.Context, w io.Writer) error
130
}
131

132
// Dictionary is a storage interface for managing stop phrases and ignored words
133
type Dictionary interface {
134
        Add(ctx context.Context, t storage.DictionaryType, data string) error
135
        Delete(ctx context.Context, id int64) error
136
        Read(ctx context.Context, t storage.DictionaryType) ([]string, error)
137
        ReadWithIDs(ctx context.Context, t storage.DictionaryType) ([]storage.DictionaryEntry, error)
138
        Stats(ctx context.Context) (*storage.DictionaryStats, error)
139
}
140

141
// DMUsersProvider provides access to recent DM users for the admin UI
142
type DMUsersProvider interface {
143
        GetDMUsers() []events.DMUser
144
}
145

146
// NewServer creates a new web API server.
147
func NewServer(cfg Config) *Server {
84✔
148
        return &Server{Config: cfg}
84✔
149
}
84✔
150

151
// Run starts server and accepts requests checking for spam messages.
152
func (s *Server) Run(ctx context.Context) error {
4✔
153
        router := routegroup.New(http.NewServeMux())
4✔
154
        router.Use(rest.Recoverer(log.Default()))
4✔
155
        router.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]")).Handler)
4✔
156
        router.Use(rest.Throttle(1000))
4✔
157
        router.Use(rest.AppInfo("tg-spam", "umputun", s.Version), rest.Ping)
4✔
158
        router.Use(tollbooth.HTTPMiddleware(tollbooth.NewLimiter(50, nil)))
4✔
159
        router.Use(rest.SizeLimit(1024 * 1024)) // 1M max request size
4✔
160
        router.Use(http.NewCrossOriginProtection().Handler)
4✔
161

4✔
162
        // hash-based authentication for maximum security. The middleware reads the
4✔
163
        // current hash from AppSettings under the same mutex used by the config
4✔
164
        // handlers so DB-sourced rotations via POST /config/reload take effect
4✔
165
        // immediately without restarting the server. Startup AuthHash is used as
4✔
166
        // a safety fallback if AppSettings hash is empty so a DB that lost the
4✔
167
        // hash can't silently unlock the API.
4✔
168
        if s.AuthHash != "" {
5✔
169
                log.Printf("[INFO] basic auth enabled for webapi server (user: %s)", s.activeAuthUser())
1✔
170
                router.Use(s.basicAuthMiddleware)
1✔
171
        } else {
4✔
172
                log.Printf("[WARN] basic auth disabled, access to webapi is not protected")
3✔
173
        }
3✔
174

175
        router = s.routes(router) // setup routes
4✔
176

4✔
177
        srv := &http.Server{Addr: s.ListenAddr, Handler: router, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second}
4✔
178
        go func() {
8✔
179
                <-ctx.Done()
4✔
180
                if err := srv.Shutdown(ctx); err != nil {
4✔
UNCOV
181
                        log.Printf("[WARN] failed to shutdown webapi server: %v", err)
×
182
                } else {
4✔
183
                        log.Printf("[INFO] webapi server stopped")
4✔
184
                }
4✔
185
        }()
186

187
        log.Printf("[INFO] start webapi server on %s", s.ListenAddr)
4✔
188
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
4✔
189
                return fmt.Errorf("failed to run server: %w", err)
×
190
        }
×
191
        return nil
4✔
192
}
193

194
// basicAuthMiddleware validates basic auth credentials against the current
195
// hash in AppSettings, falling back to the startup hash when AppSettings has
196
// no hash. Reading under RLock keeps it consistent with POST /config/reload
197
// swaps, so DB hash rotations take effect without restarting. Mirrors
198
// rest.BasicAuthWithBcryptHashAndPrompt's WWW-Authenticate prompt behavior.
199
func (s *Server) basicAuthMiddleware(h http.Handler) http.Handler {
5✔
200
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
8✔
201
                u, p, ok := r.BasicAuth()
3✔
202
                if ok && s.checkBasicAuth(u, p) {
4✔
203
                        h.ServeHTTP(w, r)
1✔
204
                        return
1✔
205
                }
1✔
206
                w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
2✔
207
                http.Error(w, "Unauthorized", http.StatusUnauthorized)
2✔
208
        })
209
}
210

211
// checkBasicAuth returns true when user matches the active auth user and
212
// passwd matches the currently active bcrypt hash. The active user is
213
// AppSettings.Server.AuthUser when non-empty, else the startup AuthUser, else
214
// the historical default "tg-spam". The active hash follows the same precedence
215
// over AuthHash; startup serves as a safety fallback so reloads that drop the
216
// DB hash can't unlock the server.
217
func (s *Server) checkBasicAuth(user, passwd string) bool {
16✔
218
        if user != s.activeAuthUser() {
20✔
219
                return false
4✔
220
        }
4✔
221
        hash := s.AuthHash
12✔
222
        s.appSettingsMu.RLock()
12✔
223
        if s.AppSettings != nil && s.AppSettings.Server.AuthHash != "" {
14✔
224
                hash = s.AppSettings.Server.AuthHash
2✔
225
        }
2✔
226
        s.appSettingsMu.RUnlock()
12✔
227
        if hash == "" {
14✔
228
                return false
2✔
229
        }
2✔
230
        return bcrypt.CompareHashAndPassword([]byte(hash), []byte(passwd)) == nil
10✔
231
}
232

233
// activeAuthUser returns the configured basic auth username with the same
234
// precedence as the hash: settings -> startup -> "tg-spam" default.
235
func (s *Server) activeAuthUser() string {
17✔
236
        s.appSettingsMu.RLock()
17✔
237
        if s.AppSettings != nil && s.AppSettings.Server.AuthUser != "" {
21✔
238
                u := s.AppSettings.Server.AuthUser
4✔
239
                s.appSettingsMu.RUnlock()
4✔
240
                return u
4✔
241
        }
4✔
242
        s.appSettingsMu.RUnlock()
13✔
243
        if s.AuthUser != "" {
15✔
244
                return s.AuthUser
2✔
245
        }
2✔
246
        return "tg-spam"
11✔
247
}
248

249
func (s *Server) routes(router *routegroup.Bundle) *routegroup.Bundle {
6✔
250
        // auth api routes; auth is applied globally by router.Use in Run, so no per-subrouter auth here
6✔
251
        router.Route(func(authApi *routegroup.Bundle) {
12✔
252
                authApi.HandleFunc("POST /check", s.checkMsgHandler)         // check a message for spam
6✔
253
                authApi.HandleFunc("GET /check/{user_id}", s.checkIDHandler) // check user id for spam
6✔
254

6✔
255
                authApi.Mount("/update").Route(func(r *routegroup.Bundle) {
12✔
256
                        // update spam/ham samples
6✔
257
                        r.HandleFunc("POST /spam", s.updateSampleHandler(s.SpamFilter.UpdateSpam)) // update spam samples
6✔
258
                        r.HandleFunc("POST /ham", s.updateSampleHandler(s.SpamFilter.UpdateHam))   // update ham samples
6✔
259
                })
6✔
260

261
                authApi.Mount("/delete").Route(func(r *routegroup.Bundle) {
12✔
262
                        // delete spam/ham samples
6✔
263
                        r.HandleFunc("POST /spam", s.deleteSampleHandler(s.SpamFilter.RemoveDynamicSpamSample))
6✔
264
                        r.HandleFunc("POST /ham", s.deleteSampleHandler(s.SpamFilter.RemoveDynamicHamSample))
6✔
265
                })
6✔
266

267
                authApi.Mount("/download").Route(func(r *routegroup.Bundle) {
12✔
268
                        r.HandleFunc("GET /spam", s.downloadSampleHandler(func(spam, _ []string) ([]string, string) {
6✔
269
                                return spam, "spam.txt"
×
270
                        }))
×
271
                        r.HandleFunc("GET /ham", s.downloadSampleHandler(func(_, ham []string) ([]string, string) {
6✔
272
                                return ham, "ham.txt"
×
273
                        }))
×
274
                        r.HandleFunc("GET /detected_spam", s.downloadDetectedSpamHandler)
6✔
275
                        r.HandleFunc("GET /backup", s.downloadBackupHandler)
6✔
276
                        r.HandleFunc("GET /export-to-postgres", s.downloadExportToPostgresHandler)
6✔
277
                })
278

279
                authApi.HandleFunc("GET /samples", s.getDynamicSamplesHandler)    // get dynamic samples
6✔
280
                authApi.HandleFunc("PUT /samples", s.reloadDynamicSamplesHandler) // reload samples
6✔
281

6✔
282
                authApi.Mount("/users").Route(func(r *routegroup.Bundle) { // manage approved users
12✔
283
                        // add user to the approved list and storage
6✔
284
                        r.HandleFunc("POST /add", s.updateApprovedUsersHandler(s.Detector.AddApprovedUser))
6✔
285
                        // remove user from an approved list and storage
6✔
286
                        r.HandleFunc("POST /delete", s.updateApprovedUsersHandler(s.removeApprovedUser))
6✔
287
                        // get approved users
6✔
288
                        r.HandleFunc("GET /", s.getApprovedUsersHandler)
6✔
289
                })
6✔
290

291
                authApi.HandleFunc("GET /settings", s.getSettingsHandler) // get application settings
6✔
292

6✔
293
                authApi.Mount("/dictionary").Route(func(r *routegroup.Bundle) { // manage dictionary
12✔
294
                        // add stop phrase or ignored word
6✔
295
                        r.HandleFunc("POST /add", s.addDictionaryEntryHandler)
6✔
296
                        // delete entry by id
6✔
297
                        r.HandleFunc("POST /delete", s.deleteDictionaryEntryHandler)
6✔
298
                        // get all entries
6✔
299
                        r.HandleFunc("GET /", s.getDictionaryEntriesHandler)
6✔
300
                })
6✔
301
        })
302

303
        router.Route(func(webUI *routegroup.Bundle) {
12✔
304
                webUI.HandleFunc("GET /", s.htmlSpamCheckHandler)                         // serve template for webUI UI
6✔
305
                webUI.HandleFunc("GET /manage_samples", s.htmlManageSamplesHandler)       // serve manage samples page
6✔
306
                webUI.HandleFunc("GET /manage_users", s.htmlManageUsersHandler)           // serve manage users page
6✔
307
                webUI.HandleFunc("GET /manage_dictionary", s.htmlManageDictionaryHandler) // serve manage dictionary page
6✔
308
                webUI.HandleFunc("GET /detected_spam", s.htmlDetectedSpamHandler)         // serve detected spam page
6✔
309
                webUI.HandleFunc("GET /list_settings", s.htmlSettingsHandler)             // serve settings
6✔
310
                webUI.HandleFunc("POST /detected_spam/add", s.htmlAddDetectedSpamHandler) // add detected spam to samples
6✔
311
                webUI.HandleFunc("GET /dm-users", s.getDMUsersHandler)                    // get recent DM users (HTMX/JSON)
6✔
312

6✔
313
                // configuration management endpoints
6✔
314
                if s.SettingsStore != nil && s.ConfigDBMode {
6✔
315
                        webUI.Route(func(cfgRouter *routegroup.Bundle) {
×
316
                                cfgRouter.HandleFunc("POST /config", s.saveConfigHandler) // save current configuration to database
×
317
                                // reload uses POST because it mutates state; GET would bypass cross-origin protection (safe methods are always allowed)
×
318
                                cfgRouter.HandleFunc("POST /config/reload", s.loadConfigHandler)
×
319
                                cfgRouter.HandleFunc("PUT /config", s.updateConfigHandler)    // update configuration
×
320
                                cfgRouter.HandleFunc("DELETE /config", s.deleteConfigHandler) // delete configuration
×
321
                        })
×
322
                }
323

324
                // handle logout - force Basic Auth re-authentication
325
                webUI.HandleFunc("GET /logout", func(w http.ResponseWriter, _ *http.Request) {
6✔
326
                        w.Header().Set("WWW-Authenticate", `Basic realm="tg-spam"`)
×
327
                        w.WriteHeader(http.StatusUnauthorized)
×
328
                        fmt.Fprintln(w, "Logged out successfully")
×
329
                })
×
330

331
                // serve only specific static files at root level
332
                staticFiles := newStaticFS(templateFS,
6✔
333
                        staticFileMapping{urlPath: "styles.css", filesysPath: "assets/styles.css"},
6✔
334
                        staticFileMapping{urlPath: "logo.png", filesysPath: "assets/logo.png"},
6✔
335
                        staticFileMapping{urlPath: "spinner.svg", filesysPath: "assets/spinner.svg"},
6✔
336
                )
6✔
337
                webUI.HandleFiles("/", http.FS(staticFiles))
6✔
338
        })
339

340
        return router
6✔
341
}
342

343
// checkMsgHandler handles POST /check request.
344
// it gets message text and user id from request body and returns spam status and check results.
345
func (s *Server) checkMsgHandler(w http.ResponseWriter, r *http.Request) {
11✔
346
        type CheckResultDisplay struct {
11✔
347
                Spam   bool
11✔
348
                Checks []spamcheck.Response
11✔
349
        }
11✔
350

11✔
351
        isHtmxRequest := r.Header.Get("HX-Request") == "true"
11✔
352

11✔
353
        req := spamcheck.Request{CheckOnly: true}
11✔
354
        if !isHtmxRequest {
21✔
355
                // API request
10✔
356
                if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
12✔
357
                        _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't decode request", "details": err.Error()})
2✔
358
                        log.Printf("[WARN] can't decode request: %v", err)
2✔
359
                        return
2✔
360
                }
2✔
361
        } else {
1✔
362
                // for hx-request (HTMX) we need to get the values from the form
1✔
363
                req.UserID = r.FormValue("user_id")
1✔
364
                req.UserName = r.FormValue("user_name")
1✔
365
                req.Msg = r.FormValue("msg")
1✔
366
        }
1✔
367

368
        spam, cr := s.Detector.Check(req)
9✔
369
        if !isHtmxRequest {
17✔
370
                // for API request return JSON
8✔
371
                rest.RenderJSON(w, rest.JSON{"spam": spam, "checks": cr})
8✔
372
                return
8✔
373
        }
8✔
374

375
        if req.Msg == "" {
1✔
376
                w.Header().Set("HX-Retarget", "#error-message")
×
377
                fmt.Fprintln(w, "<div class='alert alert-danger'>Valid message required.</div>")
×
378
                return
×
379
        }
×
380

381
        // render result for HTMX request
382
        resultDisplay := CheckResultDisplay{
1✔
383
                Spam:   spam,
1✔
384
                Checks: cr,
1✔
385
        }
1✔
386

1✔
387
        if err := tmpl.ExecuteTemplate(w, "check_results", resultDisplay); err != nil {
1✔
388
                log.Printf("[WARN] can't execute result template: %v", err)
×
389
                http.Error(w, "Error rendering result", http.StatusInternalServerError)
×
390
                return
×
391
        }
×
392
}
393

394
// checkIDHandler handles GET /check/{user_id} request.
395
// it returns JSON with the status "spam" or "ham" for a given user id.
396
// if user is spammer, it also returns check results.
397
func (s *Server) checkIDHandler(w http.ResponseWriter, r *http.Request) {
4✔
398
        type info struct {
4✔
399
                UserName  string               `json:"user_name,omitempty"`
4✔
400
                Message   string               `json:"message,omitempty"`
4✔
401
                Timestamp time.Time            `json:"timestamp,omitzero"`
4✔
402
                Checks    []spamcheck.Response `json:"checks,omitempty"`
4✔
403
        }
4✔
404
        resp := struct {
4✔
405
                Status string `json:"status"`
4✔
406
                Info   *info  `json:"info,omitempty"`
4✔
407
        }{
4✔
408
                Status: "ham",
4✔
409
        }
4✔
410

4✔
411
        userID, err := strconv.ParseInt(r.PathValue("user_id"), 10, 64)
4✔
412
        if err != nil {
5✔
413
                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't parse user id", "details": err.Error()})
1✔
414
                return
1✔
415
        }
1✔
416

417
        si, err := s.DetectedSpam.FindByUserID(r.Context(), userID)
3✔
418
        if err != nil {
4✔
419
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't get user info", "details": err.Error()})
1✔
420
                return
1✔
421
        }
1✔
422
        if si != nil {
3✔
423
                resp.Status = "spam"
1✔
424
                resp.Info = &info{
1✔
425
                        UserName:  si.UserName,
1✔
426
                        Message:   si.Text,
1✔
427
                        Timestamp: si.Timestamp,
1✔
428
                        Checks:    si.Checks,
1✔
429
                }
1✔
430
        }
1✔
431
        rest.RenderJSON(w, resp)
2✔
432
}
433

434
// getDynamicSamplesHandler handles GET /samples request. It returns dynamic samples both for spam and ham.
435
func (s *Server) getDynamicSamplesHandler(w http.ResponseWriter, _ *http.Request) {
2✔
436
        spam, ham, err := s.SpamFilter.DynamicSamples()
2✔
437
        if err != nil {
3✔
438
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't get dynamic samples", "details": err.Error()})
1✔
439
                return
1✔
440
        }
1✔
441
        rest.RenderJSON(w, rest.JSON{"spam": spam, "ham": ham})
1✔
442
}
443

444
// downloadSampleHandler handles GET /download/spam|ham request.
445
// It returns dynamic samples both for spam and ham.
446
func (s *Server) downloadSampleHandler(pickFn func(spam, ham []string) ([]string, string)) http.HandlerFunc {
15✔
447
        return func(w http.ResponseWriter, _ *http.Request) {
18✔
448
                spam, ham, err := s.SpamFilter.DynamicSamples()
3✔
449
                if err != nil {
4✔
450
                        _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't get dynamic samples", "details": err.Error()})
1✔
451
                        return
1✔
452
                }
1✔
453
                samples, name := pickFn(spam, ham)
2✔
454
                body := strings.Join(samples, "\n")
2✔
455
                w.Header().Set("Content-Type", "text/plain; charset=utf-8")
2✔
456
                w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
2✔
457
                w.Header().Set("Content-Length", strconv.Itoa(len(body)))
2✔
458
                w.WriteHeader(http.StatusOK)
2✔
459
                _, _ = w.Write([]byte(body))
2✔
460
        }
461
}
462

463
// updateSampleHandler handles POST /update/spam|ham request. It updates dynamic samples both for spam and ham.
464
func (s *Server) updateSampleHandler(updFn func(msg string) error) func(w http.ResponseWriter, r *http.Request) {
17✔
465
        return func(w http.ResponseWriter, r *http.Request) {
24✔
466
                var req struct {
7✔
467
                        Msg string `json:"msg"`
7✔
468
                }
7✔
469

7✔
470
                isHtmxRequest := r.Header.Get("HX-Request") == "true"
7✔
471

7✔
472
                if isHtmxRequest {
7✔
473
                        req.Msg = r.FormValue("msg")
×
474
                } else {
7✔
475
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
9✔
476
                                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't decode request", "details": err.Error()})
2✔
477
                                return
2✔
478
                        }
2✔
479
                }
480

481
                err := updFn(req.Msg)
5✔
482
                if err != nil {
7✔
483
                        _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't update samples", "details": err.Error()})
2✔
484
                        return
2✔
485
                }
2✔
486

487
                if isHtmxRequest {
3✔
488
                        s.renderSamples(w, "samples_list")
×
489
                } else {
3✔
490
                        rest.RenderJSON(w, rest.JSON{"updated": true, "msg": req.Msg})
3✔
491
                }
3✔
492
        }
493
}
494

495
// deleteSampleHandler handles DELETE /samples request. It deletes dynamic samples both for spam and ham.
496
func (s *Server) deleteSampleHandler(delFn func(msg string) error) func(w http.ResponseWriter, r *http.Request) {
15✔
497
        return func(w http.ResponseWriter, r *http.Request) {
20✔
498
                var req struct {
5✔
499
                        Msg string `json:"msg"`
5✔
500
                }
5✔
501
                isHtmxRequest := r.Header.Get("HX-Request") == "true"
5✔
502
                if isHtmxRequest {
6✔
503
                        req.Msg = r.FormValue("msg")
1✔
504
                } else {
5✔
505
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
4✔
506
                                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't decode request", "details": err.Error()})
×
507
                                return
×
508
                        }
×
509
                }
510

511
                if err := delFn(req.Msg); err != nil {
6✔
512
                        _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't delete sample", "details": err.Error()})
1✔
513
                        return
1✔
514
                }
1✔
515

516
                if isHtmxRequest {
5✔
517
                        s.renderSamples(w, "samples_list")
1✔
518
                } else {
4✔
519
                        rest.RenderJSON(w, rest.JSON{"deleted": true, "msg": req.Msg, "count": 1})
3✔
520
                }
3✔
521
        }
522
}
523

524
// reloadDynamicSamplesHandler handles PUT /samples request. It reloads dynamic samples from db storage.
525
func (s *Server) reloadDynamicSamplesHandler(w http.ResponseWriter, _ *http.Request) {
2✔
526
        if err := s.SpamFilter.ReloadSamples(); err != nil {
3✔
527
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't reload samples", "details": err.Error()})
1✔
528
                return
1✔
529
        }
1✔
530
        rest.RenderJSON(w, rest.JSON{"reloaded": true})
1✔
531
}
532

533
// updateApprovedUsersHandler handles POST /users/add and /users/delete requests, it adds or removes users from approved list.
534
func (s *Server) updateApprovedUsersHandler(updFn func(ui approved.UserInfo) error) func(w http.ResponseWriter, r *http.Request) {
17✔
535
        return func(w http.ResponseWriter, r *http.Request) {
27✔
536
                req := approved.UserInfo{}
10✔
537
                isHtmxRequest := r.Header.Get("HX-Request") == "true"
10✔
538
                if isHtmxRequest {
11✔
539
                        req.UserID = r.FormValue("user_id")
1✔
540
                        req.UserName = r.FormValue("user_name")
1✔
541
                } else {
10✔
542
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
10✔
543
                                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
544
                                return
1✔
545
                        }
1✔
546
                }
547

548
                // try to get userID from request and fallback to userName lookup if it's empty
549
                if req.UserID == "" {
14✔
550
                        req.UserID = strconv.FormatInt(s.Locator.UserIDByName(r.Context(), req.UserName), 10)
5✔
551
                }
5✔
552

553
                if req.UserID == "" || req.UserID == "0" {
11✔
554
                        if isHtmxRequest {
2✔
555
                                w.Header().Set("HX-Retarget", "#error-message")
×
556
                                fmt.Fprintln(w, "<div class='alert alert-danger'>Either userid or valid username required.</div>")
×
557
                                return
×
558
                        }
×
559
                        _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "user ID is required"})
2✔
560
                        return
2✔
561
                }
562

563
                // add or remove user from the approved list of detector
564
                if err := updFn(req); err != nil {
7✔
565
                        _ = rest.EncodeJSON(w, http.StatusInternalServerError,
×
566
                                rest.JSON{"error": "can't update approved users", "details": err.Error()})
×
567
                        return
×
568
                }
×
569

570
                if isHtmxRequest {
8✔
571
                        users := s.Detector.ApprovedUsers()
1✔
572
                        tmplData := struct {
1✔
573
                                ApprovedUsers      []approved.UserInfo
1✔
574
                                TotalApprovedUsers int
1✔
575
                        }{
1✔
576
                                ApprovedUsers:      users,
1✔
577
                                TotalApprovedUsers: len(users),
1✔
578
                        }
1✔
579

1✔
580
                        if err := tmpl.ExecuteTemplate(w, "users_list", tmplData); err != nil {
1✔
581
                                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
582
                                return
×
583
                        }
×
584

585
                } else {
6✔
586
                        rest.RenderJSON(w, rest.JSON{"updated": true, "user_id": req.UserID, "user_name": req.UserName})
6✔
587
                }
6✔
588
        }
589
}
590

591
// removeApprovedUser is adopter for updateApprovedUsersHandler updFn
592
func (s *Server) removeApprovedUser(req approved.UserInfo) error {
2✔
593
        if err := s.Detector.RemoveApprovedUser(req.UserID); err != nil {
2✔
594
                return fmt.Errorf("failed to remove approved user %s: %w", req.UserID, err)
×
595
        }
×
596
        return nil
2✔
597
}
598

599
// getApprovedUsersHandler handles GET /users request. It returns list of approved users.
600
func (s *Server) getApprovedUsersHandler(w http.ResponseWriter, _ *http.Request) {
1✔
601
        rest.RenderJSON(w, rest.JSON{"user_ids": s.Detector.ApprovedUsers()})
1✔
602
}
1✔
603

604
// getSettingsHandler returns application settings, including the list of available Lua plugins.
605
// Sensitive credential fields (tokens, auth hash) are redacted in the response; the list of
606
// available Lua plugins is exposed separately from the user-selected enabled plugins.
607
func (s *Server) getSettingsHandler(w http.ResponseWriter, _ *http.Request) {
5✔
608
        // shallow copy so we can redact sensitive fields without mutating the live settings
5✔
609
        s.appSettingsMu.RLock()
5✔
610
        var safe config.Settings
5✔
611
        if s.AppSettings != nil {
9✔
612
                safe = *s.AppSettings
4✔
613
        }
4✔
614
        s.appSettingsMu.RUnlock()
5✔
615
        safe.Telegram.Token = ""
5✔
616
        safe.OpenAI.Token = ""
5✔
617
        safe.Gemini.Token = ""
5✔
618
        safe.Server.AuthHash = ""
5✔
619

5✔
620
        resp := struct {
5✔
621
                *config.Settings
5✔
622
                LuaAvailablePlugins []string `json:"lua_available_plugins"`
5✔
623
        }{
5✔
624
                Settings:            &safe,
5✔
625
                LuaAvailablePlugins: s.Detector.GetLuaPluginNames(),
5✔
626
        }
5✔
627
        rest.RenderJSON(w, resp)
5✔
628
}
629

630
// getDictionaryEntriesHandler handles GET /dictionary request. It returns stop phrases and ignored words.
631
func (s *Server) getDictionaryEntriesHandler(w http.ResponseWriter, r *http.Request) {
3✔
632
        stopPhrases, err := s.Dictionary.Read(r.Context(), storage.DictionaryTypeStopPhrase)
3✔
633
        if err != nil {
4✔
634
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't get stop phrases", "details": err.Error()})
1✔
635
                return
1✔
636
        }
1✔
637

638
        ignoredWords, err := s.Dictionary.Read(r.Context(), storage.DictionaryTypeIgnoredWord)
2✔
639
        if err != nil {
3✔
640
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't get ignored words", "details": err.Error()})
1✔
641
                return
1✔
642
        }
1✔
643

644
        rest.RenderJSON(w, rest.JSON{"stop_phrases": stopPhrases, "ignored_words": ignoredWords})
1✔
645
}
646

647
// addDictionaryEntryHandler handles POST /dictionary/add request. It adds a stop phrase or ignored word.
648
func (s *Server) addDictionaryEntryHandler(w http.ResponseWriter, r *http.Request) {
11✔
649
        var req struct {
11✔
650
                Type string `json:"type"`
11✔
651
                Data string `json:"data"`
11✔
652
        }
11✔
653

11✔
654
        isHtmxRequest := r.Header.Get("HX-Request") == "true"
11✔
655

11✔
656
        if isHtmxRequest {
15✔
657
                req.Type = r.FormValue("type")
4✔
658
                req.Data = r.FormValue("data")
4✔
659
        } else {
11✔
660
                if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
8✔
661
                        _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
662
                        return
1✔
663
                }
1✔
664
        }
665

666
        if req.Data == "" {
13✔
667
                if isHtmxRequest {
4✔
668
                        w.Header().Set("HX-Retarget", "#error-message")
1✔
669
                        fmt.Fprintln(w, "<div class='alert alert-danger'>Data cannot be empty.</div>")
1✔
670
                        return
1✔
671
                }
1✔
672
                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "data cannot be empty"})
2✔
673
                return
2✔
674
        }
675

676
        dictType := storage.DictionaryType(req.Type)
7✔
677
        if err := dictType.Validate(); err != nil {
9✔
678
                if isHtmxRequest {
3✔
679
                        w.Header().Set("HX-Retarget", "#error-message")
1✔
680
                        fmt.Fprintf(w, "<div class='alert alert-danger'>Invalid type: %v</div>", err)
1✔
681
                        return
1✔
682
                }
1✔
683
                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "invalid type", "details": err.Error()})
1✔
684
                return
1✔
685
        }
686

687
        if err := s.Dictionary.Add(r.Context(), dictType, req.Data); err != nil {
6✔
688
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't add entry", "details": err.Error()})
1✔
689
                return
1✔
690
        }
1✔
691

692
        // reload samples to apply dictionary changes immediately
693
        if err := s.SpamFilter.ReloadSamples(); err != nil {
6✔
694
                log.Printf("[WARN] failed to reload samples after dictionary add: %v", err)
2✔
695
                if !isHtmxRequest {
3✔
696
                        _ = rest.EncodeJSON(w, http.StatusInternalServerError,
1✔
697
                                rest.JSON{"error": "entry added but reload failed", "details": err.Error()})
1✔
698
                        return
1✔
699
                }
1✔
700
                // for HTMX, log but continue rendering (entry was added successfully)
701
        }
702

703
        if isHtmxRequest {
5✔
704
                s.renderDictionary(r.Context(), w, "dictionary_list")
2✔
705
        } else {
3✔
706
                rest.RenderJSON(w, rest.JSON{"added": true, "type": req.Type, "data": req.Data})
1✔
707
        }
1✔
708
}
709

710
// deleteDictionaryEntryHandler handles POST /dictionary/delete request. It deletes an entry by data.
711
func (s *Server) deleteDictionaryEntryHandler(w http.ResponseWriter, r *http.Request) {
7✔
712
        var req struct {
7✔
713
                ID int64 `json:"id"`
7✔
714
        }
7✔
715

7✔
716
        isHtmxRequest := r.Header.Get("HX-Request") == "true"
7✔
717

7✔
718
        if isHtmxRequest {
10✔
719
                idStr := r.FormValue("id")
3✔
720
                var err error
3✔
721
                req.ID, err = strconv.ParseInt(idStr, 10, 64)
3✔
722
                if err != nil {
4✔
723
                        w.Header().Set("HX-Retarget", "#error-message")
1✔
724
                        fmt.Fprintf(w, "<div class='alert alert-danger'>Invalid ID: %v</div>", err)
1✔
725
                        return
1✔
726
                }
1✔
727
        } else {
4✔
728
                if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
5✔
729
                        _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
730
                        return
1✔
731
                }
1✔
732
        }
733

734
        if err := s.Dictionary.Delete(r.Context(), req.ID); err != nil {
6✔
735
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't delete entry", "details": err.Error()})
1✔
736
                return
1✔
737
        }
1✔
738

739
        // reload samples to apply dictionary changes immediately
740
        if err := s.SpamFilter.ReloadSamples(); err != nil {
6✔
741
                log.Printf("[WARN] failed to reload samples after dictionary delete: %v", err)
2✔
742
                if !isHtmxRequest {
3✔
743
                        _ = rest.EncodeJSON(w, http.StatusInternalServerError,
1✔
744
                                rest.JSON{"error": "entry deleted but reload failed", "details": err.Error()})
1✔
745
                        return
1✔
746
                }
1✔
747
                // for HTMX, log but continue rendering (entry was deleted successfully)
748
        }
749

750
        if isHtmxRequest {
5✔
751
                s.renderDictionary(r.Context(), w, "dictionary_list")
2✔
752
        } else {
3✔
753
                rest.RenderJSON(w, rest.JSON{"deleted": true, "id": req.ID})
1✔
754
        }
1✔
755
}
756

757
// htmlSpamCheckHandler handles GET / request.
758
// It returns rendered spam_check.html template with all the components.
759
func (s *Server) htmlSpamCheckHandler(w http.ResponseWriter, _ *http.Request) {
3✔
760
        tmplData := struct {
3✔
761
                Version string
3✔
762
        }{
3✔
763
                Version: s.Version,
3✔
764
        }
3✔
765

3✔
766
        if err := tmpl.ExecuteTemplate(w, "spam_check.html", tmplData); err != nil {
4✔
767
                log.Printf("[WARN] can't execute template: %v", err)
1✔
768
                http.Error(w, "Error executing template", http.StatusInternalServerError)
1✔
769
                return
1✔
770
        }
1✔
771
}
772

773
// htmlManageSamplesHandler handles GET /manage_samples request.
774
// It returns rendered manage_samples.html template with all the components.
775
func (s *Server) htmlManageSamplesHandler(w http.ResponseWriter, _ *http.Request) {
1✔
776
        s.renderSamples(w, "manage_samples.html")
1✔
777
}
1✔
778

779
func (s *Server) htmlManageUsersHandler(w http.ResponseWriter, _ *http.Request) {
3✔
780
        users := s.Detector.ApprovedUsers()
3✔
781
        tmplData := struct {
3✔
782
                ApprovedUsers      []approved.UserInfo
3✔
783
                TotalApprovedUsers int
3✔
784
        }{
3✔
785
                ApprovedUsers:      users,
3✔
786
                TotalApprovedUsers: len(users),
3✔
787
        }
3✔
788
        tmplData.TotalApprovedUsers = len(tmplData.ApprovedUsers)
3✔
789

3✔
790
        if err := tmpl.ExecuteTemplate(w, "manage_users.html", tmplData); err != nil {
4✔
791
                log.Printf("[WARN] can't execute template: %v", err)
1✔
792
                http.Error(w, "Error executing template", http.StatusInternalServerError)
1✔
793
                return
1✔
794
        }
1✔
795
}
796

797
func (s *Server) htmlManageDictionaryHandler(w http.ResponseWriter, r *http.Request) {
×
798
        s.renderDictionary(r.Context(), w, "manage_dictionary.html")
×
799
}
×
800

801
func (s *Server) htmlDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
2✔
802
        ds, err := s.DetectedSpam.Read(r.Context())
2✔
803
        if err != nil {
3✔
804
                log.Printf("[ERROR] Failed to fetch detected spam: %v", err)
1✔
805
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
1✔
806
                return
1✔
807
        }
1✔
808

809
        // clean up detected spam entries
810
        for i, d := range ds {
3✔
811
                d.Text = strings.ReplaceAll(d.Text, "'", " ")
2✔
812
                d.Text = strings.ReplaceAll(d.Text, "\n", " ")
2✔
813
                d.Text = strings.ReplaceAll(d.Text, "\r", " ")
2✔
814
                d.Text = strings.ReplaceAll(d.Text, "\t", " ")
2✔
815
                d.Text = strings.ReplaceAll(d.Text, "\"", " ")
2✔
816
                d.Text = strings.ReplaceAll(d.Text, "\\", " ")
2✔
817
                ds[i] = d
2✔
818
        }
2✔
819

820
        // get filter from query param, default to "all"
821
        filter := r.URL.Query().Get("filter")
1✔
822
        if filter == "" {
2✔
823
                filter = "all"
1✔
824
        }
1✔
825

826
        // apply filtering
827
        var filteredDS []storage.DetectedSpamInfo
1✔
828
        switch filter {
1✔
829
        case "non-classified":
×
830
                for _, entry := range ds {
×
831
                        hasClassifierHam := false
×
832
                        for _, check := range entry.Checks {
×
833
                                if check.Name == "classifier" && !check.Spam {
×
834
                                        hasClassifierHam = true
×
835
                                        break
×
836
                                }
837
                        }
838
                        if hasClassifierHam {
×
839
                                filteredDS = append(filteredDS, entry)
×
840
                        }
×
841
                }
842
        case "openai":
×
843
                for _, entry := range ds {
×
844
                        hasOpenAI := false
×
845
                        for _, check := range entry.Checks {
×
846
                                if check.Name == "openai" {
×
847
                                        hasOpenAI = true
×
848
                                        break
×
849
                                }
850
                        }
851
                        if hasOpenAI {
×
852
                                filteredDS = append(filteredDS, entry)
×
853
                        }
×
854
                }
855
        case "gemini":
×
856
                for _, entry := range ds {
×
857
                        hasGemini := false
×
858
                        for _, check := range entry.Checks {
×
859
                                if check.Name == "gemini" {
×
860
                                        hasGemini = true
×
861
                                        break
×
862
                                }
863
                        }
864
                        if hasGemini {
×
865
                                filteredDS = append(filteredDS, entry)
×
866
                        }
×
867
                }
868
        default: // "all" or any other value
1✔
869
                filteredDS = ds
1✔
870
        }
871

872
        s.appSettingsMu.RLock()
1✔
873
        openAIEnabled := s.AppSettings != nil && s.AppSettings.IsOpenAIEnabled()
1✔
874
        geminiEnabled := s.AppSettings != nil && s.AppSettings.Gemini.Token != ""
1✔
875
        s.appSettingsMu.RUnlock()
1✔
876

1✔
877
        tmplData := struct {
1✔
878
                DetectedSpamEntries []storage.DetectedSpamInfo
1✔
879
                TotalDetectedSpam   int
1✔
880
                FilteredCount       int
1✔
881
                Filter              string
1✔
882
                OpenAIEnabled       bool
1✔
883
                GeminiEnabled       bool
1✔
884
        }{
1✔
885
                DetectedSpamEntries: filteredDS,
1✔
886
                TotalDetectedSpam:   len(ds),
1✔
887
                FilteredCount:       len(filteredDS),
1✔
888
                Filter:              filter,
1✔
889
                OpenAIEnabled:       openAIEnabled,
1✔
890
                GeminiEnabled:       geminiEnabled,
1✔
891
        }
1✔
892

1✔
893
        // if it's an HTMX request, render both content and count display for OOB swap
1✔
894
        if r.Header.Get("HX-Request") == "true" {
1✔
895
                var buf bytes.Buffer
×
896

×
897
                // first render the content template
×
898
                if err := tmpl.ExecuteTemplate(&buf, "detected_spam_content", tmplData); err != nil {
×
899
                        log.Printf("[WARN] can't execute content template: %v", err)
×
900
                        http.Error(w, "Error executing template", http.StatusInternalServerError)
×
901
                        return
×
902
                }
×
903

904
                // then append OOB swap for the count display
905
                countHTML := ""
×
906
                if filter != "all" {
×
907
                        countHTML = fmt.Sprintf("(%d/%d)", len(filteredDS), len(ds))
×
908
                } else {
×
909
                        countHTML = fmt.Sprintf("(%d)", len(ds))
×
910
                }
×
911

912
                buf.WriteString(`<span id="count-display" hx-swap-oob="true">` + countHTML + `</span>`)
×
913

×
914
                // write the combined response
×
915
                if _, err := buf.WriteTo(w); err != nil {
×
916
                        log.Printf("[WARN] failed to write response: %v", err)
×
917
                }
×
918
                return
×
919
        }
920

921
        // full page render for normal requests
922
        if err := tmpl.ExecuteTemplate(w, "detected_spam.html", tmplData); err != nil {
1✔
923
                log.Printf("[WARN] can't execute template: %v", err)
×
924
                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
925
                return
×
926
        }
×
927
}
928

929
func (s *Server) htmlAddDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
5✔
930
        reportErr := func(err error, _ int) {
9✔
931
                w.Header().Set("HX-Retarget", "#error-message")
4✔
932
                fmt.Fprintf(w, "<div class='alert alert-danger'>%s</div>", err)
4✔
933
        }
4✔
934
        msg := r.FormValue("msg")
5✔
935

5✔
936
        id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
5✔
937
        if err != nil || msg == "" {
7✔
938
                log.Printf("[WARN] bad request: %v", err)
2✔
939
                reportErr(fmt.Errorf("bad request: %v", err), http.StatusBadRequest)
2✔
940
                return
2✔
941
        }
2✔
942

943
        if err := s.SpamFilter.UpdateSpam(msg); err != nil {
4✔
944
                log.Printf("[WARN] failed to update spam samples: %v", err)
1✔
945
                reportErr(fmt.Errorf("can't update spam samples: %v", err), http.StatusInternalServerError)
1✔
946
                return
1✔
947

1✔
948
        }
1✔
949
        if err := s.DetectedSpam.SetAddedToSamplesFlag(r.Context(), id); err != nil {
3✔
950
                log.Printf("[WARN] failed to update detected spam: %v", err)
1✔
951
                reportErr(fmt.Errorf("can't update detected spam: %v", err), http.StatusInternalServerError)
1✔
952
                return
1✔
953
        }
1✔
954
        w.WriteHeader(http.StatusOK)
1✔
955
}
956

957
func (s *Server) htmlSettingsHandler(w http.ResponseWriter, r *http.Request) {
6✔
958
        // get database information if StorageEngine is available
6✔
959
        var dbInfo struct {
6✔
960
                DatabaseType   string `json:"database_type"`
6✔
961
                GID            string `json:"gid"`
6✔
962
                DatabaseStatus string `json:"database_status"`
6✔
963
        }
6✔
964

6✔
965
        if s.StorageEngine != nil {
8✔
966
                // try to cast to SQL engine to get type information
2✔
967
                if sqlEngine, ok := s.StorageEngine.(*engine.SQL); ok {
2✔
968
                        dbInfo.DatabaseType = string(sqlEngine.Type())
×
969
                        dbInfo.GID = sqlEngine.GID()
×
970
                        dbInfo.DatabaseStatus = "Connected"
×
971
                } else {
2✔
972
                        dbInfo.DatabaseType = "Unknown"
2✔
973
                        dbInfo.DatabaseStatus = "Connected (unknown type)"
2✔
974
                }
2✔
975
        } else {
4✔
976
                dbInfo.DatabaseStatus = "Not connected"
4✔
977
        }
4✔
978

979
        // get backup information
980
        backupURL := "/download/backup"
6✔
981
        backupFilename := fmt.Sprintf("tg-spam-backup-%s-%s.sql.gz", dbInfo.DatabaseType, time.Now().Format("20060102-150405"))
6✔
982

6✔
983
        // get system info - uptime since server start
6✔
984
        uptime := time.Since(startTime)
6✔
985

6✔
986
        // get the list of available Lua plugins
6✔
987
        luaPlugins := s.Detector.GetLuaPluginNames()
6✔
988

6✔
989
        // get configuration DB status
6✔
990
        configAvailable := false
6✔
991
        var lastUpdated time.Time
6✔
992
        if s.SettingsStore != nil {
6✔
993
                configAvailable = true
×
994
                if lu, err := s.SettingsStore.LastUpdated(r.Context()); err == nil {
×
995
                        lastUpdated = lu
×
996
                } else if !errors.Is(err, sql.ErrNoRows) {
×
997
                        log.Printf("[WARN] failed to get last config update time: %v", err)
×
998
                }
×
999
        }
1000

1001
        // snapshot AppSettings under the read lock so template rendering sees a
1002
        // consistent view even if a config handler mutates or swaps the pointer.
1003
        s.appSettingsMu.RLock()
6✔
1004
        var settingsSnapshot *config.Settings
6✔
1005
        if s.AppSettings != nil {
11✔
1006
                cp := *s.AppSettings
5✔
1007
                settingsSnapshot = &cp
5✔
1008
        }
5✔
1009
        s.appSettingsMu.RUnlock()
6✔
1010

6✔
1011
        geminiEnabled := settingsSnapshot != nil && settingsSnapshot.Gemini.Token != ""
6✔
1012

6✔
1013
        data := struct {
6✔
1014
                *config.Settings
6✔
1015
                LuaAvailablePlugins []string
6✔
1016
                Version             string
6✔
1017
                Database            struct {
6✔
1018
                        Type   string
6✔
1019
                        GID    string
6✔
1020
                        Status string
6✔
1021
                }
6✔
1022
                Backup struct {
6✔
1023
                        URL      string
6✔
1024
                        Filename string
6✔
1025
                }
6✔
1026
                System struct {
6✔
1027
                        Uptime string
6✔
1028
                }
6✔
1029
                ConfigAvailable bool
6✔
1030
                LastUpdated     time.Time
6✔
1031
                ConfigDBMode    bool
6✔
1032
                BotUsername     string
6✔
1033
                GeminiEnabled   bool
6✔
1034
        }{
6✔
1035
                Settings:            settingsSnapshot,
6✔
1036
                LuaAvailablePlugins: luaPlugins,
6✔
1037
                Version:             s.Version,
6✔
1038
                Database: struct {
6✔
1039
                        Type   string
6✔
1040
                        GID    string
6✔
1041
                        Status string
6✔
1042
                }{
6✔
1043
                        Type:   dbInfo.DatabaseType,
6✔
1044
                        GID:    dbInfo.GID,
6✔
1045
                        Status: dbInfo.DatabaseStatus,
6✔
1046
                },
6✔
1047
                Backup: struct {
6✔
1048
                        URL      string
6✔
1049
                        Filename string
6✔
1050
                }{
6✔
1051
                        URL:      backupURL,
6✔
1052
                        Filename: backupFilename,
6✔
1053
                },
6✔
1054
                System: struct {
6✔
1055
                        Uptime string
6✔
1056
                }{
6✔
1057
                        Uptime: formatDuration(uptime),
6✔
1058
                },
6✔
1059
                ConfigAvailable: configAvailable,
6✔
1060
                LastUpdated:     lastUpdated,
6✔
1061
                ConfigDBMode:    s.ConfigDBMode,
6✔
1062
                BotUsername:     s.BotUsername,
6✔
1063
                GeminiEnabled:   geminiEnabled,
6✔
1064
        }
6✔
1065

6✔
1066
        if err := tmpl.ExecuteTemplate(w, "settings.html", data); err != nil {
7✔
1067
                log.Printf("[WARN] can't execute template: %v", err)
1✔
1068
                http.Error(w, "Error executing template", http.StatusInternalServerError)
1✔
1069
                return
1✔
1070
        }
1✔
1071
}
1072

1073
// getDMUsersHandler handles GET /dm-users. For HTMX requests it renders the dm_users.html partial,
1074
// for API requests it returns JSON with the list of recent DM users.
1075
func (s *Server) getDMUsersHandler(w http.ResponseWriter, r *http.Request) {
6✔
1076
        if s.DMUsersProvider == nil {
7✔
1077
                http.Error(w, "DM users provider not configured", http.StatusServiceUnavailable)
1✔
1078
                return
1✔
1079
        }
1✔
1080

1081
        users := s.DMUsersProvider.GetDMUsers()
5✔
1082

5✔
1083
        if r.Header.Get("HX-Request") != "true" {
7✔
1084
                // api response — return raw timestamps, no relative time
2✔
1085
                type dmUserJSON struct {
2✔
1086
                        UserID      int64     `json:"user_id"`
2✔
1087
                        UserName    string    `json:"user_name"`
2✔
1088
                        DisplayName string    `json:"display_name"`
2✔
1089
                        Timestamp   time.Time `json:"timestamp"`
2✔
1090
                }
2✔
1091
                result := make([]dmUserJSON, len(users))
2✔
1092
                for i, u := range users {
4✔
1093
                        result[i] = dmUserJSON{
2✔
1094
                                UserID:      u.UserID,
2✔
1095
                                UserName:    u.UserName,
2✔
1096
                                DisplayName: u.DisplayName,
2✔
1097
                                Timestamp:   u.Timestamp,
2✔
1098
                        }
2✔
1099
                }
2✔
1100
                rest.RenderJSON(w, result)
2✔
1101
                return
2✔
1102
        }
1103

1104
        // htmx response — render partial template with relative timestamps
1105
        type dmUserView struct {
3✔
1106
                UserID      int64
3✔
1107
                UserName    string
3✔
1108
                DisplayName string
3✔
1109
                When        string
3✔
1110
        }
3✔
1111
        viewUsers := make([]dmUserView, len(users))
3✔
1112
        for i, u := range users {
7✔
1113
                viewUsers[i] = dmUserView{
4✔
1114
                        UserID:      u.UserID,
4✔
1115
                        UserName:    u.UserName,
4✔
1116
                        DisplayName: u.DisplayName,
4✔
1117
                        When:        relativeTime(u.Timestamp),
4✔
1118
                }
4✔
1119
        }
4✔
1120

1121
        data := struct {
3✔
1122
                Users []dmUserView
3✔
1123
        }{Users: viewUsers}
3✔
1124

3✔
1125
        if err := tmpl.ExecuteTemplate(w, "dm_users.html", data); err != nil {
3✔
1126
                log.Printf("[WARN] can't execute dm_users template: %v", err)
×
1127
                http.Error(w, "Error rendering template", http.StatusInternalServerError)
×
1128
                return
×
1129
        }
×
1130
}
1131

1132
// relativeTime formats a timestamp as a human-readable relative time string.
1133
// accepts an optional reference time; if omitted, uses time.Now().
1134
func relativeTime(t time.Time, now ...time.Time) string {
11✔
1135
        ref := time.Now()
11✔
1136
        if len(now) > 0 {
18✔
1137
                ref = now[0]
7✔
1138
        }
7✔
1139
        d := ref.Sub(t)
11✔
1140
        switch {
11✔
1141
        case d < time.Minute:
1✔
1142
                return "just now"
1✔
1143
        case d < time.Hour:
4✔
1144
                return fmt.Sprintf("%dm ago", int(d.Minutes()))
4✔
1145
        case d < 24*time.Hour:
4✔
1146
                return fmt.Sprintf("%dh ago", int(d.Hours()))
4✔
1147
        default:
2✔
1148
                return fmt.Sprintf("%dd ago", int(d.Hours()/24))
2✔
1149
        }
1150
}
1151

1152
// formatDuration formats a duration in a human-readable way
1153
func formatDuration(d time.Duration) string {
14✔
1154
        days := int(d.Hours() / 24)
14✔
1155
        hours := int(d.Hours()) % 24
14✔
1156
        minutes := int(d.Minutes()) % 60
14✔
1157

14✔
1158
        if days > 0 {
17✔
1159
                return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
3✔
1160
        }
3✔
1161

1162
        if hours > 0 {
13✔
1163
                return fmt.Sprintf("%dh %dm", hours, minutes)
2✔
1164
        }
2✔
1165

1166
        return fmt.Sprintf("%dm", minutes)
9✔
1167
}
1168

1169
func (s *Server) downloadDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
3✔
1170
        ctx := r.Context()
3✔
1171
        spam, err := s.DetectedSpam.Read(ctx)
3✔
1172
        if err != nil {
4✔
1173
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't get detected spam", "details": err.Error()})
1✔
1174
                return
1✔
1175
        }
1✔
1176

1177
        type jsonSpamInfo struct {
2✔
1178
                ID        int64                `json:"id"`
2✔
1179
                GID       string               `json:"gid"`
2✔
1180
                Text      string               `json:"text"`
2✔
1181
                UserID    int64                `json:"user_id"`
2✔
1182
                UserName  string               `json:"user_name"`
2✔
1183
                Timestamp time.Time            `json:"timestamp"`
2✔
1184
                Added     bool                 `json:"added"`
2✔
1185
                Checks    []spamcheck.Response `json:"checks"`
2✔
1186
        }
2✔
1187

2✔
1188
        // convert entries to jsonl format with lowercase fields
2✔
1189
        lines := make([]string, 0, len(spam))
2✔
1190
        for _, entry := range spam {
5✔
1191
                data, err := json.Marshal(jsonSpamInfo{
3✔
1192
                        ID:        entry.ID,
3✔
1193
                        GID:       entry.GID,
3✔
1194
                        Text:      entry.Text,
3✔
1195
                        UserID:    entry.UserID,
3✔
1196
                        UserName:  entry.UserName,
3✔
1197
                        Timestamp: entry.Timestamp,
3✔
1198
                        Added:     entry.Added,
3✔
1199
                        Checks:    entry.Checks,
3✔
1200
                })
3✔
1201
                if err != nil {
3✔
1202
                        _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't marshal entry", "details": err.Error()})
×
1203
                        return
×
1204
                }
×
1205
                lines = append(lines, string(data))
3✔
1206
        }
1207

1208
        body := strings.Join(lines, "\n")
2✔
1209
        w.Header().Set("Content-Type", "application/x-jsonlines")
2✔
1210
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "detected_spam.jsonl"))
2✔
1211
        w.Header().Set("Content-Length", strconv.Itoa(len(body)))
2✔
1212
        w.WriteHeader(http.StatusOK)
2✔
1213
        _, _ = w.Write([]byte(body))
2✔
1214
}
1215

1216
// downloadBackupHandler streams a database backup as an SQL file with gzip compression
1217
// Files are always compressed and always have .gz extension to ensure consistency
1218
func (s *Server) downloadBackupHandler(w http.ResponseWriter, r *http.Request) {
2✔
1219
        if s.StorageEngine == nil {
3✔
1220
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "storage engine not available"})
1✔
1221
                return
1✔
1222
        }
1✔
1223

1224
        // set filename based on database type and timestamp
1225
        dbType := "db"
1✔
1226
        sqlEng, ok := s.StorageEngine.(*engine.SQL)
1✔
1227
        if ok {
1✔
1228
                dbType = string(sqlEng.Type())
×
1229
        }
×
1230
        timestamp := time.Now().Format("20060102-150405")
1✔
1231

1✔
1232
        // always use a .gz extension as the content is always compressed
1✔
1233
        filename := fmt.Sprintf("tg-spam-backup-%s-%s.sql.gz", dbType, timestamp)
1✔
1234

1✔
1235
        // set headers for file download - note we're using application/octet-stream
1✔
1236
        // instead of application/sql to prevent browsers from trying to interpret the file
1✔
1237
        w.Header().Set("Content-Type", "application/octet-stream")
1✔
1238
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
1✔
1239
        w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
1✔
1240
        w.Header().Set("Pragma", "no-cache")
1✔
1241
        w.Header().Set("Expires", "0")
1✔
1242

1✔
1243
        // create a gzip writer that streams to response
1✔
1244
        gzipWriter := gzip.NewWriter(w)
1✔
1245
        defer func() {
2✔
1246
                if err := gzipWriter.Close(); err != nil {
1✔
1247
                        log.Printf("[ERROR] failed to close gzip writer: %v", err)
×
1248
                }
×
1249
        }()
1250

1251
        // stream backup directly to response through gzip
1252
        if err := s.StorageEngine.Backup(r.Context(), gzipWriter); err != nil {
1✔
1253
                log.Printf("[ERROR] failed to create backup: %v", err)
×
1254
                // we've already started writing the response, so we can't send a proper error response
×
1255
                return
×
1256
        }
×
1257

1258
        // flush the gzip writer to ensure all data is written
1259
        if err := gzipWriter.Flush(); err != nil {
1✔
1260
                log.Printf("[ERROR] failed to flush gzip writer: %v", err)
×
1261
        }
×
1262
}
1263

1264
// downloadExportToPostgresHandler streams a PostgreSQL-compatible export from a SQLite database
1265
func (s *Server) downloadExportToPostgresHandler(w http.ResponseWriter, r *http.Request) {
3✔
1266
        if s.StorageEngine == nil {
4✔
1267
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "storage engine not available"})
1✔
1268
                return
1✔
1269
        }
1✔
1270

1271
        // check if the database is SQLite
1272
        if s.StorageEngine.Type() != engine.Sqlite {
3✔
1273
                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "source database must be SQLite"})
1✔
1274
                return
1✔
1275
        }
1✔
1276

1277
        // set filename based on timestamp
1278
        timestamp := time.Now().Format("20060102-150405")
1✔
1279
        filename := fmt.Sprintf("tg-spam-sqlite-to-postgres-%s.sql.gz", timestamp)
1✔
1280

1✔
1281
        // set headers for file download
1✔
1282
        w.Header().Set("Content-Type", "application/octet-stream")
1✔
1283
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
1✔
1284
        w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
1✔
1285
        w.Header().Set("Pragma", "no-cache")
1✔
1286
        w.Header().Set("Expires", "0")
1✔
1287

1✔
1288
        // create a gzip writer that streams to response
1✔
1289
        gzipWriter := gzip.NewWriter(w)
1✔
1290
        defer func() {
2✔
1291
                if err := gzipWriter.Close(); err != nil {
1✔
1292
                        log.Printf("[ERROR] failed to close gzip writer: %v", err)
×
1293
                }
×
1294
        }()
1295

1296
        // stream export directly to response through gzip
1297
        if err := s.StorageEngine.BackupSqliteAsPostgres(r.Context(), gzipWriter); err != nil {
1✔
1298
                log.Printf("[ERROR] failed to create export: %v", err)
×
1299
                // we've already started writing the response, so we can't send a proper error response
×
1300
                return
×
1301
        }
×
1302

1303
        // flush the gzip writer to ensure all data is written
1304
        if err := gzipWriter.Flush(); err != nil {
1✔
1305
                log.Printf("[ERROR] failed to flush gzip writer: %v", err)
×
1306
        }
×
1307
}
1308

1309
func (s *Server) renderSamples(w http.ResponseWriter, tmplName string) {
6✔
1310
        spam, ham, err := s.SpamFilter.DynamicSamples()
6✔
1311
        if err != nil {
7✔
1312
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't fetch samples", "details": err.Error()})
1✔
1313
                return
1✔
1314
        }
1✔
1315

1316
        spam, ham = s.reverseSamples(spam, ham)
5✔
1317

5✔
1318
        type smpleWithID struct {
5✔
1319
                ID     string
5✔
1320
                Sample string
5✔
1321
        }
5✔
1322

5✔
1323
        makeID := func(s string) string {
19✔
1324
                hash := sha1.New() //nolint
14✔
1325
                if _, err := hash.Write([]byte(s)); err != nil {
14✔
1326
                        return fmt.Sprintf("%x", s)
×
1327
                }
×
1328
                return fmt.Sprintf("%x", hash.Sum(nil))
14✔
1329
        }
1330

1331
        tmplData := struct {
5✔
1332
                SpamSamples      []smpleWithID
5✔
1333
                HamSamples       []smpleWithID
5✔
1334
                TotalHamSamples  int
5✔
1335
                TotalSpamSamples int
5✔
1336
        }{
5✔
1337
                TotalHamSamples:  len(ham),
5✔
1338
                TotalSpamSamples: len(spam),
5✔
1339
        }
5✔
1340
        for _, s := range spam {
12✔
1341
                tmplData.SpamSamples = append(tmplData.SpamSamples, smpleWithID{ID: makeID(s), Sample: s})
7✔
1342
        }
7✔
1343
        for _, h := range ham {
12✔
1344
                tmplData.HamSamples = append(tmplData.HamSamples, smpleWithID{ID: makeID(h), Sample: h})
7✔
1345
        }
7✔
1346

1347
        if err := tmpl.ExecuteTemplate(w, tmplName, tmplData); err != nil {
6✔
1348
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't execute template", "details": err.Error()})
1✔
1349
                return
1✔
1350
        }
1✔
1351
}
1352

1353
// reverseSamples returns reversed lists of spam and ham samples
1354
func (s *Server) reverseSamples(spam, ham []string) (revSpam, revHam []string) {
8✔
1355
        revSpam = make([]string, len(spam))
8✔
1356
        revHam = make([]string, len(ham))
8✔
1357

8✔
1358
        for i, j := 0, len(spam)-1; i < len(spam); i, j = i+1, j-1 {
19✔
1359
                revSpam[i] = spam[j]
11✔
1360
        }
11✔
1361
        for i, j := 0, len(ham)-1; i < len(ham); i, j = i+1, j-1 {
19✔
1362
                revHam[i] = ham[j]
11✔
1363
        }
11✔
1364
        return revSpam, revHam
8✔
1365
}
1366

1367
// renderDictionary renders dictionary entries for HTMX or full page request
1368
func (s *Server) renderDictionary(ctx context.Context, w http.ResponseWriter, tmplName string) {
4✔
1369
        stopPhrases, err := s.Dictionary.ReadWithIDs(ctx, storage.DictionaryTypeStopPhrase)
4✔
1370
        if err != nil {
4✔
1371
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't fetch stop phrases", "details": err.Error()})
×
1372
                return
×
1373
        }
×
1374

1375
        ignoredWords, err := s.Dictionary.ReadWithIDs(ctx, storage.DictionaryTypeIgnoredWord)
4✔
1376
        if err != nil {
4✔
1377
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't fetch ignored words", "details": err.Error()})
×
1378
                return
×
1379
        }
×
1380

1381
        tmplData := struct {
4✔
1382
                StopPhrases       []storage.DictionaryEntry
4✔
1383
                IgnoredWords      []storage.DictionaryEntry
4✔
1384
                TotalStopPhrases  int
4✔
1385
                TotalIgnoredWords int
4✔
1386
        }{
4✔
1387
                StopPhrases:       stopPhrases,
4✔
1388
                IgnoredWords:      ignoredWords,
4✔
1389
                TotalStopPhrases:  len(stopPhrases),
4✔
1390
                TotalIgnoredWords: len(ignoredWords),
4✔
1391
        }
4✔
1392

4✔
1393
        if err := tmpl.ExecuteTemplate(w, tmplName, tmplData); err != nil {
4✔
1394
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't execute template", "details": err.Error()})
×
1395
                return
×
1396
        }
×
1397
}
1398

1399
// staticFS is a filtered filesystem that only exposes specific static files
1400
type staticFS struct {
1401
        fs        fs.FS
1402
        urlToPath map[string]string
1403
}
1404

1405
// staticFileMapping defines a mapping between URL path and filesystem path
1406
type staticFileMapping struct {
1407
        urlPath     string
1408
        filesysPath string
1409
}
1410

1411
func newStaticFS(fsys fs.FS, files ...staticFileMapping) *staticFS {
6✔
1412
        urlToPath := make(map[string]string)
6✔
1413
        for _, f := range files {
24✔
1414
                urlToPath[f.urlPath] = f.filesysPath
18✔
1415
        }
18✔
1416

1417
        return &staticFS{
6✔
1418
                fs:        fsys,
6✔
1419
                urlToPath: urlToPath,
6✔
1420
        }
6✔
1421
}
1422

1423
func (sfs *staticFS) Open(name string) (fs.File, error) {
6✔
1424
        cleanName := path.Clean("/" + name)[1:]
6✔
1425

6✔
1426
        fsPath, ok := sfs.urlToPath[cleanName]
6✔
1427
        if !ok {
9✔
1428
                return nil, fs.ErrNotExist
3✔
1429
        }
3✔
1430

1431
        file, err := sfs.fs.Open(fsPath)
3✔
1432
        if err != nil {
3✔
1433
                return nil, fmt.Errorf("failed to open static file %s: %w", fsPath, err)
×
1434
        }
×
1435
        return file, nil
3✔
1436
}
1437

1438
// GenerateRandomPassword generates a random password of a given length
1439
func GenerateRandomPassword(length int) (string, error) {
2✔
1440
        const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+"
2✔
1441
        const charsetLen = int64(len(charset))
2✔
1442

2✔
1443
        result := make([]byte, length)
2✔
1444
        for i := range length {
66✔
1445
                n, err := rand.Int(rand.Reader, big.NewInt(charsetLen))
64✔
1446
                if err != nil {
64✔
1447
                        return "", fmt.Errorf("failed to generate random number: %w", err)
×
1448
                }
×
1449
                result[i] = charset[n.Int64()]
64✔
1450
        }
1451
        return string(result), nil
2✔
1452
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc