diff options
| author | Anhgelus Morhtuuzh <william@herges.fr> | 2025-12-22 18:18:56 +0000 |
|---|---|---|
| committer | Anhgelus Morhtuuzh <william@herges.fr> | 2025-12-22 18:18:56 +0000 |
| commit | 66d6ab349196fd5d95937f533faa9db6ba65ebb3 (patch) | |
| tree | 84ce7dd31c9e4dc10dbb41c5428cc2cb66bb44d9 | |
| parent | 1c86bdda2a3067ade9aa765d8d87fe27151dd895 (diff) | |
| parent | c66a5cc0809875df0299ebb80be423436c195416 (diff) | |
Merge pull request '[Feat] Stats' (#4) from feat/stats into main
Reviewed-on: https://git.anhgelus.world/anhgelus/small-web/pulls/4
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | backend/admin.go | 53 | ||||
| -rw-r--r-- | backend/config.go | 16 | ||||
| -rw-r--r-- | backend/data.go | 5 | ||||
| -rw-r--r-- | backend/router.go | 49 | ||||
| -rw-r--r-- | backend/section.go | 2 | ||||
| -rw-r--r-- | backend/storage/db.go | 74 | ||||
| -rw-r--r-- | backend/storage/migrations/000_init.sql | 6 | ||||
| -rw-r--r-- | backend/storage/stats.go | 173 | ||||
| -rw-r--r-- | backend/templates/admin.html | 51 | ||||
| -rw-r--r-- | backend/templates/components.html | 24 | ||||
| -rw-r--r-- | frontend/scss/general.scss | 35 | ||||
| -rw-r--r-- | go.mod | 2 | ||||
| -rw-r--r-- | go.sum | 2 | ||||
| -rw-r--r-- | main.go | 15 |
15 files changed, 483 insertions, 25 deletions
@@ -161,3 +161,4 @@ config.toml data small-web *.tar.gz +*.sqlite diff --git a/backend/admin.go b/backend/admin.go new file mode 100644 index 0000000..087a3ea --- /dev/null +++ b/backend/admin.go @@ -0,0 +1,53 @@ +package backend + +import ( + "log/slog" + "net/http" + "strconv" + + "git.anhgelus.world/anhgelus/small-web/backend/storage" + "github.com/go-chi/chi/v5" +) + +type adminData struct { + *data + Visits []storage.StatsRow + Rows []storage.StatsRow + PagesNumber int + CurrentPage int +} + +func HandleAdmin(r *chi.Mux) { + r.Get("/admin", func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !ctx.Value(loginKey).(bool) { + w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + d := new(adminData) + d.data = new(data) + rawPage := r.URL.Query().Get("page") + page := 1 + var err error + if rawPage != "" { + page, err = strconv.Atoi(rawPage) + if err != nil || page < 1 { + slog.Warn("invalid page number", "rawPage", rawPage) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + } + d.Rows, err = storage.GetStatsRows(ctx, uint(page)) + if err != nil { + panic(err) + } + d.Visits, err = storage.GetUnionStatsRows(ctx) + if err != nil { + panic(err) + } + d.PagesNumber = page + max(len(d.Rows)-storage.StatsPerPage+1, 0) + d.CurrentPage = page + d.handleGeneric(w, r, "admin", d) + }) +} diff --git a/backend/config.go b/backend/config.go index e009b8a..33d5852 100644 --- a/backend/config.go +++ b/backend/config.go @@ -29,12 +29,14 @@ type Replacer struct { } type Config struct { - Domain string `toml:"domain"` - Name string `toml:"name"` - Description string `toml:"description"` - DefaultImage string `toml:"default_image"` - Quotes []string `toml:"quotes"` - Language string `toml:"language"` + Domain string `toml:"domain"` + Name string `toml:"name"` + Description string `toml:"description"` + DefaultImage string `toml:"default_image"` + Quotes []string `toml:"quotes"` + Language string `toml:"language"` + Database string `toml:"database"` + AdminPassword string `toml:"admin_password"` Sections []Section `toml:"section"` @@ -73,6 +75,8 @@ func (c *Config) DefaultValues() { }} c.RootFolder = "data" c.PublicFolder = "public" + c.Database = "database.sqlite" + c.AdminPassword = "Ch@ngeM€Please!" c.Quotes = []string{"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do."} c.Replacers = []Replacer{{"~", " "}} } diff --git a/backend/data.go b/backend/data.go index 5bc5391..2f2e06d 100644 --- a/backend/data.go +++ b/backend/data.go @@ -111,6 +111,11 @@ func (d *data) handleGeneric(w http.ResponseWriter, r *http.Request, name string if err != nil { panic(err) } + if name == "404" { + w.WriteHeader(http.StatusNotFound) + return + } + UpdateStats(r) } func (d *data) handleRSS(w http.ResponseWriter, r *http.Request, custom dataUsable) { diff --git a/backend/router.go b/backend/router.go index b7403cf..1a2f024 100644 --- a/backend/router.go +++ b/backend/router.go @@ -2,6 +2,9 @@ package backend import ( "context" + "crypto/sha256" + "crypto/subtle" + "database/sql" "embed" "fmt" "io/fs" @@ -11,6 +14,7 @@ import ( "strings" "time" + "git.anhgelus.world/anhgelus/small-web/backend/storage" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/httplog/v3" @@ -21,6 +25,7 @@ const ( configKey = "config" assetsFSKey = "assets_fs" debugKey = "debug" + loginKey = "login" ) //go:embed templates @@ -45,7 +50,7 @@ func SetupLogger(debug bool) { slog.SetDefault(logger) } -func NewRouter(debug bool, cfg *Config, assets fs.FS) *chi.Mux { +func NewRouter(debug bool, cfg *Config, db *sql.DB, assets fs.FS) *chi.Mux { r := chi.NewRouter() logLevel := slog.LevelWarn @@ -70,7 +75,7 @@ func NewRouter(debug bool, cfg *Config, assets fs.FS) *chi.Mux { r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // prevent tracking - w.Header().Add("Referrer-Policy", "no-referrer") + w.Header().Add("Referrer-Policy", "same-origin") // prevent iframe w.Header().Add("X-Frame-Options", "deny") // prevent bad content being parsed @@ -88,9 +93,23 @@ func NewRouter(debug bool, cfg *Config, assets fs.FS) *chi.Mux { // context r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := context.WithValue(r.Context(), configKey, cfg) - ctx = context.WithValue(ctx, assetsFSKey, assets) - ctx = context.WithValue(ctx, debugKey, debug) + next.ServeHTTP(w, r.WithContext( + setContext(r.Context(), cfg, debug, db, assets), + )) + }) + }) + // login + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, pass, ok := r.BasicAuth() + ctx := r.Context() + if ok { + cfg := ctx.Value(configKey).(*Config) + passHash := sha256.Sum256([]byte(pass)) + rightPassHash := sha256.Sum256([]byte(cfg.AdminPassword)) + ok = subtle.ConstantTimeCompare(passHash[:], rightPassHash[:]) == 1 + } + ctx = context.WithValue(ctx, loginKey, ok) next.ServeHTTP(w, r.WithContext(ctx)) }) }) @@ -143,3 +162,23 @@ func HandleStaticFiles(r *chi.Mux, path string, root fs.FS) { http.StripPrefix(pathPrefix, http.FileServerFS(root)).ServeHTTP(w, req) }) } + +func UpdateStats(r *http.Request) { + ctx := r.Context() + cfg := ctx.Value(configKey).(*Config) + debug := ctx.Value(debugKey).(bool) + db := ctx.Value(storage.DBKey).(*sql.DB) + assets := ctx.Value(assetsFSKey).(fs.FS) + + ctx2, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + if err := storage.UpdateStats(setContext(ctx2, cfg, debug, db, assets), r, cfg.Domain); err != nil { + slog.Error("updating stats", "error", err) + } +} +func setContext(ctx context.Context, cfg *Config, debug bool, db *sql.DB, assets fs.FS) context.Context { + ctx = context.WithValue(ctx, configKey, cfg) + ctx = context.WithValue(ctx, assetsFSKey, assets) + ctx = context.WithValue(ctx, debugKey, debug) + return context.WithValue(ctx, storage.DBKey, db) +} diff --git a/backend/section.go b/backend/section.go index e2150cb..1b5ab9f 100644 --- a/backend/section.go +++ b/backend/section.go @@ -270,7 +270,7 @@ func (s *Section) handlePagination(w http.ResponseWriter, r *http.Request, maxLo page, err = strconv.Atoi(rawPage) if err != nil || page < 1 { slog.Warn("invalid page number", "rawPage", rawPage) - w.WriteHeader(http.StatusBadRequest) + http.Error(w, "Bad request", http.StatusBadRequest) return nil } } diff --git a/backend/storage/db.go b/backend/storage/db.go new file mode 100644 index 0000000..0ee50da --- /dev/null +++ b/backend/storage/db.go @@ -0,0 +1,74 @@ +package storage + +import ( + "context" + "database/sql" + "embed" + "fmt" + "log/slog" + "regexp" + "slices" + "strconv" + + _ "github.com/mattn/go-sqlite3" +) + +const DBKey = "db" + +//go:embed migrations +var migrations embed.FS + +var nameReg = regexp.MustCompile(`(\d{3})_[a-zA-Z_-]+.sql`) + +func ConnectDatabase(file string) *sql.DB { + db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?cache=shared", file)) + if err != nil { + panic(err) + } + return db +} + +func RunMigration(ctx context.Context, db *sql.DB) error { + entries, err := migrations.ReadDir("migrations") + if err != nil { + return err + } + type runMig struct { + val string + n int + } + var toRun []runMig + for _, e := range entries { + rawId := nameReg.FindStringSubmatch(e.Name()) + id, err := strconv.Atoi(rawId[1]) + if err != nil { + return err + } + b, err := migrations.ReadFile("migrations/" + e.Name()) + if err != nil { + return err + } + slog.Debug("loading migration", "n", id, "file", e.Name(), "content", string(b)) + toRun = append(toRun, runMig{ + val: string(b), n: id, + }) + } + if len(toRun) == 0 { + return nil + } + slices.SortFunc(toRun, func(a, b runMig) int { + return a.n - b.n + }) + for _, m := range toRun { + slog.Info("migrating", "n", m.n) + _, err := db.ExecContext(ctx, m.val) + if err != nil { + return err + } + } + return nil +} + +func getDB(ctx context.Context) *sql.DB { + return ctx.Value(DBKey).(*sql.DB) +} diff --git a/backend/storage/migrations/000_init.sql b/backend/storage/migrations/000_init.sql new file mode 100644 index 0000000..6dd385c --- /dev/null +++ b/backend/storage/migrations/000_init.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS stats( + id INTEGER PRIMARY KEY, + visit INTEGER NOT NULL, + origin TEXT NOT NULL, + target TEXT NOT NULL +); diff --git a/backend/storage/stats.go b/backend/storage/stats.go new file mode 100644 index 0000000..b30cb8c --- /dev/null +++ b/backend/storage/stats.go @@ -0,0 +1,173 @@ +package storage + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "net/url" + "slices" + "strings" + "sync" + "time" +) + +type loaded struct { + data map[string]struct{} + mu *sync.RWMutex +} + +func (l *loaded) Has(k string) bool { + l.mu.RLock() + defer l.mu.RUnlock() + _, ok := l.data[k] + return ok +} + +func (l *loaded) Add(k string) { + l.mu.Lock() + defer l.mu.Unlock() + l.data[k] = struct{}{} +} + +func (l *loaded) Remove(k string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.data, k) +} + +func newLoaded() *loaded { + return &loaded{ + data: make(map[string]struct{}), + mu: new(sync.RWMutex), + } +} + +var load = newLoaded() + +func UpdateStats(ctx context.Context, r *http.Request, domain string) error { + target := r.URL.Path + if strings.HasPrefix(target, "/static") || strings.HasPrefix(target, "/admin") { + return nil + } + ref := r.Header.Get("Referer") + if ref == "" { + return nil + } + refUrl, err := url.Parse(ref) + if err != nil { + return nil + } + ref = refUrl.Host + if ref == domain || ref == fmt.Sprintf("localhost:%d", 8000) { + ref = refUrl.Path + if ref == target || strings.HasPrefix(ref, "/admin") || ref == "/favicon.ico" { + return nil + } + } + // using /assets/styles.css to detect if a page is loaded → majority of bots will not load this + if target == "/assets/styles.css" { + target = ref + ref = "?" + } + if load.Has(target) { + return nil + } + db := getDB(ctx) + rows, err := db.QueryContext(ctx, "SELECT id, visit FROM stats WHERE origin = ? AND target = ?", ref, target) + if err != nil { + return err + } + defer func() { + if err == nil { + slog.Debug("stats updated") + load.Add(target) + go func(target string) { + time.Sleep(5 * time.Second) + load.Remove(target) + }(target) + } + }() + if !rows.Next() { + _, err = db.ExecContext(ctx, "INSERT INTO stats (origin, target, visit) VALUES (?, ?, 1)", ref, target) + return err + } + var id uint + var nb uint + err = rows.Scan(&id, &nb) + if err != nil { + return err + } + err = rows.Close() + if err != nil { + return err + } + _, err = db.ExecContext(ctx, "UPDATE stats SET visit = ? WHERE id = ?", nb+1, id) + return err +} + +type StatsRow struct { + Origin string + Target string + Visit uint +} + +const StatsPerPage = 25 + +func GetStatsRows(ctx context.Context, page uint) ([]StatsRow, error) { + rows, err := getDB(ctx).QueryContext( + ctx, + "SELECT origin, target, visit FROM stats ORDER BY visit DESC LIMIT ? OFFSET ?", + StatsPerPage, (page-1)*StatsPerPage, + ) + if err != nil { + return nil, err + } + defer rows.Close() + statRows := make([]StatsRow, StatsPerPage) + var i uint8 + for i = 0; rows.Next(); i++ { + var stat StatsRow + err = rows.Scan(&stat.Origin, &stat.Target, &stat.Visit) + if err != nil { + return nil, err + } + statRows[i] = stat + } + if i == 0 { + return nil, nil + } + return statRows[:i], nil +} + +func GetUnionStatsRows(ctx context.Context) ([]StatsRow, error) { + rows, err := getDB(ctx).QueryContext(ctx, "SELECT target, visit FROM stats ORDER BY visit DESC") + if err != nil { + return nil, err + } + defer rows.Close() + data := make(map[string]uint) + for rows.Next() { + var stat StatsRow + err = rows.Scan(&stat.Target, &stat.Visit) + if err != nil { + return nil, err + } + if _, ok := data[stat.Target]; !ok { + data[stat.Target] = stat.Visit + } else { + data[stat.Target] += stat.Visit + } + } + var statRows []StatsRow + for k, v := range data { + statRows = append(statRows, StatsRow{ + Target: k, + Visit: v, + }) + } + slices.SortFunc(statRows, func(a, b StatsRow) int { + return int(b.Visit) - int(a.Visit) + }) + return statRows, nil +} diff --git a/backend/templates/admin.html b/backend/templates/admin.html new file mode 100644 index 0000000..008827f --- /dev/null +++ b/backend/templates/admin.html @@ -0,0 +1,51 @@ +{{define "body"}} +<main id="content"> + <div class="introduction"> + <h1>Administration</h1> + <p> + Panel d'administration très simple. Il affiche les stats et possiblement les webmentions et autre contenu à + modérer. + </p> + </div> + <article> + <h2>Stats</h2> + <h3>Visits</h3> + <table> + <thead> + <tr> + <th>Target</th> + <th>Visits</th> + </tr> + </thead> + <tbody> + {{ range .Visits }} + <tr> + <td>{{ .Target }}</td> + <td>{{ .Visit }}</td> + </tr> + {{ end }} + </tbody> + </table> + <h3>Origin</h3> + <table> + <thead> + <tr> + <th>Origin</th> + <th>Target</th> + <th>Visits</th> + </tr> + </thead> + <tbody> + {{ range .Rows }} + <tr> + <td>{{ .Origin }}</td> + <td>{{ .Target }}</td> + <td>{{ .Visit }}</td> + </tr> + {{ end }} + </tbody> + </table> + <div class="pagination">{{ template "pagination" . }}</div> + </article> +</main> +{{end}} diff --git a/backend/templates/components.html b/backend/templates/components.html index b4ffbe6..b180123 100644 --- a/backend/templates/components.html +++ b/backend/templates/components.html @@ -1,4 +1,14 @@ -{{define "section_display"}} +{{ define "pagination" }} +<nav> + {{ if ne .CurrentPage 1 }}<a href="?page={{ before .CurrentPage }}">Précédent</a>{{else}} + <p></p> + {{end}} + <p>{{ .CurrentPage }}/{{ .PagesNumber }}</p> + {{ if ne .CurrentPage .PagesNumber }}<a href="?page={{ next .CurrentPage }}">Suivant</a>{{else}} + <p></p> + {{end}} +</nav> +{{ end }} {{define "section_display"}} <article> {{ $uri := .URI }} {{ range .Data }} <article> @@ -11,17 +21,7 @@ </article> {{ end }} <div class="pagination"> - {{ if .Paginate }} - <nav> - {{ if ne .CurrentPage 1 }}<a href="?page={{ before .CurrentPage }}">Précédent</a>{{else}} - <p></p> - {{end}} - <p>{{ .CurrentPage }}/{{ .PagesNumber }}</p> - {{ if ne .CurrentPage .PagesNumber }}<a href="?page={{ next .CurrentPage }}">Suivant</a>{{else}} - <p></p> - {{end}} - </nav> - {{ else }} {{ if eq (len .Data) .LenMax }} + {{ if .Paginate }} {{ template "pagination" . }} {{ else }} {{ if eq (len .Data) .LenMax }} <a href="/{{ $uri }}/">Voir plus</a> {{ end }} {{ end }} </div> diff --git a/frontend/scss/general.scss b/frontend/scss/general.scss index bab3ada..28c1c81 100644 --- a/frontend/scss/general.scss +++ b/frontend/scss/general.scss @@ -180,3 +180,38 @@ pre { white-space: break-spaces; } } + +table { + width: calc(100% + 4rem); + + margin: 2rem -2rem; + padding: 0 1rem; + + line-height: var(--line-height-smaller); +} + +thead { + font-weight: bold; + font-size: 1rem; + + background: var(--color-rose); + color: var(--color-dark); + + & th { + padding-top: 0.25rem; + padding-bottom: 0.25rem; + } +} + +tbody { + background: var(--color-light-rose); + + font-family: monospace; + font-size: 0.85rem; +} + +td, +th { + padding-left: 0.5rem; + padding-right: 0.5rem; +} @@ -8,3 +8,5 @@ require ( github.com/joho/godotenv v1.5.1 github.com/pelletier/go-toml/v2 v2.2.4 ) + +require github.com/mattn/go-sqlite3 v1.14.32 // indirect @@ -4,5 +4,7 @@ github.com/go-chi/httplog/v3 v3.2.2 h1:G0oYv3YYcikNjijArHFUlqfR78cQNh9fGT43i6Stq github.com/go-chi/httplog/v3 v3.2.2/go.mod h1:N/J1l5l1fozUrqIVuT8Z/HzNeSy8TF2EFyokPLe6y2w= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= @@ -12,8 +12,10 @@ import ( "os/signal" "strconv" "syscall" + "time" "git.anhgelus.world/anhgelus/small-web/backend" + "git.anhgelus.world/anhgelus/small-web/backend/storage" "github.com/joho/godotenv" ) @@ -58,6 +60,15 @@ func main() { os.Exit(1) } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + db := storage.ConnectDatabase(cfg.Database) + defer db.Close() + err := storage.RunMigration(ctx, db) + if err != nil { + panic(err) + } + for _, sec := range cfg.Sections { if ok = sec.Load(cfg); !ok { slog.Info("exiting") @@ -70,7 +81,7 @@ func main() { assetsFS = os.DirFS("dist") } - r := backend.NewRouter(dev, cfg, assetsFS) + r := backend.NewRouter(dev, cfg, db, assetsFS) backend.HandleHome(r) backend.HandleRoot(r, cfg) @@ -79,6 +90,8 @@ func main() { } backend.Handle404(r) + backend.HandleAdmin(r) + backend.HandleStaticFiles(r, "/assets", assetsFS) backend.HandleStaticFiles(r, "/static", os.DirFS(cfg.PublicFolder)) |
