Files
Dion Kobi 097904f2b9 feat: implement pagination in Web UI (Fixes #86) (#325)
* feat: implement pagination in Web UI (Fixes #86)

- Added Count method to JobRepository and Offset to SelectParams
- Updated sqlite implementation to support offset and count calculations
- Added ListJobs function to Service, keeping All intact for API backward compatibility
- Updated getJobs to parse ?page query param and return JobRowsData
- Updated HTML templates to support HTMX outerHTML replacements with out-of-band updates

* fix: resolve pagination bugs and add tests for #86

* fix: remove HTMX load loop, stray files, add trigger regression test (#86)

- index.html: initial tbody uses hx-trigger='load' only (fires once)
- job_rows.html: returned tbody uses hx-trigger='every 10s' only (no load)
  This eliminates the outerHTML reload loop where the inserted element
  immediately fires 'load' again before the 10s timer.
- Remove pr_body.md and results.csv (local artifacts, not part of #86)
- Add TestGetJobsHTMXTriggerIsPollingOnly to assert the fix is permanent
- Service.All remains unchanged for existing callers

* fix: address pagination review feedback

Co-authored-by: Giorgos Komninos <admin@gkomninos.com>

---------

Co-authored-by: Giorgos Komninos <admin@gkomninos.com>
2026-09-13 11:27:34 +03:00

720 lines
15 KiB
Go

package web
import (
"bytes"
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"uuid"
)
//go:embed static
var static embed.FS
const (
jobsPageLimit = 20
methodNotAllowedMessage = "Method not allowed"
)
type Server struct {
tmpl map[string]*template.Template
srv *http.Server
svc *Service
}
func New(svc *Service, addr string) (*Server, error) {
ans := Server{
svc: svc,
tmpl: make(map[string]*template.Template),
srv: &http.Server{
Addr: addr,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,
},
}
staticFS, err := fs.Sub(static, "static")
if err != nil {
return nil, err
}
fileServer := http.FileServer(http.FS(staticFS))
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", fileServer))
mux.HandleFunc("/scrape", ans.scrape)
mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
r = requestWithID(r)
ans.download(w, r)
})
mux.HandleFunc("/delete", func(w http.ResponseWriter, r *http.Request) {
r = requestWithID(r)
ans.delete(w, r)
})
mux.HandleFunc("/jobs", ans.getJobs)
mux.HandleFunc("/view", func(w http.ResponseWriter, r *http.Request) {
r = requestWithID(r)
ans.viewJob(w, r)
})
mux.HandleFunc("/", ans.index)
// api routes
mux.HandleFunc("/api/docs", ans.redocHandler)
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
ans.apiScrape(w, r)
case http.MethodGet:
ans.apiGetJobs(w, r)
default:
ans := apiError{
Code: http.StatusMethodNotAllowed,
Message: methodNotAllowedMessage,
}
renderJSON(w, http.StatusMethodNotAllowed, ans)
}
})
mux.HandleFunc("/api/v1/jobs/{id}", func(w http.ResponseWriter, r *http.Request) {
r = requestWithID(r)
switch r.Method {
case http.MethodGet:
ans.apiGetJob(w, r)
case http.MethodDelete:
ans.apiDeleteJob(w, r)
default:
ans := apiError{
Code: http.StatusMethodNotAllowed,
Message: methodNotAllowedMessage,
}
renderJSON(w, http.StatusMethodNotAllowed, ans)
}
})
mux.HandleFunc("/api/v1/jobs/{id}/download", func(w http.ResponseWriter, r *http.Request) {
r = requestWithID(r)
if r.Method != http.MethodGet {
ans := apiError{
Code: http.StatusMethodNotAllowed,
Message: methodNotAllowedMessage,
}
renderJSON(w, http.StatusMethodNotAllowed, ans)
return
}
ans.download(w, r)
})
handler := securityHeaders(mux)
ans.srv.Handler = handler
tmplsKeys := []string{
"static/templates/index.html",
"static/templates/job_rows.html",
"static/templates/job_row.html",
"static/templates/job_view.html",
"static/templates/redoc.html",
}
for _, key := range tmplsKeys {
tmp, err := template.ParseFS(static, key)
if err != nil {
return nil, err
}
ans.tmpl[key] = tmp
}
return &ans, nil
}
func (s *Server) Start(ctx context.Context) error {
go func(shutdownSource context.Context) {
<-shutdownSource.Done()
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(shutdownSource), 10*time.Second)
defer cancel()
err := s.srv.Shutdown(shutdownCtx)
if err != nil {
log.Println(err)
return
}
log.Println("server stopped")
}(ctx)
fmt.Fprintf(os.Stderr, "visit http://localhost%s\n", s.srv.Addr)
err := s.srv.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
type formData struct {
Name string
MaxTime string
Keywords []string
Language string
Zoom int
FastMode bool
Radius int
Lat string
Lon string
Depth int
Email bool
Proxies []string
}
type ctxKey string
const idCtxKey ctxKey = "id"
func requestWithID(r *http.Request) *http.Request {
id := r.PathValue("id")
if id == "" {
id = r.URL.Query().Get("id")
}
parsed, err := uuid.Parse(id)
if err == nil {
r = r.WithContext(context.WithValue(r.Context(), idCtxKey, parsed))
}
return r
}
func getIDFromRequest(r *http.Request) (uuid.UUID, bool) {
id, ok := r.Context().Value(idCtxKey).(uuid.UUID)
return id, ok
}
//nolint:gocritic // this is used in template
func (f formData) ProxiesString() string {
return strings.Join(f.Proxies, "\n")
}
//nolint:gocritic // this is used in template
func (f formData) KeywordsString() string {
return strings.Join(f.Keywords, "\n")
}
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, methodNotAllowedMessage, http.StatusMethodNotAllowed)
return
}
tmpl, ok := s.tmpl["static/templates/index.html"]
if !ok {
http.Error(w, "missing tpl", http.StatusInternalServerError)
return
}
data := formData{
Name: "",
MaxTime: "10m",
Keywords: []string{},
Language: "en",
Zoom: 15,
FastMode: false,
Radius: 10000,
Lat: "0",
Lon: "0",
Depth: 10,
Email: false,
}
_ = tmpl.Execute(w, data)
}
func (s *Server) scrape(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, methodNotAllowedMessage, http.StatusMethodNotAllowed)
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
newJob := Job{
ID: uuid.NewV4().String(),
Name: r.Form.Get("name"),
Date: time.Now().UTC(),
Status: StatusPending,
Data: JobData{},
}
maxTimeStr := r.Form.Get("maxtime")
maxTime, err := time.ParseDuration(maxTimeStr)
if err != nil {
http.Error(w, "invalid max time", http.StatusUnprocessableEntity)
return
}
if maxTime < time.Minute*3 {
http.Error(w, "max time must be more than 3m", http.StatusUnprocessableEntity)
return
}
newJob.Data.MaxTime = maxTime
keywordsStr, ok := r.Form["keywords"]
if !ok {
http.Error(w, "missing keywords", http.StatusUnprocessableEntity)
return
}
keywords := strings.Split(keywordsStr[0], "\n")
for _, k := range keywords {
k = strings.TrimSpace(k)
if k == "" {
continue
}
newJob.Data.Keywords = append(newJob.Data.Keywords, k)
}
newJob.Data.Lang = r.Form.Get("lang")
newJob.Data.Zoom, err = strconv.Atoi(r.Form.Get("zoom"))
if err != nil {
http.Error(w, "invalid zoom", http.StatusUnprocessableEntity)
return
}
if r.Form.Get("fastmode") == "on" {
newJob.Data.FastMode = true
}
newJob.Data.Radius, err = strconv.Atoi(r.Form.Get("radius"))
if err != nil {
http.Error(w, "invalid radius", http.StatusUnprocessableEntity)
return
}
newJob.Data.Lat = r.Form.Get("latitude")
newJob.Data.Lon = r.Form.Get("longitude")
newJob.Data.Depth, err = strconv.Atoi(r.Form.Get("depth"))
if err != nil {
http.Error(w, "invalid depth", http.StatusUnprocessableEntity)
return
}
newJob.Data.Email = r.Form.Get("email") == "on"
proxies := strings.Split(r.Form.Get("proxies"), "\n")
if len(proxies) > 0 {
for _, p := range proxies {
p = strings.TrimSpace(p)
if p == "" {
continue
}
newJob.Data.Proxies = append(newJob.Data.Proxies, p)
}
}
err = newJob.Validate()
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
err = s.svc.Create(r.Context(), &newJob)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.renderJobsPage(r.Context(), w, 1); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Server) renderJobsPage(ctx context.Context, w io.Writer, page int) error {
tmpl, ok := s.tmpl["static/templates/job_rows.html"]
if !ok {
return errors.New("missing tpl")
}
jobPage, err := s.svc.ListJobs(ctx, page, jobsPageLimit)
if err != nil {
return fmt.Errorf("list jobs: %w", err)
}
var output bytes.Buffer
if err := tmpl.Execute(&output, jobPage); err != nil {
return fmt.Errorf("render jobs: %w", err)
}
if _, err := output.WriteTo(w); err != nil {
return fmt.Errorf("write jobs: %w", err)
}
return nil
}
func pageFromRequest(r *http.Request) int {
page, err := strconv.Atoi(r.URL.Query().Get("page"))
if err != nil || page < 1 {
return 1
}
return page
}
func (s *Server) getJobs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, methodNotAllowedMessage, http.StatusMethodNotAllowed)
return
}
if err := s.renderJobsPage(r.Context(), w, pageFromRequest(r)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Server) download(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, methodNotAllowedMessage, http.StatusMethodNotAllowed)
return
}
ctx := r.Context()
id, ok := getIDFromRequest(r)
if !ok {
http.Error(w, "Invalid ID", http.StatusUnprocessableEntity)
return
}
filePath, err := s.svc.GetCSV(ctx, id.String())
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
file, err := os.Open(filePath) //nolint:gosec // GetCSV returns a validated path rooted in the configured data directory.
if err != nil {
http.Error(w, "Failed to open file", http.StatusInternalServerError)
return
}
defer file.Close()
fileName := filepath.Base(filePath)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
w.Header().Set("Content-Type", "text/csv")
_, err = io.Copy(w, file)
if err != nil {
http.Error(w, "Failed to send file", http.StatusInternalServerError)
return
}
}
func (s *Server) delete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, methodNotAllowedMessage, http.StatusMethodNotAllowed)
return
}
deleteID, ok := getIDFromRequest(r)
if !ok {
http.Error(w, "Invalid ID", http.StatusUnprocessableEntity)
return
}
err := s.svc.Delete(r.Context(), deleteID.String())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.renderJobsPage(r.Context(), w, pageFromRequest(r)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
type apiError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type apiScrapeRequest struct {
Name string
JobData
}
type apiScrapeResponse struct {
ID string `json:"id"`
}
func (s *Server) redocHandler(w http.ResponseWriter, _ *http.Request) {
tmpl, ok := s.tmpl["static/templates/redoc.html"]
if !ok {
http.Error(w, "missing tpl", http.StatusInternalServerError)
return
}
_ = tmpl.Execute(w, nil)
}
func (s *Server) apiScrape(w http.ResponseWriter, r *http.Request) {
var req apiScrapeRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
ans := apiError{
Code: http.StatusUnprocessableEntity,
Message: err.Error(),
}
renderJSON(w, http.StatusUnprocessableEntity, ans)
return
}
newJob := Job{
ID: uuid.NewV4().String(),
Name: req.Name,
Date: time.Now().UTC(),
Status: StatusPending,
Data: req.JobData,
}
// convert to seconds
newJob.Data.MaxTime *= time.Second
err = newJob.Validate()
if err != nil {
ans := apiError{
Code: http.StatusUnprocessableEntity,
Message: err.Error(),
}
renderJSON(w, http.StatusUnprocessableEntity, ans)
return
}
err = s.svc.Create(r.Context(), &newJob)
if err != nil {
ans := apiError{
Code: http.StatusInternalServerError,
Message: err.Error(),
}
renderJSON(w, http.StatusInternalServerError, ans)
return
}
ans := apiScrapeResponse{
ID: newJob.ID,
}
renderJSON(w, http.StatusCreated, ans)
}
func (s *Server) apiGetJobs(w http.ResponseWriter, r *http.Request) {
jobs, err := s.svc.All(r.Context())
if err != nil {
apiError := apiError{
Code: http.StatusInternalServerError,
Message: err.Error(),
}
renderJSON(w, http.StatusInternalServerError, apiError)
return
}
renderJSON(w, http.StatusOK, jobs)
}
func (s *Server) apiGetJob(w http.ResponseWriter, r *http.Request) {
id, ok := getIDFromRequest(r)
if !ok {
apiError := apiError{
Code: http.StatusUnprocessableEntity,
Message: "Invalid ID",
}
renderJSON(w, http.StatusUnprocessableEntity, apiError)
return
}
job, err := s.svc.Get(r.Context(), id.String())
if err != nil {
apiError := apiError{
Code: http.StatusNotFound,
Message: http.StatusText(http.StatusNotFound),
}
renderJSON(w, http.StatusNotFound, apiError)
return
}
renderJSON(w, http.StatusOK, job)
}
// viewJob renders the map modal fragment for a job, embedding the job's places
// directly so the client needs no separate data request.
func (s *Server) viewJob(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, methodNotAllowedMessage, http.StatusMethodNotAllowed)
return
}
id, ok := getIDFromRequest(r)
if !ok {
http.Error(w, "Invalid ID", http.StatusUnprocessableEntity)
return
}
places, err := s.svc.GetPlaces(r.Context(), id.String())
if err != nil {
if !errors.Is(err, ErrPlacesNotFound) {
log.Printf("view job %s: %v", id, err) //nolint:gosec // id is a parsed UUID and cannot contain log control characters.
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// No CSV yet: render the modal with an empty state rather than an error.
places = []Place{}
}
tmpl, ok := s.tmpl["static/templates/job_view.html"]
if !ok {
http.Error(w, "missing tpl", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, places); err != nil {
log.Printf("view job %s: render: %v", id, err) //nolint:gosec // id is a parsed UUID and cannot contain log control characters.
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
_, _ = buf.WriteTo(w)
}
func (s *Server) apiDeleteJob(w http.ResponseWriter, r *http.Request) {
id, ok := getIDFromRequest(r)
if !ok {
apiError := apiError{
Code: http.StatusUnprocessableEntity,
Message: "Invalid ID",
}
renderJSON(w, http.StatusUnprocessableEntity, apiError)
return
}
err := s.svc.Delete(r.Context(), id.String())
if err != nil {
apiError := apiError{
Code: http.StatusInternalServerError,
Message: err.Error(),
}
renderJSON(w, http.StatusInternalServerError, apiError)
return
}
w.WriteHeader(http.StatusOK)
}
func renderJSON(w http.ResponseWriter, code int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(data)
}
func formatDate(t time.Time) string {
return t.Format("Jan 02, 2006 15:04:05")
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' cdn.redoc.ly cdnjs.cloudflare.com 'unsafe-inline' 'unsafe-eval'; "+
"worker-src 'self' blob:; "+
"style-src 'self' 'unsafe-inline' fonts.googleapis.com cdnjs.cloudflare.com; "+
"img-src 'self' data: cdn.redoc.ly cdnjs.cloudflare.com *.tile.openstreetmap.org; "+
"font-src 'self' fonts.gstatic.com; "+
"connect-src 'self'")
next.ServeHTTP(w, r)
})
}