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

umputun / tg-spam / 13687725087

05 Mar 2025 11:35PM UTC coverage: 81.632% (-0.05%) from 81.677%
13687725087

push

github

web-flow
Merge pull request #259 from umputun/feature/keyboard-detection

Add keyboard detection to spam filters

21 of 28 new or added lines in 5 files covered. (75.0%)

145 existing lines in 4 files now uncovered.

4373 of 5357 relevant lines covered (81.63%)

62.26 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

142
// StorageEngine provides access to the database engine for operations like backup
143
type StorageEngine interface {
144
        Backup(ctx context.Context, w io.Writer) error
145
        Type() engine.Type
146
        BackupSqliteAsPostgres(ctx context.Context, w io.Writer) error
147
}
148

149
// NewServer creates a new web API server.
150
func NewServer(config Config) *Server {
44✔
151
        return &Server{Config: config}
44✔
152
}
44✔
153

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

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

175
        router = s.routes(router) // setup routes
3✔
176

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

187
        log.Printf("[INFO] start webapi server on %s", s.ListenAddr)
3✔
188
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
3✔
189
                return fmt.Errorf("failed to run server: %w", err)
×
UNCOV
190
        }
×
191
        return nil
3✔
192
}
193

194
func (s *Server) routes(router *routegroup.Bundle) *routegroup.Bundle {
5✔
195
        // auth api routes
5✔
196
        router.Route(func(authApi *routegroup.Bundle) {
10✔
197
                authApi.Use(s.authMiddleware(rest.BasicAuthWithUserPasswd("tg-spam", s.AuthPasswd)))
5✔
198
                authApi.HandleFunc("POST /check", s.checkMsgHandler)         // check a message for spam
5✔
199
                authApi.HandleFunc("GET /check/{user_id}", s.checkIDHandler) // check user id for spam
5✔
200

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

207
                authApi.Mount("/delete").Route(func(r *routegroup.Bundle) {
10✔
208
                        // delete spam/ham samples
5✔
209
                        r.HandleFunc("POST /spam", s.deleteSampleHandler(s.SpamFilter.RemoveDynamicSpamSample))
5✔
210
                        r.HandleFunc("POST /ham", s.deleteSampleHandler(s.SpamFilter.RemoveDynamicHamSample))
5✔
211
                })
5✔
212

213
                authApi.Mount("/download").Route(func(r *routegroup.Bundle) {
10✔
214
                        r.HandleFunc("GET /spam", s.downloadSampleHandler(func(spam, _ []string) ([]string, string) {
5✔
215
                                return spam, "spam.txt"
×
UNCOV
216
                        }))
×
217
                        r.HandleFunc("GET /ham", s.downloadSampleHandler(func(_, ham []string) ([]string, string) {
5✔
218
                                return ham, "ham.txt"
×
UNCOV
219
                        }))
×
220
                        r.HandleFunc("GET /detected_spam", s.downloadDetectedSpamHandler)
5✔
221
                        r.HandleFunc("GET /backup", s.downloadBackupHandler)
5✔
222
                        r.HandleFunc("GET /export-to-postgres", s.downloadExportToPostgresHandler)
5✔
223
                })
224

225
                authApi.HandleFunc("GET /samples", s.getDynamicSamplesHandler)    // get dynamic samples
5✔
226
                authApi.HandleFunc("PUT /samples", s.reloadDynamicSamplesHandler) // reload samples
5✔
227

5✔
228
                authApi.Mount("/users").Route(func(r *routegroup.Bundle) { // manage approved users
10✔
229
                        // add user to the approved list and storage
5✔
230
                        r.HandleFunc("POST /add", s.updateApprovedUsersHandler(s.Detector.AddApprovedUser))
5✔
231
                        // remove user from an approved list and storage
5✔
232
                        r.HandleFunc("POST /delete", s.updateApprovedUsersHandler(s.removeApprovedUser))
5✔
233
                        // get approved users
5✔
234
                        r.HandleFunc("GET /", s.getApprovedUsersHandler)
5✔
235
                })
5✔
236

237
                authApi.HandleFunc("GET /settings", func(w http.ResponseWriter, _ *http.Request) {
6✔
238
                        rest.RenderJSON(w, s.Settings)
1✔
239
                })
1✔
240
        })
241

242
        router.Route(func(webUI *routegroup.Bundle) {
10✔
243
                webUI.Use(s.authMiddleware(rest.BasicAuthWithPrompt("tg-spam", s.AuthPasswd)))
5✔
244
                webUI.HandleFunc("GET /", s.htmlSpamCheckHandler)                         // serve template for webUI UI
5✔
245
                webUI.HandleFunc("GET /manage_samples", s.htmlManageSamplesHandler)       // serve manage samples page
5✔
246
                webUI.HandleFunc("GET /manage_users", s.htmlManageUsersHandler)           // serve manage users page
5✔
247
                webUI.HandleFunc("GET /detected_spam", s.htmlDetectedSpamHandler)         // serve detected spam page
5✔
248
                webUI.HandleFunc("GET /list_settings", s.htmlSettingsHandler)             // serve settings
5✔
249
                webUI.HandleFunc("POST /detected_spam/add", s.htmlAddDetectedSpamHandler) // add detected spam to samples
5✔
250

5✔
251
                // handle logout - force Basic Auth re-authentication
5✔
252
                webUI.HandleFunc("GET /logout", func(w http.ResponseWriter, _ *http.Request) {
5✔
253
                        w.Header().Set("WWW-Authenticate", `Basic realm="tg-spam"`)
×
254
                        w.WriteHeader(http.StatusUnauthorized)
×
255
                        fmt.Fprintln(w, "Logged out successfully")
×
UNCOV
256
                })
×
257

258
                // serve only specific static files at root level
259
                staticFiles := newStaticFS(templateFS,
5✔
260
                        staticFileMapping{urlPath: "styles.css", filesysPath: "assets/styles.css"},
5✔
261
                        staticFileMapping{urlPath: "logo.png", filesysPath: "assets/logo.png"},
5✔
262
                        staticFileMapping{urlPath: "spinner.svg", filesysPath: "assets/spinner.svg"},
5✔
263
                )
5✔
264
                webUI.HandleFiles("/", http.FS(staticFiles))
5✔
265
        })
266

267
        return router
5✔
268
}
269

270
// checkMsgHandler handles POST /check request.
271
// it gets message text and user id from request body and returns spam status and check results.
272
func (s *Server) checkMsgHandler(w http.ResponseWriter, r *http.Request) {
7✔
273
        type CheckResultDisplay struct {
7✔
274
                Spam   bool
7✔
275
                Checks []spamcheck.Response
7✔
276
        }
7✔
277

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

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

296
        spam, cr := s.Detector.Check(req)
6✔
297
        if !isHtmxRequest {
11✔
298
                // for API request return JSON
5✔
299
                rest.RenderJSON(w, rest.JSON{"spam": spam, "checks": cr})
5✔
300
                return
5✔
301
        }
5✔
302

303
        if req.Msg == "" || req.UserID == "" || req.UserID == "0" {
1✔
304
                w.Header().Set("HX-Retarget", "#error-message")
×
305
                fmt.Fprintln(w, "<div class='alert alert-danger'>userid and valid message required.</div>")
×
306
                return
×
UNCOV
307
        }
×
308

309
        // render result for HTMX request
310
        resultDisplay := CheckResultDisplay{
1✔
311
                Spam:   spam,
1✔
312
                Checks: cr,
1✔
313
        }
1✔
314

1✔
315
        if err := tmpl.ExecuteTemplate(w, "check_results", resultDisplay); err != nil {
1✔
316
                log.Printf("[WARN] can't execute result template: %v", err)
×
317
                http.Error(w, "Error rendering result", http.StatusInternalServerError)
×
318
                return
×
UNCOV
319
        }
×
320
}
321

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

2✔
339
        userID, err := strconv.ParseInt(r.PathValue("user_id"), 10, 64)
2✔
340
        if err != nil {
2✔
341
                w.WriteHeader(http.StatusBadRequest)
×
342
                rest.RenderJSON(w, rest.JSON{"error": "can't parse user id", "details": err.Error()})
×
343
                return
×
UNCOV
344
        }
×
345

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

364
// getDynamicSamplesHandler handles GET /samples request. It returns dynamic samples both for spam and ham.
365
func (s *Server) getDynamicSamplesHandler(w http.ResponseWriter, _ *http.Request) {
2✔
366
        spam, ham, err := s.SpamFilter.DynamicSamples()
2✔
367
        if err != nil {
3✔
368
                w.WriteHeader(http.StatusInternalServerError)
1✔
369
                rest.RenderJSON(w, rest.JSON{"error": "can't get dynamic samples", "details": err.Error()})
1✔
370
                return
1✔
371
        }
1✔
372
        rest.RenderJSON(w, rest.JSON{"spam": spam, "ham": ham})
1✔
373
}
374

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

394
// updateSampleHandler handles POST /update/spam|ham request. It updates dynamic samples both for spam and ham.
395
func (s *Server) updateSampleHandler(updFn func(msg string) error) func(w http.ResponseWriter, r *http.Request) {
13✔
396
        return func(w http.ResponseWriter, r *http.Request) {
18✔
397
                var req struct {
5✔
398
                        Msg string `json:"msg"`
5✔
399
                }
5✔
400

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

5✔
403
                if isHtmxRequest {
5✔
UNCOV
404
                        req.Msg = r.FormValue("msg")
×
405
                } else {
5✔
406
                        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
6✔
407
                                w.WriteHeader(http.StatusBadRequest)
1✔
408
                                rest.RenderJSON(w, rest.JSON{"error": "can't decode request", "details": err.Error()})
1✔
409
                                return
1✔
410
                        }
1✔
411
                }
412

413
                err := updFn(req.Msg)
4✔
414
                if err != nil {
5✔
415
                        w.WriteHeader(http.StatusInternalServerError)
1✔
416
                        rest.RenderJSON(w, rest.JSON{"error": "can't update samples", "details": err.Error()})
1✔
417
                        return
1✔
418
                }
1✔
419

420
                if isHtmxRequest {
3✔
UNCOV
421
                        s.renderSamples(w, "samples_list")
×
422
                } else {
3✔
423
                        rest.RenderJSON(w, rest.JSON{"updated": true, "msg": req.Msg})
3✔
424
                }
3✔
425
        }
426
}
427

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

445
                if err := delFn(req.Msg); err != nil {
6✔
446
                        w.WriteHeader(http.StatusInternalServerError)
1✔
447
                        rest.RenderJSON(w, rest.JSON{"error": "can't delete sample", "details": err.Error()})
1✔
448
                        return
1✔
449
                }
1✔
450

451
                if isHtmxRequest {
5✔
452
                        s.renderSamples(w, "samples_list")
1✔
453
                } else {
4✔
454
                        rest.RenderJSON(w, rest.JSON{"deleted": true, "msg": req.Msg, "count": 1})
3✔
455
                }
3✔
456
        }
457
}
458

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

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

485
                // try to get userID from request and fallback to userName lookup if it's empty
486
                if req.UserID == "" {
12✔
487
                        req.UserID = strconv.FormatInt(s.Locator.UserIDByName(r.Context(), req.UserName), 10)
4✔
488
                }
4✔
489

490
                if req.UserID == "" || req.UserID == "0" {
9✔
491
                        if isHtmxRequest {
1✔
492
                                w.Header().Set("HX-Retarget", "#error-message")
×
493
                                fmt.Fprintln(w, "<div class='alert alert-danger'>Either userid or valid username required.</div>")
×
494
                                return
×
UNCOV
495
                        }
×
496
                        w.WriteHeader(http.StatusBadRequest)
1✔
497
                        rest.RenderJSON(w, rest.JSON{"error": "user ID is required"})
1✔
498
                        return
1✔
499
                }
500

501
                // add or remove user from the approved list of detector
502
                if err := updFn(req); err != nil {
7✔
503
                        w.WriteHeader(http.StatusInternalServerError)
×
504
                        rest.RenderJSON(w, rest.JSON{"error": "can't update approved users", "details": err.Error()})
×
505
                        return
×
UNCOV
506
                }
×
507

508
                if isHtmxRequest {
8✔
509
                        users := s.Detector.ApprovedUsers()
1✔
510
                        tmplData := struct {
1✔
511
                                ApprovedUsers      []approved.UserInfo
1✔
512
                                TotalApprovedUsers int
1✔
513
                        }{
1✔
514
                                ApprovedUsers:      users,
1✔
515
                                TotalApprovedUsers: len(users),
1✔
516
                        }
1✔
517

1✔
518
                        if err := tmpl.ExecuteTemplate(w, "users_list", tmplData); err != nil {
1✔
519
                                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
520
                                return
×
UNCOV
521
                        }
×
522

523
                } else {
6✔
524
                        rest.RenderJSON(w, rest.JSON{"updated": true, "user_id": req.UserID, "user_name": req.UserName})
6✔
525
                }
6✔
526
        }
527
}
528

529
// removeApprovedUser is adopter for updateApprovedUsersHandler updFn
530
func (s *Server) removeApprovedUser(req approved.UserInfo) error {
2✔
531
        return s.Detector.RemoveApprovedUser(req.UserID)
2✔
532
}
2✔
533

534
// getApprovedUsersHandler handles GET /users request. It returns list of approved users.
535
func (s *Server) getApprovedUsersHandler(w http.ResponseWriter, _ *http.Request) {
1✔
536
        rest.RenderJSON(w, rest.JSON{"user_ids": s.Detector.ApprovedUsers()})
1✔
537
}
1✔
538

539
// htmlSpamCheckHandler handles GET / request.
540
// It returns rendered spam_check.html template with all the components.
541
func (s *Server) htmlSpamCheckHandler(w http.ResponseWriter, _ *http.Request) {
3✔
542
        tmplData := struct {
3✔
543
                Version string
3✔
544
        }{
3✔
545
                Version: s.Version,
3✔
546
        }
3✔
547

3✔
548
        if err := tmpl.ExecuteTemplate(w, "spam_check.html", tmplData); err != nil {
4✔
549
                log.Printf("[WARN] can't execute template: %v", err)
1✔
550
                http.Error(w, "Error executing template", http.StatusInternalServerError)
1✔
551
                return
1✔
552
        }
1✔
553
}
554

555
// htmlManageSamplesHandler handles GET /manage_samples request.
556
// It returns rendered manage_samples.html template with all the components.
557
func (s *Server) htmlManageSamplesHandler(w http.ResponseWriter, _ *http.Request) {
1✔
558
        s.renderSamples(w, "manage_samples.html")
1✔
559
}
1✔
560

561
func (s *Server) htmlManageUsersHandler(w http.ResponseWriter, _ *http.Request) {
3✔
562
        users := s.Detector.ApprovedUsers()
3✔
563
        tmplData := struct {
3✔
564
                ApprovedUsers      []approved.UserInfo
3✔
565
                TotalApprovedUsers int
3✔
566
        }{
3✔
567
                ApprovedUsers:      users,
3✔
568
                TotalApprovedUsers: len(users),
3✔
569
        }
3✔
570
        tmplData.TotalApprovedUsers = len(tmplData.ApprovedUsers)
3✔
571

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

579
func (s *Server) htmlDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
2✔
580
        ds, err := s.DetectedSpam.Read(r.Context())
2✔
581
        if err != nil {
3✔
582
                log.Printf("[ERROR] Failed to fetch detected spam: %v", err)
1✔
583
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
1✔
584
                return
1✔
585
        }
1✔
586

587
        // clean up detected spam entries
588
        for i, d := range ds {
3✔
589
                d.Text = strings.ReplaceAll(d.Text, "'", " ")
2✔
590
                d.Text = strings.ReplaceAll(d.Text, "\n", " ")
2✔
591
                d.Text = strings.ReplaceAll(d.Text, "\r", " ")
2✔
592
                d.Text = strings.ReplaceAll(d.Text, "\t", " ")
2✔
593
                d.Text = strings.ReplaceAll(d.Text, "\"", " ")
2✔
594
                d.Text = strings.ReplaceAll(d.Text, "\\", " ")
2✔
595
                ds[i] = d
2✔
596
        }
2✔
597

598
        tmplData := struct {
1✔
599
                DetectedSpamEntries []storage.DetectedSpamInfo
1✔
600
                TotalDetectedSpam   int
1✔
601
        }{
1✔
602
                DetectedSpamEntries: ds,
1✔
603
                TotalDetectedSpam:   len(ds),
1✔
604
        }
1✔
605

1✔
606
        if err := tmpl.ExecuteTemplate(w, "detected_spam.html", tmplData); err != nil {
1✔
607
                log.Printf("[WARN] can't execute template: %v", err)
×
608
                http.Error(w, "Error executing template", http.StatusInternalServerError)
×
609
                return
×
UNCOV
610
        }
×
611
}
612

613
func (s *Server) htmlAddDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
5✔
614
        reportErr := func(err error, _ int) {
9✔
615
                w.Header().Set("HX-Retarget", "#error-message")
4✔
616
                fmt.Fprintf(w, "<div class='alert alert-danger'>%s</div>", err)
4✔
617
        }
4✔
618
        msg := r.FormValue("msg")
5✔
619

5✔
620
        id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
5✔
621
        if err != nil || msg == "" {
7✔
622
                log.Printf("[WARN] bad request: %v", err)
2✔
623
                reportErr(fmt.Errorf("bad request: %v", err), http.StatusBadRequest)
2✔
624
                return
2✔
625
        }
2✔
626

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

1✔
632
        }
1✔
633
        if err := s.DetectedSpam.SetAddedToSamplesFlag(r.Context(), id); err != nil {
3✔
634
                log.Printf("[WARN] failed to update detected spam: %v", err)
1✔
635
                reportErr(fmt.Errorf("can't update detected spam: %v", err), http.StatusInternalServerError)
1✔
636
                return
1✔
637
        }
1✔
638
        w.WriteHeader(http.StatusOK)
1✔
639
}
640

641
func (s *Server) htmlSettingsHandler(w http.ResponseWriter, _ *http.Request) {
4✔
642
        // get database information if StorageEngine is available
4✔
643
        var dbInfo struct {
4✔
644
                DatabaseType   string `json:"database_type"`
4✔
645
                GID            string `json:"gid"`
4✔
646
                DatabaseStatus string `json:"database_status"`
4✔
647
        }
4✔
648

4✔
649
        if s.StorageEngine != nil {
6✔
650
                // try to cast to SQL engine to get type information
2✔
651
                if sqlEngine, ok := s.StorageEngine.(*engine.SQL); ok {
2✔
652
                        dbInfo.DatabaseType = string(sqlEngine.Type())
×
653
                        dbInfo.GID = sqlEngine.GID()
×
UNCOV
654
                        dbInfo.DatabaseStatus = "Connected"
×
655
                } else {
2✔
656
                        dbInfo.DatabaseType = "Unknown"
2✔
657
                        dbInfo.DatabaseStatus = "Connected (unknown type)"
2✔
658
                }
2✔
659
        } else {
2✔
660
                dbInfo.DatabaseStatus = "Not connected"
2✔
661
        }
2✔
662

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

4✔
667
        // get system info - uptime since server start
4✔
668
        uptime := time.Since(startTime)
4✔
669

4✔
670
        data := struct {
4✔
671
                Settings
4✔
672
                Version  string
4✔
673
                Database struct {
4✔
674
                        Type   string
4✔
675
                        GID    string
4✔
676
                        Status string
4✔
677
                }
4✔
678
                Backup struct {
4✔
679
                        URL      string
4✔
680
                        Filename string
4✔
681
                }
4✔
682
                System struct {
4✔
683
                        Uptime string
4✔
684
                }
4✔
685
        }{
4✔
686
                Settings: s.Settings,
4✔
687
                Version:  s.Version,
4✔
688
                Database: struct {
4✔
689
                        Type   string
4✔
690
                        GID    string
4✔
691
                        Status string
4✔
692
                }{
4✔
693
                        Type:   dbInfo.DatabaseType,
4✔
694
                        GID:    dbInfo.GID,
4✔
695
                        Status: dbInfo.DatabaseStatus,
4✔
696
                },
4✔
697
                Backup: struct {
4✔
698
                        URL      string
4✔
699
                        Filename string
4✔
700
                }{
4✔
701
                        URL:      backupURL,
4✔
702
                        Filename: backupFilename,
4✔
703
                },
4✔
704
                System: struct {
4✔
705
                        Uptime string
4✔
706
                }{
4✔
707
                        Uptime: formatDuration(uptime),
4✔
708
                },
4✔
709
        }
4✔
710

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

718
// formatDuration formats a duration in a human-readable way
719
func formatDuration(d time.Duration) string {
12✔
720
        days := int(d.Hours() / 24)
12✔
721
        hours := int(d.Hours()) % 24
12✔
722
        minutes := int(d.Minutes()) % 60
12✔
723

12✔
724
        if days > 0 {
15✔
725
                return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
3✔
726
        }
3✔
727

728
        if hours > 0 {
11✔
729
                return fmt.Sprintf("%dh %dm", hours, minutes)
2✔
730
        }
2✔
731

732
        return fmt.Sprintf("%dm", minutes)
7✔
733
}
734

735
func (s *Server) downloadDetectedSpamHandler(w http.ResponseWriter, r *http.Request) {
3✔
736
        ctx := r.Context()
3✔
737
        spam, err := s.DetectedSpam.Read(ctx)
3✔
738
        if err != nil {
4✔
739
                w.WriteHeader(http.StatusInternalServerError)
1✔
740
                rest.RenderJSON(w, rest.JSON{"error": "can't get detected spam", "details": err.Error()})
1✔
741
                return
1✔
742
        }
1✔
743

744
        type jsonSpamInfo struct {
2✔
745
                ID        int64                `json:"id"`
2✔
746
                GID       string               `json:"gid"`
2✔
747
                Text      string               `json:"text"`
2✔
748
                UserID    int64                `json:"user_id"`
2✔
749
                UserName  string               `json:"user_name"`
2✔
750
                Timestamp time.Time            `json:"timestamp"`
2✔
751
                Added     bool                 `json:"added"`
2✔
752
                Checks    []spamcheck.Response `json:"checks"`
2✔
753
        }
2✔
754

2✔
755
        // convert entries to jsonl format with lowercase fields
2✔
756
        lines := make([]string, 0, len(spam))
2✔
757
        for _, entry := range spam {
5✔
758
                data, err := json.Marshal(jsonSpamInfo{
3✔
759
                        ID:        entry.ID,
3✔
760
                        GID:       entry.GID,
3✔
761
                        Text:      entry.Text,
3✔
762
                        UserID:    entry.UserID,
3✔
763
                        UserName:  entry.UserName,
3✔
764
                        Timestamp: entry.Timestamp,
3✔
765
                        Added:     entry.Added,
3✔
766
                        Checks:    entry.Checks,
3✔
767
                })
3✔
768
                if err != nil {
3✔
769
                        w.WriteHeader(http.StatusInternalServerError)
×
770
                        rest.RenderJSON(w, rest.JSON{"error": "can't marshal entry", "details": err.Error()})
×
771
                        return
×
UNCOV
772
                }
×
773
                lines = append(lines, string(data))
3✔
774
        }
775

776
        body := strings.Join(lines, "\n")
2✔
777
        w.Header().Set("Content-Type", "application/x-jsonlines")
2✔
778
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "detected_spam.jsonl"))
2✔
779
        w.Header().Set("Content-Length", strconv.Itoa(len(body)))
2✔
780
        w.WriteHeader(http.StatusOK)
2✔
781
        _, _ = w.Write([]byte(body))
2✔
782
}
783

784
// downloadBackupHandler streams a database backup as an SQL file with gzip compression
785
// Files are always compressed and always have .gz extension to ensure consistency
786
func (s *Server) downloadBackupHandler(w http.ResponseWriter, r *http.Request) {
2✔
787
        if s.StorageEngine == nil {
3✔
788
                w.WriteHeader(http.StatusInternalServerError)
1✔
789
                rest.RenderJSON(w, rest.JSON{"error": "storage engine not available"})
1✔
790
                return
1✔
791
        }
1✔
792

793
        // set filename based on database type and timestamp
794
        dbType := "db"
1✔
795
        sqlEng, ok := s.StorageEngine.(*engine.SQL)
1✔
796
        if ok {
1✔
797
                dbType = string(sqlEng.Type())
×
UNCOV
798
        }
×
799
        timestamp := time.Now().Format("20060102-150405")
1✔
800

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

1✔
804
        // set headers for file download - note we're using application/octet-stream
1✔
805
        // instead of application/sql to prevent browsers from trying to interpret the file
1✔
806
        w.Header().Set("Content-Type", "application/octet-stream")
1✔
807
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
1✔
808
        w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
1✔
809
        w.Header().Set("Pragma", "no-cache")
1✔
810
        w.Header().Set("Expires", "0")
1✔
811

1✔
812
        // create a gzip writer that streams to response
1✔
813
        gzipWriter := gzip.NewWriter(w)
1✔
814
        defer func() {
2✔
815
                if err := gzipWriter.Close(); err != nil {
1✔
816
                        log.Printf("[ERROR] failed to close gzip writer: %v", err)
×
UNCOV
817
                }
×
818
        }()
819

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

827
        // flush the gzip writer to ensure all data is written
828
        if err := gzipWriter.Flush(); err != nil {
1✔
829
                log.Printf("[ERROR] failed to flush gzip writer: %v", err)
×
UNCOV
830
        }
×
831
}
832

833
// downloadExportToPostgresHandler streams a PostgreSQL-compatible export from a SQLite database
834
func (s *Server) downloadExportToPostgresHandler(w http.ResponseWriter, r *http.Request) {
3✔
835
        if s.StorageEngine == nil {
4✔
836
                w.WriteHeader(http.StatusInternalServerError)
1✔
837
                rest.RenderJSON(w, rest.JSON{"error": "storage engine not available"})
1✔
838
                return
1✔
839
        }
1✔
840

841
        // check if the database is SQLite
842
        if s.StorageEngine.Type() != engine.Sqlite {
3✔
843
                w.WriteHeader(http.StatusBadRequest)
1✔
844
                rest.RenderJSON(w, rest.JSON{"error": "source database must be SQLite"})
1✔
845
                return
1✔
846
        }
1✔
847

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

1✔
852
        // set headers for file download
1✔
853
        w.Header().Set("Content-Type", "application/octet-stream")
1✔
854
        w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
1✔
855
        w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
1✔
856
        w.Header().Set("Pragma", "no-cache")
1✔
857
        w.Header().Set("Expires", "0")
1✔
858

1✔
859
        // create a gzip writer that streams to response
1✔
860
        gzipWriter := gzip.NewWriter(w)
1✔
861
        defer func() {
2✔
862
                if err := gzipWriter.Close(); err != nil {
1✔
863
                        log.Printf("[ERROR] failed to close gzip writer: %v", err)
×
UNCOV
864
                }
×
865
        }()
866

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

874
        // flush the gzip writer to ensure all data is written
875
        if err := gzipWriter.Flush(); err != nil {
1✔
876
                log.Printf("[ERROR] failed to flush gzip writer: %v", err)
×
UNCOV
877
        }
×
878
}
879

880
func (s *Server) renderSamples(w http.ResponseWriter, tmplName string) {
6✔
881
        spam, ham, err := s.SpamFilter.DynamicSamples()
6✔
882
        if err != nil {
7✔
883
                w.WriteHeader(http.StatusInternalServerError)
1✔
884
                rest.RenderJSON(w, rest.JSON{"error": "can't fetch samples", "details": err.Error()})
1✔
885
                return
1✔
886
        }
1✔
887

888
        spam, ham = s.reverseSamples(spam, ham)
5✔
889

5✔
890
        type smpleWithID struct {
5✔
891
                ID     string
5✔
892
                Sample string
5✔
893
        }
5✔
894

5✔
895
        makeID := func(s string) string {
19✔
896
                hash := sha1.New() //nolint
14✔
897
                if _, err := hash.Write([]byte(s)); err != nil {
14✔
898
                        return fmt.Sprintf("%x", s)
×
UNCOV
899
                }
×
900
                return fmt.Sprintf("%x", hash.Sum(nil))
14✔
901
        }
902

903
        tmplData := struct {
5✔
904
                SpamSamples      []smpleWithID
5✔
905
                HamSamples       []smpleWithID
5✔
906
                TotalHamSamples  int
5✔
907
                TotalSpamSamples int
5✔
908
        }{
5✔
909
                TotalHamSamples:  len(ham),
5✔
910
                TotalSpamSamples: len(spam),
5✔
911
        }
5✔
912
        for _, s := range spam {
12✔
913
                tmplData.SpamSamples = append(tmplData.SpamSamples, smpleWithID{ID: makeID(s), Sample: s})
7✔
914
        }
7✔
915
        for _, h := range ham {
12✔
916
                tmplData.HamSamples = append(tmplData.HamSamples, smpleWithID{ID: makeID(h), Sample: h})
7✔
917
        }
7✔
918

919
        if err := tmpl.ExecuteTemplate(w, tmplName, tmplData); err != nil {
6✔
920
                w.WriteHeader(http.StatusInternalServerError)
1✔
921
                rest.RenderJSON(w, rest.JSON{"error": "can't execute template", "details": err.Error()})
1✔
922
                return
1✔
923
        }
1✔
924
}
925

926
func (s *Server) authMiddleware(mw func(next http.Handler) http.Handler) func(next http.Handler) http.Handler {
10✔
927
        if s.AuthPasswd == "" {
16✔
928
                return func(next http.Handler) http.Handler {
105✔
929
                        return next
99✔
930
                }
99✔
931
        }
932
        return func(next http.Handler) http.Handler {
70✔
933
                return mw(next)
66✔
934
        }
66✔
935
}
936

937
// reverseSamples returns reversed lists of spam and ham samples
938
func (s *Server) reverseSamples(spam, ham []string) (revSpam, revHam []string) {
8✔
939
        revSpam = make([]string, len(spam))
8✔
940
        revHam = make([]string, len(ham))
8✔
941

8✔
942
        for i, j := 0, len(spam)-1; i < len(spam); i, j = i+1, j-1 {
19✔
943
                revSpam[i] = spam[j]
11✔
944
        }
11✔
945
        for i, j := 0, len(ham)-1; i < len(ham); i, j = i+1, j-1 {
19✔
946
                revHam[i] = ham[j]
11✔
947
        }
11✔
948
        return revSpam, revHam
8✔
949
}
950

951
// staticFS is a filtered filesystem that only exposes specific static files
952
type staticFS struct {
953
        fs        fs.FS
954
        urlToPath map[string]string
955
}
956

957
// staticFileMapping defines a mapping between URL path and filesystem path
958
type staticFileMapping struct {
959
        urlPath     string
960
        filesysPath string
961
}
962

963
func newStaticFS(fsys fs.FS, files ...staticFileMapping) *staticFS {
5✔
964
        urlToPath := make(map[string]string)
5✔
965
        for _, f := range files {
20✔
966
                urlToPath[f.urlPath] = f.filesysPath
15✔
967
        }
15✔
968

969
        return &staticFS{
5✔
970
                fs:        fsys,
5✔
971
                urlToPath: urlToPath,
5✔
972
        }
5✔
973
}
974

975
func (sfs *staticFS) Open(name string) (fs.File, error) {
5✔
976
        name = path.Clean("/" + name)[1:]
5✔
977
        if fsPath, ok := sfs.urlToPath[name]; ok {
8✔
978
                return sfs.fs.Open(fsPath)
3✔
979
        }
3✔
980
        return nil, fs.ErrNotExist
2✔
981
}
982

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

2✔
987
        var password strings.Builder
2✔
988
        charsetSize := big.NewInt(int64(len(charset)))
2✔
989

2✔
990
        for i := 0; i < length; i++ {
66✔
991
                randomNumber, err := rand.Int(rand.Reader, charsetSize)
64✔
992
                if err != nil {
64✔
993
                        return "", err
×
UNCOV
994
                }
×
995

996
                password.WriteByte(charset[randomNumber.Int64()])
64✔
997
        }
998

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