Skip to content

feat: Full sub-path hosting support (BASE_URL with path)#1455

Open
3rg0n wants to merge 17 commits into
getfider:mainfrom
3rg0n:fix/subpath-hosting
Open

feat: Full sub-path hosting support (BASE_URL with path)#1455
3rg0n wants to merge 17 commits into
getfider:mainfrom
3rg0n:fix/subpath-hosting

Conversation

@3rg0n
Copy link
Copy Markdown

@3rg0n 3rg0n commented Feb 11, 2026

Summary

  • Fixes Support hosting Fider under a URL sub-path (e.g., example.com/feedback/) #1453: Adds comprehensive sub-path hosting support so Fider can be hosted at e.g. example.com/feedback/ behind a reverse proxy.
  • Fixes Support relative path (e.g. example.com/feedback) #1298: Adds comprehensive sub-path hosting support so Fider can be hosted at e.g. example.com/feedback/ behind a reverse proxy.
  • Depends on fix: Context.BaseURL() returns full BASE_URL in single-host mode #1454 (includes that commit): The core Context.BaseURL() fix that preserves the path component from BASE_URL.
  • Adds a basePath() frontend utility (in navigator.ts) that extracts the path portion from Fider.settings.baseURL for use in href attributes and navigation.
  • Updates the http service's fetch() wrapper to automatically prepend the base path to all root-relative API calls (/api/v1/..., /_api/...).
  • Fixes navigator.goHome(), goTo(), and replaceState() to respect the base path.
  • Updates all hardcoded root-relative href attributes across 15+ React components.
  • Updates all hardcoded c.Redirect("/...") calls in 5 Go handler/middleware files to use c.BaseURL().
  • Updates views/index.html Atom feed <link> tags to use the template baseURL variable.

Files changed (24 total)

Backend (Go):

  • app/handlers/oauth.go — 3 redirects fixed
  • app/handlers/post.go — 1 redirect fixed
  • app/handlers/signin.go — 1 redirect fixed
  • app/handlers/signup.go — 1 redirect fixed
  • app/middlewares/tenant.go — 3 redirects fixed

Frontend services:

  • public/services/navigator.ts — New basePath() utility, fixed goHome/goTo/replaceState
  • public/services/http.ts — Auto-prepend base path to fetch URLs
  • public/services/index.ts — Re-export basePath

Frontend components (href fixes):

  • public/components/Header.tsx
  • public/components/UserMenu.tsx
  • public/components/ReadOnlyNotice.tsx
  • public/components/common/Legal.tsx
  • public/pages/Administration/components/SideMenu.tsx (12 links)
  • public/pages/Administration/pages/Export.page.tsx
  • public/pages/Administration/pages/GeneralSettings.page.tsx
  • public/pages/Administration/pages/ManageBilling.page.tsx
  • public/pages/Administration/pages/ContentModeration.page.tsx
  • public/pages/Home/Home.page.tsx
  • public/pages/Home/components/ShareFeedback.tsx
  • public/pages/SignIn/CompleteSignInProfile.page.tsx
  • public/pages/SignIn/LoginEmailSent.page.tsx
  • commercial/components/ModerationIndicator.tsx
  • commercial/pages/Administration/ContentModeration.page.tsx

Templates:

  • views/index.html — Atom feed link hrefs

Test plan

  • All 5 BaseURL unit tests pass
  • go vet passes on changed Go packages
  • Deploy with BASE_URL=https://example.com/feedback behind a reverse proxy
  • Verify all navigation links include /feedback prefix
  • Verify API calls (create/delete/vote on posts) go to /feedback/api/v1/...
  • Verify sign-in/sign-out redirects go to /feedback/ not /
  • Verify admin panel links work under the sub-path

🤖 Generated with Claude Code

3rg0n and others added 3 commits February 11, 2026 11:54
Context.BaseURL() was always calling Request.BaseURL() which only
returns scheme://host:port, stripping any path component from the
configured BASE_URL. This broke all redirects and frontend navigation
when Fider is hosted under a sub-path (e.g., BASE_URL=https://example.com/feedback).

The package-level web.BaseURL() function already handled this correctly
by returning env.Config.BaseURL in single-host mode. This change aligns
the Context method with that behavior.

Fixes getfider#1452

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add basePath() utility that extracts the path component from BASE_URL,
enabling Fider to be hosted under a subfolder (e.g., example.com/feedback).

Backend: Replace all hardcoded c.Redirect("/") calls with c.BaseURL()
to ensure redirects respect the configured base path.

Frontend: Update all hardcoded href attributes and location.href
assignments to use basePath() prefix. Add automatic URL resolution
in the http service so API calls include the base path. Fix
navigator.goHome/goTo/replaceState to prepend the base path.

Fixes getfider#1453

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
TS6133 error - Fider was imported but only basePath is used.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3rg0n and others added 4 commits February 13, 2026 15:15
Documents all installation types (single-host vs multi-host, with/without
sub-path) and their test coverage. Includes unit test results and manual
testing verification for the production deployment.

Addresses maintainer feedback requesting testing across all supported
installation types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add docker-compose test setup with Caddy reverse proxy and self-signed
certificates to test all deployment scenarios locally:

- Scenario 1: Single-host without sub-path (https://fider.local)
- Scenario 2: Single-host WITH sub-path (https://app.local/feedback)
- Scenario 3: Multi-host with subdomains (https://*.multi.local)

Includes detailed test checklists, setup scripts, and quick-start guide.

Addresses maintainer request to test against all supported installation
types before merging PRs getfider#1454 and getfider#1455.

Files added:
- docker-compose-test.yml: Multi-scenario test environment
- Caddyfile.test: Reverse proxy config with automatic HTTPS
- TEST-SCENARIOS.md: Detailed test checklists for each scenario
- QUICK-TEST.md: Fast setup guide
- TEST-README.md: Overview and architecture
- setup-hosts.sh/ps1: Host file configuration scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add Context.BasePath() method that extracts just the path prefix from
BASE_URL for building redirect paths. Only hardcoded redirects are
changed to use BasePath(); handlers already using BaseURL() are left
as-is since the getfider#1452 fix makes them correct.

Also fixes lint issues: prettier formatting in ContentModeration and
SideMenu, adds rel="noreferrer" to Legal.tsx target="_blank" links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@mattwoberts
Copy link
Copy Markdown
Contributor

mattwoberts commented Apr 29, 2026

Thanks for this — sub-path hosting is a useful feature. A few things to address before this can merge.

1. Root-level file bloat

8 test infrastructure files have been added to the repo root: TESTING.md, TEST-README.md, TEST-SCENARIOS.md, QUICK-TEST.md, docker-compose-test.yml, Caddyfile.test, setup-hosts.sh, setup-hosts.ps1. These should be removed from this PR entirely.

2. Missed components — still broken under sub-path hosting

The manual basePath() approach missed several files that still have bare root-relative hrefs:

File Line Broken href
public/pages/Home/components/ListPosts.tsx 30 href={`/posts/${...}`}
public/pages/Home/components/ListPosts.tsx 84 href={`/posts/${...}`}
public/components/NotificationIndicator.tsx 17 href={`/notifications/${...}`}
public/components/ShowPostResponse.tsx 51 href={`/posts/${...}`}
public/pages/MyNotifications/MyNotifications.page.tsx 41 href={`/notifications/${...}`}
public/components/ShowTag.tsx 37 href={`/?tags=${...}`}

These are the exact bugs that prove the core concern below.

3. Fragile pattern — future bugs guaranteed

The fundamental problem with this PR is that every developer must remember to call basePath() on every href and every c.Redirect(). The PR itself missing 6 components demonstrates this won't scale. A few suggestions:

Frontend:

a) Create a resolveHref() utility in navigator.ts that centralises the basePath-prepending logic (it's currently duplicated between navigator.goTo(), http.ts's resolveUrl(), and every call site).

b) Auto-resolve inside Button and Dropdown.ListItem — both components render <a href={props.href}> and pass the href straight through (Button.tsx:55, Dropdown.tsx:28). If they called resolveHref() internally, callers wouldn't need to think about it. The idempotency guard (!href.startsWith(bp)) makes double-application harmless.

c) Create a <Link> component — a thin wrapper around <a> that calls resolveHref(). Replace all bare <a href="/..."> with <Link href="/...">. Developers write natural paths, basePath is handled automatically.

d) Add an ESLint rule to flag hardcoded root-relative href attributes in JSX (both string literals like href="/admin" and template literals like href={`/posts/${id}`}). This can be a local rule in eslint-rules/ using --rulesdir — zero new dependencies. The lint rule is the safety net; the components above are the primary defense.

Backend (Go):

e) Add a c.RedirectTo(path) helper on Context that does c.Redirect(c.BasePath() + path). Replace all 9 manual c.Redirect(c.BasePath() + "/...") calls. Same principle — developers write natural paths, the helper handles the prefix.

4. Duplicated basePath logic

basePath() extraction appears in three places with slightly different implementations:

  • navigator.ts basePath()
  • http.ts resolveUrl() (inline)
  • navigator.ts goTo() (inline)

These should be consolidated into one resolveHref() function.


@mattwoberts mattwoberts self-requested a review April 29, 2026 07:23
Copy link
Copy Markdown
Contributor

@mattwoberts mattwoberts left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi - I added a comment that is basically the review :)

@3rg0n
Copy link
Copy Markdown
Author

3rg0n commented Apr 29, 2026

Thanks Matt, will get to it today and push the updates.

3rg0n added 10 commits April 29, 2026 12:55
# Conflicts:
#	commercial/pages/Administration/ContentModeration.page.tsx
#	public/pages/Administration/pages/ContentModeration.page.tsx
#	public/pages/Administration/pages/ManageBilling.page.tsx
Addresses review item 1: these 8 files were local test scaffolding that
should not have been included in the PR.

- TESTING.md
- TEST-README.md
- TEST-SCENARIOS.md
- QUICK-TEST.md
- docker-compose-test.yml
- Caddyfile.test
- setup-hosts.sh
- setup-hosts.ps1
Addresses review items 3a and 4: three near-duplicate implementations of
basePath-prepending ('navigator.ts' basePath(), 'navigator.ts' goTo()
inline, 'http.ts' resolveUrl() inline) replaced with a single exported
resolveHref() utility in navigator.ts.

- resolveHref(href) is idempotent: non-root-relative hrefs and already-
  prefixed hrefs are returned unchanged, so it is safe to apply at any
  layer.
- navigator.goTo() now calls resolveHref() directly.
- http.ts replaces its private resolveUrl() with resolveHref().
- http.ts's 401 re-auth redirect (`/signin?redirect=...`) was previously
  bare root-relative and broken under sub-path hosting; now also uses
  resolveHref().
Addresses review item 3b: both components render <a href={props.href}>
and previously passed the href through unchanged, forcing every caller
to remember to prepend basePath() for sub-path hosting.

Callers now pass natural root-relative hrefs and the component handles
resolution internally via resolveHref(). The idempotency guard in
resolveHref() (!href.startsWith(bp)) makes existing call sites that
still pass ${basePath()}/... continue to work without a double-prefix,
so this change is safe to land independently from call-site cleanup.
Addresses review items 2 and 3c together since migrating the six flagged
components onto a new <Link> primitive is a cleaner fix than sprinkling
basePath() calls at each site.

Item 3c — new <Link> component:
  public/components/common/Link.tsx is a thin wrapper around <a> that
  runs href through resolveHref(). Callers write natural root-relative
  paths; sub-path hosting is handled internally. Re-exported from
  @fider/components via ./common.

Item 2 — six missed bare hrefs fixed by swapping <a> for <Link>:
  - public/pages/Home/components/ListPosts.tsx (two hrefs, lines 30 and 84 of the PR snapshot)
  - public/components/NotificationIndicator.tsx (line 17)
  - public/components/ShowPostResponse.tsx (line 51)
  - public/pages/MyNotifications/MyNotifications.page.tsx (line 41)
  - public/components/ShowTag.tsx (line 37)

Rationale for <Link> over a bare basePath() call at each site:
  The PR having missed six sites across five components is direct
  evidence that the "remember to call basePath()" convention does not
  scale. A wrapper component inverts that — the default path is correct
  and developers would have to go out of their way to get it wrong by
  reaching for <a> instead of <Link>. The ESLint rule added in a later
  commit enforces this as a safety net.
Addresses review item 3e: manual c.Redirect(c.BasePath() + "/...") sites
are boilerplate that every developer must remember. New helper takes a
natural root-relative path and prepends BasePath() internally.

Migrated 8 c.Redirect(c.BasePath() + "/...") sites in:
  - app/handlers/oauth.go (3)
  - app/handlers/signin.go (1)
  - app/handlers/signup.go (1)
  - app/middlewares/tenant.go (3)

Also migrated 3 bare c.Redirect("/...") sites that were missing basePath
entirely, brought in by the upstream merge of OAuth allowed-roles work:
  - app/handlers/oauth.go: /access-denied
  - app/middlewares/user.go: /signin and /signin?redirect=...

These would have been sub-path hosting bugs on their own — using
RedirectTo() makes them correct and consistent with the rest of the
codebase.
Addresses review item 3d: local lint rule that flags hardcoded
root-relative hrefs in JSX (both string literals and template literals),
enforcing the <Link>/<Button>/<Dropdown.ListItem>/resolveHref() pattern
as the safety net for sub-path hosting.

Rule:
  eslint-rules/no-bare-root-href.js — zero new npm dependencies, loaded
  via --rulesdir. Flags <a href="/..."> and <a href={`/posts/${id}`}>.
  Exempts <Link>, <Button>, and Dropdown.ListItem (all three auto-resolve
  hrefs internally). Exempts template literals that begin with
  ${basePath()}/... and non-/-prefixed hrefs (absolute URLs, fragments,
  mailto: etc.).

Rationale for local rule vs. adding eslint-plugin-* dependency:
  Reviewer explicitly preferred --rulesdir, and the rule is small and
  project-specific. A plugin would add a package, a publish story, and
  a dependency bump in the lockfile; none of that is warranted for a
  ~60-line rule file used only by this repo.

Follow-up:
  Also swapped window.location.href = link for navigator.goTo(link) in
  public/pages/Administration/pages/ContentModeration.page.tsx (this
  file arrived from upstream during the merge and had a bare
  root-relative string literal assigned to location.href, which the
  rule cannot catch because it is not a JSX href attribute —
  navigator.goTo() runs resolveHref() internally).
Without this, the rule file's CommonJS `module.exports` trips the
no-undef rule from the default eslint:recommended config.
Addresses implicit test-coverage expectation for the new sub-path
primitives introduced in earlier commits.

Coverage:
  public/services/navigator.spec.ts (10 tests)
    - idempotency guard under sub-path hosting
    - trailing-slash handling in BASE_URL
    - pass-through for absolute URLs, fragments, mailto:, empty, relative
    - graceful fallback when BASE_URL is malformed

  public/components/common/Link.spec.tsx (3 tests)
    - bare href at domain root
    - prefix insertion under sub-path
    - passthrough of className/target/rel props

All 140 Jest tests pass.
Collapsed multi-line message string onto one line so it fits within
prettier's printWidth: 160. Caught when running the full `make lint`
inside a Linux container (Windows CRLF was masking the issue locally).
@3rg0n
Copy link
Copy Markdown
Author

3rg0n commented Apr 29, 2026

Hi @mattwoberts — thanks for the detailed review. Pushed fixes on top of the existing branch (not force-pushed, so your "viewed" state should still be intact). Here's a per-item mapping with commit SHAs.

First, a note on the merge: upstream had moved significantly since this PR was opened (Go 1.25 bump, commercial module removed, OAuth allowed-roles, zh-TW locale, DoS fix, etc.) and our branch modified a file that got deleted upstream (commercial/pages/Administration/ContentModeration.page.tsx), so the PR no longer applied cleanly. Merged upstream/main first (c75f6546) so the reviewable diff is against the current main. Conflict resolutions:

  • commercial/pages/Administration/ContentModeration.page.tsx — accepted upstream delete
  • public/pages/Administration/pages/ContentModeration.page.tsx — took upstream's full implementation (our version was a stub pointing at the now-removed commercial module)
  • public/pages/Administration/pages/ManageBilling.page.tsx — took upstream (our commercial-upgrade banner was obsolete since the licensing model was removed)

Item 1 — Root-level file bloat ✅

Commit: 343cf320 — removed all 8 files (TESTING.md, TEST-README.md, TEST-SCENARIOS.md, QUICK-TEST.md, docker-compose-test.yml, Caddyfile.test, setup-hosts.sh, setup-hosts.ps1).

Item 2 — Missed components ✅

Commit: 8a6160f2 — all 6 flagged hrefs migrated to the new <Link> component introduced in the same commit (item 3c). Fixing them via the primitive rather than scattering basePath() calls addresses your scalability concern directly.

Item 3a + 4 — Consolidate basePath logic, resolveHref() utility ✅

Commit: 02140d94 — single resolveHref() in public/services/navigator.ts. Replaced the three duplicates:

  • navigator.basePath() call site inside goTo() (now resolveHref(url))
  • navigator.goTo() inline logic (now uses resolveHref())
  • http.ts resolveUrl() (removed, uses resolveHref())

Also fixed a bare window.location.href = "/signin?redirect=..." in http.ts (401 re-auth path) that was broken under sub-path hosting — now goes through resolveHref().

The idempotency guard (!href.startsWith(bp)) means any remaining ${basePath()}/... call sites continue to work without a double-prefix, so this change was safe to land independently of call-site cleanup.

Item 3b — Auto-resolve in Button and Dropdown.ListItem

Commit: 01dcc089 — both components now run href through resolveHref() internally.

Item 3c — <Link> component ✅

Commit: 8a6160f2public/components/common/Link.tsx. Thin wrapper around <a> that runs href through resolveHref(). Used for all 6 flagged sites.

Rationale for <Link> over a bare basePath() call at each site: the PR having missed 6 sites across 5 components is direct evidence that a "remember to call basePath()" convention does not scale. A wrapper component inverts the default — correctness is the path of least resistance. The ESLint rule (item 3d) is the safety net enforcing this.

Item 3d — ESLint rule ✅

Commits: fb3a4f13 + 6d59f411 + f94cd695eslint-rules/no-bare-root-href.js. Flags bare root-relative hrefs in JSX (string literals and template literals). Exempts <Link>, <Button>, Dropdown.ListItem, ${basePath()}/... templates, absolute URLs, fragments, etc.

Rationale for --rulesdir local rule vs. an ESLint plugin package: you explicitly preferred no new dependencies, and a 60-line rule doesn't warrant a publish story or a package bump. Wired into make lint-ui via --rulesdir eslint-rules.

Scope: the rule catches JSX href attributes. It doesn't cover window.location.href = "/..." patterns (those aren't JSX). One such case was introduced by the upstream merge in ContentModeration.page.tsx — fixed that by switching to navigator.goTo() (which uses resolveHref() internally).

Item 3e — c.RedirectTo(path) helper ✅

Commit: 85c995f2 — new helper on web.Context in app/pkg/web/context.go:

func (c *Context) RedirectTo(path string) error {
    return c.Redirect(c.BasePath() + path)
}

Migrated 8 c.Redirect(c.BasePath() + "/...") sites across app/handlers/oauth.go, signin.go, signup.go, and app/middlewares/tenant.go.

Also fixed 3 bare c.Redirect("/...") sites that arrived with the upstream OAuth allowed-roles work (/access-denied in oauth.go, /signin and /signin?redirect=... in middlewares/user.go) — these were sub-path hosting bugs on their own.


Tests added (commit 81176366)

  • public/services/navigator.spec.ts — 10 tests for resolveHref(): domain-root pass-through, sub-path prefix, idempotency, trailing-slash handling, pass-through for absolute URLs/fragments/mailto/empty/relative, malformed-baseURL fallback.
  • public/components/common/Link.spec.tsx — 3 tests for <Link>: bare href at root, prefix insertion under sub-path, passthrough of className/target/rel.

Test results — full make test and make lint locally

Ran the contributor test suite per CONTRIBUTING.md inside a Linux container (golang:1.25-bookworm + Node 22) against Podman-hosted Postgres/MinIO/MailHog (the docker-compose.yml services). My Windows Go toolchain can't link rogchap.com/v8go for the SSR runtime, so the container approach was the cleanest way to mirror CI locally:

  • make lint: ✅ clean. 0 errors. 2 pre-existing warnings in public/services/fider.ts (unrelated @typescript-eslint/no-non-null-assertion).
  • make test-server: ✅ all 34 Go test packages pass (app/cmd, app/handlers, app/handlers/apiv1, app/middlewares, app/pkg/web, app/services/email/smtp, app/services/oauth, app/services/sqlstore/postgres, app/tasks, etc.).
  • make test-ui: ✅ 12 Jest suites, 140/140 tests pass (includes the two new specs above).

Re-requesting review. Happy to iterate further.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support hosting Fider under a URL sub-path (e.g., example.com/feedback/) Support relative path (e.g. example.com/feedback)

2 participants