diff --git a/helpers/undici/index.js b/helpers/undici/index.js index 9c761756e..ba0e14bcd 100644 --- a/helpers/undici/index.js +++ b/helpers/undici/index.js @@ -13,7 +13,7 @@ const copies = { }; export const bundledVersion = process.versions.undici; -export const packageName = copies[Number(bundledVersion.split(".", 1)[0])]; +export const packageName = copies[Number(bundledVersion?.split(".", 1)[0])]; if (!packageName) { throw new Error( diff --git a/jsconfig.base.json b/jsconfig.base.json index c7adc1642..bf528c87c 100644 --- a/jsconfig.base.json +++ b/jsconfig.base.json @@ -8,6 +8,7 @@ "resolveJsonModule": true, "skipLibCheck": true, "strict": false, + "strictNullChecks": true, "target": "ESNext", "types": [], "useUnknownInCatchVariables": true diff --git a/packages/endpoint-auth/lib/client.js b/packages/endpoint-auth/lib/client.js index f4d61966a..bc8497760 100644 --- a/packages/endpoint-auth/lib/client.js +++ b/packages/endpoint-auth/lib/client.js @@ -63,7 +63,7 @@ export const getApplicationInformation = (body, client) => { for (const item of items) { const { properties, type } = item; - if (/^h-(?:x-)?app$/.test(type[0])) { + if (type && /^h-(?:x-)?app$/.test(type[0])) { // If no URL property, use baseUrl if (!properties.url) { properties.url = [client.url]; diff --git a/packages/endpoint-auth/lib/controllers/introspection.js b/packages/endpoint-auth/lib/controllers/introspection.js index 22f2aa4bf..cf549add5 100644 --- a/packages/endpoint-auth/lib/controllers/introspection.js +++ b/packages/endpoint-auth/lib/controllers/introspection.js @@ -14,7 +14,11 @@ export const introspectionController = { if (!token) { // Remove ‘Bearer ’ from authorization header - token = request.headers.authorization.trim().split(/\s+/, 2)[1]; + token = request.headers.authorization?.trim().split(/\s+/, 2)[1]; + } + + if (!token) { + return response.json({ active: false }); } let accessToken = verifyToken(token); diff --git a/packages/endpoint-auth/lib/controllers/userinfo.js b/packages/endpoint-auth/lib/controllers/userinfo.js index 5e744dcd4..ed337fc6f 100644 --- a/packages/endpoint-auth/lib/controllers/userinfo.js +++ b/packages/endpoint-auth/lib/controllers/userinfo.js @@ -20,6 +20,10 @@ export const userinfoController = try { // Remove ‘Bearer ’ from authorization header const token = request.headers.authorization?.trim().split(/\s+/, 2)[1]; + if (!token) { + throw new Error("No token"); + } + accessToken = verifyToken(token); } catch { throw IndiekitError.unauthorized( diff --git a/packages/endpoint-files/lib/controllers/files.js b/packages/endpoint-files/lib/controllers/files.js index 598ca098d..68cd54a92 100644 --- a/packages/endpoint-files/lib/controllers/files.js +++ b/packages/endpoint-files/lib/controllers/files.js @@ -10,7 +10,8 @@ import { getFileName } from "../utils.js"; export const filesController = async (request, response, next) => { try { const { application } = request.app.locals; - const { access_token, scope } = request.session; + const access_token = request.session?.access_token; + const scope = request.session?.scope; const { after, before, success } = request.query; const limit = Number(request.query.limit) || 20; diff --git a/packages/endpoint-files/lib/utils.js b/packages/endpoint-files/lib/utils.js index 4ff8651ba..bfb47b667 100644 --- a/packages/endpoint-files/lib/utils.js +++ b/packages/endpoint-files/lib/utils.js @@ -40,7 +40,7 @@ export const getFileProperties = async (uid, mediaEndpoint, accessToken) => { */ export const getFileName = (url) => { const { pathname } = new URL(url); - return pathname.split("/").pop(); + return pathname.slice(pathname.lastIndexOf("/") + 1); }; /** diff --git a/packages/endpoint-media/lib/controllers/action.js b/packages/endpoint-media/lib/controllers/action.js index f305293b6..17dd3a67b 100644 --- a/packages/endpoint-media/lib/controllers/action.js +++ b/packages/endpoint-media/lib/controllers/action.js @@ -19,7 +19,7 @@ export const actionController = (imageProcessing) => try { // Check provided scope - const { scope } = session; + const scope = session?.scope; const hasScope = checkScope(scope); if (!hasScope) { throw IndiekitError.insufficientScope( diff --git a/packages/endpoint-media/lib/file.js b/packages/endpoint-media/lib/file.js index 897dda1b3..13a5693d7 100644 --- a/packages/endpoint-media/lib/file.js +++ b/packages/endpoint-media/lib/file.js @@ -1,8 +1,24 @@ import path from "node:path"; +import { IndiekitError } from "@indiekit/error"; import { getDate, slugify } from "@indiekit/util"; import { fileTypeFromBuffer } from "file-type"; +/** + * Get file type from file data + * @param {object} file - File object + * @returns {Promise} File type + */ +const getFileType = async (file) => { + const fileType = await fileTypeFromBuffer(file.data); + if (!fileType) { + // Message is the media type; controller localises the error + throw IndiekitError.unsupportedMediaType(file.mimetype || "unknown"); + } + + return fileType; +}; + /** * Derive properties from file data * @param {object} publication - Publication configuration @@ -17,7 +33,7 @@ import { fileTypeFromBuffer } from "file-type"; * } */ export const getFileProperties = async (publication, file, timeZone) => { - const { ext } = await fileTypeFromBuffer(file.data); + const { ext } = await getFileType(file); const published = getPublishedProperty(timeZone); let basename = path.basename(file.name, path.extname(file.name)); @@ -39,7 +55,7 @@ export const getFileProperties = async (publication, file, timeZone) => { * @example getMediaType("brighton-pier.jpg") => "photo" */ export const getMediaType = async (file) => { - const { mime } = await fileTypeFromBuffer(file.data); + const { mime } = await getFileType(file); const type = mime.split("/", 1)[0]; if (type === "image") { diff --git a/packages/endpoint-media/lib/scope.js b/packages/endpoint-media/lib/scope.js index def0013a0..707be9165 100644 --- a/packages/endpoint-media/lib/scope.js +++ b/packages/endpoint-media/lib/scope.js @@ -1,6 +1,6 @@ /** * Check provided scope(s) satisfies required scope - * @param {string} scope - Provided scope (space separated) + * @param {string} [scope] - Provided scope (space separated) * @param {string} [action] - Required action * @returns {boolean} `true` if provided scope includes action */ diff --git a/packages/endpoint-media/test/unit/file.js b/packages/endpoint-media/test/unit/file.js index 149207e43..537d6274e 100644 --- a/packages/endpoint-media/test/unit/file.js +++ b/packages/endpoint-media/test/unit/file.js @@ -1,4 +1,5 @@ import { strict as assert } from "node:assert"; +import { Buffer } from "node:buffer"; import { describe, it } from "node:test"; import { getFixture } from "@indiekit-test/fixtures"; @@ -35,4 +36,21 @@ describe("endpoint-media/lib/file", () => { assert.equal(result.md5, "be7d321488de26f2eb38834af7162164"); assert.equal(isValid(parseISO(result.published)), true); }); + + it("Throws error if file type can’t be determined", async () => { + const file = { + data: Buffer.from("Plain text"), + mimetype: "text/plain", + name: "notes.txt", + }; + + await assert.rejects(getMediaType(file), { + name: "UnsupportedMediaTypeError", + message: "text/plain", + status: 415, + }); + await assert.rejects(getFileProperties({}, file, "UTC"), { + name: "UnsupportedMediaTypeError", + }); + }); }); diff --git a/packages/endpoint-micropub/lib/controllers/action.js b/packages/endpoint-micropub/lib/controllers/action.js index 792266e33..2c976f816 100644 --- a/packages/endpoint-micropub/lib/controllers/action.js +++ b/packages/endpoint-micropub/lib/controllers/action.js @@ -18,7 +18,8 @@ export const actionController = async (request, response, next) => { try { // Check provided scope - const { scope, token } = session; + const scope = session?.scope; + const token = session?.token; const hasScope = checkScope(scope, action); if (!hasScope) { throw IndiekitError.insufficientScope( diff --git a/packages/endpoint-micropub/lib/controllers/query.js b/packages/endpoint-micropub/lib/controllers/query.js index c80b4144e..d322927f2 100644 --- a/packages/endpoint-micropub/lib/controllers/query.js +++ b/packages/endpoint-micropub/lib/controllers/query.js @@ -77,6 +77,9 @@ export const queryController = async (request, response, next) => { return response.json(getMf2Properties(mf2, properties)); } // Return mf2 for published posts + /** + * @type {{ items: object[], hasNext: boolean, hasPrev: boolean, firstItem?: string, lastItem?: string }} + */ let cursor = { items: [], hasNext: false, diff --git a/packages/endpoint-micropub/lib/jf2.js b/packages/endpoint-micropub/lib/jf2.js index a9cc736b4..2b411a673 100644 --- a/packages/endpoint-micropub/lib/jf2.js +++ b/packages/endpoint-micropub/lib/jf2.js @@ -215,15 +215,19 @@ export const getLocationProperty = (properties) => { if (typeof location === "string" && location.startsWith("geo:")) { const geoUriRegexp = /geo:(?[\d+.?-]*),(?[\d+.?-]*)(?:,(?[\d+.?-]*))?/; - const { latitude, longitude, altitude } = - location.match(geoUriRegexp).groups; - - location = { - type: "geo", - latitude, - longitude, - ...(altitude && { altitude }), - }; + const groups = location.match(geoUriRegexp)?.groups; + + // Leave an un-parseable Geo URI as given + if (groups) { + const { latitude, longitude, altitude } = groups; + + location = { + type: "geo", + latitude, + longitude, + ...(altitude && { altitude }), + }; + } } return location; @@ -278,6 +282,9 @@ export const getSlugProperty = (properties, separator) => { const suggested = properties["mp-slug"]; const { name, published } = properties; + /** + * @type {string} + */ let string; if (suggested) { string = suggested; diff --git a/packages/endpoint-micropub/lib/mf2.js b/packages/endpoint-micropub/lib/mf2.js index 37931ed17..69f1ea754 100644 --- a/packages/endpoint-micropub/lib/mf2.js +++ b/packages/endpoint-micropub/lib/mf2.js @@ -1,7 +1,7 @@ /** * Return mf2 properties of a post * @param {object} mf2 - mf2 object - * @param {Array|string} requestedProperties - mf2 properties to select + * @param {Array|string} [requestedProperties] - mf2 properties to select * @returns {Promise} mf2 with requested properties */ export const getMf2Properties = (mf2, requestedProperties) => { diff --git a/packages/endpoint-micropub/lib/post-data.js b/packages/endpoint-micropub/lib/post-data.js index fddd1db08..69d74f1fa 100644 --- a/packages/endpoint-micropub/lib/post-data.js +++ b/packages/endpoint-micropub/lib/post-data.js @@ -15,13 +15,13 @@ const debug = makeDebug("indiekit:endpoint-micropub:post-data"); /** * Get post type configuration, which must include a post path * @param {object} postTypes - Publication post types - * @param {string} type - Post type + * @param {string|null} type - Post type * @returns {object} Post type configuration */ const getTypeConfig = (postTypes, type) => { - const typeConfig = postTypes[type]; + const typeConfig = type ? postTypes[type] : undefined; if (!typeConfig?.post?.path) { - throw IndiekitError.notImplemented(type); + throw IndiekitError.notImplemented(String(type)); } return typeConfig; diff --git a/packages/endpoint-micropub/lib/scope.js b/packages/endpoint-micropub/lib/scope.js index d1f5d8552..6a133645f 100644 --- a/packages/endpoint-micropub/lib/scope.js +++ b/packages/endpoint-micropub/lib/scope.js @@ -1,6 +1,6 @@ /** * Check provided scope(s) satisfies required scope - * @param {string} scope - Provided scope (space separated) + * @param {string} [scope] - Provided scope (space separated) * @param {string} [action] - Required action * @returns {boolean|string} `true` if provided scope includes action, * `draft` if draft scope, otherwise `false` diff --git a/packages/endpoint-micropub/test/unit/media.js b/packages/endpoint-micropub/test/unit/media.js index 5e523fe18..55ad986ce 100644 --- a/packages/endpoint-micropub/test/unit/media.js +++ b/packages/endpoint-micropub/test/unit/media.js @@ -53,6 +53,7 @@ describe("endpoint-micropub/lib/media", () => { }); it("Throws error no media endpoint URL", async () => { + // @ts-ignore: Testing invalid input await assert.rejects(uploadMedia(undefined, token, properties, files), { message: "Failed to parse URL from undefined", }); diff --git a/packages/endpoint-posts/lib/controllers/new.js b/packages/endpoint-posts/lib/controllers/new.js index 96b1984c2..1f7e30f9d 100644 --- a/packages/endpoint-posts/lib/controllers/new.js +++ b/packages/endpoint-posts/lib/controllers/new.js @@ -14,7 +14,7 @@ export const newController = { const { publication } = request.app.locals; const postsPath = path.dirname(request.baseUrl + request.path); const postType = request.query.type; - const { scope } = request.session; + const scope = request.session?.scope; const postTypeItems = Object.values(publication.postTypes) .toSorted((a, b) => { diff --git a/packages/endpoint-posts/lib/controllers/posts.js b/packages/endpoint-posts/lib/controllers/posts.js index cca8bcf80..2c85bf287 100644 --- a/packages/endpoint-posts/lib/controllers/posts.js +++ b/packages/endpoint-posts/lib/controllers/posts.js @@ -15,7 +15,8 @@ import { getPostStatusBadges, getPostName, getPhotoUrl } from "../utils.js"; export const postsController = async (request, response, next) => { try { const { application, publication } = request.app.locals; - const { access_token, scope } = request.session; + const access_token = request.session?.access_token; + const scope = request.session?.scope; const { after, before, success } = request.query; const limit = Number(request.query.limit) || 12; diff --git a/packages/endpoint-posts/lib/utils.js b/packages/endpoint-posts/lib/utils.js index 86ba2867d..db719b5f6 100644 --- a/packages/endpoint-posts/lib/utils.js +++ b/packages/endpoint-posts/lib/utils.js @@ -26,7 +26,12 @@ export const getChannelItems = (publication) => { * @returns {object} JF2 geo location property */ export const getGeoProperty = (geo) => { - const { latitude, longitude } = geo.match(ISO_6709_RE).groups; + const groups = geo.match(ISO_6709_RE)?.groups; + if (!groups) { + throw IndiekitError.badRequest(`Invalid geographic coordinates: ${geo}`); + } + + const { latitude, longitude } = groups; return { type: "geo", diff --git a/packages/error/test/index.js b/packages/error/test/index.js index 2e80f1d05..ea351ff64 100644 --- a/packages/error/test/index.js +++ b/packages/error/test/index.js @@ -44,6 +44,7 @@ describe("error", () => { indiekitError(); }, (error) => { + assert.ok(error instanceof IndiekitError); assert.equal(error.toString(), "IndiekitError: Message"); return true; }, diff --git a/packages/frontend/components/add-another/index.js b/packages/frontend/components/add-another/index.js index 35d5e83bb..5285803c8 100644 --- a/packages/frontend/components/add-another/index.js +++ b/packages/frontend/components/add-another/index.js @@ -1,3 +1,5 @@ +import { getElement } from "../../scripts/utils/get-element"; + const focusableSelector = `button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]`; export const AddAnotherComponent = class extends HTMLElement { @@ -22,10 +24,10 @@ export const AddAnotherComponent = class extends HTMLElement { $list; connectedCallback() { - this.$addButtonTemplate = this.querySelector("#add-button"); - this.$deleteButtonTemplate = this.querySelector("#delete-button"); + this.$addButtonTemplate = getElement(this, "#add-button"); + this.$deleteButtonTemplate = getElement(this, "#delete-button"); this.$$fields = this.querySelectorAll(".field"); - this.$list = this.querySelector(".add-another__list"); + this.$list = getElement(this, ".add-another__list"); this.updateItems(); this.createAddButton(); @@ -70,7 +72,7 @@ export const AddAnotherComponent = class extends HTMLElement { * @returns {HTMLLegendElement} - Group legend */ getHeading() { - return this.querySelector("legend"); + return getElement(this, "legend"); } /** @@ -91,14 +93,14 @@ export const AddAnotherComponent = class extends HTMLElement { this.append($addButton); - $addButton = this.querySelector(".add-another__add"); + $addButton = getElement(this, ".add-another__add"); $addButton.addEventListener("click", (event) => this.add(event)); } /** * Get delete button * @param {HTMLElement} element - Containing element - * @returns {HTMLButtonElement} - Delete button + * @returns {HTMLButtonElement|null} - Delete button */ getDeleteButton(element) { return element.querySelector(".add-another__delete"); @@ -109,8 +111,10 @@ export const AddAnotherComponent = class extends HTMLElement { * @param {HTMLElement} element - Containing element */ createDeleteButton(element) { - const $deleteButton = - this.$deleteButtonTemplate.content.firstElementChild.cloneNode(true); + const $deleteButton = getElement( + this.$deleteButtonTemplate.content, + ".add-another__delete", + ).cloneNode(true); element.append($deleteButton); } @@ -121,6 +125,11 @@ export const AddAnotherComponent = class extends HTMLElement { */ updateDeleteButton(element) { const $deleteButton = this.getDeleteButton(element); + + if (!$deleteButton) { + return; + } + $deleteButton.setAttribute("aria-labelledby", `delete-title ${element.id}`); $deleteButton.addEventListener("click", (event) => this.delete(event)); } @@ -161,6 +170,11 @@ export const AddAnotherComponent = class extends HTMLElement { const $$labels = $item.querySelectorAll("label"); for (const $label of $$labels) { const forAttribute = $label.getAttribute("for"); + + if (!forAttribute) { + continue; + } + $label.setAttribute("for", forAttribute.replace("-0", `-${uid}`)); } @@ -190,13 +204,13 @@ export const AddAnotherComponent = class extends HTMLElement { this.createDeleteButton($item); } + const $deleteButton = this.getDeleteButton($item); + // If has delete button - if (this.getDeleteButton($item)) { + if ($deleteButton) { if ($$items.length === 1) { - // If only 1 item in list, remove button - this.getDeleteButton($item).remove(); + $deleteButton.remove(); } else { - // Else update button attributes this.updateDeleteButton($item); } } diff --git a/packages/frontend/components/character-count/index.js b/packages/frontend/components/character-count/index.js index 73472dff9..699a4e440 100644 --- a/packages/frontend/components/character-count/index.js +++ b/packages/frontend/components/character-count/index.js @@ -1,3 +1,5 @@ +import { getElement } from "../../scripts/utils/get-element"; + /** * Based on the Character count component from the GOV.UK Design System. * Provides a visible, real-time character and word count, and visually @@ -9,6 +11,31 @@ * @see {@link https://dav-idc.com/making-a-character-count-component-more-accessible} */ export const CharacterCountComponent = class extends HTMLElement { + /** + * @type {string} + */ + i18nChar; + + /** + * @type {string} + */ + i18nChars; + + /** + * @type {string} + */ + i18nWord; + + /** + * @type {string} + */ + i18nWords; + + /** + * @type {HTMLTextAreaElement} + */ + $textarea; + constructor() { super(); @@ -19,13 +46,8 @@ export const CharacterCountComponent = class extends HTMLElement { this.$screenReaderCountMessage = document.createElement("p"); this.$screenReaderCountMessage.className = "-!-visually-hidden"; this.$screenReaderCountMessage.setAttribute("aria-live", "polite"); - this.$textareaDescription.insertAdjacentElement( - "afterend", - this.$screenReaderCountMessage, - ); this.$visibleCountMessage = document.createElement("p"); - this.$visibleCountMessage.className = this.$textareaDescription.className; this.$visibleCountMessage.setAttribute("aria-hidden", "true"); } @@ -168,17 +190,20 @@ export const CharacterCountComponent = class extends HTMLElement { this.i18nWord = this.getAttribute("i18n-word") || `%s word`; this.i18nWords = this.getAttribute("i18n-words") || `%s words`; - this.$textarea = this.querySelector("textarea"); + this.$textarea = getElement(this, "textarea"); this.$textarea.addEventListener("keyup", this.#handleKeyUp.bind(this)); this.$textarea.addEventListener("focus", this.#handleFocus.bind(this)); this.$textarea.addEventListener("blur", this.#handleBlur.bind(this)); window.addEventListener("pageshow", this.#updateCountMessages.bind(this)); this.#updateCountMessages(); - this.$textareaDescription = this.querySelector( - `#${this.$textarea.id}-info`, - ); + this.$textareaDescription = getElement(this, `#${this.$textarea.id}-info`); this.$textareaDescription.classList.add("-!-visually-hidden"); + this.$textareaDescription.insertAdjacentElement( + "afterend", + this.$screenReaderCountMessage, + ); + this.$visibleCountMessage.className = this.$textareaDescription.className; this.$textareaDescription.insertAdjacentElement( "afterend", this.$visibleCountMessage, diff --git a/packages/frontend/components/checkboxes/index.js b/packages/frontend/components/checkboxes/index.js index a9022cb64..ab5fb055f 100644 --- a/packages/frontend/components/checkboxes/index.js +++ b/packages/frontend/components/checkboxes/index.js @@ -3,6 +3,11 @@ * @see {@link https://github.com/alphagov/govuk-frontend/blob/main/packages/govuk-frontend/src/govuk/components/checkboxes/checkboxes.mjs} */ export const CheckboxesFieldComponent = class extends HTMLElement { + /** + * @type {NodeListOf} + */ + $$inputTargets; + connectedCallback() { this.$$inputTargets = this.querySelectorAll("input"); for (const $input of this.$$inputTargets) { diff --git a/packages/frontend/components/error-summary/index.js b/packages/frontend/components/error-summary/index.js index 8246ad43b..edaa212d8 100644 --- a/packages/frontend/components/error-summary/index.js +++ b/packages/frontend/components/error-summary/index.js @@ -62,10 +62,14 @@ export const ErrorSummaryComponent = class extends HTMLElement { const fragment = this.getFragmentFromUrl($target.href); - /** - * @satisfies {HTMLInputElement} - */ - const $input = document.querySelector(`#${fragment}`); + if (!fragment) { + return false; + } + + const $input = /** @type {HTMLInputElement|null} */ ( + document.querySelector(`#${CSS.escape(fragment)}`) + ); + if (!$input) { return false; } @@ -85,11 +89,12 @@ export const ErrorSummaryComponent = class extends HTMLElement { /** * Get fragment name from a URL - * @param {string} url - URL - * @returns {string|boolean} Fragment name (without the hash) + * @param {string} url - Absolute or relative URL, or a bare fragment + * @returns {string|undefined} Fragment name (without hash), undefined if none */ getFragmentFromUrl(url) { - return !url.startsWith("#") && url.split("#").pop(); + const index = url.indexOf("#"); + return index === -1 ? undefined : url.slice(index + 1); } /** @@ -103,7 +108,7 @@ export const ErrorSummaryComponent = class extends HTMLElement { * - The first `