Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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 @@ -321,8 +321,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 @@ -346,8 +345,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 @@ -396,6 +394,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 },
})
}
},
}
},
}
3 changes: 2 additions & 1 deletion 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 { basePath } from "@fider/services"
import { HStack } from "./layout"
import { Trans } from "@lingui/react/macro"
import { i18n } from "@lingui/core"
Expand Down Expand Up @@ -37,7 +38,7 @@ export const Header = (props: HeaderProps) => {
<div className="container c-header__container">
<div className="flex flex-wrap flex-items-center gap-2">
<div className="flex flex-x flex-items-center justify-between w-full">
<a href="/" className="flex flex-x flex-items-center flex--spacing-2 h-8">
<a href={`${basePath()}/`} className="flex flex-x flex-items-center flex--spacing-2 h-8">
<TenantLogo size={100} />
<h1 className="text-header">{fider.session.tenant.name}</h1>
</a>
Expand Down
5 changes: 3 additions & 2 deletions public/components/ModerationIndicator.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react"
import { Icon } from "@fider/components"
import { useFider } from "@fider/hooks"
import { basePath } from "@fider/services"
import ThumbsUp from "@fider/assets/images/heroicons-thumbsup.svg"
import ThumbsDown from "@fider/assets/images/heroicons-thumbsdown.svg"
import { HStack } from "@fider/components/layout"
Expand All @@ -13,7 +14,7 @@ export const ModerationIndicator = () => {
useEffect(() => {
const fetchCount = async () => {
try {
const response = await fetch("/_api/admin/moderation/count")
const response = await fetch(`${basePath()}/_api/admin/moderation/count`)
if (response.ok) {
const data = await response.json()
setCount(data.count || 0)
Expand Down Expand Up @@ -48,7 +49,7 @@ export const ModerationIndicator = () => {

if (count > 0) {
return (
<a href="/admin/moderation">
<a href={`${basePath()}/admin/moderation`}>
<HStack className="bg-green-200 rounded-full px-4">
<Icon width="18" height="18" sprite={ThumbsUp} />
<Icon width="18" height="18" sprite={ThumbsDown} />
Expand Down
Loading