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

umputun / tg-spam / 24966212061

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

Pull #294

github

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

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

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

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

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

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

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

Address review:

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

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

10 existing lines in 2 files now uncovered.

8174 of 9839 relevant lines covered (83.08%)

232.02 hits per line

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

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✔
NEW
315
                        webUI.Route(func(cfgRouter *routegroup.Bundle) {
×
NEW
316
                                cfgRouter.HandleFunc("POST /config", s.saveConfigHandler) // save current configuration to database
×
NEW
317
                                // reload uses POST because it mutates state; GET would bypass cross-origin protection (safe methods are always allowed)
×
NEW
318
                                cfgRouter.HandleFunc("POST /config/reload", s.loadConfigHandler)
×
NEW
319
                                cfgRouter.HandleFunc("PUT /config", s.updateConfigHandler)    // update configuration
×
NEW
320
                                cfgRouter.HandleFunc("DELETE /config", s.deleteConfigHandler) // delete configuration
×
NEW
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✔
NEW
993
                configAvailable = true
×
NEW
994
                if lu, err := s.SettingsStore.LastUpdated(r.Context()); err == nil {
×
NEW
995
                        lastUpdated = lu
×
NEW
996
                } else if !errors.Is(err, sql.ErrNoRows) {
×
NEW
997
                        log.Printf("[WARN] failed to get last config update time: %v", err)
×
NEW
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