aboutsummaryrefslogtreecommitdiff
path: root/backend
diff options
context:
space:
mode:
authorAnhgelus Morhtuuzh <william@herges.fr>2025-12-22 18:18:56 +0000
committerAnhgelus Morhtuuzh <william@herges.fr>2025-12-22 18:18:56 +0000
commit66d6ab349196fd5d95937f533faa9db6ba65ebb3 (patch)
tree84ce7dd31c9e4dc10dbb41c5428cc2cb66bb44d9 /backend
parent1c86bdda2a3067ade9aa765d8d87fe27151dd895 (diff)
parentc66a5cc0809875df0299ebb80be423436c195416 (diff)
Merge pull request '[Feat] Stats' (#4) from feat/stats into main
Reviewed-on: https://git.anhgelus.world/anhgelus/small-web/pulls/4
Diffstat (limited to 'backend')
-rw-r--r--backend/admin.go53
-rw-r--r--backend/config.go16
-rw-r--r--backend/data.go5
-rw-r--r--backend/router.go49
-rw-r--r--backend/section.go2
-rw-r--r--backend/storage/db.go74
-rw-r--r--backend/storage/migrations/000_init.sql6
-rw-r--r--backend/storage/stats.go173
-rw-r--r--backend/templates/admin.html51
-rw-r--r--backend/templates/components.html24
10 files changed, 429 insertions, 24 deletions
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{{"~", "&thinsp;"}}
}
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>