FIX: Simplify sidebar custom link implementation (#26201)

All our link validation, and conversion from url -> route/model/query is expensive and prone to bugs. Instead, if people enter a link, we can just use it as-is.

Originally all this extra logic was added to handle unusual situations like `/safe-mode`, `/my/...`, etc. However, all of these are now handled correctly by our Ember router, so there is no need for it.

Now, we just pass the user-supplied `href` directly to the SectionLink component, and let Ember handle routing to it when clicked.

The only functional change here is that we no longer validate internal links by parsing them with the Ember router. But I'd argue this is fine, because the previous logic would cause both false positives (e.g. `/t/123` would be valid, even if topic 123 doesn't exist), and false negatives (for routes which are server-side only, like the new AI share pages).
This commit is contained in:
David Taylor 2024-03-20 12:55:40 +00:00 committed by GitHub
parent a884842fa5
commit e3cfb1967d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 103 additions and 199 deletions

View File

@ -7,17 +7,10 @@ import { isEmpty } from "@ember/utils";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { extractError } from "discourse/lib/ajax-error"; import { extractError } from "discourse/lib/ajax-error";
import { SIDEBAR_SECTION, SIDEBAR_URL } from "discourse/lib/constants"; import { SIDEBAR_SECTION, SIDEBAR_URL } from "discourse/lib/constants";
import RouteInfoHelper from "discourse/lib/sidebar/route-info-helper";
import { sanitize } from "discourse/lib/text"; import { sanitize } from "discourse/lib/text";
import { afterRender, bind } from "discourse-common/utils/decorators"; import { afterRender, bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
const FULL_RELOAD_LINKS_REGEX = [
/^\/my\/[a-z_\-\/]+$/,
/^\/pub\/[a-z_\-\/]+$/,
/^\/safe-mode$/,
];
class Section { class Section {
@tracked title; @tracked title;
@tracked links; @tracked links;
@ -213,28 +206,16 @@ class SectionLink {
} }
get #invalidValue() { get #invalidValue() {
return ( return this.path && !this.#validLink();
this.path &&
(this.external ? !this.#validExternal() : !this.#validInternal())
);
} }
#validExternal() { #validLink() {
try { try {
return new URL(this.value); return new URL(this.value, document.location.origin);
} catch { } catch {
return false; return false;
} }
} }
#validInternal() {
const routeInfoHelper = new RouteInfoHelper(this.router, this.path);
return (
routeInfoHelper.route !== "unknown" ||
FULL_RELOAD_LINKS_REGEX.some((regex) => this.path.match(regex))
);
}
} }
export default class SidebarSectionForm extends Component { export default class SidebarSectionForm extends Component {

View File

@ -9,38 +9,25 @@
class={{this.section.dragCss}} class={{this.section.dragCss}}
> >
{{#each this.section.links as |link|}} {{#each this.section.links as |link|}}
{{#if link.externalOrFullReload}} <Sidebar::SectionLink
<Sidebar::SectionLink @badgeText={{link.badgeText}}
@shouldDisplay={{link.shouldDisplay}} @content={{replace-emoji link.text}}
@linkName={{link.name}} @currentWhen={{link.currentWhen}}
@content={{replace-emoji link.text}} @href={{or link.value link.href}}
@prefixType="icon" @linkClass={{link.linkDragCss}}
@prefixValue={{link.prefixValue}} @linkName={{link.name}}
@fullReload={{link.fullReload}} @model={{link.model}}
@href={{link.value}} @models={{link.models}}
@linkClass={{link.linkDragCss}} @prefixType="icon"
/> @prefixValue={{link.prefixValue}}
{{else}} @query={{link.query}}
<Sidebar::SectionLink @route={{link.route}}
@shouldDisplay={{link.shouldDisplay}} @shouldDisplay={{link.shouldDisplay}}
@href={{link.href}} @suffixCSSClass={{link.suffixCSSClass}}
@title={{link.title}} @suffixType={{link.suffixType}}
@linkName={{link.name}} @suffixValue={{link.suffixValue}}
@route={{link.route}} @title={{link.title}}
@model={{link.model}} />
@models={{link.models}}
@query={{link.query}}
@content={{replace-emoji link.text}}
@badgeText={{link.badgeText}}
@prefixType="icon"
@prefixValue={{link.prefixValue}}
@suffixCSSClass={{link.suffixCSSClass}}
@suffixValue={{link.suffixValue}}
@suffixType={{link.suffixType}}
@currentWhen={{link.currentWhen}}
@linkClass={{link.linkDragCss}}
/>
{{/if}}
{{/each}} {{/each}}
{{#if this.section.moreLinks}} {{#if this.section.moreLinks}}

View File

@ -1,30 +1,18 @@
{{#if @sectionLink.externalOrFullReload}} <Sidebar::SectionLink
<Sidebar::SectionLink @badgeText={{@sectionLink.badgeText}}
@shouldDisplay={{@sectionLink.shouldDisplay}} @content={{replace-emoji @sectionLink.text}}
@linkName={{@sectionLink.name}} @currentWhen={{@sectionLink.currentWhen}}
@content={{replace-emoji @sectionLink.text}} @href={{or @sectionLink.href @sectionLink.value}}
@prefixType="icon" @linkName={{@sectionLink.name}}
@prefixValue={{@sectionLink.prefixValue}} @model={{@sectionLink.model}}
@fullReload={{@sectionLink.fullReload}} @models={{@sectionLink.models}}
@href={{@sectionLink.value}} @prefixType="icon"
/> @prefixValue={{@sectionLink.prefixValue}}
{{else}} @query={{@sectionLink.query}}
<Sidebar::SectionLink @route={{@sectionLink.route}}
@shouldDisplay={{@sectionLink.shouldDisplay}} @shouldDisplay={{@sectionLink.shouldDisplay}}
@href={{@sectionLink.href}} @suffixCSSClass={{@sectionLink.suffixCSSClass}}
@title={{@sectionLink.title}} @suffixType={{@sectionLink.suffixType}}
@linkName={{@sectionLink.name}} @suffixValue={{@sectionLink.suffixValue}}
@route={{@sectionLink.route}} @title={{@sectionLink.title}}
@model={{@sectionLink.model}} />
@models={{@sectionLink.models}}
@query={{@sectionLink.query}}
@content={{replace-emoji @sectionLink.text}}
@badgeText={{@sectionLink.badgeText}}
@prefixType="icon"
@prefixValue={{@sectionLink.prefixValue}}
@suffixCSSClass={{@sectionLink.suffixCSSClass}}
@suffixValue={{@sectionLink.suffixValue}}
@suffixType={{@sectionLink.suffixType}}
@currentWhen={{@sectionLink.currentWhen}}
/>
{{/if}}

View File

@ -58,14 +58,20 @@ export default class SectionLink extends Component {
} }
get target() { get target() {
if (this.args.fullReload) { return this.currentUser?.user_option?.external_links_in_new_tab &&
return "_self"; this.isExternal
}
return this.currentUser?.user_option?.external_links_in_new_tab
? "_blank" ? "_blank"
: "_self"; : "_self";
} }
get isExternal() {
return (
this.args.href &&
new URL(this.args.href, window.location.href).origin !==
window.location.origin
);
}
get models() { get models() {
if (this.args.model) { if (this.args.model) {
return [this.args.model]; return [this.args.model];

View File

@ -47,11 +47,9 @@ export default class BaseCommunitySectionLink {
} }
/** /**
* @returns {string} Ember route * @returns {string|undefined} Ember route
*/ */
get route() { get route() {}
this._notImplemented();
}
/** /**
* @returns {string} href attribute for the link. This property will take precedence over the `route` property when set. * @returns {string} href attribute for the link. This property will take precedence over the `route` property when set.

View File

@ -1,5 +1,4 @@
import BaseCommunitySectionLink from "discourse/lib/sidebar/base-community-section-link"; import BaseCommunitySectionLink from "discourse/lib/sidebar/base-community-section-link";
import RouteInfoHelper from "discourse/lib/sidebar/route-info-helper";
export let customSectionLinks = []; export let customSectionLinks = [];
export let secondaryCustomSectionLinks = []; export let secondaryCustomSectionLinks = [];
@ -27,38 +26,10 @@ export function addSectionLink(args, secondary) {
links.push(args.call(this, BaseCommunitySectionLink)); links.push(args.call(this, BaseCommunitySectionLink));
} else { } else {
const klass = class extends BaseCommunitySectionLink { const klass = class extends BaseCommunitySectionLink {
constructor() {
super(...arguments);
if (args.href) {
this.routeInfoHelper = new RouteInfoHelper(this.router, args.href);
}
}
get name() { get name() {
return args.name; return args.name;
} }
get route() {
if (args.href) {
return this.routeInfoHelper.route;
} else {
return args.route;
}
}
get models() {
if (args.href) {
return this.routeInfoHelper.models;
}
}
get query() {
if (args.href) {
return this.routeInfoHelper.query;
}
}
get text() { get text() {
return args.text; return args.text;
} }
@ -67,6 +38,14 @@ export function addSectionLink(args, secondary) {
return args.title; return args.title;
} }
get href() {
return args.href;
}
get route() {
return args.route;
}
get prefixValue() { get prefixValue() {
return args.icon || super.prefixValue; return args.icon || super.prefixValue;
} }

View File

@ -1,36 +0,0 @@
export default class RouteInfoHelper {
constructor(router, url) {
this.routeInfo = router.recognize(router.rootURL.replace(/\/$/, "") + url);
}
get route() {
return this.routeInfo.name;
}
get models() {
return this.#getParameters;
}
get query() {
return this.routeInfo.queryParams;
}
/**
* Extracted from https://github.com/emberjs/rfcs/issues/658
* Retrieves all parameters for a `RouteInfo` object and its parents in
* correct oder, so that you can pass them to e.g.
* `transitionTo(routeName, ...params)`.
*/
get #getParameters() {
let allParameters = [];
let current = this.routeInfo;
do {
const { params, paramNames } = current;
const currentParameters = paramNames.map((n) => params[n]);
allParameters = [...currentParameters, ...allParameters];
} while ((current = current.parent));
return allParameters;
}
}

View File

@ -1,17 +1,10 @@
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import RouteInfoHelper from "discourse/lib/sidebar/route-info-helper";
import { defaultHomepage } from "discourse/lib/utilities";
export default class SectionLink { export default class SectionLink {
@tracked linkDragCss; @tracked linkDragCss;
constructor( constructor({ external, icon, id, name, value }, section) {
{ external, full_reload, icon, id, name, value },
section,
router
) {
this.external = external; this.external = external;
this.fullReload = full_reload;
this.prefixValue = icon; this.prefixValue = icon;
this.id = id; this.id = id;
this.name = name; this.name = name;
@ -19,26 +12,9 @@ export default class SectionLink {
this.value = value; this.value = value;
this.section = section; this.section = section;
this.withAnchor = value.match(/#\w+$/gi); this.withAnchor = value.match(/#\w+$/gi);
if (!this.externalOrFullReload) {
const routeInfoHelper = new RouteInfoHelper(router, value);
if (routeInfoHelper.route === "discovery.index") {
this.route = `discovery.${defaultHomepage()}`;
} else {
this.route = routeInfoHelper.route;
}
this.models = routeInfoHelper.models;
this.query = routeInfoHelper.query;
}
} }
get shouldDisplay() { get shouldDisplay() {
return true; return true;
} }
get externalOrFullReload() {
return this.external || this.fullReload || this.withAnchor;
}
} }

View File

@ -3,7 +3,6 @@
class SidebarUrl < ActiveRecord::Base class SidebarUrl < ActiveRecord::Base
enum :segment, { primary: 0, secondary: 1 }, scopes: false, suffix: true enum :segment, { primary: 0, secondary: 1 }, scopes: false, suffix: true
FULL_RELOAD_LINKS_REGEX = [%r{\A/my/[a-z_\-/]+\z}, %r{\A/pub/[a-z_\-/]+\z}, %r{\A/safe-mode\z}]
MAX_ICON_LENGTH = 40 MAX_ICON_LENGTH = 40
MAX_NAME_LENGTH = 80 MAX_NAME_LENGTH = 80
MAX_VALUE_LENGTH = 1000 MAX_VALUE_LENGTH = 1000
@ -69,10 +68,6 @@ class SidebarUrl < ActiveRecord::Base
def set_external def set_external
self.external = value.start_with?("http://", "https://") self.external = value.start_with?("http://", "https://")
end end
def full_reload?
FULL_RELOAD_LINKS_REGEX.any? { |regex| value =~ regex }
end
end end
# == Schema Information # == Schema Information

View File

@ -1,13 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
class SidebarUrlSerializer < ApplicationSerializer class SidebarUrlSerializer < ApplicationSerializer
attributes :id, :name, :value, :icon, :external, :full_reload, :segment attributes :id, :name, :value, :icon, :external, :segment
def external def external
object.external? object.external?
end end
def full_reload
object.full_reload?
end
end end

View File

@ -55,7 +55,7 @@ describe "Custom sidebar sections", type: :system do
expect(sidebar).to have_section_link("My preferences", target: "_self") expect(sidebar).to have_section_link("My preferences", target: "_self")
end end
it "allows the user to create custom section with `/` path which generates a link based on the first item in the `top_menu` site settings" do it "allows the user to create custom section with `/` path" do
SiteSetting.top_menu = "read|posted|latest" SiteSetting.top_menu = "read|posted|latest"
sign_in user sign_in user
@ -67,7 +67,10 @@ describe "Custom sidebar sections", type: :system do
section_modal.save section_modal.save
expect(sidebar).to have_section("My section") expect(sidebar).to have_section("My section")
expect(sidebar).to have_section_link("Home", href: "/read") expect(sidebar).to have_section_link("Home", href: "/")
sidebar.click_section_link("Home")
expect(page).to have_css("#navigation-bar .active a[href='/read']")
end end
it "allows the user to create custom section with /pub link" do it "allows the user to create custom section with /pub link" do
@ -94,9 +97,6 @@ describe "Custom sidebar sections", type: :system do
section_modal.fill_name("My section") section_modal.fill_name("My section")
section_modal.fill_link("Discourse Homepage", "htt")
expect(section_modal).to have_disabled_save
section_modal.fill_link("Discourse Homepage", "https://discourse.org") section_modal.fill_link("Discourse Homepage", "https://discourse.org")
expect(section_modal).to have_enabled_save expect(section_modal).to have_enabled_save
@ -124,7 +124,41 @@ describe "Custom sidebar sections", type: :system do
section_modal.save section_modal.save
expect(sidebar).to have_section("My section") expect(sidebar).to have_section("My section")
expect(sidebar).to have_section_link("Faq", target: "_blank") expect(sidebar).to have_section_link("Faq", target: "_self", href: "/faq#anchor")
end
it "allows the user to create custom section with query param" do
sign_in user
visit("/latest")
sidebar.click_add_section_button
expect(section_modal).to be_visible
expect(section_modal).to have_disabled_save
expect(sidebar.custom_section_modal_title).to have_content("Add custom section")
section_modal.fill_name("My section")
section_modal.fill_link("Faq", "/faq?a=b")
section_modal.save
expect(sidebar).to have_section("My section")
expect(sidebar).to have_section_link("Faq", target: "_self", href: "/faq?a=b")
end
it "allows the user to create custom section with anchor link" do
sign_in user
visit("/latest")
sidebar.click_add_section_button
expect(section_modal).to be_visible
expect(section_modal).to have_disabled_save
expect(sidebar.custom_section_modal_title).to have_content("Add custom section")
section_modal.fill_name("My section")
section_modal.fill_link("Faq", "/faq#someheading")
section_modal.save
expect(sidebar).to have_section("My section")
expect(sidebar).to have_section_link("Faq", target: "_self", href: "/faq#someheading")
end end
it "accessibility - when new row is added in custom section, first new input is focused" do it "accessibility - when new row is added in custom section, first new input is focused" do
@ -309,7 +343,7 @@ describe "Custom sidebar sections", type: :system do
sidebar.click_add_section_button sidebar.click_add_section_button
section_modal.fill_name("A" * (SidebarSection::MAX_TITLE_LENGTH + 1)) section_modal.fill_name("A" * (SidebarSection::MAX_TITLE_LENGTH + 1))
section_modal.fill_link("B" * (SidebarUrl::MAX_NAME_LENGTH + 1), "/wrong-url") section_modal.fill_link("B" * (SidebarUrl::MAX_NAME_LENGTH + 1), "https:")
expect(page.find(".title.warning")).to have_content("Title must be shorter than 30 characters") expect(page.find(".title.warning")).to have_content("Title must be shorter than 30 characters")
expect(page.find(".name.warning")).to have_content("Name must be shorter than 80 characters") expect(page.find(".name.warning")).to have_content("Name must be shorter than 80 characters")