Fix prefix URL routing for encoded CIDR notation

Change route from wildcard /prefix/* to explicit /prefix/{prefix}/{len}
to properly handle URL-encoded IPv6 addresses with CIDR notation.

- Separate prefix and length into individual path parameters
- Add prefixURL template function for generating correct links
- Remove url.QueryUnescape from handlers (chi handles decoding)
This commit is contained in:
Jeffrey Paul 2026-01-01 05:37:12 +07:00
parent 27909e021f
commit 45810e3fc8
5 changed files with 40 additions and 30 deletions

View File

@ -7,7 +7,6 @@ import (
"errors" "errors"
"net" "net"
"net/http" "net/http"
"net/url"
"runtime" "runtime"
"sort" "sort"
"strconv" "strconv"
@ -736,21 +735,18 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
// handlePrefixDetailJSON returns prefix details as JSON // handlePrefixDetailJSON returns prefix details as JSON
func (s *Server) handlePrefixDetailJSON() http.HandlerFunc { func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// Get wildcard parameter (everything after /prefix/) // Get prefix and length from URL params
prefixParam := chi.URLParam(r, "*") prefixParam := chi.URLParam(r, "prefix")
if prefixParam == "" { lenParam := chi.URLParam(r, "len")
writeJSONError(w, http.StatusBadRequest, "Prefix parameter is required")
if prefixParam == "" || lenParam == "" {
writeJSONError(w, http.StatusBadRequest, "Prefix and length parameters are required")
return return
} }
// URL decode the prefix parameter // Combine prefix and length into CIDR notation
prefix, err := url.QueryUnescape(prefixParam) prefix := prefixParam + "/" + lenParam
if err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid prefix parameter")
return
}
routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix) routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
if err != nil { if err != nil {
@ -903,21 +899,18 @@ func (s *Server) handleASDetail() http.HandlerFunc {
// handlePrefixDetail returns a handler that serves the prefix detail HTML page // handlePrefixDetail returns a handler that serves the prefix detail HTML page
func (s *Server) handlePrefixDetail() http.HandlerFunc { func (s *Server) handlePrefixDetail() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// Get wildcard parameter (everything after /prefix/) // Get prefix and length from URL params
prefixParam := chi.URLParam(r, "*") prefixParam := chi.URLParam(r, "prefix")
if prefixParam == "" { lenParam := chi.URLParam(r, "len")
http.Error(w, "Prefix parameter is required", http.StatusBadRequest)
if prefixParam == "" || lenParam == "" {
http.Error(w, "Prefix and length parameters are required", http.StatusBadRequest)
return return
} }
// URL decode the prefix parameter // Combine prefix and length into CIDR notation
prefix, err := url.QueryUnescape(prefixParam) prefix := prefixParam + "/" + lenParam
if err != nil {
http.Error(w, "Invalid prefix parameter", http.StatusBadRequest)
return
}
routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix) routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
if err != nil { if err != nil {

View File

@ -28,7 +28,7 @@ func (s *Server) setupRoutes() {
// AS and prefix detail pages // AS and prefix detail pages
r.Get("/as/{asn}", s.handleASDetail()) r.Get("/as/{asn}", s.handleASDetail())
r.Get("/prefix/*", s.handlePrefixDetail()) r.Get("/prefix/{prefix}/{len}", s.handlePrefixDetail())
r.Get("/prefixlength/{length}", s.handlePrefixLength()) r.Get("/prefixlength/{length}", s.handlePrefixLength())
r.Get("/prefixlength6/{length}", s.handlePrefixLength6()) r.Get("/prefixlength6/{length}", s.handlePrefixLength6())
@ -45,7 +45,7 @@ func (s *Server) setupRoutes() {
r.Get("/stats", s.handleStats()) r.Get("/stats", s.handleStats())
r.Get("/ip/{ip}", s.handleIPLookup()) r.Get("/ip/{ip}", s.handleIPLookup())
r.Get("/as/{asn}", s.handleASDetailJSON()) r.Get("/as/{asn}", s.handleASDetailJSON())
r.Get("/prefix/*", s.handlePrefixDetailJSON()) r.Get("/prefix/{prefix}/{len}", s.handlePrefixDetailJSON())
}) })
s.router = r s.router = r

View File

@ -182,7 +182,7 @@
<tbody> <tbody>
{{range .IPv4Prefixes}} {{range .IPv4Prefixes}}
<tr> <tr>
<td><a href="/prefix/{{.Prefix | urlEncode}}" class="prefix-link">{{.Prefix}}</a></td> <td><a href="{{.Prefix | prefixURL}}" class="prefix-link">{{.Prefix}}</a></td>
<td>/{{.MaskLength}}</td> <td>/{{.MaskLength}}</td>
<td>{{.LastUpdated.Format "2006-01-02 15:04:05"}}</td> <td>{{.LastUpdated.Format "2006-01-02 15:04:05"}}</td>
<td class="age">{{.LastUpdated | timeSince}}</td> <td class="age">{{.LastUpdated | timeSince}}</td>
@ -211,7 +211,7 @@
<tbody> <tbody>
{{range .IPv6Prefixes}} {{range .IPv6Prefixes}}
<tr> <tr>
<td><a href="/prefix/{{.Prefix | urlEncode}}" class="prefix-link">{{.Prefix}}</a></td> <td><a href="{{.Prefix | prefixURL}}" class="prefix-link">{{.Prefix}}</a></td>
<td>/{{.MaskLength}}</td> <td>/{{.MaskLength}}</td>
<td>{{.LastUpdated.Format "2006-01-02 15:04:05"}}</td> <td>{{.LastUpdated.Format "2006-01-02 15:04:05"}}</td>
<td class="age">{{.LastUpdated | timeSince}}</td> <td class="age">{{.LastUpdated | timeSince}}</td>

View File

@ -93,7 +93,7 @@
<tbody> <tbody>
{{ range .Prefixes }} {{ range .Prefixes }}
<tr> <tr>
<td><a href="/prefix/{{ .Prefix | urlEncode }}" class="prefix-link">{{ .Prefix }}</a></td> <td><a href="{{ .Prefix | prefixURL }}" class="prefix-link">{{ .Prefix }}</a></td>
<td class="age">{{ .Age }}</td> <td class="age">{{ .Age }}</td>
<td> <td>
<a href="/as/{{ .OriginASN }}" class="as-link"> <a href="/as/{{ .OriginASN }}" class="as-link">

View File

@ -5,6 +5,7 @@ import (
_ "embed" _ "embed"
"html/template" "html/template"
"net/url" "net/url"
"strings"
"sync" "sync"
"time" "time"
@ -43,8 +44,9 @@ var (
) )
const ( const (
hoursPerDay = 24 hoursPerDay = 24
daysPerMonth = 30 daysPerMonth = 30
cidrPartCount = 2 // A CIDR has two parts: prefix and length
) )
// timeSince returns a human-readable duration since the given time // timeSince returns a human-readable duration since the given time
@ -80,6 +82,20 @@ func timeSince(t time.Time) string {
return t.Format("2006-01-02") return t.Format("2006-01-02")
} }
// prefixURL generates a URL path for a prefix in CIDR notation.
// Takes a prefix like "192.168.1.0/24" and returns "/prefix/192.168.1.0/24"
// with the prefix part URL-encoded to handle IPv6 colons.
func prefixURL(cidr string) string {
// Split CIDR into prefix and length
parts := strings.SplitN(cidr, "/", cidrPartCount)
if len(parts) != cidrPartCount {
// Fallback if no slash found
return "/prefix/" + url.PathEscape(cidr) + "/0"
}
return "/prefix/" + url.PathEscape(parts[0]) + "/" + parts[1]
}
// initTemplates parses all embedded templates // initTemplates parses all embedded templates
func initTemplates() { func initTemplates() {
var err error var err error
@ -90,6 +106,7 @@ func initTemplates() {
funcs := template.FuncMap{ funcs := template.FuncMap{
"timeSince": timeSince, "timeSince": timeSince,
"urlEncode": url.QueryEscape, "urlEncode": url.QueryEscape,
"prefixURL": prefixURL,
"appName": func() string { return version.Name }, "appName": func() string { return version.Name },
"appAuthor": func() string { return version.Author }, "appAuthor": func() string { return version.Author },
"appAuthorURL": func() string { return version.AuthorURL }, "appAuthorURL": func() string { return version.AuthorURL },