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

umputun / tg-spam / 18451090490

12 Oct 2025 11:36PM UTC coverage: 81.722% (+0.02%) from 81.698%
18451090490

push

github

web-flow
feat(delete): add options to delete join/leave system messages immediately (#323)

* feat(delete): add options to delete join/leave system messages immediately

- add Delete.JoinMessages and Delete.LeaveMessages flags
- create deleteSystemMessage helper function
- update Do() to delete messages when flags enabled
- fix double-deletion bug by making DeleteJoinMessages and SuppressJoinMessage mutually exclusive
- add comprehensive tests for delete functionality
- update README with new options and clear distinction from suppress-join-message

this allows keeping chat clean by immediately deleting all join/leave messages,
independent of spam detection. can be used together with suppress-join-message
for maximum cleanup (suppress tracks for spammers, delete removes all).

* docs(delete): add detailed comment explaining double-deletion prevention logic

25 of 29 new or added lines in 2 files covered. (86.21%)

1 existing line in 1 file now uncovered.

5392 of 6598 relevant lines covered (81.72%)

310.58 hits per line

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

83.51
/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

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

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

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

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

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

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

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

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

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

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

160
// NewServer creates a new web API server.
161
func NewServer(config Config) *Server {
46✔
162
        return &Server{Config: config}
46✔
163
}
46✔
164

165
// Run starts server and accepts requests checking for spam messages.
166
func (s *Server) Run(ctx context.Context) error {
3✔
167
        router := routegroup.New(http.NewServeMux())
3✔
168
        router.Use(rest.Recoverer(log.Default()))
3✔
169
        router.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]")).Handler)
3✔
170
        router.Use(rest.Throttle(1000))
3✔
171
        router.Use(rest.AppInfo("tg-spam", "umputun", s.Version), rest.Ping)
3✔
172
        router.Use(tollbooth.HTTPMiddleware(tollbooth.NewLimiter(50, nil)))
3✔
173
        router.Use(rest.SizeLimit(1024 * 1024)) // 1M max request size
3✔
174

3✔
175
        if s.AuthPasswd != "" || s.AuthHash != "" {
6✔
176
                log.Printf("[INFO] basic auth enabled for webapi server")
3✔
177
                if s.AuthHash != "" {
4✔
178
                        router.Use(rest.BasicAuthWithBcryptHashAndPrompt("tg-spam", s.AuthHash))
1✔
179
                } else {
3✔
180
                        router.Use(rest.BasicAuthWithPrompt("tg-spam", s.AuthPasswd))
2✔
181
                }
2✔
182
        } else {
×
183
                log.Printf("[WARN] basic auth disabled, access to webapi is not protected")
×
184
        }
×
185

186
        router = s.routes(router) // setup routes
3✔
187

3✔
188
        srv := &http.Server{Addr: s.ListenAddr, Handler: router, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second}
3✔
189
        go func() {
6✔
190
                <-ctx.Done()
3✔
191
                if err := srv.Shutdown(ctx); err != nil {
3✔
UNCOV
192
                        log.Printf("[WARN] failed to shutdown webapi server: %v", err)
×
193
                } else {
3✔
194
                        log.Printf("[INFO] webapi server stopped")
3✔
195
                }
3✔
196
        }()
197

198
        log.Printf("[INFO] start webapi server on %s", s.ListenAddr)
3✔
199
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
3✔
200
                return fmt.Errorf("failed to run server: %w", err)
×
201
        }
×
202
        return nil
3✔
203
}
204

205
func (s *Server) routes(router *routegroup.Bundle) *routegroup.Bundle {
5✔
206
        // auth api routes
5✔
207
        router.Route(func(authApi *routegroup.Bundle) {
10✔
208
                authApi.Use(s.authMiddleware(rest.BasicAuthWithUserPasswd("tg-spam", s.AuthPasswd)))
5✔
209
                authApi.HandleFunc("POST /check", s.checkMsgHandler)         // check a message for spam
5✔
210
                authApi.HandleFunc("GET /check/{user_id}", s.checkIDHandler) // check user id for spam
5✔
211

5✔
212
                authApi.Mount("/update").Route(func(r *routegroup.Bundle) {
10✔
213
                        // update spam/ham samples
5✔
214
                        r.HandleFunc("POST /spam", s.updateSampleHandler(s.SpamFilter.UpdateSpam)) // update spam samples
5✔
215
                        r.HandleFunc("POST /ham", s.updateSampleHandler(s.SpamFilter.UpdateHam))   // update ham samples
5✔
216
                })
5✔
217

218
                authApi.Mount("/delete").Route(func(r *routegroup.Bundle) {
10✔
219
                        // delete spam/ham samples
5✔
220
                        r.HandleFunc("POST /spam", s.deleteSampleHandler(s.SpamFilter.RemoveDynamicSpamSample))
5✔
221
                        r.HandleFunc("POST /ham", s.deleteSampleHandler(s.SpamFilter.RemoveDynamicHamSample))
5✔
222
                })
5✔
223

224
                authApi.Mount("/download").Route(func(r *routegroup.Bundle) {
10✔
225
                        r.HandleFunc("GET /spam", s.downloadSampleHandler(func(spam, _ []string) ([]string, string) {
5✔
226
                                return spam, "spam.txt"
×
227
                        }))
×
228
                        r.HandleFunc("GET /ham", s.downloadSampleHandler(func(_, ham []string) ([]string, string) {
5✔
229
                                return ham, "ham.txt"
×
230
                        }))
×
231
                        r.HandleFunc("GET /detected_spam", s.downloadDetectedSpamHandler)
5✔
232
                        r.HandleFunc("GET /backup", s.downloadBackupHandler)
5✔
233
                        r.HandleFunc("GET /export-to-postgres", s.downloadExportToPostgresHandler)
5✔
234
                })
235

236
                authApi.HandleFunc("GET /samples", s.getDynamicSamplesHandler)    // get dynamic samples
5✔
237
                authApi.HandleFunc("PUT /samples", s.reloadDynamicSamplesHandler) // reload samples
5✔
238

5✔
239
                authApi.Mount("/users").Route(func(r *routegroup.Bundle) { // manage approved users
10✔
240
                        // add user to the approved list and storage
5✔
241
                        r.HandleFunc("POST /add", s.updateApprovedUsersHandler(s.Detector.AddApprovedUser))
5✔
242
                        // remove user from an approved list and storage
5✔
243
                        r.HandleFunc("POST /delete", s.updateApprovedUsersHandler(s.removeApprovedUser))
5✔
244
                        // get approved users
5✔
245
                        r.HandleFunc("GET /", s.getApprovedUsersHandler)
5✔
246
                })
5✔
247

248
                authApi.HandleFunc("GET /settings", s.getSettingsHandler) // get application settings
5✔
249
        })
250

251
        router.Route(func(webUI *routegroup.Bundle) {
10✔
252
                webUI.Use(s.authMiddleware(rest.BasicAuthWithPrompt("tg-spam", s.AuthPasswd)))
5✔
253
                webUI.HandleFunc("GET /", s.htmlSpamCheckHandler)                         // serve template for webUI UI
5✔
254
                webUI.HandleFunc("GET /manage_samples", s.htmlManageSamplesHandler)       // serve manage samples page
5✔
255
                webUI.HandleFunc("GET /manage_users", s.htmlManageUsersHandler)           // serve manage users page
5✔
256
                webUI.HandleFunc("GET /detected_spam", s.htmlDetectedSpamHandler)         // serve detected spam page
5✔
257
                webUI.HandleFunc("GET /list_settings", s.htmlSettingsHandler)             // serve settings
5✔
258
                webUI.HandleFunc("POST /detected_spam/add", s.htmlAddDetectedSpamHandler) // add detected spam to samples
5✔
259

5✔
260
                // handle logout - force Basic Auth re-authentication
5✔
261
                webUI.HandleFunc("GET /logout", func(w http.ResponseWriter, _ *http.Request) {
5✔
262
                        w.Header().Set("WWW-Authenticate", `Basic realm="tg-spam"`)
×
263
                        w.WriteHeader(http.StatusUnauthorized)
×
264
                        fmt.Fprintln(w, "Logged out successfully")
×
265
                })
×
266

267
                // serve only specific static files at root level
268
                staticFiles := newStaticFS(templateFS,
5✔
269
                        staticFileMapping{urlPath: "styles.css", filesysPath: "assets/styles.css"},
5✔
270
                        staticFileMapping{urlPath: "logo.png", filesysPath: "assets/logo.png"},
5✔
271
                        staticFileMapping{urlPath: "spinner.svg", filesysPath: "assets/spinner.svg"},
5✔
272
                )
5✔
273
                webUI.HandleFiles("/", http.FS(staticFiles))
5✔
274
        })
275

276
        return router
5✔
277
}
278

279
// checkMsgHandler handles POST /check request.
280
// it gets message text and user id from request body and returns spam status and check results.
281
func (s *Server) checkMsgHandler(w http.ResponseWriter, r *http.Request) {
8✔
282
        type CheckResultDisplay struct {
8✔
283
                Spam   bool
8✔
284
                Checks []spamcheck.Response
8✔
285
        }
8✔
286

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

8✔
289
        req := spamcheck.Request{CheckOnly: true}
8✔
290
        if !isHtmxRequest {
15✔
291
                // API request
7✔
292
                if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
8✔
293
                        w.WriteHeader(http.StatusBadRequest)
1✔
294
                        rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
295
                        log.Printf("[WARN] can't decode request: %v", err)
1✔
296
                        return
1✔
297
                }
1✔
298
        } else {
1✔
299
                // for hx-request (HTMX) we need to get the values from the form
1✔
300
                req.UserID = r.FormValue("user_id")
1✔
301
                req.UserName = r.FormValue("user_name")
1✔
302
                req.Msg = r.FormValue("msg")
1✔
303
        }
1✔
304

305
        spam, cr := s.Detector.Check(req)
7✔
306
        if !isHtmxRequest {
13✔
307
                // for API request return JSON
6✔
308
                rest.RenderJSON(w, rest.JSON{"spam": spam, "checks": cr})
6✔
309
                return
6✔
310
        }
6✔
311

312
        if req.Msg == "" {
1✔
313
                w.Header().Set("HX-Retarget", "#error-message")
×
314
                fmt.Fprintln(w, "<div class='alert alert-danger'>Valid message required.</div>")
×
315
                return
×
316
        }
×
317

318
        // render result for HTMX request
319
        resultDisplay := CheckResultDisplay{
1✔
320
                Spam:   spam,
1✔
321
                Checks: cr,
1✔
322
        }
1✔
323

1✔
324
        if err := tmpl.ExecuteTemplate(w, "check_results", resultDisplay); err != nil {
1✔
325
                log.Printf("[WARN] can't execute result template: %v", err)
×
326
                http.Error(w, "Error rendering result", http.StatusInternalServerError)
×
327
                return
×
328
        }
×
329
}
330

331
// checkIDHandler handles GET /check/{user_id} request.
332
// it returns JSON with the status "spam" or "ham" for a given user id.
333
// if user is spammer, it also returns check results.
334
func (s *Server) checkIDHandler(w http.ResponseWriter, r *http.Request) {
2✔
335
        type info struct {
2✔
336
                UserName  string               `json:"user_name,omitempty"`
2✔
337
                Message   string               `json:"message,omitempty"`
2✔
338
                Timestamp time.Time            `json:"timestamp,omitempty"`
2✔
339
                Checks    []spamcheck.Response `json:"checks,omitempty"`
2✔
340
        }
2✔
341
        resp := struct {
2✔
342
                Status string `json:"status"`
2✔
343
                Info   *info  `json:"info,omitempty"`
2✔
344
        }{
2✔
345
                Status: "ham",
2✔
346
        }
2✔
347

2✔
348
        userID, err := strconv.ParseInt(r.PathValue("user_id"), 10, 64)
2✔
349
        if err != nil {
2✔
350
                w.WriteHeader(http.StatusBadRequest)
×
351
                rest.RenderJSON(w, rest.JSON{"error": "can't parse user id", "details": err.Error()})
×
352
                return
×
353
        }
×
354

355
        si, err := s.DetectedSpam.FindByUserID(r.Context(), userID)
2✔
356
        if err != nil {
2✔
357
                w.WriteHeader(http.StatusInternalServerError)
×
358
                rest.RenderJSON(w, rest.JSON{"error": "can't get user info", "details": err.Error()})
×
359
                return
×
360
        }
×
361
        if si != nil {
3✔
362
                resp.Status = "spam"
1✔
363
                resp.Info = &info{
1✔
364
                        UserName:  si.UserName,
1✔
365
                        Message:   si.Text,
1✔
366
                        Timestamp: si.Timestamp,
1✔
367
                        Checks:    si.Checks,
1✔
368
                }
1✔
369
        }
1✔
370
        rest.RenderJSON(w, resp)
2✔
371
}
372

373
// getDynamicSamplesHandler handles GET /samples request. It returns dynamic samples both for spam and ham.
374
func (s *Server) getDynamicSamplesHandler(w http.ResponseWriter, _ *http.Request) {
2✔
375
        spam, ham, err := s.SpamFilter.DynamicSamples()
2✔
376
        if err != nil {
3✔
377
                w.WriteHeader(http.StatusInternalServerError)
1✔
378
                rest.RenderJSON(w, rest.JSON{"error": "can't get dynamic samples", "details": err.Error()})
1✔
379
                return
1✔
380
        }
1✔
381
        rest.RenderJSON(w, rest.JSON{"spam": spam, "ham": ham})
1✔
382
}
383

384
// downloadSampleHandler handles GET /download/spam|ham request. It returns dynamic samples both for spam and ham.
385
func (s *Server) downloadSampleHandler(pickFn func(spam, ham []string) ([]string, string)) func(w http.ResponseWriter, r *http.Request) {
13✔
386
        return func(w http.ResponseWriter, _ *http.Request) {
16✔
387
                spam, ham, err := s.SpamFilter.DynamicSamples()
3✔
388
                if err != nil {
4✔
389
                        w.WriteHeader(http.StatusInternalServerError)
1✔
390
                        rest.RenderJSON(w, rest.JSON{"error": "can't get dynamic samples", "details": err.Error()})
1✔
391
                        return
1✔
392
                }
1✔
393
                samples, name := pickFn(spam, ham)
2✔
394
                body := strings.Join(samples, "\n")
2✔
395
                w.Header().Set("Content-Type", "text/plain; charset=utf-8")
2✔
396
                w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
2✔
397
                w.Header().Set("Content-Length", strconv.Itoa(len(body)))
2✔
398
                w.WriteHeader(http.StatusOK)
2✔
399
                _, _ = w.Write([]byte(body))
2✔
400
        }
401
}
402

403
// updateSampleHandler handles POST /update/spam|ham request. It updates dynamic samples both for spam and ham.
404
func (s *Server) updateSampleHandler(updFn func(msg string) error) func(w http.ResponseWriter, r *http.Request) {
13✔
405
        return func(w http.ResponseWriter, r *http.Request) {
18✔
406
                var req struct {
5✔
407
                        Msg string `json:"msg"`
5✔
408
                }
5✔
409

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

5✔
412
                if isHtmxRequest {
5✔
413
                        req.Msg = r.FormValue("msg")
×
414
                } else {
5✔
415
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
6✔
416
                                w.WriteHeader(http.StatusBadRequest)
1✔
417
                                rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
418
                                return
1✔
419
                        }
1✔
420
                }
421

422
                err := updFn(req.Msg)
4✔
423
                if err != nil {
5✔
424
                        w.WriteHeader(http.StatusInternalServerError)
1✔
425
                        rest.RenderJSON(w, rest.JSON{"error": "can't update samples", "details": err.Error()})
1✔
426
                        return
1✔
427
                }
1✔
428

429
                if isHtmxRequest {
3✔
430
                        s.renderSamples(w, "samples_list")
×
431
                } else {
3✔
432
                        rest.RenderJSON(w, rest.JSON{"updated": true, "msg": req.Msg})
3✔
433
                }
3✔
434
        }
435
}
436

437
// deleteSampleHandler handles DELETE /samples request. It deletes dynamic samples both for spam and ham.
438
func (s *Server) deleteSampleHandler(delFn func(msg string) error) func(w http.ResponseWriter, r *http.Request) {
13✔
439
        return func(w http.ResponseWriter, r *http.Request) {
18✔
440
                var req struct {
5✔
441
                        Msg string `json:"msg"`
5✔
442
                }
5✔
443
                isHtmxRequest := r.Header.Get("HX-Request") == "true"
5✔
444
                if isHtmxRequest {
6✔
445
                        req.Msg = r.FormValue("msg")
1✔
446
                } else {
5✔
447
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
4✔
448
                                w.WriteHeader(http.StatusBadRequest)
×
449
                                rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
×
450
                                return
×
451
                        }
×
452
                }
453

454
                if err := delFn(req.Msg); err != nil {
6✔
455
                        w.WriteHeader(http.StatusInternalServerError)
1✔
456
                        rest.RenderJSON(w, rest.JSON{"error": "can't delete sample", "details": err.Error()})
1✔
457
                        return
1✔
458
                }
1✔
459

460
                if isHtmxRequest {
5✔
461
                        s.renderSamples(w, "samples_list")
1✔
462
                } else {
4✔
463
                        rest.RenderJSON(w, rest.JSON{"deleted": true, "msg": req.Msg, "count": 1})
3✔
464
                }
3✔
465
        }
466
}
467

468
// reloadDynamicSamplesHandler handles PUT /samples request. It reloads dynamic samples from db storage.
469
func (s *Server) reloadDynamicSamplesHandler(w http.ResponseWriter, _ *http.Request) {
2✔
470
        if err := s.SpamFilter.ReloadSamples(); err != nil {
3✔
471
                w.WriteHeader(http.StatusInternalServerError)
1✔
472
                rest.RenderJSON(w, rest.JSON{"error": "can't reload samples", "details": err.Error()})
1✔
473
                return
1✔
474
        }
1✔
475
        rest.RenderJSON(w, rest.JSON{"reloaded": true})
1✔
476
}
477

478
// updateApprovedUsersHandler handles POST /users/add and /users/delete requests, it adds or removes users from approved list.
479
func (s *Server) updateApprovedUsersHandler(updFn func(ui approved.UserInfo) error) func(w http.ResponseWriter, r *http.Request) {
14✔
480
        return func(w http.ResponseWriter, r *http.Request) {
23✔
481
                req := approved.UserInfo{}
9✔
482
                isHtmxRequest := r.Header.Get("HX-Request") == "true"
9✔
483
                if isHtmxRequest {
10✔
484
                        req.UserID = r.FormValue("user_id")
1✔
485
                        req.UserName = r.FormValue("user_name")
1✔
486
                } else {
9✔
487
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
9✔
488
                                w.WriteHeader(http.StatusBadRequest)
1✔
489
                                rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
490
                                return
1✔
491
                        }
1✔
492
                }
493

494
                // try to get userID from request and fallback to userName lookup if it's empty
495
                if req.UserID == "" {
12✔
496
                        req.UserID = strconv.FormatInt(s.Locator.UserIDByName(r.Context(), req.UserName), 10)
4✔
497
                }
4✔
498

499
                if req.UserID == "" || req.UserID == "0" {
9✔
500
                        if isHtmxRequest {
1✔
501
                                w.Header().Set("HX-Retarget", "#error-message")
×
502
                                fmt.Fprintln(w, "<div class='alert alert-danger'>Either userid or valid username required.</div>")
×
503
                                return
×
504
                        }
×
505
                        w.WriteHeader(http.StatusBadRequest)
1✔
506
                        rest.RenderJSON(w, rest.JSON{"error": "user ID is required"})
1✔
507
                        return
1✔
508
                }
509

510
                // add or remove user from the approved list of detector
511
                if err := updFn(req); err != nil {
7✔
512
                        w.WriteHeader(http.StatusInternalServerError)
×
513
                        rest.RenderJSON(w, rest.JSON{"error": "can't update approved users", "details": err.Error()})
×
514
                        return
×
515
                }
×
516

517
                if isHtmxRequest {
8✔
518
                        users := s.Detector.ApprovedUsers()
1✔
519
                        tmplData := struct {
1✔
520
                                ApprovedUsers      []approved.UserInfo
1✔
521
                                TotalApprovedUsers int
1✔
522
                        }{
1✔
523
                                ApprovedUsers:      users,
1✔
524
                                TotalApprovedUsers: len(users),
1✔
525
                        }
1✔
526

1✔
527
                        if err := tmpl.ExecuteTemplate(w, "users_list", tmplData); err != nil {
1✔
528
                                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
529
                                return
×
530
                        }
×
531

532
                } else {
6✔
533
                        rest.RenderJSON(w, rest.JSON{"updated": true, "user_id": req.UserID, "user_name": req.UserName})
6✔
534
                }
6✔
535
        }
536
}
537

538
// removeApprovedUser is adopter for updateApprovedUsersHandler updFn
539
func (s *Server) removeApprovedUser(req approved.UserInfo) error {
2✔
540
        if err := s.Detector.RemoveApprovedUser(req.UserID); err != nil {
2✔
541
                return fmt.Errorf("failed to remove approved user %s: %w", req.UserID, err)
×
542
        }
×
543
        return nil
2✔
544
}
545

546
// getApprovedUsersHandler handles GET /users request. It returns list of approved users.
547
func (s *Server) getApprovedUsersHandler(w http.ResponseWriter, _ *http.Request) {
1✔
548
        rest.RenderJSON(w, rest.JSON{"user_ids": s.Detector.ApprovedUsers()})
1✔
549
}
1✔
550

551
// getSettingsHandler returns application settings, including the list of available Lua plugins
552
func (s *Server) getSettingsHandler(w http.ResponseWriter, _ *http.Request) {
3✔
553
        // get the list of available Lua plugins before returning settings
3✔
554
        s.Settings.LuaAvailablePlugins = s.Detector.GetLuaPluginNames()
3✔
555
        rest.RenderJSON(w, s.Settings)
3✔
556
}
3✔
557

558
// htmlSpamCheckHandler handles GET / request.
559
// It returns rendered spam_check.html template with all the components.
560
func (s *Server) htmlSpamCheckHandler(w http.ResponseWriter, _ *http.Request) {
3✔
561
        tmplData := struct {
3✔
562
                Version string
3✔
563
        }{
3✔
564
                Version: s.Version,
3✔
565
        }
3✔
566

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

574
// htmlManageSamplesHandler handles GET /manage_samples request.
575
// It returns rendered manage_samples.html template with all the components.
576
func (s *Server) htmlManageSamplesHandler(w http.ResponseWriter, _ *http.Request) {
1✔
577
        s.renderSamples(w, "manage_samples.html")
1✔
578
}
1✔
579

580
func (s *Server) htmlManageUsersHandler(w http.ResponseWriter, _ *http.Request) {
3✔
581
        users := s.Detector.ApprovedUsers()
3✔
582
        tmplData := struct {
3✔
583
                ApprovedUsers      []approved.UserInfo
3✔
584
                TotalApprovedUsers int
3✔
585
        }{
3✔
586
                ApprovedUsers:      users,
3✔
587
                TotalApprovedUsers: len(users),
3✔
588
        }
3✔
589
        tmplData.TotalApprovedUsers = len(tmplData.ApprovedUsers)
3✔
590

3✔
591
        if err := tmpl.ExecuteTemplate(w, "manage_users.html", tmplData); err != nil {
4✔
592
                log.Printf("[WARN] can't execute template: %v", err)
1✔
593
                http.Error(w, "Error executing template", http.StatusInternalServerError)
1✔
594
                return
1✔
595
        }
1✔
596
}
597

598
func (s *Server) htmlDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
2✔
599
        ds, err := s.DetectedSpam.Read(r.Context())
2✔
600
        if err != nil {
3✔
601
                log.Printf("[ERROR] Failed to fetch detected spam: %v", err)
1✔
602
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
1✔
603
                return
1✔
604
        }
1✔
605

606
        // clean up detected spam entries
607
        for i, d := range ds {
3✔
608
                d.Text = strings.ReplaceAll(d.Text, "'", " ")
2✔
609
                d.Text = strings.ReplaceAll(d.Text, "\n", " ")
2✔
610
                d.Text = strings.ReplaceAll(d.Text, "\r", " ")
2✔
611
                d.Text = strings.ReplaceAll(d.Text, "\t", " ")
2✔
612
                d.Text = strings.ReplaceAll(d.Text, "\"", " ")
2✔
613
                d.Text = strings.ReplaceAll(d.Text, "\\", " ")
2✔
614
                ds[i] = d
2✔
615
        }
2✔
616

617
        // get filter from query param, default to "all"
618
        filter := r.URL.Query().Get("filter")
1✔
619
        if filter == "" {
2✔
620
                filter = "all"
1✔
621
        }
1✔
622

623
        // apply filtering
624
        var filteredDS []storage.DetectedSpamInfo
1✔
625
        switch filter {
1✔
626
        case "non-classified":
×
627
                for _, entry := range ds {
×
628
                        hasClassifierHam := false
×
629
                        for _, check := range entry.Checks {
×
630
                                if check.Name == "classifier" && !check.Spam {
×
631
                                        hasClassifierHam = true
×
632
                                        break
×
633
                                }
634
                        }
635
                        if hasClassifierHam {
×
636
                                filteredDS = append(filteredDS, entry)
×
637
                        }
×
638
                }
639
        case "openai":
×
640
                for _, entry := range ds {
×
641
                        hasOpenAI := false
×
642
                        for _, check := range entry.Checks {
×
643
                                if check.Name == "openai" {
×
644
                                        hasOpenAI = true
×
645
                                        break
×
646
                                }
647
                        }
648
                        if hasOpenAI {
×
649
                                filteredDS = append(filteredDS, entry)
×
650
                        }
×
651
                }
652
        default: // "all" or any other value
1✔
653
                filteredDS = ds
1✔
654
        }
655

656
        tmplData := struct {
1✔
657
                DetectedSpamEntries []storage.DetectedSpamInfo
1✔
658
                TotalDetectedSpam   int
1✔
659
                FilteredCount       int
1✔
660
                Filter              string
1✔
661
                OpenAIEnabled       bool
1✔
662
        }{
1✔
663
                DetectedSpamEntries: filteredDS,
1✔
664
                TotalDetectedSpam:   len(ds),
1✔
665
                FilteredCount:       len(filteredDS),
1✔
666
                Filter:              filter,
1✔
667
                OpenAIEnabled:       s.Settings.OpenAIEnabled,
1✔
668
        }
1✔
669

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

×
674
                // first render the content template
×
675
                if err := tmpl.ExecuteTemplate(&buf, "detected_spam_content", tmplData); err != nil {
×
676
                        log.Printf("[WARN] can't execute content template: %v", err)
×
677
                        http.Error(w, "Error executing template", http.StatusInternalServerError)
×
678
                        return
×
679
                }
×
680

681
                // then append OOB swap for the count display
682
                countHTML := ""
×
683
                if filter != "all" {
×
684
                        countHTML = fmt.Sprintf("(%d/%d)", len(filteredDS), len(ds))
×
685
                } else {
×
686
                        countHTML = fmt.Sprintf("(%d)", len(ds))
×
687
                }
×
688

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

×
691
                // write the combined response
×
692
                if _, err := buf.WriteTo(w); err != nil {
×
693
                        log.Printf("[WARN] failed to write response: %v", err)
×
694
                }
×
695
                return
×
696
        }
697

698
        // full page render for normal requests
699
        if err := tmpl.ExecuteTemplate(w, "detected_spam.html", tmplData); err != nil {
1✔
700
                log.Printf("[WARN] can't execute template: %v", err)
×
701
                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
702
                return
×
703
        }
×
704
}
705

706
func (s *Server) htmlAddDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
5✔
707
        reportErr := func(err error, _ int) {
9✔
708
                w.Header().Set("HX-Retarget", "#error-message")
4✔
709
                fmt.Fprintf(w, "<div class='alert alert-danger'>%s</div>", err)
4✔
710
        }
4✔
711
        msg := r.FormValue("msg")
5✔
712

5✔
713
        id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
5✔
714
        if err != nil || msg == "" {
7✔
715
                log.Printf("[WARN] bad request: %v", err)
2✔
716
                reportErr(fmt.Errorf("bad request: %v", err), http.StatusBadRequest)
2✔
717
                return
2✔
718
        }
2✔
719

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

1✔
725
        }
1✔
726
        if err := s.DetectedSpam.SetAddedToSamplesFlag(r.Context(), id); err != nil {
3✔
727
                log.Printf("[WARN] failed to update detected spam: %v", err)
1✔
728
                reportErr(fmt.Errorf("can't update detected spam: %v", err), http.StatusInternalServerError)
1✔
729
                return
1✔
730
        }
1✔
731
        w.WriteHeader(http.StatusOK)
1✔
732
}
733

734
func (s *Server) htmlSettingsHandler(w http.ResponseWriter, _ *http.Request) {
4✔
735
        // get database information if StorageEngine is available
4✔
736
        var dbInfo struct {
4✔
737
                DatabaseType   string `json:"database_type"`
4✔
738
                GID            string `json:"gid"`
4✔
739
                DatabaseStatus string `json:"database_status"`
4✔
740
        }
4✔
741

4✔
742
        if s.StorageEngine != nil {
6✔
743
                // try to cast to SQL engine to get type information
2✔
744
                if sqlEngine, ok := s.StorageEngine.(*engine.SQL); ok {
2✔
745
                        dbInfo.DatabaseType = string(sqlEngine.Type())
×
746
                        dbInfo.GID = sqlEngine.GID()
×
747
                        dbInfo.DatabaseStatus = "Connected"
×
748
                } else {
2✔
749
                        dbInfo.DatabaseType = "Unknown"
2✔
750
                        dbInfo.DatabaseStatus = "Connected (unknown type)"
2✔
751
                }
2✔
752
        } else {
2✔
753
                dbInfo.DatabaseStatus = "Not connected"
2✔
754
        }
2✔
755

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

4✔
760
        // get system info - uptime since server start
4✔
761
        uptime := time.Since(startTime)
4✔
762

4✔
763
        // get the list of available Lua plugins
4✔
764
        s.Settings.LuaAvailablePlugins = s.Detector.GetLuaPluginNames()
4✔
765

4✔
766
        data := struct {
4✔
767
                Settings
4✔
768
                Version  string
4✔
769
                Database struct {
4✔
770
                        Type   string
4✔
771
                        GID    string
4✔
772
                        Status string
4✔
773
                }
4✔
774
                Backup struct {
4✔
775
                        URL      string
4✔
776
                        Filename string
4✔
777
                }
4✔
778
                System struct {
4✔
779
                        Uptime string
4✔
780
                }
4✔
781
        }{
4✔
782
                Settings: s.Settings,
4✔
783
                Version:  s.Version,
4✔
784
                Database: struct {
4✔
785
                        Type   string
4✔
786
                        GID    string
4✔
787
                        Status string
4✔
788
                }{
4✔
789
                        Type:   dbInfo.DatabaseType,
4✔
790
                        GID:    dbInfo.GID,
4✔
791
                        Status: dbInfo.DatabaseStatus,
4✔
792
                },
4✔
793
                Backup: struct {
4✔
794
                        URL      string
4✔
795
                        Filename string
4✔
796
                }{
4✔
797
                        URL:      backupURL,
4✔
798
                        Filename: backupFilename,
4✔
799
                },
4✔
800
                System: struct {
4✔
801
                        Uptime string
4✔
802
                }{
4✔
803
                        Uptime: formatDuration(uptime),
4✔
804
                },
4✔
805
        }
4✔
806

4✔
807
        if err := tmpl.ExecuteTemplate(w, "settings.html", data); err != nil {
5✔
808
                log.Printf("[WARN] can't execute template: %v", err)
1✔
809
                http.Error(w, "Error executing template", http.StatusInternalServerError)
1✔
810
                return
1✔
811
        }
1✔
812
}
813

814
// formatDuration formats a duration in a human-readable way
815
func formatDuration(d time.Duration) string {
12✔
816
        days := int(d.Hours() / 24)
12✔
817
        hours := int(d.Hours()) % 24
12✔
818
        minutes := int(d.Minutes()) % 60
12✔
819

12✔
820
        if days > 0 {
15✔
821
                return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
3✔
822
        }
3✔
823

824
        if hours > 0 {
11✔
825
                return fmt.Sprintf("%dh %dm", hours, minutes)
2✔
826
        }
2✔
827

828
        return fmt.Sprintf("%dm", minutes)
7✔
829
}
830

831
func (s *Server) downloadDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
3✔
832
        ctx := r.Context()
3✔
833
        spam, err := s.DetectedSpam.Read(ctx)
3✔
834
        if err != nil {
4✔
835
                w.WriteHeader(http.StatusInternalServerError)
1✔
836
                rest.RenderJSON(w, rest.JSON{"error": "can't get detected spam", "details": err.Error()})
1✔
837
                return
1✔
838
        }
1✔
839

840
        type jsonSpamInfo struct {
2✔
841
                ID        int64                `json:"id"`
2✔
842
                GID       string               `json:"gid"`
2✔
843
                Text      string               `json:"text"`
2✔
844
                UserID    int64                `json:"user_id"`
2✔
845
                UserName  string               `json:"user_name"`
2✔
846
                Timestamp time.Time            `json:"timestamp"`
2✔
847
                Added     bool                 `json:"added"`
2✔
848
                Checks    []spamcheck.Response `json:"checks"`
2✔
849
        }
2✔
850

2✔
851
        // convert entries to jsonl format with lowercase fields
2✔
852
        lines := make([]string, 0, len(spam))
2✔
853
        for _, entry := range spam {
5✔
854
                data, err := json.Marshal(jsonSpamInfo{
3✔
855
                        ID:        entry.ID,
3✔
856
                        GID:       entry.GID,
3✔
857
                        Text:      entry.Text,
3✔
858
                        UserID:    entry.UserID,
3✔
859
                        UserName:  entry.UserName,
3✔
860
                        Timestamp: entry.Timestamp,
3✔
861
                        Added:     entry.Added,
3✔
862
                        Checks:    entry.Checks,
3✔
863
                })
3✔
864
                if err != nil {
3✔
865
                        w.WriteHeader(http.StatusInternalServerError)
×
866
                        rest.RenderJSON(w, rest.JSON{"error": "can't marshal entry", "details": err.Error()})
×
867
                        return
×
868
                }
×
869
                lines = append(lines, string(data))
3✔
870
        }
871

872
        body := strings.Join(lines, "\n")
2✔
873
        w.Header().Set("Content-Type", "application/x-jsonlines")
2✔
874
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "detected_spam.jsonl"))
2✔
875
        w.Header().Set("Content-Length", strconv.Itoa(len(body)))
2✔
876
        w.WriteHeader(http.StatusOK)
2✔
877
        _, _ = w.Write([]byte(body))
2✔
878
}
879

880
// downloadBackupHandler streams a database backup as an SQL file with gzip compression
881
// Files are always compressed and always have .gz extension to ensure consistency
882
func (s *Server) downloadBackupHandler(w http.ResponseWriter, r *http.Request) {
2✔
883
        if s.StorageEngine == nil {
3✔
884
                w.WriteHeader(http.StatusInternalServerError)
1✔
885
                rest.RenderJSON(w, rest.JSON{"error": "storage engine not available"})
1✔
886
                return
1✔
887
        }
1✔
888

889
        // set filename based on database type and timestamp
890
        dbType := "db"
1✔
891
        sqlEng, ok := s.StorageEngine.(*engine.SQL)
1✔
892
        if ok {
1✔
893
                dbType = string(sqlEng.Type())
×
894
        }
×
895
        timestamp := time.Now().Format("20060102-150405")
1✔
896

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

1✔
900
        // set headers for file download - note we're using application/octet-stream
1✔
901
        // instead of application/sql to prevent browsers from trying to interpret the file
1✔
902
        w.Header().Set("Content-Type", "application/octet-stream")
1✔
903
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
1✔
904
        w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
1✔
905
        w.Header().Set("Pragma", "no-cache")
1✔
906
        w.Header().Set("Expires", "0")
1✔
907

1✔
908
        // create a gzip writer that streams to response
1✔
909
        gzipWriter := gzip.NewWriter(w)
1✔
910
        defer func() {
2✔
911
                if err := gzipWriter.Close(); err != nil {
1✔
912
                        log.Printf("[ERROR] failed to close gzip writer: %v", err)
×
913
                }
×
914
        }()
915

916
        // stream backup directly to response through gzip
917
        if err := s.StorageEngine.Backup(r.Context(), gzipWriter); err != nil {
1✔
918
                log.Printf("[ERROR] failed to create backup: %v", err)
×
919
                // we've already started writing the response, so we can't send a proper error response
×
920
                return
×
921
        }
×
922

923
        // flush the gzip writer to ensure all data is written
924
        if err := gzipWriter.Flush(); err != nil {
1✔
925
                log.Printf("[ERROR] failed to flush gzip writer: %v", err)
×
926
        }
×
927
}
928

929
// downloadExportToPostgresHandler streams a PostgreSQL-compatible export from a SQLite database
930
func (s *Server) downloadExportToPostgresHandler(w http.ResponseWriter, r *http.Request) {
3✔
931
        if s.StorageEngine == nil {
4✔
932
                w.WriteHeader(http.StatusInternalServerError)
1✔
933
                rest.RenderJSON(w, rest.JSON{"error": "storage engine not available"})
1✔
934
                return
1✔
935
        }
1✔
936

937
        // check if the database is SQLite
938
        if s.StorageEngine.Type() != engine.Sqlite {
3✔
939
                w.WriteHeader(http.StatusBadRequest)
1✔
940
                rest.RenderJSON(w, rest.JSON{"error": "source database must be SQLite"})
1✔
941
                return
1✔
942
        }
1✔
943

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

1✔
948
        // set headers for file download
1✔
949
        w.Header().Set("Content-Type", "application/octet-stream")
1✔
950
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
1✔
951
        w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
1✔
952
        w.Header().Set("Pragma", "no-cache")
1✔
953
        w.Header().Set("Expires", "0")
1✔
954

1✔
955
        // create a gzip writer that streams to response
1✔
956
        gzipWriter := gzip.NewWriter(w)
1✔
957
        defer func() {
2✔
958
                if err := gzipWriter.Close(); err != nil {
1✔
959
                        log.Printf("[ERROR] failed to close gzip writer: %v", err)
×
960
                }
×
961
        }()
962

963
        // stream export directly to response through gzip
964
        if err := s.StorageEngine.BackupSqliteAsPostgres(r.Context(), gzipWriter); err != nil {
1✔
965
                log.Printf("[ERROR] failed to create export: %v", err)
×
966
                // we've already started writing the response, so we can't send a proper error response
×
967
                return
×
968
        }
×
969

970
        // flush the gzip writer to ensure all data is written
971
        if err := gzipWriter.Flush(); err != nil {
1✔
972
                log.Printf("[ERROR] failed to flush gzip writer: %v", err)
×
973
        }
×
974
}
975

976
func (s *Server) renderSamples(w http.ResponseWriter, tmplName string) {
6✔
977
        spam, ham, err := s.SpamFilter.DynamicSamples()
6✔
978
        if err != nil {
7✔
979
                w.WriteHeader(http.StatusInternalServerError)
1✔
980
                rest.RenderJSON(w, rest.JSON{"error": "can't fetch samples", "details": err.Error()})
1✔
981
                return
1✔
982
        }
1✔
983

984
        spam, ham = s.reverseSamples(spam, ham)
5✔
985

5✔
986
        type smpleWithID struct {
5✔
987
                ID     string
5✔
988
                Sample string
5✔
989
        }
5✔
990

5✔
991
        makeID := func(s string) string {
19✔
992
                hash := sha1.New() //nolint
14✔
993
                if _, err := hash.Write([]byte(s)); err != nil {
14✔
994
                        return fmt.Sprintf("%x", s)
×
995
                }
×
996
                return fmt.Sprintf("%x", hash.Sum(nil))
14✔
997
        }
998

999
        tmplData := struct {
5✔
1000
                SpamSamples      []smpleWithID
5✔
1001
                HamSamples       []smpleWithID
5✔
1002
                TotalHamSamples  int
5✔
1003
                TotalSpamSamples int
5✔
1004
        }{
5✔
1005
                TotalHamSamples:  len(ham),
5✔
1006
                TotalSpamSamples: len(spam),
5✔
1007
        }
5✔
1008
        for _, s := range spam {
12✔
1009
                tmplData.SpamSamples = append(tmplData.SpamSamples, smpleWithID{ID: makeID(s), Sample: s})
7✔
1010
        }
7✔
1011
        for _, h := range ham {
12✔
1012
                tmplData.HamSamples = append(tmplData.HamSamples, smpleWithID{ID: makeID(h), Sample: h})
7✔
1013
        }
7✔
1014

1015
        if err := tmpl.ExecuteTemplate(w, tmplName, tmplData); err != nil {
6✔
1016
                w.WriteHeader(http.StatusInternalServerError)
1✔
1017
                rest.RenderJSON(w, rest.JSON{"error": "can't execute template", "details": err.Error()})
1✔
1018
                return
1✔
1019
        }
1✔
1020
}
1021

1022
func (s *Server) authMiddleware(mw func(next http.Handler) http.Handler) func(next http.Handler) http.Handler {
10✔
1023
        if s.AuthPasswd == "" {
16✔
1024
                return func(next http.Handler) http.Handler {
105✔
1025
                        return next
99✔
1026
                }
99✔
1027
        }
1028
        return func(next http.Handler) http.Handler {
70✔
1029
                return mw(next)
66✔
1030
        }
66✔
1031
}
1032

1033
// reverseSamples returns reversed lists of spam and ham samples
1034
func (s *Server) reverseSamples(spam, ham []string) (revSpam, revHam []string) {
8✔
1035
        revSpam = make([]string, len(spam))
8✔
1036
        revHam = make([]string, len(ham))
8✔
1037

8✔
1038
        for i, j := 0, len(spam)-1; i < len(spam); i, j = i+1, j-1 {
19✔
1039
                revSpam[i] = spam[j]
11✔
1040
        }
11✔
1041
        for i, j := 0, len(ham)-1; i < len(ham); i, j = i+1, j-1 {
19✔
1042
                revHam[i] = ham[j]
11✔
1043
        }
11✔
1044
        return revSpam, revHam
8✔
1045
}
1046

1047
// staticFS is a filtered filesystem that only exposes specific static files
1048
type staticFS struct {
1049
        fs        fs.FS
1050
        urlToPath map[string]string
1051
}
1052

1053
// staticFileMapping defines a mapping between URL path and filesystem path
1054
type staticFileMapping struct {
1055
        urlPath     string
1056
        filesysPath string
1057
}
1058

1059
func newStaticFS(fsys fs.FS, files ...staticFileMapping) *staticFS {
5✔
1060
        urlToPath := make(map[string]string)
5✔
1061
        for _, f := range files {
20✔
1062
                urlToPath[f.urlPath] = f.filesysPath
15✔
1063
        }
15✔
1064

1065
        return &staticFS{
5✔
1066
                fs:        fsys,
5✔
1067
                urlToPath: urlToPath,
5✔
1068
        }
5✔
1069
}
1070

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

5✔
1074
        fsPath, ok := sfs.urlToPath[cleanName]
5✔
1075
        if !ok {
7✔
1076
                return nil, fs.ErrNotExist
2✔
1077
        }
2✔
1078

1079
        file, err := sfs.fs.Open(fsPath)
3✔
1080
        if err != nil {
3✔
1081
                return nil, fmt.Errorf("failed to open static file %s: %w", fsPath, err)
×
1082
        }
×
1083
        return file, nil
3✔
1084
}
1085

1086
// GenerateRandomPassword generates a random password of a given length
1087
func GenerateRandomPassword(length int) (string, error) {
2✔
1088
        const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+"
2✔
1089
        const charsetLen = int64(len(charset))
2✔
1090

2✔
1091
        result := make([]byte, length)
2✔
1092
        for i := 0; i < length; i++ {
66✔
1093
                n, err := rand.Int(rand.Reader, big.NewInt(charsetLen))
64✔
1094
                if err != nil {
64✔
1095
                        return "", fmt.Errorf("failed to generate random number: %w", err)
×
1096
                }
×
1097
                result[i] = charset[n.Int64()]
64✔
1098
        }
1099
        return string(result), nil
2✔
1100
}
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