Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save tsibley/4922b5fde8d8b7010b3271782173617d to your computer and use it in GitHub Desktop.
Save tsibley/4922b5fde8d8b7010b3271782173617d to your computer and use it in GitHub Desktop.
From c04b4e79916b73706deed209600dcee9ee1694b4 Mon Sep 17 00:00:00 2001
From: Thomas Sibley <[email protected]>
Date: Thu, 27 Apr 2023 12:13:17 -0700
Subject: [PATCH] Use "href" template literals for safe-by-construction URL
strings
Avoids having to write out encodeURIComponent(), which is quite a
mouthful!
---
src/app.js | 7 +++----
src/endpoints/cli.js | 7 ++++---
src/href.js | 13 +++++++++++++
.../src/components/Groups/edit-logo-form.jsx | 7 ++++---
.../src/components/Groups/edit-overview-form.jsx | 5 +++--
static-site/src/sections/group-settings-page.jsx | 5 +++--
static-site/src/sections/individual-group-page.jsx | 5 +++--
7 files changed, 33 insertions(+), 16 deletions(-)
create mode 100644 src/href.js
diff --git a/src/app.js b/src/app.js
index 0da9337..215ea24 100644
--- a/src/app.js
+++ b/src/app.js
@@ -28,6 +28,7 @@ import { replacer as jsonReplacer } from './json.js';
import * as middleware from './middleware.js';
import * as redirects from './redirects.js';
import * as sources from './sources/index.js';
+import href from './href.js';
const {
setSource,
@@ -60,8 +61,6 @@ const {
GroupSource,
} = sources;
-const esc = encodeURIComponent;
-
const jsonMediaType = type => type.match(/^application\/(.+\+)?json$/);
@@ -306,7 +305,7 @@ app.use("/groups/:groupName",
const restOfUrl = req.url !== "/" ? req.url : "";
const canonicalName = req.context.source.group.name;
- const canonicalUrl = `/groups/${esc(canonicalName)}${restOfUrl}`;
+ const canonicalUrl = href`/groups/${canonicalName}` + restOfUrl;
return req.params.groupName !== canonicalName
? res.redirect(canonicalUrl)
@@ -345,7 +344,7 @@ app.route("/groups/:groupName/settings/*")
// Avoid matching "narratives" as a dataset name.
app.routeAsync("/groups/:groupName/narratives")
- .getAsync((req, res) => res.redirect(`/groups/${esc(req.params.groupName)}`));
+ .getAsync((req, res) => res.redirect(href`/groups/${req.params.groupName}`));
app.routeAsync("/groups/:groupName/narratives/*")
.all(setNarrative(req => req.params[0]))
diff --git a/src/endpoints/cli.js b/src/endpoints/cli.js
index f2ae96c..e83fd6d 100644
--- a/src/endpoints/cli.js
+++ b/src/endpoints/cli.js
@@ -4,6 +4,7 @@ import mime from 'mime';
import { pipeline } from 'stream/promises';
import { BadRequest, InternalServerError, NotFound, ServiceUnavailable } from '../httpErrors.js';
import { fetch } from '../fetch.js';
+import href from '../href.js';
const authorization = process.env.GITHUB_TOKEN
@@ -18,7 +19,7 @@ const download = async (req, res) => {
const endpoint = version === "latest"
? "https://api.github.com/repos/nextstrain/cli/releases/latest"
- : `https://api.github.com/repos/nextstrain/cli/releases/tags/${encodeURIComponent(version)}`;
+ : href`https://api.github.com/repos/nextstrain/cli/releases/tags/${version}`;
const response = await fetch(endpoint, {headers: {authorization}});
assertStatusOk(response);
@@ -44,7 +45,7 @@ const downloadPRBuild = async (req, res) => {
* associated with it. Not all PR-associated workflow runs contain entries
* in the run's "pull_request" array.
*/
- const prResponse = await fetch(`https://api.github.com/repos/nextstrain/cli/pulls/${encodeURIComponent(prId)}`, {headers: {authorization}});
+ const prResponse = await fetch(href`https://api.github.com/repos/nextstrain/cli/pulls/${prId}`, {headers: {authorization}});
assertStatusOk(prResponse);
const pr = await prResponse.json();
@@ -87,7 +88,7 @@ const downloadCIBuild = async (req, res) => {
const assetSuffix = req.params.assetSuffix;
if (!runId || !assetSuffix) throw new BadRequest();
- const endpoint = `https://api.github.com/repos/nextstrain/cli/actions/runs/${encodeURIComponent(runId)}/artifacts`;
+ const endpoint = href`https://api.github.com/repos/nextstrain/cli/actions/runs/${runId}/artifacts`;
const apiResponse = await fetch(endpoint, {headers: {authorization}});
assertStatusOk(apiResponse);
diff --git a/src/href.js b/src/href.js
new file mode 100644
index 0000000..b5808fd
--- /dev/null
+++ b/src/href.js
@@ -0,0 +1,13 @@
+/**
+ * Safe-by-construction URL strings via template literals.
+ *
+ * Interpolations in the template literal are automatically URI-encoded.
+ *
+ * @returns string
+ */
+export default function href(literalParts, ...exprParts) {
+ return literalParts.slice(1).reduce(
+ (url, literalPart, idx) => `${url}${encodeURIComponent(exprParts[idx])}${literalPart}`,
+ literalParts[0]
+ );
+}
diff --git a/static-site/src/components/Groups/edit-logo-form.jsx b/static-site/src/components/Groups/edit-logo-form.jsx
index 2004a0c..c118ee0 100644
--- a/static-site/src/components/Groups/edit-logo-form.jsx
+++ b/static-site/src/components/Groups/edit-logo-form.jsx
@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
+import href from "../../../../src/href.js";
import { MediumSpacer } from "../../layouts/generalComponents";
import * as splashStyles from "../splash/styles";
import { AvatarWithoutMargins, CenteredForm, InputButton, InputLabel} from "./styles";
@@ -24,7 +25,7 @@ const EditLogoForm = ({ groupName, createErrorMessage, clearErrorMessage }) => {
const getGroupLogo = async () => {
clearErrorMessage();
try {
- const response = await fetch(`/groups/${encodeURIComponent(groupName)}/settings/logo`);
+ const response = await fetch(href`/groups/${groupName}/settings/logo`);
if (response.status === 404) return null;
if (response.ok) return URL.createObjectURL(await response.blob());
createErrorMessage(response.statusText);
@@ -41,7 +42,7 @@ const EditLogoForm = ({ groupName, createErrorMessage, clearErrorMessage }) => {
setDeletionInProgress(true);
try {
- const response = await fetch(`/groups/${encodeURIComponent(groupName)}/settings/logo`, {method: "DELETE"});
+ const response = await fetch(href`/groups/${groupName}/settings/logo`, {method: "DELETE"});
response.ok
? setLogo({ ...logo, current: null })
: createErrorMessage(response.statusText);
@@ -59,7 +60,7 @@ const EditLogoForm = ({ groupName, createErrorMessage, clearErrorMessage }) => {
setUploadInProgress(true);
try {
- const response = await fetch(`/groups/${encodeURIComponent(groupName)}/settings/logo`, {
+ const response = await fetch(href`/groups/${groupName}/settings/logo`, {
method: "PUT",
headers: {
"Content-Type": "image/png"
diff --git a/static-site/src/components/Groups/edit-overview-form.jsx b/static-site/src/components/Groups/edit-overview-form.jsx
index 37ace94..58bda73 100644
--- a/static-site/src/components/Groups/edit-overview-form.jsx
+++ b/static-site/src/components/Groups/edit-overview-form.jsx
@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
+import href from "../../../../src/href.js";
import * as splashStyles from "../splash/styles";
import { CenteredForm, InputButton, TextArea } from "./styles";
@@ -33,7 +34,7 @@ const EditOverviewForm = ({ groupName, createErrorMessage, clearErrorMessage })
const getOverview = async () => {
clearErrorMessage();
try {
- const response = await fetch(`/groups/${encodeURIComponent(groupName)}/settings/overview`);
+ const response = await fetch(href`/groups/${groupName}/settings/overview`);
if (response.status === 404) return OVERVIEW_TEMPLATE;
if (response.ok) {
const currentOverview = await response.text();
@@ -54,7 +55,7 @@ const EditOverviewForm = ({ groupName, createErrorMessage, clearErrorMessage })
setUploadInProgress(true);
try {
- const response = await fetch(`/groups/${encodeURIComponent(groupName)}/settings/overview`, {
+ const response = await fetch(href`/groups/${groupName}/settings/overview`, {
method: "PUT",
headers: {
"Content-Type": "text/markdown"
diff --git a/static-site/src/sections/group-settings-page.jsx b/static-site/src/sections/group-settings-page.jsx
index 83a352a..ae17348 100644
--- a/static-site/src/sections/group-settings-page.jsx
+++ b/static-site/src/sections/group-settings-page.jsx
@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
+import href from "../../../src/href.js";
import * as splashStyles from "../components/splash/styles";
import { ErrorBanner } from "../components/splash/errorMessages";
import GenericPage from "../layouts/generic-page";
@@ -40,7 +41,7 @@ const EditGroupSettingsPage = ({ location, groupName }) => {
return (
<GenericPage location={location}>
<FlexGridRight>
- <splashStyles.Button to={`/groups/${encodeURIComponent(groupName)}`}>
+ <splashStyles.Button to={href`/groups/${groupName}`}>
Return to "{groupName}" Page
</splashStyles.Button>
</FlexGridRight>
@@ -73,7 +74,7 @@ const EditGroupSettingsPage = ({ location, groupName }) => {
export const canUserEditGroupSettings = async (groupName) => {
try {
- const groupOverviewOptions = await fetch(`/groups/${encodeURIComponent(groupName)}/settings/overview`, { method: "OPTIONS" });
+ const groupOverviewOptions = await fetch(href`/groups/${groupName}/settings/overview`, { method: "OPTIONS" });
const allowedMethods = new Set(groupOverviewOptions.headers.get("Allow")?.split(/\s*,\s*/));
const editMethods = ["PUT", "DELETE"];
return editMethods.every((method) => allowedMethods.has(method));
diff --git a/static-site/src/sections/individual-group-page.jsx b/static-site/src/sections/individual-group-page.jsx
index 3c82657..8d03a86 100644
--- a/static-site/src/sections/individual-group-page.jsx
+++ b/static-site/src/sections/individual-group-page.jsx
@@ -1,5 +1,6 @@
import React from "react";
import ScrollableAnchor, { configureAnchors } from "react-scrollable-anchor";
+import href from "../../../src/href.js";
import { HugeSpacer, FlexGridRight } from "../layouts/generalComponents";
import * as splashStyles from "../components/splash/styles";
import DatasetSelect from "../components/Datasets/dataset-select";
@@ -36,8 +37,8 @@ class Index extends React.Component {
const groupName = this.props["groupName"];
try {
const [sourceInfo, availableData] = await Promise.all([
- fetchAndParseJSON(`/charon/getSourceInfo?prefix=/groups/${encodeURIComponent(groupName)}/`),
- fetchAndParseJSON(`/charon/getAvailable?prefix=/groups/${encodeURIComponent(groupName)}/`)
+ fetchAndParseJSON(href`/charon/getSourceInfo?prefix=/groups/${groupName}/`),
+ fetchAndParseJSON(href`/charon/getAvailable?prefix=/groups/${groupName}/`)
]);
this.setState({
--
2.40.1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment