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

umputun / tg-spam / 23994194211

05 Apr 2026 04:25AM UTC coverage: 82.786% (+0.01%) from 82.773%
23994194211

push

github

web-flow
Add Google GenAI spam classification support (#386)

* Add Google GenAI spam classification support

After introducing Gemma 4 model family, with nice (for it's size) multilingual capabilities and generous free quota (1.5K req/day), Google GenAI became a clever choice for even relatively large TG groups.

* fix: change MaxTokensResponse type from int to int32 for compatibility

* feat: refactor LLM provider-specific spam detection logic to avoid code duplication

* feat: add LLM consensus configuration and update documentation

* fix: string formatting (golangci-lint)

* refactor: convert LLM consensus functions to methods

* fix: UTF-8 safe truncation for message requests in Gemini and OpenAI checkers

* fix: update LLM eligibility check for short messages in spam detection

* feat: add request timeout for LLM checks and update context handling

---------

Co-authored-by: Umputun <umputun@gmail.com>

224 of 265 new or added lines in 6 files covered. (84.53%)

6887 of 8319 relevant lines covered (82.79%)

259.95 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

180
// NewServer creates a new web API server.
181
func NewServer(config Config) *Server {
73✔
182
        return &Server{Config: config}
73✔
183
}
73✔
184

185
// Run starts server and accepts requests checking for spam messages.
186
func (s *Server) Run(ctx context.Context) error {
3✔
187
        router := routegroup.New(http.NewServeMux())
3✔
188
        router.Use(rest.Recoverer(log.Default()))
3✔
189
        router.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]")).Handler)
3✔
190
        router.Use(rest.Throttle(1000))
3✔
191
        router.Use(rest.AppInfo("tg-spam", "umputun", s.Version), rest.Ping)
3✔
192
        router.Use(tollbooth.HTTPMiddleware(tollbooth.NewLimiter(50, nil)))
3✔
193
        router.Use(rest.SizeLimit(1024 * 1024)) // 1M max request size
3✔
194

3✔
195
        if s.AuthPasswd != "" || s.AuthHash != "" {
6✔
196
                log.Printf("[INFO] basic auth enabled for webapi server")
3✔
197
                if s.AuthHash != "" {
4✔
198
                        router.Use(rest.BasicAuthWithBcryptHashAndPrompt("tg-spam", s.AuthHash))
1✔
199
                } else {
3✔
200
                        router.Use(rest.BasicAuthWithPrompt("tg-spam", s.AuthPasswd))
2✔
201
                }
2✔
202
        } else {
×
203
                log.Printf("[WARN] basic auth disabled, access to webapi is not protected")
×
204
        }
×
205

206
        router = s.routes(router) // setup routes
3✔
207

3✔
208
        srv := &http.Server{Addr: s.ListenAddr, Handler: router, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second}
3✔
209
        go func() {
6✔
210
                <-ctx.Done()
3✔
211
                if err := srv.Shutdown(ctx); err != nil {
4✔
212
                        log.Printf("[WARN] failed to shutdown webapi server: %v", err)
1✔
213
                } else {
3✔
214
                        log.Printf("[INFO] webapi server stopped")
2✔
215
                }
2✔
216
        }()
217

218
        log.Printf("[INFO] start webapi server on %s", s.ListenAddr)
3✔
219
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
3✔
220
                return fmt.Errorf("failed to run server: %w", err)
×
221
        }
×
222
        return nil
3✔
223
}
224

225
func (s *Server) routes(router *routegroup.Bundle) *routegroup.Bundle {
5✔
226
        // auth api routes
5✔
227
        router.Group().Route(func(authApi *routegroup.Bundle) {
10✔
228
                authApi.Use(s.authMiddleware(rest.BasicAuthWithUserPasswd("tg-spam", s.AuthPasswd)))
5✔
229
                authApi.HandleFunc("POST /check", s.checkMsgHandler)         // check a message for spam
5✔
230
                authApi.HandleFunc("GET /check/{user_id}", s.checkIDHandler) // check user id for spam
5✔
231

5✔
232
                authApi.Mount("/update").Route(func(r *routegroup.Bundle) {
10✔
233
                        // update spam/ham samples
5✔
234
                        r.HandleFunc("POST /spam", s.updateSampleHandler(s.SpamFilter.UpdateSpam)) // update spam samples
5✔
235
                        r.HandleFunc("POST /ham", s.updateSampleHandler(s.SpamFilter.UpdateHam))   // update ham samples
5✔
236
                })
5✔
237

238
                authApi.Mount("/delete").Route(func(r *routegroup.Bundle) {
10✔
239
                        // delete spam/ham samples
5✔
240
                        r.HandleFunc("POST /spam", s.deleteSampleHandler(s.SpamFilter.RemoveDynamicSpamSample))
5✔
241
                        r.HandleFunc("POST /ham", s.deleteSampleHandler(s.SpamFilter.RemoveDynamicHamSample))
5✔
242
                })
5✔
243

244
                authApi.Mount("/download").Route(func(r *routegroup.Bundle) {
10✔
245
                        r.HandleFunc("GET /spam", s.downloadSampleHandler(func(spam, _ []string) ([]string, string) {
5✔
246
                                return spam, "spam.txt"
×
247
                        }))
×
248
                        r.HandleFunc("GET /ham", s.downloadSampleHandler(func(_, ham []string) ([]string, string) {
5✔
249
                                return ham, "ham.txt"
×
250
                        }))
×
251
                        r.HandleFunc("GET /detected_spam", s.downloadDetectedSpamHandler)
5✔
252
                        r.HandleFunc("GET /backup", s.downloadBackupHandler)
5✔
253
                        r.HandleFunc("GET /export-to-postgres", s.downloadExportToPostgresHandler)
5✔
254
                })
255

256
                authApi.HandleFunc("GET /samples", s.getDynamicSamplesHandler)    // get dynamic samples
5✔
257
                authApi.HandleFunc("PUT /samples", s.reloadDynamicSamplesHandler) // reload samples
5✔
258

5✔
259
                authApi.Mount("/users").Route(func(r *routegroup.Bundle) { // manage approved users
10✔
260
                        // add user to the approved list and storage
5✔
261
                        r.HandleFunc("POST /add", s.updateApprovedUsersHandler(s.Detector.AddApprovedUser))
5✔
262
                        // remove user from an approved list and storage
5✔
263
                        r.HandleFunc("POST /delete", s.updateApprovedUsersHandler(s.removeApprovedUser))
5✔
264
                        // get approved users
5✔
265
                        r.HandleFunc("GET /", s.getApprovedUsersHandler)
5✔
266
                })
5✔
267

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

5✔
270
                authApi.Mount("/dictionary").Route(func(r *routegroup.Bundle) { // manage dictionary
10✔
271
                        // add stop phrase or ignored word
5✔
272
                        r.HandleFunc("POST /add", s.addDictionaryEntryHandler)
5✔
273
                        // delete entry by id
5✔
274
                        r.HandleFunc("POST /delete", s.deleteDictionaryEntryHandler)
5✔
275
                        // get all entries
5✔
276
                        r.HandleFunc("GET /", s.getDictionaryEntriesHandler)
5✔
277
                })
5✔
278
        })
279

280
        router.Group().Route(func(webUI *routegroup.Bundle) {
10✔
281
                webUI.Use(s.authMiddleware(rest.BasicAuthWithPrompt("tg-spam", s.AuthPasswd)))
5✔
282
                webUI.HandleFunc("GET /", s.htmlSpamCheckHandler)                         // serve template for webUI UI
5✔
283
                webUI.HandleFunc("GET /manage_samples", s.htmlManageSamplesHandler)       // serve manage samples page
5✔
284
                webUI.HandleFunc("GET /manage_users", s.htmlManageUsersHandler)           // serve manage users page
5✔
285
                webUI.HandleFunc("GET /manage_dictionary", s.htmlManageDictionaryHandler) // serve manage dictionary page
5✔
286
                webUI.HandleFunc("GET /detected_spam", s.htmlDetectedSpamHandler)         // serve detected spam page
5✔
287
                webUI.HandleFunc("GET /list_settings", s.htmlSettingsHandler)             // serve settings
5✔
288
                webUI.HandleFunc("POST /detected_spam/add", s.htmlAddDetectedSpamHandler) // add detected spam to samples
5✔
289

5✔
290
                // handle logout - force Basic Auth re-authentication
5✔
291
                webUI.HandleFunc("GET /logout", func(w http.ResponseWriter, _ *http.Request) {
5✔
292
                        w.Header().Set("WWW-Authenticate", `Basic realm="tg-spam"`)
×
293
                        w.WriteHeader(http.StatusUnauthorized)
×
294
                        fmt.Fprintln(w, "Logged out successfully")
×
295
                })
×
296

297
                // serve only specific static files at root level
298
                staticFiles := newStaticFS(templateFS,
5✔
299
                        staticFileMapping{urlPath: "styles.css", filesysPath: "assets/styles.css"},
5✔
300
                        staticFileMapping{urlPath: "logo.png", filesysPath: "assets/logo.png"},
5✔
301
                        staticFileMapping{urlPath: "spinner.svg", filesysPath: "assets/spinner.svg"},
5✔
302
                )
5✔
303
                webUI.HandleFiles("/", http.FS(staticFiles))
5✔
304
        })
305

306
        return router
5✔
307
}
308

309
// checkMsgHandler handles POST /check request.
310
// it gets message text and user id from request body and returns spam status and check results.
311
func (s *Server) checkMsgHandler(w http.ResponseWriter, r *http.Request) {
9✔
312
        type CheckResultDisplay struct {
9✔
313
                Spam   bool
9✔
314
                Checks []spamcheck.Response
9✔
315
        }
9✔
316

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

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

334
        spam, cr := s.Detector.Check(req)
7✔
335
        if !isHtmxRequest {
13✔
336
                // for API request return JSON
6✔
337
                rest.RenderJSON(w, rest.JSON{"spam": spam, "checks": cr})
6✔
338
                return
6✔
339
        }
6✔
340

341
        if req.Msg == "" {
1✔
342
                w.Header().Set("HX-Retarget", "#error-message")
×
343
                fmt.Fprintln(w, "<div class='alert alert-danger'>Valid message required.</div>")
×
344
                return
×
345
        }
×
346

347
        // render result for HTMX request
348
        resultDisplay := CheckResultDisplay{
1✔
349
                Spam:   spam,
1✔
350
                Checks: cr,
1✔
351
        }
1✔
352

1✔
353
        if err := tmpl.ExecuteTemplate(w, "check_results", resultDisplay); err != nil {
1✔
354
                log.Printf("[WARN] can't execute result template: %v", err)
×
355
                http.Error(w, "Error rendering result", http.StatusInternalServerError)
×
356
                return
×
357
        }
×
358
}
359

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

4✔
377
        userID, err := strconv.ParseInt(r.PathValue("user_id"), 10, 64)
4✔
378
        if err != nil {
5✔
379
                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't parse user id", "details": err.Error()})
1✔
380
                return
1✔
381
        }
1✔
382

383
        si, err := s.DetectedSpam.FindByUserID(r.Context(), userID)
3✔
384
        if err != nil {
4✔
385
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't get user info", "details": err.Error()})
1✔
386
                return
1✔
387
        }
1✔
388
        if si != nil {
3✔
389
                resp.Status = "spam"
1✔
390
                resp.Info = &info{
1✔
391
                        UserName:  si.UserName,
1✔
392
                        Message:   si.Text,
1✔
393
                        Timestamp: si.Timestamp,
1✔
394
                        Checks:    si.Checks,
1✔
395
                }
1✔
396
        }
1✔
397
        rest.RenderJSON(w, resp)
2✔
398
}
399

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

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

429
// updateSampleHandler handles POST /update/spam|ham request. It updates dynamic samples both for spam and ham.
430
func (s *Server) updateSampleHandler(updFn func(msg string) error) func(w http.ResponseWriter, r *http.Request) {
15✔
431
        return func(w http.ResponseWriter, r *http.Request) {
22✔
432
                var req struct {
7✔
433
                        Msg string `json:"msg"`
7✔
434
                }
7✔
435

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

7✔
438
                if isHtmxRequest {
7✔
439
                        req.Msg = r.FormValue("msg")
×
440
                } else {
7✔
441
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
9✔
442
                                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't decode request", "details": err.Error()})
2✔
443
                                return
2✔
444
                        }
2✔
445
                }
446

447
                err := updFn(req.Msg)
5✔
448
                if err != nil {
7✔
449
                        _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't update samples", "details": err.Error()})
2✔
450
                        return
2✔
451
                }
2✔
452

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

585
        ignoredWords, err := s.Dictionary.Read(r.Context(), storage.DictionaryTypeIgnoredWord)
2✔
586
        if err != nil {
3✔
587
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't get ignored words", "details": err.Error()})
1✔
588
                return
1✔
589
        }
1✔
590

591
        rest.RenderJSON(w, rest.JSON{"stop_phrases": stopPhrases, "ignored_words": ignoredWords})
1✔
592
}
593

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

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

11✔
603
        if isHtmxRequest {
15✔
604
                req.Type = r.FormValue("type")
4✔
605
                req.Data = r.FormValue("data")
4✔
606
        } else {
11✔
607
                if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
8✔
608
                        _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
609
                        return
1✔
610
                }
1✔
611
        }
612

613
        if req.Data == "" {
13✔
614
                if isHtmxRequest {
4✔
615
                        w.Header().Set("HX-Retarget", "#error-message")
1✔
616
                        fmt.Fprintln(w, "<div class='alert alert-danger'>Data cannot be empty.</div>")
1✔
617
                        return
1✔
618
                }
1✔
619
                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "data cannot be empty"})
2✔
620
                return
2✔
621
        }
622

623
        dictType := storage.DictionaryType(req.Type)
7✔
624
        if err := dictType.Validate(); err != nil {
9✔
625
                if isHtmxRequest {
3✔
626
                        w.Header().Set("HX-Retarget", "#error-message")
1✔
627
                        fmt.Fprintf(w, "<div class='alert alert-danger'>Invalid type: %v</div>", err)
1✔
628
                        return
1✔
629
                }
1✔
630
                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "invalid type", "details": err.Error()})
1✔
631
                return
1✔
632
        }
633

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

639
        // reload samples to apply dictionary changes immediately
640
        if err := s.SpamFilter.ReloadSamples(); err != nil {
6✔
641
                log.Printf("[WARN] failed to reload samples after dictionary add: %v", err)
2✔
642
                if !isHtmxRequest {
3✔
643
                        _ = rest.EncodeJSON(w, http.StatusInternalServerError,
1✔
644
                                rest.JSON{"error": "entry added but reload failed", "details": err.Error()})
1✔
645
                        return
1✔
646
                }
1✔
647
                // for HTMX, log but continue rendering (entry was added successfully)
648
        }
649

650
        if isHtmxRequest {
5✔
651
                s.renderDictionary(r.Context(), w, "dictionary_list")
2✔
652
        } else {
3✔
653
                rest.RenderJSON(w, rest.JSON{"added": true, "type": req.Type, "data": req.Data})
1✔
654
        }
1✔
655
}
656

657
// deleteDictionaryEntryHandler handles POST /dictionary/delete request. It deletes an entry by data.
658
func (s *Server) deleteDictionaryEntryHandler(w http.ResponseWriter, r *http.Request) {
7✔
659
        var req struct {
7✔
660
                ID int64 `json:"id"`
7✔
661
        }
7✔
662

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

7✔
665
        if isHtmxRequest {
10✔
666
                idStr := r.FormValue("id")
3✔
667
                var err error
3✔
668
                req.ID, err = strconv.ParseInt(idStr, 10, 64)
3✔
669
                if err != nil {
4✔
670
                        w.Header().Set("HX-Retarget", "#error-message")
1✔
671
                        fmt.Fprintf(w, "<div class='alert alert-danger'>Invalid ID: %v</div>", err)
1✔
672
                        return
1✔
673
                }
1✔
674
        } else {
4✔
675
                if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
5✔
676
                        _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
677
                        return
1✔
678
                }
1✔
679
        }
680

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

686
        // reload samples to apply dictionary changes immediately
687
        if err := s.SpamFilter.ReloadSamples(); err != nil {
6✔
688
                log.Printf("[WARN] failed to reload samples after dictionary delete: %v", err)
2✔
689
                if !isHtmxRequest {
3✔
690
                        _ = rest.EncodeJSON(w, http.StatusInternalServerError,
1✔
691
                                rest.JSON{"error": "entry deleted but reload failed", "details": err.Error()})
1✔
692
                        return
1✔
693
                }
1✔
694
                // for HTMX, log but continue rendering (entry was deleted successfully)
695
        }
696

697
        if isHtmxRequest {
5✔
698
                s.renderDictionary(r.Context(), w, "dictionary_list")
2✔
699
        } else {
3✔
700
                rest.RenderJSON(w, rest.JSON{"deleted": true, "id": req.ID})
1✔
701
        }
1✔
702
}
703

704
// htmlSpamCheckHandler handles GET / request.
705
// It returns rendered spam_check.html template with all the components.
706
func (s *Server) htmlSpamCheckHandler(w http.ResponseWriter, _ *http.Request) {
3✔
707
        tmplData := struct {
3✔
708
                Version string
3✔
709
        }{
3✔
710
                Version: s.Version,
3✔
711
        }
3✔
712

3✔
713
        if err := tmpl.ExecuteTemplate(w, "spam_check.html", tmplData); err != nil {
4✔
714
                log.Printf("[WARN] can't execute template: %v", err)
1✔
715
                http.Error(w, "Error executing template", http.StatusInternalServerError)
1✔
716
                return
1✔
717
        }
1✔
718
}
719

720
// htmlManageSamplesHandler handles GET /manage_samples request.
721
// It returns rendered manage_samples.html template with all the components.
722
func (s *Server) htmlManageSamplesHandler(w http.ResponseWriter, _ *http.Request) {
1✔
723
        s.renderSamples(w, "manage_samples.html")
1✔
724
}
1✔
725

726
func (s *Server) htmlManageUsersHandler(w http.ResponseWriter, _ *http.Request) {
3✔
727
        users := s.Detector.ApprovedUsers()
3✔
728
        tmplData := struct {
3✔
729
                ApprovedUsers      []approved.UserInfo
3✔
730
                TotalApprovedUsers int
3✔
731
        }{
3✔
732
                ApprovedUsers:      users,
3✔
733
                TotalApprovedUsers: len(users),
3✔
734
        }
3✔
735
        tmplData.TotalApprovedUsers = len(tmplData.ApprovedUsers)
3✔
736

3✔
737
        if err := tmpl.ExecuteTemplate(w, "manage_users.html", tmplData); err != nil {
4✔
738
                log.Printf("[WARN] can't execute template: %v", err)
1✔
739
                http.Error(w, "Error executing template", http.StatusInternalServerError)
1✔
740
                return
1✔
741
        }
1✔
742
}
743

744
func (s *Server) htmlManageDictionaryHandler(w http.ResponseWriter, r *http.Request) {
×
745
        s.renderDictionary(r.Context(), w, "manage_dictionary.html")
×
746
}
×
747

748
func (s *Server) htmlDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
2✔
749
        ds, err := s.DetectedSpam.Read(r.Context())
2✔
750
        if err != nil {
3✔
751
                log.Printf("[ERROR] Failed to fetch detected spam: %v", err)
1✔
752
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
1✔
753
                return
1✔
754
        }
1✔
755

756
        // clean up detected spam entries
757
        for i, d := range ds {
3✔
758
                d.Text = strings.ReplaceAll(d.Text, "'", " ")
2✔
759
                d.Text = strings.ReplaceAll(d.Text, "\n", " ")
2✔
760
                d.Text = strings.ReplaceAll(d.Text, "\r", " ")
2✔
761
                d.Text = strings.ReplaceAll(d.Text, "\t", " ")
2✔
762
                d.Text = strings.ReplaceAll(d.Text, "\"", " ")
2✔
763
                d.Text = strings.ReplaceAll(d.Text, "\\", " ")
2✔
764
                ds[i] = d
2✔
765
        }
2✔
766

767
        // get filter from query param, default to "all"
768
        filter := r.URL.Query().Get("filter")
1✔
769
        if filter == "" {
2✔
770
                filter = "all"
1✔
771
        }
1✔
772

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

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

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

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

846
                // then append OOB swap for the count display
847
                countHTML := ""
×
848
                if filter != "all" {
×
849
                        countHTML = fmt.Sprintf("(%d/%d)", len(filteredDS), len(ds))
×
850
                } else {
×
851
                        countHTML = fmt.Sprintf("(%d)", len(ds))
×
852
                }
×
853

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

×
856
                // write the combined response
×
857
                if _, err := buf.WriteTo(w); err != nil {
×
858
                        log.Printf("[WARN] failed to write response: %v", err)
×
859
                }
×
860
                return
×
861
        }
862

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

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

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

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

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

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

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

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

4✔
925
        // get system info - uptime since server start
4✔
926
        uptime := time.Since(startTime)
4✔
927

4✔
928
        // get the list of available Lua plugins
4✔
929
        s.Settings.LuaAvailablePlugins = s.Detector.GetLuaPluginNames()
4✔
930

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

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

979
// formatDuration formats a duration in a human-readable way
980
func formatDuration(d time.Duration) string {
12✔
981
        days := int(d.Hours() / 24)
12✔
982
        hours := int(d.Hours()) % 24
12✔
983
        minutes := int(d.Minutes()) % 60
12✔
984

12✔
985
        if days > 0 {
15✔
986
                return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
3✔
987
        }
3✔
988

989
        if hours > 0 {
11✔
990
                return fmt.Sprintf("%dh %dm", hours, minutes)
2✔
991
        }
2✔
992

993
        return fmt.Sprintf("%dm", minutes)
7✔
994
}
995

996
func (s *Server) downloadDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
3✔
997
        ctx := r.Context()
3✔
998
        spam, err := s.DetectedSpam.Read(ctx)
3✔
999
        if err != nil {
4✔
1000
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't get detected spam", "details": err.Error()})
1✔
1001
                return
1✔
1002
        }
1✔
1003

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1213
        ignoredWords, err := s.Dictionary.ReadWithIDs(ctx, storage.DictionaryTypeIgnoredWord)
4✔
1214
        if err != nil {
4✔
1215
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't fetch ignored words", "details": err.Error()})
×
1216
                return
×
1217
        }
×
1218

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

4✔
1231
        if err := tmpl.ExecuteTemplate(w, tmplName, tmplData); err != nil {
4✔
1232
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't execute template", "details": err.Error()})
×
1233
                return
×
1234
        }
×
1235
}
1236

1237
// staticFS is a filtered filesystem that only exposes specific static files
1238
type staticFS struct {
1239
        fs        fs.FS
1240
        urlToPath map[string]string
1241
}
1242

1243
// staticFileMapping defines a mapping between URL path and filesystem path
1244
type staticFileMapping struct {
1245
        urlPath     string
1246
        filesysPath string
1247
}
1248

1249
func newStaticFS(fsys fs.FS, files ...staticFileMapping) *staticFS {
5✔
1250
        urlToPath := make(map[string]string)
5✔
1251
        for _, f := range files {
20✔
1252
                urlToPath[f.urlPath] = f.filesysPath
15✔
1253
        }
15✔
1254

1255
        return &staticFS{
5✔
1256
                fs:        fsys,
5✔
1257
                urlToPath: urlToPath,
5✔
1258
        }
5✔
1259
}
1260

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

5✔
1264
        fsPath, ok := sfs.urlToPath[cleanName]
5✔
1265
        if !ok {
7✔
1266
                return nil, fs.ErrNotExist
2✔
1267
        }
2✔
1268

1269
        file, err := sfs.fs.Open(fsPath)
3✔
1270
        if err != nil {
3✔
1271
                return nil, fmt.Errorf("failed to open static file %s: %w", fsPath, err)
×
1272
        }
×
1273
        return file, nil
3✔
1274
}
1275

1276
// GenerateRandomPassword generates a random password of a given length
1277
func GenerateRandomPassword(length int) (string, error) {
2✔
1278
        const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+"
2✔
1279
        const charsetLen = int64(len(charset))
2✔
1280

2✔
1281
        result := make([]byte, length)
2✔
1282
        for i := range length {
66✔
1283
                n, err := rand.Int(rand.Reader, big.NewInt(charsetLen))
64✔
1284
                if err != nil {
64✔
1285
                        return "", fmt.Errorf("failed to generate random number: %w", err)
×
1286
                }
×
1287
                result[i] = charset[n.Int64()]
64✔
1288
        }
1289
        return string(result), nil
2✔
1290
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc