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

umputun / tg-spam / 23829164434

01 Apr 2026 02:23AM UTC coverage: 82.826% (+0.1%) from 82.685%
23829164434

Pull #384

github

paskal
refactor: remove SSE, add bot link and refresh button

- remove SSE infrastructure (sseHandler, Subscribe/Unsubscribe, keepalive)
- replace with simple HTMX Refresh button for DM users table
- add t.me/botname link in instructions when bot username is available
- add BotUsername to Settings, wire from TelegramListener
- update README to reflect simplified approach
- simplify DMUsersProvider interface to single GetDMUsers method
- remove unused subscriber code from dmUsers storage
Pull Request #384: feat: show recent bot DM users in admin settings for easy user ID discovery

126 of 135 new or added lines in 4 files covered. (93.33%)

29 existing lines in 1 file now uncovered.

6805 of 8216 relevant lines covered (82.83%)

263.52 hits per line

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

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

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

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

49
// startTime tracks when the server started
50
var startTime = time.Now()
51

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

57
// Config defines  server parameters
58
type Config struct {
59
        Version         string          // version to show in /ping
60
        ListenAddr      string          // listen address
61
        Detector        Detector        // spam detector
62
        SpamFilter      SpamFilter      // spam filter (bot)
63
        DetectedSpam    DetectedSpam    // detected spam accessor
64
        Locator         Locator         // locator for user info
65
        Dictionary      Dictionary      // dictionary for stop phrases and ignored words
66
        StorageEngine   StorageEngine   // database engine access for backups
67
        DMUsersProvider DMUsersProvider // provider for recent DM users
68
        AuthPasswd      string          // basic auth password for user "tg-spam"
69
        AuthHash        string          // basic auth bcrypt hash for user "tg-spam", takes precedence over AuthPasswd
70
        Dbg             bool            // debug mode
71
        Settings        Settings        // application settings
72
}
73

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

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

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

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

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

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

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

177
// DMUsersProvider provides access to recent DM users for the admin UI
178
type DMUsersProvider interface {
179
        GetDMUsers() []events.DMUser
180
}
181

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

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

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

208
        router = s.routes(router) // setup routes
3✔
209

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

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

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

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

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

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

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

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

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

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

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

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

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

309
        return router
5✔
310
}
311

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

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

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

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

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

350
        // render result for HTMX request
351
        resultDisplay := CheckResultDisplay{
1✔
352
                Spam:   spam,
1✔
353
                Checks: cr,
1✔
354
        }
1✔
355

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

747
func (s *Server) htmlManageDictionaryHandler(w http.ResponseWriter, r *http.Request) {
×
748
        s.renderDictionary(r.Context(), w, "manage_dictionary.html")
×
749
}
×
750

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

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

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

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

809
        tmplData := struct {
1✔
810
                DetectedSpamEntries []storage.DetectedSpamInfo
1✔
811
                TotalDetectedSpam   int
1✔
812
                FilteredCount       int
1✔
813
                Filter              string
1✔
814
                OpenAIEnabled       bool
1✔
815
        }{
1✔
816
                DetectedSpamEntries: filteredDS,
1✔
817
                TotalDetectedSpam:   len(ds),
1✔
818
                FilteredCount:       len(filteredDS),
1✔
819
                Filter:              filter,
1✔
820
                OpenAIEnabled:       s.Settings.OpenAIEnabled,
1✔
821
        }
1✔
822

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

×
827
                // first render the content template
×
828
                if err := tmpl.ExecuteTemplate(&buf, "detected_spam_content", tmplData); err != nil {
×
829
                        log.Printf("[WARN] can't execute content template: %v", err)
×
830
                        http.Error(w, "Error executing template", http.StatusInternalServerError)
×
831
                        return
×
832
                }
×
833

834
                // then append OOB swap for the count display
835
                countHTML := ""
×
836
                if filter != "all" {
×
837
                        countHTML = fmt.Sprintf("(%d/%d)", len(filteredDS), len(ds))
×
838
                } else {
×
839
                        countHTML = fmt.Sprintf("(%d)", len(ds))
×
840
                }
×
841

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

×
844
                // write the combined response
×
845
                if _, err := buf.WriteTo(w); err != nil {
×
846
                        log.Printf("[WARN] failed to write response: %v", err)
×
847
                }
×
848
                return
×
849
        }
850

851
        // full page render for normal requests
852
        if err := tmpl.ExecuteTemplate(w, "detected_spam.html", tmplData); err != nil {
1✔
853
                log.Printf("[WARN] can't execute template: %v", err)
×
854
                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
855
                return
×
856
        }
×
857
}
858

859
func (s *Server) htmlAddDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
5✔
860
        reportErr := func(err error, _ int) {
9✔
861
                w.Header().Set("HX-Retarget", "#error-message")
4✔
862
                fmt.Fprintf(w, "<div class='alert alert-danger'>%s</div>", err)
4✔
863
        }
4✔
864
        msg := r.FormValue("msg")
5✔
865

5✔
866
        id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
5✔
867
        if err != nil || msg == "" {
7✔
868
                log.Printf("[WARN] bad request: %v", err)
2✔
869
                reportErr(fmt.Errorf("bad request: %v", err), http.StatusBadRequest)
2✔
870
                return
2✔
871
        }
2✔
872

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

1✔
878
        }
1✔
879
        if err := s.DetectedSpam.SetAddedToSamplesFlag(r.Context(), id); err != nil {
3✔
880
                log.Printf("[WARN] failed to update detected spam: %v", err)
1✔
881
                reportErr(fmt.Errorf("can't update detected spam: %v", err), http.StatusInternalServerError)
1✔
882
                return
1✔
883
        }
1✔
884
        w.WriteHeader(http.StatusOK)
1✔
885
}
886

887
func (s *Server) htmlSettingsHandler(w http.ResponseWriter, _ *http.Request) {
5✔
888
        // get database information if StorageEngine is available
5✔
889
        var dbInfo struct {
5✔
890
                DatabaseType   string `json:"database_type"`
5✔
891
                GID            string `json:"gid"`
5✔
892
                DatabaseStatus string `json:"database_status"`
5✔
893
        }
5✔
894

5✔
895
        if s.StorageEngine != nil {
7✔
896
                // try to cast to SQL engine to get type information
2✔
897
                if sqlEngine, ok := s.StorageEngine.(*engine.SQL); ok {
2✔
898
                        dbInfo.DatabaseType = string(sqlEngine.Type())
×
899
                        dbInfo.GID = sqlEngine.GID()
×
900
                        dbInfo.DatabaseStatus = "Connected"
×
901
                } else {
2✔
902
                        dbInfo.DatabaseType = "Unknown"
2✔
903
                        dbInfo.DatabaseStatus = "Connected (unknown type)"
2✔
904
                }
2✔
905
        } else {
3✔
906
                dbInfo.DatabaseStatus = "Not connected"
3✔
907
        }
3✔
908

909
        // get backup information
910
        backupURL := "/download/backup"
5✔
911
        backupFilename := fmt.Sprintf("tg-spam-backup-%s-%s.sql.gz", dbInfo.DatabaseType, time.Now().Format("20060102-150405"))
5✔
912

5✔
913
        // get system info - uptime since server start
5✔
914
        uptime := time.Since(startTime)
5✔
915

5✔
916
        // get the list of available Lua plugins
5✔
917
        s.Settings.LuaAvailablePlugins = s.Detector.GetLuaPluginNames()
5✔
918

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

5✔
960
        if err := tmpl.ExecuteTemplate(w, "settings.html", data); err != nil {
6✔
961
                log.Printf("[WARN] can't execute template: %v", err)
1✔
962
                http.Error(w, "Error executing template", http.StatusInternalServerError)
1✔
963
                return
1✔
964
        }
1✔
965
}
966

967
// getDMUsersHandler handles GET /dm-users. For HTMX requests it renders the dm_users.html partial,
968
// for API requests it returns JSON with the list of recent DM users.
969
func (s *Server) getDMUsersHandler(w http.ResponseWriter, r *http.Request) {
6✔
970
        if s.DMUsersProvider == nil {
7✔
971
                http.Error(w, "DM users provider not configured", http.StatusServiceUnavailable)
1✔
972
                return
1✔
973
        }
1✔
974

975
        users := s.DMUsersProvider.GetDMUsers()
5✔
976

5✔
977
        if r.Header.Get("HX-Request") != "true" {
7✔
978
                // api response — return raw timestamps, no relative time
2✔
979
                type dmUserJSON struct {
2✔
980
                        UserID      int64     `json:"user_id"`
2✔
981
                        UserName    string    `json:"user_name"`
2✔
982
                        DisplayName string    `json:"display_name"`
2✔
983
                        Timestamp   time.Time `json:"timestamp"`
2✔
984
                }
2✔
985
                result := make([]dmUserJSON, len(users))
2✔
986
                for i, u := range users {
4✔
987
                        result[i] = dmUserJSON{
2✔
988
                                UserID:      u.UserID,
2✔
989
                                UserName:    u.UserName,
2✔
990
                                DisplayName: u.DisplayName,
2✔
991
                                Timestamp:   u.Timestamp,
2✔
992
                        }
2✔
993
                }
2✔
994
                rest.RenderJSON(w, result)
2✔
995
                return
2✔
996
        }
997

998
        // htmx response — render partial template with relative timestamps
999
        type dmUserView struct {
3✔
1000
                UserID      int64
3✔
1001
                UserName    string
3✔
1002
                DisplayName string
3✔
1003
                When        string
3✔
1004
        }
3✔
1005
        viewUsers := make([]dmUserView, len(users))
3✔
1006
        for i, u := range users {
7✔
1007
                viewUsers[i] = dmUserView{
4✔
1008
                        UserID:      u.UserID,
4✔
1009
                        UserName:    u.UserName,
4✔
1010
                        DisplayName: u.DisplayName,
4✔
1011
                        When:        relativeTime(u.Timestamp),
4✔
1012
                }
4✔
1013
        }
4✔
1014

1015
        data := struct {
3✔
1016
                Users []dmUserView
3✔
1017
        }{Users: viewUsers}
3✔
1018

3✔
1019
        if err := tmpl.ExecuteTemplate(w, "dm_users.html", data); err != nil {
3✔
NEW
1020
                log.Printf("[WARN] can't execute dm_users template: %v", err)
×
NEW
1021
                http.Error(w, "Error rendering template", http.StatusInternalServerError)
×
NEW
1022
                return
×
NEW
1023
        }
×
1024
}
1025

1026
// formatUsername prepends @ to a username if non-empty
1027
func formatUsername(userName string) string {
2✔
1028
        if userName == "" {
3✔
1029
                return ""
1✔
1030
        }
1✔
1031
        return "@" + userName
1✔
1032
}
1033

1034
// relativeTime formats a timestamp as a human-readable relative time string.
1035
// accepts an optional reference time; if omitted, uses time.Now().
1036
func relativeTime(t time.Time, now ...time.Time) string {
11✔
1037
        ref := time.Now()
11✔
1038
        if len(now) > 0 {
18✔
1039
                ref = now[0]
7✔
1040
        }
7✔
1041
        d := ref.Sub(t)
11✔
1042
        switch {
11✔
1043
        case d < time.Minute:
1✔
1044
                return "just now"
1✔
1045
        case d < time.Hour:
4✔
1046
                return fmt.Sprintf("%dm ago", int(d.Minutes()))
4✔
1047
        case d < 24*time.Hour:
4✔
1048
                return fmt.Sprintf("%dh ago", int(d.Hours()))
4✔
1049
        default:
2✔
1050
                return fmt.Sprintf("%dd ago", int(d.Hours()/24))
2✔
1051
        }
1052
}
1053

1054
// formatDuration formats a duration in a human-readable way
1055
func formatDuration(d time.Duration) string {
13✔
1056
        days := int(d.Hours() / 24)
13✔
1057
        hours := int(d.Hours()) % 24
13✔
1058
        minutes := int(d.Minutes()) % 60
13✔
1059

13✔
1060
        if days > 0 {
16✔
1061
                return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
3✔
1062
        }
3✔
1063

1064
        if hours > 0 {
12✔
1065
                return fmt.Sprintf("%dh %dm", hours, minutes)
2✔
1066
        }
2✔
1067

1068
        return fmt.Sprintf("%dm", minutes)
8✔
1069
}
1070

1071
func (s *Server) downloadDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
3✔
1072
        ctx := r.Context()
3✔
1073
        spam, err := s.DetectedSpam.Read(ctx)
3✔
1074
        if err != nil {
4✔
1075
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't get detected spam", "details": err.Error()})
1✔
1076
                return
1✔
1077
        }
1✔
1078

1079
        type jsonSpamInfo struct {
2✔
1080
                ID        int64                `json:"id"`
2✔
1081
                GID       string               `json:"gid"`
2✔
1082
                Text      string               `json:"text"`
2✔
1083
                UserID    int64                `json:"user_id"`
2✔
1084
                UserName  string               `json:"user_name"`
2✔
1085
                Timestamp time.Time            `json:"timestamp"`
2✔
1086
                Added     bool                 `json:"added"`
2✔
1087
                Checks    []spamcheck.Response `json:"checks"`
2✔
1088
        }
2✔
1089

2✔
1090
        // convert entries to jsonl format with lowercase fields
2✔
1091
        lines := make([]string, 0, len(spam))
2✔
1092
        for _, entry := range spam {
5✔
1093
                data, err := json.Marshal(jsonSpamInfo{
3✔
1094
                        ID:        entry.ID,
3✔
1095
                        GID:       entry.GID,
3✔
1096
                        Text:      entry.Text,
3✔
1097
                        UserID:    entry.UserID,
3✔
1098
                        UserName:  entry.UserName,
3✔
1099
                        Timestamp: entry.Timestamp,
3✔
1100
                        Added:     entry.Added,
3✔
1101
                        Checks:    entry.Checks,
3✔
1102
                })
3✔
1103
                if err != nil {
3✔
UNCOV
1104
                        _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't marshal entry", "details": err.Error()})
×
UNCOV
1105
                        return
×
UNCOV
1106
                }
×
1107
                lines = append(lines, string(data))
3✔
1108
        }
1109

1110
        body := strings.Join(lines, "\n")
2✔
1111
        w.Header().Set("Content-Type", "application/x-jsonlines")
2✔
1112
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "detected_spam.jsonl"))
2✔
1113
        w.Header().Set("Content-Length", strconv.Itoa(len(body)))
2✔
1114
        w.WriteHeader(http.StatusOK)
2✔
1115
        _, _ = w.Write([]byte(body))
2✔
1116
}
1117

1118
// downloadBackupHandler streams a database backup as an SQL file with gzip compression
1119
// Files are always compressed and always have .gz extension to ensure consistency
1120
func (s *Server) downloadBackupHandler(w http.ResponseWriter, r *http.Request) {
2✔
1121
        if s.StorageEngine == nil {
3✔
1122
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "storage engine not available"})
1✔
1123
                return
1✔
1124
        }
1✔
1125

1126
        // set filename based on database type and timestamp
1127
        dbType := "db"
1✔
1128
        sqlEng, ok := s.StorageEngine.(*engine.SQL)
1✔
1129
        if ok {
1✔
UNCOV
1130
                dbType = string(sqlEng.Type())
×
UNCOV
1131
        }
×
1132
        timestamp := time.Now().Format("20060102-150405")
1✔
1133

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

1✔
1137
        // set headers for file download - note we're using application/octet-stream
1✔
1138
        // instead of application/sql to prevent browsers from trying to interpret the file
1✔
1139
        w.Header().Set("Content-Type", "application/octet-stream")
1✔
1140
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
1✔
1141
        w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
1✔
1142
        w.Header().Set("Pragma", "no-cache")
1✔
1143
        w.Header().Set("Expires", "0")
1✔
1144

1✔
1145
        // create a gzip writer that streams to response
1✔
1146
        gzipWriter := gzip.NewWriter(w)
1✔
1147
        defer func() {
2✔
1148
                if err := gzipWriter.Close(); err != nil {
1✔
1149
                        log.Printf("[ERROR] failed to close gzip writer: %v", err)
×
1150
                }
×
1151
        }()
1152

1153
        // stream backup directly to response through gzip
1154
        if err := s.StorageEngine.Backup(r.Context(), gzipWriter); err != nil {
1✔
1155
                log.Printf("[ERROR] failed to create backup: %v", err)
×
UNCOV
1156
                // we've already started writing the response, so we can't send a proper error response
×
UNCOV
1157
                return
×
UNCOV
1158
        }
×
1159

1160
        // flush the gzip writer to ensure all data is written
1161
        if err := gzipWriter.Flush(); err != nil {
1✔
UNCOV
1162
                log.Printf("[ERROR] failed to flush gzip writer: %v", err)
×
UNCOV
1163
        }
×
1164
}
1165

1166
// downloadExportToPostgresHandler streams a PostgreSQL-compatible export from a SQLite database
1167
func (s *Server) downloadExportToPostgresHandler(w http.ResponseWriter, r *http.Request) {
3✔
1168
        if s.StorageEngine == nil {
4✔
1169
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "storage engine not available"})
1✔
1170
                return
1✔
1171
        }
1✔
1172

1173
        // check if the database is SQLite
1174
        if s.StorageEngine.Type() != engine.Sqlite {
3✔
1175
                _ = rest.EncodeJSON(w, http.StatusBadRequest, rest.JSON{"error": "source database must be SQLite"})
1✔
1176
                return
1✔
1177
        }
1✔
1178

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

1✔
1183
        // set headers for file download
1✔
1184
        w.Header().Set("Content-Type", "application/octet-stream")
1✔
1185
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
1✔
1186
        w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
1✔
1187
        w.Header().Set("Pragma", "no-cache")
1✔
1188
        w.Header().Set("Expires", "0")
1✔
1189

1✔
1190
        // create a gzip writer that streams to response
1✔
1191
        gzipWriter := gzip.NewWriter(w)
1✔
1192
        defer func() {
2✔
1193
                if err := gzipWriter.Close(); err != nil {
1✔
1194
                        log.Printf("[ERROR] failed to close gzip writer: %v", err)
×
1195
                }
×
1196
        }()
1197

1198
        // stream export directly to response through gzip
1199
        if err := s.StorageEngine.BackupSqliteAsPostgres(r.Context(), gzipWriter); err != nil {
1✔
1200
                log.Printf("[ERROR] failed to create export: %v", err)
×
UNCOV
1201
                // we've already started writing the response, so we can't send a proper error response
×
UNCOV
1202
                return
×
UNCOV
1203
        }
×
1204

1205
        // flush the gzip writer to ensure all data is written
1206
        if err := gzipWriter.Flush(); err != nil {
1✔
UNCOV
1207
                log.Printf("[ERROR] failed to flush gzip writer: %v", err)
×
UNCOV
1208
        }
×
1209
}
1210

1211
func (s *Server) renderSamples(w http.ResponseWriter, tmplName string) {
6✔
1212
        spam, ham, err := s.SpamFilter.DynamicSamples()
6✔
1213
        if err != nil {
7✔
1214
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't fetch samples", "details": err.Error()})
1✔
1215
                return
1✔
1216
        }
1✔
1217

1218
        spam, ham = s.reverseSamples(spam, ham)
5✔
1219

5✔
1220
        type smpleWithID struct {
5✔
1221
                ID     string
5✔
1222
                Sample string
5✔
1223
        }
5✔
1224

5✔
1225
        makeID := func(s string) string {
19✔
1226
                hash := sha1.New() //nolint
14✔
1227
                if _, err := hash.Write([]byte(s)); err != nil {
14✔
UNCOV
1228
                        return fmt.Sprintf("%x", s)
×
UNCOV
1229
                }
×
1230
                return fmt.Sprintf("%x", hash.Sum(nil))
14✔
1231
        }
1232

1233
        tmplData := struct {
5✔
1234
                SpamSamples      []smpleWithID
5✔
1235
                HamSamples       []smpleWithID
5✔
1236
                TotalHamSamples  int
5✔
1237
                TotalSpamSamples int
5✔
1238
        }{
5✔
1239
                TotalHamSamples:  len(ham),
5✔
1240
                TotalSpamSamples: len(spam),
5✔
1241
        }
5✔
1242
        for _, s := range spam {
12✔
1243
                tmplData.SpamSamples = append(tmplData.SpamSamples, smpleWithID{ID: makeID(s), Sample: s})
7✔
1244
        }
7✔
1245
        for _, h := range ham {
12✔
1246
                tmplData.HamSamples = append(tmplData.HamSamples, smpleWithID{ID: makeID(h), Sample: h})
7✔
1247
        }
7✔
1248

1249
        if err := tmpl.ExecuteTemplate(w, tmplName, tmplData); err != nil {
6✔
1250
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't execute template", "details": err.Error()})
1✔
1251
                return
1✔
1252
        }
1✔
1253
}
1254

1255
func (s *Server) authMiddleware(mw func(next http.Handler) http.Handler) func(next http.Handler) http.Handler {
10✔
1256
        if s.AuthPasswd == "" {
16✔
1257
                return func(next http.Handler) http.Handler {
96✔
1258
                        return next
90✔
1259
                }
90✔
1260
        }
1261
        return func(next http.Handler) http.Handler {
64✔
1262
                return mw(next)
60✔
1263
        }
60✔
1264
}
1265

1266
// reverseSamples returns reversed lists of spam and ham samples
1267
func (s *Server) reverseSamples(spam, ham []string) (revSpam, revHam []string) {
8✔
1268
        revSpam = make([]string, len(spam))
8✔
1269
        revHam = make([]string, len(ham))
8✔
1270

8✔
1271
        for i, j := 0, len(spam)-1; i < len(spam); i, j = i+1, j-1 {
19✔
1272
                revSpam[i] = spam[j]
11✔
1273
        }
11✔
1274
        for i, j := 0, len(ham)-1; i < len(ham); i, j = i+1, j-1 {
19✔
1275
                revHam[i] = ham[j]
11✔
1276
        }
11✔
1277
        return revSpam, revHam
8✔
1278
}
1279

1280
// renderDictionary renders dictionary entries for HTMX or full page request
1281
func (s *Server) renderDictionary(ctx context.Context, w http.ResponseWriter, tmplName string) {
4✔
1282
        stopPhrases, err := s.Dictionary.ReadWithIDs(ctx, storage.DictionaryTypeStopPhrase)
4✔
1283
        if err != nil {
4✔
1284
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't fetch stop phrases", "details": err.Error()})
×
UNCOV
1285
                return
×
UNCOV
1286
        }
×
1287

1288
        ignoredWords, err := s.Dictionary.ReadWithIDs(ctx, storage.DictionaryTypeIgnoredWord)
4✔
1289
        if err != nil {
4✔
UNCOV
1290
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't fetch ignored words", "details": err.Error()})
×
UNCOV
1291
                return
×
UNCOV
1292
        }
×
1293

1294
        tmplData := struct {
4✔
1295
                StopPhrases       []storage.DictionaryEntry
4✔
1296
                IgnoredWords      []storage.DictionaryEntry
4✔
1297
                TotalStopPhrases  int
4✔
1298
                TotalIgnoredWords int
4✔
1299
        }{
4✔
1300
                StopPhrases:       stopPhrases,
4✔
1301
                IgnoredWords:      ignoredWords,
4✔
1302
                TotalStopPhrases:  len(stopPhrases),
4✔
1303
                TotalIgnoredWords: len(ignoredWords),
4✔
1304
        }
4✔
1305

4✔
1306
        if err := tmpl.ExecuteTemplate(w, tmplName, tmplData); err != nil {
4✔
UNCOV
1307
                _ = rest.EncodeJSON(w, http.StatusInternalServerError, rest.JSON{"error": "can't execute template", "details": err.Error()})
×
UNCOV
1308
                return
×
UNCOV
1309
        }
×
1310
}
1311

1312
// staticFS is a filtered filesystem that only exposes specific static files
1313
type staticFS struct {
1314
        fs        fs.FS
1315
        urlToPath map[string]string
1316
}
1317

1318
// staticFileMapping defines a mapping between URL path and filesystem path
1319
type staticFileMapping struct {
1320
        urlPath     string
1321
        filesysPath string
1322
}
1323

1324
func newStaticFS(fsys fs.FS, files ...staticFileMapping) *staticFS {
5✔
1325
        urlToPath := make(map[string]string)
5✔
1326
        for _, f := range files {
20✔
1327
                urlToPath[f.urlPath] = f.filesysPath
15✔
1328
        }
15✔
1329

1330
        return &staticFS{
5✔
1331
                fs:        fsys,
5✔
1332
                urlToPath: urlToPath,
5✔
1333
        }
5✔
1334
}
1335

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

5✔
1339
        fsPath, ok := sfs.urlToPath[cleanName]
5✔
1340
        if !ok {
7✔
1341
                return nil, fs.ErrNotExist
2✔
1342
        }
2✔
1343

1344
        file, err := sfs.fs.Open(fsPath)
3✔
1345
        if err != nil {
3✔
UNCOV
1346
                return nil, fmt.Errorf("failed to open static file %s: %w", fsPath, err)
×
UNCOV
1347
        }
×
1348
        return file, nil
3✔
1349
}
1350

1351
// GenerateRandomPassword generates a random password of a given length
1352
func GenerateRandomPassword(length int) (string, error) {
2✔
1353
        const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+"
2✔
1354
        const charsetLen = int64(len(charset))
2✔
1355

2✔
1356
        result := make([]byte, length)
2✔
1357
        for i := range length {
66✔
1358
                n, err := rand.Int(rand.Reader, big.NewInt(charsetLen))
64✔
1359
                if err != nil {
64✔
UNCOV
1360
                        return "", fmt.Errorf("failed to generate random number: %w", err)
×
UNCOV
1361
                }
×
1362
                result[i] = charset[n.Int64()]
64✔
1363
        }
1364
        return string(result), nil
2✔
1365
}
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