Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f7e39fb
fix: Context.BaseURL() now returns full BASE_URL in single-host mode
3rg0n Feb 11, 2026
9fcb206
feat: Add full sub-path hosting support for all routes and links
3rg0n Feb 11, 2026
86469a4
fix: Remove unused Fider import in Home.page.tsx
3rg0n Feb 11, 2026
750c518
docs: Add comprehensive testing matrix for sub-path hosting
3rg0n Feb 13, 2026
c6ca62e
test: Add comprehensive local HTTPS test environment
3rg0n Feb 13, 2026
03a92e0
fix: Add BasePath() for redirect sub-path support and fix lint issues
3rg0n Feb 16, 2026
b19f325
Merge branch 'main' into fix/subpath-hosting
mattwoberts Mar 5, 2026
c75f654
Merge remote-tracking branch 'upstream/main' into fix/subpath-hosting
3rg0n Apr 29, 2026
343cf32
chore: Remove root-level test infrastructure files
3rg0n Apr 29, 2026
02140d9
refactor: Consolidate basePath-prepending logic into resolveHref()
3rg0n Apr 29, 2026
01dcc08
refactor: Auto-resolve href in Button and Dropdown.ListItem
3rg0n Apr 29, 2026
8a6160f
feat: Add Link component and migrate 6 missed hrefs
3rg0n Apr 29, 2026
85c995f
refactor: Add c.RedirectTo(path) helper and migrate call sites
3rg0n Apr 29, 2026
fb3a4f1
feat: Add no-bare-root-href ESLint rule
3rg0n Apr 29, 2026
6d59f41
chore: Configure CommonJS env for eslint-rules directory
3rg0n Apr 29, 2026
8117636
test: Cover resolveHref() and <Link /> behavior
3rg0n Apr 29, 2026
f94cd69
style: Fix prettier formatting in no-bare-root-href rule
3rg0n Apr 29, 2026
2364994
Merge upstream/main into fix/subpath-hosting
3rg0n Jul 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,7 @@ module.exports = {
"react/jsx-no-target-blank": ["error", { allowReferrer: true }],
"@typescript-eslint/no-explicit-any": "off", // TODO: turn this on in future
"@typescript-eslint/explicit-module-boundary-types": "off", // TODO: turn this on in future
// Local rule loaded via `--rulesdir eslint-rules` on the lint-ui make target.
"no-bare-root-href": "error",
},
}
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ lint-server: ## Lint server code
golangci-lint run --timeout 3m

lint-ui: ## Lint ui code
npx eslint .
npx eslint --rulesdir eslint-rules .



Expand Down
8 changes: 4 additions & 4 deletions app/handlers/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ func OAuthEcho() web.HandlerFunc {

code := c.QueryParam("code")
if code == "" {
return c.Redirect("/")
return c.RedirectTo("/")
}

identifier := c.QueryParam("identifier")
if identifier == "" || identifier != c.SessionID() {
log.Warn(c, "OAuth identifier doesn't match with user session ID. Aborting sign in process.")
return c.Redirect("/")
return c.RedirectTo("/")
}

rawProfile := &query.GetOAuthRawProfile{Provider: provider, Code: code}
Expand Down Expand Up @@ -142,13 +142,13 @@ func OAuthToken() web.HandlerFunc {
"UserRoles": oauthUser.Result.Roles,
"AllowedRoles": providerAllowedRoles,
})
return c.Redirect("/access-denied")
return c.RedirectTo("/access-denied")
}
if err != nil {
if errors.Cause(err) == app.ErrNotFound {
isTrusted := customConfig != nil && customConfig.IsTrusted
if c.Tenant().IsPrivate && !isTrusted {
return c.Redirect("/not-invited")
return c.RedirectTo("/not-invited")
}

user = &entity.User{
Expand Down
2 changes: 1 addition & 1 deletion app/handlers/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func PostDetails() web.HandlerFunc {
}

if c.Param("slug") != getPost.Result.Slug {
return c.Redirect(fmt.Sprintf("/posts/%d/%s", getPost.Result.Number, getPost.Result.Slug))
return c.Redirect(fmt.Sprintf("%s/posts/%d/%s", c.BasePath(), getPost.Result.Number, getPost.Result.Slug))
}

isSubscribed := &query.UserSubscribedTo{PostID: getPost.Result.ID}
Expand Down
8 changes: 3 additions & 5 deletions app/handlers/signin.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,8 +351,7 @@ func VerifySignInKey(kind enum.EmailVerificationKind) web.HandlerFunc {
}

webutil.AddAuthUserCookie(c, user)
baseURL := c.BaseURL()
return c.Redirect(baseURL)
return c.Redirect(c.BaseURL())
}

// Otherwise, show profile completion page
Expand All @@ -376,8 +375,7 @@ func VerifySignInKey(kind enum.EmailVerificationKind) web.HandlerFunc {

webutil.AddAuthUserCookie(c, userByEmail.Result)

baseURL := c.BaseURL()
return c.Redirect(baseURL)
return c.Redirect(c.BaseURL())
}
}

Expand Down Expand Up @@ -426,6 +424,6 @@ func CompleteSignInProfile() web.HandlerFunc {
func SignOut() web.HandlerFunc {
return func(c *web.Context) error {
c.RemoveCookie(web.CookieAuthName)
return c.Redirect("/")
return c.RedirectTo("/")
}
}
2 changes: 1 addition & 1 deletion app/handlers/signup.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func SignUp() web.HandlerFunc {
}

if firstTenant.Result != nil {
return c.Redirect("/")
return c.RedirectTo("/")
}
} else {
baseURL := web.OAuthBaseURL(c)
Expand Down
6 changes: 3 additions & 3 deletions app/middlewares/tenant.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func RequireTenant() web.MiddlewareFunc {
tenant := c.Tenant()
if tenant == nil {
if env.IsSingleHostMode() {
return c.Redirect("/signup")
return c.RedirectTo("/signup")
}
return c.NotFound()
}
Expand Down Expand Up @@ -127,9 +127,9 @@ func CheckTenantPrivacy() web.MiddlewareFunc {
}

if redirectTarget != "" {
return c.Redirect("/signin?redirect=" + url.QueryEscape(redirectTarget))
return c.RedirectTo("/signin?redirect=" + url.QueryEscape(redirectTarget))
}
return c.Redirect("/signin")
return c.RedirectTo("/signin")
}
return next(c)
}
Expand Down
4 changes: 2 additions & 2 deletions app/middlewares/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ func User() web.MiddlewareFunc {
redirectTarget != "/" &&
!strings.HasPrefix(redirectTarget, "/signin") &&
!strings.HasPrefix(redirectTarget, "/signout") {
return c.Redirect("/signin?redirect=" + url.QueryEscape(redirectTarget))
return c.RedirectTo("/signin?redirect=" + url.QueryEscape(redirectTarget))
}
return c.Redirect("/signin")
return c.RedirectTo("/signin")
}
} else if c.Request.IsAPI() {
authHeader := c.Request.GetHeader("Authorization")
Expand Down
26 changes: 26 additions & 0 deletions app/pkg/web/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,9 +377,28 @@ func (c *Context) RemoveCookie(name string) {

// BaseURL returns base URL
func (c *Context) BaseURL() string {
if env.IsSingleHostMode() {
return env.Config.BaseURL
}
return c.Request.BaseURL()
}

// BasePath returns the path prefix from BASE_URL for sub-path hosting.
// Returns "" when Fider is hosted at the domain root, or "/feedback" when
// hosted at example.com/feedback. Use this for building redirect paths.
func (c *Context) BasePath() string {
if env.IsSingleHostMode() {
u, err := url.Parse(env.Config.BaseURL)
if err == nil {
p := strings.TrimRight(u.Path, "/")
if p != "" {
return p
}
}
}
return ""
}

// QueryParam returns querystring parameter for given key
func (c *Context) QueryParam(key string) string {
return c.Request.URL.Query().Get(key)
Expand Down Expand Up @@ -524,6 +543,13 @@ func (c *Context) Redirect(url string) error {
return nil
}

// RedirectTo redirects to a root-relative path, automatically prepending
// BasePath() for sub-path hosting. Callers pass natural paths (e.g. "/signin")
// instead of manually concatenating c.BasePath() + "/signin" at every site.
func (c *Context) RedirectTo(path string) error {
return c.Redirect(c.BasePath() + path)
}

// PermanentRedirect the request to a provided URL
func (c *Context) PermanentRedirect(url string) error {
c.Response.Header().Set("Cache-Control", "no-cache, no-store")
Expand Down
62 changes: 62 additions & 0 deletions app/pkg/web/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func TestContextID(t *testing.T) {

func TestBaseURL(t *testing.T) {
RegisterT(t)
env.Config.HostMode = "multi"

ctx := newGetContext("http://demo.test.fider.io:3000", nil)

Expand All @@ -59,6 +60,7 @@ func TestBaseURL(t *testing.T) {

func TestBaseURL_HTTPS(t *testing.T) {
RegisterT(t)
env.Config.HostMode = "multi"

ctx := newGetContext("https://demo.test.fider.io:3000", nil)

Expand All @@ -67,6 +69,7 @@ func TestBaseURL_HTTPS(t *testing.T) {

func TestBaseURL_HTTPS_Proxy(t *testing.T) {
RegisterT(t)
env.Config.HostMode = "multi"

ctx := newGetContext("http://demo.test.fider.io:3000", map[string]string{
"X-Forwarded-Proto": "https",
Expand All @@ -75,6 +78,65 @@ func TestBaseURL_HTTPS_Proxy(t *testing.T) {
Expect(ctx.BaseURL()).Equals("https://demo.test.fider.io:3000")
}

func TestBaseURL_SingleHostMode(t *testing.T) {
RegisterT(t)
env.Config.HostMode = "single"
env.Config.BaseURL = "https://example.com"

ctx := newGetContext("http://demo.test.fider.io:3000", nil)

Expect(ctx.BaseURL()).Equals("https://example.com")
}

func TestBaseURL_SingleHostMode_WithPath(t *testing.T) {
RegisterT(t)
env.Config.HostMode = "single"
env.Config.BaseURL = "https://example.com/feedback"

ctx := newGetContext("http://demo.test.fider.io:3000", nil)

Expect(ctx.BaseURL()).Equals("https://example.com/feedback")
}

func TestBasePath_MultiHostMode(t *testing.T) {
RegisterT(t)
env.Config.HostMode = "multi"

ctx := newGetContext("http://demo.test.fider.io:3000", nil)

Expect(ctx.BasePath()).Equals("")
}

func TestBasePath_SingleHostMode_NoPath(t *testing.T) {
RegisterT(t)
env.Config.HostMode = "single"
env.Config.BaseURL = "https://example.com"

ctx := newGetContext("http://demo.test.fider.io:3000", nil)

Expect(ctx.BasePath()).Equals("")
}

func TestBasePath_SingleHostMode_WithPath(t *testing.T) {
RegisterT(t)
env.Config.HostMode = "single"
env.Config.BaseURL = "https://example.com/feedback"

ctx := newGetContext("http://demo.test.fider.io:3000", nil)

Expect(ctx.BasePath()).Equals("/feedback")
}

func TestBasePath_SingleHostMode_WithTrailingSlash(t *testing.T) {
RegisterT(t)
env.Config.HostMode = "single"
env.Config.BaseURL = "https://example.com/feedback/"

ctx := newGetContext("http://demo.test.fider.io:3000", nil)

Expect(ctx.BasePath()).Equals("/feedback")
}

func TestCurrentURL(t *testing.T) {
RegisterT(t)

Expand Down
4 changes: 4 additions & 0 deletions eslint-rules/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
env: { node: true },
parserOptions: { ecmaVersion: 2020, sourceType: "script" },
}
80 changes: 80 additions & 0 deletions eslint-rules/no-bare-root-href.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"use strict"

// Local ESLint rule: flags JSX `href` attributes set to root-relative paths
// without going through the <Link> component or resolveHref(). Catches both
// string literals (href="/admin") and template literals (href={`/posts/${id}`}).
//
// Broken under sub-path hosting: when BASE_URL has a path component
// (e.g. https://example.com/feedback), bare /-prefixed hrefs resolve to the
// domain root and skip the sub-path. Using <Link> or resolveHref() routes
// the href through basePath() and produces correct URLs at every host mode.
//
// Safe elements: <Link> (auto-resolves), <Button> (auto-resolves), and
// <Dropdown.ListItem> (auto-resolves) are exempt. Anything else using a
// root-relative href is flagged.
module.exports = {
meta: {
type: "problem",
docs: {
description: "Disallow bare root-relative hrefs in JSX; use <Link> or resolveHref()",
},
messages: {
bareHref: "Avoid hardcoded root-relative href on <{{element}}>; use the <Link> component or resolveHref() so sub-path hosting is respected.",
},
schema: [],
},
create(context) {
const SAFE_ELEMENTS = new Set(["Link", "Button"])

function getElementName(jsxOpeningElement) {
const name = jsxOpeningElement.name
if (name.type === "JSXIdentifier") return name.name
if (name.type === "JSXMemberExpression") {
// Dropdown.ListItem — walk to the tail identifier
let node = name
while (node.type === "JSXMemberExpression") node = node.property
return node.name
}
return ""
}

function startsWithSlash(node) {
if (node.type === "Literal" && typeof node.value === "string") {
return node.value.startsWith("/")
}
if (node.type === "TemplateLiteral" && node.quasis.length > 0) {
const first = node.quasis[0].value.cooked
return typeof first === "string" && first.startsWith("/")
}
return false
}

return {
JSXAttribute(node) {
if (node.name.name !== "href" || !node.value) return

const opening = node.parent
const element = getElementName(opening)
if (SAFE_ELEMENTS.has(element)) return
// Dropdown.ListItem auto-resolves too
if (element === "ListItem" && opening.name.type === "JSXMemberExpression") return

let expr = null
if (node.value.type === "Literal") {
expr = node.value
} else if (node.value.type === "JSXExpressionContainer") {
expr = node.value.expression
}
if (!expr) return

if (startsWithSlash(expr)) {
context.report({
node,
messageId: "bareHref",
data: { element },
})
}
},
}
},
}
13 changes: 7 additions & 6 deletions public/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState } from "react"
import { SignInModal, RSSModal, TenantLogo, NotificationIndicator, UserMenu, ThemeSwitcher, Icon, Button, ModerationIndicator } from "@fider/components"
import { useFider } from "@fider/hooks"
import { Link } from "./common"
import { HStack } from "./layout"
import { Trans } from "@lingui/react/macro"
import { i18n } from "@lingui/core"
Expand Down Expand Up @@ -40,17 +41,17 @@ export const Header = (props: HeaderProps) => {
<HStack className="c-menu p-4 w-full">
<div className="container c-header__container">
<div className="c-header__row">
<a href="/" className="c-header__brand flex flex-x flex-items-center flex--spacing-2 h-8">
<Link href="/" className="c-header__brand flex flex-x flex-items-center flex--spacing-2 h-8">
<TenantLogo size={100} />
<h1 className="text-header">{fider.session.tenant.name}</h1>
</a>
</Link>
<HStack spacing={4} className="c-header__nav flex-items-center">
<a href="/" className={`c-header__nav-link ${isFeedbackActive ? "c-header__nav-link--active" : ""}`}>
<Link href="/" className={`c-header__nav-link ${isFeedbackActive ? "c-header__nav-link--active" : ""}`}>
<Trans id="header.nav.feedback">All Feedback</Trans>
</a>
<a href="/roadmap" className={`c-header__nav-link ${isRoadmapActive ? "c-header__nav-link--active" : ""}`}>
</Link>
<Link href="/roadmap" className={`c-header__nav-link ${isRoadmapActive ? "c-header__nav-link--active" : ""}`}>
<Trans id="header.nav.roadmap">Roadmap</Trans>
</a>
</Link>
</HStack>
{fider.session.isAuthenticated && (
<div className="c-header__moderation">
Expand Down
Loading
Loading