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

umputun / tg-spam / 13661834647

04 Mar 2025 07:55PM UTC coverage: 78.953% (-0.05%) from 78.999%
13661834647

push

github

umputun
Fix backup download compression to ensure files are always properly gzipped

- Always apply gzip compression to backup downloads
- Ensure filenames have .gz extension consistent with content
- Update tests to reflect the new behavior

9 of 10 new or added lines in 1 file covered. (90.0%)

1 existing line in 1 file now uncovered.

3905 of 4946 relevant lines covered (78.95%)

67.36 hits per line

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

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

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

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

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

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

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

45
// Server is a web API server.
46
type Server struct {
47
        Config
48
}
49

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

65
// Settings contains all application settings
66
type Settings struct {
67
        InstanceID              string        `json:"instance_id"`
68
        PrimaryGroup            string        `json:"primary_group"`
69
        AdminGroup              string        `json:"admin_group"`
70
        DisableAdminSpamForward bool          `json:"disable_admin_spam_forward"`
71
        LoggerEnabled           bool          `json:"logger_enabled"`
72
        SuperUsers              []string      `json:"super_users"`
73
        NoSpamReply             bool          `json:"no_spam_reply"`
74
        CasEnabled              bool          `json:"cas_enabled"`
75
        MetaEnabled             bool          `json:"meta_enabled"`
76
        MetaLinksLimit          int           `json:"meta_links_limit"`
77
        MetaLinksOnly           bool          `json:"meta_links_only"`
78
        MetaImageOnly           bool          `json:"meta_image_only"`
79
        MetaVideoOnly           bool          `json:"meta_video_only"`
80
        MetaAudioOnly           bool          `json:"meta_audio_only"`
81
        MetaForwarded           bool          `json:"meta_forwarded"`
82
        MultiLangLimit          int           `json:"multi_lang_limit"`
83
        OpenAIEnabled           bool          `json:"openai_enabled"`
84
        SamplesDataPath         string        `json:"samples_data_path"`
85
        DynamicDataPath         string        `json:"dynamic_data_path"`
86
        WatchIntervalSecs       int           `json:"watch_interval_secs"`
87
        SimilarityThreshold     float64       `json:"similarity_threshold"`
88
        MinMsgLen               int           `json:"min_msg_len"`
89
        MaxEmoji                int           `json:"max_emoji"`
90
        MinSpamProbability      float64       `json:"min_spam_probability"`
91
        ParanoidMode            bool          `json:"paranoid_mode"`
92
        FirstMessagesCount      int           `json:"first_messages_count"`
93
        StartupMessageEnabled   bool          `json:"startup_message_enabled"`
94
        TrainingEnabled         bool          `json:"training_enabled"`
95
        StorageTimeout          time.Duration `json:"storage_timeout"`
96
        OpenAIVeto              bool          `json:"openai_veto"`
97
        OpenAIHistorySize       int           `json:"openai_history_size"`
98
        OpenAIModel             string        `json:"openai_model"`
99
        SoftBanEnabled          bool          `json:"soft_ban_enabled"`
100
        AbnormalSpacingEnabled  bool          `json:"abnormal_spacing_enabled"`
101
        HistorySize             int           `json:"history_size"`
102
        DebugModeEnabled        bool          `json:"debug_mode_enabled"`
103
        DryModeEnabled          bool          `json:"dry_mode_enabled"`
104
        TGDebugModeEnabled      bool          `json:"tg_debug_mode_enabled"`
105
}
106

107
// Detector is a spam detector interface.
108
type Detector interface {
109
        Check(req spamcheck.Request) (spam bool, cr []spamcheck.Response)
110
        ApprovedUsers() []approved.UserInfo
111
        AddApprovedUser(user approved.UserInfo) error
112
        RemoveApprovedUser(id string) error
113
}
114

115
// SpamFilter is a spam filter, bot interface.
116
type SpamFilter interface {
117
        UpdateSpam(msg string) error
118
        UpdateHam(msg string) error
119
        ReloadSamples() (err error)
120
        DynamicSamples() (spam, ham []string, err error)
121
        RemoveDynamicSpamSample(sample string) error
122
        RemoveDynamicHamSample(sample string) error
123
}
124

125
// Locator is a storage interface used to get user id by name and vice versa.
126
type Locator interface {
127
        UserIDByName(ctx context.Context, userName string) int64
128
        UserNameByID(ctx context.Context, userID int64) string
129
}
130

131
// DetectedSpam is a storage interface used to get detected spam messages and set added flag.
132
type DetectedSpam interface {
133
        Read(ctx context.Context) ([]storage.DetectedSpamInfo, error)
134
        SetAddedToSamplesFlag(ctx context.Context, id int64) error
135
        FindByUserID(ctx context.Context, userID int64) (*storage.DetectedSpamInfo, error)
136
}
137

138
// StorageEngine provides access to the database engine for operations like backup
139
type StorageEngine interface {
140
        Backup(ctx context.Context, w io.Writer) error
141
}
142

143
// NewServer creates a new web API server.
144
func NewServer(config Config) *Server {
26✔
145
        return &Server{Config: config}
26✔
146
}
26✔
147

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

3✔
158
        if s.AuthPasswd != "" || s.AuthHash != "" {
6✔
159
                log.Printf("[INFO] basic auth enabled for webapi server")
3✔
160
                if s.AuthHash != "" {
4✔
161
                        router.Use(rest.BasicAuthWithBcryptHashAndPrompt("tg-spam", s.AuthHash))
1✔
162
                } else {
3✔
163
                        router.Use(rest.BasicAuthWithPrompt("tg-spam", s.AuthPasswd))
2✔
164
                }
2✔
165
        } else {
×
166
                log.Printf("[WARN] basic auth disabled, access to webapi is not protected")
×
167
        }
×
168

169
        router = s.routes(router) // setup routes
3✔
170

3✔
171
        srv := &http.Server{Addr: s.ListenAddr, Handler: router, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second}
3✔
172
        go func() {
6✔
173
                <-ctx.Done()
3✔
174
                if err := srv.Shutdown(ctx); err != nil {
3✔
175
                        log.Printf("[WARN] failed to shutdown webapi server: %v", err)
×
176
                } else {
3✔
177
                        log.Printf("[INFO] webapi server stopped")
3✔
178
                }
3✔
179
        }()
180

181
        log.Printf("[INFO] start webapi server on %s", s.ListenAddr)
3✔
182
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
3✔
183
                return fmt.Errorf("failed to run server: %w", err)
×
184
        }
×
185
        return nil
3✔
186
}
187

188
func (s *Server) routes(router *routegroup.Bundle) *routegroup.Bundle {
5✔
189
        // auth api routes
5✔
190
        router.Route(func(authApi *routegroup.Bundle) {
10✔
191
                authApi.Use(s.authMiddleware(rest.BasicAuthWithUserPasswd("tg-spam", s.AuthPasswd)))
5✔
192
                authApi.HandleFunc("POST /check", s.checkMsgHandler)         // check a message for spam
5✔
193
                authApi.HandleFunc("GET /check/{user_id}", s.checkIDHandler) // check user id for spam
5✔
194

5✔
195
                authApi.Mount("/update").Route(func(r *routegroup.Bundle) {
10✔
196
                        // update spam/ham samples
5✔
197
                        r.HandleFunc("POST /spam", s.updateSampleHandler(s.SpamFilter.UpdateSpam)) // update spam samples
5✔
198
                        r.HandleFunc("POST /ham", s.updateSampleHandler(s.SpamFilter.UpdateHam))   // update ham samples
5✔
199
                })
5✔
200

201
                authApi.Mount("/delete").Route(func(r *routegroup.Bundle) {
10✔
202
                        // delete spam/ham samples
5✔
203
                        r.HandleFunc("POST /spam", s.deleteSampleHandler(s.SpamFilter.RemoveDynamicSpamSample))
5✔
204
                        r.HandleFunc("POST /ham", s.deleteSampleHandler(s.SpamFilter.RemoveDynamicHamSample))
5✔
205
                })
5✔
206

207
                authApi.Mount("/download").Route(func(r *routegroup.Bundle) {
10✔
208
                        r.HandleFunc("GET /spam", s.downloadSampleHandler(func(spam, _ []string) ([]string, string) {
5✔
209
                                return spam, "spam.txt"
×
210
                        }))
×
211
                        r.HandleFunc("GET /ham", s.downloadSampleHandler(func(_, ham []string) ([]string, string) {
5✔
212
                                return ham, "ham.txt"
×
213
                        }))
×
214
                        r.HandleFunc("GET /detected_spam", s.downloadDetectedSpamHandler)
5✔
215
                        r.HandleFunc("GET /backup", s.downloadBackupHandler)
5✔
216
                })
217

218
                authApi.HandleFunc("GET /samples", s.getDynamicSamplesHandler)    // get dynamic samples
5✔
219
                authApi.HandleFunc("PUT /samples", s.reloadDynamicSamplesHandler) // reload samples
5✔
220

5✔
221
                authApi.Mount("/users").Route(func(r *routegroup.Bundle) { // manage approved users
10✔
222
                        // add user to the approved list and storage
5✔
223
                        r.HandleFunc("POST /add", s.updateApprovedUsersHandler(s.Detector.AddApprovedUser))
5✔
224
                        // remove user from an approved list and storage
5✔
225
                        r.HandleFunc("POST /delete", s.updateApprovedUsersHandler(s.removeApprovedUser))
5✔
226
                        // get approved users
5✔
227
                        r.HandleFunc("GET /", s.getApprovedUsersHandler)
5✔
228
                })
5✔
229

230
                authApi.HandleFunc("GET /settings", func(w http.ResponseWriter, _ *http.Request) {
6✔
231
                        rest.RenderJSON(w, s.Settings)
1✔
232
                })
1✔
233
        })
234

235
        router.Route(func(webUI *routegroup.Bundle) {
10✔
236
                webUI.Use(s.authMiddleware(rest.BasicAuthWithPrompt("tg-spam", s.AuthPasswd)))
5✔
237
                webUI.HandleFunc("GET /", s.htmlSpamCheckHandler)                         // serve template for webUI UI
5✔
238
                webUI.HandleFunc("GET /manage_samples", s.htmlManageSamplesHandler)       // serve manage samples page
5✔
239
                webUI.HandleFunc("GET /manage_users", s.htmlManageUsersHandler)           // serve manage users page
5✔
240
                webUI.HandleFunc("GET /detected_spam", s.htmlDetectedSpamHandler)         // serve detected spam page
5✔
241
                webUI.HandleFunc("GET /list_settings", s.htmlSettingsHandler)             // serve settings
5✔
242
                webUI.HandleFunc("POST /detected_spam/add", s.htmlAddDetectedSpamHandler) // add detected spam to samples
5✔
243

5✔
244
                // handle logout - force Basic Auth re-authentication
5✔
245
                webUI.HandleFunc("GET /logout", func(w http.ResponseWriter, _ *http.Request) {
5✔
246
                        w.Header().Set("WWW-Authenticate", `Basic realm="tg-spam"`)
×
247
                        w.WriteHeader(http.StatusUnauthorized)
×
248
                        fmt.Fprintln(w, "Logged out successfully")
×
249
                })
×
250

251
                // serve only specific static files at root level
252
                staticFiles := newStaticFS(templateFS,
5✔
253
                        staticFileMapping{urlPath: "styles.css", filesysPath: "assets/styles.css"},
5✔
254
                        staticFileMapping{urlPath: "logo.png", filesysPath: "assets/logo.png"},
5✔
255
                        staticFileMapping{urlPath: "spinner.svg", filesysPath: "assets/spinner.svg"},
5✔
256
                )
5✔
257
                webUI.HandleFiles("/", http.FS(staticFiles))
5✔
258
        })
259

260
        return router
5✔
261
}
262

263
// checkMsgHandler handles POST /check request.
264
// it gets message text and user id from request body and returns spam status and check results.
265
func (s *Server) checkMsgHandler(w http.ResponseWriter, r *http.Request) {
7✔
266
        type CheckResultDisplay struct {
7✔
267
                Spam   bool
7✔
268
                Checks []spamcheck.Response
7✔
269
        }
7✔
270

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

7✔
273
        req := spamcheck.Request{CheckOnly: true}
7✔
274
        if !isHtmxRequest {
13✔
275
                // API request
6✔
276
                if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
7✔
277
                        w.WriteHeader(http.StatusBadRequest)
1✔
278
                        rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
279
                        log.Printf("[WARN] can't decode request: %v", err)
1✔
280
                        return
1✔
281
                }
1✔
282
        } else {
1✔
283
                // for hx-request (HTMX) we need to get the values from the form
1✔
284
                req.UserID = r.FormValue("user_id")
1✔
285
                req.UserName = r.FormValue("user_name")
1✔
286
                req.Msg = r.FormValue("msg")
1✔
287
        }
1✔
288

289
        spam, cr := s.Detector.Check(req)
6✔
290
        if !isHtmxRequest {
11✔
291
                // for API request return JSON
5✔
292
                rest.RenderJSON(w, rest.JSON{"spam": spam, "checks": cr})
5✔
293
                return
5✔
294
        }
5✔
295

296
        if req.Msg == "" || req.UserID == "" || req.UserID == "0" {
1✔
297
                w.Header().Set("HX-Retarget", "#error-message")
×
298
                fmt.Fprintln(w, "<div class='alert alert-danger'>userid and valid message required.</div>")
×
299
                return
×
300
        }
×
301

302
        // render result for HTMX request
303
        resultDisplay := CheckResultDisplay{
1✔
304
                Spam:   spam,
1✔
305
                Checks: cr,
1✔
306
        }
1✔
307

1✔
308
        if err := tmpl.ExecuteTemplate(w, "check_results", resultDisplay); err != nil {
1✔
309
                log.Printf("[WARN] can't execute result template: %v", err)
×
310
                http.Error(w, "Error rendering result", http.StatusInternalServerError)
×
311
                return
×
312
        }
×
313
}
314

315
// checkIDHandler handles GET /check/{user_id} request.
316
// it returns JSON with the status "spam" or "ham" for a given user id.
317
// if user is spammer, it also returns check results.
318
func (s *Server) checkIDHandler(w http.ResponseWriter, r *http.Request) {
2✔
319
        type info struct {
2✔
320
                UserName  string               `json:"user_name,omitempty"`
2✔
321
                Message   string               `json:"message,omitempty"`
2✔
322
                Timestamp time.Time            `json:"timestamp,omitempty"`
2✔
323
                Checks    []spamcheck.Response `json:"checks,omitempty"`
2✔
324
        }
2✔
325
        resp := struct {
2✔
326
                Status string `json:"status"`
2✔
327
                Info   *info  `json:"info,omitempty"`
2✔
328
        }{
2✔
329
                Status: "ham",
2✔
330
        }
2✔
331

2✔
332
        userID, err := strconv.ParseInt(r.PathValue("user_id"), 10, 64)
2✔
333
        if err != nil {
2✔
334
                w.WriteHeader(http.StatusBadRequest)
×
335
                rest.RenderJSON(w, rest.JSON{"error": "can't parse user id", "details": err.Error()})
×
336
                return
×
337
        }
×
338

339
        si, err := s.DetectedSpam.FindByUserID(r.Context(), userID)
2✔
340
        if err != nil {
2✔
341
                w.WriteHeader(http.StatusInternalServerError)
×
342
                rest.RenderJSON(w, rest.JSON{"error": "can't get user info", "details": err.Error()})
×
343
                return
×
344
        }
×
345
        if si != nil {
3✔
346
                resp.Status = "spam"
1✔
347
                resp.Info = &info{
1✔
348
                        UserName:  si.UserName,
1✔
349
                        Message:   si.Text,
1✔
350
                        Timestamp: si.Timestamp,
1✔
351
                        Checks:    si.Checks,
1✔
352
                }
1✔
353
        }
1✔
354
        rest.RenderJSON(w, resp)
2✔
355
}
356

357
// getDynamicSamplesHandler handles GET /samples request. It returns dynamic samples both for spam and ham.
358
func (s *Server) getDynamicSamplesHandler(w http.ResponseWriter, _ *http.Request) {
×
359
        spam, ham, err := s.SpamFilter.DynamicSamples()
×
360
        if err != nil {
×
361
                w.WriteHeader(http.StatusInternalServerError)
×
362
                rest.RenderJSON(w, rest.JSON{"error": "can't get dynamic samples", "details": err.Error()})
×
363
                return
×
364
        }
×
365
        rest.RenderJSON(w, rest.JSON{"spam": spam, "ham": ham})
×
366
}
367

368
// downloadSampleHandler handles GET /download/spam|ham request. It returns dynamic samples both for spam and ham.
369
func (s *Server) downloadSampleHandler(pickFn func(spam, ham []string) ([]string, string)) func(w http.ResponseWriter, r *http.Request) {
13✔
370
        return func(w http.ResponseWriter, _ *http.Request) {
16✔
371
                spam, ham, err := s.SpamFilter.DynamicSamples()
3✔
372
                if err != nil {
4✔
373
                        w.WriteHeader(http.StatusInternalServerError)
1✔
374
                        rest.RenderJSON(w, rest.JSON{"error": "can't get dynamic samples", "details": err.Error()})
1✔
375
                        return
1✔
376
                }
1✔
377
                samples, name := pickFn(spam, ham)
2✔
378
                body := strings.Join(samples, "\n")
2✔
379
                w.Header().Set("Content-Type", "text/plain; charset=utf-8")
2✔
380
                w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
2✔
381
                w.Header().Set("Content-Length", strconv.Itoa(len(body)))
2✔
382
                w.WriteHeader(http.StatusOK)
2✔
383
                _, _ = w.Write([]byte(body))
2✔
384
        }
385
}
386

387
// updateSampleHandler handles POST /update/spam|ham request. It updates dynamic samples both for spam and ham.
388
func (s *Server) updateSampleHandler(updFn func(msg string) error) func(w http.ResponseWriter, r *http.Request) {
13✔
389
        return func(w http.ResponseWriter, r *http.Request) {
18✔
390
                var req struct {
5✔
391
                        Msg string `json:"msg"`
5✔
392
                }
5✔
393

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

5✔
396
                if isHtmxRequest {
5✔
397
                        req.Msg = r.FormValue("msg")
×
398
                } else {
5✔
399
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
6✔
400
                                w.WriteHeader(http.StatusBadRequest)
1✔
401
                                rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
402
                                return
1✔
403
                        }
1✔
404
                }
405

406
                err := updFn(req.Msg)
4✔
407
                if err != nil {
5✔
408
                        w.WriteHeader(http.StatusInternalServerError)
1✔
409
                        rest.RenderJSON(w, rest.JSON{"error": "can't update samples", "details": err.Error()})
1✔
410
                        return
1✔
411
                }
1✔
412

413
                if isHtmxRequest {
3✔
414
                        s.renderSamples(w, "samples_list")
×
415
                } else {
3✔
416
                        rest.RenderJSON(w, rest.JSON{"updated": true, "msg": req.Msg})
3✔
417
                }
3✔
418
        }
419
}
420

421
// deleteSampleHandler handles DELETE /samples request. It deletes dynamic samples both for spam and ham.
422
func (s *Server) deleteSampleHandler(delFn func(msg string) error) func(w http.ResponseWriter, r *http.Request) {
13✔
423
        return func(w http.ResponseWriter, r *http.Request) {
18✔
424
                var req struct {
5✔
425
                        Msg string `json:"msg"`
5✔
426
                }
5✔
427
                isHtmxRequest := r.Header.Get("HX-Request") == "true"
5✔
428
                if isHtmxRequest {
6✔
429
                        req.Msg = r.FormValue("msg")
1✔
430
                } else {
5✔
431
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
4✔
432
                                w.WriteHeader(http.StatusBadRequest)
×
433
                                rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
×
434
                                return
×
435
                        }
×
436
                }
437

438
                if err := delFn(req.Msg); err != nil {
6✔
439
                        w.WriteHeader(http.StatusInternalServerError)
1✔
440
                        rest.RenderJSON(w, rest.JSON{"error": "can't delete sample", "details": err.Error()})
1✔
441
                        return
1✔
442
                }
1✔
443

444
                if isHtmxRequest {
5✔
445
                        s.renderSamples(w, "samples_list")
1✔
446
                } else {
4✔
447
                        rest.RenderJSON(w, rest.JSON{"deleted": true, "msg": req.Msg, "count": 1})
3✔
448
                }
3✔
449
        }
450
}
451

452
// reloadDynamicSamplesHandler handles PUT /samples request. It reloads dynamic samples from db storage.
453
func (s *Server) reloadDynamicSamplesHandler(w http.ResponseWriter, _ *http.Request) {
2✔
454
        if err := s.SpamFilter.ReloadSamples(); err != nil {
3✔
455
                w.WriteHeader(http.StatusInternalServerError)
1✔
456
                rest.RenderJSON(w, rest.JSON{"error": "can't reload samples", "details": err.Error()})
1✔
457
                return
1✔
458
        }
1✔
459
        rest.RenderJSON(w, rest.JSON{"reloaded": true})
1✔
460
}
461

462
// updateApprovedUsersHandler handles POST /users/add and /users/delete requests, it adds or removes users from approved list.
463
func (s *Server) updateApprovedUsersHandler(updFn func(ui approved.UserInfo) error) func(w http.ResponseWriter, r *http.Request) {
14✔
464
        return func(w http.ResponseWriter, r *http.Request) {
23✔
465
                req := approved.UserInfo{}
9✔
466
                isHtmxRequest := r.Header.Get("HX-Request") == "true"
9✔
467
                if isHtmxRequest {
10✔
468
                        req.UserID = r.FormValue("user_id")
1✔
469
                        req.UserName = r.FormValue("user_name")
1✔
470
                } else {
9✔
471
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
9✔
472
                                w.WriteHeader(http.StatusBadRequest)
1✔
473
                                rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
474
                                return
1✔
475
                        }
1✔
476
                }
477

478
                // try to get userID from request and fallback to userName lookup if it's empty
479
                if req.UserID == "" {
12✔
480
                        req.UserID = strconv.FormatInt(s.Locator.UserIDByName(r.Context(), req.UserName), 10)
4✔
481
                }
4✔
482

483
                if req.UserID == "" || req.UserID == "0" {
9✔
484
                        if isHtmxRequest {
1✔
485
                                w.Header().Set("HX-Retarget", "#error-message")
×
486
                                fmt.Fprintln(w, "<div class='alert alert-danger'>Either userid or valid username required.</div>")
×
487
                                return
×
488
                        }
×
489
                        w.WriteHeader(http.StatusBadRequest)
1✔
490
                        rest.RenderJSON(w, rest.JSON{"error": "user ID is required"})
1✔
491
                        return
1✔
492
                }
493

494
                // add or remove user from the approved list of detector
495
                if err := updFn(req); err != nil {
7✔
496
                        w.WriteHeader(http.StatusInternalServerError)
×
497
                        rest.RenderJSON(w, rest.JSON{"error": "can't update approved users", "details": err.Error()})
×
498
                        return
×
499
                }
×
500

501
                if isHtmxRequest {
8✔
502
                        users := s.Detector.ApprovedUsers()
1✔
503
                        tmplData := struct {
1✔
504
                                ApprovedUsers      []approved.UserInfo
1✔
505
                                TotalApprovedUsers int
1✔
506
                        }{
1✔
507
                                ApprovedUsers:      users,
1✔
508
                                TotalApprovedUsers: len(users),
1✔
509
                        }
1✔
510

1✔
511
                        if err := tmpl.ExecuteTemplate(w, "users_list", tmplData); err != nil {
1✔
512
                                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
513
                                return
×
514
                        }
×
515

516
                } else {
6✔
517
                        rest.RenderJSON(w, rest.JSON{"updated": true, "user_id": req.UserID, "user_name": req.UserName})
6✔
518
                }
6✔
519
        }
520
}
521

522
// removeApprovedUser is adopter for updateApprovedUsersHandler updFn
523
func (s *Server) removeApprovedUser(req approved.UserInfo) error {
2✔
524
        return s.Detector.RemoveApprovedUser(req.UserID)
2✔
525
}
2✔
526

527
// getApprovedUsersHandler handles GET /users request. It returns list of approved users.
528
func (s *Server) getApprovedUsersHandler(w http.ResponseWriter, _ *http.Request) {
1✔
529
        rest.RenderJSON(w, rest.JSON{"user_ids": s.Detector.ApprovedUsers()})
1✔
530
}
1✔
531

532
// htmlSpamCheckHandler handles GET / request.
533
// It returns rendered spam_check.html template with all the components.
534
func (s *Server) htmlSpamCheckHandler(w http.ResponseWriter, _ *http.Request) {
1✔
535
        tmplData := struct {
1✔
536
                Version string
1✔
537
        }{
1✔
538
                Version: s.Version,
1✔
539
        }
1✔
540

1✔
541
        if err := tmpl.ExecuteTemplate(w, "spam_check.html", tmplData); err != nil {
1✔
542
                log.Printf("[WARN] can't execute template: %v", err)
×
543
                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
544
                return
×
545
        }
×
546
}
547

548
// htmlManageSamplesHandler handles GET /manage_samples request.
549
// It returns rendered manage_samples.html template with all the components.
550
func (s *Server) htmlManageSamplesHandler(w http.ResponseWriter, _ *http.Request) {
1✔
551
        s.renderSamples(w, "manage_samples.html")
1✔
552
}
1✔
553

554
func (s *Server) htmlManageUsersHandler(w http.ResponseWriter, _ *http.Request) {
1✔
555
        users := s.Detector.ApprovedUsers()
1✔
556
        tmplData := struct {
1✔
557
                ApprovedUsers      []approved.UserInfo
1✔
558
                TotalApprovedUsers int
1✔
559
        }{
1✔
560
                ApprovedUsers:      users,
1✔
561
                TotalApprovedUsers: len(users),
1✔
562
        }
1✔
563
        tmplData.TotalApprovedUsers = len(tmplData.ApprovedUsers)
1✔
564

1✔
565
        if err := tmpl.ExecuteTemplate(w, "manage_users.html", tmplData); err != nil {
1✔
566
                log.Printf("[WARN] can't execute template: %v", err)
×
567
                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
568
                return
×
569
        }
×
570
}
571

572
func (s *Server) htmlDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
2✔
573
        ds, err := s.DetectedSpam.Read(r.Context())
2✔
574
        if err != nil {
3✔
575
                log.Printf("[ERROR] Failed to fetch detected spam: %v", err)
1✔
576
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
1✔
577
                return
1✔
578
        }
1✔
579

580
        // clean up detected spam entries
581
        for i, d := range ds {
3✔
582
                d.Text = strings.ReplaceAll(d.Text, "'", " ")
2✔
583
                d.Text = strings.ReplaceAll(d.Text, "\n", " ")
2✔
584
                d.Text = strings.ReplaceAll(d.Text, "\r", " ")
2✔
585
                d.Text = strings.ReplaceAll(d.Text, "\t", " ")
2✔
586
                d.Text = strings.ReplaceAll(d.Text, "\"", " ")
2✔
587
                d.Text = strings.ReplaceAll(d.Text, "\\", " ")
2✔
588
                ds[i] = d
2✔
589
        }
2✔
590

591
        tmplData := struct {
1✔
592
                DetectedSpamEntries []storage.DetectedSpamInfo
1✔
593
                TotalDetectedSpam   int
1✔
594
        }{
1✔
595
                DetectedSpamEntries: ds,
1✔
596
                TotalDetectedSpam:   len(ds),
1✔
597
        }
1✔
598

1✔
599
        if err := tmpl.ExecuteTemplate(w, "detected_spam.html", tmplData); err != nil {
1✔
600
                log.Printf("[WARN] can't execute template: %v", err)
×
601
                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
602
                return
×
603
        }
×
604
}
605

606
func (s *Server) htmlAddDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
1✔
607
        reportErr := func(err error, _ int) {
1✔
608
                w.Header().Set("HX-Retarget", "#error-message")
×
609
                fmt.Fprintf(w, "<div class='alert alert-danger'>%s</div>", err)
×
610
        }
×
611
        msg := r.FormValue("msg")
1✔
612

1✔
613
        id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
1✔
614
        if err != nil || msg == "" {
1✔
615
                log.Printf("[WARN] bad request: %v", err)
×
616
                reportErr(fmt.Errorf("bad request: %v", err), http.StatusBadRequest)
×
617
                return
×
618
        }
×
619

620
        if err := s.SpamFilter.UpdateSpam(msg); err != nil {
1✔
621
                log.Printf("[WARN] failed to update spam samples: %v", err)
×
622
                reportErr(fmt.Errorf("can't update spam samples: %v", err), http.StatusInternalServerError)
×
623
                return
×
624

×
625
        }
×
626
        if err := s.DetectedSpam.SetAddedToSamplesFlag(r.Context(), id); err != nil {
1✔
627
                log.Printf("[WARN] failed to update detected spam: %v", err)
×
628
                reportErr(fmt.Errorf("can't update detected spam: %v", err), http.StatusInternalServerError)
×
629
                return
×
630
        }
×
631
        w.WriteHeader(http.StatusOK)
1✔
632
}
633

634
func (s *Server) htmlSettingsHandler(w http.ResponseWriter, _ *http.Request) {
1✔
635
        data := struct {
1✔
636
                Settings
1✔
637
                Version string
1✔
638
        }{
1✔
639
                Settings: s.Settings,
1✔
640
                Version:  s.Version,
1✔
641
        }
1✔
642

1✔
643
        if err := tmpl.ExecuteTemplate(w, "settings.html", data); err != nil {
1✔
644
                log.Printf("[WARN] can't execute template: %v", err)
×
645
                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
646
                return
×
647
        }
×
648
}
649

650
func (s *Server) downloadDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
3✔
651
        ctx := r.Context()
3✔
652
        spam, err := s.DetectedSpam.Read(ctx)
3✔
653
        if err != nil {
4✔
654
                w.WriteHeader(http.StatusInternalServerError)
1✔
655
                rest.RenderJSON(w, rest.JSON{"error": "can't get detected spam", "details": err.Error()})
1✔
656
                return
1✔
657
        }
1✔
658

659
        type jsonSpamInfo struct {
2✔
660
                ID        int64                `json:"id"`
2✔
661
                GID       string               `json:"gid"`
2✔
662
                Text      string               `json:"text"`
2✔
663
                UserID    int64                `json:"user_id"`
2✔
664
                UserName  string               `json:"user_name"`
2✔
665
                Timestamp time.Time            `json:"timestamp"`
2✔
666
                Added     bool                 `json:"added"`
2✔
667
                Checks    []spamcheck.Response `json:"checks"`
2✔
668
        }
2✔
669

2✔
670
        // convert entries to jsonl format with lowercase fields
2✔
671
        lines := make([]string, 0, len(spam))
2✔
672
        for _, entry := range spam {
5✔
673
                data, err := json.Marshal(jsonSpamInfo{
3✔
674
                        ID:        entry.ID,
3✔
675
                        GID:       entry.GID,
3✔
676
                        Text:      entry.Text,
3✔
677
                        UserID:    entry.UserID,
3✔
678
                        UserName:  entry.UserName,
3✔
679
                        Timestamp: entry.Timestamp,
3✔
680
                        Added:     entry.Added,
3✔
681
                        Checks:    entry.Checks,
3✔
682
                })
3✔
683
                if err != nil {
3✔
684
                        w.WriteHeader(http.StatusInternalServerError)
×
685
                        rest.RenderJSON(w, rest.JSON{"error": "can't marshal entry", "details": err.Error()})
×
686
                        return
×
687
                }
×
688
                lines = append(lines, string(data))
3✔
689
        }
690

691
        body := strings.Join(lines, "\n")
2✔
692
        w.Header().Set("Content-Type", "application/x-jsonlines")
2✔
693
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "detected_spam.jsonl"))
2✔
694
        w.Header().Set("Content-Length", strconv.Itoa(len(body)))
2✔
695
        w.WriteHeader(http.StatusOK)
2✔
696
        _, _ = w.Write([]byte(body))
2✔
697
}
698

699
func (s *Server) renderSamples(w http.ResponseWriter, tmplName string) {
3✔
700
        spam, ham, err := s.SpamFilter.DynamicSamples()
3✔
701
        if err != nil {
3✔
702
                w.WriteHeader(http.StatusInternalServerError)
×
703
                rest.RenderJSON(w, rest.JSON{"error": "can't fetch samples", "details": err.Error()})
×
704
                return
×
705
        }
×
706

707
        spam, ham = s.reverseSamples(spam, ham)
3✔
708

3✔
709
        type smpleWithID struct {
3✔
710
                ID     string
3✔
711
                Sample string
3✔
712
        }
3✔
713

3✔
714
        makeID := func(s string) string {
15✔
715
                hash := sha1.New() //nolint
12✔
716
                if _, err := hash.Write([]byte(s)); err != nil {
12✔
717
                        return fmt.Sprintf("%x", s)
×
718
                }
×
719
                return fmt.Sprintf("%x", hash.Sum(nil))
12✔
720
        }
721

722
        tmplData := struct {
3✔
723
                SpamSamples      []smpleWithID
3✔
724
                HamSamples       []smpleWithID
3✔
725
                TotalHamSamples  int
3✔
726
                TotalSpamSamples int
3✔
727
        }{
3✔
728
                TotalHamSamples:  len(ham),
3✔
729
                TotalSpamSamples: len(spam),
3✔
730
        }
3✔
731
        for _, s := range spam {
9✔
732
                tmplData.SpamSamples = append(tmplData.SpamSamples, smpleWithID{ID: makeID(s), Sample: s})
6✔
733
        }
6✔
734
        for _, h := range ham {
9✔
735
                tmplData.HamSamples = append(tmplData.HamSamples, smpleWithID{ID: makeID(h), Sample: h})
6✔
736
        }
6✔
737

738
        if err := tmpl.ExecuteTemplate(w, tmplName, tmplData); err != nil {
3✔
739
                w.WriteHeader(http.StatusInternalServerError)
×
740
                rest.RenderJSON(w, rest.JSON{"error": "can't execute template", "details": err.Error()})
×
741
                return
×
742
        }
×
743
}
744

745
func (s *Server) authMiddleware(mw func(next http.Handler) http.Handler) func(next http.Handler) http.Handler {
10✔
746
        if s.AuthPasswd == "" {
16✔
747
                return func(next http.Handler) http.Handler {
102✔
748
                        return next
96✔
749
                }
96✔
750
        }
751
        return func(next http.Handler) http.Handler {
68✔
752
                return mw(next)
64✔
753
        }
64✔
754
}
755

756
// reverseSamples returns reversed lists of spam and ham samples
757
func (s *Server) reverseSamples(spam, ham []string) (revSpam, revHam []string) {
6✔
758
        revSpam = make([]string, len(spam))
6✔
759
        revHam = make([]string, len(ham))
6✔
760

6✔
761
        for i, j := 0, len(spam)-1; i < len(spam); i, j = i+1, j-1 {
16✔
762
                revSpam[i] = spam[j]
10✔
763
        }
10✔
764
        for i, j := 0, len(ham)-1; i < len(ham); i, j = i+1, j-1 {
16✔
765
                revHam[i] = ham[j]
10✔
766
        }
10✔
767
        return revSpam, revHam
6✔
768
}
769

770
// staticFS is a filtered filesystem that only exposes specific static files
771
type staticFS struct {
772
        fs        fs.FS
773
        urlToPath map[string]string
774
}
775

776
// staticFileMapping defines a mapping between URL path and filesystem path
777
type staticFileMapping struct {
778
        urlPath     string
779
        filesysPath string
780
}
781

782
func newStaticFS(fsys fs.FS, files ...staticFileMapping) *staticFS {
5✔
783
        urlToPath := make(map[string]string)
5✔
784
        for _, f := range files {
20✔
785
                urlToPath[f.urlPath] = f.filesysPath
15✔
786
        }
15✔
787

788
        return &staticFS{
5✔
789
                fs:        fsys,
5✔
790
                urlToPath: urlToPath,
5✔
791
        }
5✔
792
}
793

794
func (sfs *staticFS) Open(name string) (fs.File, error) {
5✔
795
        name = path.Clean("/" + name)[1:]
5✔
796
        if fsPath, ok := sfs.urlToPath[name]; ok {
8✔
797
                return sfs.fs.Open(fsPath)
3✔
798
        }
3✔
799
        return nil, fs.ErrNotExist
2✔
800
}
801

802
// GenerateRandomPassword generates a random password of a given length
803
func GenerateRandomPassword(length int) (string, error) {
2✔
804
        const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+"
2✔
805

2✔
806
        var password strings.Builder
2✔
807
        charsetSize := big.NewInt(int64(len(charset)))
2✔
808

2✔
809
        for i := 0; i < length; i++ {
66✔
810
                randomNumber, err := rand.Int(rand.Reader, charsetSize)
64✔
811
                if err != nil {
64✔
812
                        return "", err
×
813
                }
×
814

815
                password.WriteByte(charset[randomNumber.Int64()])
64✔
816
        }
817

818
        return password.String(), nil
2✔
819
}
820

821
// downloadBackupHandler streams a database backup as an SQL file with gzip compression
822
// Files are always compressed and always have .gz extension to ensure consistency
823
func (s *Server) downloadBackupHandler(w http.ResponseWriter, r *http.Request) {
3✔
824
        if s.StorageEngine == nil {
4✔
825
                w.WriteHeader(http.StatusInternalServerError)
1✔
826
                rest.RenderJSON(w, rest.JSON{"error": "storage engine not available"})
1✔
827
                return
1✔
828
        }
1✔
829

830
        // set filename based on database type and timestamp
831
        dbType := "db"
2✔
832
        sqlEng, ok := s.StorageEngine.(*engine.SQL)
2✔
833
        if ok {
2✔
834
                dbType = string(sqlEng.Type())
×
835
        }
×
836
        timestamp := time.Now().Format("20060102-150405")
2✔
837

2✔
838
        // always use a .gz extension as the content is always compressed
2✔
839
        filename := fmt.Sprintf("tg-spam-backup-%s-%s.sql.gz", dbType, timestamp)
2✔
840

2✔
841
        // set headers for file download
2✔
842
        w.Header().Set("Content-Type", "application/sql")
2✔
843
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
2✔
844
        w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
2✔
845
        w.Header().Set("Pragma", "no-cache")
2✔
846
        w.Header().Set("Expires", "0")
2✔
847
        w.Header().Set("Content-Encoding", "gzip")
2✔
848

2✔
849
        // always apply gzip compression
2✔
850
        gzipWriter := gzip.NewWriter(w)
2✔
851
        defer gzipWriter.Close()
2✔
852

2✔
853
        // stream backup directly to response through gzip
2✔
854
        if err := s.StorageEngine.Backup(r.Context(), gzipWriter); err != nil {
3✔
855
                log.Printf("[ERROR] failed to create backup: %v", err)
1✔
856
                // we've already started writing the response, so we can't send a proper error response
1✔
857
                // just log the error and return
1✔
858
                return
1✔
859
        }
1✔
860

861
        // ensure gzip writer is properly flushed and closed
862
        if err := gzipWriter.Close(); err != nil {
1✔
NEW
863
                log.Printf("[ERROR] failed to close gzip writer: %v", err)
×
UNCOV
864
        }
×
865
}
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