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

umputun / tg-spam / 13254778469

11 Feb 2025 02:41AM UTC coverage: 79.748% (+0.6%) from 79.113%
13254778469

push

github

umputun
missing vendors

3477 of 4360 relevant lines covered (79.75%)

81.56 hits per line

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

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

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

19
        "github.com/didip/tollbooth/v8"
20
        log "github.com/go-pkgz/lgr"
21
        "github.com/go-pkgz/rest"
22
        "github.com/go-pkgz/rest/logger"
23
        "github.com/go-pkgz/routegroup"
24

25
        "github.com/umputun/tg-spam/app/storage"
26
        "github.com/umputun/tg-spam/lib/approved"
27
        "github.com/umputun/tg-spam/lib/spamcheck"
28
)
29

30
//go:generate moq --out mocks/detector.go --pkg mocks --with-resets --skip-ensure . Detector
31
//go:generate moq --out mocks/spam_filter.go --pkg mocks --with-resets --skip-ensure . SpamFilter
32
//go:generate moq --out mocks/locator.go --pkg mocks --with-resets --skip-ensure . Locator
33
//go:generate moq --out mocks/detected_spam.go --pkg mocks --with-resets --skip-ensure . DetectedSpam
34

35
//go:embed assets/* assets/components/*
36
var templateFS embed.FS
37
var tmpl = template.Must(template.ParseFS(templateFS, "assets/*.html", "assets/components/*.html"))
38

39
// Server is a web API server.
40
type Server struct {
41
        Config
42
}
43

44
// Config defines  server parameters
45
type Config struct {
46
        Version      string       // version to show in /ping
47
        ListenAddr   string       // listen address
48
        Detector     Detector     // spam detector
49
        SpamFilter   SpamFilter   // spam filter (bot)
50
        DetectedSpam DetectedSpam // detected spam accessor
51
        Locator      Locator      // locator for user info
52
        AuthPasswd   string       // basic auth password for user "tg-spam"
53
        AuthHash     string       // basic auth hash for user "tg-spam". If both AuthPasswd and AuthHash are provided, AuthHash is used
54
        Dbg          bool         // debug mode
55
        Settings     Settings     // application settings
56
}
57

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

100
// Detector is a spam detector interface.
101
type Detector interface {
102
        Check(req spamcheck.Request) (spam bool, cr []spamcheck.Response)
103
        ApprovedUsers() []approved.UserInfo
104
        AddApprovedUser(user approved.UserInfo) error
105
        RemoveApprovedUser(id string) error
106
}
107

108
// SpamFilter is a spam filter, bot interface.
109
type SpamFilter interface {
110
        UpdateSpam(msg string) error
111
        UpdateHam(msg string) error
112
        ReloadSamples() (err error)
113
        DynamicSamples() (spam, ham []string, err error)
114
        RemoveDynamicSpamSample(sample string) error
115
        RemoveDynamicHamSample(sample string) error
116
}
117

118
// Locator is a storage interface used to get user id by name and vice versa.
119
type Locator interface {
120
        UserIDByName(ctx context.Context, userName string) int64
121
        UserNameByID(ctx context.Context, userID int64) string
122
}
123

124
// DetectedSpam is a storage interface used to get detected spam messages and set added flag.
125
type DetectedSpam interface {
126
        Read(ctx context.Context) ([]storage.DetectedSpamInfo, error)
127
        SetAddedToSamplesFlag(ctx context.Context, id int64) error
128
        FindByUserID(ctx context.Context, userID int64) (*storage.DetectedSpamInfo, error)
129
}
130

131
// NewServer creates a new web API server.
132
func NewServer(config Config) *Server {
20✔
133
        return &Server{Config: config}
20✔
134
}
20✔
135

136
// Run starts server and accepts requests checking for spam messages.
137
func (s *Server) Run(ctx context.Context) error {
3✔
138
        router := routegroup.New(http.NewServeMux())
3✔
139
        router.Use(rest.Recoverer(log.Default()))
3✔
140
        router.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]")).Handler)
3✔
141
        router.Use(rest.Throttle(1000))
3✔
142
        router.Use(rest.AppInfo("tg-spam", "umputun", s.Version), rest.Ping)
3✔
143
        router.Use(tollbooth.HTTPMiddleware(tollbooth.NewLimiter(50, nil)))
3✔
144
        router.Use(rest.SizeLimit(1024 * 1024)) // 1M max request size
3✔
145

3✔
146
        if s.AuthPasswd != "" || s.AuthHash != "" {
6✔
147
                log.Printf("[INFO] basic auth enabled for webapi server")
3✔
148
                if s.AuthHash != "" {
4✔
149
                        router.Use(rest.BasicAuthWithBcryptHashAndPrompt("tg-spam", s.AuthHash))
1✔
150
                } else {
3✔
151
                        router.Use(rest.BasicAuthWithPrompt("tg-spam", s.AuthPasswd))
2✔
152
                }
2✔
153
        } else {
×
154
                log.Printf("[WARN] basic auth disabled, access to webapi is not protected")
×
155
        }
×
156

157
        router = s.routes(router) // setup routes
3✔
158

3✔
159
        srv := &http.Server{Addr: s.ListenAddr, Handler: router, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second}
3✔
160
        go func() {
6✔
161
                <-ctx.Done()
3✔
162
                if err := srv.Shutdown(ctx); err != nil {
3✔
163
                        log.Printf("[WARN] failed to shutdown webapi server: %v", err)
×
164
                } else {
3✔
165
                        log.Printf("[INFO] webapi server stopped")
3✔
166
                }
3✔
167
        }()
168

169
        log.Printf("[INFO] start webapi server on %s", s.ListenAddr)
3✔
170
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
3✔
171
                return fmt.Errorf("failed to run server: %w", err)
×
172
        }
×
173
        return nil
3✔
174
}
175

176
func (s *Server) routes(router *routegroup.Bundle) *routegroup.Bundle {
4✔
177
        // auth api routes
4✔
178
        router.Route(func(authApi *routegroup.Bundle) {
8✔
179
                authApi.Use(s.authMiddleware(rest.BasicAuthWithUserPasswd("tg-spam", s.AuthPasswd)))
4✔
180
                authApi.HandleFunc("POST /check", s.checkMsgHandler)         // check a message for spam
4✔
181
                authApi.HandleFunc("GET /check/{user_id}", s.checkIDHandler) // check user id for spam
4✔
182

4✔
183
                authApi.Mount("/update").Route(func(r *routegroup.Bundle) {
8✔
184
                        // update spam/ham samples
4✔
185
                        r.HandleFunc("POST /spam", s.updateSampleHandler(s.SpamFilter.UpdateSpam)) // update spam samples
4✔
186
                        r.HandleFunc("POST /ham", s.updateSampleHandler(s.SpamFilter.UpdateHam))   // update ham samples
4✔
187
                })
4✔
188

189
                authApi.Mount("/delete").Route(func(r *routegroup.Bundle) {
8✔
190
                        // delete spam/ham samples
4✔
191
                        r.HandleFunc("POST /spam", s.deleteSampleHandler(s.SpamFilter.RemoveDynamicSpamSample))
4✔
192
                        r.HandleFunc("POST /ham", s.deleteSampleHandler(s.SpamFilter.RemoveDynamicHamSample))
4✔
193
                })
4✔
194

195
                authApi.Mount("/download").Route(func(r *routegroup.Bundle) {
8✔
196
                        r.HandleFunc("GET /spam", s.downloadSampleHandler(func(spam, _ []string) ([]string, string) {
4✔
197
                                return spam, "spam.txt"
×
198
                        }))
×
199
                        r.HandleFunc("GET /ham", s.downloadSampleHandler(func(_, ham []string) ([]string, string) {
4✔
200
                                return ham, "ham.txt"
×
201
                        }))
×
202
                })
203

204
                authApi.HandleFunc("GET /samples", s.getDynamicSamplesHandler)    // get dynamic samples
4✔
205
                authApi.HandleFunc("PUT /samples", s.reloadDynamicSamplesHandler) // reload samples
4✔
206

4✔
207
                authApi.Mount("/users").Route(func(r *routegroup.Bundle) { // manage approved users
8✔
208
                        // add user to the approved list and storage
4✔
209
                        r.HandleFunc("POST /add", s.updateApprovedUsersHandler(s.Detector.AddApprovedUser))
4✔
210
                        // remove user from an approved list and storage
4✔
211
                        r.HandleFunc("POST /delete", s.updateApprovedUsersHandler(s.removeApprovedUser))
4✔
212
                        // get approved users
4✔
213
                        r.HandleFunc("GET /", s.getApprovedUsersHandler)
4✔
214
                })
4✔
215

216
                authApi.HandleFunc("GET /settings", func(w http.ResponseWriter, _ *http.Request) {
5✔
217
                        rest.RenderJSON(w, s.Settings)
1✔
218
                })
1✔
219
        })
220

221
        router.Route(func(webUI *routegroup.Bundle) {
8✔
222
                webUI.Use(s.authMiddleware(rest.BasicAuthWithPrompt("tg-spam", s.AuthPasswd)))
4✔
223
                webUI.HandleFunc("GET /", s.htmlSpamCheckHandler)                         // serve template for webUI UI
4✔
224
                webUI.HandleFunc("GET /manage_samples", s.htmlManageSamplesHandler)       // serve manage samples page
4✔
225
                webUI.HandleFunc("GET /manage_users", s.htmlManageUsersHandler)           // serve manage users page
4✔
226
                webUI.HandleFunc("GET /detected_spam", s.htmlDetectedSpamHandler)         // serve detected spam page
4✔
227
                webUI.HandleFunc("GET /list_settings", s.htmlSettingsHandler)             // serve settings
4✔
228
                webUI.HandleFunc("GET /styles.css", s.stylesHandler)                      // serve styles.css
4✔
229
                webUI.HandleFunc("GET /logo.png", s.logoHandler)                          // serve logo.png
4✔
230
                webUI.HandleFunc("GET /spinner.svg", s.spinnerHandler)                    // serve spinner.svg
4✔
231
                webUI.HandleFunc("POST /detected_spam/add", s.htmlAddDetectedSpamHandler) // add detected spam to samples
4✔
232
        })
4✔
233

234
        return router
4✔
235
}
236

237
// checkMsgHandler handles POST /check request.
238
// it gets message text and user id from request body and returns spam status and check results.
239
func (s *Server) checkMsgHandler(w http.ResponseWriter, r *http.Request) {
7✔
240
        type CheckResultDisplay struct {
7✔
241
                Spam   bool
7✔
242
                Checks []spamcheck.Response
7✔
243
        }
7✔
244

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

7✔
247
        req := spamcheck.Request{CheckOnly: true}
7✔
248
        if !isHtmxRequest {
13✔
249
                // API request
6✔
250
                if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
7✔
251
                        w.WriteHeader(http.StatusBadRequest)
1✔
252
                        rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
253
                        log.Printf("[WARN] can't decode request: %v", err)
1✔
254
                        return
1✔
255
                }
1✔
256
        } else {
1✔
257
                // for hx-request (HTMX) we need to get the values from the form
1✔
258
                req.UserID = r.FormValue("user_id")
1✔
259
                req.UserName = r.FormValue("user_name")
1✔
260
                req.Msg = r.FormValue("msg")
1✔
261
        }
1✔
262

263
        spam, cr := s.Detector.Check(req)
6✔
264
        if !isHtmxRequest {
11✔
265
                // for API request return JSON
5✔
266
                rest.RenderJSON(w, rest.JSON{"spam": spam, "checks": cr})
5✔
267
                return
5✔
268
        }
5✔
269

270
        if req.Msg == "" || req.UserID == "" || req.UserID == "0" {
1✔
271
                w.Header().Set("HX-Retarget", "#error-message")
×
272
                fmt.Fprintln(w, "<div class='alert alert-danger'>userid and valid message required.</div>")
×
273
                return
×
274
        }
×
275

276
        // render result for HTMX request
277
        resultDisplay := CheckResultDisplay{
1✔
278
                Spam:   spam,
1✔
279
                Checks: cr,
1✔
280
        }
1✔
281

1✔
282
        if err := tmpl.ExecuteTemplate(w, "check_results", resultDisplay); err != nil {
1✔
283
                log.Printf("[WARN] can't execute result template: %v", err)
×
284
                http.Error(w, "Error rendering result", http.StatusInternalServerError)
×
285
                return
×
286
        }
×
287
}
288

289
// checkIDHandler handles GET /check/{user_id} request.
290
// it returns JSON with the status "spam" or "ham" for a given user id.
291
// if user is spammer, it also returns check results.
292
func (s *Server) checkIDHandler(w http.ResponseWriter, r *http.Request) {
2✔
293
        type info struct {
2✔
294
                UserName  string               `json:"user_name,omitempty"`
2✔
295
                Message   string               `json:"message,omitempty"`
2✔
296
                Timestamp time.Time            `json:"timestamp,omitempty"`
2✔
297
                Checks    []spamcheck.Response `json:"checks,omitempty"`
2✔
298
        }
2✔
299
        resp := struct {
2✔
300
                Status string `json:"status"`
2✔
301
                Info   *info  `json:"info,omitempty"`
2✔
302
        }{
2✔
303
                Status: "ham",
2✔
304
        }
2✔
305

2✔
306
        userID, err := strconv.ParseInt(r.PathValue("user_id"), 10, 64)
2✔
307
        if err != nil {
2✔
308
                w.WriteHeader(http.StatusBadRequest)
×
309
                rest.RenderJSON(w, rest.JSON{"error": "can't parse user id", "details": err.Error()})
×
310
                return
×
311
        }
×
312

313
        si, err := s.DetectedSpam.FindByUserID(r.Context(), userID)
2✔
314
        if err != nil {
2✔
315
                w.WriteHeader(http.StatusInternalServerError)
×
316
                rest.RenderJSON(w, rest.JSON{"error": "can't get user info", "details": err.Error()})
×
317
                return
×
318
        }
×
319
        if si != nil {
3✔
320
                resp.Status = "spam"
1✔
321
                resp.Info = &info{
1✔
322
                        UserName:  si.UserName,
1✔
323
                        Message:   si.Text,
1✔
324
                        Timestamp: si.Timestamp,
1✔
325
                        Checks:    si.Checks,
1✔
326
                }
1✔
327
        }
1✔
328
        rest.RenderJSON(w, resp)
2✔
329
}
330

331
// getDynamicSamplesHandler handles GET /samples request. It returns dynamic samples both for spam and ham.
332
func (s *Server) getDynamicSamplesHandler(w http.ResponseWriter, _ *http.Request) {
×
333
        spam, ham, err := s.SpamFilter.DynamicSamples()
×
334
        if err != nil {
×
335
                w.WriteHeader(http.StatusInternalServerError)
×
336
                rest.RenderJSON(w, rest.JSON{"error": "can't get dynamic samples", "details": err.Error()})
×
337
                return
×
338
        }
×
339
        rest.RenderJSON(w, rest.JSON{"spam": spam, "ham": ham})
×
340
}
341

342
// downloadSampleHandler handles GET /download/spam|ham request. It returns dynamic samples both for spam and ham.
343
func (s *Server) downloadSampleHandler(pickFn func(spam, ham []string) ([]string, string)) func(w http.ResponseWriter, r *http.Request) {
11✔
344
        return func(w http.ResponseWriter, _ *http.Request) {
14✔
345
                spam, ham, err := s.SpamFilter.DynamicSamples()
3✔
346
                if err != nil {
4✔
347
                        w.WriteHeader(http.StatusInternalServerError)
1✔
348
                        rest.RenderJSON(w, rest.JSON{"error": "can't get dynamic samples", "details": err.Error()})
1✔
349
                        return
1✔
350
                }
1✔
351
                samples, name := pickFn(spam, ham)
2✔
352
                body := strings.Join(samples, "\n")
2✔
353
                w.Header().Set("Content-Type", "text/plain; charset=utf-8")
2✔
354
                w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
2✔
355
                w.Header().Set("Content-Length", strconv.Itoa(len(body)))
2✔
356
                w.WriteHeader(http.StatusOK)
2✔
357
                _, _ = w.Write([]byte(body))
2✔
358
        }
359
}
360

361
// updateSampleHandler handles POST /update/spam|ham request. It updates dynamic samples both for spam and ham.
362
func (s *Server) updateSampleHandler(updFn func(msg string) error) func(w http.ResponseWriter, r *http.Request) {
11✔
363
        return func(w http.ResponseWriter, r *http.Request) {
16✔
364
                var req struct {
5✔
365
                        Msg string `json:"msg"`
5✔
366
                }
5✔
367

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

5✔
370
                if isHtmxRequest {
5✔
371
                        req.Msg = r.FormValue("msg")
×
372
                } else {
5✔
373
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
6✔
374
                                w.WriteHeader(http.StatusBadRequest)
1✔
375
                                rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
376
                                return
1✔
377
                        }
1✔
378
                }
379

380
                err := updFn(req.Msg)
4✔
381
                if err != nil {
5✔
382
                        w.WriteHeader(http.StatusInternalServerError)
1✔
383
                        rest.RenderJSON(w, rest.JSON{"error": "can't update samples", "details": err.Error()})
1✔
384
                        return
1✔
385
                }
1✔
386

387
                if isHtmxRequest {
3✔
388
                        s.renderSamples(w, "samples_list")
×
389
                } else {
3✔
390
                        rest.RenderJSON(w, rest.JSON{"updated": true, "msg": req.Msg})
3✔
391
                }
3✔
392
        }
393
}
394

395
// deleteSampleHandler handles DELETE /samples request. It deletes dynamic samples both for spam and ham.
396
func (s *Server) deleteSampleHandler(delFn func(msg string) error) func(w http.ResponseWriter, r *http.Request) {
11✔
397
        return func(w http.ResponseWriter, r *http.Request) {
16✔
398
                var req struct {
5✔
399
                        Msg string `json:"msg"`
5✔
400
                }
5✔
401
                isHtmxRequest := r.Header.Get("HX-Request") == "true"
5✔
402
                if isHtmxRequest {
6✔
403
                        req.Msg = r.FormValue("msg")
1✔
404
                } else {
5✔
405
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
4✔
406
                                w.WriteHeader(http.StatusBadRequest)
×
407
                                rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
×
408
                                return
×
409
                        }
×
410
                }
411

412
                if err := delFn(req.Msg); err != nil {
6✔
413
                        w.WriteHeader(http.StatusInternalServerError)
1✔
414
                        rest.RenderJSON(w, rest.JSON{"error": "can't delete sample", "details": err.Error()})
1✔
415
                        return
1✔
416
                }
1✔
417

418
                if isHtmxRequest {
5✔
419
                        s.renderSamples(w, "samples_list")
1✔
420
                } else {
4✔
421
                        rest.RenderJSON(w, rest.JSON{"deleted": true, "msg": req.Msg, "count": 1})
3✔
422
                }
3✔
423
        }
424
}
425

426
// reloadDynamicSamplesHandler handles PUT /samples request. It reloads dynamic samples from db storage.
427
func (s *Server) reloadDynamicSamplesHandler(w http.ResponseWriter, _ *http.Request) {
2✔
428
        if err := s.SpamFilter.ReloadSamples(); err != nil {
3✔
429
                w.WriteHeader(http.StatusInternalServerError)
1✔
430
                rest.RenderJSON(w, rest.JSON{"error": "can't reload samples", "details": err.Error()})
1✔
431
                return
1✔
432
        }
1✔
433
        rest.RenderJSON(w, rest.JSON{"reloaded": true})
1✔
434
}
435

436
// updateApprovedUsersHandler handles POST /users/add and /users/delete requests, it adds or removes users from approved list.
437
func (s *Server) updateApprovedUsersHandler(updFn func(ui approved.UserInfo) error) func(w http.ResponseWriter, r *http.Request) {
12✔
438
        return func(w http.ResponseWriter, r *http.Request) {
21✔
439
                req := approved.UserInfo{}
9✔
440
                isHtmxRequest := r.Header.Get("HX-Request") == "true"
9✔
441
                if isHtmxRequest {
10✔
442
                        req.UserID = r.FormValue("user_id")
1✔
443
                        req.UserName = r.FormValue("user_name")
1✔
444
                } else {
9✔
445
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
9✔
446
                                w.WriteHeader(http.StatusBadRequest)
1✔
447
                                rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
448
                                return
1✔
449
                        }
1✔
450
                }
451

452
                // try to get userID from request and fallback to userName lookup if it's empty
453
                if req.UserID == "" {
12✔
454
                        req.UserID = strconv.FormatInt(s.Locator.UserIDByName(r.Context(), req.UserName), 10)
4✔
455
                }
4✔
456

457
                if req.UserID == "" || req.UserID == "0" {
9✔
458
                        if isHtmxRequest {
1✔
459
                                w.Header().Set("HX-Retarget", "#error-message")
×
460
                                fmt.Fprintln(w, "<div class='alert alert-danger'>Either userid or valid username required.</div>")
×
461
                                return
×
462
                        }
×
463
                        w.WriteHeader(http.StatusBadRequest)
1✔
464
                        rest.RenderJSON(w, rest.JSON{"error": "user ID is required"})
1✔
465
                        return
1✔
466
                }
467

468
                // add or remove user from the approved list of detector
469
                if err := updFn(req); err != nil {
7✔
470
                        w.WriteHeader(http.StatusInternalServerError)
×
471
                        rest.RenderJSON(w, rest.JSON{"error": "can't update approved users", "details": err.Error()})
×
472
                        return
×
473
                }
×
474

475
                if isHtmxRequest {
8✔
476
                        users := s.Detector.ApprovedUsers()
1✔
477
                        tmplData := struct {
1✔
478
                                ApprovedUsers      []approved.UserInfo
1✔
479
                                TotalApprovedUsers int
1✔
480
                        }{
1✔
481
                                ApprovedUsers:      users,
1✔
482
                                TotalApprovedUsers: len(users),
1✔
483
                        }
1✔
484

1✔
485
                        if err := tmpl.ExecuteTemplate(w, "users_list", tmplData); err != nil {
1✔
486
                                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
487
                                return
×
488
                        }
×
489

490
                } else {
6✔
491
                        rest.RenderJSON(w, rest.JSON{"updated": true, "user_id": req.UserID, "user_name": req.UserName})
6✔
492
                }
6✔
493
        }
494
}
495

496
// removeApprovedUser is adopter for updateApprovedUsersHandler updFn
497
func (s *Server) removeApprovedUser(req approved.UserInfo) error {
2✔
498
        return s.Detector.RemoveApprovedUser(req.UserID)
2✔
499
}
2✔
500

501
// getApprovedUsersHandler handles GET /users request. It returns list of approved users.
502
func (s *Server) getApprovedUsersHandler(w http.ResponseWriter, _ *http.Request) {
1✔
503
        rest.RenderJSON(w, rest.JSON{"user_ids": s.Detector.ApprovedUsers()})
1✔
504
}
1✔
505

506
// htmlSpamCheckHandler handles GET / request.
507
// It returns rendered spam_check.html template with all the components.
508
func (s *Server) htmlSpamCheckHandler(w http.ResponseWriter, _ *http.Request) {
1✔
509
        tmplData := struct {
1✔
510
                Version string
1✔
511
        }{
1✔
512
                Version: s.Version,
1✔
513
        }
1✔
514

1✔
515
        if err := tmpl.ExecuteTemplate(w, "spam_check.html", tmplData); err != nil {
1✔
516
                log.Printf("[WARN] can't execute template: %v", err)
×
517
                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
518
                return
×
519
        }
×
520
}
521

522
// htmlManageSamplesHandler handles GET /manage_samples request.
523
// It returns rendered manage_samples.html template with all the components.
524
func (s *Server) htmlManageSamplesHandler(w http.ResponseWriter, _ *http.Request) {
1✔
525
        s.renderSamples(w, "manage_samples.html")
1✔
526
}
1✔
527

528
func (s *Server) htmlManageUsersHandler(w http.ResponseWriter, _ *http.Request) {
1✔
529
        users := s.Detector.ApprovedUsers()
1✔
530
        tmplData := struct {
1✔
531
                ApprovedUsers      []approved.UserInfo
1✔
532
                TotalApprovedUsers int
1✔
533
        }{
1✔
534
                ApprovedUsers:      users,
1✔
535
                TotalApprovedUsers: len(users),
1✔
536
        }
1✔
537
        tmplData.TotalApprovedUsers = len(tmplData.ApprovedUsers)
1✔
538

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

546
func (s *Server) htmlDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
2✔
547
        ds, err := s.DetectedSpam.Read(r.Context())
2✔
548
        if err != nil {
3✔
549
                log.Printf("[ERROR] Failed to fetch detected spam: %v", err)
1✔
550
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
1✔
551
                return
1✔
552
        }
1✔
553

554
        // clean up detected spam entries
555
        for i, d := range ds {
3✔
556
                d.Text = strings.ReplaceAll(d.Text, "'", " ")
2✔
557
                d.Text = strings.ReplaceAll(d.Text, "\n", " ")
2✔
558
                d.Text = strings.ReplaceAll(d.Text, "\r", " ")
2✔
559
                d.Text = strings.ReplaceAll(d.Text, "\t", " ")
2✔
560
                d.Text = strings.ReplaceAll(d.Text, "\"", " ")
2✔
561
                d.Text = strings.ReplaceAll(d.Text, "\\", " ")
2✔
562
                ds[i] = d
2✔
563
        }
2✔
564

565
        tmplData := struct {
1✔
566
                DetectedSpamEntries []storage.DetectedSpamInfo
1✔
567
                TotalDetectedSpam   int
1✔
568
        }{
1✔
569
                DetectedSpamEntries: ds,
1✔
570
                TotalDetectedSpam:   len(ds),
1✔
571
        }
1✔
572

1✔
573
        if err := tmpl.ExecuteTemplate(w, "detected_spam.html", tmplData); err != nil {
1✔
574
                log.Printf("[WARN] can't execute template: %v", err)
×
575
                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
576
                return
×
577
        }
×
578
}
579

580
func (s *Server) htmlAddDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
1✔
581
        reportErr := func(err error, _ int) {
1✔
582
                w.Header().Set("HX-Retarget", "#error-message")
×
583
                fmt.Fprintf(w, "<div class='alert alert-danger'>%s</div>", err)
×
584
        }
×
585
        msg := r.FormValue("msg")
1✔
586

1✔
587
        id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
1✔
588
        if err != nil || msg == "" {
1✔
589
                log.Printf("[WARN] bad request: %v", err)
×
590
                reportErr(fmt.Errorf("bad request: %v", err), http.StatusBadRequest)
×
591
                return
×
592
        }
×
593

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

×
599
        }
×
600
        if err := s.DetectedSpam.SetAddedToSamplesFlag(r.Context(), id); err != nil {
1✔
601
                log.Printf("[WARN] failed to update detected spam: %v", err)
×
602
                reportErr(fmt.Errorf("can't update detected spam: %v", err), http.StatusInternalServerError)
×
603
                return
×
604
        }
×
605
        w.WriteHeader(http.StatusOK)
1✔
606
}
607

608
func (s *Server) htmlSettingsHandler(w http.ResponseWriter, _ *http.Request) {
1✔
609
        data := struct {
1✔
610
                Settings
1✔
611
                Version string
1✔
612
        }{
1✔
613
                Settings: s.Settings,
1✔
614
                Version:  s.Version,
1✔
615
        }
1✔
616

1✔
617
        if err := tmpl.ExecuteTemplate(w, "settings.html", data); err != nil {
1✔
618
                log.Printf("[WARN] can't execute template: %v", err)
×
619
                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
620
                return
×
621
        }
×
622
}
623

624
// stylesHandler handles GET /styles.css request. It returns styles.css file.
625
func (s *Server) stylesHandler(w http.ResponseWriter, _ *http.Request) {
1✔
626
        body, err := templateFS.ReadFile("assets/styles.css")
1✔
627
        if err != nil {
1✔
628
                log.Printf("[WARN] can't read styles.css: %v", err)
×
629
                http.Error(w, "Error reading styles.css", http.StatusInternalServerError)
×
630
                return
×
631
        }
×
632
        w.Header().Set("Content-Type", "text/css; charset=utf-8")
1✔
633
        w.WriteHeader(http.StatusOK)
1✔
634
        _, _ = w.Write(body)
1✔
635
}
636

637
// logoHandler handles GET /logo.png request. It returns assets/logo.png file.
638
func (s *Server) logoHandler(w http.ResponseWriter, _ *http.Request) {
1✔
639
        img, err := templateFS.ReadFile("assets/logo.png")
1✔
640
        if err != nil {
1✔
641
                http.Error(w, "Logo not found", http.StatusNotFound)
×
642
                return
×
643
        }
×
644
        w.Header().Set("Content-Type", "image/png")
1✔
645
        w.WriteHeader(http.StatusOK)
1✔
646
        _, _ = w.Write(img)
1✔
647
}
648

649
func (s *Server) spinnerHandler(w http.ResponseWriter, _ *http.Request) {
×
650
        img, err := templateFS.ReadFile("assets/spinner.svg")
×
651
        if err != nil {
×
652
                http.Error(w, "Logo not found", http.StatusNotFound)
×
653
                return
×
654
        }
×
655
        w.Header().Set("Content-Type", "image/svg+xml")
×
656
        w.WriteHeader(http.StatusOK)
×
657
        _, _ = w.Write(img)
×
658
}
659

660
func (s *Server) renderSamples(w http.ResponseWriter, tmplName string) {
3✔
661
        spam, ham, err := s.SpamFilter.DynamicSamples()
3✔
662
        if err != nil {
3✔
663
                w.WriteHeader(http.StatusInternalServerError)
×
664
                rest.RenderJSON(w, rest.JSON{"error": "can't fetch samples", "details": err.Error()})
×
665
                return
×
666
        }
×
667

668
        spam, ham = s.reverseSamples(spam, ham)
3✔
669

3✔
670
        type smpleWithID struct {
3✔
671
                ID     string
3✔
672
                Sample string
3✔
673
        }
3✔
674

3✔
675
        makeID := func(s string) string {
15✔
676
                hash := sha1.New() //nolint
12✔
677
                if _, err := hash.Write([]byte(s)); err != nil {
12✔
678
                        return fmt.Sprintf("%x", s)
×
679
                }
×
680
                return fmt.Sprintf("%x", hash.Sum(nil))
12✔
681
        }
682

683
        tmplData := struct {
3✔
684
                SpamSamples      []smpleWithID
3✔
685
                HamSamples       []smpleWithID
3✔
686
                TotalHamSamples  int
3✔
687
                TotalSpamSamples int
3✔
688
        }{
3✔
689
                TotalHamSamples:  len(ham),
3✔
690
                TotalSpamSamples: len(spam),
3✔
691
        }
3✔
692
        for _, s := range spam {
9✔
693
                tmplData.SpamSamples = append(tmplData.SpamSamples, smpleWithID{ID: makeID(s), Sample: s})
6✔
694
        }
6✔
695
        for _, h := range ham {
9✔
696
                tmplData.HamSamples = append(tmplData.HamSamples, smpleWithID{ID: makeID(h), Sample: h})
6✔
697
        }
6✔
698

699
        if err := tmpl.ExecuteTemplate(w, tmplName, tmplData); err != nil {
3✔
700
                w.WriteHeader(http.StatusInternalServerError)
×
701
                rest.RenderJSON(w, rest.JSON{"error": "can't execute template", "details": err.Error()})
×
702
                return
×
703
        }
×
704
}
705

706
func (s *Server) authMiddleware(mw func(next http.Handler) http.Handler) func(next http.Handler) http.Handler {
8✔
707
        if s.AuthPasswd == "" {
12✔
708
                return func(next http.Handler) http.Handler {
72✔
709
                        return next
68✔
710
                }
68✔
711
        }
712
        return func(next http.Handler) http.Handler {
72✔
713
                return mw(next)
68✔
714
        }
68✔
715
}
716

717
// reverseSamples returns reversed lists of spam and ham samples
718
func (s *Server) reverseSamples(spam, ham []string) (revSpam, revHam []string) {
6✔
719
        revSpam = make([]string, len(spam))
6✔
720
        revHam = make([]string, len(ham))
6✔
721

6✔
722
        for i, j := 0, len(spam)-1; i < len(spam); i, j = i+1, j-1 {
16✔
723
                revSpam[i] = spam[j]
10✔
724
        }
10✔
725
        for i, j := 0, len(ham)-1; i < len(ham); i, j = i+1, j-1 {
16✔
726
                revHam[i] = ham[j]
10✔
727
        }
10✔
728
        return revSpam, revHam
6✔
729
}
730

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

2✔
735
        var password strings.Builder
2✔
736
        charsetSize := big.NewInt(int64(len(charset)))
2✔
737

2✔
738
        for i := 0; i < length; i++ {
66✔
739
                randomNumber, err := rand.Int(rand.Reader, charsetSize)
64✔
740
                if err != nil {
64✔
741
                        return "", err
×
742
                }
×
743

744
                password.WriteByte(charset[randomNumber.Int64()])
64✔
745
        }
746

747
        return password.String(), nil
2✔
748
}
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