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

umputun / tg-spam / 19081859314

04 Nov 2025 08:25PM UTC coverage: 81.809% (-0.003%) from 81.812%
19081859314

Pull #331

github

umputun
feat: skip spam checks for anonymous admin posts

Implements automatic exclusion of anonymous admin posts from spam detection while maintaining spam checks for channel forwards and regular users.

Changes:
- Added check in listener.go to skip spam detection when msg.SenderChat.ID == fromChat
- Added comprehensive test coverage with 4 test cases
- Updated README.md to document the behavior

Behavior:
- Anonymous admin posts (when admins post "as the group") skip spam check
- Channel auto-forwards (SenderChat.ID != chat ID) still checked for spam
- Regular user messages (no SenderChat) still checked for spam
- Works uniformly across primary group, testing chats, and all allowed chats

The fix compares msg.SenderChat.ID to fromChat (the actual chat the message came from) rather than l.chatID, ensuring proper handling of anonymous admin posts in testing chats configured via --testing-id.

Related to #330
Pull Request #331: Skip spam checks for anonymous admin posts

5 of 5 new or added lines in 1 file covered. (100.0%)

1 existing line in 1 file now uncovered.

6152 of 7520 relevant lines covered (81.81%)

273.6 hits per line

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

84.56
/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/storage"
31
        "github.com/umputun/tg-spam/app/storage/engine"
32
        "github.com/umputun/tg-spam/lib/approved"
33
        "github.com/umputun/tg-spam/lib/spamcheck"
34
)
35

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

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

47
// startTime tracks when the server started
48
var startTime = time.Now()
49

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

55
// Config defines  server parameters
56
type Config struct {
57
        Version       string        // version to show in /ping
58
        ListenAddr    string        // listen address
59
        Detector      Detector      // spam detector
60
        SpamFilter    SpamFilter    // spam filter (bot)
61
        DetectedSpam  DetectedSpam  // detected spam accessor
62
        Locator       Locator       // locator for user info
63
        Dictionary    Dictionary    // dictionary for stop phrases and ignored words
64
        StorageEngine StorageEngine // database engine access for backups
65
        AuthPasswd    string        // basic auth password for user "tg-spam"
66
        AuthHash      string        // basic auth hash for user "tg-spam". If both AuthPasswd and AuthHash are provided, AuthHash is used
67
        Dbg           bool          // debug mode
68
        Settings      Settings      // application settings
69
}
70

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

123
// Detector is a spam detector interface.
124
type Detector interface {
125
        Check(req spamcheck.Request) (spam bool, cr []spamcheck.Response)
126
        ApprovedUsers() []approved.UserInfo
127
        AddApprovedUser(user approved.UserInfo) error
128
        RemoveApprovedUser(id string) error
129
        GetLuaPluginNames() []string // Returns the list of available Lua plugin names
130
}
131

132
// SpamFilter is a spam filter, bot interface.
133
type SpamFilter interface {
134
        UpdateSpam(msg string) error
135
        UpdateHam(msg string) error
136
        ReloadSamples() (err error)
137
        DynamicSamples() (spam, ham []string, err error)
138
        RemoveDynamicSpamSample(sample string) error
139
        RemoveDynamicHamSample(sample string) error
140
}
141

142
// Locator is a storage interface used to get user id by name and vice versa.
143
type Locator interface {
144
        UserIDByName(ctx context.Context, userName string) int64
145
        UserNameByID(ctx context.Context, userID int64) string
146
}
147

148
// DetectedSpam is a storage interface used to get detected spam messages and set added flag.
149
type DetectedSpam interface {
150
        Read(ctx context.Context) ([]storage.DetectedSpamInfo, error)
151
        SetAddedToSamplesFlag(ctx context.Context, id int64) error
152
        FindByUserID(ctx context.Context, userID int64) (*storage.DetectedSpamInfo, error)
153
}
154

155
// StorageEngine provides access to the database engine for operations like backup
156
type StorageEngine interface {
157
        Backup(ctx context.Context, w io.Writer) error
158
        Type() engine.Type
159
        BackupSqliteAsPostgres(ctx context.Context, w io.Writer) error
160
}
161

162
// Dictionary is a storage interface for managing stop phrases and ignored words
163
type Dictionary interface {
164
        Add(ctx context.Context, t storage.DictionaryType, data string) error
165
        Delete(ctx context.Context, id int64) error
166
        Read(ctx context.Context, t storage.DictionaryType) ([]string, error)
167
        ReadWithIDs(ctx context.Context, t storage.DictionaryType) ([]storage.DictionaryEntry, error)
168
        Stats(ctx context.Context) (*storage.DictionaryStats, error)
169
}
170

171
// NewServer creates a new web API server.
172
func NewServer(config Config) *Server {
66✔
173
        return &Server{Config: config}
66✔
174
}
66✔
175

176
// Run starts server and accepts requests checking for spam messages.
177
func (s *Server) Run(ctx context.Context) error {
3✔
178
        router := routegroup.New(http.NewServeMux())
3✔
179
        router.Use(rest.Recoverer(log.Default()))
3✔
180
        router.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]")).Handler)
3✔
181
        router.Use(rest.Throttle(1000))
3✔
182
        router.Use(rest.AppInfo("tg-spam", "umputun", s.Version), rest.Ping)
3✔
183
        router.Use(tollbooth.HTTPMiddleware(tollbooth.NewLimiter(50, nil)))
3✔
184
        router.Use(rest.SizeLimit(1024 * 1024)) // 1M max request size
3✔
185

3✔
186
        if s.AuthPasswd != "" || s.AuthHash != "" {
6✔
187
                log.Printf("[INFO] basic auth enabled for webapi server")
3✔
188
                if s.AuthHash != "" {
4✔
189
                        router.Use(rest.BasicAuthWithBcryptHashAndPrompt("tg-spam", s.AuthHash))
1✔
190
                } else {
3✔
191
                        router.Use(rest.BasicAuthWithPrompt("tg-spam", s.AuthPasswd))
2✔
192
                }
2✔
193
        } else {
×
194
                log.Printf("[WARN] basic auth disabled, access to webapi is not protected")
×
195
        }
×
196

197
        router = s.routes(router) // setup routes
3✔
198

3✔
199
        srv := &http.Server{Addr: s.ListenAddr, Handler: router, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second}
3✔
200
        go func() {
6✔
201
                <-ctx.Done()
3✔
202
                if err := srv.Shutdown(ctx); err != nil {
3✔
UNCOV
203
                        log.Printf("[WARN] failed to shutdown webapi server: %v", err)
×
204
                } else {
3✔
205
                        log.Printf("[INFO] webapi server stopped")
3✔
206
                }
3✔
207
        }()
208

209
        log.Printf("[INFO] start webapi server on %s", s.ListenAddr)
3✔
210
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
3✔
211
                return fmt.Errorf("failed to run server: %w", err)
×
212
        }
×
213
        return nil
3✔
214
}
215

216
func (s *Server) routes(router *routegroup.Bundle) *routegroup.Bundle {
5✔
217
        // auth api routes
5✔
218
        router.Group().Route(func(authApi *routegroup.Bundle) {
10✔
219
                authApi.Use(s.authMiddleware(rest.BasicAuthWithUserPasswd("tg-spam", s.AuthPasswd)))
5✔
220
                authApi.HandleFunc("POST /check", s.checkMsgHandler)         // check a message for spam
5✔
221
                authApi.HandleFunc("GET /check/{user_id}", s.checkIDHandler) // check user id for spam
5✔
222

5✔
223
                authApi.Mount("/update").Route(func(r *routegroup.Bundle) {
10✔
224
                        // update spam/ham samples
5✔
225
                        r.HandleFunc("POST /spam", s.updateSampleHandler(s.SpamFilter.UpdateSpam)) // update spam samples
5✔
226
                        r.HandleFunc("POST /ham", s.updateSampleHandler(s.SpamFilter.UpdateHam))   // update ham samples
5✔
227
                })
5✔
228

229
                authApi.Mount("/delete").Route(func(r *routegroup.Bundle) {
10✔
230
                        // delete spam/ham samples
5✔
231
                        r.HandleFunc("POST /spam", s.deleteSampleHandler(s.SpamFilter.RemoveDynamicSpamSample))
5✔
232
                        r.HandleFunc("POST /ham", s.deleteSampleHandler(s.SpamFilter.RemoveDynamicHamSample))
5✔
233
                })
5✔
234

235
                authApi.Mount("/download").Route(func(r *routegroup.Bundle) {
10✔
236
                        r.HandleFunc("GET /spam", s.downloadSampleHandler(func(spam, _ []string) ([]string, string) {
5✔
237
                                return spam, "spam.txt"
×
238
                        }))
×
239
                        r.HandleFunc("GET /ham", s.downloadSampleHandler(func(_, ham []string) ([]string, string) {
5✔
240
                                return ham, "ham.txt"
×
241
                        }))
×
242
                        r.HandleFunc("GET /detected_spam", s.downloadDetectedSpamHandler)
5✔
243
                        r.HandleFunc("GET /backup", s.downloadBackupHandler)
5✔
244
                        r.HandleFunc("GET /export-to-postgres", s.downloadExportToPostgresHandler)
5✔
245
                })
246

247
                authApi.HandleFunc("GET /samples", s.getDynamicSamplesHandler)    // get dynamic samples
5✔
248
                authApi.HandleFunc("PUT /samples", s.reloadDynamicSamplesHandler) // reload samples
5✔
249

5✔
250
                authApi.Mount("/users").Route(func(r *routegroup.Bundle) { // manage approved users
10✔
251
                        // add user to the approved list and storage
5✔
252
                        r.HandleFunc("POST /add", s.updateApprovedUsersHandler(s.Detector.AddApprovedUser))
5✔
253
                        // remove user from an approved list and storage
5✔
254
                        r.HandleFunc("POST /delete", s.updateApprovedUsersHandler(s.removeApprovedUser))
5✔
255
                        // get approved users
5✔
256
                        r.HandleFunc("GET /", s.getApprovedUsersHandler)
5✔
257
                })
5✔
258

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

5✔
261
                authApi.Mount("/dictionary").Route(func(r *routegroup.Bundle) { // manage dictionary
10✔
262
                        // add stop phrase or ignored word
5✔
263
                        r.HandleFunc("POST /add", s.addDictionaryEntryHandler)
5✔
264
                        // delete entry by id
5✔
265
                        r.HandleFunc("POST /delete", s.deleteDictionaryEntryHandler)
5✔
266
                        // get all entries
5✔
267
                        r.HandleFunc("GET /", s.getDictionaryEntriesHandler)
5✔
268
                })
5✔
269
        })
270

271
        router.Group().Route(func(webUI *routegroup.Bundle) {
10✔
272
                webUI.Use(s.authMiddleware(rest.BasicAuthWithPrompt("tg-spam", s.AuthPasswd)))
5✔
273
                webUI.HandleFunc("GET /", s.htmlSpamCheckHandler)                         // serve template for webUI UI
5✔
274
                webUI.HandleFunc("GET /manage_samples", s.htmlManageSamplesHandler)       // serve manage samples page
5✔
275
                webUI.HandleFunc("GET /manage_users", s.htmlManageUsersHandler)           // serve manage users page
5✔
276
                webUI.HandleFunc("GET /manage_dictionary", s.htmlManageDictionaryHandler) // serve manage dictionary page
5✔
277
                webUI.HandleFunc("GET /detected_spam", s.htmlDetectedSpamHandler)         // serve detected spam page
5✔
278
                webUI.HandleFunc("GET /list_settings", s.htmlSettingsHandler)             // serve settings
5✔
279
                webUI.HandleFunc("POST /detected_spam/add", s.htmlAddDetectedSpamHandler) // add detected spam to samples
5✔
280

5✔
281
                // handle logout - force Basic Auth re-authentication
5✔
282
                webUI.HandleFunc("GET /logout", func(w http.ResponseWriter, _ *http.Request) {
5✔
283
                        w.Header().Set("WWW-Authenticate", `Basic realm="tg-spam"`)
×
284
                        w.WriteHeader(http.StatusUnauthorized)
×
285
                        fmt.Fprintln(w, "Logged out successfully")
×
286
                })
×
287

288
                // serve only specific static files at root level
289
                staticFiles := newStaticFS(templateFS,
5✔
290
                        staticFileMapping{urlPath: "styles.css", filesysPath: "assets/styles.css"},
5✔
291
                        staticFileMapping{urlPath: "logo.png", filesysPath: "assets/logo.png"},
5✔
292
                        staticFileMapping{urlPath: "spinner.svg", filesysPath: "assets/spinner.svg"},
5✔
293
                )
5✔
294
                webUI.HandleFiles("/", http.FS(staticFiles))
5✔
295
        })
296

297
        return router
5✔
298
}
299

300
// checkMsgHandler handles POST /check request.
301
// it gets message text and user id from request body and returns spam status and check results.
302
func (s *Server) checkMsgHandler(w http.ResponseWriter, r *http.Request) {
8✔
303
        type CheckResultDisplay struct {
8✔
304
                Spam   bool
8✔
305
                Checks []spamcheck.Response
8✔
306
        }
8✔
307

8✔
308
        isHtmxRequest := r.Header.Get("HX-Request") == "true"
8✔
309

8✔
310
        req := spamcheck.Request{CheckOnly: true}
8✔
311
        if !isHtmxRequest {
15✔
312
                // API request
7✔
313
                if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
8✔
314
                        w.WriteHeader(http.StatusBadRequest)
1✔
315
                        rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
316
                        log.Printf("[WARN] can't decode request: %v", err)
1✔
317
                        return
1✔
318
                }
1✔
319
        } else {
1✔
320
                // for hx-request (HTMX) we need to get the values from the form
1✔
321
                req.UserID = r.FormValue("user_id")
1✔
322
                req.UserName = r.FormValue("user_name")
1✔
323
                req.Msg = r.FormValue("msg")
1✔
324
        }
1✔
325

326
        spam, cr := s.Detector.Check(req)
7✔
327
        if !isHtmxRequest {
13✔
328
                // for API request return JSON
6✔
329
                rest.RenderJSON(w, rest.JSON{"spam": spam, "checks": cr})
6✔
330
                return
6✔
331
        }
6✔
332

333
        if req.Msg == "" {
1✔
334
                w.Header().Set("HX-Retarget", "#error-message")
×
335
                fmt.Fprintln(w, "<div class='alert alert-danger'>Valid message required.</div>")
×
336
                return
×
337
        }
×
338

339
        // render result for HTMX request
340
        resultDisplay := CheckResultDisplay{
1✔
341
                Spam:   spam,
1✔
342
                Checks: cr,
1✔
343
        }
1✔
344

1✔
345
        if err := tmpl.ExecuteTemplate(w, "check_results", resultDisplay); err != nil {
1✔
346
                log.Printf("[WARN] can't execute result template: %v", err)
×
347
                http.Error(w, "Error rendering result", http.StatusInternalServerError)
×
348
                return
×
349
        }
×
350
}
351

352
// checkIDHandler handles GET /check/{user_id} request.
353
// it returns JSON with the status "spam" or "ham" for a given user id.
354
// if user is spammer, it also returns check results.
355
func (s *Server) checkIDHandler(w http.ResponseWriter, r *http.Request) {
2✔
356
        type info struct {
2✔
357
                UserName  string               `json:"user_name,omitempty"`
2✔
358
                Message   string               `json:"message,omitempty"`
2✔
359
                Timestamp time.Time            `json:"timestamp,omitempty"`
2✔
360
                Checks    []spamcheck.Response `json:"checks,omitempty"`
2✔
361
        }
2✔
362
        resp := struct {
2✔
363
                Status string `json:"status"`
2✔
364
                Info   *info  `json:"info,omitempty"`
2✔
365
        }{
2✔
366
                Status: "ham",
2✔
367
        }
2✔
368

2✔
369
        userID, err := strconv.ParseInt(r.PathValue("user_id"), 10, 64)
2✔
370
        if err != nil {
2✔
371
                w.WriteHeader(http.StatusBadRequest)
×
372
                rest.RenderJSON(w, rest.JSON{"error": "can't parse user id", "details": err.Error()})
×
373
                return
×
374
        }
×
375

376
        si, err := s.DetectedSpam.FindByUserID(r.Context(), userID)
2✔
377
        if err != nil {
2✔
378
                w.WriteHeader(http.StatusInternalServerError)
×
379
                rest.RenderJSON(w, rest.JSON{"error": "can't get user info", "details": err.Error()})
×
380
                return
×
381
        }
×
382
        if si != nil {
3✔
383
                resp.Status = "spam"
1✔
384
                resp.Info = &info{
1✔
385
                        UserName:  si.UserName,
1✔
386
                        Message:   si.Text,
1✔
387
                        Timestamp: si.Timestamp,
1✔
388
                        Checks:    si.Checks,
1✔
389
                }
1✔
390
        }
1✔
391
        rest.RenderJSON(w, resp)
2✔
392
}
393

394
// getDynamicSamplesHandler handles GET /samples request. It returns dynamic samples both for spam and ham.
395
func (s *Server) getDynamicSamplesHandler(w http.ResponseWriter, _ *http.Request) {
2✔
396
        spam, ham, err := s.SpamFilter.DynamicSamples()
2✔
397
        if err != nil {
3✔
398
                w.WriteHeader(http.StatusInternalServerError)
1✔
399
                rest.RenderJSON(w, rest.JSON{"error": "can't get dynamic samples", "details": err.Error()})
1✔
400
                return
1✔
401
        }
1✔
402
        rest.RenderJSON(w, rest.JSON{"spam": spam, "ham": ham})
1✔
403
}
404

405
// downloadSampleHandler handles GET /download/spam|ham request. It returns dynamic samples both for spam and ham.
406
func (s *Server) downloadSampleHandler(pickFn func(spam, ham []string) ([]string, string)) func(w http.ResponseWriter, r *http.Request) {
13✔
407
        return func(w http.ResponseWriter, _ *http.Request) {
16✔
408
                spam, ham, err := s.SpamFilter.DynamicSamples()
3✔
409
                if err != nil {
4✔
410
                        w.WriteHeader(http.StatusInternalServerError)
1✔
411
                        rest.RenderJSON(w, rest.JSON{"error": "can't get dynamic samples", "details": err.Error()})
1✔
412
                        return
1✔
413
                }
1✔
414
                samples, name := pickFn(spam, ham)
2✔
415
                body := strings.Join(samples, "\n")
2✔
416
                w.Header().Set("Content-Type", "text/plain; charset=utf-8")
2✔
417
                w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
2✔
418
                w.Header().Set("Content-Length", strconv.Itoa(len(body)))
2✔
419
                w.WriteHeader(http.StatusOK)
2✔
420
                _, _ = w.Write([]byte(body))
2✔
421
        }
422
}
423

424
// updateSampleHandler handles POST /update/spam|ham request. It updates dynamic samples both for spam and ham.
425
func (s *Server) updateSampleHandler(updFn func(msg string) error) func(w http.ResponseWriter, r *http.Request) {
13✔
426
        return func(w http.ResponseWriter, r *http.Request) {
18✔
427
                var req struct {
5✔
428
                        Msg string `json:"msg"`
5✔
429
                }
5✔
430

5✔
431
                isHtmxRequest := r.Header.Get("HX-Request") == "true"
5✔
432

5✔
433
                if isHtmxRequest {
5✔
434
                        req.Msg = r.FormValue("msg")
×
435
                } else {
5✔
436
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
6✔
437
                                w.WriteHeader(http.StatusBadRequest)
1✔
438
                                rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
439
                                return
1✔
440
                        }
1✔
441
                }
442

443
                err := updFn(req.Msg)
4✔
444
                if err != nil {
5✔
445
                        w.WriteHeader(http.StatusInternalServerError)
1✔
446
                        rest.RenderJSON(w, rest.JSON{"error": "can't update samples", "details": err.Error()})
1✔
447
                        return
1✔
448
                }
1✔
449

450
                if isHtmxRequest {
3✔
451
                        s.renderSamples(w, "samples_list")
×
452
                } else {
3✔
453
                        rest.RenderJSON(w, rest.JSON{"updated": true, "msg": req.Msg})
3✔
454
                }
3✔
455
        }
456
}
457

458
// deleteSampleHandler handles DELETE /samples request. It deletes dynamic samples both for spam and ham.
459
func (s *Server) deleteSampleHandler(delFn func(msg string) error) func(w http.ResponseWriter, r *http.Request) {
13✔
460
        return func(w http.ResponseWriter, r *http.Request) {
18✔
461
                var req struct {
5✔
462
                        Msg string `json:"msg"`
5✔
463
                }
5✔
464
                isHtmxRequest := r.Header.Get("HX-Request") == "true"
5✔
465
                if isHtmxRequest {
6✔
466
                        req.Msg = r.FormValue("msg")
1✔
467
                } else {
5✔
468
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
4✔
469
                                w.WriteHeader(http.StatusBadRequest)
×
470
                                rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
×
471
                                return
×
472
                        }
×
473
                }
474

475
                if err := delFn(req.Msg); err != nil {
6✔
476
                        w.WriteHeader(http.StatusInternalServerError)
1✔
477
                        rest.RenderJSON(w, rest.JSON{"error": "can't delete sample", "details": err.Error()})
1✔
478
                        return
1✔
479
                }
1✔
480

481
                if isHtmxRequest {
5✔
482
                        s.renderSamples(w, "samples_list")
1✔
483
                } else {
4✔
484
                        rest.RenderJSON(w, rest.JSON{"deleted": true, "msg": req.Msg, "count": 1})
3✔
485
                }
3✔
486
        }
487
}
488

489
// reloadDynamicSamplesHandler handles PUT /samples request. It reloads dynamic samples from db storage.
490
func (s *Server) reloadDynamicSamplesHandler(w http.ResponseWriter, _ *http.Request) {
2✔
491
        if err := s.SpamFilter.ReloadSamples(); err != nil {
3✔
492
                w.WriteHeader(http.StatusInternalServerError)
1✔
493
                rest.RenderJSON(w, rest.JSON{"error": "can't reload samples", "details": err.Error()})
1✔
494
                return
1✔
495
        }
1✔
496
        rest.RenderJSON(w, rest.JSON{"reloaded": true})
1✔
497
}
498

499
// updateApprovedUsersHandler handles POST /users/add and /users/delete requests, it adds or removes users from approved list.
500
func (s *Server) updateApprovedUsersHandler(updFn func(ui approved.UserInfo) error) func(w http.ResponseWriter, r *http.Request) {
14✔
501
        return func(w http.ResponseWriter, r *http.Request) {
23✔
502
                req := approved.UserInfo{}
9✔
503
                isHtmxRequest := r.Header.Get("HX-Request") == "true"
9✔
504
                if isHtmxRequest {
10✔
505
                        req.UserID = r.FormValue("user_id")
1✔
506
                        req.UserName = r.FormValue("user_name")
1✔
507
                } else {
9✔
508
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
9✔
509
                                w.WriteHeader(http.StatusBadRequest)
1✔
510
                                rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
511
                                return
1✔
512
                        }
1✔
513
                }
514

515
                // try to get userID from request and fallback to userName lookup if it's empty
516
                if req.UserID == "" {
12✔
517
                        req.UserID = strconv.FormatInt(s.Locator.UserIDByName(r.Context(), req.UserName), 10)
4✔
518
                }
4✔
519

520
                if req.UserID == "" || req.UserID == "0" {
9✔
521
                        if isHtmxRequest {
1✔
522
                                w.Header().Set("HX-Retarget", "#error-message")
×
523
                                fmt.Fprintln(w, "<div class='alert alert-danger'>Either userid or valid username required.</div>")
×
524
                                return
×
525
                        }
×
526
                        w.WriteHeader(http.StatusBadRequest)
1✔
527
                        rest.RenderJSON(w, rest.JSON{"error": "user ID is required"})
1✔
528
                        return
1✔
529
                }
530

531
                // add or remove user from the approved list of detector
532
                if err := updFn(req); err != nil {
7✔
533
                        w.WriteHeader(http.StatusInternalServerError)
×
534
                        rest.RenderJSON(w, rest.JSON{"error": "can't update approved users", "details": err.Error()})
×
535
                        return
×
536
                }
×
537

538
                if isHtmxRequest {
8✔
539
                        users := s.Detector.ApprovedUsers()
1✔
540
                        tmplData := struct {
1✔
541
                                ApprovedUsers      []approved.UserInfo
1✔
542
                                TotalApprovedUsers int
1✔
543
                        }{
1✔
544
                                ApprovedUsers:      users,
1✔
545
                                TotalApprovedUsers: len(users),
1✔
546
                        }
1✔
547

1✔
548
                        if err := tmpl.ExecuteTemplate(w, "users_list", tmplData); err != nil {
1✔
549
                                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
550
                                return
×
551
                        }
×
552

553
                } else {
6✔
554
                        rest.RenderJSON(w, rest.JSON{"updated": true, "user_id": req.UserID, "user_name": req.UserName})
6✔
555
                }
6✔
556
        }
557
}
558

559
// removeApprovedUser is adopter for updateApprovedUsersHandler updFn
560
func (s *Server) removeApprovedUser(req approved.UserInfo) error {
2✔
561
        if err := s.Detector.RemoveApprovedUser(req.UserID); err != nil {
2✔
562
                return fmt.Errorf("failed to remove approved user %s: %w", req.UserID, err)
×
563
        }
×
564
        return nil
2✔
565
}
566

567
// getApprovedUsersHandler handles GET /users request. It returns list of approved users.
568
func (s *Server) getApprovedUsersHandler(w http.ResponseWriter, _ *http.Request) {
1✔
569
        rest.RenderJSON(w, rest.JSON{"user_ids": s.Detector.ApprovedUsers()})
1✔
570
}
1✔
571

572
// getSettingsHandler returns application settings, including the list of available Lua plugins
573
func (s *Server) getSettingsHandler(w http.ResponseWriter, _ *http.Request) {
3✔
574
        // get the list of available Lua plugins before returning settings
3✔
575
        s.Settings.LuaAvailablePlugins = s.Detector.GetLuaPluginNames()
3✔
576
        rest.RenderJSON(w, s.Settings)
3✔
577
}
3✔
578

579
// getDictionaryEntriesHandler handles GET /dictionary request. It returns stop phrases and ignored words.
580
func (s *Server) getDictionaryEntriesHandler(w http.ResponseWriter, r *http.Request) {
3✔
581
        stopPhrases, err := s.Dictionary.Read(r.Context(), storage.DictionaryTypeStopPhrase)
3✔
582
        if err != nil {
4✔
583
                w.WriteHeader(http.StatusInternalServerError)
1✔
584
                rest.RenderJSON(w, rest.JSON{"error": "can't get stop phrases", "details": err.Error()})
1✔
585
                return
1✔
586
        }
1✔
587

588
        ignoredWords, err := s.Dictionary.Read(r.Context(), storage.DictionaryTypeIgnoredWord)
2✔
589
        if err != nil {
3✔
590
                w.WriteHeader(http.StatusInternalServerError)
1✔
591
                rest.RenderJSON(w, rest.JSON{"error": "can't get ignored words", "details": err.Error()})
1✔
592
                return
1✔
593
        }
1✔
594

595
        rest.RenderJSON(w, rest.JSON{"stop_phrases": stopPhrases, "ignored_words": ignoredWords})
1✔
596
}
597

598
// addDictionaryEntryHandler handles POST /dictionary/add request. It adds a stop phrase or ignored word.
599
func (s *Server) addDictionaryEntryHandler(w http.ResponseWriter, r *http.Request) {
10✔
600
        var req struct {
10✔
601
                Type string `json:"type"`
10✔
602
                Data string `json:"data"`
10✔
603
        }
10✔
604

10✔
605
        isHtmxRequest := r.Header.Get("HX-Request") == "true"
10✔
606

10✔
607
        if isHtmxRequest {
14✔
608
                req.Type = r.FormValue("type")
4✔
609
                req.Data = r.FormValue("data")
4✔
610
        } else {
10✔
611
                if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
7✔
612
                        w.WriteHeader(http.StatusBadRequest)
1✔
613
                        rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
614
                        return
1✔
615
                }
1✔
616
        }
617

618
        if req.Data == "" {
11✔
619
                if isHtmxRequest {
3✔
620
                        w.Header().Set("HX-Retarget", "#error-message")
1✔
621
                        fmt.Fprintln(w, "<div class='alert alert-danger'>Data cannot be empty.</div>")
1✔
622
                        return
1✔
623
                }
1✔
624
                w.WriteHeader(http.StatusBadRequest)
1✔
625
                rest.RenderJSON(w, rest.JSON{"error": "data cannot be empty"})
1✔
626
                return
1✔
627
        }
628

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

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

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

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

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

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

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

690
        if err := s.Dictionary.Delete(r.Context(), req.ID); err != nil {
6✔
691
                w.WriteHeader(http.StatusInternalServerError)
1✔
692
                rest.RenderJSON(w, 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
                        w.WriteHeader(http.StatusInternalServerError)
1✔
701
                        rest.RenderJSON(w, 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
        default: // "all" or any other value
1✔
813
                filteredDS = ds
1✔
814
        }
815

816
        tmplData := struct {
1✔
817
                DetectedSpamEntries []storage.DetectedSpamInfo
1✔
818
                TotalDetectedSpam   int
1✔
819
                FilteredCount       int
1✔
820
                Filter              string
1✔
821
                OpenAIEnabled       bool
1✔
822
        }{
1✔
823
                DetectedSpamEntries: filteredDS,
1✔
824
                TotalDetectedSpam:   len(ds),
1✔
825
                FilteredCount:       len(filteredDS),
1✔
826
                Filter:              filter,
1✔
827
                OpenAIEnabled:       s.Settings.OpenAIEnabled,
1✔
828
        }
1✔
829

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

×
834
                // first render the content template
×
835
                if err := tmpl.ExecuteTemplate(&buf, "detected_spam_content", tmplData); err != nil {
×
836
                        log.Printf("[WARN] can't execute content template: %v", err)
×
837
                        http.Error(w, "Error executing template", http.StatusInternalServerError)
×
838
                        return
×
839
                }
×
840

841
                // then append OOB swap for the count display
842
                countHTML := ""
×
843
                if filter != "all" {
×
844
                        countHTML = fmt.Sprintf("(%d/%d)", len(filteredDS), len(ds))
×
845
                } else {
×
846
                        countHTML = fmt.Sprintf("(%d)", len(ds))
×
847
                }
×
848

849
                buf.WriteString(fmt.Sprintf(`<span id="count-display" hx-swap-oob="true">%s</span>`, countHTML))
×
850

×
851
                // write the combined response
×
852
                if _, err := buf.WriteTo(w); err != nil {
×
853
                        log.Printf("[WARN] failed to write response: %v", err)
×
854
                }
×
855
                return
×
856
        }
857

858
        // full page render for normal requests
859
        if err := tmpl.ExecuteTemplate(w, "detected_spam.html", tmplData); err != nil {
1✔
860
                log.Printf("[WARN] can't execute template: %v", err)
×
861
                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
862
                return
×
863
        }
×
864
}
865

866
func (s *Server) htmlAddDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
5✔
867
        reportErr := func(err error, _ int) {
9✔
868
                w.Header().Set("HX-Retarget", "#error-message")
4✔
869
                fmt.Fprintf(w, "<div class='alert alert-danger'>%s</div>", err)
4✔
870
        }
4✔
871
        msg := r.FormValue("msg")
5✔
872

5✔
873
        id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
5✔
874
        if err != nil || msg == "" {
7✔
875
                log.Printf("[WARN] bad request: %v", err)
2✔
876
                reportErr(fmt.Errorf("bad request: %v", err), http.StatusBadRequest)
2✔
877
                return
2✔
878
        }
2✔
879

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

1✔
885
        }
1✔
886
        if err := s.DetectedSpam.SetAddedToSamplesFlag(r.Context(), id); err != nil {
3✔
887
                log.Printf("[WARN] failed to update detected spam: %v", err)
1✔
888
                reportErr(fmt.Errorf("can't update detected spam: %v", err), http.StatusInternalServerError)
1✔
889
                return
1✔
890
        }
1✔
891
        w.WriteHeader(http.StatusOK)
1✔
892
}
893

894
func (s *Server) htmlSettingsHandler(w http.ResponseWriter, _ *http.Request) {
4✔
895
        // get database information if StorageEngine is available
4✔
896
        var dbInfo struct {
4✔
897
                DatabaseType   string `json:"database_type"`
4✔
898
                GID            string `json:"gid"`
4✔
899
                DatabaseStatus string `json:"database_status"`
4✔
900
        }
4✔
901

4✔
902
        if s.StorageEngine != nil {
6✔
903
                // try to cast to SQL engine to get type information
2✔
904
                if sqlEngine, ok := s.StorageEngine.(*engine.SQL); ok {
2✔
905
                        dbInfo.DatabaseType = string(sqlEngine.Type())
×
906
                        dbInfo.GID = sqlEngine.GID()
×
907
                        dbInfo.DatabaseStatus = "Connected"
×
908
                } else {
2✔
909
                        dbInfo.DatabaseType = "Unknown"
2✔
910
                        dbInfo.DatabaseStatus = "Connected (unknown type)"
2✔
911
                }
2✔
912
        } else {
2✔
913
                dbInfo.DatabaseStatus = "Not connected"
2✔
914
        }
2✔
915

916
        // get backup information
917
        backupURL := "/download/backup"
4✔
918
        backupFilename := fmt.Sprintf("tg-spam-backup-%s-%s.sql.gz", dbInfo.DatabaseType, time.Now().Format("20060102-150405"))
4✔
919

4✔
920
        // get system info - uptime since server start
4✔
921
        uptime := time.Since(startTime)
4✔
922

4✔
923
        // get the list of available Lua plugins
4✔
924
        s.Settings.LuaAvailablePlugins = s.Detector.GetLuaPluginNames()
4✔
925

4✔
926
        data := struct {
4✔
927
                Settings
4✔
928
                Version  string
4✔
929
                Database struct {
4✔
930
                        Type   string
4✔
931
                        GID    string
4✔
932
                        Status string
4✔
933
                }
4✔
934
                Backup struct {
4✔
935
                        URL      string
4✔
936
                        Filename string
4✔
937
                }
4✔
938
                System struct {
4✔
939
                        Uptime string
4✔
940
                }
4✔
941
        }{
4✔
942
                Settings: s.Settings,
4✔
943
                Version:  s.Version,
4✔
944
                Database: struct {
4✔
945
                        Type   string
4✔
946
                        GID    string
4✔
947
                        Status string
4✔
948
                }{
4✔
949
                        Type:   dbInfo.DatabaseType,
4✔
950
                        GID:    dbInfo.GID,
4✔
951
                        Status: dbInfo.DatabaseStatus,
4✔
952
                },
4✔
953
                Backup: struct {
4✔
954
                        URL      string
4✔
955
                        Filename string
4✔
956
                }{
4✔
957
                        URL:      backupURL,
4✔
958
                        Filename: backupFilename,
4✔
959
                },
4✔
960
                System: struct {
4✔
961
                        Uptime string
4✔
962
                }{
4✔
963
                        Uptime: formatDuration(uptime),
4✔
964
                },
4✔
965
        }
4✔
966

4✔
967
        if err := tmpl.ExecuteTemplate(w, "settings.html", data); err != nil {
5✔
968
                log.Printf("[WARN] can't execute template: %v", err)
1✔
969
                http.Error(w, "Error executing template", http.StatusInternalServerError)
1✔
970
                return
1✔
971
        }
1✔
972
}
973

974
// formatDuration formats a duration in a human-readable way
975
func formatDuration(d time.Duration) string {
12✔
976
        days := int(d.Hours() / 24)
12✔
977
        hours := int(d.Hours()) % 24
12✔
978
        minutes := int(d.Minutes()) % 60
12✔
979

12✔
980
        if days > 0 {
15✔
981
                return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
3✔
982
        }
3✔
983

984
        if hours > 0 {
11✔
985
                return fmt.Sprintf("%dh %dm", hours, minutes)
2✔
986
        }
2✔
987

988
        return fmt.Sprintf("%dm", minutes)
7✔
989
}
990

991
func (s *Server) downloadDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
3✔
992
        ctx := r.Context()
3✔
993
        spam, err := s.DetectedSpam.Read(ctx)
3✔
994
        if err != nil {
4✔
995
                w.WriteHeader(http.StatusInternalServerError)
1✔
996
                rest.RenderJSON(w, rest.JSON{"error": "can't get detected spam", "details": err.Error()})
1✔
997
                return
1✔
998
        }
1✔
999

1000
        type jsonSpamInfo struct {
2✔
1001
                ID        int64                `json:"id"`
2✔
1002
                GID       string               `json:"gid"`
2✔
1003
                Text      string               `json:"text"`
2✔
1004
                UserID    int64                `json:"user_id"`
2✔
1005
                UserName  string               `json:"user_name"`
2✔
1006
                Timestamp time.Time            `json:"timestamp"`
2✔
1007
                Added     bool                 `json:"added"`
2✔
1008
                Checks    []spamcheck.Response `json:"checks"`
2✔
1009
        }
2✔
1010

2✔
1011
        // convert entries to jsonl format with lowercase fields
2✔
1012
        lines := make([]string, 0, len(spam))
2✔
1013
        for _, entry := range spam {
5✔
1014
                data, err := json.Marshal(jsonSpamInfo{
3✔
1015
                        ID:        entry.ID,
3✔
1016
                        GID:       entry.GID,
3✔
1017
                        Text:      entry.Text,
3✔
1018
                        UserID:    entry.UserID,
3✔
1019
                        UserName:  entry.UserName,
3✔
1020
                        Timestamp: entry.Timestamp,
3✔
1021
                        Added:     entry.Added,
3✔
1022
                        Checks:    entry.Checks,
3✔
1023
                })
3✔
1024
                if err != nil {
3✔
1025
                        w.WriteHeader(http.StatusInternalServerError)
×
1026
                        rest.RenderJSON(w, rest.JSON{"error": "can't marshal entry", "details": err.Error()})
×
1027
                        return
×
1028
                }
×
1029
                lines = append(lines, string(data))
3✔
1030
        }
1031

1032
        body := strings.Join(lines, "\n")
2✔
1033
        w.Header().Set("Content-Type", "application/x-jsonlines")
2✔
1034
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "detected_spam.jsonl"))
2✔
1035
        w.Header().Set("Content-Length", strconv.Itoa(len(body)))
2✔
1036
        w.WriteHeader(http.StatusOK)
2✔
1037
        _, _ = w.Write([]byte(body))
2✔
1038
}
1039

1040
// downloadBackupHandler streams a database backup as an SQL file with gzip compression
1041
// Files are always compressed and always have .gz extension to ensure consistency
1042
func (s *Server) downloadBackupHandler(w http.ResponseWriter, r *http.Request) {
2✔
1043
        if s.StorageEngine == nil {
3✔
1044
                w.WriteHeader(http.StatusInternalServerError)
1✔
1045
                rest.RenderJSON(w, rest.JSON{"error": "storage engine not available"})
1✔
1046
                return
1✔
1047
        }
1✔
1048

1049
        // set filename based on database type and timestamp
1050
        dbType := "db"
1✔
1051
        sqlEng, ok := s.StorageEngine.(*engine.SQL)
1✔
1052
        if ok {
1✔
1053
                dbType = string(sqlEng.Type())
×
1054
        }
×
1055
        timestamp := time.Now().Format("20060102-150405")
1✔
1056

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

1✔
1060
        // set headers for file download - note we're using application/octet-stream
1✔
1061
        // instead of application/sql to prevent browsers from trying to interpret the file
1✔
1062
        w.Header().Set("Content-Type", "application/octet-stream")
1✔
1063
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
1✔
1064
        w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
1✔
1065
        w.Header().Set("Pragma", "no-cache")
1✔
1066
        w.Header().Set("Expires", "0")
1✔
1067

1✔
1068
        // create a gzip writer that streams to response
1✔
1069
        gzipWriter := gzip.NewWriter(w)
1✔
1070
        defer func() {
2✔
1071
                if err := gzipWriter.Close(); err != nil {
1✔
1072
                        log.Printf("[ERROR] failed to close gzip writer: %v", err)
×
1073
                }
×
1074
        }()
1075

1076
        // stream backup directly to response through gzip
1077
        if err := s.StorageEngine.Backup(r.Context(), gzipWriter); err != nil {
1✔
1078
                log.Printf("[ERROR] failed to create backup: %v", err)
×
1079
                // we've already started writing the response, so we can't send a proper error response
×
1080
                return
×
1081
        }
×
1082

1083
        // flush the gzip writer to ensure all data is written
1084
        if err := gzipWriter.Flush(); err != nil {
1✔
1085
                log.Printf("[ERROR] failed to flush gzip writer: %v", err)
×
1086
        }
×
1087
}
1088

1089
// downloadExportToPostgresHandler streams a PostgreSQL-compatible export from a SQLite database
1090
func (s *Server) downloadExportToPostgresHandler(w http.ResponseWriter, r *http.Request) {
3✔
1091
        if s.StorageEngine == nil {
4✔
1092
                w.WriteHeader(http.StatusInternalServerError)
1✔
1093
                rest.RenderJSON(w, rest.JSON{"error": "storage engine not available"})
1✔
1094
                return
1✔
1095
        }
1✔
1096

1097
        // check if the database is SQLite
1098
        if s.StorageEngine.Type() != engine.Sqlite {
3✔
1099
                w.WriteHeader(http.StatusBadRequest)
1✔
1100
                rest.RenderJSON(w, rest.JSON{"error": "source database must be SQLite"})
1✔
1101
                return
1✔
1102
        }
1✔
1103

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

1✔
1108
        // set headers for file download
1✔
1109
        w.Header().Set("Content-Type", "application/octet-stream")
1✔
1110
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
1✔
1111
        w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
1✔
1112
        w.Header().Set("Pragma", "no-cache")
1✔
1113
        w.Header().Set("Expires", "0")
1✔
1114

1✔
1115
        // create a gzip writer that streams to response
1✔
1116
        gzipWriter := gzip.NewWriter(w)
1✔
1117
        defer func() {
2✔
1118
                if err := gzipWriter.Close(); err != nil {
1✔
1119
                        log.Printf("[ERROR] failed to close gzip writer: %v", err)
×
1120
                }
×
1121
        }()
1122

1123
        // stream export directly to response through gzip
1124
        if err := s.StorageEngine.BackupSqliteAsPostgres(r.Context(), gzipWriter); err != nil {
1✔
1125
                log.Printf("[ERROR] failed to create export: %v", err)
×
1126
                // we've already started writing the response, so we can't send a proper error response
×
1127
                return
×
1128
        }
×
1129

1130
        // flush the gzip writer to ensure all data is written
1131
        if err := gzipWriter.Flush(); err != nil {
1✔
1132
                log.Printf("[ERROR] failed to flush gzip writer: %v", err)
×
1133
        }
×
1134
}
1135

1136
func (s *Server) renderSamples(w http.ResponseWriter, tmplName string) {
6✔
1137
        spam, ham, err := s.SpamFilter.DynamicSamples()
6✔
1138
        if err != nil {
7✔
1139
                w.WriteHeader(http.StatusInternalServerError)
1✔
1140
                rest.RenderJSON(w, rest.JSON{"error": "can't fetch samples", "details": err.Error()})
1✔
1141
                return
1✔
1142
        }
1✔
1143

1144
        spam, ham = s.reverseSamples(spam, ham)
5✔
1145

5✔
1146
        type smpleWithID struct {
5✔
1147
                ID     string
5✔
1148
                Sample string
5✔
1149
        }
5✔
1150

5✔
1151
        makeID := func(s string) string {
19✔
1152
                hash := sha1.New() //nolint
14✔
1153
                if _, err := hash.Write([]byte(s)); err != nil {
14✔
1154
                        return fmt.Sprintf("%x", s)
×
1155
                }
×
1156
                return fmt.Sprintf("%x", hash.Sum(nil))
14✔
1157
        }
1158

1159
        tmplData := struct {
5✔
1160
                SpamSamples      []smpleWithID
5✔
1161
                HamSamples       []smpleWithID
5✔
1162
                TotalHamSamples  int
5✔
1163
                TotalSpamSamples int
5✔
1164
        }{
5✔
1165
                TotalHamSamples:  len(ham),
5✔
1166
                TotalSpamSamples: len(spam),
5✔
1167
        }
5✔
1168
        for _, s := range spam {
12✔
1169
                tmplData.SpamSamples = append(tmplData.SpamSamples, smpleWithID{ID: makeID(s), Sample: s})
7✔
1170
        }
7✔
1171
        for _, h := range ham {
12✔
1172
                tmplData.HamSamples = append(tmplData.HamSamples, smpleWithID{ID: makeID(h), Sample: h})
7✔
1173
        }
7✔
1174

1175
        if err := tmpl.ExecuteTemplate(w, tmplName, tmplData); err != nil {
6✔
1176
                w.WriteHeader(http.StatusInternalServerError)
1✔
1177
                rest.RenderJSON(w, rest.JSON{"error": "can't execute template", "details": err.Error()})
1✔
1178
                return
1✔
1179
        }
1✔
1180
}
1181

1182
func (s *Server) authMiddleware(mw func(next http.Handler) http.Handler) func(next http.Handler) http.Handler {
10✔
1183
        if s.AuthPasswd == "" {
16✔
1184
                return func(next http.Handler) http.Handler {
93✔
1185
                        return next
87✔
1186
                }
87✔
1187
        }
1188
        return func(next http.Handler) http.Handler {
62✔
1189
                return mw(next)
58✔
1190
        }
58✔
1191
}
1192

1193
// reverseSamples returns reversed lists of spam and ham samples
1194
func (s *Server) reverseSamples(spam, ham []string) (revSpam, revHam []string) {
8✔
1195
        revSpam = make([]string, len(spam))
8✔
1196
        revHam = make([]string, len(ham))
8✔
1197

8✔
1198
        for i, j := 0, len(spam)-1; i < len(spam); i, j = i+1, j-1 {
19✔
1199
                revSpam[i] = spam[j]
11✔
1200
        }
11✔
1201
        for i, j := 0, len(ham)-1; i < len(ham); i, j = i+1, j-1 {
19✔
1202
                revHam[i] = ham[j]
11✔
1203
        }
11✔
1204
        return revSpam, revHam
8✔
1205
}
1206

1207
// renderDictionary renders dictionary entries for HTMX or full page request
1208
func (s *Server) renderDictionary(ctx context.Context, w http.ResponseWriter, tmplName string) {
4✔
1209
        stopPhrases, err := s.Dictionary.ReadWithIDs(ctx, storage.DictionaryTypeStopPhrase)
4✔
1210
        if err != nil {
4✔
1211
                w.WriteHeader(http.StatusInternalServerError)
×
1212
                rest.RenderJSON(w, rest.JSON{"error": "can't fetch stop phrases", "details": err.Error()})
×
1213
                return
×
1214
        }
×
1215

1216
        ignoredWords, err := s.Dictionary.ReadWithIDs(ctx, storage.DictionaryTypeIgnoredWord)
4✔
1217
        if err != nil {
4✔
1218
                w.WriteHeader(http.StatusInternalServerError)
×
1219
                rest.RenderJSON(w, rest.JSON{"error": "can't fetch ignored words", "details": err.Error()})
×
1220
                return
×
1221
        }
×
1222

1223
        tmplData := struct {
4✔
1224
                StopPhrases       []storage.DictionaryEntry
4✔
1225
                IgnoredWords      []storage.DictionaryEntry
4✔
1226
                TotalStopPhrases  int
4✔
1227
                TotalIgnoredWords int
4✔
1228
        }{
4✔
1229
                StopPhrases:       stopPhrases,
4✔
1230
                IgnoredWords:      ignoredWords,
4✔
1231
                TotalStopPhrases:  len(stopPhrases),
4✔
1232
                TotalIgnoredWords: len(ignoredWords),
4✔
1233
        }
4✔
1234

4✔
1235
        if err := tmpl.ExecuteTemplate(w, tmplName, tmplData); err != nil {
4✔
1236
                w.WriteHeader(http.StatusInternalServerError)
×
1237
                rest.RenderJSON(w, rest.JSON{"error": "can't execute template", "details": err.Error()})
×
1238
                return
×
1239
        }
×
1240
}
1241

1242
// staticFS is a filtered filesystem that only exposes specific static files
1243
type staticFS struct {
1244
        fs        fs.FS
1245
        urlToPath map[string]string
1246
}
1247

1248
// staticFileMapping defines a mapping between URL path and filesystem path
1249
type staticFileMapping struct {
1250
        urlPath     string
1251
        filesysPath string
1252
}
1253

1254
func newStaticFS(fsys fs.FS, files ...staticFileMapping) *staticFS {
5✔
1255
        urlToPath := make(map[string]string)
5✔
1256
        for _, f := range files {
20✔
1257
                urlToPath[f.urlPath] = f.filesysPath
15✔
1258
        }
15✔
1259

1260
        return &staticFS{
5✔
1261
                fs:        fsys,
5✔
1262
                urlToPath: urlToPath,
5✔
1263
        }
5✔
1264
}
1265

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

5✔
1269
        fsPath, ok := sfs.urlToPath[cleanName]
5✔
1270
        if !ok {
7✔
1271
                return nil, fs.ErrNotExist
2✔
1272
        }
2✔
1273

1274
        file, err := sfs.fs.Open(fsPath)
3✔
1275
        if err != nil {
3✔
1276
                return nil, fmt.Errorf("failed to open static file %s: %w", fsPath, err)
×
1277
        }
×
1278
        return file, nil
3✔
1279
}
1280

1281
// GenerateRandomPassword generates a random password of a given length
1282
func GenerateRandomPassword(length int) (string, error) {
2✔
1283
        const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+"
2✔
1284
        const charsetLen = int64(len(charset))
2✔
1285

2✔
1286
        result := make([]byte, length)
2✔
1287
        for i := 0; i < length; i++ {
66✔
1288
                n, err := rand.Int(rand.Reader, big.NewInt(charsetLen))
64✔
1289
                if err != nil {
64✔
1290
                        return "", fmt.Errorf("failed to generate random number: %w", err)
×
1291
                }
×
1292
                result[i] = charset[n.Int64()]
64✔
1293
        }
1294
        return string(result), nil
2✔
1295
}
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