diff --git a/.eslintrc.js b/.eslintrc.js index aaf26df8d..a594e5f9c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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", }, } diff --git a/Makefile b/Makefile index 68c6fccc5..76c20f814 100644 --- a/Makefile +++ b/Makefile @@ -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 . diff --git a/app/handlers/oauth.go b/app/handlers/oauth.go index 24a97dcf2..f57ae992f 100644 --- a/app/handlers/oauth.go +++ b/app/handlers/oauth.go @@ -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} @@ -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{ diff --git a/app/handlers/post.go b/app/handlers/post.go index 5d2f837c5..089cf0b01 100644 --- a/app/handlers/post.go +++ b/app/handlers/post.go @@ -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} diff --git a/app/handlers/signin.go b/app/handlers/signin.go index 881fab00d..a69a0798b 100644 --- a/app/handlers/signin.go +++ b/app/handlers/signin.go @@ -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 @@ -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()) } } @@ -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("/") } } diff --git a/app/handlers/signup.go b/app/handlers/signup.go index dd84a05a7..5d4cb6fae 100644 --- a/app/handlers/signup.go +++ b/app/handlers/signup.go @@ -141,7 +141,7 @@ func SignUp() web.HandlerFunc { } if firstTenant.Result != nil { - return c.Redirect("/") + return c.RedirectTo("/") } } else { baseURL := web.OAuthBaseURL(c) diff --git a/app/middlewares/tenant.go b/app/middlewares/tenant.go index 03beb5bca..b75413ae0 100644 --- a/app/middlewares/tenant.go +++ b/app/middlewares/tenant.go @@ -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() } @@ -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) } diff --git a/app/middlewares/user.go b/app/middlewares/user.go index 320856871..7f8b53961 100644 --- a/app/middlewares/user.go +++ b/app/middlewares/user.go @@ -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") diff --git a/app/pkg/web/context.go b/app/pkg/web/context.go index 4857bcded..ccaeb786c 100644 --- a/app/pkg/web/context.go +++ b/app/pkg/web/context.go @@ -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) @@ -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") diff --git a/app/pkg/web/context_test.go b/app/pkg/web/context_test.go index 8bf097095..6830272c3 100644 --- a/app/pkg/web/context_test.go +++ b/app/pkg/web/context_test.go @@ -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) @@ -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) @@ -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", @@ -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) diff --git a/eslint-rules/.eslintrc.js b/eslint-rules/.eslintrc.js new file mode 100644 index 000000000..feee87bfe --- /dev/null +++ b/eslint-rules/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + env: { node: true }, + parserOptions: { ecmaVersion: 2020, sourceType: "script" }, +} diff --git a/eslint-rules/no-bare-root-href.js b/eslint-rules/no-bare-root-href.js new file mode 100644 index 000000000..9b4315267 --- /dev/null +++ b/eslint-rules/no-bare-root-href.js @@ -0,0 +1,80 @@ +"use strict" + +// Local ESLint rule: flags JSX `href` attributes set to root-relative paths +// without going through the 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 or resolveHref() routes +// the href through basePath() and produces correct URLs at every host mode. +// +// Safe elements: (auto-resolves), @@ -28,7 +29,7 @@ export default class ExportPage extends AdminBasePage {

Use this button to download a ZIP file with your data in JSON format. This is a full backup and contains all of your data.

- diff --git a/public/pages/Administration/pages/GeneralSettings.page.tsx b/public/pages/Administration/pages/GeneralSettings.page.tsx index 1a44518fa..154e4545c 100644 --- a/public/pages/Administration/pages/GeneralSettings.page.tsx +++ b/public/pages/Administration/pages/GeneralSettings.page.tsx @@ -22,7 +22,7 @@ const GeneralSettingsPage = () => { const result = await actions.updateTenantSettings({ title, cname, welcomeMessage, welcomeHeader, invitation, logo, locale }) if (result.ok) { e.preventEnable() - location.href = `/` + location.href = Fider.settings.baseURL } else if (result.error) { setError(result.error) } diff --git a/public/pages/Home/Home.page.tsx b/public/pages/Home/Home.page.tsx index e96744714..ca414ee6e 100644 --- a/public/pages/Home/Home.page.tsx +++ b/public/pages/Home/Home.page.tsx @@ -8,6 +8,7 @@ import { Post, Tag, PostStatus } from "@fider/models" import { Markdown, Hint, PoweredByFider, Icon, Header, Button } from "@fider/components" import { PostsContainer } from "./components/PostsContainer" import { useFider } from "@fider/hooks" +import { basePath } from "@fider/services" import { HStack, VStack } from "@fider/components/layout" import { ShareFeedback } from "./components/ShareFeedback" import { i18n } from "@lingui/core" @@ -73,13 +74,13 @@ const HomePage = (props: HomePageProps) => { setSelectedPostId(postNumber) setLastOpenedPostId(postNumber) // Track which post was opened setIsPostDirty(false) // Reset dirty flag when opening overlay - window.history.pushState({ selectedPostId: postNumber }, "", `/posts/${postNumber}/${slug}`) + window.history.pushState({ selectedPostId: postNumber }, "", `${basePath()}/posts/${postNumber}/${slug}`) } // Handle closing the overlay const handleCloseOverlay = () => { setSelectedPostId(null) - window.history.pushState({}, "", `/${savedSearch}`) + window.history.pushState({}, "", `${basePath()}/${savedSearch}`) } // Track which post was opened so we can update just that one diff --git a/public/pages/Home/components/ListPosts.tsx b/public/pages/Home/components/ListPosts.tsx index 2fc55f96a..eaf732f19 100644 --- a/public/pages/Home/components/ListPosts.tsx +++ b/public/pages/Home/components/ListPosts.tsx @@ -1,6 +1,7 @@ import React from "react" import { Post, Tag, CurrentUser } from "@fider/models" import { ShowTag, Markdown, Icon, ResponseLozenge } from "@fider/components" +import { Link } from "@fider/components/common" import IconChatAlt2 from "@fider/assets/images/heroicons-chat-alt-2.svg" import IconCheck from "@fider/assets/images/heroicons-check.svg" import { HStack, VStack } from "@fider/components/layout" @@ -28,7 +29,7 @@ const ListPostItem = (props: { post: Post; user?: CurrentUser; tags: Tag[]; onPo } return ( - + @@ -68,7 +69,7 @@ const ListPostItem = (props: { post: Post; user?: CurrentUser; tags: Tag[]; onPo {props.post.status !== "open" && } - + ) } @@ -88,9 +89,9 @@ const MinimalListPostItem = (props: { post: Post; tags: Tag[]; onPostClick?: (po - + {props.post.title} - + {isPending && pending} {props.post.status !== "open" ? ( diff --git a/public/pages/Home/components/ShareFeedback.tsx b/public/pages/Home/components/ShareFeedback.tsx index 84ae1d494..52738f1a0 100644 --- a/public/pages/Home/components/ShareFeedback.tsx +++ b/public/pages/Home/components/ShareFeedback.tsx @@ -5,7 +5,7 @@ import { SignInControl } from "@fider/components/common/SignInControl" import { Modal, CloseIcon, Form, Button, Input, LegalFooter } from "@fider/components/common" import { useFider } from "@fider/hooks" import { Trans } from "@lingui/react/macro" -import { actions, Failure, querystring, classSet, cache } from "@fider/services" +import { actions, Failure, querystring, classSet, cache, navigator } from "@fider/services" import { plainText } from "@fider/services/markdown" import { i18n } from "@lingui/core" import { Tag } from "@fider/models" @@ -196,7 +196,7 @@ export const ShareFeedback: React.FC = (props) => { } else { cache.session.set("POST_CREATED_SUCCESS", "true") } - location.href = `/posts/${result.data.number}/${result.data.slug}` + navigator.goTo(`/posts/${result.data.number}/${result.data.slug}`) } else if (result.error) { setError(result.error) } diff --git a/public/pages/MyNotifications/MyNotifications.page.tsx b/public/pages/MyNotifications/MyNotifications.page.tsx index 0bba70dbd..284c1f4ae 100644 --- a/public/pages/MyNotifications/MyNotifications.page.tsx +++ b/public/pages/MyNotifications/MyNotifications.page.tsx @@ -1,7 +1,7 @@ import React from "react" import { Notification } from "@fider/models" -import { Header, Markdown, Moment, PageTitle } from "@fider/components" +import { Header, Link, Markdown, Moment, PageTitle } from "@fider/components" import { actions, Fider } from "@fider/services" import { HStack, VStack } from "@fider/components/layout" import { i18n } from "@lingui/core" @@ -38,9 +38,9 @@ export default class MyNotificationsPage extends React.Component { return (
- + - + diff --git a/public/pages/SignIn/CompleteSignInProfile.page.tsx b/public/pages/SignIn/CompleteSignInProfile.page.tsx index 5db053e79..599945a93 100644 --- a/public/pages/SignIn/CompleteSignInProfile.page.tsx +++ b/public/pages/SignIn/CompleteSignInProfile.page.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react" import { Button, Form, Input, TenantLogo } from "@fider/components" -import { actions, Failure } from "@fider/services" +import { actions, Failure, Fider, basePath } from "@fider/services" import { i18n } from "@lingui/core" import { Trans } from "@lingui/react/macro" @@ -23,9 +23,9 @@ const CompleteSignInProfilePage = (props: CompleteSignInProfilePageProps) => { const result = await actions.completeProfile(props.kind, props.k, name) if (result.ok) { if (props.c !== undefined) { - location.href = "/?c=" + props.c + location.href = Fider.settings.baseURL + "/?c=" + props.c } else { - location.href = "/" + location.href = Fider.settings.baseURL } } else if (result.error) { setError(result.error) @@ -37,7 +37,7 @@ const CompleteSignInProfilePage = (props: CompleteSignInProfilePageProps) => {
diff --git a/public/pages/SignIn/LoginEmailSent.page.tsx b/public/pages/SignIn/LoginEmailSent.page.tsx index 42d6e0298..2f0a2ca91 100644 --- a/public/pages/SignIn/LoginEmailSent.page.tsx +++ b/public/pages/SignIn/LoginEmailSent.page.tsx @@ -2,6 +2,7 @@ import React from "react" import MailSentIllustration from "@fider/assets/images/undraw_mail-sent.svg" import { LegalFooter, TenantLogo, Icon } from "@fider/components" +import { basePath } from "@fider/services" import { Trans } from "@lingui/react/macro" import "./LoginEmailSent.page.scss" @@ -12,7 +13,7 @@ const LoginEmailSentPage = ({ email }: { email: string }) => {
diff --git a/public/services/http.ts b/public/services/http.ts index 36d678e38..d3ded0222 100644 --- a/public/services/http.ts +++ b/public/services/http.ts @@ -1,4 +1,4 @@ -import { analytics, notify, truncate, Fider } from "@fider/services" +import { analytics, notify, truncate, Fider, resolveHref } from "@fider/services" export interface ErrorItem { field?: string @@ -33,7 +33,7 @@ async function toResult(response: Response): Promise> { // Redirect to /signin so they re-authenticate and the role check runs again. if (Fider.session.isAuthenticated) { const redirect = encodeURIComponent(window.location.pathname + window.location.search) - window.location.href = `/signin?redirect=${redirect}` + window.location.href = resolveHref(`/signin?redirect=${redirect}`) // Return a never-resolving promise so no further code runs while navigating. // eslint-disable-next-line @typescript-eslint/no-empty-function return new Promise>(() => {}) @@ -57,7 +57,7 @@ async function request(url: string, method: "GET" | "POST" | "PUT" | "DELETE" ["Content-Type", "application/json"], ] try { - const response = await fetch(url, { + const response = await fetch(resolveHref(url), { method, headers, body: JSON.stringify(body), diff --git a/public/services/index.ts b/public/services/index.ts index d941c04fc..708e59b2e 100644 --- a/public/services/index.ts +++ b/public/services/index.ts @@ -11,4 +11,5 @@ import * as querystring from "./querystring" import * as device from "./device" import * as actions from "./actions" import navigator from "./navigator" +export { basePath, resolveHref } from "./navigator" export { actions, querystring, navigator, device, notify, markdown } diff --git a/public/services/navigator.spec.ts b/public/services/navigator.spec.ts new file mode 100644 index 000000000..99150543e --- /dev/null +++ b/public/services/navigator.spec.ts @@ -0,0 +1,74 @@ +import { resolveHref } from "./navigator" +import { Fider } from "./fider" + +beforeAll(() => { + // Fider is usually initialized from a