diff options
| author | Anhgelus Morhtuuzh <william@herges.fr> | 2025-12-22 15:07:55 +0100 |
|---|---|---|
| committer | Anhgelus Morhtuuzh <william@herges.fr> | 2025-12-22 15:07:55 +0100 |
| commit | 84af6427d8205b1882b9f9df11ce394f96d6b792 (patch) | |
| tree | ae51188d67616069300afd6a19300ff7ed23e7d3 | |
| parent | 93c4f9047426f9f56940ed2b733dcde2d98b0c0e (diff) | |
feat(backend): admin dashboard
| -rw-r--r-- | backend/config.go | 16 | ||||
| -rw-r--r-- | backend/router.go | 18 | ||||
| -rw-r--r-- | backend/section.go | 2 | ||||
| -rw-r--r-- | backend/stats.go | 81 | ||||
| -rw-r--r-- | backend/templates/admin.html | 33 | ||||
| -rw-r--r-- | backend/templates/components.html | 24 | ||||
| -rw-r--r-- | frontend/scss/general.scss | 35 | ||||
| -rw-r--r-- | main.go | 2 |
8 files changed, 189 insertions, 22 deletions
diff --git a/backend/config.go b/backend/config.go index b221978..33d5852 100644 --- a/backend/config.go +++ b/backend/config.go @@ -29,13 +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"` - Database string `toml:"database"` + 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"` @@ -75,6 +76,7 @@ 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/router.go b/backend/router.go index 14abc06..f2ed775 100644 --- a/backend/router.go +++ b/backend/router.go @@ -2,6 +2,8 @@ package backend import ( "context" + "crypto/sha256" + "crypto/subtle" "database/sql" "embed" "fmt" @@ -23,6 +25,7 @@ const ( assetsFSKey = "assets_fs" debugKey = "debug" dbKey = "db" + loginKey = "login" ) //go:embed templates @@ -112,6 +115,21 @@ func NewRouter(debug bool, cfg *Config, db *sql.DB, assets fs.FS) *chi.Mux { next.ServeHTTP(w, r) }) }) + // 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)) + }) + }) return r } 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/stats.go b/backend/stats.go index 2b07221..dfdf040 100644 --- a/backend/stats.go +++ b/backend/stats.go @@ -7,7 +7,10 @@ import ( "log/slog" "net/http" "regexp" + "strconv" "strings" + + "github.com/go-chi/chi/v5" ) var trimRefererReg = regexp.MustCompile(`https?://([a-z-0-9.]+(:\d+)?)/.*`) @@ -18,7 +21,9 @@ func getDB(ctx context.Context) *sql.DB { func UpdateStats(ctx context.Context, r *http.Request) error { target := r.URL.Path - if strings.HasPrefix(target, "/assets") || strings.HasPrefix(target, "/static") { + if strings.HasPrefix(target, "/assets") || + strings.HasPrefix(target, "/static") || + strings.HasPrefix(target, "/admin") { return nil } ref := r.Header.Get("Referer") @@ -32,7 +37,7 @@ func UpdateStats(ctx context.Context, r *http.Request) error { ref = subs[1] if ref == ctx.Value(configKey).(*Config).Domain || ref == fmt.Sprintf("localhost:%d", 8000) { ref = subs[0][strings.Index(subs[0], ref)+len(ref):] - if ref == target { + if ref == target || strings.HasPrefix(ref, "/admin") || ref == "/favicon.ico" { return nil } } @@ -63,3 +68,75 @@ func UpdateStats(ctx context.Context, r *http.Request) error { _, err = db.ExecContext(ctx, "UPDATE stats SET visit = ? WHERE id = ?", nb+1, id) return err } + +type statRow struct { + Origin string + Target string + Visit uint +} + +const statPerPage = 25 + +func GetStatRows(ctx context.Context, page uint) ([]statRow, error) { + rows, err := getDB(ctx).QueryContext( + ctx, + "SELECT origin, target, visit FROM stats ORDER BY visit DESC LIMIT ? OFFSET ?", + statPerPage, (page-1)*statPerPage, + ) + if err != nil { + return nil, err + } + defer rows.Close() + statRows := make([]statRow, statPerPage) + var i uint8 + for i = 0; rows.Next(); i++ { + var stat statRow + 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 +} + +type adminData struct { + *data + Rows []statRow + 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 = GetStatRows(ctx, uint(page)) + if err != nil { + panic(err) + } + d.PagesNumber = page + max(len(d.Rows)-statPerPage+1, 0) + d.CurrentPage = page + d.handleGeneric(w, r, "admin", d) + }) +} diff --git a/backend/templates/admin.html b/backend/templates/admin.html new file mode 100644 index 0000000..41addc9 --- /dev/null +++ b/backend/templates/admin.html @@ -0,0 +1,33 @@ +{{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> + <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; +} @@ -89,6 +89,8 @@ func main() { } backend.Handle404(r) + backend.HandleAdmin(r) + backend.HandleStaticFiles(r, "/assets", assetsFS) backend.HandleStaticFiles(r, "/static", os.DirFS(cfg.PublicFolder)) |
