浏览代码

refactor(urllib): replace AbsoluteURL and GetAbsoluteURL

Frédéric Guillot 2 月之前
父节点
当前提交
bb05b25530

+ 3 - 3
internal/reader/atom/atom_03_adapter.go

@@ -24,7 +24,7 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
 	// Populate the feed URL.
 	feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
 	if feedURL != "" {
-		if absoluteFeedURL, err := urllib.AbsoluteURL(baseURL, feedURL); err == nil {
+		if absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(baseURL, feedURL); err == nil {
 			feed.FeedURL = absoluteFeedURL
 		}
 	} else {
@@ -34,7 +34,7 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
 	// Populate the site URL.
 	siteURL := a.atomFeed.Links.originalLink()
 	if siteURL != "" {
-		if absoluteSiteURL, err := urllib.AbsoluteURL(baseURL, siteURL); err == nil {
+		if absoluteSiteURL, err := urllib.ResolveToAbsoluteURL(baseURL, siteURL); err == nil {
 			feed.SiteURL = absoluteSiteURL
 		}
 	} else {
@@ -53,7 +53,7 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
 		// Populate the entry URL.
 		entry.URL = atomEntry.Links.originalLink()
 		if entry.URL != "" {
-			if absoluteEntryURL, err := urllib.AbsoluteURL(feed.SiteURL, entry.URL); err == nil {
+			if absoluteEntryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, entry.URL); err == nil {
 				entry.URL = absoluteEntryURL
 			}
 		}

+ 9 - 9
internal/reader/atom/atom_10_adapter.go

@@ -32,7 +32,7 @@ func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
 	// Populate the feed URL.
 	feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
 	if feedURL != "" {
-		if absoluteFeedURL, err := urllib.AbsoluteURL(baseURL, feedURL); err == nil {
+		if absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(baseURL, feedURL); err == nil {
 			feed.FeedURL = absoluteFeedURL
 		}
 	} else {
@@ -42,7 +42,7 @@ func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
 	// Populate the site URL.
 	siteURL := a.atomFeed.Links.originalLink()
 	if siteURL != "" {
-		if absoluteSiteURL, err := urllib.AbsoluteURL(baseURL, siteURL); err == nil {
+		if absoluteSiteURL, err := urllib.ResolveToAbsoluteURL(baseURL, siteURL); err == nil {
 			feed.SiteURL = absoluteSiteURL
 		}
 	} else {
@@ -60,11 +60,11 @@ func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
 
 	// Populate the feed icon.
 	if a.atomFeed.Icon != "" {
-		if absoluteIconURL, err := urllib.AbsoluteURL(feed.SiteURL, a.atomFeed.Icon); err == nil {
+		if absoluteIconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, a.atomFeed.Icon); err == nil {
 			feed.IconURL = absoluteIconURL
 		}
 	} else if a.atomFeed.Logo != "" {
-		if absoluteLogoURL, err := urllib.AbsoluteURL(feed.SiteURL, a.atomFeed.Logo); err == nil {
+		if absoluteLogoURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, a.atomFeed.Logo); err == nil {
 			feed.IconURL = absoluteLogoURL
 		}
 	}
@@ -81,7 +81,7 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
 		// Populate the entry URL.
 		entry.URL = atomEntry.Links.originalLink()
 		if entry.URL != "" {
-			if absoluteEntryURL, err := urllib.AbsoluteURL(siteURL, entry.URL); err == nil {
+			if absoluteEntryURL, err := urllib.ResolveToAbsoluteURL(siteURL, entry.URL); err == nil {
 				entry.URL = absoluteEntryURL
 			}
 		}
@@ -168,7 +168,7 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
 				continue
 			}
 			if _, found := uniqueEnclosuresMap[mediaURL]; !found {
-				if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
+				if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
 					slog.Debug("Unable to build absolute URL for media thumbnail",
 						slog.String("url", mediaThumbnail.URL),
 						slog.String("site_url", siteURL),
@@ -186,7 +186,7 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
 		}
 
 		for _, link := range atomEntry.Links.findAllLinksWithRelation("enclosure") {
-			absoluteEnclosureURL, err := urllib.AbsoluteURL(siteURL, link.Href)
+			absoluteEnclosureURL, err := urllib.ResolveToAbsoluteURL(siteURL, link.Href)
 			if err != nil {
 				slog.Debug("Unable to resolve absolute URL for enclosure",
 					slog.String("enclosure_url", link.Href),
@@ -211,7 +211,7 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
 			if mediaURL == "" {
 				continue
 			}
-			if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
+			if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
 				slog.Debug("Unable to build absolute URL for media content",
 					slog.String("url", mediaContent.URL),
 					slog.String("site_url", siteURL),
@@ -234,7 +234,7 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
 			if mediaURL == "" {
 				continue
 			}
-			if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
+			if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
 				slog.Debug("Unable to build absolute URL for media peer link",
 					slog.String("url", mediaPeerLink.URL),
 					slog.String("site_url", siteURL),

+ 1 - 1
internal/reader/icon/finder.go

@@ -275,7 +275,7 @@ func findIconURLsFromHTMLDocument(documentURL string, body io.Reader, contentTyp
 				continue
 			}
 
-			if absoluteIconURL, err := urllib.AbsoluteURL(documentURL, href); err != nil {
+			if absoluteIconURL, err := urllib.ResolveToAbsoluteURL(documentURL, href); err != nil {
 				slog.Warn("Unable to convert icon URL to absolute URL", slog.Any("error", err), slog.String("icon_href", href))
 			} else {
 				iconURLs = append(iconURLs, absoluteIconURL)

+ 5 - 5
internal/reader/json/adapter.go

@@ -41,11 +41,11 @@ func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
 		feed.SiteURL = feed.FeedURL
 	}
 
-	if feedURL, err := urllib.AbsoluteURL(baseURL, feed.FeedURL); err == nil {
+	if feedURL, err := urllib.ResolveToAbsoluteURL(baseURL, feed.FeedURL); err == nil {
 		feed.FeedURL = feedURL
 	}
 
-	if siteURL, err := urllib.AbsoluteURL(baseURL, feed.SiteURL); err == nil {
+	if siteURL, err := urllib.ResolveToAbsoluteURL(baseURL, feed.SiteURL); err == nil {
 		feed.SiteURL = siteURL
 	}
 
@@ -58,7 +58,7 @@ func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
 	for _, iconURL := range []string{j.jsonFeed.FaviconURL, j.jsonFeed.IconURL} {
 		iconURL = strings.TrimSpace(iconURL)
 		if iconURL != "" {
-			if absoluteIconURL, err := urllib.AbsoluteURL(feed.SiteURL, iconURL); err == nil {
+			if absoluteIconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, iconURL); err == nil {
 				feed.IconURL = absoluteIconURL
 				break
 			}
@@ -73,7 +73,7 @@ func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
 			itemURL = strings.TrimSpace(itemURL)
 			if itemURL != "" {
 				// Make sure the entry URL is absolute.
-				if entryURL, err := urllib.AbsoluteURL(feed.SiteURL, itemURL); err == nil {
+				if entryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, itemURL); err == nil {
 					entry.URL = entryURL
 				}
 				break
@@ -144,7 +144,7 @@ func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
 		for _, attachment := range item.Attachments {
 			attachmentURL := strings.TrimSpace(attachment.URL)
 			if attachmentURL != "" {
-				if absoluteAttachmentURL, err := urllib.AbsoluteURL(feed.SiteURL, attachmentURL); err == nil {
+				if absoluteAttachmentURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, attachmentURL); err == nil {
 					entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
 						URL:      absoluteAttachmentURL,
 						MimeType: attachment.MimeType,

+ 2 - 2
internal/reader/rdf/adapter.go

@@ -32,7 +32,7 @@ func (r *rdfAdapter) buildFeed(baseURL string) *model.Feed {
 		feed.Title = baseURL
 	}
 
-	if siteURL, err := urllib.AbsoluteURL(feed.FeedURL, feed.SiteURL); err == nil {
+	if siteURL, err := urllib.ResolveToAbsoluteURL(feed.FeedURL, feed.SiteURL); err == nil {
 		feed.SiteURL = siteURL
 	}
 
@@ -43,7 +43,7 @@ func (r *rdfAdapter) buildFeed(baseURL string) *model.Feed {
 		// Populate the entry URL.
 		if itemLink == "" {
 			entry.URL = feed.SiteURL // Fallback to the feed URL if the entry URL is empty.
-		} else if entryURL, err := urllib.AbsoluteURL(feed.SiteURL, itemLink); err == nil {
+		} else if entryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, itemLink); err == nil {
 			entry.URL = entryURL
 		} else {
 			entry.URL = itemLink

+ 8 - 8
internal/reader/rss/adapter.go

@@ -32,7 +32,7 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
 	}
 
 	// Ensure the Site URL is absolute.
-	if absoluteSiteURL, err := urllib.AbsoluteURL(baseURL, feed.SiteURL); err == nil {
+	if absoluteSiteURL, err := urllib.ResolveToAbsoluteURL(baseURL, feed.SiteURL); err == nil {
 		feed.SiteURL = absoluteSiteURL
 	}
 
@@ -40,7 +40,7 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
 	for _, atomLink := range r.rss.Channel.Links {
 		atomLinkHref := strings.TrimSpace(atomLink.Href)
 		if atomLinkHref != "" && atomLink.Rel == "self" {
-			if absoluteFeedURL, err := urllib.AbsoluteURL(feed.FeedURL, atomLinkHref); err == nil {
+			if absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(feed.FeedURL, atomLinkHref); err == nil {
 				feed.FeedURL = absoluteFeedURL
 				break
 			}
@@ -61,7 +61,7 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
 
 	// Get the feed icon URL if defined.
 	if r.rss.Channel.Image != nil {
-		if absoluteIconURL, err := urllib.AbsoluteURL(feed.SiteURL, r.rss.Channel.Image.URL); err == nil {
+		if absoluteIconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, r.rss.Channel.Image.URL); err == nil {
 			feed.IconURL = absoluteIconURL
 		}
 	}
@@ -83,7 +83,7 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
 				entry.URL = feed.SiteURL
 			}
 		} else {
-			if absoluteEntryURL, err := urllib.AbsoluteURL(feed.SiteURL, entryURL); err == nil {
+			if absoluteEntryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, entryURL); err == nil {
 				entry.URL = absoluteEntryURL
 			} else {
 				entry.URL = entryURL
@@ -309,7 +309,7 @@ func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
 			continue
 		}
 		if _, found := duplicates[mediaURL]; !found {
-			if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
+			if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
 				slog.Debug("Unable to build absolute URL for media thumbnail",
 					slog.String("url", mediaThumbnail.URL),
 					slog.String("site_url", siteURL),
@@ -341,7 +341,7 @@ func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
 			continue
 		}
 
-		if absoluteEnclosureURL, err := urllib.AbsoluteURL(siteURL, enclosureURL); err == nil {
+		if absoluteEnclosureURL, err := urllib.ResolveToAbsoluteURL(siteURL, enclosureURL); err == nil {
 			enclosureURL = absoluteEnclosureURL
 		}
 
@@ -363,7 +363,7 @@ func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
 		}
 		if _, found := duplicates[mediaURL]; !found {
 			mediaURL := strings.TrimSpace(mediaContent.URL)
-			if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
+			if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
 				slog.Debug("Unable to build absolute URL for media content",
 					slog.String("url", mediaContent.URL),
 					slog.String("site_url", siteURL),
@@ -387,7 +387,7 @@ func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
 		}
 		if _, found := duplicates[mediaURL]; !found {
 			mediaURL := strings.TrimSpace(mediaPeerLink.URL)
-			if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
+			if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
 				slog.Debug("Unable to build absolute URL for media peer link",
 					slog.String("url", mediaPeerLink.URL),
 					slog.String("site_url", siteURL),

+ 2 - 18
internal/reader/sanitizer/sanitizer.go

@@ -374,7 +374,7 @@ func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []htm
 				value = attribute.Val
 				isAnchorLink = true
 			default:
-				value, err = absoluteURLParsedBase(parsedBaseUrl, value)
+				value, err = urllib.ResolveToAbsoluteURLWithParsedBaseURL(parsedBaseUrl, value)
 				if err != nil {
 					continue
 				}
@@ -575,7 +575,7 @@ func sanitizeSrcsetAttr(parsedBaseURL *url.URL, value string) string {
 	imageCandidates := ParseSrcSetAttribute(value)
 
 	for _, imageCandidate := range imageCandidates {
-		if absoluteURL, err := absoluteURLParsedBase(parsedBaseURL, imageCandidate.ImageURL); err == nil {
+		if absoluteURL, err := urllib.ResolveToAbsoluteURLWithParsedBaseURL(parsedBaseURL, imageCandidate.ImageURL); err == nil {
 			imageCandidate.ImageURL = absoluteURL
 		}
 	}
@@ -627,19 +627,3 @@ func isValidDecodingValue(value string) bool {
 	}
 	return false
 }
-
-// absoluteURLParsedBase is used instead of urllib.AbsoluteURL to avoid parsing baseURL over and over.
-func absoluteURLParsedBase(parsedBaseURL *url.URL, input string) (string, error) {
-	absURL, u, err := urllib.GetAbsoluteURL(input)
-	if err != nil {
-		return "", err
-	}
-	if absURL != "" {
-		return absURL, nil
-	}
-	if parsedBaseURL == nil {
-		return "", nil
-	}
-
-	return parsedBaseURL.ResolveReference(u).String(), nil
-}

+ 4 - 4
internal/reader/subscription/finder.go

@@ -149,7 +149,7 @@ func (f *subscriptionFinder) findSubscriptionsFromWebPage(websiteURL, contentTyp
 			subscription.Type = kind
 
 			if feedURL, exists := s.Attr("href"); exists && feedURL != "" {
-				subscription.URL, err = urllib.AbsoluteURL(websiteURL, feedURL)
+				subscription.URL, err = urllib.ResolveToAbsoluteURL(websiteURL, feedURL)
 				if err != nil {
 					return
 				}
@@ -195,7 +195,7 @@ func (f *subscriptionFinder) findSubscriptionsFromWellKnownURLs(websiteURL strin
 	}
 
 	// Look for knownURLs in current subdirectory, such as 'example.com/blog/'.
-	websiteURL, _ = urllib.AbsoluteURL(websiteURL, "./")
+	websiteURL, _ = urllib.ResolveToAbsoluteURL(websiteURL, "./")
 	if websiteURL != websiteURLRoot {
 		baseURLs = append(baseURLs, websiteURL)
 	}
@@ -203,7 +203,7 @@ func (f *subscriptionFinder) findSubscriptionsFromWellKnownURLs(websiteURL strin
 	var subscriptions Subscriptions
 	for _, baseURL := range baseURLs {
 		for knownURL, kind := range knownURLs {
-			fullURL, err := urllib.AbsoluteURL(baseURL, knownURL)
+			fullURL, err := urllib.ResolveToAbsoluteURL(baseURL, knownURL)
 			if err != nil {
 				continue
 			}
@@ -347,7 +347,7 @@ func (f *subscriptionFinder) findCanonicalURL(effectiveURL, contentType string,
 		return effectiveURL
 	}
 
-	canonicalURL, err := urllib.AbsoluteURL(baseURL, strings.TrimSpace(canonicalHref))
+	canonicalURL, err := urllib.ResolveToAbsoluteURL(baseURL, strings.TrimSpace(canonicalHref))
 	if err != nil {
 		return effectiveURL
 	}

+ 57 - 44
internal/urllib/url.go

@@ -12,7 +12,7 @@ import (
 	"strings"
 )
 
-// IsRelativePath returns true if the link is a relative path.
+// IsRelativePath reports whether the link is a relative path (no scheme, host, or scheme-relative // form).
 func IsRelativePath(link string) bool {
 	if link == "" {
 		return false
@@ -27,73 +27,87 @@ func IsRelativePath(link string) bool {
 	return false
 }
 
-// IsAbsoluteURL returns true if the link is absolute.
-func IsAbsoluteURL(link string) bool {
-	u, err := url.Parse(link)
+// hasHTTPPrefix reports whether the URL string begins with an HTTP or HTTPS scheme.
+func hasHTTPPrefix(inputURL string) bool {
+	return strings.HasPrefix(inputURL, "https://") || strings.HasPrefix(inputURL, "http://")
+}
+
+// IsAbsoluteURL reports whether the link is absolute.
+func IsAbsoluteURL(inputURL string) bool {
+	if hasHTTPPrefix(inputURL) {
+		return true
+	}
+	parsedURL, err := url.Parse(inputURL)
 	if err != nil {
 		return false
 	}
-	return u.IsAbs()
+	return parsedURL.IsAbs()
 }
 
-// GetAbsoluteURL returns the absolute form of `input` if possible, as well as its parsed form.
-func GetAbsoluteURL(input string) (string, *url.URL, error) {
-	if strings.HasPrefix(input, "//") {
-		return "https:" + input, nil, nil
+// resolveToAbsoluteURL resolves a relative URL using a base URL, parsing the base only if needed.
+func resolveToAbsoluteURL(parsedBaseURL *url.URL, baseURL, relativeURL string) (string, error) {
+	// Avoid parsing the relative URL if it's already absolute
+	if strings.HasPrefix(relativeURL, "//") {
+		return "https:" + relativeURL, nil
 	}
-	if strings.HasPrefix(input, "https://") || strings.HasPrefix(input, "http://") {
-		return input, nil, nil
+	if hasHTTPPrefix(relativeURL) {
+		return relativeURL, nil
 	}
 
-	u, err := url.Parse(input)
+	// Parse the relative URL and check if it's already absolute
+	parsedRelativeURL, err := url.Parse(relativeURL)
 	if err != nil {
-		return "", nil, fmt.Errorf("unable to parse input URL: %v", err)
+		return "", fmt.Errorf("unable to parse relative URL: %w", err)
 	}
-
-	if u.IsAbs() {
-		return u.String(), u, nil
+	if parsedRelativeURL.IsAbs() {
+		return relativeURL, nil
 	}
-	return "", u, nil
-}
 
-// AbsoluteURL converts the input URL as absolute URL if necessary.
-func AbsoluteURL(baseURL, input string) (string, error) {
-	absURL, u, err := GetAbsoluteURL(input)
-	if err != nil {
-		return "", err
-	}
-	if absURL != "" {
-		return absURL, nil
+	// Parse the base URL if not already parsed
+	if parsedBaseURL == nil {
+		parsedBaseURL, err = url.Parse(baseURL)
+		if err != nil {
+			return "", fmt.Errorf("unable to parse base URL: %w", err)
+		}
 	}
 
-	base, err := url.Parse(baseURL)
-	if err != nil {
-		return "", fmt.Errorf("unable to parse base URL: %v", err)
-	}
+	return parsedBaseURL.ResolveReference(parsedRelativeURL).String(), nil
+}
 
-	return base.ResolveReference(u).String(), nil
+// ResolveToAbsoluteURL resolves a relative URL against a base URL and returns the absolute URL.
+func ResolveToAbsoluteURL(baseURL, relativeURL string) (string, error) {
+	return resolveToAbsoluteURL(nil, baseURL, relativeURL)
 }
 
-// RootURL returns absolute URL without the path.
+// ResolveToAbsoluteURLWithParsedBaseURL resolves a relative URL using a pre-parsed base URL and returns the absolute URL.
+func ResolveToAbsoluteURLWithParsedBaseURL(parsedBaseURL *url.URL, relativeURL string) (string, error) {
+	return resolveToAbsoluteURL(parsedBaseURL, "", relativeURL)
+}
+
+// RootURL returns the scheme and host of the given URL with a trailing slash.
 func RootURL(websiteURL string) string {
+	if websiteURL == "" {
+		return ""
+	}
+
 	if strings.HasPrefix(websiteURL, "//") {
 		websiteURL = "https://" + websiteURL[2:]
 	}
 
-	absoluteURL, err := AbsoluteURL(websiteURL, "")
-	if err != nil {
+	u, err := url.Parse(websiteURL)
+	if err != nil || u.Scheme == "" || u.Host == "" {
 		return websiteURL
 	}
 
-	u, err := url.Parse(absoluteURL)
-	if err != nil {
-		return absoluteURL
-	}
+	u.Fragment = ""
+	u.RawQuery = ""
+	u.Path = "/"
+	u.RawPath = ""
 
 	return u.Scheme + "://" + u.Host + "/"
 }
 
-// IsHTTPS returns true if the URL is using HTTPS.
+// IsHTTPS reports whether the URL uses HTTPS.
 func IsHTTPS(websiteURL string) bool {
 	parsedURL, err := url.Parse(websiteURL)
 	if err != nil {
@@ -103,7 +117,7 @@ func IsHTTPS(websiteURL string) bool {
 	return strings.EqualFold(parsedURL.Scheme, "https")
 }
 
-// Domain returns only the domain part of the given URL.
+// Domain returns the host component of the given URL.
 func Domain(websiteURL string) string {
 	parsedURL, err := url.Parse(websiteURL)
 	if err != nil {
@@ -113,12 +127,12 @@ func Domain(websiteURL string) string {
 	return parsedURL.Host
 }
 
-// DomainWithoutWWW returns only the domain part of the given URL, with the "www." prefix removed if present.
+// DomainWithoutWWW returns the host component without a leading "www." prefix when present.
 func DomainWithoutWWW(websiteURL string) string {
 	return strings.TrimPrefix(Domain(websiteURL), "www.")
 }
 
-// JoinBaseURLAndPath returns a URL string with the provided path elements joined together.
+// JoinBaseURLAndPath joins a base URL and a path segment into a single URL string.
 func JoinBaseURLAndPath(baseURL, path string) (string, error) {
 	if baseURL == "" {
 		return "", errors.New("empty base URL")
@@ -141,8 +155,7 @@ func JoinBaseURLAndPath(baseURL, path string) (string, error) {
 	return finalURL, nil
 }
 
-// ResolvesToPrivateIP resolves a hostname and returns true if
-// ANY resolved IP address is non-public.
+// ResolvesToPrivateIP resolves a hostname and reports whether any resolved IP address is non-public.
 func ResolvesToPrivateIP(host string) (bool, error) {
 	ips, err := net.LookupIP(host)
 	if err != nil {

+ 81 - 21
internal/urllib/url_test.go

@@ -5,6 +5,7 @@ package urllib // import "miniflux.app/v2/internal/urllib"
 
 import (
 	"net"
+	"net/url"
 	"testing"
 )
 
@@ -46,7 +47,8 @@ func TestIsAbsoluteURL(t *testing.T) {
 	scenarios := map[string]bool{
 		"https://example.org/file.pdf": true,
 		"magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C&xt.2=urn:sha1:TXGCZQTH26NL6OUQAJJPFALHG2LTGBC7": true,
-		"invalid url": false,
+		"invalid url":    false,
+		"/relative/path": false,
 	}
 
 	for input, expected := range scenarios {
@@ -58,36 +60,78 @@ func TestIsAbsoluteURL(t *testing.T) {
 }
 
 func TestAbsoluteURL(t *testing.T) {
-	scenarios := [][]string{
-		{"https://example.org/path/file.ext", "https://example.org/folder/", "/path/file.ext"},
-		{"https://example.org/folder/path/file.ext", "https://example.org/folder/", "path/file.ext"},
-		{"https://example.org/", "https://example.org/path", "./"},
-		{"https://example.org/folder/", "https://example.org/folder/", "./"},
-		{"https://example.org/path/file.ext", "https://example.org/folder", "path/file.ext"},
-		{"https://example.org/path/file.ext", "https://example.org/folder/", "https://example.org/path/file.ext"},
-		{"https://static.example.org/path/file.ext", "https://www.example.org/", "//static.example.org/path/file.ext"},
-		{"magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a", "https://www.example.org/", "magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a"},
-		{"magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C&xt.2=urn:sha1:TXGCZQTH26NL6OUQAJJPFALHG2LTGBC7", "https://www.example.org/", "magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C&xt.2=urn:sha1:TXGCZQTH26NL6OUQAJJPFALHG2LTGBC7"},
+	type absoluteScenario struct {
+		name          string
+		base          string
+		relative      string
+		expected      string
+		wantErr       bool
+		runWithParsed bool
+		useNilParsed  bool
+	}
+
+	scenarios := []absoluteScenario{
+		{"absolute path", "https://example.org/folder/", "/path/file.ext", "https://example.org/path/file.ext", false, true, false},
+		{"relative path", "https://example.org/folder/", "path/file.ext", "https://example.org/folder/path/file.ext", false, true, false},
+		{"dot path root", "https://example.org/path", "./", "https://example.org/", false, true, false},
+		{"dot path folder", "https://example.org/folder/", "./", "https://example.org/folder/", false, true, false},
+		{"missing slash in base", "https://example.org/folder", "path/file.ext", "https://example.org/path/file.ext", false, true, false},
+		{"already absolute", "https://example.org/folder/", "https://example.org/path/file.ext", "https://example.org/path/file.ext", false, true, false},
+		{"protocol relative", "https://www.example.org/", "//static.example.org/path/file.ext", "https://static.example.org/path/file.ext", false, true, false},
+		{"magnet keeps scheme", "https://www.example.org/", "magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a", "magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a", false, true, false},
+		{"magnet with query", "https://www.example.org/", "magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C&xt.2=urn:sha1:TXGCZQTH26NL6OUQAJJPFALHG2LTGBC7", "magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C&xt.2=urn:sha1:TXGCZQTH26NL6OUQAJJPFALHG2LTGBC7", false, true, false},
+		{"empty relative returns base", "https://example.org/folder/", "", "https://example.org/folder/", false, true, false},
+		{"invalid base errors", "://bad", "path/file.ext", "", true, false, false},
+		{"absolute ignores invalid base", "://bad", "https://example.org/path/file.ext", "https://example.org/path/file.ext", false, true, true},
 	}
 
 	for _, scenario := range scenarios {
-		actual, err := AbsoluteURL(scenario[1], scenario[2])
+		t.Run(scenario.name, func(t *testing.T) {
+			actual, err := ResolveToAbsoluteURL(scenario.base, scenario.relative)
+			if scenario.wantErr {
+				if err == nil {
+					t.Fatalf("expected error for base %q relative %q", scenario.base, scenario.relative)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("unexpected error for base %q relative %q: %v", scenario.base, scenario.relative, err)
+			}
+			if actual != scenario.expected {
+				t.Fatalf("unexpected result, got %q instead of %q for (%q, %q)", actual, scenario.expected, scenario.base, scenario.relative)
+			}
 
-		if err != nil {
-			t.Errorf(`Got error for (%q, %q): %v`, scenario[1], scenario[2], err)
-		}
+			if scenario.runWithParsed {
+				var parsedBase *url.URL
+				if !scenario.useNilParsed && scenario.base != "" {
+					var parseErr error
+					parsedBase, parseErr = url.Parse(scenario.base)
+					if parseErr != nil {
+						t.Fatalf("unable to parse base %q: %v", scenario.base, parseErr)
+					}
+				}
 
-		if actual != scenario[0] {
-			t.Errorf(`Unexpected result, got %q instead of %q for (%q, %q)`, actual, scenario[0], scenario[1], scenario[2])
-		}
+				actualParsed, errParsed := ResolveToAbsoluteURLWithParsedBaseURL(parsedBase, scenario.relative)
+				if errParsed != nil {
+					t.Fatalf("unexpected error with parsed base for (%q, %q): %v", scenario.base, scenario.relative, errParsed)
+				}
+				if actualParsed != scenario.expected {
+					t.Fatalf("unexpected parsed-base result, got %q instead of %q for (%q, %q)", actualParsed, scenario.expected, scenario.base, scenario.relative)
+				}
+			}
+		})
 	}
 }
 
 func TestRootURL(t *testing.T) {
 	scenarios := map[string]string{
-		"https://example.org/path/file.ext":  "https://example.org/",
-		"//static.example.org/path/file.ext": "https://static.example.org/",
-		"https://example|org/path/file.ext":  "https://example|org/path/file.ext",
+		"":                                  "",
+		"https://example.org/path/file.ext": "https://example.org/",
+		"https://example.org/path/file.ext?test=abc": "https://example.org/",
+		"//static.example.org/path/file.ext":         "https://static.example.org/",
+		"https://example|org/path/file.ext":          "https://example|org/path/file.ext",
+		"/relative/path":                             "/relative/path",
+		"http://example.org:8080/path":               "http://example.org:8080/",
 	}
 
 	for input, expected := range scenarios {
@@ -127,6 +171,22 @@ func TestDomain(t *testing.T) {
 	}
 }
 
+func TestDomainWithoutWWW(t *testing.T) {
+	scenarios := map[string]string{
+		"https://www.example.org/":     "example.org",
+		"https://example.org/":         "example.org",
+		"https://www.sub.example.org/": "sub.example.org",
+		"https://example|org/":         "https://example|org/",
+	}
+
+	for input, expected := range scenarios {
+		actual := DomainWithoutWWW(input)
+		if actual != expected {
+			t.Errorf(`Unexpected result, got %q instead of %q`, actual, expected)
+		}
+	}
+}
+
 func TestJoinBaseURLAndPath(t *testing.T) {
 	type args struct {
 		baseURL string