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

umputun / tg-spam / 23994248693

05 Apr 2026 04:29AM UTC coverage: 82.876% (+0.09%) from 82.786%
23994248693

push

github

web-flow
feat: show recent bot DM users in admin settings for easy user ID discovery (#384)

* feat: add DMUser type and in-memory storage with SSE subscription support

* feat: add web API endpoint and SSE stream for DM users

* feat: add UI section for DM users in Bot Behavior settings tab

* docs: update README with recent bot users feature, move plan to completed

* refactor: remove SSE, add bot link and refresh button

- remove SSE infrastructure (sseHandler, Subscribe/Unsubscribe, keepalive)
- replace with simple HTMX Refresh button for DM users table
- add t.me/botname link in instructions when bot username is available
- add BotUsername to Settings, wire from TelegramListener
- update README to reflect simplified approach
- simplify DMUsersProvider interface to single GetDMUsers method
- remove unused subscriber code from dmUsers storage

* fix: address review feedback — restore data files, remove dead code, rename JS function

- Restore accidentally deleted data files (exclude-tokens, ham-samples, spam-samples, stop-words)
- Remove unused formatUsername function and its test
- Rename addSuperUser to copyUserID, drop dead textarea fallback

121 of 130 new or added lines in 4 files covered. (93.08%)

3 existing lines in 2 files now uncovered.

6984 of 8427 relevant lines covered (82.88%)

258.1 hits per line

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

85.24
/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
        "embed"
11
        "encoding/json"
12
        "errors"
13
        "fmt"
14
        "html/template"
15
        "io"
16
        "io/fs"
17
        "math/big"
18
        "net/http"
19
        "path"
20
        "strconv"
21
        "strings"
22
        "time"
23

24
        "github.com/didip/tollbooth/v8"
25
        log "github.com/go-pkgz/lgr"
26
        "github.com/go-pkgz/rest"
27
        "github.com/go-pkgz/rest/logger"
28
        "github.com/go-pkgz/routegroup"
29

30
        "github.com/umputun/tg-spam/app/events"
31
        "github.com/umputun/tg-spam/app/storage"
32
        "github.com/umputun/tg-spam/app/storage/engine"
33
        "github.com/umputun/tg-spam/lib/approved"
34
        "github.com/umputun/tg-spam/lib/spamcheck"
35
)
36

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

45
//go:embed assets/* assets/components/*
46
var templateFS embed.FS
47
var tmpl = template.Must(template.ParseFS(templateFS, "assets/*.html", "assets/components/*.html"))
48

49
// startTime tracks when the server started
50
var startTime = time.Now()
51

52
// Server is a web API server.
53
type Server struct {
54
        Config
55
}
56

57
// Config defines  server parameters
58
type Config struct {
59
        Version         string          // version to show in /ping
60
        ListenAddr      string          // listen address
61
        Detector        Detector        // spam detector
62
        SpamFilter      SpamFilter      // spam filter (bot)
63
        DetectedSpam    DetectedSpam    // detected spam accessor
64
        Locator         Locator         // locator for user info
65
        Dictionary      Dictionary      // dictionary for stop phrases and ignored words
66
        StorageEngine   StorageEngine   // database engine access for backups
67
        DMUsersProvider DMUsersProvider // provider for recent DM users
68
        AuthPasswd      string          // basic auth password for user "tg-spam"
69
        AuthHash        string          // basic auth bcrypt hash for user "tg-spam", takes precedence over AuthPasswd
70
        Dbg             bool            // debug mode
71
        Settings        Settings        // application settings
72
}
73

74
// Settings contains all application settings
75
type Settings struct {
76
        InstanceID               string        `json:"instance_id"`
77
        BotUsername              string        `json:"bot_username"`
78
        PrimaryGroup             string        `json:"primary_group"`
79
        AdminGroup               string        `json:"admin_group"`
80
        DisableAdminSpamForward  bool          `json:"disable_admin_spam_forward"`
81
        LoggerEnabled            bool          `json:"logger_enabled"`
82
        SuperUsers               []string      `json:"super_users"`
83
        NoSpamReply              bool          `json:"no_spam_reply"`
84
        CasEnabled               bool          `json:"cas_enabled"`
85
        MetaEnabled              bool          `json:"meta_enabled"`
86
        MetaLinksLimit           int           `json:"meta_links_limit"`
87
        MetaMentionsLimit        int           `json:"meta_mentions_limit"`
88
        MetaLinksOnly            bool          `json:"meta_links_only"`
89
        MetaImageOnly            bool          `json:"meta_image_only"`
90
        MetaVideoOnly            bool          `json:"meta_video_only"`
91
        MetaAudioOnly            bool          `json:"meta_audio_only"`
92
        MetaForwarded            bool          `json:"meta_forwarded"`
93
        MetaKeyboard             bool          `json:"meta_keyboard"`
94
        MetaContactOnly          bool          `json:"meta_contact_only"`
95
        MetaUsernameSymbols      string        `json:"meta_username_symbols"`
96
        MetaGiveaway             bool          `json:"meta_giveaway"`
97
        MultiLangLimit           int           `json:"multi_lang_limit"`
98
        LLMConsensus             string        `json:"llm_consensus"`
99
        OpenAIEnabled            bool          `json:"openai_enabled"`
100
        OpenAIVeto               bool          `json:"openai_veto"`
101
        OpenAIHistorySize        int           `json:"openai_history_size"`
102
        OpenAIModel              string        `json:"openai_model"`
103
        OpenAICheckShortMessages bool          `json:"openai_check_short_messages"`
104
        OpenAICustomPrompts      []string      `json:"openai_custom_prompts"`
105
        GeminiEnabled            bool          `json:"gemini_enabled"`
106
        GeminiVeto               bool          `json:"gemini_veto"`
107
        GeminiHistorySize        int           `json:"gemini_history_size"`
108
        GeminiModel              string        `json:"gemini_model"`
109
        GeminiCheckShortMessages bool          `json:"gemini_check_short_messages"`
110
        GeminiCustomPrompts      []string      `json:"gemini_custom_prompts"`
111
        LuaPluginsEnabled        bool          `json:"lua_plugins_enabled"`
112
        LuaPluginsDir            string        `json:"lua_plugins_dir"`
113
        LuaEnabledPlugins        []string      `json:"lua_enabled_plugins"`
114
        LuaDynamicReload         bool          `json:"lua_dynamic_reload"`
115
        LuaAvailablePlugins      []string      `json:"lua_available_plugins"` // the list of all available Lua plugins
116
        SamplesDataPath          string        `json:"samples_data_path"`
117
        DynamicDataPath          string        `json:"dynamic_data_path"`
118
        WatchIntervalSecs        int           `json:"watch_interval_secs"`
119
        SimilarityThreshold      float64       `json:"similarity_threshold"`
120
        MinMsgLen                int           `json:"min_msg_len"`
121
        MaxEmoji                 int           `json:"max_emoji"`
122
        MinSpamProbability       float64       `json:"min_spam_probability"`
123
        ParanoidMode             bool          `json:"paranoid_mode"`
124
        FirstMessagesCount       int           `json:"first_messages_count"`
125
        StartupMessageEnabled    bool          `json:"startup_message_enabled"`
126
        TrainingEnabled          bool          `json:"training_enabled"`
127
        StorageTimeout           time.Duration `json:"storage_timeout"`
128
        SoftBanEnabled           bool          `json:"soft_ban_enabled"`
129
        AbnormalSpacingEnabled   bool          `json:"abnormal_spacing_enabled"`
130
        HistorySize              int           `json:"history_size"`
131
        DebugModeEnabled         bool          `json:"debug_mode_enabled"`
132
        DryModeEnabled           bool          `json:"dry_mode_enabled"`
133
        TGDebugModeEnabled       bool          `json:"tg_debug_mode_enabled"`
134
}
135

136
// Detector is a spam detector interface.
137
type Detector interface {
138
        Check(req spamcheck.Request) (spam bool, cr []spamcheck.Response)
139
        ApprovedUsers() []approved.UserInfo
140
        AddApprovedUser(user approved.UserInfo) error
141
        RemoveApprovedUser(id string) error
142
        GetLuaPluginNames() []string // Returns the list of available Lua plugin names
143
}
144

145
// SpamFilter is a spam filter, bot interface.
146
type SpamFilter interface {
147
        UpdateSpam(msg string) error
148
        UpdateHam(msg string) error
149
        ReloadSamples() (err error)
150
        DynamicSamples() (spam, ham []string, err error)
151
        RemoveDynamicSpamSample(sample string) error
152
        RemoveDynamicHamSample(sample string) error
153
}
154

155
// Locator is a storage interface used to get user id by name and vice versa.
156
type Locator interface {
157
        UserIDByName(ctx context.Context, userName string) int64
158
        UserNameByID(ctx context.Context, userID int64) string
159
}
160

161
// DetectedSpam is a storage interface used to get detected spam messages and set added flag.
162
type DetectedSpam interface {
163
        Read(ctx context.Context) ([]storage.DetectedSpamInfo, error)
164
        SetAddedToSamplesFlag(ctx context.Context, id int64) error
165
        FindByUserID(ctx context.Context, userID int64) (*storage.DetectedSpamInfo, error)
166
}
167

168
// StorageEngine provides access to the database engine for operations like backup
169
type StorageEngine interface {
170
        Backup(ctx context.Context, w io.Writer) error
171
        Type() engine.Type
172
        BackupSqliteAsPostgres(ctx context.Context, w io.Writer) error
173
}
174

175
// Dictionary is a storage interface for managing stop phrases and ignored words
176
type Dictionary interface {
177
        Add(ctx context.Context, t storage.DictionaryType, data string) error
178
        Delete(ctx context.Context, id int64) error
179
        Read(ctx context.Context, t storage.DictionaryType) ([]string, error)
180
        ReadWithIDs(ctx context.Context, t storage.DictionaryType) ([]storage.DictionaryEntry, error)
181
        Stats(ctx context.Context) (*storage.DictionaryStats, error)
182
}
183

184
// DMUsersProvider provides access to recent DM users for the admin UI
185
type DMUsersProvider interface {
186
        GetDMUsers() []events.DMUser
187
}
188

189
// NewServer creates a new web API server.
190
func NewServer(config Config) *Server {
80✔
191
        return &Server{Config: config}
80✔
192
}
80✔
193

194
// Run starts server and accepts requests checking for spam messages.
195
func (s *Server) Run(ctx context.Context) error {
3✔
196
        router := routegroup.New(http.NewServeMux())
3✔
197
        router.Use(rest.Recoverer(log.Default()))
3✔
198
        router.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]")).Handler)
3✔
199
        router.Use(rest.Throttle(1000))
3✔
200
        router.Use(rest.AppInfo("tg-spam", "umputun", s.Version), rest.Ping)
3✔
201
        router.Use(tollbooth.HTTPMiddleware(tollbooth.NewLimiter(50, nil)))
3✔
202
        router.Use(rest.SizeLimit(1024 * 1024)) // 1M max request size
3✔
203

3✔
204
        if s.AuthPasswd != "" || s.AuthHash != "" {
6✔
205
                log.Printf("[INFO] basic auth enabled for webapi server")
3✔
206
                if s.AuthHash != "" {
4✔
207
                        router.Use(rest.BasicAuthWithBcryptHashAndPrompt("tg-spam", s.AuthHash))
1✔
208
                } else {
3✔
209
                        router.Use(rest.BasicAuthWithPrompt("tg-spam", s.AuthPasswd))
2✔
210
                }
2✔
211
        } else {
×
212
                log.Printf("[WARN] basic auth disabled, access to webapi is not protected")
×
213
        }
×
214

215
        router = s.routes(router) // setup routes
3✔
216

3✔
217
        srv := &http.Server{Addr: s.ListenAddr, Handler: router, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second}
3✔
218
        go func() {
6✔
219
                <-ctx.Done()
3✔
220
                if err := srv.Shutdown(ctx); err != nil {
3✔
UNCOV
221
                        log.Printf("[WARN] failed to shutdown webapi server: %v", err)
×
222
                } else {
3✔
223
                        log.Printf("[INFO] webapi server stopped")
3✔
224
                }
3✔
225
        }()
226

227
        log.Printf("[INFO] start webapi server on %s", s.ListenAddr)
3✔
228
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
3✔
229
                return fmt.Errorf("failed to run server: %w", err)
×
230
        }
×
231
        return nil
3✔
232
}
233

234
func (s *Server) routes(router *routegroup.Bundle) *routegroup.Bundle {
5✔
235
        // auth api routes
5✔
236
        router.Group().Route(func(authApi *routegroup.Bundle) {
10✔
237
                authApi.Use(s.authMiddleware(rest.BasicAuthWithUserPasswd("tg-spam", s.AuthPasswd)))
5✔
238
                authApi.HandleFunc("POST /check", s.checkMsgHandler)         // check a message for spam
5✔
239
                authApi.HandleFunc("GET /check/{user_id}", s.checkIDHandler) // check user id for spam
5✔
240

5✔
241
                authApi.Mount("/update").Route(func(r *routegroup.Bundle) {
10✔
242
                        // update spam/ham samples
5✔
243
                        r.HandleFunc("POST /spam", s.updateSampleHandler(s.SpamFilter.UpdateSpam)) // update spam samples
5✔
244
                        r.HandleFunc("POST /ham", s.updateSampleHandler(s.SpamFilter.UpdateHam))   // update ham samples
5✔
245
                })
5✔
246

247
                authApi.Mount("/delete").Route(func(r *routegroup.Bundle) {
10✔
248
                        // delete spam/ham samples
5✔
249
                        r.HandleFunc("POST /spam", s.deleteSampleHandler(s.SpamFilter.RemoveDynamicSpamSample))
5✔
250
                        r.HandleFunc("POST /ham", s.deleteSampleHandler(s.SpamFilter.RemoveDynamicHamSample))
5✔
251
                })
5✔
252

253
                authApi.Mount("/download").Route(func(r *routegroup.Bundle) {
10✔
254
                        r.HandleFunc("GET /spam", s.downloadSampleHandler(func(spam, _ []string) ([]string, string) {
5✔
255
                                return spam, "spam.txt"
×
256
                        }))
×
257
                        r.HandleFunc("GET /ham", s.downloadSampleHandler(func(_, ham []string) ([]string, string) {
5✔
258
                                return ham, "ham.txt"
×
259
                        }))
×
260
                        r.HandleFunc("GET /detected_spam", s.downloadDetectedSpamHandler)
5✔
261
                        r.HandleFunc("GET /backup", s.downloadBackupHandler)
5✔
262
                        r.HandleFunc("GET /export-to-postgres", s.downloadExportToPostgresHandler)
5✔
263
                })
264

265
                authApi.HandleFunc("GET /samples", s.getDynamicSamplesHandler)    // get dynamic samples
5✔
266
                authApi.HandleFunc("PUT /samples", s.reloadDynamicSamplesHandler) // reload samples
5✔
267

5✔
268
                authApi.Mount("/users").Route(func(r *routegroup.Bundle) { // manage approved users
10✔
269
                        // add user to the approved list and storage
5✔
270
                        r.HandleFunc("POST /add", s.updateApprovedUsersHandler(s.Detector.AddApprovedUser))
5✔
271
                        // remove user from an approved list and storage
5✔
272
                        r.HandleFunc("POST /delete", s.updateApprovedUsersHandler(s.removeApprovedUser))
5✔
273
                        // get approved users
5✔
274
                        r.HandleFunc("GET /", s.getApprovedUsersHandler)
5✔
275
                })
5✔
276

277
                authApi.HandleFunc("GET /settings", s.getSettingsHandler) // get application settings
5✔
278

5✔
279
                authApi.Mount("/dictionary").Route(func(r *routegroup.Bundle) { // manage dictionary
10✔
280
                        // add stop phrase or ignored word
5✔
281
                        r.HandleFunc("POST /add", s.addDictionaryEntryHandler)
5✔
282
                        // delete entry by id
5✔
283
                        r.HandleFunc("POST /delete", s.deleteDictionaryEntryHandler)
5✔
284
                        // get all entries
5✔
285
                        r.HandleFunc("GET /", s.getDictionaryEntriesHandler)
5✔
286
                })
5✔
287
        })
288

289
        router.Group().Route(func(webUI *routegroup.Bundle) {
10✔
290
                webUI.Use(s.authMiddleware(rest.BasicAuthWithPrompt("tg-spam", s.AuthPasswd)))
5✔
291
                webUI.HandleFunc("GET /", s.htmlSpamCheckHandler)                         // serve template for webUI UI
5✔
292
                webUI.HandleFunc("GET /manage_samples", s.htmlManageSamplesHandler)       // serve manage samples page
5✔
293
                webUI.HandleFunc("GET /manage_users", s.htmlManageUsersHandler)           // serve manage users page
5✔
294
                webUI.HandleFunc("GET /manage_dictionary", s.htmlManageDictionaryHandler) // serve manage dictionary page
5✔
295
                webUI.HandleFunc("GET /detected_spam", s.htmlDetectedSpamHandler)         // serve detected spam page
5✔
296
                webUI.HandleFunc("GET /list_settings", s.htmlSettingsHandler)             // serve settings
5✔
297
                webUI.HandleFunc("POST /detected_spam/add", s.htmlAddDetectedSpamHandler) // add detected spam to samples
5✔
298
                webUI.HandleFunc("GET /dm-users", s.getDMUsersHandler)                    // get recent DM users (HTMX/JSON)
5✔
299

5✔
300
                // handle logout - force Basic Auth re-authentication
5✔
301
                webUI.HandleFunc("GET /logout", func(w http.ResponseWriter, _ *http.Request) {
5✔
302
                        w.Header().Set("WWW-Authenticate", `Basic realm="tg-spam"`)
×
303
                        w.WriteHeader(http.StatusUnauthorized)
×
304
                        fmt.Fprintln(w, "Logged out successfully")
×
305
                })
×
306

307
                // serve only specific static files at root level
308
                staticFiles := newStaticFS(templateFS,
5✔
309
                        staticFileMapping{urlPath: "styles.css", filesysPath: "assets/styles.css"},
5✔
310
                        staticFileMapping{urlPath: "logo.png", filesysPath: "assets/logo.png"},
5✔
311
                        staticFileMapping{urlPath: "spinner.svg", filesysPath: "assets/spinner.svg"},
5✔
312
                )
5✔
313
                webUI.HandleFiles("/", http.FS(staticFiles))
5✔
314
        })
315

316
        return router
5✔
317
}
318

319
// checkMsgHandler handles POST /check request.
320
// it gets message text and user id from request body and returns spam status and check results.
321
func (s *Server) checkMsgHandler(w http.ResponseWriter, r *http.Request) {
9✔
322
        type CheckResultDisplay struct {
9✔
323
                Spam   bool
9✔
324
                Checks []spamcheck.Response
9✔
325
        }
9✔
326

9✔
327
        isHtmxRequest := r.Header.Get("HX-Request") == "true"
9✔
328

9✔
329
        req := spamcheck.Request{CheckOnly: true}
9✔
330
        if !isHtmxRequest {
17✔
331
                // API request
8✔
332
                if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
10✔
333
                        _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't decode request", "details": err.Error()})
2✔
334
                        log.Printf("[WARN] can't decode request: %v", err)
2✔
335
                        return
2✔
336
                }
2✔
337
        } else {
1✔
338
                // for hx-request (HTMX) we need to get the values from the form
1✔
339
                req.UserID = r.FormValue("user_id")
1✔
340
                req.UserName = r.FormValue("user_name")
1✔
341
                req.Msg = r.FormValue("msg")
1✔
342
        }
1✔
343

344
        spam, cr := s.Detector.Check(req)
7✔
345
        if !isHtmxRequest {
13✔
346
                // for API request return JSON
6✔
347
                rest.RenderJSON(w, rest.JSON{"spam": spam, "checks": cr})
6✔
348
                return
6✔
349
        }
6✔
350

351
        if req.Msg == "" {
1✔
352
                w.Header().Set("HX-Retarget", "#error-message")
×
353
                fmt.Fprintln(w, "<div class='alert alert-danger'>Valid message required.</div>")
×
354
                return
×
355
        }
×
356

357
        // render result for HTMX request
358
        resultDisplay := CheckResultDisplay{
1✔
359
                Spam:   spam,
1✔
360
                Checks: cr,
1✔
361
        }
1✔
362

1✔
363
        if err := tmpl.ExecuteTemplate(w, "check_results", resultDisplay); err != nil {
1✔
364
                log.Printf("[WARN] can't execute result template: %v", err)
×
365
                http.Error(w, "Error rendering result", http.StatusInternalServerError)
×
366
                return
×
367
        }
×
368
}
369

370
// checkIDHandler handles GET /check/{user_id} request.
371
// it returns JSON with the status "spam" or "ham" for a given user id.
372
// if user is spammer, it also returns check results.
373
func (s *Server) checkIDHandler(w http.ResponseWriter, r *http.Request) {
4✔
374
        type info struct {
4✔
375
                UserName  string               `json:"user_name,omitempty"`
4✔
376
                Message   string               `json:"message,omitempty"`
4✔
377
                Timestamp time.Time            `json:"timestamp,omitzero"`
4✔
378
                Checks    []spamcheck.Response `json:"checks,omitempty"`
4✔
379
        }
4✔
380
        resp := struct {
4✔
381
                Status string `json:"status"`
4✔
382
                Info   *info  `json:"info,omitempty"`
4✔
383
        }{
4✔
384
                Status: "ham",
4✔
385
        }
4✔
386

4✔
387
        userID, err := strconv.ParseInt(r.PathValue("user_id"), 10, 64)
4✔
388
        if err != nil {
5✔
389
                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't parse user id", "details": err.Error()})
1✔
390
                return
1✔
391
        }
1✔
392

393
        si, err := s.DetectedSpam.FindByUserID(r.Context(), userID)
3✔
394
        if err != nil {
4✔
395
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't get user info", "details": err.Error()})
1✔
396
                return
1✔
397
        }
1✔
398
        if si != nil {
3✔
399
                resp.Status = "spam"
1✔
400
                resp.Info = &info{
1✔
401
                        UserName:  si.UserName,
1✔
402
                        Message:   si.Text,
1✔
403
                        Timestamp: si.Timestamp,
1✔
404
                        Checks:    si.Checks,
1✔
405
                }
1✔
406
        }
1✔
407
        rest.RenderJSON(w, resp)
2✔
408
}
409

410
// getDynamicSamplesHandler handles GET /samples request. It returns dynamic samples both for spam and ham.
411
func (s *Server) getDynamicSamplesHandler(w http.ResponseWriter, _ *http.Request) {
2✔
412
        spam, ham, err := s.SpamFilter.DynamicSamples()
2✔
413
        if err != nil {
3✔
414
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't get dynamic samples", "details": err.Error()})
1✔
415
                return
1✔
416
        }
1✔
417
        rest.RenderJSON(w, rest.JSON{"spam": spam, "ham": ham})
1✔
418
}
419

420
// downloadSampleHandler handles GET /download/spam|ham request.
421
// It returns dynamic samples both for spam and ham.
422
func (s *Server) downloadSampleHandler(pickFn func(spam, ham []string) ([]string, string)) http.HandlerFunc {
13✔
423
        return func(w http.ResponseWriter, _ *http.Request) {
16✔
424
                spam, ham, err := s.SpamFilter.DynamicSamples()
3✔
425
                if err != nil {
4✔
426
                        _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't get dynamic samples", "details": err.Error()})
1✔
427
                        return
1✔
428
                }
1✔
429
                samples, name := pickFn(spam, ham)
2✔
430
                body := strings.Join(samples, "\n")
2✔
431
                w.Header().Set("Content-Type", "text/plain; charset=utf-8")
2✔
432
                w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
2✔
433
                w.Header().Set("Content-Length", strconv.Itoa(len(body)))
2✔
434
                w.WriteHeader(http.StatusOK)
2✔
435
                _, _ = w.Write([]byte(body))
2✔
436
        }
437
}
438

439
// updateSampleHandler handles POST /update/spam|ham request. It updates dynamic samples both for spam and ham.
440
func (s *Server) updateSampleHandler(updFn func(msg string) error) func(w http.ResponseWriter, r *http.Request) {
15✔
441
        return func(w http.ResponseWriter, r *http.Request) {
22✔
442
                var req struct {
7✔
443
                        Msg string `json:"msg"`
7✔
444
                }
7✔
445

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

7✔
448
                if isHtmxRequest {
7✔
449
                        req.Msg = r.FormValue("msg")
×
450
                } else {
7✔
451
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
9✔
452
                                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't decode request", "details": err.Error()})
2✔
453
                                return
2✔
454
                        }
2✔
455
                }
456

457
                err := updFn(req.Msg)
5✔
458
                if err != nil {
7✔
459
                        _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't update samples", "details": err.Error()})
2✔
460
                        return
2✔
461
                }
2✔
462

463
                if isHtmxRequest {
3✔
464
                        s.renderSamples(w, "samples_list")
×
465
                } else {
3✔
466
                        rest.RenderJSON(w, rest.JSON{"updated": true, "msg": req.Msg})
3✔
467
                }
3✔
468
        }
469
}
470

471
// deleteSampleHandler handles DELETE /samples request. It deletes dynamic samples both for spam and ham.
472
func (s *Server) deleteSampleHandler(delFn func(msg string) error) func(w http.ResponseWriter, r *http.Request) {
13✔
473
        return func(w http.ResponseWriter, r *http.Request) {
18✔
474
                var req struct {
5✔
475
                        Msg string `json:"msg"`
5✔
476
                }
5✔
477
                isHtmxRequest := r.Header.Get("HX-Request") == "true"
5✔
478
                if isHtmxRequest {
6✔
479
                        req.Msg = r.FormValue("msg")
1✔
480
                } else {
5✔
481
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
4✔
482
                                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't decode request", "details": err.Error()})
×
483
                                return
×
484
                        }
×
485
                }
486

487
                if err := delFn(req.Msg); err != nil {
6✔
488
                        _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't delete sample", "details": err.Error()})
1✔
489
                        return
1✔
490
                }
1✔
491

492
                if isHtmxRequest {
5✔
493
                        s.renderSamples(w, "samples_list")
1✔
494
                } else {
4✔
495
                        rest.RenderJSON(w, rest.JSON{"deleted": true, "msg": req.Msg, "count": 1})
3✔
496
                }
3✔
497
        }
498
}
499

500
// reloadDynamicSamplesHandler handles PUT /samples request. It reloads dynamic samples from db storage.
501
func (s *Server) reloadDynamicSamplesHandler(w http.ResponseWriter, _ *http.Request) {
2✔
502
        if err := s.SpamFilter.ReloadSamples(); err != nil {
3✔
503
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't reload samples", "details": err.Error()})
1✔
504
                return
1✔
505
        }
1✔
506
        rest.RenderJSON(w, rest.JSON{"reloaded": true})
1✔
507
}
508

509
// updateApprovedUsersHandler handles POST /users/add and /users/delete requests, it adds or removes users from approved list.
510
func (s *Server) updateApprovedUsersHandler(updFn func(ui approved.UserInfo) error) func(w http.ResponseWriter, r *http.Request) {
15✔
511
        return func(w http.ResponseWriter, r *http.Request) {
25✔
512
                req := approved.UserInfo{}
10✔
513
                isHtmxRequest := r.Header.Get("HX-Request") == "true"
10✔
514
                if isHtmxRequest {
11✔
515
                        req.UserID = r.FormValue("user_id")
1✔
516
                        req.UserName = r.FormValue("user_name")
1✔
517
                } else {
10✔
518
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
10✔
519
                                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
520
                                return
1✔
521
                        }
1✔
522
                }
523

524
                // try to get userID from request and fallback to userName lookup if it's empty
525
                if req.UserID == "" {
14✔
526
                        req.UserID = strconv.FormatInt(s.Locator.UserIDByName(r.Context(), req.UserName), 10)
5✔
527
                }
5✔
528

529
                if req.UserID == "" || req.UserID == "0" {
11✔
530
                        if isHtmxRequest {
2✔
531
                                w.Header().Set("HX-Retarget", "#error-message")
×
532
                                fmt.Fprintln(w, "<div class='alert alert-danger'>Either userid or valid username required.</div>")
×
533
                                return
×
534
                        }
×
535
                        _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "user ID is required"})
2✔
536
                        return
2✔
537
                }
538

539
                // add or remove user from the approved list of detector
540
                if err := updFn(req); err != nil {
7✔
541
                        _ = rest.EncodeJSON(w, http.StatusInternalServerError,
×
542
                                rest.JSON{"error": "can't update approved users", "details": err.Error()})
×
543
                        return
×
544
                }
×
545

546
                if isHtmxRequest {
8✔
547
                        users := s.Detector.ApprovedUsers()
1✔
548
                        tmplData := struct {
1✔
549
                                ApprovedUsers      []approved.UserInfo
1✔
550
                                TotalApprovedUsers int
1✔
551
                        }{
1✔
552
                                ApprovedUsers:      users,
1✔
553
                                TotalApprovedUsers: len(users),
1✔
554
                        }
1✔
555

1✔
556
                        if err := tmpl.ExecuteTemplate(w, "users_list", tmplData); err != nil {
1✔
557
                                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
558
                                return
×
559
                        }
×
560

561
                } else {
6✔
562
                        rest.RenderJSON(w, rest.JSON{"updated": true, "user_id": req.UserID, "user_name": req.UserName})
6✔
563
                }
6✔
564
        }
565
}
566

567
// removeApprovedUser is adopter for updateApprovedUsersHandler updFn
568
func (s *Server) removeApprovedUser(req approved.UserInfo) error {
2✔
569
        if err := s.Detector.RemoveApprovedUser(req.UserID); err != nil {
2✔
570
                return fmt.Errorf("failed to remove approved user %s: %w", req.UserID, err)
×
571
        }
×
572
        return nil
2✔
573
}
574

575
// getApprovedUsersHandler handles GET /users request. It returns list of approved users.
576
func (s *Server) getApprovedUsersHandler(w http.ResponseWriter, _ *http.Request) {
1✔
577
        rest.RenderJSON(w, rest.JSON{"user_ids": s.Detector.ApprovedUsers()})
1✔
578
}
1✔
579

580
// getSettingsHandler returns application settings, including the list of available Lua plugins
581
func (s *Server) getSettingsHandler(w http.ResponseWriter, _ *http.Request) {
3✔
582
        // get the list of available Lua plugins before returning settings
3✔
583
        s.Settings.LuaAvailablePlugins = s.Detector.GetLuaPluginNames()
3✔
584
        rest.RenderJSON(w, s.Settings)
3✔
585
}
3✔
586

587
// getDictionaryEntriesHandler handles GET /dictionary request. It returns stop phrases and ignored words.
588
func (s *Server) getDictionaryEntriesHandler(w http.ResponseWriter, r *http.Request) {
3✔
589
        stopPhrases, err := s.Dictionary.Read(r.Context(), storage.DictionaryTypeStopPhrase)
3✔
590
        if err != nil {
4✔
591
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't get stop phrases", "details": err.Error()})
1✔
592
                return
1✔
593
        }
1✔
594

595
        ignoredWords, err := s.Dictionary.Read(r.Context(), storage.DictionaryTypeIgnoredWord)
2✔
596
        if err != nil {
3✔
597
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't get ignored words", "details": err.Error()})
1✔
598
                return
1✔
599
        }
1✔
600

601
        rest.RenderJSON(w, rest.JSON{"stop_phrases": stopPhrases, "ignored_words": ignoredWords})
1✔
602
}
603

604
// addDictionaryEntryHandler handles POST /dictionary/add request. It adds a stop phrase or ignored word.
605
func (s *Server) addDictionaryEntryHandler(w http.ResponseWriter, r *http.Request) {
11✔
606
        var req struct {
11✔
607
                Type string `json:"type"`
11✔
608
                Data string `json:"data"`
11✔
609
        }
11✔
610

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

11✔
613
        if isHtmxRequest {
15✔
614
                req.Type = r.FormValue("type")
4✔
615
                req.Data = r.FormValue("data")
4✔
616
        } else {
11✔
617
                if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
8✔
618
                        _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
619
                        return
1✔
620
                }
1✔
621
        }
622

623
        if req.Data == "" {
13✔
624
                if isHtmxRequest {
4✔
625
                        w.Header().Set("HX-Retarget", "#error-message")
1✔
626
                        fmt.Fprintln(w, "<div class='alert alert-danger'>Data cannot be empty.</div>")
1✔
627
                        return
1✔
628
                }
1✔
629
                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "data cannot be empty"})
2✔
630
                return
2✔
631
        }
632

633
        dictType := storage.DictionaryType(req.Type)
7✔
634
        if err := dictType.Validate(); err != nil {
9✔
635
                if isHtmxRequest {
3✔
636
                        w.Header().Set("HX-Retarget", "#error-message")
1✔
637
                        fmt.Fprintf(w, "<div class='alert alert-danger'>Invalid type: %v</div>", err)
1✔
638
                        return
1✔
639
                }
1✔
640
                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "invalid type", "details": err.Error()})
1✔
641
                return
1✔
642
        }
643

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

649
        // reload samples to apply dictionary changes immediately
650
        if err := s.SpamFilter.ReloadSamples(); err != nil {
6✔
651
                log.Printf("[WARN] failed to reload samples after dictionary add: %v", err)
2✔
652
                if !isHtmxRequest {
3✔
653
                        _ = rest.EncodeJSON(w, http.StatusInternalServerError,
1✔
654
                                rest.JSON{"error": "entry added but reload failed", "details": err.Error()})
1✔
655
                        return
1✔
656
                }
1✔
657
                // for HTMX, log but continue rendering (entry was added successfully)
658
        }
659

660
        if isHtmxRequest {
5✔
661
                s.renderDictionary(r.Context(), w, "dictionary_list")
2✔
662
        } else {
3✔
663
                rest.RenderJSON(w, rest.JSON{"added": true, "type": req.Type, "data": req.Data})
1✔
664
        }
1✔
665
}
666

667
// deleteDictionaryEntryHandler handles POST /dictionary/delete request. It deletes an entry by data.
668
func (s *Server) deleteDictionaryEntryHandler(w http.ResponseWriter, r *http.Request) {
7✔
669
        var req struct {
7✔
670
                ID int64 `json:"id"`
7✔
671
        }
7✔
672

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

7✔
675
        if isHtmxRequest {
10✔
676
                idStr := r.FormValue("id")
3✔
677
                var err error
3✔
678
                req.ID, err = strconv.ParseInt(idStr, 10, 64)
3✔
679
                if err != nil {
4✔
680
                        w.Header().Set("HX-Retarget", "#error-message")
1✔
681
                        fmt.Fprintf(w, "<div class='alert alert-danger'>Invalid ID: %v</div>", err)
1✔
682
                        return
1✔
683
                }
1✔
684
        } else {
4✔
685
                if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
5✔
686
                        _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
687
                        return
1✔
688
                }
1✔
689
        }
690

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

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

707
        if isHtmxRequest {
5✔
708
                s.renderDictionary(r.Context(), w, "dictionary_list")
2✔
709
        } else {
3✔
710
                rest.RenderJSON(w, rest.JSON{"deleted": true, "id": req.ID})
1✔
711
        }
1✔
712
}
713

714
// htmlSpamCheckHandler handles GET / request.
715
// It returns rendered spam_check.html template with all the components.
716
func (s *Server) htmlSpamCheckHandler(w http.ResponseWriter, _ *http.Request) {
3✔
717
        tmplData := struct {
3✔
718
                Version string
3✔
719
        }{
3✔
720
                Version: s.Version,
3✔
721
        }
3✔
722

3✔
723
        if err := tmpl.ExecuteTemplate(w, "spam_check.html", tmplData); err != nil {
4✔
724
                log.Printf("[WARN] can't execute template: %v", err)
1✔
725
                http.Error(w, "Error executing template", http.StatusInternalServerError)
1✔
726
                return
1✔
727
        }
1✔
728
}
729

730
// htmlManageSamplesHandler handles GET /manage_samples request.
731
// It returns rendered manage_samples.html template with all the components.
732
func (s *Server) htmlManageSamplesHandler(w http.ResponseWriter, _ *http.Request) {
1✔
733
        s.renderSamples(w, "manage_samples.html")
1✔
734
}
1✔
735

736
func (s *Server) htmlManageUsersHandler(w http.ResponseWriter, _ *http.Request) {
3✔
737
        users := s.Detector.ApprovedUsers()
3✔
738
        tmplData := struct {
3✔
739
                ApprovedUsers      []approved.UserInfo
3✔
740
                TotalApprovedUsers int
3✔
741
        }{
3✔
742
                ApprovedUsers:      users,
3✔
743
                TotalApprovedUsers: len(users),
3✔
744
        }
3✔
745
        tmplData.TotalApprovedUsers = len(tmplData.ApprovedUsers)
3✔
746

3✔
747
        if err := tmpl.ExecuteTemplate(w, "manage_users.html", tmplData); err != nil {
4✔
748
                log.Printf("[WARN] can't execute template: %v", err)
1✔
749
                http.Error(w, "Error executing template", http.StatusInternalServerError)
1✔
750
                return
1✔
751
        }
1✔
752
}
753

754
func (s *Server) htmlManageDictionaryHandler(w http.ResponseWriter, r *http.Request) {
×
755
        s.renderDictionary(r.Context(), w, "manage_dictionary.html")
×
756
}
×
757

758
func (s *Server) htmlDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
2✔
759
        ds, err := s.DetectedSpam.Read(r.Context())
2✔
760
        if err != nil {
3✔
761
                log.Printf("[ERROR] Failed to fetch detected spam: %v", err)
1✔
762
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
1✔
763
                return
1✔
764
        }
1✔
765

766
        // clean up detected spam entries
767
        for i, d := range ds {
3✔
768
                d.Text = strings.ReplaceAll(d.Text, "'", " ")
2✔
769
                d.Text = strings.ReplaceAll(d.Text, "\n", " ")
2✔
770
                d.Text = strings.ReplaceAll(d.Text, "\r", " ")
2✔
771
                d.Text = strings.ReplaceAll(d.Text, "\t", " ")
2✔
772
                d.Text = strings.ReplaceAll(d.Text, "\"", " ")
2✔
773
                d.Text = strings.ReplaceAll(d.Text, "\\", " ")
2✔
774
                ds[i] = d
2✔
775
        }
2✔
776

777
        // get filter from query param, default to "all"
778
        filter := r.URL.Query().Get("filter")
1✔
779
        if filter == "" {
2✔
780
                filter = "all"
1✔
781
        }
1✔
782

783
        // apply filtering
784
        var filteredDS []storage.DetectedSpamInfo
1✔
785
        switch filter {
1✔
786
        case "non-classified":
×
787
                for _, entry := range ds {
×
788
                        hasClassifierHam := false
×
789
                        for _, check := range entry.Checks {
×
790
                                if check.Name == "classifier" && !check.Spam {
×
791
                                        hasClassifierHam = true
×
792
                                        break
×
793
                                }
794
                        }
795
                        if hasClassifierHam {
×
796
                                filteredDS = append(filteredDS, entry)
×
797
                        }
×
798
                }
799
        case "openai":
×
800
                for _, entry := range ds {
×
801
                        hasOpenAI := false
×
802
                        for _, check := range entry.Checks {
×
803
                                if check.Name == "openai" {
×
804
                                        hasOpenAI = true
×
805
                                        break
×
806
                                }
807
                        }
808
                        if hasOpenAI {
×
809
                                filteredDS = append(filteredDS, entry)
×
810
                        }
×
811
                }
812
        case "gemini":
×
813
                for _, entry := range ds {
×
814
                        hasGemini := false
×
815
                        for _, check := range entry.Checks {
×
816
                                if check.Name == "gemini" {
×
817
                                        hasGemini = true
×
818
                                        break
×
819
                                }
820
                        }
821
                        if hasGemini {
×
822
                                filteredDS = append(filteredDS, entry)
×
823
                        }
×
824
                }
825
        default: // "all" or any other value
1✔
826
                filteredDS = ds
1✔
827
        }
828

829
        tmplData := struct {
1✔
830
                DetectedSpamEntries []storage.DetectedSpamInfo
1✔
831
                TotalDetectedSpam   int
1✔
832
                FilteredCount       int
1✔
833
                Filter              string
1✔
834
                OpenAIEnabled       bool
1✔
835
                GeminiEnabled       bool
1✔
836
        }{
1✔
837
                DetectedSpamEntries: filteredDS,
1✔
838
                TotalDetectedSpam:   len(ds),
1✔
839
                FilteredCount:       len(filteredDS),
1✔
840
                Filter:              filter,
1✔
841
                OpenAIEnabled:       s.Settings.OpenAIEnabled,
1✔
842
                GeminiEnabled:       s.Settings.GeminiEnabled,
1✔
843
        }
1✔
844

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

×
849
                // first render the content template
×
850
                if err := tmpl.ExecuteTemplate(&buf, "detected_spam_content", tmplData); err != nil {
×
851
                        log.Printf("[WARN] can't execute content template: %v", err)
×
852
                        http.Error(w, "Error executing template", http.StatusInternalServerError)
×
853
                        return
×
854
                }
×
855

856
                // then append OOB swap for the count display
857
                countHTML := ""
×
858
                if filter != "all" {
×
859
                        countHTML = fmt.Sprintf("(%d/%d)", len(filteredDS), len(ds))
×
860
                } else {
×
861
                        countHTML = fmt.Sprintf("(%d)", len(ds))
×
862
                }
×
863

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

×
866
                // write the combined response
×
867
                if _, err := buf.WriteTo(w); err != nil {
×
868
                        log.Printf("[WARN] failed to write response: %v", err)
×
869
                }
×
870
                return
×
871
        }
872

873
        // full page render for normal requests
874
        if err := tmpl.ExecuteTemplate(w, "detected_spam.html", tmplData); err != nil {
1✔
875
                log.Printf("[WARN] can't execute template: %v", err)
×
876
                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
877
                return
×
878
        }
×
879
}
880

881
func (s *Server) htmlAddDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
5✔
882
        reportErr := func(err error, _ int) {
9✔
883
                w.Header().Set("HX-Retarget", "#error-message")
4✔
884
                fmt.Fprintf(w, "<div class='alert alert-danger'>%s</div>", err)
4✔
885
        }
4✔
886
        msg := r.FormValue("msg")
5✔
887

5✔
888
        id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
5✔
889
        if err != nil || msg == "" {
7✔
890
                log.Printf("[WARN] bad request: %v", err)
2✔
891
                reportErr(fmt.Errorf("bad request: %v", err), http.StatusBadRequest)
2✔
892
                return
2✔
893
        }
2✔
894

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

1✔
900
        }
1✔
901
        if err := s.DetectedSpam.SetAddedToSamplesFlag(r.Context(), id); err != nil {
3✔
902
                log.Printf("[WARN] failed to update detected spam: %v", err)
1✔
903
                reportErr(fmt.Errorf("can't update detected spam: %v", err), http.StatusInternalServerError)
1✔
904
                return
1✔
905
        }
1✔
906
        w.WriteHeader(http.StatusOK)
1✔
907
}
908

909
func (s *Server) htmlSettingsHandler(w http.ResponseWriter, _ *http.Request) {
5✔
910
        // get database information if StorageEngine is available
5✔
911
        var dbInfo struct {
5✔
912
                DatabaseType   string `json:"database_type"`
5✔
913
                GID            string `json:"gid"`
5✔
914
                DatabaseStatus string `json:"database_status"`
5✔
915
        }
5✔
916

5✔
917
        if s.StorageEngine != nil {
7✔
918
                // try to cast to SQL engine to get type information
2✔
919
                if sqlEngine, ok := s.StorageEngine.(*engine.SQL); ok {
2✔
920
                        dbInfo.DatabaseType = string(sqlEngine.Type())
×
921
                        dbInfo.GID = sqlEngine.GID()
×
922
                        dbInfo.DatabaseStatus = "Connected"
×
923
                } else {
2✔
924
                        dbInfo.DatabaseType = "Unknown"
2✔
925
                        dbInfo.DatabaseStatus = "Connected (unknown type)"
2✔
926
                }
2✔
927
        } else {
3✔
928
                dbInfo.DatabaseStatus = "Not connected"
3✔
929
        }
3✔
930

931
        // get backup information
932
        backupURL := "/download/backup"
5✔
933
        backupFilename := fmt.Sprintf("tg-spam-backup-%s-%s.sql.gz", dbInfo.DatabaseType, time.Now().Format("20060102-150405"))
5✔
934

5✔
935
        // get system info - uptime since server start
5✔
936
        uptime := time.Since(startTime)
5✔
937

5✔
938
        // get the list of available Lua plugins
5✔
939
        s.Settings.LuaAvailablePlugins = s.Detector.GetLuaPluginNames()
5✔
940

5✔
941
        data := struct {
5✔
942
                Settings
5✔
943
                Version  string
5✔
944
                Database struct {
5✔
945
                        Type   string
5✔
946
                        GID    string
5✔
947
                        Status string
5✔
948
                }
5✔
949
                Backup struct {
5✔
950
                        URL      string
5✔
951
                        Filename string
5✔
952
                }
5✔
953
                System struct {
5✔
954
                        Uptime string
5✔
955
                }
5✔
956
        }{
5✔
957
                Settings: s.Settings,
5✔
958
                Version:  s.Version,
5✔
959
                Database: struct {
5✔
960
                        Type   string
5✔
961
                        GID    string
5✔
962
                        Status string
5✔
963
                }{
5✔
964
                        Type:   dbInfo.DatabaseType,
5✔
965
                        GID:    dbInfo.GID,
5✔
966
                        Status: dbInfo.DatabaseStatus,
5✔
967
                },
5✔
968
                Backup: struct {
5✔
969
                        URL      string
5✔
970
                        Filename string
5✔
971
                }{
5✔
972
                        URL:      backupURL,
5✔
973
                        Filename: backupFilename,
5✔
974
                },
5✔
975
                System: struct {
5✔
976
                        Uptime string
5✔
977
                }{
5✔
978
                        Uptime: formatDuration(uptime),
5✔
979
                },
5✔
980
        }
5✔
981

5✔
982
        if err := tmpl.ExecuteTemplate(w, "settings.html", data); err != nil {
6✔
983
                log.Printf("[WARN] can't execute template: %v", err)
1✔
984
                http.Error(w, "Error executing template", http.StatusInternalServerError)
1✔
985
                return
1✔
986
        }
1✔
987
}
988

989
// getDMUsersHandler handles GET /dm-users. For HTMX requests it renders the dm_users.html partial,
990
// for API requests it returns JSON with the list of recent DM users.
991
func (s *Server) getDMUsersHandler(w http.ResponseWriter, r *http.Request) {
6✔
992
        if s.DMUsersProvider == nil {
7✔
993
                http.Error(w, "DM users provider not configured", http.StatusServiceUnavailable)
1✔
994
                return
1✔
995
        }
1✔
996

997
        users := s.DMUsersProvider.GetDMUsers()
5✔
998

5✔
999
        if r.Header.Get("HX-Request") != "true" {
7✔
1000
                // api response — return raw timestamps, no relative time
2✔
1001
                type dmUserJSON struct {
2✔
1002
                        UserID      int64     `json:"user_id"`
2✔
1003
                        UserName    string    `json:"user_name"`
2✔
1004
                        DisplayName string    `json:"display_name"`
2✔
1005
                        Timestamp   time.Time `json:"timestamp"`
2✔
1006
                }
2✔
1007
                result := make([]dmUserJSON, len(users))
2✔
1008
                for i, u := range users {
4✔
1009
                        result[i] = dmUserJSON{
2✔
1010
                                UserID:      u.UserID,
2✔
1011
                                UserName:    u.UserName,
2✔
1012
                                DisplayName: u.DisplayName,
2✔
1013
                                Timestamp:   u.Timestamp,
2✔
1014
                        }
2✔
1015
                }
2✔
1016
                rest.RenderJSON(w, result)
2✔
1017
                return
2✔
1018
        }
1019

1020
        // htmx response — render partial template with relative timestamps
1021
        type dmUserView struct {
3✔
1022
                UserID      int64
3✔
1023
                UserName    string
3✔
1024
                DisplayName string
3✔
1025
                When        string
3✔
1026
        }
3✔
1027
        viewUsers := make([]dmUserView, len(users))
3✔
1028
        for i, u := range users {
7✔
1029
                viewUsers[i] = dmUserView{
4✔
1030
                        UserID:      u.UserID,
4✔
1031
                        UserName:    u.UserName,
4✔
1032
                        DisplayName: u.DisplayName,
4✔
1033
                        When:        relativeTime(u.Timestamp),
4✔
1034
                }
4✔
1035
        }
4✔
1036

1037
        data := struct {
3✔
1038
                Users []dmUserView
3✔
1039
        }{Users: viewUsers}
3✔
1040

3✔
1041
        if err := tmpl.ExecuteTemplate(w, "dm_users.html", data); err != nil {
3✔
NEW
1042
                log.Printf("[WARN] can't execute dm_users template: %v", err)
×
NEW
1043
                http.Error(w, "Error rendering template", http.StatusInternalServerError)
×
NEW
1044
                return
×
NEW
1045
        }
×
1046
}
1047

1048
// relativeTime formats a timestamp as a human-readable relative time string.
1049
// accepts an optional reference time; if omitted, uses time.Now().
1050
func relativeTime(t time.Time, now ...time.Time) string {
11✔
1051
        ref := time.Now()
11✔
1052
        if len(now) > 0 {
18✔
1053
                ref = now[0]
7✔
1054
        }
7✔
1055
        d := ref.Sub(t)
11✔
1056
        switch {
11✔
1057
        case d < time.Minute:
1✔
1058
                return "just now"
1✔
1059
        case d < time.Hour:
4✔
1060
                return fmt.Sprintf("%dm ago", int(d.Minutes()))
4✔
1061
        case d < 24*time.Hour:
4✔
1062
                return fmt.Sprintf("%dh ago", int(d.Hours()))
4✔
1063
        default:
2✔
1064
                return fmt.Sprintf("%dd ago", int(d.Hours()/24))
2✔
1065
        }
1066
}
1067

1068
// formatDuration formats a duration in a human-readable way
1069
func formatDuration(d time.Duration) string {
13✔
1070
        days := int(d.Hours() / 24)
13✔
1071
        hours := int(d.Hours()) % 24
13✔
1072
        minutes := int(d.Minutes()) % 60
13✔
1073

13✔
1074
        if days > 0 {
16✔
1075
                return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
3✔
1076
        }
3✔
1077

1078
        if hours > 0 {
12✔
1079
                return fmt.Sprintf("%dh %dm", hours, minutes)
2✔
1080
        }
2✔
1081

1082
        return fmt.Sprintf("%dm", minutes)
8✔
1083
}
1084

1085
func (s *Server) downloadDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
3✔
1086
        ctx := r.Context()
3✔
1087
        spam, err := s.DetectedSpam.Read(ctx)
3✔
1088
        if err != nil {
4✔
1089
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't get detected spam", "details": err.Error()})
1✔
1090
                return
1✔
1091
        }
1✔
1092

1093
        type jsonSpamInfo struct {
2✔
1094
                ID        int64                `json:"id"`
2✔
1095
                GID       string               `json:"gid"`
2✔
1096
                Text      string               `json:"text"`
2✔
1097
                UserID    int64                `json:"user_id"`
2✔
1098
                UserName  string               `json:"user_name"`
2✔
1099
                Timestamp time.Time            `json:"timestamp"`
2✔
1100
                Added     bool                 `json:"added"`
2✔
1101
                Checks    []spamcheck.Response `json:"checks"`
2✔
1102
        }
2✔
1103

2✔
1104
        // convert entries to jsonl format with lowercase fields
2✔
1105
        lines := make([]string, 0, len(spam))
2✔
1106
        for _, entry := range spam {
5✔
1107
                data, err := json.Marshal(jsonSpamInfo{
3✔
1108
                        ID:        entry.ID,
3✔
1109
                        GID:       entry.GID,
3✔
1110
                        Text:      entry.Text,
3✔
1111
                        UserID:    entry.UserID,
3✔
1112
                        UserName:  entry.UserName,
3✔
1113
                        Timestamp: entry.Timestamp,
3✔
1114
                        Added:     entry.Added,
3✔
1115
                        Checks:    entry.Checks,
3✔
1116
                })
3✔
1117
                if err != nil {
3✔
1118
                        _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't marshal entry", "details": err.Error()})
×
1119
                        return
×
1120
                }
×
1121
                lines = append(lines, string(data))
3✔
1122
        }
1123

1124
        body := strings.Join(lines, "\n")
2✔
1125
        w.Header().Set("Content-Type", "application/x-jsonlines")
2✔
1126
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "detected_spam.jsonl"))
2✔
1127
        w.Header().Set("Content-Length", strconv.Itoa(len(body)))
2✔
1128
        w.WriteHeader(http.StatusOK)
2✔
1129
        _, _ = w.Write([]byte(body))
2✔
1130
}
1131

1132
// downloadBackupHandler streams a database backup as an SQL file with gzip compression
1133
// Files are always compressed and always have .gz extension to ensure consistency
1134
func (s *Server) downloadBackupHandler(w http.ResponseWriter, r *http.Request) {
2✔
1135
        if s.StorageEngine == nil {
3✔
1136
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "storage engine not available"})
1✔
1137
                return
1✔
1138
        }
1✔
1139

1140
        // set filename based on database type and timestamp
1141
        dbType := "db"
1✔
1142
        sqlEng, ok := s.StorageEngine.(*engine.SQL)
1✔
1143
        if ok {
1✔
1144
                dbType = string(sqlEng.Type())
×
1145
        }
×
1146
        timestamp := time.Now().Format("20060102-150405")
1✔
1147

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

1✔
1151
        // set headers for file download - note we're using application/octet-stream
1✔
1152
        // instead of application/sql to prevent browsers from trying to interpret the file
1✔
1153
        w.Header().Set("Content-Type", "application/octet-stream")
1✔
1154
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
1✔
1155
        w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
1✔
1156
        w.Header().Set("Pragma", "no-cache")
1✔
1157
        w.Header().Set("Expires", "0")
1✔
1158

1✔
1159
        // create a gzip writer that streams to response
1✔
1160
        gzipWriter := gzip.NewWriter(w)
1✔
1161
        defer func() {
2✔
1162
                if err := gzipWriter.Close(); err != nil {
1✔
1163
                        log.Printf("[ERROR] failed to close gzip writer: %v", err)
×
1164
                }
×
1165
        }()
1166

1167
        // stream backup directly to response through gzip
1168
        if err := s.StorageEngine.Backup(r.Context(), gzipWriter); err != nil {
1✔
1169
                log.Printf("[ERROR] failed to create backup: %v", err)
×
1170
                // we've already started writing the response, so we can't send a proper error response
×
1171
                return
×
1172
        }
×
1173

1174
        // flush the gzip writer to ensure all data is written
1175
        if err := gzipWriter.Flush(); err != nil {
1✔
1176
                log.Printf("[ERROR] failed to flush gzip writer: %v", err)
×
1177
        }
×
1178
}
1179

1180
// downloadExportToPostgresHandler streams a PostgreSQL-compatible export from a SQLite database
1181
func (s *Server) downloadExportToPostgresHandler(w http.ResponseWriter, r *http.Request) {
3✔
1182
        if s.StorageEngine == nil {
4✔
1183
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "storage engine not available"})
1✔
1184
                return
1✔
1185
        }
1✔
1186

1187
        // check if the database is SQLite
1188
        if s.StorageEngine.Type() != engine.Sqlite {
3✔
1189
                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "source database must be SQLite"})
1✔
1190
                return
1✔
1191
        }
1✔
1192

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

1✔
1197
        // set headers for file download
1✔
1198
        w.Header().Set("Content-Type", "application/octet-stream")
1✔
1199
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
1✔
1200
        w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
1✔
1201
        w.Header().Set("Pragma", "no-cache")
1✔
1202
        w.Header().Set("Expires", "0")
1✔
1203

1✔
1204
        // create a gzip writer that streams to response
1✔
1205
        gzipWriter := gzip.NewWriter(w)
1✔
1206
        defer func() {
2✔
1207
                if err := gzipWriter.Close(); err != nil {
1✔
1208
                        log.Printf("[ERROR] failed to close gzip writer: %v", err)
×
1209
                }
×
1210
        }()
1211

1212
        // stream export directly to response through gzip
1213
        if err := s.StorageEngine.BackupSqliteAsPostgres(r.Context(), gzipWriter); err != nil {
1✔
1214
                log.Printf("[ERROR] failed to create export: %v", err)
×
1215
                // we've already started writing the response, so we can't send a proper error response
×
1216
                return
×
1217
        }
×
1218

1219
        // flush the gzip writer to ensure all data is written
1220
        if err := gzipWriter.Flush(); err != nil {
1✔
1221
                log.Printf("[ERROR] failed to flush gzip writer: %v", err)
×
1222
        }
×
1223
}
1224

1225
func (s *Server) renderSamples(w http.ResponseWriter, tmplName string) {
6✔
1226
        spam, ham, err := s.SpamFilter.DynamicSamples()
6✔
1227
        if err != nil {
7✔
1228
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't fetch samples", "details": err.Error()})
1✔
1229
                return
1✔
1230
        }
1✔
1231

1232
        spam, ham = s.reverseSamples(spam, ham)
5✔
1233

5✔
1234
        type smpleWithID struct {
5✔
1235
                ID     string
5✔
1236
                Sample string
5✔
1237
        }
5✔
1238

5✔
1239
        makeID := func(s string) string {
19✔
1240
                hash := sha1.New() //nolint
14✔
1241
                if _, err := hash.Write([]byte(s)); err != nil {
14✔
1242
                        return fmt.Sprintf("%x", s)
×
1243
                }
×
1244
                return fmt.Sprintf("%x", hash.Sum(nil))
14✔
1245
        }
1246

1247
        tmplData := struct {
5✔
1248
                SpamSamples      []smpleWithID
5✔
1249
                HamSamples       []smpleWithID
5✔
1250
                TotalHamSamples  int
5✔
1251
                TotalSpamSamples int
5✔
1252
        }{
5✔
1253
                TotalHamSamples:  len(ham),
5✔
1254
                TotalSpamSamples: len(spam),
5✔
1255
        }
5✔
1256
        for _, s := range spam {
12✔
1257
                tmplData.SpamSamples = append(tmplData.SpamSamples, smpleWithID{ID: makeID(s), Sample: s})
7✔
1258
        }
7✔
1259
        for _, h := range ham {
12✔
1260
                tmplData.HamSamples = append(tmplData.HamSamples, smpleWithID{ID: makeID(h), Sample: h})
7✔
1261
        }
7✔
1262

1263
        if err := tmpl.ExecuteTemplate(w, tmplName, tmplData); err != nil {
6✔
1264
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't execute template", "details": err.Error()})
1✔
1265
                return
1✔
1266
        }
1✔
1267
}
1268

1269
func (s *Server) authMiddleware(mw func(next http.Handler) http.Handler) func(next http.Handler) http.Handler {
10✔
1270
        if s.AuthPasswd == "" {
16✔
1271
                return func(next http.Handler) http.Handler {
96✔
1272
                        return next
90✔
1273
                }
90✔
1274
        }
1275
        return func(next http.Handler) http.Handler {
64✔
1276
                return mw(next)
60✔
1277
        }
60✔
1278
}
1279

1280
// reverseSamples returns reversed lists of spam and ham samples
1281
func (s *Server) reverseSamples(spam, ham []string) (revSpam, revHam []string) {
8✔
1282
        revSpam = make([]string, len(spam))
8✔
1283
        revHam = make([]string, len(ham))
8✔
1284

8✔
1285
        for i, j := 0, len(spam)-1; i < len(spam); i, j = i+1, j-1 {
19✔
1286
                revSpam[i] = spam[j]
11✔
1287
        }
11✔
1288
        for i, j := 0, len(ham)-1; i < len(ham); i, j = i+1, j-1 {
19✔
1289
                revHam[i] = ham[j]
11✔
1290
        }
11✔
1291
        return revSpam, revHam
8✔
1292
}
1293

1294
// renderDictionary renders dictionary entries for HTMX or full page request
1295
func (s *Server) renderDictionary(ctx context.Context, w http.ResponseWriter, tmplName string) {
4✔
1296
        stopPhrases, err := s.Dictionary.ReadWithIDs(ctx, storage.DictionaryTypeStopPhrase)
4✔
1297
        if err != nil {
4✔
1298
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't fetch stop phrases", "details": err.Error()})
×
1299
                return
×
1300
        }
×
1301

1302
        ignoredWords, err := s.Dictionary.ReadWithIDs(ctx, storage.DictionaryTypeIgnoredWord)
4✔
1303
        if err != nil {
4✔
1304
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't fetch ignored words", "details": err.Error()})
×
1305
                return
×
1306
        }
×
1307

1308
        tmplData := struct {
4✔
1309
                StopPhrases       []storage.DictionaryEntry
4✔
1310
                IgnoredWords      []storage.DictionaryEntry
4✔
1311
                TotalStopPhrases  int
4✔
1312
                TotalIgnoredWords int
4✔
1313
        }{
4✔
1314
                StopPhrases:       stopPhrases,
4✔
1315
                IgnoredWords:      ignoredWords,
4✔
1316
                TotalStopPhrases:  len(stopPhrases),
4✔
1317
                TotalIgnoredWords: len(ignoredWords),
4✔
1318
        }
4✔
1319

4✔
1320
        if err := tmpl.ExecuteTemplate(w, tmplName, tmplData); err != nil {
4✔
1321
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't execute template", "details": err.Error()})
×
1322
                return
×
1323
        }
×
1324
}
1325

1326
// staticFS is a filtered filesystem that only exposes specific static files
1327
type staticFS struct {
1328
        fs        fs.FS
1329
        urlToPath map[string]string
1330
}
1331

1332
// staticFileMapping defines a mapping between URL path and filesystem path
1333
type staticFileMapping struct {
1334
        urlPath     string
1335
        filesysPath string
1336
}
1337

1338
func newStaticFS(fsys fs.FS, files ...staticFileMapping) *staticFS {
5✔
1339
        urlToPath := make(map[string]string)
5✔
1340
        for _, f := range files {
20✔
1341
                urlToPath[f.urlPath] = f.filesysPath
15✔
1342
        }
15✔
1343

1344
        return &staticFS{
5✔
1345
                fs:        fsys,
5✔
1346
                urlToPath: urlToPath,
5✔
1347
        }
5✔
1348
}
1349

1350
func (sfs *staticFS) Open(name string) (fs.File, error) {
5✔
1351
        cleanName := path.Clean("/" + name)[1:]
5✔
1352

5✔
1353
        fsPath, ok := sfs.urlToPath[cleanName]
5✔
1354
        if !ok {
7✔
1355
                return nil, fs.ErrNotExist
2✔
1356
        }
2✔
1357

1358
        file, err := sfs.fs.Open(fsPath)
3✔
1359
        if err != nil {
3✔
1360
                return nil, fmt.Errorf("failed to open static file %s: %w", fsPath, err)
×
1361
        }
×
1362
        return file, nil
3✔
1363
}
1364

1365
// GenerateRandomPassword generates a random password of a given length
1366
func GenerateRandomPassword(length int) (string, error) {
2✔
1367
        const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+"
2✔
1368
        const charsetLen = int64(len(charset))
2✔
1369

2✔
1370
        result := make([]byte, length)
2✔
1371
        for i := range length {
66✔
1372
                n, err := rand.Int(rand.Reader, big.NewInt(charsetLen))
64✔
1373
                if err != nil {
64✔
1374
                        return "", fmt.Errorf("failed to generate random number: %w", err)
×
1375
                }
×
1376
                result[i] = charset[n.Int64()]
64✔
1377
        }
1378
        return string(result), nil
2✔
1379
}
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