Kaynağa Gözat

refactor: remove dependency on gorilla/mux

Frédéric Guillot 1 hafta önce
ebeveyn
işleme
f03285883b
100 değiştirilmiş dosya ile 632 ekleme ve 744 silme
  1. 0 1
      go.mod
  2. 0 2
      go.sum
  3. 1 1
      internal/googlereader/handler.go
  4. 0 7
      internal/http/request/params.go
  5. 0 70
      internal/http/request/params_test.go
  6. 0 35
      internal/http/route/route.go
  7. 27 0
      internal/http/server/healthcheck.go
  8. 1 130
      internal/http/server/httpd.go
  9. 70 0
      internal/http/server/metrics.go
  10. 74 0
      internal/http/server/routes.go
  11. 2 4
      internal/template/engine.go
  12. 8 8
      internal/template/functions.go
  13. 8 8
      internal/template/templates/common/feed_list.html
  14. 5 5
      internal/template/templates/common/feed_menu.html
  15. 5 5
      internal/template/templates/common/item_meta.html
  16. 30 30
      internal/template/templates/common/layout.html
  17. 6 6
      internal/template/templates/common/settings_menu.html
  18. 1 1
      internal/template/templates/views/add_subscription.html
  19. 2 2
      internal/template/templates/views/api_keys.html
  20. 7 7
      internal/template/templates/views/categories.html
  21. 14 14
      internal/template/templates/views/category_entries.html
  22. 5 5
      internal/template/templates/views/category_feeds.html
  23. 1 1
      internal/template/templates/views/choose_subscription.html
  24. 2 2
      internal/template/templates/views/create_api_key.html
  25. 3 3
      internal/template/templates/views/create_category.html
  26. 2 2
      internal/template/templates/views/create_user.html
  27. 4 4
      internal/template/templates/views/edit_category.html
  28. 7 7
      internal/template/templates/views/edit_feed.html
  29. 2 2
      internal/template/templates/views/edit_user.html
  30. 13 13
      internal/template/templates/views/entry.html
  31. 12 12
      internal/template/templates/views/feed_entries.html
  32. 5 5
      internal/template/templates/views/history_entries.html
  33. 2 2
      internal/template/templates/views/import.html
  34. 4 4
      internal/template/templates/views/integrations.html
  35. 3 3
      internal/template/templates/views/login.html
  36. 1 1
      internal/template/templates/views/offline.html
  37. 4 4
      internal/template/templates/views/search.html
  38. 1 1
      internal/template/templates/views/sessions.html
  39. 7 7
      internal/template/templates/views/settings.html
  40. 8 8
      internal/template/templates/views/shared_entries.html
  41. 3 3
      internal/template/templates/views/starred_entries.html
  42. 3 3
      internal/template/templates/views/tag_entries.html
  43. 5 5
      internal/template/templates/views/unread_entries.html
  44. 3 3
      internal/template/templates/views/users.html
  45. 1 1
      internal/template/templates/views/webauthn_rename.html
  46. 1 2
      internal/ui/api_key_remove.go
  47. 1 2
      internal/ui/api_key_save.go
  48. 1 2
      internal/ui/category_entries.go
  49. 1 2
      internal/ui/category_entries_all.go
  50. 1 2
      internal/ui/category_entries_starred.go
  51. 1 2
      internal/ui/category_mark_as_read.go
  52. 2 3
      internal/ui/category_refresh.go
  53. 1 2
      internal/ui/category_remove.go
  54. 1 2
      internal/ui/category_remove_feed.go
  55. 1 2
      internal/ui/category_save.go
  56. 1 2
      internal/ui/category_update.go
  57. 2 3
      internal/ui/entry_category.go
  58. 2 3
      internal/ui/entry_feed.go
  59. 2 3
      internal/ui/entry_read.go
  60. 2 3
      internal/ui/entry_search.go
  61. 2 3
      internal/ui/entry_starred.go
  62. 2 3
      internal/ui/entry_tag.go
  63. 3 4
      internal/ui/entry_unread.go
  64. 1 2
      internal/ui/feed_entries.go
  65. 1 2
      internal/ui/feed_entries_all.go
  66. 1 2
      internal/ui/feed_mark_as_read.go
  67. 2 3
      internal/ui/feed_refresh.go
  68. 1 2
      internal/ui/feed_remove.go
  69. 1 2
      internal/ui/feed_update.go
  70. 13 6
      internal/ui/handler.go
  71. 1 2
      internal/ui/history_entries.go
  72. 4 5
      internal/ui/integration_update.go
  73. 1 2
      internal/ui/login_check.go
  74. 1 2
      internal/ui/login_show.go
  75. 1 2
      internal/ui/logout.go
  76. 34 35
      internal/ui/middleware.go
  77. 8 9
      internal/ui/oauth2_callback.go
  78. 2 3
      internal/ui/oauth2_redirect.go
  79. 5 6
      internal/ui/oauth2_unlink.go
  80. 4 5
      internal/ui/opml_upload.go
  81. 1 2
      internal/ui/search.go
  82. 1 2
      internal/ui/session_remove.go
  83. 1 2
      internal/ui/settings_update.go
  84. 2 3
      internal/ui/share.go
  85. 1 2
      internal/ui/shared_entries.go
  86. 1 2
      internal/ui/starred_entries.go
  87. 2 3
      internal/ui/starred_entry_category.go
  88. 9 4
      internal/ui/static_javascript.go
  89. 16 17
      internal/ui/static_manifest.go
  90. 3 2
      internal/ui/static_stylesheet.go
  91. 1 2
      internal/ui/subscription_choose.go
  92. 2 3
      internal/ui/subscription_submit.go
  93. 1 2
      internal/ui/tag_entries_all.go
  94. 113 112
      internal/ui/ui.go
  95. 1 2
      internal/ui/unread_entries.go
  96. 2 3
      internal/ui/unread_entry_category.go
  97. 2 3
      internal/ui/unread_entry_feed.go
  98. 1 2
      internal/ui/user_remove.go
  99. 1 2
      internal/ui/user_save.go
  100. 1 2
      internal/ui/user_update.go

+ 0 - 1
go.mod

@@ -7,7 +7,6 @@ require (
 	github.com/andybalholm/brotli v1.2.0
 	github.com/coreos/go-oidc/v3 v3.17.0
 	github.com/go-webauthn/webauthn v0.16.1
-	github.com/gorilla/mux v1.8.1
 	github.com/lib/pq v1.12.0
 	github.com/prometheus/client_golang v1.23.2
 	github.com/tdewolff/minify/v2 v2.24.10

+ 0 - 2
go.sum

@@ -34,8 +34,6 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLz
 github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
-github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
 github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=

+ 1 - 1
internal/googlereader/handler.go

@@ -518,7 +518,7 @@ func move(feedStream Stream, labelStream Stream, store *storage.Storage, userID
 
 func (h *greaderHandler) feedIconURL(f *model.Feed) string {
 	if f.Icon != nil && f.Icon.ExternalIconID != "" {
-		return config.Opts.BaseURL() + "/feed/icon/" + f.Icon.ExternalIconID
+		return config.Opts.BaseURL() + "/feed-icon/" + f.Icon.ExternalIconID
 	}
 	return ""
 }

+ 0 - 7
internal/http/request/params.go

@@ -7,8 +7,6 @@ import (
 	"net/http"
 	"strconv"
 	"strings"
-
-	"github.com/gorilla/mux"
 )
 
 // FormInt64Value returns the named form value parsed as int64, or 0 on error.
@@ -129,10 +127,5 @@ func HasQueryParam(r *http.Request, param string) bool {
 }
 
 func routeParam(r *http.Request, param string) string {
-	vars := mux.Vars(r)
-	if value, found := vars[param]; found {
-		return value
-	}
-
 	return r.PathValue(param)
 }

+ 0 - 70
internal/http/request/params_test.go

@@ -9,8 +9,6 @@ import (
 	"net/url"
 	"reflect"
 	"testing"
-
-	"github.com/gorilla/mux"
 )
 
 func TestFormInt64Value(t *testing.T) {
@@ -42,33 +40,6 @@ func TestFormInt64Value(t *testing.T) {
 	}
 }
 
-func TestRouteStringParamWithGorillaMux(t *testing.T) {
-	router := mux.NewRouter()
-	router.HandleFunc("/route/{variable}/index", func(w http.ResponseWriter, r *http.Request) {
-		result := RouteStringParam(r, "variable")
-		expected := "value"
-
-		if result != expected {
-			t.Errorf(`Unexpected result, got %q instead of %q`, result, expected)
-		}
-
-		result = RouteStringParam(r, "missing variable")
-		expected = ""
-
-		if result != expected {
-			t.Errorf(`Unexpected result, got %q instead of %q`, result, expected)
-		}
-	})
-
-	r, err := http.NewRequest("GET", "/route/value/index", nil)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	w := httptest.NewRecorder()
-	router.ServeHTTP(w, r)
-}
-
 func TestRouteStringParamWithServerMux(t *testing.T) {
 	router := http.NewServeMux()
 	router.HandleFunc("GET /route/{variable}/index", func(w http.ResponseWriter, r *http.Request) {
@@ -96,47 +67,6 @@ func TestRouteStringParamWithServerMux(t *testing.T) {
 	router.ServeHTTP(w, r)
 }
 
-func TestRouteInt64ParamWithGorillaMux(t *testing.T) {
-	router := mux.NewRouter()
-	router.HandleFunc("/a/{variable1}/b/{variable2}/c/{variable3}", func(w http.ResponseWriter, r *http.Request) {
-		result := RouteInt64Param(r, "variable1")
-		expected := int64(42)
-
-		if result != expected {
-			t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
-		}
-
-		result = RouteInt64Param(r, "missing variable")
-		expected = 0
-
-		if result != expected {
-			t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
-		}
-
-		result = RouteInt64Param(r, "variable2")
-		expected = 0
-
-		if result != expected {
-			t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
-		}
-
-		result = RouteInt64Param(r, "variable3")
-		expected = 0
-
-		if result != expected {
-			t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
-		}
-	})
-
-	r, err := http.NewRequest("GET", "/a/42/b/not-int/c/-10", nil)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	w := httptest.NewRecorder()
-	router.ServeHTTP(w, r)
-}
-
 func TestRouteInt64ParamWithServerMux(t *testing.T) {
 	router := http.NewServeMux()
 	router.HandleFunc("GET /a/{variable1}/b/{variable2}/c/{variable3}", func(w http.ResponseWriter, r *http.Request) {

+ 0 - 35
internal/http/route/route.go

@@ -1,35 +0,0 @@
-// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package route // import "miniflux.app/v2/internal/http/route"
-
-import (
-	"strconv"
-
-	"github.com/gorilla/mux"
-)
-
-// Path returns the defined route based on given arguments.
-func Path(router *mux.Router, name string, args ...any) string {
-	route := router.Get(name)
-	if route == nil {
-		panic("route not found: " + name)
-	}
-
-	pairs := make([]string, 0, len(args))
-	for _, arg := range args {
-		switch param := arg.(type) {
-		case string:
-			pairs = append(pairs, param)
-		case int64:
-			pairs = append(pairs, strconv.FormatInt(param, 10))
-		}
-	}
-
-	result, err := route.URLPath(pairs...)
-	if err != nil {
-		panic(err)
-	}
-
-	return result.String()
-}

+ 27 - 0
internal/http/server/healthcheck.go

@@ -0,0 +1,27 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package server // import "miniflux.app/v2/internal/http/server"
+
+import (
+	"fmt"
+	"net/http"
+
+	"miniflux.app/v2/internal/storage"
+)
+
+func livenessProbe(w http.ResponseWriter, r *http.Request) {
+	w.WriteHeader(http.StatusOK)
+	w.Write([]byte("OK"))
+}
+
+func newReadinessProbe(store *storage.Storage) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if err := store.Ping(); err != nil {
+			http.Error(w, fmt.Sprintf("Database Connection Error: %q", err), http.StatusServiceUnavailable)
+			return
+		}
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte("OK"))
+	}
+}

+ 1 - 130
internal/http/server/httpd.go

@@ -13,17 +13,10 @@ import (
 	"strconv"
 	"strings"
 
-	"miniflux.app/v2/internal/api"
 	"miniflux.app/v2/internal/config"
-	"miniflux.app/v2/internal/fever"
-	"miniflux.app/v2/internal/googlereader"
-	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/storage"
-	"miniflux.app/v2/internal/ui"
 	"miniflux.app/v2/internal/worker"
 
-	"github.com/gorilla/mux"
-	"github.com/prometheus/client_golang/prometheus/promhttp"
 	"golang.org/x/crypto/acme"
 	"golang.org/x/crypto/acme/autocert"
 )
@@ -68,7 +61,7 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
 			ReadTimeout:  config.Opts.HTTPServerTimeout(),
 			WriteTimeout: config.Opts.HTTPServerTimeout(),
 			IdleTimeout:  config.Opts.HTTPServerTimeout(),
-			Handler:      setupHandler(store, pool),
+			Handler:      newRouter(store, pool),
 		}
 
 		isUNIXSocket := strings.HasPrefix(listenAddr, "/")
@@ -200,128 +193,6 @@ func startHTTPServer(server *http.Server) {
 	}()
 }
 
-func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
-	livenessProbe := func(w http.ResponseWriter, r *http.Request) {
-		w.WriteHeader(http.StatusOK)
-		w.Write([]byte("OK"))
-	}
-	readinessProbe := func(w http.ResponseWriter, r *http.Request) {
-		if err := store.Ping(); err != nil {
-			http.Error(w, fmt.Sprintf("Database Connection Error: %q", err), http.StatusServiceUnavailable)
-			return
-		}
-		w.WriteHeader(http.StatusOK)
-		w.Write([]byte("OK"))
-	}
-
-	router := mux.NewRouter()
-
-	// These routes do not take the base path into consideration and are always available at the root of the server.
-	router.HandleFunc("/liveness", livenessProbe).Name("liveness")
-	router.HandleFunc("/healthz", livenessProbe).Name("healthz")
-	router.HandleFunc("/readiness", readinessProbe).Name("readiness")
-	router.HandleFunc("/readyz", readinessProbe).Name("readyz")
-
-	var subrouter *mux.Router
-	if config.Opts.BasePath() != "" {
-		subrouter = router.PathPrefix(config.Opts.BasePath()).Subrouter()
-	} else {
-		subrouter = router.NewRoute().Subrouter()
-	}
-
-	if config.Opts.HasMaintenanceMode() {
-		subrouter.Use(func(next http.Handler) http.Handler {
-			return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-				w.Write([]byte(config.Opts.MaintenanceMessage()))
-			})
-		})
-	}
-
-	subrouter.Use(middleware)
-
-	// Fever API routing
-	feverSubrouter := subrouter.PathPrefix("/fever").Subrouter()
-	feverSubrouter.Use(fever.Middleware(store))
-	feverSubrouter.Handle("/", fever.NewHandler(store)).Name("feverEndpoint")
-
-	// Google Reader API routing
-	googleReaderHandler := http.StripPrefix(config.Opts.BasePath(), googlereader.NewHandler(store))
-	subrouter.HandleFunc("/accounts/ClientLogin", googleReaderHandler.ServeHTTP).Methods(http.MethodPost).Name("ClientLogin")
-	subrouter.PathPrefix("/reader/api/0").Handler(googleReaderHandler)
-
-	if config.Opts.HasAPI() {
-		apiHandler := http.StripPrefix(config.Opts.BasePath(), api.NewHandler(store, pool))
-		subrouter.PathPrefix("/v1").Handler(apiHandler)
-	}
-	ui.Serve(subrouter, store, pool)
-
-	subrouter.HandleFunc("/healthcheck", readinessProbe).Name("healthcheck")
-
-	if config.Opts.HasMetricsCollector() {
-		subrouter.Handle("/metrics", promhttp.Handler()).Name("metrics")
-		subrouter.Use(func(next http.Handler) http.Handler {
-			return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-				route := mux.CurrentRoute(r)
-
-				// Returns a 404 if the client is not authorized to access the metrics endpoint.
-				if route.GetName() == "metrics" && !isAllowedToAccessMetricsEndpoint(r) {
-					slog.Warn("Authentication failed while accessing the metrics endpoint",
-						slog.String("client_ip", request.ClientIP(r)),
-						slog.String("client_user_agent", r.UserAgent()),
-						slog.String("client_remote_addr", r.RemoteAddr),
-					)
-					http.NotFound(w, r)
-					return
-				}
-
-				next.ServeHTTP(w, r)
-			})
-		})
-	}
-
-	return router
-}
-
-func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
-	clientIP := request.ClientIP(r)
-
-	if config.Opts.MetricsUsername() != "" && config.Opts.MetricsPassword() != "" {
-		username, password, authOK := r.BasicAuth()
-		if !authOK {
-			slog.Warn("Metrics endpoint accessed without authentication header",
-				slog.Bool("authentication_failed", true),
-				slog.String("client_ip", clientIP),
-				slog.String("client_user_agent", r.UserAgent()),
-				slog.String("client_remote_addr", r.RemoteAddr),
-			)
-			return false
-		}
-
-		if username == "" || password == "" {
-			slog.Warn("Metrics endpoint accessed with empty username or password",
-				slog.Bool("authentication_failed", true),
-				slog.String("client_ip", clientIP),
-				slog.String("client_user_agent", r.UserAgent()),
-				slog.String("client_remote_addr", r.RemoteAddr),
-			)
-			return false
-		}
-
-		if username != config.Opts.MetricsUsername() || password != config.Opts.MetricsPassword() {
-			slog.Warn("Metrics endpoint accessed with invalid username or password",
-				slog.Bool("authentication_failed", true),
-				slog.String("client_ip", clientIP),
-				slog.String("client_user_agent", r.UserAgent()),
-				slog.String("client_remote_addr", r.RemoteAddr),
-			)
-			return false
-		}
-	}
-
-	remoteIP := request.FindRemoteIP(r)
-	return request.IsTrustedIP(remoteIP, config.Opts.MetricsAllowedNetworks())
-}
-
 func printErrorAndExit(format string, a ...any) {
 	message := fmt.Sprintf(format, a...)
 	slog.Error(message)

+ 70 - 0
internal/http/server/metrics.go

@@ -0,0 +1,70 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package server // import "miniflux.app/v2/internal/http/server"
+
+import (
+	"log/slog"
+	"net/http"
+
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/request"
+
+	"github.com/prometheus/client_golang/prometheus/promhttp"
+)
+
+func metricsHandler() http.Handler {
+	handler := promhttp.Handler()
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if !isAllowedToAccessMetricsEndpoint(r) {
+			slog.Warn("Authentication failed while accessing the metrics endpoint",
+				slog.String("client_ip", request.ClientIP(r)),
+				slog.String("client_user_agent", r.UserAgent()),
+				slog.String("client_remote_addr", r.RemoteAddr),
+			)
+			http.NotFound(w, r)
+			return
+		}
+		handler.ServeHTTP(w, r)
+	})
+}
+
+func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
+	clientIP := request.ClientIP(r)
+
+	if config.Opts.MetricsUsername() != "" && config.Opts.MetricsPassword() != "" {
+		username, password, authOK := r.BasicAuth()
+		if !authOK {
+			slog.Warn("Metrics endpoint accessed without authentication header",
+				slog.Bool("authentication_failed", true),
+				slog.String("client_ip", clientIP),
+				slog.String("client_user_agent", r.UserAgent()),
+				slog.String("client_remote_addr", r.RemoteAddr),
+			)
+			return false
+		}
+
+		if username == "" || password == "" {
+			slog.Warn("Metrics endpoint accessed with empty username or password",
+				slog.Bool("authentication_failed", true),
+				slog.String("client_ip", clientIP),
+				slog.String("client_user_agent", r.UserAgent()),
+				slog.String("client_remote_addr", r.RemoteAddr),
+			)
+			return false
+		}
+
+		if username != config.Opts.MetricsUsername() || password != config.Opts.MetricsPassword() {
+			slog.Warn("Metrics endpoint accessed with invalid username or password",
+				slog.Bool("authentication_failed", true),
+				slog.String("client_ip", clientIP),
+				slog.String("client_user_agent", r.UserAgent()),
+				slog.String("client_remote_addr", r.RemoteAddr),
+			)
+			return false
+		}
+	}
+
+	remoteIP := request.FindRemoteIP(r)
+	return request.IsTrustedIP(remoteIP, config.Opts.MetricsAllowedNetworks())
+}

+ 74 - 0
internal/http/server/routes.go

@@ -0,0 +1,74 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package server // import "miniflux.app/v2/internal/http/server"
+
+import (
+	"net/http"
+
+	"miniflux.app/v2/internal/api"
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/fever"
+	"miniflux.app/v2/internal/googlereader"
+	"miniflux.app/v2/internal/storage"
+	"miniflux.app/v2/internal/ui"
+	"miniflux.app/v2/internal/worker"
+)
+
+func newRouter(store *storage.Storage, pool *worker.Pool) http.Handler {
+	readinessProbe := newReadinessProbe(store)
+
+	// Application routes served under the base path.
+	appMux := http.NewServeMux()
+
+	appMux.HandleFunc("GET /healthcheck", readinessProbe)
+
+	// Fever API routing.
+	feverHandler := fever.Middleware(store)(fever.NewHandler(store))
+	appMux.Handle("/fever/", feverHandler)
+
+	// Google Reader API routing.
+	googleReaderHandler := googlereader.NewHandler(store)
+	appMux.HandleFunc("POST /accounts/ClientLogin", googleReaderHandler.ServeHTTP)
+	appMux.Handle("/reader/api/0/", googleReaderHandler)
+
+	// REST API routing.
+	if config.Opts.HasAPI() {
+		appMux.Handle("/v1/", api.NewHandler(store, pool))
+	}
+
+	// Metrics endpoint.
+	if config.Opts.HasMetricsCollector() {
+		appMux.Handle("GET /metrics", metricsHandler())
+	}
+
+	// UI routing (catch-all).
+	appMux.Handle("/", ui.Serve(store, pool))
+
+	// Apply shared middleware.
+	var appHandler http.Handler = appMux
+	if config.Opts.HasMaintenanceMode() {
+		appHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Write([]byte(config.Opts.MaintenanceMessage()))
+		})
+	}
+	appHandler = middleware(appHandler)
+
+	// Root router: health probes at root, app routes under base path.
+	rootMux := http.NewServeMux()
+
+	// These routes do not take the base path into consideration and are always available at the root of the server.
+	rootMux.HandleFunc("/liveness", livenessProbe)
+	rootMux.HandleFunc("/healthz", livenessProbe)
+	rootMux.HandleFunc("/readiness", readinessProbe)
+	rootMux.HandleFunc("/readyz", readinessProbe)
+
+	basePath := config.Opts.BasePath()
+	if basePath != "" {
+		rootMux.Handle(basePath+"/", http.StripPrefix(basePath, appHandler))
+	} else {
+		rootMux.Handle("/", appHandler)
+	}
+
+	return rootMux
+}

+ 2 - 4
internal/template/engine.go

@@ -10,8 +10,6 @@ import (
 	"time"
 
 	"miniflux.app/v2/internal/locale"
-
-	"github.com/gorilla/mux"
 )
 
 //go:embed templates/common/*.html
@@ -27,10 +25,10 @@ type Engine struct {
 }
 
 // NewEngine returns a new template engine.
-func NewEngine(router *mux.Router) *Engine {
+func NewEngine(basePath string) *Engine {
 	return &Engine{
 		templates: make(map[string]*template.Template),
-		funcMap:   &funcMap{router},
+		funcMap:   &funcMap{basePath},
 	}
 }
 

+ 8 - 8
internal/template/functions.go

@@ -17,18 +17,15 @@ import (
 
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/crypto"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/mediaproxy"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/timezone"
 	"miniflux.app/v2/internal/urllib"
-
-	"github.com/gorilla/mux"
 )
 
 type funcMap struct {
-	router *mux.Router
+	basePath string
 }
 
 // Map returns a map of template functions that are compiled during template parsing.
@@ -52,8 +49,11 @@ func (f *funcMap) Map() template.FuncMap {
 		"hasAuthProxy": func() bool {
 			return config.Opts.AuthProxyHeader() != ""
 		},
-		"route": func(name string, args ...any) string {
-			return route.Path(f.router, name, args...)
+		"routePath": func(format string, args ...any) string {
+			if len(args) > 0 {
+				return f.basePath + fmt.Sprintf(format, args...)
+			}
+			return f.basePath + format
 		},
 		"safeURL": func(url string) template.URL {
 			return template.URL(url)
@@ -90,8 +90,8 @@ func (f *funcMap) Map() template.FuncMap {
 		"theme_color": model.ThemeColor,
 		"icon": func(iconName string) template.HTML {
 			return template.HTML(fmt.Sprintf(
-				`<svg class="icon" aria-hidden="true"><use href="%s#icon-%s"/></svg>`,
-				route.Path(f.router, "appIcon", "filename", "sprite.svg"),
+				`<svg class="icon" aria-hidden="true"><use href="%s/icon/sprite.svg#icon-%s"/></svg>`,
+				f.basePath,
 				iconName,
 			))
 		},

+ 8 - 8
internal/template/templates/common/feed_list.html

@@ -8,9 +8,9 @@
         >
             <header class="item-header" dir="auto">
                 <h2 id="feed-title-{{ .ID }}" class="item-title">
-                    <a href="{{ route "feedEntries" "feedID" .ID }}">
+                    <a href="{{ routePath "/feed/%d/entries" .ID }}">
                         {{ if and (.Icon) (gt .Icon.IconID 0) }}
-                        <img src="{{ route "feedIcon" "externalIconID" .Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="">
+                        <img src="{{ routePath "/feed-icon/%s" .Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="">
                         {{ end }}
                         {{ if .Disabled }} 🚫 {{ end }}
                         {{ .Title }}
@@ -25,7 +25,7 @@
                 </span>
                 <span class="category">
                     <a id="feed-category-{{ .ID }}"
-                       href="{{ route "categoryEntries" "categoryID" .Category.ID }}"
+                       href="{{ routePath "/category/%d/entries" .Category.ID }}"
                        aria-label="{{ t "page.category_label" .Category.Title }}"
                     >
                         {{ .Category.Title }}
@@ -51,12 +51,12 @@
                 </ul>
                 <ul class="item-meta-icons">
                     <li class="item-meta-icons-refresh">
-                        <a href="{{ route "refreshFeed" "feedID" .ID }}" aria-describedby="feed-title-{{ .ID }}">
+                        <a href="{{ routePath "/feed/%d/refresh" .ID }}" aria-describedby="feed-title-{{ .ID }}">
                             {{ icon "refresh" }}<span class="icon-label">{{ t "menu.refresh_feed" }}</span>
                         </a>
                     </li>
                     <li class="item-meta-icons-edit">
-                        <a href="{{ route "editFeed" "feedID" .ID }}" aria-describedby="feed-title-{{ .ID }}">
+                        <a href="{{ routePath "/feed/%d/edit" .ID }}" aria-describedby="feed-title-{{ .ID }}">
                             {{ icon "edit" }}<span class="icon-label">{{ t "menu.edit_feed" }}</span>
                         </a>
                     </li>
@@ -69,9 +69,9 @@
                             data-label-no="{{ t "confirm.no" }}"
                             data-label-loading="{{ t "confirm.loading" }}"
                             {{ if $.categoryID }}
-                                data-url="{{ route "removeCategoryFeed" "categoryID" $.categoryID "feedID" .ID }}"
+                                data-url="{{ routePath "/category/%d/feed/%d/remove" $.categoryID .ID }}"
                             {{ else }}
-                                data-url="{{ route "removeFeed" "feedID" .ID }}"
+                                data-url="{{ routePath "/feed/%d/remove" .ID }}"
                             {{ end }}>{{ icon "delete" }}<span class="icon-label">{{ t "action.remove" }}</span></button>
                     </li>
                     {{ if .UnreadCount }}
@@ -83,7 +83,7 @@
                             data-label-yes="{{ t "confirm.yes" }}"
                             data-label-no="{{ t "confirm.no" }}"
                             data-label-loading="{{ t "confirm.loading" }}"
-                            data-url="{{ route "markFeedAsRead" "feedID" .ID }}">
+                            data-url="{{ routePath "/feed/%d/mark-all-as-read" .ID }}">
                                 {{ icon "read" }}<span class="icon-label">{{ t "menu.mark_all_as_read" }}</span>
                         </button>
                     </li>

+ 5 - 5
internal/template/templates/common/feed_menu.html

@@ -1,19 +1,19 @@
 {{ define "feed_menu" }}
 <nav aria-label="{{ t "page.feeds.title" }} {{ t "menu.title" }}"><ul>
     <li>
-        <a class="page-link" href="{{ route "feeds" }}">{{ icon "feeds" }}{{ t "menu.feeds" }}</a>
+        <a class="page-link" href="{{ routePath "/feeds" }}">{{ icon "feeds" }}{{ t "menu.feeds" }}</a>
     </li>
     <li>
-        <a class="page-link" href="{{ route "addSubscription" }}">{{ icon "add-feed" }}{{ t "menu.add_feed" }}</a>
+        <a class="page-link" href="{{ routePath "/subscribe" }}">{{ icon "add-feed" }}{{ t "menu.add_feed" }}</a>
     </li>
     <li>
-        <a class="page-link" href="{{ route "export" }}">{{ icon "feed-export" }}{{ t "menu.export" }}</a>
+        <a class="page-link" href="{{ routePath "/export" }}">{{ icon "feed-export" }}{{ t "menu.export" }}</a>
     </li>
     <li>
-        <a class="page-link" href="{{ route "import" }}">{{ icon "feed-import" }}{{ t "menu.import" }}</a>
+        <a class="page-link" href="{{ routePath "/import" }}">{{ icon "feed-import" }}{{ t "menu.import" }}</a>
     </li>
     <li>
-        <form action="{{ route "refreshAllFeeds" }}" class="page-header-action-form">
+        <form action="{{ routePath "/feeds/refresh" }}" class="page-header-action-form">
             <button class="page-button" data-label-loading="{{ t "confirm.loading" }}">
                 {{ icon "refresh" }}{{ t "menu.refresh_all_feeds" }}
             </button>

+ 5 - 5
internal/template/templates/common/item_meta.html

@@ -2,7 +2,7 @@
 <div class="item-meta">
     <ul class="item-meta-info">
         <li class="item-meta-info-title">
-            <a href="{{ route "feedEntries" "feedID" .entry.Feed.ID }}" title="{{ .entry.Feed.SiteURL }}" data-feed-link="true">{{ truncate .entry.Feed.Title 35 }}</a>
+            <a href="{{ routePath "/feed/%d/entries" .entry.Feed.ID }}" title="{{ .entry.Feed.SiteURL }}" data-feed-link="true">{{ truncate .entry.Feed.Title 35 }}</a>
         </li>
         <li class="item-meta-info-timestamp">
             <time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed .user.Timezone .entry.Date }}</time>
@@ -29,7 +29,7 @@
             <button
                 aria-describedby="entry-title-{{ .entry.ID }}"
                 data-toggle-starred="true"
-                data-star-url="{{ route "toggleStarred" "entryID" .entry.ID }}"
+                data-star-url="{{ routePath "/entry/star/%d" .entry.ID }}"
                 data-label-loading="{{ t "entry.state.saving" }}"
                 data-label-star="{{ t "entry.starred.toggle.on" }}"
                 data-label-unstar="{{ t "entry.starred.toggle.off" }}"
@@ -38,7 +38,7 @@
         </li>
         {{ if .entry.ShareCode }}
             <li class="item-meta-icons-share">
-                <a href="{{ route "sharedEntry" "shareCode" .entry.ShareCode }}"
+                <a href="{{ routePath "/share/%s" .entry.ShareCode }}"
                     aria-describedby="entry-title-{{ .entry.ID }}"
                     title="{{ t "entry.shared_entry.title" }}"
                     {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ end }}>{{ icon "share" }}<span class="icon-label">{{ t "entry.shared_entry.label" }}</span></a>
@@ -47,7 +47,7 @@
                 <button
                     aria-describedby="entry-title-{{ .entry.ID }}"
                     data-confirm="true"
-                    data-url="{{ route "unshareEntry" "entryID" .entry.ID }}"
+                    data-url="{{ routePath "/entry/unshare/%d" .entry.ID }}"
                     data-label-question="{{ t "confirm.question" }}"
                     data-label-yes="{{ t "confirm.yes" }}"
                     data-label-no="{{ t "confirm.no" }}"
@@ -60,7 +60,7 @@
                     aria-describedby="entry-title-{{ .entry.ID }}"
                     title="{{ t "entry.save.title" }}"
                     data-save-entry="true"
-                    data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}"
+                    data-save-url="{{ routePath "/entry/save/%d" .entry.ID }}"
                     data-label-loading="{{ t "entry.state.saving" }}"
                     data-label-done="{{ t "entry.save.completed" }}"
                     >{{ icon "save" }}<span class="icon-label">{{ t "entry.save.label" }}</span></button>

+ 30 - 30
internal/template/templates/common/layout.html

@@ -14,21 +14,21 @@
     <meta name="theme-color" content="{{ theme_color .theme "light" }}" media="(prefers-color-scheme: light)">
     <meta name="theme-color" content="{{ theme_color .theme "dark" }}" media="(prefers-color-scheme: dark)">
 
-    <link rel="manifest" href="{{ route "webManifest" }}" crossorigin="use-credentials">
+    <link rel="manifest" href="{{ routePath "/manifest.json" }}" crossorigin="use-credentials">
 
-    <link rel="icon" type="image/png" sizes="16x16" href="{{ route "appIcon" "filename" "icon-16.png" }}">
-    <link rel="icon" type="image/png" sizes="32x32" href="{{ route "appIcon" "filename" "icon-32.png" }}">
-    <link rel="icon" type="image/png" sizes="128x128" href="{{ route "appIcon" "filename" "icon-128.png" }}">
-    <link rel="icon" type="image/png" sizes="192x192" href="{{ route "appIcon" "filename" "icon-192.png" }}">
-    <link rel="apple-touch-icon" sizes="120x120" href="{{ route "appIcon" "filename" "icon-120.png" }}">
-    <link rel="apple-touch-icon" sizes="152x152" href="{{ route "appIcon" "filename" "icon-152.png" }}">
-    <link rel="apple-touch-icon" sizes="167x167" href="{{ route "appIcon" "filename" "icon-167.png" }}">
-    <link rel="apple-touch-icon" sizes="180x180" href="{{ route "appIcon" "filename" "icon-180.png" }}">
+    <link rel="icon" type="image/png" sizes="16x16" href="{{ routePath "/icon/%s" "icon-16.png" }}">
+    <link rel="icon" type="image/png" sizes="32x32" href="{{ routePath "/icon/%s" "icon-32.png" }}">
+    <link rel="icon" type="image/png" sizes="128x128" href="{{ routePath "/icon/%s" "icon-128.png" }}">
+    <link rel="icon" type="image/png" sizes="192x192" href="{{ routePath "/icon/%s" "icon-192.png" }}">
+    <link rel="apple-touch-icon" sizes="120x120" href="{{ routePath "/icon/%s" "icon-120.png" }}">
+    <link rel="apple-touch-icon" sizes="152x152" href="{{ routePath "/icon/%s" "icon-152.png" }}">
+    <link rel="apple-touch-icon" sizes="167x167" href="{{ routePath "/icon/%s" "icon-167.png" }}">
+    <link rel="apple-touch-icon" sizes="180x180" href="{{ routePath "/icon/%s" "icon-180.png" }}">
 
     {{ $cspNonce := nonce }}
     {{ csp .user $cspNonce | safeHTML }}
-    <link rel="stylesheet" nonce="{{ $cspNonce }}" type="text/css" href="{{ route "stylesheet" "name" .theme "checksum" .theme_checksum }}">
-    <script nonce="{{ $cspNonce }}" src="{{ route "javascript" "name" "app" "checksum" .app_js_checksum }}" type="module"></script>
+    <link rel="stylesheet" nonce="{{ $cspNonce }}" type="text/css" href="{{ routePath "/stylesheets/%s.%s.css" .theme .theme_checksum }}">
+    <script nonce="{{ $cspNonce }}" src="{{ routePath "/%s.%s.js" "app" .app_js_checksum }}" type="module"></script>
     {{ if .user -}}
         {{ if .user.Stylesheet -}}
         <style nonce="{{ $cspNonce }}">{{ .user.Stylesheet | safeCSS }}</style>
@@ -39,17 +39,17 @@
     {{ end -}}
 </head>
 <body
-    data-service-worker-url="{{ route "javascript" "name" "service-worker" "checksum" .sw_js_checksum }}"
+    data-service-worker-url="{{ routePath "/%s.%s.js" "service-worker" .sw_js_checksum }}"
     {{ if .csrf }}data-csrf-token="{{ .csrf }}"{{ end }}
-    data-add-subscription-url="{{ route "addSubscription" }}"
-    data-entries-status-url="{{ route "updateEntriesStatus" }}"
-    data-refresh-all-feeds-url="{{ route "refreshAllFeeds" }}"
+    data-add-subscription-url="{{ routePath "/subscribe" }}"
+    data-entries-status-url="{{ routePath "/entry/status" }}"
+    data-refresh-all-feeds-url="{{ routePath "/feeds/refresh" }}"
     {{ if .webAuthnEnabled }}
-    data-webauthn-register-begin-url="{{ route "webauthnRegisterBegin" }}"
-    data-webauthn-register-finish-url="{{ route "webauthnRegisterFinish" }}"
-    data-webauthn-login-begin-url="{{ route "webauthnLoginBegin" }}"
-    data-webauthn-login-finish-url="{{ route "webauthnLoginFinish" }}"
-    data-webauthn-delete-all-url="{{ route "webauthnDeleteAll" }}"
+    data-webauthn-register-begin-url="{{ routePath "/webauthn/register/begin" }}"
+    data-webauthn-register-finish-url="{{ routePath "/webauthn/register/finish" }}"
+    data-webauthn-login-begin-url="{{ routePath "/webauthn/login/begin" }}"
+    data-webauthn-login-finish-url="{{ routePath "/webauthn/login/finish" }}"
+    data-webauthn-delete-all-url="{{ routePath "/webauthn/deleteall" }}"
     {{ end }}
     {{ if .user }}
         {{ if not .user.KeyboardShortcuts }}data-disable-keyboard-shortcuts="true"{{ end }}
@@ -61,14 +61,14 @@
     <header class="header">
         <nav>
             <div class="logo" data-toggle-button-label="{{ t "menu.title" }}">
-                <a aria-label="{{ t "menu.home_page" }}" href="{{ route .user.DefaultHomePage }}">
+                <a aria-label="{{ t "menu.home_page" }}" href="{{ routePath (printf "/%s" .user.DefaultHomePage) }}">
                     Mini<span>flux</span>
                 </a>
                 {{ icon "chevron-down"}}
             </div>
             <ul id="header-menu">
                 <li {{ if eq .menu "unread" }}class="active"{{ end }} title="{{ t "tooltip.keyboard_shortcuts" "g u" }}">
-                    <a href="{{ route "unread" }}"
+                    <a href="{{ routePath "/unread" }}"
                         data-page="unread"
                         {{ if gt .countUnread 0 }}
                         aria-label="{{ t "menu.unread" }}, {{ plural "page.unread_entry_count" .countUnread .countUnread }}"
@@ -81,33 +81,33 @@
                     </a>
                 </li>
                 <li {{ if eq .menu "starred" }}class="active"{{ end }} title="{{ t "tooltip.keyboard_shortcuts" "g b" }}">
-                    <a href="{{ route "starred" }}" data-page="starred">{{ icon "star" }}{{ t "menu.starred" }}</a>
+                    <a href="{{ routePath "/starred" }}" data-page="starred">{{ icon "star" }}{{ t "menu.starred" }}</a>
                 </li>
                 <li {{ if eq .menu "history" }}class="active"{{ end }} title="{{ t "tooltip.keyboard_shortcuts" "g h" }}">
-                    <a href="{{ route "history" }}" data-page="history">{{ icon "history" }}{{ t "menu.history" }}</a>
+                    <a href="{{ routePath "/history" }}" data-page="history">{{ icon "history" }}{{ t "menu.history" }}</a>
                 </li>
                 <li {{ if eq .menu "feeds" }}class="active"{{ end }} title="{{ t "tooltip.keyboard_shortcuts" "g f" }}">
-                    <a href="{{ route "feeds" }}" data-page="feeds">{{ icon "feeds" }}{{ t "menu.feeds" }}
+                    <a href="{{ routePath "/feeds" }}" data-page="feeds">{{ icon "feeds" }}{{ t "menu.feeds" }}
                       {{ if gt .countErrorFeeds 0 }}
                           <span class="error-feeds-counter-wrapper">(<span class="error-feeds-counter">{{ .countErrorFeeds }}</span>)</span>
                       {{ end }}
                     </a>
-                    <a href="{{ route "addSubscription" }}" title="{{ t "tooltip.keyboard_shortcuts" "+" }}" aria-label="{{ t "menu.add_feed" }}">
+                    <a href="{{ routePath "/subscribe" }}" title="{{ t "tooltip.keyboard_shortcuts" "+" }}" aria-label="{{ t "menu.add_feed" }}">
                         (+)
                     </a>
                 </li>
                 <li {{ if eq .menu "categories" }}class="active"{{ end }} title="{{ t "tooltip.keyboard_shortcuts" "g c" }}">
-                    <a href="{{ route "categories" }}" data-page="categories">{{ icon "categories" }}{{ t "menu.categories" }}</a>
+                    <a href="{{ routePath "/categories" }}" data-page="categories">{{ icon "categories" }}{{ t "menu.categories" }}</a>
                 </li>
                 <li {{ if eq .menu "search" }}class="active"{{ end }} title="{{ t "tooltip.keyboard_shortcuts" "/" }}">
-                    <a href="{{ route "search" }}" data-page="search">{{ icon "search" }}{{ t "menu.search" }}</a>
+                    <a href="{{ routePath "/search" }}" data-page="search">{{ icon "search" }}{{ t "menu.search" }}</a>
                 </li>
                 <li {{ if eq .menu "settings" }}class="active"{{ end }} title="{{ t "tooltip.keyboard_shortcuts" "g s" }}">
-                    <a href="{{ route "settings" }}" data-page="settings">{{ icon "settings" }}{{ t "menu.settings" }}</a>
+                    <a href="{{ routePath "/settings" }}" data-page="settings">{{ icon "settings" }}{{ t "menu.settings" }}</a>
                 </li>
                 {{ if not hasAuthProxy }}
                     <li>
-                        <a href="{{ route "logout" }}" title="{{ t "tooltip.logged_user" .user.Username }}">{{ icon "logout" }}{{ t "menu.logout" }}</a>
+                        <a href="{{ routePath "/logout" }}" title="{{ t "tooltip.logged_user" .user.Username }}">{{ icon "logout" }}{{ t "menu.logout" }}</a>
                     </li>
                 {{ end }}
             </ul>

+ 6 - 6
internal/template/templates/common/settings_menu.html

@@ -2,26 +2,26 @@
 <nav aria-label="{{ t "page.settings.title" }} {{ t "menu.title" }}">
     <ul>
         <li>
-            <a href="{{ route "settings" }}">{{ icon "settings" }}{{ t "menu.settings" }}</a>
+            <a href="{{ routePath "/settings" }}">{{ icon "settings" }}{{ t "menu.settings" }}</a>
         </li>
         <li>
-            <a href="{{ route "integrations" }}">{{ icon "third-party-services" }}{{ t "menu.integrations" }}</a>
+            <a href="{{ routePath "/integrations" }}">{{ icon "third-party-services" }}{{ t "menu.integrations" }}</a>
         </li>
         {{ if apiEnabled }}
         <li>
-            <a href="{{ route "apiKeys" }}">{{ icon "api" }}{{ t "menu.api_keys" }}</a>
+            <a href="{{ routePath "/keys" }}">{{ icon "api" }}{{ t "menu.api_keys" }}</a>
         </li>
         {{ end }}
         <li>
-            <a href="{{ route "sessions" }}">{{ icon "sessions" }}{{ t "menu.sessions" }}</a>
+            <a href="{{ routePath "/sessions" }}">{{ icon "sessions" }}{{ t "menu.sessions" }}</a>
         </li>
         {{ if .user.IsAdmin }}
             <li>
-                <a href="{{ route "users" }}">{{ icon "users" }}{{ t "menu.users" }}</a>
+                <a href="{{ routePath "/users" }}">{{ icon "users" }}{{ t "menu.users" }}</a>
             </li>
         {{ end }}
         <li>
-            <a href="{{ route "about" }}">{{ icon "about" }}{{ t "menu.about" }}</a>
+            <a href="{{ routePath "/about" }}">{{ icon "about" }}{{ t "menu.about" }}</a>
         </li>
     </ul>
 </nav>

+ 1 - 1
internal/template/templates/views/add_subscription.html

@@ -11,7 +11,7 @@
 {{ if not .categories }}
     <p role="alert" class="alert alert-error">{{ t "page.add_feed.no_category" }}</p>
 {{ else }}
-    <form action="{{ route "submitSubscription" }}" method="post" autocomplete="off">
+    <form action="{{ routePath "/subscribe" }}" method="post" autocomplete="off">
         <input type="hidden" name="csrf" value="{{ .csrf }}">
 
         {{ if .errorMessage }}

+ 2 - 2
internal/template/templates/views/api_keys.html

@@ -44,7 +44,7 @@
                 data-label-yes="{{ t "confirm.yes" }}"
                 data-label-no="{{ t "confirm.no" }}"
                 data-label-loading="{{ t "confirm.loading" }}"
-                data-url="{{ route "deleteAPIKey" "keyID" .ID }}">{{ t "action.remove" }}</a>
+                data-url="{{ routePath "/keys/%d/delete" .ID }}">{{ t "action.remove" }}</a>
         </td>
     </tr>
     </table>
@@ -68,7 +68,7 @@
 </div>
 
 <p>
-    <a href="{{ route "createAPIKey" }}" class="button button-primary">{{ t "menu.create_api_key" }}</a>
+    <a href="{{ routePath "/keys/create" }}" class="button button-primary">{{ t "menu.create_api_key" }}</a>
 </p>
 
 {{ end }}

+ 7 - 7
internal/template/templates/views/categories.html

@@ -10,7 +10,7 @@
     <nav aria-label="{{ t "page.categories.title" }} {{ t "menu.title" }}">
         <ul>
             <li>
-                <a href="{{ route "createCategory" }}">{{ icon "add-category" }}{{ t "menu.create_category" }}</a>
+                <a href="{{ routePath "/category/create" }}">{{ icon "add-category" }}{{ t "menu.create_category" }}</a>
             </li>
         </ul>
     </nav>
@@ -30,7 +30,7 @@
         >
             <header id="category-title-{{ .ID }}"  class="item-header" dir="auto">
                 <h2 class="item-title">
-                    <a href="{{ route "categoryEntries" "categoryID" .ID }}">
+                    <a href="{{ routePath "/category/%d/entries" .ID }}">
                         {{ .Title }}
                         <span class="category-item-total" aria-hidden="true">({{ .TotalUnread }})</span>
                         <span class="sr-only">{{ plural "page.unread_entry_count" (deRef .TotalUnread) (deRef .TotalUnread) }}</span>
@@ -45,13 +45,13 @@
                 </ul>
                 <ul class="item-meta-icons">
                     <li class="item-meta-icons-entries">
-                        <a href="{{ route "categoryEntries" "categoryID" .ID }}">{{ icon "entries" }}<span class="icon-label">{{ t "page.categories.entries" }}</span></a>
+                        <a href="{{ routePath "/category/%d/entries" .ID }}">{{ icon "entries" }}<span class="icon-label">{{ t "page.categories.entries" }}</span></a>
                     </li>
                     <li class="item-meta-icons-feeds">
-                        <a href="{{ route "categoryFeeds" "categoryID" .ID }}">{{ icon "feeds" }}<span class="icon-label">{{ t "page.categories.feeds" }}</span></a>
+                        <a href="{{ routePath "/category/%d/feeds" .ID }}">{{ icon "feeds" }}<span class="icon-label">{{ t "page.categories.feeds" }}</span></a>
                     </li>
                     <li class="item-meta-icons-edit">
-                        <a href="{{ route "editCategory" "categoryID" .ID }}">{{ icon "edit" }}<span class="icon-label">{{ t "menu.edit_category" }}</span></a>
+                        <a href="{{ routePath "/category/%d/edit" .ID }}">{{ icon "edit" }}<span class="icon-label">{{ t "menu.edit_category" }}</span></a>
                     </li>
                     {{ if eq (deRef .FeedCount) 0 }}
                     <li class="item-meta-icons-delete">
@@ -62,7 +62,7 @@
                             data-label-yes="{{ t "confirm.yes" }}"
                             data-label-no="{{ t "confirm.no" }}"
                             data-label-loading="{{ t "confirm.loading" }}"
-                            data-url="{{ route "removeCategory" "categoryID" .ID }}">{{ icon "delete" }}<span class="icon-label">{{ t "action.remove" }}</span></button>
+                            data-url="{{ routePath "/category/%d/remove" .ID }}">{{ icon "delete" }}<span class="icon-label">{{ t "action.remove" }}</span></button>
                     </li>
                     {{ end }}
                     {{ if gt (deRef .TotalUnread) 0 }}
@@ -74,7 +74,7 @@
                             data-label-yes="{{ t "confirm.yes" }}"
                             data-label-no="{{ t "confirm.no" }}"
                             data-label-loading="{{ t "confirm.loading" }}"
-                            data-url="{{ route "markCategoryAsRead" "categoryID" .ID }}">{{ icon "read" }}<span class="icon-label">{{ t "menu.mark_all_as_read" }}</span></button>
+                            data-url="{{ routePath "/category/%d/mark-all-as-read" .ID }}">{{ icon "read" }}<span class="icon-label">{{ t "menu.mark_all_as_read" }}</span></button>
                       </li>
                     {{ end }}
                 </ul>

+ 14 - 14
internal/template/templates/views/category_entries.html

@@ -34,37 +34,37 @@
                     data-label-yes="{{ t "confirm.yes" }}"
                     data-label-no="{{ t "confirm.no" }}"
                     data-label-loading="{{ t "confirm.loading" }}"
-                    data-url="{{ route "markCategoryAsRead" "categoryID" .category.ID }}">{{ icon "mark-all-as-read" }}{{ t "menu.mark_all_as_read" }}</button>
+                    data-url="{{ routePath "/category/%d/mark-all-as-read" .category.ID }}">{{ icon "mark-all-as-read" }}{{ t "menu.mark_all_as_read" }}</button>
             </li>
             {{ end }}
             {{ if .showOnlyUnreadEntries }}
             <li>
-                <a class="page-link" href="{{ route "categoryEntriesAll" "categoryID" .category.ID }}">{{ icon "show-all-entries" }}{{ t "menu.show_all_entries" }}</a>
+                <a class="page-link" href="{{ routePath "/category/%d/entries/all" .category.ID }}">{{ icon "show-all-entries" }}{{ t "menu.show_all_entries" }}</a>
             </li>
             <li>
-                <a class="page-link" href="{{ route "categoryEntriesStarred" "categoryID" .category.ID }}">{{ icon "star" }}{{ t "menu.show_only_starred_entries" }}</a>
+                <a class="page-link" href="{{ routePath "/category/%d/entries/starred" .category.ID }}">{{ icon "star" }}{{ t "menu.show_only_starred_entries" }}</a>
             </li>
             {{ else if .showOnlyStarredEntries }}
             <li>
-                <a class="page-link" href="{{ route "categoryEntries" "categoryID" .category.ID }}">{{ icon "show-unread-entries" }}{{ t "menu.show_only_unread_entries" }}</a>
+                <a class="page-link" href="{{ routePath "/category/%d/entries" .category.ID }}">{{ icon "show-unread-entries" }}{{ t "menu.show_only_unread_entries" }}</a>
             </li>
             <li>
-                <a class="page-link" href="{{ route "categoryEntriesAll" "categoryID" .category.ID }}">{{ icon "show-all-entries" }}{{ t "menu.show_all_entries" }}</a>
+                <a class="page-link" href="{{ routePath "/category/%d/entries/all" .category.ID }}">{{ icon "show-all-entries" }}{{ t "menu.show_all_entries" }}</a>
             </li>
             {{ else }}
             <li>
-                <a class="page-link" href="{{ route "categoryEntries" "categoryID" .category.ID }}">{{ icon "show-unread-entries" }}{{ t "menu.show_only_unread_entries" }}</a>
+                <a class="page-link" href="{{ routePath "/category/%d/entries" .category.ID }}">{{ icon "show-unread-entries" }}{{ t "menu.show_only_unread_entries" }}</a>
             </li>
             <li>
-                <a class="page-link" href="{{ route "categoryEntriesStarred" "categoryID" .category.ID }}">{{ icon "star" }}{{ t "menu.show_only_starred_entries" }}</a>
+                <a class="page-link" href="{{ routePath "/category/%d/entries/starred" .category.ID }}">{{ icon "star" }}{{ t "menu.show_only_starred_entries" }}</a>
             </li>
             {{ end }}
             <li>
-                <a class="page-link" href="{{ route "categoryFeeds" "categoryID" .category.ID }}">{{ icon "feeds" }}{{ t "menu.feeds" }}</a>
+                <a class="page-link" href="{{ routePath "/category/%d/feeds" .category.ID }}">{{ icon "feeds" }}{{ t "menu.feeds" }}</a>
             </li>
             <li>
                 <form
-                    action="{{ route "refreshCategoryEntriesPage" "categoryID" .category.ID }}"
+                    action="{{ routePath "/category/%d/entries/refresh" .category.ID }}"
                     class="page-header-action-form"
                 >
                     <button class="page-button" data-label-loading="{{ t "confirm.loading" }}">
@@ -96,21 +96,21 @@
                 <h2 id="entry-title-{{ .ID }}" class="item-title">
                     <a
                         {{ if $.showOnlyUnreadEntries }}
-                        href="{{ route "unreadCategoryEntry" "categoryID" .Feed.Category.ID "entryID" .ID }}"
+                        href="{{ routePath "/unread/category/%d/entry/%d" .Feed.Category.ID .ID }}"
                         {{ else if $.showOnlyStarredEntries }}
-                        href="{{ route "starredCategoryEntry" "categoryID" .Feed.Category.ID "entryID" .ID }}"
+                        href="{{ routePath "/starred/category/%d/entry/%d" .Feed.Category.ID .ID }}"
                         {{ else }}
-                        href="{{ route "categoryEntry" "categoryID" .Feed.Category.ID "entryID" .ID }}"
+                        href="{{ routePath "/category/%d/entry/%d" .Feed.Category.ID .ID }}"
                         {{ end }}
                     >
                         {{ if ne .Feed.Icon.IconID 0 }}
-                            <img src="{{ route "feedIcon" "externalIconID" .Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="">
+                            <img src="{{ routePath "/feed-icon/%s" .Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="">
                         {{ end }}
                         {{ .Title }}
                     </a>
                 </h2>
                 <span class="category">
-                    <a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}" aria-label="{{ t "page.category_label" .Feed.Category.Title }}">
+                    <a href="{{ routePath "/category/%d/entries" .Feed.Category.ID }}" aria-label="{{ t "page.category_label" .Feed.Category.Title }}">
                         {{ .Feed.Category.Title }}
                     </a>
                 </span>

+ 5 - 5
internal/template/templates/views/category_feeds.html

@@ -10,10 +10,10 @@
     <nav aria-label="{{ .category.Title }} {{ t "page.feeds.title" }} {{ t "menu.title" }}">
         <ul>
             <li>
-                <a class="page-link" href="{{ route "categoryEntries" "categoryID" .category.ID }}">{{ icon "entries" }}{{ t "menu.feed_entries" }}</a>
+                <a class="page-link" href="{{ routePath "/category/%d/entries" .category.ID }}">{{ icon "entries" }}{{ t "menu.feed_entries" }}</a>
             </li>
             <li>
-                <a class="page-link" href="{{ route "editCategory" "categoryID" .category.ID }}">{{ icon "edit" }}{{ t "menu.edit_category" }}</a>
+                <a class="page-link" href="{{ routePath "/category/%d/edit" .category.ID }}">{{ icon "edit" }}{{ t "menu.edit_category" }}</a>
             </li>
             {{ if eq .total 0 }}
             <li>
@@ -24,8 +24,8 @@
                     data-label-yes="{{ t "confirm.yes" }}"
                     data-label-no="{{ t "confirm.no" }}"
                     data-label-loading="{{ t "confirm.loading" }}"
-                    data-redirect-url="{{ route "categories" }}"
-                    data-url="{{ route "removeCategory" "categoryID" .category.ID }}"
+                    data-redirect-url="{{ routePath "/categories" }}"
+                    data-url="{{ routePath "/category/%d/remove" .category.ID }}"
                 >
                     {{ icon "delete" }}{{ t "action.remove" }}
                 </button>
@@ -34,7 +34,7 @@
             <li>
                 <form
                     class="page-header-action-form"
-                    action="{{ route "refreshCategoryFeedsPage" "categoryID" .category.ID }}"
+                    action="{{ routePath "/category/%d/feeds/refresh" .category.ID }}"
                 >
                     <button
                         class="page-button"

+ 1 - 1
internal/template/templates/views/choose_subscription.html

@@ -8,7 +8,7 @@
 {{ end }}
 
 {{ define "content"}}
-<form action="{{ route "chooseSubscription" }}" method="POST">
+<form action="{{ routePath "/subscriptions" }}" method="POST">
     <input type="hidden" name="csrf" value="{{ .csrf }}">
     <input type="hidden" name="category_id" value="{{ .form.CategoryID }}">
     <input type="hidden" name="user_agent" value="{{ .form.UserAgent }}">

+ 2 - 2
internal/template/templates/views/create_api_key.html

@@ -8,7 +8,7 @@
 {{ end }}
 
 {{ define "content"}}
-<form action="{{ route "saveAPIKey" }}" method="post" autocomplete="off">
+<form action="{{ routePath "/keys/save" }}" method="post" autocomplete="off">
     <input type="hidden" name="csrf" value="{{ .csrf }}">
 
     {{ if .errorMessage }}
@@ -19,7 +19,7 @@
     <input type="text" name="description" id="form-description" value="{{ .form.Description }}" spellcheck="false" required autofocus>
 
     <div class="buttons">
-        <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.save" }}</button> {{ t "action.or" }} <a href="{{ route "apiKeys" }}">{{ t "action.cancel" }}</a>
+        <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.save" }}</button> {{ t "action.or" }} <a href="{{ routePath "/keys" }}">{{ t "action.cancel" }}</a>
     </div>
 </form>
 {{ end }}

+ 3 - 3
internal/template/templates/views/create_category.html

@@ -6,7 +6,7 @@
     <nav aria-label="{{ t "page.new_category.title" }} {{ t "menu.title" }}">
         <ul>
             <li>
-                <a href="{{ route "categories" }}">{{ icon "categories" }}{{ t "menu.categories" }}</a>
+                <a href="{{ routePath "/categories" }}">{{ icon "categories" }}{{ t "menu.categories" }}</a>
             </li>
         </ul>
     </nav>
@@ -14,7 +14,7 @@
 {{ end }}
 
 {{ define "content"}}
-<form action="{{ route "saveCategory" }}" method="post" autocomplete="off">
+<form action="{{ routePath "/category/save" }}" method="post" autocomplete="off">
     <input type="hidden" name="csrf" value="{{ .csrf }}">
 
     {{ if .errorMessage }}
@@ -25,7 +25,7 @@
     <input type="text" name="title" id="form-title" value="{{ .form.Title }}" required autofocus>
 
     <div class="buttons">
-        <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.save" }}</button> {{ t "action.or" }} <a href="{{ route "categories" }}">{{ t "action.cancel" }}</a>
+        <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.save" }}</button> {{ t "action.or" }} <a href="{{ routePath "/categories" }}">{{ t "action.cancel" }}</a>
     </div>
 </form>
 {{ end }}

+ 2 - 2
internal/template/templates/views/create_user.html

@@ -8,7 +8,7 @@
 {{ end }}
 
 {{ define "content"}}
-<form action="{{ route "saveUser" }}" method="post" autocomplete="off">
+<form action="{{ routePath "/user/save" }}" method="post" autocomplete="off">
     <input type="hidden" name="csrf" value="{{ .csrf }}">
 
     {{ if .errorMessage }}
@@ -27,7 +27,7 @@
     <label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked{{ end }}> {{ t "form.user.label.admin" }}</label>
 
     <div class="buttons">
-        <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.save" }}</button> {{ t "action.or" }} <a href="{{ route "users" }}">{{ t "action.cancel" }}</a>
+        <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.save" }}</button> {{ t "action.or" }} <a href="{{ routePath "/users" }}">{{ t "action.cancel" }}</a>
     </div>
 </form>
 {{ end }}

+ 4 - 4
internal/template/templates/views/edit_category.html

@@ -6,13 +6,13 @@
     <nav aria-label="{{ t "page.edit_category.title" .category.Title }} {{ t "menu.title" }}">
         <ul>
             <li>
-                <a href="{{ route "categories" }}">{{ icon "categories" }}{{ t "menu.categories" }}</a>
+                <a href="{{ routePath "/categories" }}">{{ icon "categories" }}{{ t "menu.categories" }}</a>
             </li>
             <li>
-                <a href="{{ route "categoryFeeds" "categoryID" .category.ID }}">{{ icon "feeds" }}{{ t "menu.feeds" }}</a>
+                <a href="{{ routePath "/category/%d/feeds" .category.ID }}">{{ icon "feeds" }}{{ t "menu.feeds" }}</a>
             </li>
             <li>
-                <a href="{{ route "createCategory" }}">{{ icon "add-category" }}{{ t "menu.create_category" }}</a>
+                <a href="{{ routePath "/category/create" }}">{{ icon "add-category" }}{{ t "menu.create_category" }}</a>
             </li>
         </ul>
     </nav>
@@ -20,7 +20,7 @@
 {{ end }}
 
 {{ define "content"}}
-<form action="{{ route "updateCategory" "categoryID" .category.ID }}" method="post" autocomplete="off">
+<form action="{{ routePath "/category/%d/update" .category.ID }}" method="post" autocomplete="off">
     <input type="hidden" name="csrf" value="{{ .csrf }}">
 
     {{ if .errorMessage }}

+ 7 - 7
internal/template/templates/views/edit_feed.html

@@ -6,10 +6,10 @@
     <nav aria-label="{{ .feed.Title }} {{ t "menu.title" }}">
         <ul>
             <li>
-                <a href="{{ route "feeds" }}">{{ icon "feeds" }}{{ t "menu.feeds" }}</a>
+                <a href="{{ routePath "/feeds" }}">{{ icon "feeds" }}{{ t "menu.feeds" }}</a>
             </li>
             <li>
-                <a href="{{ route "feedEntries" "feedID" .feed.ID }}">{{ icon "entries" }}{{ t "menu.feed_entries" }}</a>
+                <a href="{{ routePath "/feed/%d/entries" .feed.ID }}">{{ icon "entries" }}{{ t "menu.feed_entries" }}</a>
             </li>
             <li>
                 <a href="#"
@@ -18,8 +18,8 @@
                     data-label-yes="{{ t "confirm.yes" }}"
                     data-label-no="{{ t "confirm.no" }}"
                     data-label-loading="{{ t "confirm.loading" }}"
-                    data-url="{{ route "refreshFeed" "feedID" .feed.ID }}?forceRefresh=true"
-                    data-no-action-url="{{ route "refreshFeed" "feedID" .feed.ID }}?forceRefresh=false">{{ icon "refresh" }}{{ t "menu.refresh_feed" }}</a>
+                    data-url="{{ routePath "/feed/%d/refresh" .feed.ID }}?forceRefresh=true"
+                    data-no-action-url="{{ routePath "/feed/%d/refresh" .feed.ID }}?forceRefresh=false">{{ icon "refresh" }}{{ t "menu.refresh_feed" }}</a>
             </li>
         </ul>
     </nav>
@@ -37,7 +37,7 @@
     </div>
     {{ end }}
 
-    <form action="{{ route "updateFeed" "feedID" .feed.ID }}" method="post" autocomplete="off">
+    <form action="{{ routePath "/feed/%d/update" .feed.ID }}" method="post" autocomplete="off">
         <input type="hidden" name="csrf" value="{{ .csrf }}">
 
         {{ if .errorMessage }}
@@ -299,8 +299,8 @@
             data-label-yes="{{ t "confirm.yes" }}"
             data-label-no="{{ t "confirm.no" }}"
             data-label-loading="{{ t "confirm.loading" }}"
-            data-url="{{ route "removeFeed" "feedID" .feed.ID }}"
-            data-redirect-url="{{ route "feeds" }}">{{ t "action.remove_feed" }}</a>
+            data-url="{{ routePath "/feed/%d/remove" .feed.ID }}"
+            data-redirect-url="{{ routePath "/feeds" }}">{{ t "action.remove_feed" }}</a>
     </div>
 {{ end }}
 

+ 2 - 2
internal/template/templates/views/edit_user.html

@@ -8,7 +8,7 @@
 {{ end }}
 
 {{ define "content"}}
-<form action="{{ route "updateUser" "userID" .selected_user.ID }}" method="post" autocomplete="off">
+<form action="{{ routePath "/users/%d/update" .selected_user.ID }}" method="post" autocomplete="off">
     <input type="hidden" name="csrf" value="{{ .csrf }}">
 
     {{ if .errorMessage }}
@@ -27,7 +27,7 @@
     <label><input type="checkbox" name="is_admin" value="1" {{ if .form.IsAdmin }}checked{{ end }}> {{ t "form.user.label.admin" }}</label>
 
     <div class="buttons">
-        <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button> {{ t "action.or" }} <a href="{{ route "users" }}">{{ t "action.cancel" }}</a>
+        <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button> {{ t "action.or" }} <a href="{{ routePath "/users" }}">{{ t "action.cancel" }}</a>
     </div>
 </form>
 {{ end }}

+ 13 - 13
internal/template/templates/views/entry.html

@@ -67,7 +67,7 @@
                     <button
                         class="page-button"
                         data-toggle-starred="true"
-                        data-star-url="{{ route "toggleStarred" "entryID" .entry.ID }}"
+                        data-star-url="{{ routePath "/entry/star/%d" .entry.ID }}"
                         data-label-loading="{{ t "entry.state.saving" }}"
                         data-label-star="{{ t "entry.starred.toggle.on" }}"
                         data-label-unstar="{{ t "entry.starred.toggle.off" }}"
@@ -82,7 +82,7 @@
                         class="page-button"
                         title="{{ t "entry.save.title" }}"
                         data-save-entry="true"
-                        data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}"
+                        data-save-url="{{ routePath "/entry/save/%d" .entry.ID }}"
                         data-label-loading="{{ t "entry.state.saving" }}"
                         data-label-done="{{ t "entry.save.completed" }}"
                         data-toast-done="{{ t "entry.save.toast.completed" }}"
@@ -91,7 +91,7 @@
                 {{ end }}
                 {{ if .entry.ShareCode }}
                 <li>
-                    <a href="{{ route "sharedEntry" "shareCode" .entry.ShareCode }}"
+                    <a href="{{ routePath "/share/%s" .entry.ShareCode }}"
                         title="{{ t "entry.shared_entry.title" }}"
                         data-share-status="shared"
                         {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ end }}>{{ icon "share" }}<span class="icon-label">{{ t "entry.shared_entry.label" }}</span></a>
@@ -100,7 +100,7 @@
                     <button
                         class="page-button"
                         data-confirm="true"
-                        data-url="{{ route "unshareEntry" "entryID" .entry.ID }}"
+                        data-url="{{ routePath "/entry/unshare/%d" .entry.ID }}"
                         data-label-question="{{ t "confirm.question" }}"
                         data-label-yes="{{ t "confirm.yes" }}"
                         data-label-no="{{ t "confirm.no" }}"
@@ -108,7 +108,7 @@
                 </li>
                 {{ else }}
                 <li>
-                    <form method="post" action="{{route "shareEntry" "entryID" .entry.ID }}">
+                    <form method="post" action="{{ routePath "/entry/share/%d" .entry.ID }}">
                         <input type="hidden" name="csrf" value="{{ .csrf }}">
                         <button type="submit" class="page-button">
                             {{ icon "share" }}<span class="icon-label">{{ t "entry.share.label" }}</span>
@@ -121,7 +121,7 @@
                         class="page-button"
                         title="{{ t "entry.scraper.title" }}"
                         data-fetch-content-entry="true"
-                        data-fetch-content-url="{{ route "fetchContent" "entryID" .entry.ID }}"
+                        data-fetch-content-url="{{ routePath "/entry/download/%d" .entry.ID }}"
                         data-label-loading="{{ t "entry.state.loading" }}"
                         >{{ icon "scraper" }}<span class="icon-label">{{ t "entry.scraper.label" }}</span></button>
                 </li>
@@ -141,10 +141,10 @@
         <div class="entry-meta" dir="auto">
             <span class="entry-website">
                 {{ if ne .entry.Feed.Icon.IconID 0 }}
-                <img src="{{ route "feedIcon" "externalIconID" .entry.Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="{{ .entry.Feed.Title }}">
+                <img src="{{ routePath "/feed-icon/%s" .entry.Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="{{ .entry.Feed.Title }}">
                 {{ end }}
                 {{ if .user }}
-                <a href="{{ route "feedEntries" "feedID" .entry.Feed.ID }}">{{ .entry.Feed.Title }}</a>
+                <a href="{{ routePath "/feed/%d/entries" .entry.Feed.ID }}">{{ .entry.Feed.Title }}</a>
                 {{ else }}
                 <a href="{{ .entry.Feed.SiteURL | safeURL }}">{{ .entry.Feed.Title }}</a>
                 {{ end }}
@@ -160,7 +160,7 @@
             {{ end }}
             {{ if .user }}
             <span class="category">
-                <a href="{{ route "categoryEntries" "categoryID" .entry.Feed.Category.ID }}">{{ .entry.Feed.Category.Title }}</a>
+                <a href="{{ routePath "/category/%d/entries" .entry.Feed.Category.ID }}">{{ .entry.Feed.Category.Title }}</a>
             </span>
             {{ end }}
         </div>
@@ -176,7 +176,7 @@
                 {{ range $i, $tagName := $allTags }}
                     {{ if lt $i $tagsLimit }}
                         {{ if $.user }}
-                            <li><a href="{{ route "tagEntriesAll" "tagName" (urlEncode $tagName) }}"><strong>{{ $tagName }}</strong></a></li>
+                            <li><a href="{{ routePath "/tags/%s/entries/all" (urlEncode $tagName) }}"><strong>{{ $tagName }}</strong></a></li>
                         {{ else }}
                             <li><strong>{{ $tagName }}</strong></li>
                         {{ end }}
@@ -193,7 +193,7 @@
                     {{ range $idx, $tagName := $allTags }}
                         {{ if ge $idx $tagsLimit }}
                             {{ if $.user }}
-                                <li><a href="{{ route "tagEntriesAll" "tagName" (urlEncode $tagName) }}"><strong>{{ $tagName }}</strong></a></li>
+                                <li><a href="{{ routePath "/tags/%s/entries/all" (urlEncode $tagName) }}"><strong>{{ $tagName }}</strong></a></li>
                             {{ else }}
                                 <li><strong>{{ $tagName }}</strong></li>
                             {{ end }}
@@ -248,7 +248,7 @@
                             {{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
                             {{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
                             {{ if $.user.MarkReadOnMediaPlayerCompletion }}data-mark-read-on-completion="0.9"{{ end }}
-                            {{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
+                            {{ if $.user }}data-save-url="{{ routePath "/entry/enclosure/%d/save-progression" .ID }}"{{ end }}
                             data-enclosure-id="{{ .ID }}"
                             >
                             {{ if (and $.user (mustBeProxyfied "audio")) }}
@@ -265,7 +265,7 @@
                             {{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
                             {{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
                             {{ if $.user.MarkReadOnMediaPlayerCompletion }}data-mark-read-on-completion="0.9"{{ end }}
-                            {{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
+                            {{ if $.user }}data-save-url="{{ routePath "/entry/enclosure/%d/save-progression" .ID }}"{{ end }}
                             data-enclosure-id="{{ .ID }}"
                             >
                             {{ if (and $.user (mustBeProxyfied "video")) }}

+ 12 - 12
internal/template/templates/views/feed_entries.html

@@ -34,16 +34,16 @@
                     data-label-yes="{{ t "confirm.yes" }}"
                     data-label-no="{{ t "confirm.no" }}"
                     data-label-loading="{{ t "confirm.loading" }}"
-                    data-url="{{ route "markFeedAsRead" "feedID" .feed.ID }}">{{ icon "mark-all-as-read" }}{{ t "menu.mark_all_as_read" }}</button>
+                    data-url="{{ routePath "/feed/%d/mark-all-as-read" .feed.ID }}">{{ icon "mark-all-as-read" }}{{ t "menu.mark_all_as_read" }}</button>
             </li>
             {{ end }}
             {{ if .showOnlyUnreadEntries }}
             <li>
-                <a class="page-link" href="{{ route "feedEntriesAll" "feedID" .feed.ID }}">{{ icon "show-all-entries" }}{{ t "menu.show_all_entries" }}</a>
+                <a class="page-link" href="{{ routePath "/feed/%d/entries/all" .feed.ID }}">{{ icon "show-all-entries" }}{{ t "menu.show_all_entries" }}</a>
             </li>
             {{ else }}
             <li>
-                <a class="page-link" href="{{ route "feedEntries" "feedID" .feed.ID }}">{{ icon "show-unread-entries" }}{{ t "menu.show_only_unread_entries" }}</a>
+                <a class="page-link" href="{{ routePath "/feed/%d/entries" .feed.ID }}">{{ icon "show-unread-entries" }}{{ t "menu.show_only_unread_entries" }}</a>
             </li>
             {{ end }}
             <li>
@@ -54,11 +54,11 @@
                     data-label-yes="{{ t "confirm.yes" }}"
                     data-label-no="{{ t "confirm.no" }}"
                     data-label-loading="{{ t "confirm.loading" }}"
-                    data-url="{{ route "refreshFeed" "feedID" .feed.ID }}?forceRefresh=true"
-                    data-no-action-url="{{ route "refreshFeed" "feedID" .feed.ID }}?forceRefresh=false">{{ icon "refresh" }}{{ t "menu.refresh_feed" }}</button>
+                    data-url="{{ routePath "/feed/%d/refresh" .feed.ID }}?forceRefresh=true"
+                    data-no-action-url="{{ routePath "/feed/%d/refresh" .feed.ID }}?forceRefresh=false">{{ icon "refresh" }}{{ t "menu.refresh_feed" }}</button>
             </li>
             <li>
-                <a class="page-link" href="{{ route "editFeed" "feedID" .feed.ID }}">{{ icon "edit" }}{{ t "menu.edit_feed" }}</a>
+                <a class="page-link" href="{{ routePath "/feed/%d/edit" .feed.ID }}">{{ icon "edit" }}{{ t "menu.edit_feed" }}</a>
             </li>
             <li>
                 <button
@@ -69,8 +69,8 @@
                     data-label-yes="{{ t "confirm.yes" }}"
                     data-label-no="{{ t "confirm.no" }}"
                     data-label-loading="{{ t "confirm.loading" }}"
-                    data-url="{{ route "removeFeed" "feedID" .feed.ID }}"
-                    data-redirect-url="{{ route "feeds" }}">{{ icon "delete" }}{{ t "action.remove_feed" }}</button>
+                    data-url="{{ routePath "/feed/%d/remove" .feed.ID }}"
+                    data-redirect-url="{{ routePath "/feeds" }}">{{ icon "delete" }}{{ t "action.remove_feed" }}</button>
             </li>
         </ul>
     </nav>
@@ -107,20 +107,20 @@
                 <h2 id="entry-title-{{ .ID }}" class="item-title">
                     <a
                         {{ if $.showOnlyUnreadEntries }}
-                        href="{{ route "unreadFeedEntry" "feedID" .Feed.ID "entryID" .ID }}"
+                        href="{{ routePath "/unread/feed/%d/entry/%d" .Feed.ID .ID }}"
                         {{ else }}
-                        href="{{ route "feedEntry" "feedID" .Feed.ID "entryID" .ID }}"
+                        href="{{ routePath "/feed/%d/entry/%d" .Feed.ID .ID }}"
                         {{ end }}
                     >
                         {{ if ne .Feed.Icon.IconID 0 }}
-                        <img src="{{ route "feedIcon" "externalIconID" .Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="">
+                        <img src="{{ routePath "/feed-icon/%s" .Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="">
                         {{ end }}
                         {{ .Title }}
                     </a>
                 </h2>
                 <span class="category">
                     <a
-                        href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}"
+                        href="{{ routePath "/category/%d/entries" .Feed.Category.ID }}"
                         aria-label="{{ t "page.category_label" .Feed.Category.Title }}"
                     >
                         {{ .Feed.Category.Title }}

+ 5 - 5
internal/template/templates/views/history_entries.html

@@ -14,7 +14,7 @@
                 <button
                     class="page-button"
                     data-confirm="true"
-                    data-url="{{ route "flushHistory" }}"
+                    data-url="{{ routePath "/history/flush" }}"
                     data-label-question="{{ t "confirm.question" }}"
                     data-label-yes="{{ t "confirm.yes" }}"
                     data-label-no="{{ t "confirm.no" }}"
@@ -22,7 +22,7 @@
             </li>
             {{ end }}
             <li>
-                <a class="page-link" href="{{ route "sharedEntries" }}">{{ icon "share" }}{{ t "menu.shared_entries" }}</a>
+                <a class="page-link" href="{{ routePath "/shares" }}">{{ icon "share" }}{{ t "menu.shared_entries" }}</a>
             </li>
         </ul>
     </nav>
@@ -46,15 +46,15 @@
         >
             <header class="item-header" dir="auto">
                 <h2 id="entry-title-{{ .ID }}" class="item-title">
-                    <a href="{{ route "readEntry" "entryID" .ID }}">
+                    <a href="{{ routePath "/history/entry/%d" .ID }}">
                         {{ if ne .Feed.Icon.IconID 0 }}
-                        <img src="{{ route "feedIcon" "externalIconID" .Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="">
+                        <img src="{{ routePath "/feed-icon/%s" .Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="">
                         {{ end }}
                         {{ .Title }}
                     </a>
                 </h2>
                 <span class="category">
-                    <a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">
+                    <a href="{{ routePath "/category/%d/entries" .Feed.Category.ID }}">
                         {{ .Feed.Category.Title }}
                     </a>
                 </span>

+ 2 - 2
internal/template/templates/views/import.html

@@ -12,7 +12,7 @@
     <div role="alert" class="alert alert-error">{{ .errorMessage }}</div>
 {{ end }}
 
-<form action="{{ route "uploadOPML" }}" method="post" enctype="multipart/form-data">
+<form action="{{ routePath "/upload" }}" method="post" enctype="multipart/form-data">
     <input type="hidden" name="csrf" value="{{ .csrf }}">
 
     <label for="form-file">{{ t "form.import.label.file" }}</label>
@@ -23,7 +23,7 @@
     </div>
 </form>
 <hr>
-<form action="{{ route "fetchOPML" }}" method="post" enctype="multipart/form-data">
+<form action="{{ routePath "/fetch" }}" method="post" enctype="multipart/form-data">
     <input type="hidden" name="csrf" value="{{ .csrf }}">
 
     <label for="form-url">{{ t "form.import.label.url" }}</label>

+ 4 - 4
internal/template/templates/views/integrations.html

@@ -8,7 +8,7 @@
 {{ end }}
 
 {{ define "content"}}
-<form method="post" autocomplete="off" action="{{ route "updateIntegration" }}" class="integration-form">
+<form method="post" autocomplete="off" action="{{ routePath "/integration" }}" class="integration-form">
     <input type="hidden" name="csrf" value="{{ .csrf }}">
 
     {{ if .errorMessage }}
@@ -136,7 +136,7 @@
             <label for="form-fever-password">{{ t "form.integration.fever_password" }}</label>
             <input type="password" name="fever_password" id="form-fever-password" value="{{ .form.FeverPassword }}" autocomplete="new-password">
 
-            <p>{{ t "form.integration.fever_endpoint" }} <strong>{{ rootURL }}{{ route "feverEndpoint" }}</strong></p>
+            <p>{{ t "form.integration.fever_endpoint" }} <strong>{{ rootURL }}{{ routePath "/fever/" }}</strong></p>
 
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
@@ -157,7 +157,7 @@
             <label for="form-googlereader-password">{{ t "form.integration.googlereader_password" }}</label>
             <input type="password" name="googlereader_password" id="form-googlereader-password" value="{{ .form.GoogleReaderPassword }}" autocomplete="new-password">
 
-            <p>{{ t "form.integration.googlereader_endpoint" }} <strong>{{ rootURL }}{{ route "login" }}</strong></p>
+            <p>{{ t "form.integration.googlereader_endpoint" }} <strong>{{ rootURL }}{{ routePath "/" }}</strong></p>
 
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
@@ -722,7 +722,7 @@
     <p>{{ t "page.integration.bookmarklet.help" }}</p>
 
     <div class="bookmarklet">
-        <a href="javascript:location.href='{{ rootURL }}{{ route "bookmarklet" }}?uri='+encodeURIComponent(window.location.href)">{{ t "page.integration.bookmarklet.name" }}</a>
+        <a href="javascript:location.href='{{ rootURL }}{{ routePath "/bookmarklet" }}?uri='+encodeURIComponent(window.location.href)">{{ t "page.integration.bookmarklet.name" }}</a>
     </div>
 
     <p>{{ t "page.integration.bookmarklet.instructions" }}</p>

+ 3 - 3
internal/template/templates/views/login.html

@@ -6,7 +6,7 @@
 {{ define "content"}}
 <section class="login-form">
     {{ if not disableLocalAuth }}
-    <form action="{{ route "checkLogin" }}" method="post">
+    <form action="{{ routePath "/login" }}" method="post">
         <input type="hidden" name="csrf" value="{{ .csrf }}">
         <input type="hidden" name="redirect_url" value="{{ .redirectURL }}">
 
@@ -49,11 +49,11 @@
     {{ end }}
     {{ if hasOAuth2Provider "google" }}
     <div class="oauth2">
-        <a href="{{ route "oauth2Redirect" "provider" "google" }}">{{ t "page.login.google_signin" }}</a>
+        <a href="{{ routePath "/oauth2/%s/redirect" "google" }}">{{ t "page.login.google_signin" }}</a>
     </div>
     {{ else if hasOAuth2Provider "oidc" }}
     <div class="oauth2">
-        <a href="{{ route "oauth2Redirect" "provider" "oidc" }}">{{ t "page.login.oidc_signin" oidcProviderName }}</a>
+        <a href="{{ routePath "/oauth2/%s/redirect" "oidc" }}">{{ t "page.login.oidc_signin" oidcProviderName }}</a>
     </div>
     {{ end }}
 </section>

+ 1 - 1
internal/template/templates/views/offline.html

@@ -10,7 +10,7 @@
         <meta name="theme-color" content="{{ theme_color .theme "dark" }}" media="(prefers-color-scheme: dark)">
     </head>
     <body>
-        <p>{{ t "page.offline.message" }} - <a href="{{ route "unread" }}">{{ t "page.offline.refresh_page" }}</a>.</p>
+        <p>{{ t "page.offline.message" }} - <a href="{{ routePath "/unread" }}">{{ t "page.offline.refresh_page" }}</a>.</p>
     </body>
 </html>
 {{end}}

+ 4 - 4
internal/template/templates/views/search.html

@@ -8,7 +8,7 @@
 
 {{ define "content"}}
 <search role="search">
-    <form class="search-form" action="{{ route "search" }}" aria-labelledby="search-input-label">
+    <form class="search-form" action="{{ routePath "/search" }}" aria-labelledby="search-input-label">
         <div class="search-input-row">
             <input type="search" name="q" id="search-input" aria-label="{{ t "search.label" }}" placeholder="{{ t "search.placeholder" }}" {{ if $.searchQuery }}value="{{ .searchQuery }}"{{ else }}autofocus{{ end }} required>
             <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.loading" }}">{{ t "search.submit" }}</button>
@@ -33,9 +33,9 @@
             >
                 <header class="item-header" dir="auto">
                     <h2 id="entry-title-{{ .ID }}" class="item-title">
-                        <a href="{{ route "searchEntry" "entryID" .ID }}{{ queryString (dict "q" $.searchQuery "unread" $.searchUnreadOnly) }}">
+                        <a href="{{ routePath "/search/entry/%d" .ID }}{{ queryString (dict "q" $.searchQuery "unread" $.searchUnreadOnly) }}">
                             {{ if ne .Feed.Icon.IconID 0 }}
-                            <img src="{{ route "feedIcon" "externalIconID" .Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="{{ .Feed.Title }}">
+                            <img src="{{ routePath "/feed-icon/%s" .Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="{{ .Feed.Title }}">
                             {{ else }}
                             <span class="sr-only">{{ .Feed.Title }}</span>
                             {{ end }}
@@ -43,7 +43,7 @@
                         </a>
                     </h2>
                     <span class="category">
-                        <a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">
+                        <a href="{{ routePath "/category/%d/entries" .Feed.Category.ID }}">
                             {{ .Feed.Category.Title }}
                         </a>
                     </span>

+ 1 - 1
internal/template/templates/views/sessions.html

@@ -30,7 +30,7 @@
                     data-label-yes="{{ t "confirm.yes" }}"
                     data-label-no="{{ t "confirm.no" }}"
                     data-label-loading="{{ t "confirm.loading" }}"
-                    data-url="{{ route "removeSession" "sessionID" .ID }}">{{ icon "delete" }}{{ t "action.remove" }}</a>
+                    data-url="{{ routePath "/sessions/%d/remove" .ID }}">{{ icon "delete" }}{{ t "action.remove" }}</a>
             {{ end }}
         </td>
     </tr>

+ 7 - 7
internal/template/templates/views/settings.html

@@ -8,7 +8,7 @@
 {{ end }}
 
 {{ define "content"}}
-<form method="post" autocomplete="off" action="{{ route "updateSettings" }}">
+<form method="post" autocomplete="off" action="{{ routePath "/settings" }}">
     <input type="hidden" name="csrf" value="{{ .csrf }}">
 
     {{ if .errorMessage }}
@@ -31,17 +31,17 @@
         {{ if hasOAuth2Provider "google" }}
         <p>
             {{ if .user.GoogleID }}
-                <a href="{{ route "oauth2Unlink" "provider" "google" }}">{{ t "page.settings.unlink_google_account" }}</a>
+                <a href="{{ routePath "/oauth2/%s/unlink" "google" }}">{{ t "page.settings.unlink_google_account" }}</a>
             {{ else }}
-                <a href="{{ route "oauth2Redirect" "provider" "google" }}">{{ t "page.settings.link_google_account" }}</a>
+                <a href="{{ routePath "/oauth2/%s/redirect" "google" }}">{{ t "page.settings.link_google_account" }}</a>
             {{ end }}
         </p>
         {{ else if hasOAuth2Provider "oidc" }}
         <p>
             {{ if .user.OpenIDConnectID }}
-                <a href="{{ route "oauth2Unlink" "provider" "oidc" }}">{{ t "page.settings.unlink_oidc_account" oidcProviderName }}</a>
+                <a href="{{ routePath "/oauth2/%s/unlink" "oidc" }}">{{ t "page.settings.unlink_oidc_account" oidcProviderName }}</a>
             {{ else }}
-                <a href="{{ route "oauth2Redirect" "provider" "oidc" }}">{{ t "page.settings.link_oidc_account" oidcProviderName }}</a>
+                <a href="{{ routePath "/oauth2/%s/redirect" "oidc" }}">{{ t "page.settings.link_oidc_account" oidcProviderName }}</a>
             {{ end }}
         </p>
         {{ end }}
@@ -83,8 +83,8 @@
                         data-label-yes="{{ t "confirm.yes" }}"
                         data-label-no="{{ t "confirm.no" }}"
                         data-label-loading="{{ t "confirm.loading" }}"
-                        data-url="{{ route "webauthnDelete" "credentialHandle" .HandleEncoded }}">{{ icon "delete" }}{{ t "action.remove" }}</a>
-                    <a href="{{ route "webauthnRename" "credentialHandle" .HandleEncoded }}">{{ icon "edit" }} {{ t "action.edit" }}</a>
+                        data-url="{{ routePath "/webauthn/%s/delete" .HandleEncoded }}">{{ icon "delete" }}{{ t "action.remove" }}</a>
+                    <a href="{{ routePath "/webauthn/%s/rename" .HandleEncoded }}">{{ icon "edit" }} {{ t "action.edit" }}</a>
                 </td>
             </tr>
             {{ end }}

+ 8 - 8
internal/template/templates/views/shared_entries.html

@@ -14,14 +14,14 @@
                 <button
                     class="page-button"
                     data-confirm="true"
-                    data-url="{{ route "flushHistory" }}"
+                    data-url="{{ routePath "/history/flush" }}"
                     data-label-question="{{ t "confirm.question" }}"
                     data-label-yes="{{ t "confirm.yes" }}"
                     data-label-no="{{ t "confirm.no" }}"
                     data-label-loading="{{ t "confirm.loading" }}">{{ icon "delete" }}{{ t "menu.flush_history" }}</button>
             </li>
             <li>
-                <a class="page-link" href="{{ route "sharedEntries" }}">{{ icon "share" }}{{ t "menu.shared_entries" }}</a>
+                <a class="page-link" href="{{ routePath "/shares" }}">{{ icon "share" }}{{ t "menu.shared_entries" }}</a>
             </li>
         </ul>
     </nav>
@@ -46,24 +46,24 @@
         >
             <header class="item-header" dir="auto">
                 <h2 id="entry-title-{{ .ID }}" class="item-title">
-                    <a href="{{ route "readEntry" "entryID" .ID }}">
+                    <a href="{{ routePath "/history/entry/%d" .ID }}">
                         {{ if ne .Feed.Icon.IconID 0 }}
-                        <img src="{{ route "feedIcon" "externalIconID" .Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="">
+                        <img src="{{ routePath "/feed-icon/%s" .Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="">
                         {{ end }}
                         {{ .Title }}
                     </a>
                     {{ if .ShareCode }}
-                    <a href="{{ route "sharedEntry" "shareCode" .ShareCode }}"
+                    <a href="{{ routePath "/share/%s" .ShareCode }}"
                         title="{{ t "entry.shared_entry.title" }}"
                         {{ if $.user.OpenExternalLinksInNewTab }}target="_blank"{{ end }}>{{ icon "share" }}</a>
                     {{ end }}
                 </h2>
-                <span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
+                <span class="category"><a href="{{ routePath "/category/%d/entries" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
             </header>
             <div class="item-meta">
                 <ul class="item-meta-info">
                     <li class="item-meta-info-site-url">
-                        <a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.SiteURL }}">{{ truncate .Feed.Title 35 }}</a>
+                        <a href="{{ routePath "/feed/%d/entries" .Feed.ID }}" title="{{ .Feed.SiteURL }}">{{ truncate .Feed.Title 35 }}</a>
                     </li>
                     <li class="item-meta-info-timestamp">
                         <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed $.user.Timezone .Date }}</time>
@@ -74,7 +74,7 @@
                         {{ icon "delete" }}
                         <a href="#"
                             data-confirm="true"
-                            data-url="{{ route "unshareEntry" "entryID" .ID }}"
+                            data-url="{{ routePath "/entry/unshare/%d" .ID }}"
                             data-label-question="{{ t "confirm.question" }}"
                             data-label-yes="{{ t "confirm.yes" }}"
                             data-label-no="{{ t "confirm.no" }}"

+ 3 - 3
internal/template/templates/views/starred_entries.html

@@ -27,15 +27,15 @@
         >
             <header class="item-header" dir="auto">
                 <h2 id="entry-title-{{ .ID }}" class="item-title">
-                    <a href="{{ route "starredEntry" "entryID" .ID }}">
+                    <a href="{{ routePath "/starred/entry/%d" .ID }}">
                         {{ if ne .Feed.Icon.IconID 0 }}
-                        <img src="{{ route "feedIcon" "externalIconID" .Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="">
+                        <img src="{{ routePath "/feed-icon/%s" .Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="">
                         {{ end }}
                         {{ .Title }}
                     </a>
                 </h2>
                 <span class="category">
-                    <a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">
+                    <a href="{{ routePath "/category/%d/entries" .Feed.Category.ID }}">
                         {{ .Feed.Category.Title }}
                     </a>
                 </span>

+ 3 - 3
internal/template/templates/views/tag_entries.html

@@ -27,15 +27,15 @@
         >
             <header class="item-header" dir="auto">
                 <h2 id="entry-title-{{ .ID }}" class="item-title">
-                    <a href="{{ route "tagEntry" "entryID" .ID "tagName" (urlEncode $.tagName) }}">
+                    <a href="{{ routePath "/tags/%s/entry/%d" (urlEncode $.tagName) .ID }}">
                         {{ if ne .Feed.Icon.IconID 0 }}
-                        <img src="{{ route "feedIcon" "externalIconID" .Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="">
+                        <img src="{{ routePath "/feed-icon/%s" .Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="">
                         {{ end }}
                         {{ .Title }}
                     </a>
                 </h2>
                 <span class="category">
-                    <a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">
+                    <a href="{{ routePath "/category/%d/entries" .Feed.Category.ID }}">
                         {{ .Feed.Category.Title }}
                     </a>
                 </span>

+ 5 - 5
internal/template/templates/views/unread_entries.html

@@ -24,8 +24,8 @@
                 <button
                     class="page-button"
                     data-confirm="true"
-                    data-url="{{ route "markAllAsRead" }}"
-                    data-redirect-url="{{ route "unread" }}"
+                    data-url="{{ routePath "/mark-all-as-read" }}"
+                    data-redirect-url="{{ routePath "/unread" }}"
                     data-label-question="{{ t "confirm.question" }}"
                     data-label-yes="{{ t "confirm.yes" }}"
                     data-label-no="{{ t "confirm.no" }}"
@@ -54,15 +54,15 @@
         >
             <header class="item-header" dir="auto">
                 <h2 id="entry-title-{{ .ID }}" class="item-title">
-                    <a href="{{ route "unreadEntry" "entryID" .ID }}">
+                    <a href="{{ routePath "/unread/entry/%d" .ID }}">
                         {{ if ne .Feed.Icon.IconID 0 -}}
-                        <img src="{{ route "feedIcon" "externalIconID" .Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="">
+                        <img src="{{ routePath "/feed-icon/%s" .Feed.Icon.ExternalIconID }}" width="16" height="16" loading="lazy" alt="">
                         {{ end -}}
                         {{ .Title }}
                     </a>
                 </h2>
                 <span class="category">
-                    <a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">
+                    <a href="{{ routePath "/category/%d/entries" .Feed.Category.ID }}">
                         {{ .Feed.Category.Title }}
                     </a>
                 </span>

+ 3 - 3
internal/template/templates/views/users.html

@@ -31,14 +31,14 @@
                     {{ end }}
                 </td>
                 <td>
-                    <a href="{{ route "editUser" "userID" .ID }}">{{ t "action.edit" }}</a>,
+                    <a href="{{ routePath "/users/%d/edit" .ID }}">{{ t "action.edit" }}</a>,
                     <a href="#"
                         data-confirm="true"
                         data-label-question="{{ t "confirm.question" }}"
                         data-label-yes="{{ t "confirm.yes" }}"
                         data-label-no="{{ t "confirm.no" }}"
                         data-label-loading="{{ t "confirm.loading" }}"
-                        data-url="{{ route "removeUser" "userID" .ID }}">{{ t "action.remove" }}</a>
+                        data-url="{{ routePath "/users/%d/remove" .ID }}">{{ t "action.remove" }}</a>
                 </td>
             </tr>
             {{ end }}
@@ -48,7 +48,7 @@
 {{ end }}
 
 <p>
-    <a href="{{ route "createUser" }}" class="button button-primary">{{ t "menu.add_user" }}</a>
+    <a href="{{ routePath "/user/create" }}" class="button button-primary">{{ t "menu.add_user" }}</a>
 </p>
 
 {{ end }}

+ 1 - 1
internal/template/templates/views/webauthn_rename.html

@@ -7,7 +7,7 @@
 {{ end }}
 
 {{ define "content"}}
-<form action="{{ route "webauthnSave" "credentialHandle" .cred.HandleEncoded }}" method="post" autocomplete="off">
+<form action="{{ routePath "/webauthn/%s/save" .cred.HandleEncoded }}" method="post" autocomplete="off">
     <input type="hidden" name="csrf" value="{{ .csrf }}">
 
     {{ if .errorMessage }}

+ 1 - 2
internal/ui/api_key_remove.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 )
 
 func (h *handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
@@ -18,5 +17,5 @@ func (h *handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "apiKeys"))
+	response.HTMLRedirect(w, r, h.routePath("/keys"))
 }

+ 1 - 2
internal/ui/api_key_save.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/session"
@@ -46,5 +45,5 @@ func (h *handler) saveAPIKey(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "apiKeys"))
+	response.HTMLRedirect(w, r, h.routePath("/keys"))
 }

+ 1 - 2
internal/ui/category_entries.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
@@ -59,7 +58,7 @@ func (h *handler) showCategoryEntriesPage(w http.ResponseWriter, r *http.Request
 	view.Set("category", category)
 	view.Set("total", count)
 	view.Set("entries", entries)
-	view.Set("pagination", getPagination(route.Path(h.router, "categoryEntries", "categoryID", category.ID), count, offset, user.EntriesPerPage))
+	view.Set("pagination", getPagination(h.routePath("/category/%d/entries", category.ID), count, offset, user.EntriesPerPage))
 	view.Set("menu", "categories")
 	view.Set("user", user)
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))

+ 1 - 2
internal/ui/category_entries_all.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
@@ -59,7 +58,7 @@ func (h *handler) showCategoryEntriesAllPage(w http.ResponseWriter, r *http.Requ
 	view.Set("category", category)
 	view.Set("total", count)
 	view.Set("entries", entries)
-	view.Set("pagination", getPagination(route.Path(h.router, "categoryEntriesAll", "categoryID", category.ID), count, offset, user.EntriesPerPage))
+	view.Set("pagination", getPagination(h.routePath("/category/%d/entries/all", category.ID), count, offset, user.EntriesPerPage))
 	view.Set("menu", "categories")
 	view.Set("user", user)
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))

+ 1 - 2
internal/ui/category_entries_starred.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
@@ -60,7 +59,7 @@ func (h *handler) showCategoryEntriesStarredPage(w http.ResponseWriter, r *http.
 	view.Set("category", category)
 	view.Set("total", count)
 	view.Set("entries", entries)
-	view.Set("pagination", getPagination(route.Path(h.router, "categoryEntriesStarred", "categoryID", category.ID), count, offset, user.EntriesPerPage))
+	view.Set("pagination", getPagination(h.routePath("/category/%d/entries/starred", category.ID), count, offset, user.EntriesPerPage))
 	view.Set("menu", "categories")
 	view.Set("user", user)
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))

+ 1 - 2
internal/ui/category_mark_as_read.go

@@ -9,7 +9,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 )
 
 func (h *handler) markCategoryAsRead(w http.ResponseWriter, r *http.Request) {
@@ -32,5 +31,5 @@ func (h *handler) markCategoryAsRead(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "categories"))
+	response.HTMLRedirect(w, r, h.routePath("/categories"))
 }

+ 2 - 3
internal/ui/category_refresh.go

@@ -11,19 +11,18 @@ import (
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/ui/session"
 )
 
 func (h *handler) refreshCategoryEntriesPage(w http.ResponseWriter, r *http.Request) {
 	categoryID := h.refreshCategory(w, r)
-	response.HTMLRedirect(w, r, route.Path(h.router, "categoryEntries", "categoryID", categoryID))
+	response.HTMLRedirect(w, r, h.routePath("/category/%d/entries", categoryID))
 }
 
 func (h *handler) refreshCategoryFeedsPage(w http.ResponseWriter, r *http.Request) {
 	categoryID := h.refreshCategory(w, r)
-	response.HTMLRedirect(w, r, route.Path(h.router, "categoryFeeds", "categoryID", categoryID))
+	response.HTMLRedirect(w, r, h.routePath("/category/%d/feeds", categoryID))
 }
 
 func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) int64 {

+ 1 - 2
internal/ui/category_remove.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 )
 
 func (h *handler) removeCategory(w http.ResponseWriter, r *http.Request) {
@@ -35,5 +34,5 @@ func (h *handler) removeCategory(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "categories"))
+	response.HTMLRedirect(w, r, h.routePath("/categories"))
 }

+ 1 - 2
internal/ui/category_remove_feed.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 )
 
 func (h *handler) removeCategoryFeed(w http.ResponseWriter, r *http.Request) {
@@ -25,5 +24,5 @@ func (h *handler) removeCategoryFeed(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "categoryFeeds", "categoryID", categoryID))
+	response.HTMLRedirect(w, r, h.routePath("/category/%d/feeds", categoryID))
 }

+ 1 - 2
internal/ui/category_save.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/session"
@@ -46,5 +45,5 @@ func (h *handler) saveCategory(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "categories"))
+	response.HTMLRedirect(w, r, h.routePath("/categories"))
 }

+ 1 - 2
internal/ui/category_update.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/session"
@@ -63,5 +62,5 @@ func (h *handler) updateCategory(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "categoryFeeds", "categoryID", categoryID))
+	response.HTMLRedirect(w, r, h.routePath("/category/%d/feeds", categoryID))
 }

+ 2 - 3
internal/ui/entry_category.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/ui/session"
@@ -66,12 +65,12 @@ func (h *handler) showCategoryEntryPage(w http.ResponseWriter, r *http.Request)
 
 	nextEntryRoute := ""
 	if nextEntry != nil {
-		nextEntryRoute = route.Path(h.router, "categoryEntry", "categoryID", categoryID, "entryID", nextEntry.ID)
+		nextEntryRoute = h.routePath("/category/%d/entry/%d", categoryID, nextEntry.ID)
 	}
 
 	prevEntryRoute := ""
 	if prevEntry != nil {
-		prevEntryRoute = route.Path(h.router, "categoryEntry", "categoryID", categoryID, "entryID", prevEntry.ID)
+		prevEntryRoute = h.routePath("/category/%d/entry/%d", categoryID, prevEntry.ID)
 	}
 
 	sess := session.New(h.store, request.SessionID(r))

+ 2 - 3
internal/ui/entry_feed.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/ui/session"
@@ -66,12 +65,12 @@ func (h *handler) showFeedEntryPage(w http.ResponseWriter, r *http.Request) {
 
 	nextEntryRoute := ""
 	if nextEntry != nil {
-		nextEntryRoute = route.Path(h.router, "feedEntry", "feedID", feedID, "entryID", nextEntry.ID)
+		nextEntryRoute = h.routePath("/feed/%d/entry/%d", feedID, nextEntry.ID)
 	}
 
 	prevEntryRoute := ""
 	if prevEntry != nil {
-		prevEntryRoute = route.Path(h.router, "feedEntry", "feedID", feedID, "entryID", prevEntry.ID)
+		prevEntryRoute = h.routePath("/feed/%d/entry/%d", feedID, prevEntry.ID)
 	}
 
 	sess := session.New(h.store, request.SessionID(r))

+ 2 - 3
internal/ui/entry_read.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/ui/session"
@@ -53,12 +52,12 @@ func (h *handler) showReadEntryPage(w http.ResponseWriter, r *http.Request) {
 
 	nextEntryRoute := ""
 	if nextEntry != nil {
-		nextEntryRoute = route.Path(h.router, "readEntry", "entryID", nextEntry.ID)
+		nextEntryRoute = h.routePath("/history/entry/%d", nextEntry.ID)
 	}
 
 	prevEntryRoute := ""
 	if prevEntry != nil {
-		prevEntryRoute = route.Path(h.router, "readEntry", "entryID", prevEntry.ID)
+		prevEntryRoute = h.routePath("/history/entry/%d", prevEntry.ID)
 	}
 
 	sess := session.New(h.store, request.SessionID(r))

+ 2 - 3
internal/ui/entry_search.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/ui/session"
@@ -74,12 +73,12 @@ func (h *handler) showSearchEntryPage(w http.ResponseWriter, r *http.Request) {
 
 	nextEntryRoute := ""
 	if nextEntry != nil {
-		nextEntryRoute = route.Path(h.router, "searchEntry", "entryID", nextEntry.ID)
+		nextEntryRoute = h.routePath("/search/entry/%d", nextEntry.ID)
 	}
 
 	prevEntryRoute := ""
 	if prevEntry != nil {
-		prevEntryRoute = route.Path(h.router, "searchEntry", "entryID", prevEntry.ID)
+		prevEntryRoute = h.routePath("/search/entry/%d", prevEntry.ID)
 	}
 
 	sess := session.New(h.store, request.SessionID(r))

+ 2 - 3
internal/ui/entry_starred.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/ui/session"
@@ -63,12 +62,12 @@ func (h *handler) showStarredEntryPage(w http.ResponseWriter, r *http.Request) {
 
 	nextEntryRoute := ""
 	if nextEntry != nil {
-		nextEntryRoute = route.Path(h.router, "starredEntry", "entryID", nextEntry.ID)
+		nextEntryRoute = h.routePath("/starred/entry/%d", nextEntry.ID)
 	}
 
 	prevEntryRoute := ""
 	if prevEntry != nil {
-		prevEntryRoute = route.Path(h.router, "starredEntry", "entryID", prevEntry.ID)
+		prevEntryRoute = h.routePath("/starred/entry/%d", prevEntry.ID)
 	}
 
 	sess := session.New(h.store, request.SessionID(r))

+ 2 - 3
internal/ui/entry_tag.go

@@ -9,7 +9,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/ui/session"
@@ -66,12 +65,12 @@ func (h *handler) showTagEntryPage(w http.ResponseWriter, r *http.Request) {
 
 	nextEntryRoute := ""
 	if nextEntry != nil {
-		nextEntryRoute = route.Path(h.router, "tagEntry", "tagName", url.PathEscape(tagName), "entryID", nextEntry.ID)
+		nextEntryRoute = h.routePath("/tags/%s/entry/%d", url.PathEscape(tagName), nextEntry.ID)
 	}
 
 	prevEntryRoute := ""
 	if prevEntry != nil {
-		prevEntryRoute = route.Path(h.router, "tagEntry", "tagName", url.PathEscape(tagName), "entryID", prevEntry.ID)
+		prevEntryRoute = h.routePath("/tags/%s/entry/%d", url.PathEscape(tagName), prevEntry.ID)
 	}
 
 	sess := session.New(h.store, request.SessionID(r))

+ 3 - 4
internal/ui/entry_unread.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/ui/session"
@@ -34,7 +33,7 @@ func (h *handler) showUnreadEntryPage(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if entry == nil {
-		response.HTMLRedirect(w, r, route.Path(h.router, "unread"))
+		response.HTMLRedirect(w, r, h.routePath("/unread"))
 		return
 	}
 
@@ -58,12 +57,12 @@ func (h *handler) showUnreadEntryPage(w http.ResponseWriter, r *http.Request) {
 
 	nextEntryRoute := ""
 	if nextEntry != nil {
-		nextEntryRoute = route.Path(h.router, "unreadEntry", "entryID", nextEntry.ID)
+		nextEntryRoute = h.routePath("/unread/entry/%d", nextEntry.ID)
 	}
 
 	prevEntryRoute := ""
 	if prevEntry != nil {
-		prevEntryRoute = route.Path(h.router, "unreadEntry", "entryID", prevEntry.ID)
+		prevEntryRoute = h.routePath("/unread/entry/%d", prevEntry.ID)
 	}
 
 	if entry.ShouldMarkAsReadOnView(user) {

+ 1 - 2
internal/ui/feed_entries.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
@@ -59,7 +58,7 @@ func (h *handler) showFeedEntriesPage(w http.ResponseWriter, r *http.Request) {
 	view.Set("feed", feed)
 	view.Set("entries", entries)
 	view.Set("total", count)
-	view.Set("pagination", getPagination(route.Path(h.router, "feedEntries", "feedID", feed.ID), count, offset, user.EntriesPerPage))
+	view.Set("pagination", getPagination(h.routePath("/feed/%d/entries", feed.ID), count, offset, user.EntriesPerPage))
 	view.Set("menu", "feeds")
 	view.Set("user", user)
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))

+ 1 - 2
internal/ui/feed_entries_all.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
@@ -59,7 +58,7 @@ func (h *handler) showFeedEntriesAllPage(w http.ResponseWriter, r *http.Request)
 	view.Set("feed", feed)
 	view.Set("entries", entries)
 	view.Set("total", count)
-	view.Set("pagination", getPagination(route.Path(h.router, "feedEntriesAll", "feedID", feed.ID), count, offset, user.EntriesPerPage))
+	view.Set("pagination", getPagination(h.routePath("/feed/%d/entries/all", feed.ID), count, offset, user.EntriesPerPage))
 	view.Set("menu", "feeds")
 	view.Set("user", user)
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))

+ 1 - 2
internal/ui/feed_mark_as_read.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 )
 
 func (h *handler) markFeedAsRead(w http.ResponseWriter, r *http.Request) {
@@ -26,5 +25,5 @@ func (h *handler) markFeedAsRead(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "feeds"))
+	response.HTMLRedirect(w, r, h.routePath("/feeds"))
 }

+ 2 - 3
internal/ui/feed_refresh.go

@@ -11,7 +11,6 @@ import (
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/locale"
 	feedHandler "miniflux.app/v2/internal/reader/handler"
 	"miniflux.app/v2/internal/ui/session"
@@ -29,7 +28,7 @@ func (h *handler) refreshFeed(w http.ResponseWriter, r *http.Request) {
 		)
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "feedEntries", "feedID", feedID))
+	response.HTMLRedirect(w, r, h.routePath("/feed/%d/entries", feedID))
 }
 
 func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {
@@ -67,5 +66,5 @@ func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {
 		sess.NewFlashMessage(printer.Print("alert.background_feed_refresh"))
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "feeds"))
+	response.HTMLRedirect(w, r, h.routePath("/feeds"))
 }

+ 1 - 2
internal/ui/feed_remove.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 )
 
 func (h *handler) removeFeed(w http.ResponseWriter, r *http.Request) {
@@ -24,5 +23,5 @@ func (h *handler) removeFeed(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "feeds"))
+	response.HTMLRedirect(w, r, h.routePath("/feeds"))
 }

+ 1 - 2
internal/ui/feed_update.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/session"
@@ -79,5 +78,5 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "feedEntries", "feedID", feed.ID))
+	response.HTMLRedirect(w, r, h.routePath("/feed/%d/entries", feed.ID))
 }

+ 13 - 6
internal/ui/handler.go

@@ -4,16 +4,23 @@
 package ui // import "miniflux.app/v2/internal/ui"
 
 import (
+	"fmt"
+
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/template"
 	"miniflux.app/v2/internal/worker"
-
-	"github.com/gorilla/mux"
 )
 
 type handler struct {
-	router *mux.Router
-	store  *storage.Storage
-	tpl    *template.Engine
-	pool   *worker.Pool
+	basePath string
+	store    *storage.Storage
+	tpl      *template.Engine
+	pool     *worker.Pool
+}
+
+func (h *handler) routePath(format string, args ...any) string {
+	if len(args) > 0 {
+		return h.basePath + fmt.Sprintf(format, args...)
+	}
+	return h.basePath + format
 }

+ 1 - 2
internal/ui/history_entries.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
@@ -45,7 +44,7 @@ func (h *handler) showHistoryPage(w http.ResponseWriter, r *http.Request) {
 	view := view.New(h.tpl, r, sess)
 	view.Set("entries", entries)
 	view.Set("total", count)
-	view.Set("pagination", getPagination(route.Path(h.router, "history"), count, offset, user.EntriesPerPage))
+	view.Set("pagination", getPagination(h.routePath("/history"), count, offset, user.EntriesPerPage))
 	view.Set("menu", "history")
 	view.Set("user", user)
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))

+ 4 - 5
internal/ui/integration_update.go

@@ -11,7 +11,6 @@ import (
 	"miniflux.app/v2/internal/crypto"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/session"
@@ -33,7 +32,7 @@ func (h *handler) updateIntegration(w http.ResponseWriter, r *http.Request) {
 
 	if integration.FeverUsername != "" && h.store.HasDuplicateFeverUsername(userID, integration.FeverUsername) {
 		sess.NewFlashErrorMessage(printer.Print("error.duplicate_fever_username"))
-		response.HTMLRedirect(w, r, route.Path(h.router, "integrations"))
+		response.HTMLRedirect(w, r, h.routePath("/integrations"))
 		return
 	}
 
@@ -47,7 +46,7 @@ func (h *handler) updateIntegration(w http.ResponseWriter, r *http.Request) {
 
 	if integration.GoogleReaderUsername != "" && h.store.HasDuplicateGoogleReaderUsername(userID, integration.GoogleReaderUsername) {
 		sess.NewFlashErrorMessage(printer.Print("error.duplicate_googlereader_username"))
-		response.HTMLRedirect(w, r, route.Path(h.router, "integrations"))
+		response.HTMLRedirect(w, r, h.routePath("/integrations"))
 		return
 	}
 
@@ -78,7 +77,7 @@ func (h *handler) updateIntegration(w http.ResponseWriter, r *http.Request) {
 	if integrationForm.LinktacoEnabled {
 		if integrationForm.LinktacoAPIToken == "" || integrationForm.LinktacoOrgSlug == "" {
 			sess.NewFlashErrorMessage(printer.Print("error.linktaco_missing_required_fields"))
-			response.HTMLRedirect(w, r, route.Path(h.router, "integrations"))
+			response.HTMLRedirect(w, r, h.routePath("/integrations"))
 			return
 		}
 		if integration.LinktacoVisibility == "" {
@@ -93,5 +92,5 @@ func (h *handler) updateIntegration(w http.ResponseWriter, r *http.Request) {
 	}
 
 	sess.NewFlashMessage(printer.Print("alert.prefs_saved"))
-	response.HTMLRedirect(w, r, route.Path(h.router, "integrations"))
+	response.HTMLRedirect(w, r, h.routePath("/integrations"))
 }

+ 1 - 2
internal/ui/login_check.go

@@ -11,7 +11,6 @@ import (
 	"miniflux.app/v2/internal/http/cookie"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/session"
@@ -101,5 +100,5 @@ func (h *handler) checkLogin(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, user.DefaultHomePage))
+	response.HTMLRedirect(w, r, h.basePath+"/"+user.DefaultHomePage)
 }

+ 1 - 2
internal/ui/login_show.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 )
@@ -21,7 +20,7 @@ func (h *handler) showLoginPage(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		response.HTMLRedirect(w, r, route.Path(h.router, user.DefaultHomePage))
+		response.HTMLRedirect(w, r, h.basePath+"/"+user.DefaultHomePage)
 		return
 	}
 

+ 1 - 2
internal/ui/logout.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/cookie"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/ui/session"
 )
 
@@ -36,5 +35,5 @@ func (h *handler) logout(w http.ResponseWriter, r *http.Request) {
 		config.Opts.BasePath(),
 	))
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "login"))
+	response.HTMLRedirect(w, r, h.routePath("/"))
 }

+ 34 - 35
internal/ui/middleware.go

@@ -9,27 +9,25 @@ import (
 	"log/slog"
 	"net/http"
 	"net/url"
+	"strings"
 
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/crypto"
 	"miniflux.app/v2/internal/http/cookie"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/ui/session"
-
-	"github.com/gorilla/mux"
 )
 
 type middleware struct {
-	router *mux.Router
-	store  *storage.Storage
+	basePath string
+	store    *storage.Storage
 }
 
-func newMiddleware(router *mux.Router, store *storage.Storage) *middleware {
-	return &middleware{router, store}
+func newMiddleware(basePath string, store *storage.Storage) *middleware {
+	return &middleware{basePath, store}
 }
 
 func (m *middleware) handleUserSession(next http.Handler) http.Handler {
@@ -37,13 +35,13 @@ func (m *middleware) handleUserSession(next http.Handler) http.Handler {
 		session := m.getUserSessionFromCookie(r)
 
 		if session == nil {
-			if m.isPublicRoute(r) {
+			if isPublicRoute(r) {
 				next.ServeHTTP(w, r)
 			} else {
 				slog.Debug("Redirecting to login page because no user session has been found",
 					slog.String("url", r.RequestURI),
 				)
-				loginURL, _ := url.Parse(route.Path(m.router, "login"))
+				loginURL, _ := url.Parse(m.basePath + "/")
 				values := loginURL.Query()
 				values.Set("redirect_url", r.RequestURI)
 				loginURL.RawQuery = values.Encode()
@@ -68,7 +66,7 @@ func (m *middleware) handleUserSession(next http.Handler) http.Handler {
 
 func (m *middleware) handleAppSession(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if mux.CurrentRoute(r).GetName() == "feedIcon" {
+		if strings.HasPrefix(r.URL.Path, "/feed-icon/") {
 			// Skip app session handling for the feed icon route to avoid unnecessary session creation
 			// when fetching feed icons.
 			next.ServeHTTP(w, r)
@@ -112,8 +110,8 @@ func (m *middleware) handleAppSession(next http.Handler) http.Handler {
 					slog.String("header_csrf", headerValue),
 				)
 
-				if mux.CurrentRoute(r).GetName() == "checkLogin" {
-					response.HTMLRedirect(w, r, route.Path(m.router, "login"))
+				if r.URL.Path == "/login" {
+					response.HTMLRedirect(w, r, m.basePath+"/")
 					return
 				}
 
@@ -155,30 +153,31 @@ func (m *middleware) getAppSessionValueFromCookie(r *http.Request) *model.Sessio
 	return session
 }
 
-func (m *middleware) isPublicRoute(r *http.Request) bool {
-	route := mux.CurrentRoute(r)
-	switch route.GetName() {
-	case "login",
-		"checkLogin",
-		"stylesheet",
-		"javascript",
-		"oauth2Redirect",
-		"oauth2Callback",
-		"appIcon",
-		"feedIcon",
-		"favicon",
-		"webManifest",
-		"robots",
-		"sharedEntry",
-		"healthcheck",
-		"offline",
-		"proxy",
-		"webauthnLoginBegin",
-		"webauthnLoginFinish":
+// isPublicRoute checks if the request path corresponds to a route that
+// does not require authentication. The path is expected to have the base
+// path already stripped.
+func isPublicRoute(r *http.Request) bool {
+	path := r.URL.Path
+
+	switch path {
+	case "/", "/login", "/favicon.ico", "/manifest.json", "/robots.txt",
+		"/healthcheck", "/offline",
+		"/webauthn/login/begin", "/webauthn/login/finish":
+		return true
+	}
+
+	if strings.HasPrefix(path, "/stylesheets/") ||
+		strings.HasPrefix(path, "/icon/") ||
+		strings.HasPrefix(path, "/feed-icon/") ||
+		strings.HasSuffix(path, "/redirect") && strings.HasPrefix(path, "/oauth2/") ||
+		strings.HasSuffix(path, "/callback") && strings.HasPrefix(path, "/oauth2/") ||
+		strings.HasPrefix(path, "/share/") ||
+		strings.HasPrefix(path, "/proxy/") ||
+		strings.HasSuffix(path, ".js") {
 		return true
-	default:
-		return false
 	}
+
+	return false
 }
 
 func (m *middleware) getUserSessionFromCookie(r *http.Request) *model.UserSession {
@@ -286,6 +285,6 @@ func (m *middleware) handleAuthProxy(next http.Handler) http.Handler {
 			config.Opts.BasePath(),
 		))
 
-		response.HTMLRedirect(w, r, route.Path(m.router, user.DefaultHomePage))
+		response.HTMLRedirect(w, r, m.basePath+"/"+user.DefaultHomePage)
 	})
 }

+ 8 - 9
internal/ui/oauth2_callback.go

@@ -13,7 +13,6 @@ import (
 	"miniflux.app/v2/internal/http/cookie"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/session"
@@ -23,14 +22,14 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 	provider := request.RouteStringParam(r, "provider")
 	if provider == "" {
 		slog.Warn("Invalid or missing OAuth2 provider")
-		response.HTMLRedirect(w, r, route.Path(h.router, "login"))
+		response.HTMLRedirect(w, r, h.routePath("/"))
 		return
 	}
 
 	code := request.QueryStringParam(r, "code", "")
 	if code == "" {
 		slog.Warn("No code received on OAuth2 callback")
-		response.HTMLRedirect(w, r, route.Path(h.router, "login"))
+		response.HTMLRedirect(w, r, h.routePath("/"))
 		return
 	}
 
@@ -40,7 +39,7 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 			slog.String("expected", request.OAuth2State(r)),
 			slog.String("received", state),
 		)
-		response.HTMLRedirect(w, r, route.Path(h.router, "login"))
+		response.HTMLRedirect(w, r, h.routePath("/"))
 		return
 	}
 
@@ -50,7 +49,7 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 			slog.String("provider", provider),
 			slog.Any("error", err),
 		)
-		response.HTMLRedirect(w, r, route.Path(h.router, "login"))
+		response.HTMLRedirect(w, r, h.routePath("/"))
 		return
 	}
 
@@ -60,7 +59,7 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 			slog.String("provider", provider),
 			slog.Any("error", err),
 		)
-		response.HTMLRedirect(w, r, route.Path(h.router, "login"))
+		response.HTMLRedirect(w, r, h.routePath("/"))
 		return
 	}
 
@@ -81,7 +80,7 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 				slog.String("oauth2_profile_id", profile.ID),
 			)
 			sess.NewFlashErrorMessage(printer.Print("error.duplicate_linked_account"))
-			response.HTMLRedirect(w, r, route.Path(h.router, "settings"))
+			response.HTMLRedirect(w, r, h.routePath("/settings"))
 			return
 		}
 
@@ -92,7 +91,7 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 		}
 
 		sess.NewFlashMessage(printer.Print("alert.account_linked"))
-		response.HTMLRedirect(w, r, route.Path(h.router, "settings"))
+		response.HTMLRedirect(w, r, h.routePath("/settings"))
 		return
 	}
 
@@ -149,5 +148,5 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 		config.Opts.BasePath(),
 	))
 
-	response.HTMLRedirect(w, r, route.Path(h.router, user.DefaultHomePage))
+	response.HTMLRedirect(w, r, h.basePath+"/"+user.DefaultHomePage)
 }

+ 2 - 3
internal/ui/oauth2_redirect.go

@@ -9,7 +9,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/oauth2"
 	"miniflux.app/v2/internal/ui/session"
 )
@@ -18,7 +17,7 @@ func (h *handler) oauth2Redirect(w http.ResponseWriter, r *http.Request) {
 	provider := request.RouteStringParam(r, "provider")
 	if provider == "" {
 		slog.Warn("Invalid or missing OAuth2 provider")
-		response.HTMLRedirect(w, r, route.Path(h.router, "login"))
+		response.HTMLRedirect(w, r, h.routePath("/"))
 		return
 	}
 
@@ -28,7 +27,7 @@ func (h *handler) oauth2Redirect(w http.ResponseWriter, r *http.Request) {
 			slog.String("provider", provider),
 			slog.Any("error", err),
 		)
-		response.HTMLRedirect(w, r, route.Path(h.router, "login"))
+		response.HTMLRedirect(w, r, h.routePath("/"))
 		return
 	}
 

+ 5 - 6
internal/ui/oauth2_unlink.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/ui/session"
 )
@@ -20,14 +19,14 @@ func (h *handler) oauth2Unlink(w http.ResponseWriter, r *http.Request) {
 		slog.Warn("blocking oauth2 unlink attempt, local auth is disabled",
 			slog.String("user_agent", r.UserAgent()),
 		)
-		response.HTMLRedirect(w, r, route.Path(h.router, "login"))
+		response.HTMLRedirect(w, r, h.routePath("/"))
 		return
 	}
 
 	provider := request.RouteStringParam(r, "provider")
 	if provider == "" {
 		slog.Warn("Invalid or missing OAuth2 provider")
-		response.HTMLRedirect(w, r, route.Path(h.router, "login"))
+		response.HTMLRedirect(w, r, h.routePath("/"))
 		return
 	}
 
@@ -37,7 +36,7 @@ func (h *handler) oauth2Unlink(w http.ResponseWriter, r *http.Request) {
 			slog.String("provider", provider),
 			slog.Any("error", err),
 		)
-		response.HTMLRedirect(w, r, route.Path(h.router, "settings"))
+		response.HTMLRedirect(w, r, h.routePath("/settings"))
 		return
 	}
 
@@ -57,7 +56,7 @@ func (h *handler) oauth2Unlink(w http.ResponseWriter, r *http.Request) {
 	printer := locale.NewPrinter(request.UserLanguage(r))
 	if !hasPassword {
 		sess.NewFlashErrorMessage(printer.Print("error.unlink_account_without_password"))
-		response.HTMLRedirect(w, r, route.Path(h.router, "settings"))
+		response.HTMLRedirect(w, r, h.routePath("/settings"))
 		return
 	}
 
@@ -68,5 +67,5 @@ func (h *handler) oauth2Unlink(w http.ResponseWriter, r *http.Request) {
 	}
 
 	sess.NewFlashMessage(printer.Print("alert.account_unlinked"))
-	response.HTMLRedirect(w, r, route.Path(h.router, "settings"))
+	response.HTMLRedirect(w, r, h.routePath("/settings"))
 }

+ 4 - 5
internal/ui/opml_upload.go

@@ -11,7 +11,6 @@ import (
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/proxyrotator"
 	"miniflux.app/v2/internal/reader/fetcher"
@@ -34,7 +33,7 @@ func (h *handler) uploadOPML(w http.ResponseWriter, r *http.Request) {
 			slog.Any("error", err),
 		)
 
-		response.HTMLRedirect(w, r, route.Path(h.router, "import"))
+		response.HTMLRedirect(w, r, h.routePath("/import"))
 		return
 	}
 	defer file.Close()
@@ -64,7 +63,7 @@ func (h *handler) uploadOPML(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "feeds"))
+	response.HTMLRedirect(w, r, h.routePath("/feeds"))
 }
 
 func (h *handler) fetchOPML(w http.ResponseWriter, r *http.Request) {
@@ -76,7 +75,7 @@ func (h *handler) fetchOPML(w http.ResponseWriter, r *http.Request) {
 
 	opmlFileURL := strings.TrimSpace(r.FormValue("url"))
 	if opmlFileURL == "" {
-		response.HTMLRedirect(w, r, route.Path(h.router, "import"))
+		response.HTMLRedirect(w, r, h.routePath("/import"))
 		return
 	}
 
@@ -112,5 +111,5 @@ func (h *handler) fetchOPML(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "feeds"))
+	response.HTMLRedirect(w, r, h.routePath("/feeds"))
 }

+ 1 - 2
internal/ui/search.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
@@ -53,7 +52,7 @@ func (h *handler) showSearchPage(w http.ResponseWriter, r *http.Request) {
 
 	sess := session.New(h.store, request.SessionID(r))
 	view := view.New(h.tpl, r, sess)
-	pagination := getPagination(route.Path(h.router, "search"), entriesCount, offset, user.EntriesPerPage)
+	pagination := getPagination(h.routePath("/search"), entriesCount, offset, user.EntriesPerPage)
 	pagination.SearchQuery = searchQuery
 	pagination.UnreadOnly = unreadOnly
 

+ 1 - 2
internal/ui/session_remove.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 )
 
 func (h *handler) removeSession(w http.ResponseWriter, r *http.Request) {
@@ -19,5 +18,5 @@ func (h *handler) removeSession(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "sessions"))
+	response.HTMLRedirect(w, r, h.routePath("/sessions"))
 }

+ 1 - 2
internal/ui/settings_update.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/timezone"
@@ -96,5 +95,5 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
 	sess.SetLanguage(user.Language)
 	sess.SetTheme(user.Theme)
 	sess.NewFlashMessage(locale.NewPrinter(request.UserLanguage(r)).Printf("alert.prefs_saved"))
-	response.HTMLRedirect(w, r, route.Path(h.router, "settings"))
+	response.HTMLRedirect(w, r, h.routePath("/settings"))
 }

+ 2 - 3
internal/ui/share.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
@@ -24,7 +23,7 @@ func (h *handler) createSharedEntry(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "sharedEntry", "shareCode", shareCode))
+	response.HTMLRedirect(w, r, h.routePath("/share/%s", shareCode))
 }
 
 func (h *handler) unshareEntry(w http.ResponseWriter, r *http.Request) {
@@ -34,7 +33,7 @@ func (h *handler) unshareEntry(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "sharedEntries"))
+	response.HTMLRedirect(w, r, h.routePath("/shares"))
 }
 
 func (h *handler) sharedEntry(w http.ResponseWriter, r *http.Request) {

+ 1 - 2
internal/ui/shared_entries.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 )
@@ -44,7 +43,7 @@ func (h *handler) sharedEntries(w http.ResponseWriter, r *http.Request) {
 	view := view.New(h.tpl, r, sess)
 	view.Set("entries", entries)
 	view.Set("total", count)
-	view.Set("pagination", getPagination(route.Path(h.router, "sharedEntries"), count, offset, user.EntriesPerPage))
+	view.Set("pagination", getPagination(h.routePath("/shares"), count, offset, user.EntriesPerPage))
 	view.Set("menu", "history")
 	view.Set("user", user)
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))

+ 1 - 2
internal/ui/starred_entries.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
@@ -46,7 +45,7 @@ func (h *handler) showStarredPage(w http.ResponseWriter, r *http.Request) {
 	view := view.New(h.tpl, r, sess)
 	view.Set("total", count)
 	view.Set("entries", entries)
-	view.Set("pagination", getPagination(route.Path(h.router, "starred"), count, offset, user.EntriesPerPage))
+	view.Set("pagination", getPagination(h.routePath("/starred"), count, offset, user.EntriesPerPage))
 	view.Set("menu", "starred")
 	view.Set("user", user)
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))

+ 2 - 3
internal/ui/starred_entry_category.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/ui/session"
@@ -68,12 +67,12 @@ func (h *handler) showStarredCategoryEntryPage(w http.ResponseWriter, r *http.Re
 
 	nextEntryRoute := ""
 	if nextEntry != nil {
-		nextEntryRoute = route.Path(h.router, "starredCategoryEntry", "categoryID", categoryID, "entryID", nextEntry.ID)
+		nextEntryRoute = h.routePath("/starred/category/%d/entry/%d", categoryID, nextEntry.ID)
 	}
 
 	prevEntryRoute := ""
 	if prevEntry != nil {
-		prevEntryRoute = route.Path(h.router, "starredCategoryEntry", "categoryID", categoryID, "entryID", prevEntry.ID)
+		prevEntryRoute = h.routePath("/starred/category/%d/entry/%d", categoryID, prevEntry.ID)
 	}
 
 	sess := session.New(h.store, request.SessionID(r))

+ 9 - 4
internal/ui/static_javascript.go

@@ -9,10 +9,8 @@ import (
 	"strings"
 	"time"
 
-	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/ui/static"
 )
 
@@ -20,7 +18,14 @@ const licensePrefix = "//@license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d3
 const licenseSuffix = "\n//@license-end"
 
 func (h *handler) showJavascript(w http.ResponseWriter, r *http.Request) {
-	filename := request.RouteStringParam(r, "name")
+	// The filename path value contains "name.checksum.js"; reject non-JS requests
+	// and extract the name portion.
+	rawFilename := r.PathValue("filename")
+	if !strings.HasSuffix(rawFilename, ".js") {
+		response.HTMLNotFound(w, r)
+		return
+	}
+	filename, _, _ := strings.Cut(strings.TrimSuffix(rawFilename, ".js"), ".")
 	js, found := static.JavascriptBundles[filename]
 	if !found {
 		response.HTMLNotFound(w, r)
@@ -31,7 +36,7 @@ func (h *handler) showJavascript(w http.ResponseWriter, r *http.Request) {
 		contents := js.Data
 
 		if filename == "service-worker" {
-			variables := fmt.Sprintf(`const OFFLINE_URL=%q;`, route.Path(h.router, "offline"))
+			variables := fmt.Sprintf(`const OFFLINE_URL=%q;`, h.routePath("/offline"))
 			contents = append([]byte(variables), contents...)
 		}
 

+ 16 - 17
internal/ui/static_manifest.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/model"
 )
@@ -83,31 +82,31 @@ func (h *handler) showWebManifest(w http.ResponseWriter, r *http.Request) {
 		ShortName:       "Miniflux",
 		Description:     "Minimalist Feed Reader",
 		Display:         displayMode,
-		StartURL:        route.Path(h.router, "login"),
+		StartURL:        h.routePath("/"),
 		BackgroundColor: themeColor,
 		Icons: []webManifestIcon{
-			{Source: route.Path(h.router, "appIcon", "filename", "icon-120.png"), Sizes: "120x120", Type: "image/png", Purpose: "any"},
-			{Source: route.Path(h.router, "appIcon", "filename", "icon-192.png"), Sizes: "192x192", Type: "image/png", Purpose: "any"},
-			{Source: route.Path(h.router, "appIcon", "filename", "icon-512.png"), Sizes: "512x512", Type: "image/png", Purpose: "any"},
-			{Source: route.Path(h.router, "appIcon", "filename", "maskable-icon-120.png"), Sizes: "120x120", Type: "image/png", Purpose: "maskable"},
-			{Source: route.Path(h.router, "appIcon", "filename", "maskable-icon-192.png"), Sizes: "192x192", Type: "image/png", Purpose: "maskable"},
-			{Source: route.Path(h.router, "appIcon", "filename", "maskable-icon-512.png"), Sizes: "512x512", Type: "image/png", Purpose: "maskable"},
+			{Source: h.routePath("/icon/%s", "icon-120.png"), Sizes: "120x120", Type: "image/png", Purpose: "any"},
+			{Source: h.routePath("/icon/%s", "icon-192.png"), Sizes: "192x192", Type: "image/png", Purpose: "any"},
+			{Source: h.routePath("/icon/%s", "icon-512.png"), Sizes: "512x512", Type: "image/png", Purpose: "any"},
+			{Source: h.routePath("/icon/%s", "maskable-icon-120.png"), Sizes: "120x120", Type: "image/png", Purpose: "maskable"},
+			{Source: h.routePath("/icon/%s", "maskable-icon-192.png"), Sizes: "192x192", Type: "image/png", Purpose: "maskable"},
+			{Source: h.routePath("/icon/%s", "maskable-icon-512.png"), Sizes: "512x512", Type: "image/png", Purpose: "maskable"},
 		},
 		ShareTarget: webManifestShareTarget{
-			Action:  route.Path(h.router, "bookmarklet"),
+			Action:  h.routePath("/bookmarklet"),
 			Method:  http.MethodGet,
 			Enctype: "application/x-www-form-urlencoded",
 			Params:  webManifestShareTargetParams{URL: "uri", Text: "text"},
 		},
 		Shortcuts: []webManifestShortcut{
-			{Name: labelNewFeed, URL: route.Path(h.router, "addSubscription"), Icons: []webManifestIcon{{Source: route.Path(h.router, "appIcon", "filename", "add-feed-icon.png"), Sizes: "240x240", Type: "image/png"}}},
-			{Name: labelUnreadMenu, URL: route.Path(h.router, "unread"), Icons: []webManifestIcon{{Source: route.Path(h.router, "appIcon", "filename", "unread-icon.png"), Sizes: "240x240", Type: "image/png"}}},
-			{Name: labelStarredMenu, URL: route.Path(h.router, "starred"), Icons: []webManifestIcon{{Source: route.Path(h.router, "appIcon", "filename", "starred-icon.png"), Sizes: "240x240", Type: "image/png"}}},
-			{Name: labelHistoryMenu, URL: route.Path(h.router, "history"), Icons: []webManifestIcon{{Source: route.Path(h.router, "appIcon", "filename", "history-icon.png"), Sizes: "240x240", Type: "image/png"}}},
-			{Name: labelFeedsMenu, URL: route.Path(h.router, "feeds"), Icons: []webManifestIcon{{Source: route.Path(h.router, "appIcon", "filename", "feeds-icon.png"), Sizes: "240x240", Type: "image/png"}}},
-			{Name: labelCategoriesMenu, URL: route.Path(h.router, "categories"), Icons: []webManifestIcon{{Source: route.Path(h.router, "appIcon", "filename", "categories-icon.png"), Sizes: "240x240", Type: "image/png"}}},
-			{Name: labelSearchMenu, URL: route.Path(h.router, "search"), Icons: []webManifestIcon{{Source: route.Path(h.router, "appIcon", "filename", "search-icon.png"), Sizes: "240x240", Type: "image/png"}}},
-			{Name: labelSettingsMenu, URL: route.Path(h.router, "settings"), Icons: []webManifestIcon{{Source: route.Path(h.router, "appIcon", "filename", "settings-icon.png"), Sizes: "240x240", Type: "image/png"}}},
+			{Name: labelNewFeed, URL: h.routePath("/subscribe"), Icons: []webManifestIcon{{Source: h.routePath("/icon/%s", "add-feed-icon.png"), Sizes: "240x240", Type: "image/png"}}},
+			{Name: labelUnreadMenu, URL: h.routePath("/unread"), Icons: []webManifestIcon{{Source: h.routePath("/icon/%s", "unread-icon.png"), Sizes: "240x240", Type: "image/png"}}},
+			{Name: labelStarredMenu, URL: h.routePath("/starred"), Icons: []webManifestIcon{{Source: h.routePath("/icon/%s", "starred-icon.png"), Sizes: "240x240", Type: "image/png"}}},
+			{Name: labelHistoryMenu, URL: h.routePath("/history"), Icons: []webManifestIcon{{Source: h.routePath("/icon/%s", "history-icon.png"), Sizes: "240x240", Type: "image/png"}}},
+			{Name: labelFeedsMenu, URL: h.routePath("/feeds"), Icons: []webManifestIcon{{Source: h.routePath("/icon/%s", "feeds-icon.png"), Sizes: "240x240", Type: "image/png"}}},
+			{Name: labelCategoriesMenu, URL: h.routePath("/categories"), Icons: []webManifestIcon{{Source: h.routePath("/icon/%s", "categories-icon.png"), Sizes: "240x240", Type: "image/png"}}},
+			{Name: labelSearchMenu, URL: h.routePath("/search"), Icons: []webManifestIcon{{Source: h.routePath("/icon/%s", "search-icon.png"), Sizes: "240x240", Type: "image/png"}}},
+			{Name: labelSettingsMenu, URL: h.routePath("/settings"), Icons: []webManifestIcon{{Source: h.routePath("/icon/%s", "settings-icon.png"), Sizes: "240x240", Type: "image/png"}}},
 		},
 	}
 

+ 3 - 2
internal/ui/static_stylesheet.go

@@ -5,16 +5,17 @@ package ui // import "miniflux.app/v2/internal/ui"
 
 import (
 	"net/http"
+	"strings"
 	"time"
 
-	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 
 	"miniflux.app/v2/internal/ui/static"
 )
 
 func (h *handler) showStylesheet(w http.ResponseWriter, r *http.Request) {
-	filename := request.RouteStringParam(r, "name")
+	// The filename path value contains "name.checksum.css"; extract the name portion.
+	filename, _, _ := strings.Cut(r.PathValue("filename"), ".")
 	m, found := static.StylesheetBundles[filename]
 	if !found {
 		response.HTMLNotFound(w, r)

+ 1 - 2
internal/ui/subscription_choose.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	feedHandler "miniflux.app/v2/internal/reader/handler"
 	"miniflux.app/v2/internal/ui/form"
@@ -75,5 +74,5 @@ func (h *handler) showChooseSubscriptionPage(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "feedEntries", "feedID", feed.ID))
+	response.HTMLRedirect(w, r, h.routePath("/feed/%d/entries", feed.ID))
 }

+ 2 - 3
internal/ui/subscription_submit.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/proxyrotator"
@@ -124,7 +123,7 @@ func (h *handler) submitSubscription(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		response.HTMLRedirect(w, r, route.Path(h.router, "feedEntries", "feedID", feed.ID))
+		response.HTMLRedirect(w, r, h.routePath("/feed/%d/entries", feed.ID))
 	case n == 1 && !subscriptionFinder.IsFeedAlreadyDownloaded():
 		feed, localizedError := feedHandler.CreateFeed(h.store, user.ID, &model.FeedCreationRequest{
 			CategoryID:                  subscriptionForm.CategoryID,
@@ -154,7 +153,7 @@ func (h *handler) submitSubscription(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		response.HTMLRedirect(w, r, route.Path(h.router, "feedEntries", "feedID", feed.ID))
+		response.HTMLRedirect(w, r, h.routePath("/feed/%d/entries", feed.ID))
 	case n > 1:
 		view := view.New(h.tpl, r, sess)
 		view.Set("subscriptions", subscriptions)

+ 1 - 2
internal/ui/tag_entries_all.go

@@ -9,7 +9,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
@@ -55,7 +54,7 @@ func (h *handler) showTagEntriesAllPage(w http.ResponseWriter, r *http.Request)
 	view.Set("tagName", tagName)
 	view.Set("total", count)
 	view.Set("entries", entries)
-	view.Set("pagination", getPagination(route.Path(h.router, "tagEntriesAll", "tagName", url.PathEscape(tagName)), count, offset, user.EntriesPerPage))
+	view.Set("pagination", getPagination(h.routePath("/tags/%s/entries/all", url.PathEscape(tagName)), count, offset, user.EntriesPerPage))
 	view.Set("user", user)
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
 	view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))

+ 113 - 112
internal/ui/ui.go

@@ -10,173 +10,174 @@ import (
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/template"
 	"miniflux.app/v2/internal/worker"
-
-	"github.com/gorilla/mux"
 )
 
-// Serve declares all routes for the user interface.
-func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
-	middleware := newMiddleware(router, store)
+// Serve returns an http.Handler that serves the user interface.
+// The returned handler expects the base path to be stripped from the request URL.
+func Serve(store *storage.Storage, pool *worker.Pool) http.Handler {
+	basePath := config.Opts.BasePath()
+	middleware := newMiddleware(basePath, store)
 
-	templateEngine := template.NewEngine(router)
+	templateEngine := template.NewEngine(basePath)
 	templateEngine.ParseTemplates()
 
-	handler := &handler{router, store, templateEngine, pool}
+	handler := &handler{basePath, store, templateEngine, pool}
 
-	uiRouter := router.NewRoute().Subrouter()
-	uiRouter.Use(middleware.handleUserSession)
-	uiRouter.Use(middleware.handleAppSession)
-	uiRouter.StrictSlash(true)
+	mux := http.NewServeMux()
 
 	// Static assets.
-	uiRouter.HandleFunc("/stylesheets/{name}.{checksum}.css", handler.showStylesheet).Name("stylesheet").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/{name}.{checksum}.js", handler.showJavascript).Name("javascript").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/favicon.ico", handler.showFavicon).Name("favicon").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/icon/{filename}", handler.showAppIcon).Name("appIcon").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/manifest.json", handler.showWebManifest).Name("webManifest").Methods(http.MethodGet)
+	mux.HandleFunc("GET /stylesheets/{filename}", handler.showStylesheet)
+	mux.HandleFunc("GET /{filename}", handler.showJavascript)
+	mux.HandleFunc("GET /favicon.ico", handler.showFavicon)
+	mux.HandleFunc("GET /icon/{filename}", handler.showAppIcon)
+	mux.HandleFunc("GET /manifest.json", handler.showWebManifest)
 
 	// New subscription pages.
-	uiRouter.HandleFunc("/subscribe", handler.showAddSubscriptionPage).Name("addSubscription").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/subscribe", handler.submitSubscription).Name("submitSubscription").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/subscriptions", handler.showChooseSubscriptionPage).Name("chooseSubscription").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/bookmarklet", handler.bookmarklet).Name("bookmarklet").Methods(http.MethodGet)
+	mux.HandleFunc("GET /subscribe", handler.showAddSubscriptionPage)
+	mux.HandleFunc("POST /subscribe", handler.submitSubscription)
+	mux.HandleFunc("POST /subscriptions", handler.showChooseSubscriptionPage)
+	mux.HandleFunc("GET /bookmarklet", handler.bookmarklet)
 
 	// Unread page.
-	uiRouter.HandleFunc("/mark-all-as-read", handler.markAllAsRead).Name("markAllAsRead").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/unread", handler.showUnreadPage).Name("unread").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/unread/entry/{entryID}", handler.showUnreadEntryPage).Name("unreadEntry").Methods(http.MethodGet)
+	mux.HandleFunc("POST /mark-all-as-read", handler.markAllAsRead)
+	mux.HandleFunc("GET /unread", handler.showUnreadPage)
+	mux.HandleFunc("GET /unread/entry/{entryID}", handler.showUnreadEntryPage)
 
 	// History pages.
-	uiRouter.HandleFunc("/history", handler.showHistoryPage).Name("history").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/history/entry/{entryID}", handler.showReadEntryPage).Name("readEntry").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/history/flush", handler.flushHistory).Name("flushHistory").Methods(http.MethodPost)
+	mux.HandleFunc("GET /history", handler.showHistoryPage)
+	mux.HandleFunc("GET /history/entry/{entryID}", handler.showReadEntryPage)
+	mux.HandleFunc("POST /history/flush", handler.flushHistory)
 
 	// Starred pages.
-	uiRouter.HandleFunc("/starred", handler.showStarredPage).Name("starred").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/starred/entry/{entryID}", handler.showStarredEntryPage).Name("starredEntry").Methods(http.MethodGet)
+	mux.HandleFunc("GET /starred", handler.showStarredPage)
+	mux.HandleFunc("GET /starred/entry/{entryID}", handler.showStarredEntryPage)
 
 	// Search pages.
-	uiRouter.HandleFunc("/search", handler.showSearchPage).Name("search").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/search/entry/{entryID}", handler.showSearchEntryPage).Name("searchEntry").Methods(http.MethodGet)
+	mux.HandleFunc("GET /search", handler.showSearchPage)
+	mux.HandleFunc("GET /search/entry/{entryID}", handler.showSearchEntryPage)
 
 	// Feed listing pages.
-	uiRouter.HandleFunc("/feeds", handler.showFeedsPage).Name("feeds").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/feeds/refresh", handler.refreshAllFeeds).Name("refreshAllFeeds").Methods(http.MethodGet)
+	mux.HandleFunc("GET /feeds", handler.showFeedsPage)
+	mux.HandleFunc("GET /feeds/refresh", handler.refreshAllFeeds)
 
 	// Individual feed pages.
-	uiRouter.HandleFunc("/feed/{feedID}/refresh", handler.refreshFeed).Name("refreshFeed").Methods(http.MethodGet, http.MethodPost)
-	uiRouter.HandleFunc("/feed/{feedID}/refresh", handler.refreshFeed).Queries("forceRefresh", "{forceRefresh:true|false}").Name("refreshFeed").Methods(http.MethodGet, http.MethodPost)
-	uiRouter.HandleFunc("/feed/{feedID}/edit", handler.showEditFeedPage).Name("editFeed").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/feed/{feedID}/remove", handler.removeFeed).Name("removeFeed").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/feed/{feedID}/update", handler.updateFeed).Name("updateFeed").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/feed/{feedID}/entries", handler.showFeedEntriesPage).Name("feedEntries").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/feed/{feedID}/entries/all", handler.showFeedEntriesAllPage).Name("feedEntriesAll").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/feed/{feedID}/entry/{entryID}", handler.showFeedEntryPage).Name("feedEntry").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/unread/feed/{feedID}/entry/{entryID}", handler.showUnreadFeedEntryPage).Name("unreadFeedEntry").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/feed/icon/{externalIconID}", handler.showFeedIcon).Name("feedIcon").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/feed/{feedID}/mark-all-as-read", handler.markFeedAsRead).Name("markFeedAsRead").Methods(http.MethodPost)
+	mux.HandleFunc("GET /feed/{feedID}/refresh", handler.refreshFeed)
+	mux.HandleFunc("POST /feed/{feedID}/refresh", handler.refreshFeed)
+	mux.HandleFunc("GET /feed/{feedID}/edit", handler.showEditFeedPage)
+	mux.HandleFunc("POST /feed/{feedID}/remove", handler.removeFeed)
+	mux.HandleFunc("POST /feed/{feedID}/update", handler.updateFeed)
+	mux.HandleFunc("GET /feed/{feedID}/entries", handler.showFeedEntriesPage)
+	mux.HandleFunc("GET /feed/{feedID}/entries/all", handler.showFeedEntriesAllPage)
+	mux.HandleFunc("GET /feed/{feedID}/entry/{entryID}", handler.showFeedEntryPage)
+	mux.HandleFunc("GET /unread/feed/{feedID}/entry/{entryID}", handler.showUnreadFeedEntryPage)
+	mux.HandleFunc("POST /feed/{feedID}/mark-all-as-read", handler.markFeedAsRead)
+	mux.HandleFunc("GET /feed-icon/{externalIconID}", handler.showFeedIcon)
 
 	// Category pages.
-	uiRouter.HandleFunc("/category/{categoryID}/entry/{entryID}", handler.showCategoryEntryPage).Name("categoryEntry").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/unread/category/{categoryID}/entry/{entryID}", handler.showUnreadCategoryEntryPage).Name("unreadCategoryEntry").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/starred/category/{categoryID}/entry/{entryID}", handler.showStarredCategoryEntryPage).Name("starredCategoryEntry").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/categories", handler.showCategoryListPage).Name("categories").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/category/create", handler.showCreateCategoryPage).Name("createCategory").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/category/save", handler.saveCategory).Name("saveCategory").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/category/{categoryID}/feeds", handler.showCategoryFeedsPage).Name("categoryFeeds").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/category/{categoryID}/feed/{feedID}/remove", handler.removeCategoryFeed).Name("removeCategoryFeed").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/category/{categoryID}/feeds/refresh", handler.refreshCategoryFeedsPage).Name("refreshCategoryFeedsPage").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/category/{categoryID}/entries", handler.showCategoryEntriesPage).Name("categoryEntries").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/category/{categoryID}/entries/refresh", handler.refreshCategoryEntriesPage).Name("refreshCategoryEntriesPage").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/category/{categoryID}/entries/all", handler.showCategoryEntriesAllPage).Name("categoryEntriesAll").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/category/{categoryID}/entries/starred", handler.showCategoryEntriesStarredPage).Name("categoryEntriesStarred").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/category/{categoryID}/edit", handler.showEditCategoryPage).Name("editCategory").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/category/{categoryID}/update", handler.updateCategory).Name("updateCategory").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/category/{categoryID}/remove", handler.removeCategory).Name("removeCategory").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/category/{categoryID}/mark-all-as-read", handler.markCategoryAsRead).Name("markCategoryAsRead").Methods(http.MethodPost)
+	mux.HandleFunc("GET /category/{categoryID}/entry/{entryID}", handler.showCategoryEntryPage)
+	mux.HandleFunc("GET /unread/category/{categoryID}/entry/{entryID}", handler.showUnreadCategoryEntryPage)
+	mux.HandleFunc("GET /starred/category/{categoryID}/entry/{entryID}", handler.showStarredCategoryEntryPage)
+	mux.HandleFunc("GET /categories", handler.showCategoryListPage)
+	mux.HandleFunc("GET /category/create", handler.showCreateCategoryPage)
+	mux.HandleFunc("POST /category/save", handler.saveCategory)
+	mux.HandleFunc("GET /category/{categoryID}/feeds", handler.showCategoryFeedsPage)
+	mux.HandleFunc("POST /category/{categoryID}/feed/{feedID}/remove", handler.removeCategoryFeed)
+	mux.HandleFunc("GET /category/{categoryID}/feeds/refresh", handler.refreshCategoryFeedsPage)
+	mux.HandleFunc("GET /category/{categoryID}/entries", handler.showCategoryEntriesPage)
+	mux.HandleFunc("GET /category/{categoryID}/entries/refresh", handler.refreshCategoryEntriesPage)
+	mux.HandleFunc("GET /category/{categoryID}/entries/all", handler.showCategoryEntriesAllPage)
+	mux.HandleFunc("GET /category/{categoryID}/entries/starred", handler.showCategoryEntriesStarredPage)
+	mux.HandleFunc("GET /category/{categoryID}/edit", handler.showEditCategoryPage)
+	mux.HandleFunc("POST /category/{categoryID}/update", handler.updateCategory)
+	mux.HandleFunc("POST /category/{categoryID}/remove", handler.removeCategory)
+	mux.HandleFunc("POST /category/{categoryID}/mark-all-as-read", handler.markCategoryAsRead)
 
 	// Tag pages.
-	uiRouter.HandleFunc("/tags/{tagName}/entries/all", handler.showTagEntriesAllPage).Name("tagEntriesAll").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/tags/{tagName}/entry/{entryID}", handler.showTagEntryPage).Name("tagEntry").Methods(http.MethodGet)
+	mux.HandleFunc("GET /tags/{tagName}/entries/all", handler.showTagEntriesAllPage)
+	mux.HandleFunc("GET /tags/{tagName}/entry/{entryID}", handler.showTagEntryPage)
 
 	// Entry pages.
-	uiRouter.HandleFunc("/entry/status", handler.updateEntriesStatus).Name("updateEntriesStatus").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/entry/save/{entryID}", handler.saveEntry).Name("saveEntry").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/entry/enclosure/{enclosureID}/save-progression", handler.saveEnclosureProgression).Name("saveEnclosureProgression").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/entry/download/{entryID}", handler.fetchContent).Name("fetchContent").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/entry/star/{entryID}", handler.toggleStarred).Name("toggleStarred").Methods(http.MethodPost)
+	mux.HandleFunc("POST /entry/status", handler.updateEntriesStatus)
+	mux.HandleFunc("POST /entry/save/{entryID}", handler.saveEntry)
+	mux.HandleFunc("POST /entry/enclosure/{enclosureID}/save-progression", handler.saveEnclosureProgression)
+	mux.HandleFunc("POST /entry/download/{entryID}", handler.fetchContent)
+	mux.HandleFunc("POST /entry/star/{entryID}", handler.toggleStarred)
 
 	// Media proxy.
-	uiRouter.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", handler.mediaProxy).Name("proxy").Methods(http.MethodGet)
+	mux.HandleFunc("GET /proxy/{encodedDigest}/{encodedURL}", handler.mediaProxy)
 
 	// Share pages.
-	uiRouter.HandleFunc("/entry/share/{entryID}", handler.createSharedEntry).Name("shareEntry").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/entry/unshare/{entryID}", handler.unshareEntry).Name("unshareEntry").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/share/{shareCode}", handler.sharedEntry).Name("sharedEntry").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/shares", handler.sharedEntries).Name("sharedEntries").Methods(http.MethodGet)
+	mux.HandleFunc("POST /entry/share/{entryID}", handler.createSharedEntry)
+	mux.HandleFunc("POST /entry/unshare/{entryID}", handler.unshareEntry)
+	mux.HandleFunc("GET /share/{shareCode}", handler.sharedEntry)
+	mux.HandleFunc("GET /shares", handler.sharedEntries)
 
 	// User pages.
-	uiRouter.HandleFunc("/users", handler.showUsersPage).Name("users").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/user/create", handler.showCreateUserPage).Name("createUser").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/user/save", handler.saveUser).Name("saveUser").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/users/{userID}/edit", handler.showEditUserPage).Name("editUser").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/users/{userID}/update", handler.updateUser).Name("updateUser").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/users/{userID}/remove", handler.removeUser).Name("removeUser").Methods(http.MethodPost)
+	mux.HandleFunc("GET /users", handler.showUsersPage)
+	mux.HandleFunc("GET /user/create", handler.showCreateUserPage)
+	mux.HandleFunc("POST /user/save", handler.saveUser)
+	mux.HandleFunc("GET /users/{userID}/edit", handler.showEditUserPage)
+	mux.HandleFunc("POST /users/{userID}/update", handler.updateUser)
+	mux.HandleFunc("POST /users/{userID}/remove", handler.removeUser)
 
 	// Settings pages.
-	uiRouter.HandleFunc("/settings", handler.showSettingsPage).Name("settings").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/settings", handler.updateSettings).Name("updateSettings").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/integrations", handler.showIntegrationPage).Name("integrations").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/integration", handler.updateIntegration).Name("updateIntegration").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/about", handler.showAboutPage).Name("about").Methods(http.MethodGet)
+	mux.HandleFunc("GET /settings", handler.showSettingsPage)
+	mux.HandleFunc("POST /settings", handler.updateSettings)
+	mux.HandleFunc("GET /integrations", handler.showIntegrationPage)
+	mux.HandleFunc("POST /integration", handler.updateIntegration)
+	mux.HandleFunc("GET /about", handler.showAboutPage)
 
 	// Session pages.
-	uiRouter.HandleFunc("/sessions", handler.showSessionsPage).Name("sessions").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/sessions/{sessionID}/remove", handler.removeSession).Name("removeSession").Methods(http.MethodPost)
+	mux.HandleFunc("GET /sessions", handler.showSessionsPage)
+	mux.HandleFunc("POST /sessions/{sessionID}/remove", handler.removeSession)
 
 	// API Keys pages.
 	if config.Opts.HasAPI() {
-		uiRouter.HandleFunc("/keys", handler.showAPIKeysPage).Name("apiKeys").Methods(http.MethodGet)
-		uiRouter.HandleFunc("/keys/{keyID}/delete", handler.deleteAPIKey).Name("deleteAPIKey").Methods(http.MethodPost)
-		uiRouter.HandleFunc("/keys/create", handler.showCreateAPIKeyPage).Name("createAPIKey").Methods(http.MethodGet)
-		uiRouter.HandleFunc("/keys/save", handler.saveAPIKey).Name("saveAPIKey").Methods(http.MethodPost)
+		mux.HandleFunc("GET /keys", handler.showAPIKeysPage)
+		mux.HandleFunc("POST /keys/{keyID}/delete", handler.deleteAPIKey)
+		mux.HandleFunc("GET /keys/create", handler.showCreateAPIKeyPage)
+		mux.HandleFunc("POST /keys/save", handler.saveAPIKey)
 	}
 
 	// OPML pages.
-	uiRouter.HandleFunc("/export", handler.exportFeeds).Name("export").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/import", handler.showImportPage).Name("import").Methods(http.MethodGet)
-	uiRouter.HandleFunc("/upload", handler.uploadOPML).Name("uploadOPML").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/fetch", handler.fetchOPML).Name("fetchOPML").Methods(http.MethodPost)
+	mux.HandleFunc("GET /export", handler.exportFeeds)
+	mux.HandleFunc("GET /import", handler.showImportPage)
+	mux.HandleFunc("POST /upload", handler.uploadOPML)
+	mux.HandleFunc("POST /fetch", handler.fetchOPML)
 
 	// OAuth2 flow.
 	if config.Opts.OAuth2Provider() != "" {
-		uiRouter.HandleFunc("/oauth2/{provider}/unlink", handler.oauth2Unlink).Name("oauth2Unlink").Methods(http.MethodGet)
-		uiRouter.HandleFunc("/oauth2/{provider}/redirect", handler.oauth2Redirect).Name("oauth2Redirect").Methods(http.MethodGet)
-		uiRouter.HandleFunc("/oauth2/{provider}/callback", handler.oauth2Callback).Name("oauth2Callback").Methods(http.MethodGet)
+		mux.HandleFunc("GET /oauth2/{provider}/unlink", handler.oauth2Unlink)
+		mux.HandleFunc("GET /oauth2/{provider}/redirect", handler.oauth2Redirect)
+		mux.HandleFunc("GET /oauth2/{provider}/callback", handler.oauth2Callback)
 	}
 
-	// Offline page
-	uiRouter.HandleFunc("/offline", handler.showOfflinePage).Name("offline").Methods(http.MethodGet)
+	// Offline page.
+	mux.HandleFunc("GET /offline", handler.showOfflinePage)
 
 	// Authentication pages.
-	uiRouter.HandleFunc("/login", handler.checkLogin).Name("checkLogin").Methods(http.MethodPost)
-	uiRouter.HandleFunc("/logout", handler.logout).Name("logout").Methods(http.MethodGet)
-	uiRouter.Handle("/", middleware.handleAuthProxy(http.HandlerFunc(handler.showLoginPage))).Name("login").Methods(http.MethodGet)
+	mux.HandleFunc("POST /login", handler.checkLogin)
+	mux.HandleFunc("GET /logout", handler.logout)
+	mux.Handle("GET /{$}", middleware.handleAuthProxy(http.HandlerFunc(handler.showLoginPage)))
 
-	// WebAuthn flow
+	// WebAuthn flow.
 	if config.Opts.WebAuthn() {
-		uiRouter.HandleFunc("/webauthn/register/begin", handler.beginRegistration).Name("webauthnRegisterBegin").Methods(http.MethodGet)
-		uiRouter.HandleFunc("/webauthn/register/finish", handler.finishRegistration).Name("webauthnRegisterFinish").Methods(http.MethodPost)
-		uiRouter.HandleFunc("/webauthn/login/begin", handler.beginLogin).Name("webauthnLoginBegin").Methods(http.MethodGet)
-		uiRouter.HandleFunc("/webauthn/login/finish", handler.finishLogin).Name("webauthnLoginFinish").Methods(http.MethodPost)
-		uiRouter.HandleFunc("/webauthn/deleteall", handler.deleteAllCredentials).Name("webauthnDeleteAll").Methods(http.MethodPost)
-		uiRouter.HandleFunc("/webauthn/{credentialHandle}/delete", handler.deleteCredential).Name("webauthnDelete").Methods(http.MethodPost)
-		uiRouter.HandleFunc("/webauthn/{credentialHandle}/rename", handler.renameCredential).Name("webauthnRename").Methods(http.MethodGet)
-		uiRouter.HandleFunc("/webauthn/{credentialHandle}/save", handler.saveCredential).Name("webauthnSave").Methods(http.MethodPost)
+		mux.HandleFunc("GET /webauthn/register/begin", handler.beginRegistration)
+		mux.HandleFunc("POST /webauthn/register/finish", handler.finishRegistration)
+		mux.HandleFunc("GET /webauthn/login/begin", handler.beginLogin)
+		mux.HandleFunc("POST /webauthn/login/finish", handler.finishLogin)
+		mux.HandleFunc("POST /webauthn/deleteall", handler.deleteAllCredentials)
+		mux.HandleFunc("POST /webauthn/{credentialHandle}/delete", handler.deleteCredential)
+		mux.HandleFunc("GET /webauthn/{credentialHandle}/rename", handler.renameCredential)
+		mux.HandleFunc("POST /webauthn/{credentialHandle}/save", handler.saveCredential)
 	}
 
-	router.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
+	// robots.txt
+	mux.HandleFunc("GET /robots.txt", func(w http.ResponseWriter, r *http.Request) {
 		w.Header().Set("Content-Type", "text/plain")
 		w.Write([]byte("User-agent: *\nDisallow: /"))
-	}).Name("robots")
+	})
+
+	// Apply middleware chain: user session then app session.
+	return middleware.handleUserSession(middleware.handleAppSession(mux))
 }

+ 1 - 2
internal/ui/unread_entries.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
@@ -51,7 +50,7 @@ func (h *handler) showUnreadPage(w http.ResponseWriter, r *http.Request) {
 	sess := session.New(h.store, request.SessionID(r))
 	view := view.New(h.tpl, r, sess)
 	view.Set("entries", entries)
-	view.Set("pagination", getPagination(route.Path(h.router, "unread"), countUnread, offset, user.EntriesPerPage))
+	view.Set("pagination", getPagination(h.routePath("/unread"), countUnread, offset, user.EntriesPerPage))
 	view.Set("menu", "unread")
 	view.Set("user", user)
 	view.Set("countUnread", countUnread)

+ 2 - 3
internal/ui/unread_entry_category.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/ui/session"
@@ -71,12 +70,12 @@ func (h *handler) showUnreadCategoryEntryPage(w http.ResponseWriter, r *http.Req
 
 	nextEntryRoute := ""
 	if nextEntry != nil {
-		nextEntryRoute = route.Path(h.router, "unreadCategoryEntry", "categoryID", categoryID, "entryID", nextEntry.ID)
+		nextEntryRoute = h.routePath("/unread/category/%d/entry/%d", categoryID, nextEntry.ID)
 	}
 
 	prevEntryRoute := ""
 	if prevEntry != nil {
-		prevEntryRoute = route.Path(h.router, "unreadCategoryEntry", "categoryID", categoryID, "entryID", prevEntry.ID)
+		prevEntryRoute = h.routePath("/unread/category/%d/entry/%d", categoryID, prevEntry.ID)
 	}
 
 	// Restore entry read status if needed after fetching the pagination.

+ 2 - 3
internal/ui/unread_entry_feed.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/ui/session"
@@ -71,12 +70,12 @@ func (h *handler) showUnreadFeedEntryPage(w http.ResponseWriter, r *http.Request
 
 	nextEntryRoute := ""
 	if nextEntry != nil {
-		nextEntryRoute = route.Path(h.router, "unreadFeedEntry", "feedID", feedID, "entryID", nextEntry.ID)
+		nextEntryRoute = h.routePath("/unread/feed/%d/entry/%d", feedID, nextEntry.ID)
 	}
 
 	prevEntryRoute := ""
 	if prevEntry != nil {
-		prevEntryRoute = route.Path(h.router, "unreadFeedEntry", "feedID", feedID, "entryID", prevEntry.ID)
+		prevEntryRoute = h.routePath("/unread/feed/%d/entry/%d", feedID, prevEntry.ID)
 	}
 
 	// Restore entry read status if needed after fetching the pagination.

+ 1 - 2
internal/ui/user_remove.go

@@ -9,7 +9,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 )
 
 func (h *handler) removeUser(w http.ResponseWriter, r *http.Request) {
@@ -46,5 +45,5 @@ func (h *handler) removeUser(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "users"))
+	response.HTMLRedirect(w, r, h.routePath("/users"))
 }

+ 1 - 2
internal/ui/user_save.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/form"
@@ -68,5 +67,5 @@ func (h *handler) saveUser(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "users"))
+	response.HTMLRedirect(w, r, h.routePath("/users"))
 }

+ 1 - 2
internal/ui/user_update.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/http/route"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/session"
@@ -68,5 +67,5 @@ func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	response.HTMLRedirect(w, r, route.Path(h.router, "users"))
+	response.HTMLRedirect(w, r, h.routePath("/users"))
 }

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor