diff --git a/jsconfig.json b/jsconfig.json index d1365282c..b50f8e19f 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -10,6 +10,8 @@ "sourceMap": true, "strict": false, "moduleResolution": "bundler", + "noUncheckedIndexedAccess": true, + "strictNullChecks": true, "types": ["@testing-library/jest-dom"] }, // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias and https://kit.svelte.dev/docs/configuration#files diff --git a/src/addons/AddOnPin.svelte b/src/addons/AddOnPin.svelte index aed8124e0..8e4125dc4 100644 --- a/src/addons/AddOnPin.svelte +++ b/src/addons/AddOnPin.svelte @@ -19,6 +19,10 @@ event.preventDefault(); const csrftoken = getCsrfToken(); + if (!csrftoken) { + console.error("No CSRF token found"); + return; + } const options: RequestInit = { credentials: "include", method: "PATCH", // this component can only update whether an addon is active or not diff --git a/src/addons/types.ts b/src/addons/types.ts index 86ed8cf9e..b7cbbc78a 100644 --- a/src/addons/types.ts +++ b/src/addons/types.ts @@ -60,8 +60,8 @@ interface AddOnParameters { // https://api.www.documentcloud.org/api/addons/ export interface AddOnListItem { id: number; - user: number; - organization: number; + user: null | number; + organization: null | number; access: "public" | "private"; name: string; repository: string; diff --git a/src/api/session.ts b/src/api/session.ts index 3ed07a6e6..97c4da24e 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -25,7 +25,7 @@ export function getCsrfToken() { ?.split(";") ?.map((c) => c.split("=")) // in case there's spaces in the cookie string, trim the key - ?.find(([k, v]) => k.trim() === CSRF_COOKIE_NAME) ?? []; + ?.find(([k, v]) => k?.trim() === CSRF_COOKIE_NAME) ?? []; return token; } diff --git a/src/api/types/project.ts b/src/api/types/project.ts index 08af727dd..41eedc08f 100644 --- a/src/api/types/project.ts +++ b/src/api/types/project.ts @@ -7,7 +7,7 @@ export interface Project { private: boolean; created_at: string; updated_at: string; - edit_access: boolean; - add_remove_access: boolean; + edit_access: null | boolean; + add_remove_access: null | boolean; pinned?: boolean; } diff --git a/src/common/Button.svelte b/src/common/Button.svelte index ae60e78e6..f61074197 100644 --- a/src/common/Button.svelte +++ b/src/common/Button.svelte @@ -23,7 +23,7 @@ export let type: "submit" | "reset" | "button" = "submit"; export let label = "Submit"; - export let disabledReason = null; + export let disabledReason = ""; diff --git a/src/common/RelativeTime.svelte b/src/common/RelativeTime.svelte index 182e1e68f..2a8feafdb 100644 --- a/src/common/RelativeTime.svelte +++ b/src/common/RelativeTime.svelte @@ -3,7 +3,7 @@ export let date: Date; - const relativeFormatter = new Intl.RelativeTimeFormat($locale, { + const relativeFormatter = new Intl.RelativeTimeFormat($locale ?? "en", { style: "long", }); @@ -24,23 +24,30 @@ function formatTimeAgo(date: Date): string { let duration = (date.getTime() - new Date().getTime()) / 1000; + let formatted: string = ""; for (let i = 0; i < DIVISIONS.length; i++) { - const division = DIVISIONS[i]; + const division = DIVISIONS[i]!; if (Math.abs(duration) < division.amount) { - return relativeFormatter.format(Math.round(duration), division.name); + formatted = relativeFormatter.format( + Math.round(duration), + division.name, + ); + break; } duration /= division.amount; } + + return formatted; } + + - - diff --git a/src/langs/json/en.json b/src/langs/json/en.json index 70a439bea..41c05a019 100644 --- a/src/langs/json/en.json +++ b/src/langs/json/en.json @@ -1046,7 +1046,8 @@ "memberMessage": "This Premium Add-On uses AI to perform advanced analysis. Contact your organization admin about upgrading your plan." }, "dispatch": "Dispatch", - "history": "History" + "history": "History", + "noHistory": "You have not run this add-on before" }, "addonBrowserDialog": { "title": "Browse Add-Ons", @@ -1108,6 +1109,7 @@ "mailkey": { "title": "Upload via email", "description": "

You can upload documents to your account by sending them to to a special email address as attachments.

For security reasons, this email is only shown to you once. Please copy it to a secure location.

This address will accept attachments from any email account. Documents are uploaded as private. Generating a new upload address will disable any previously created addresses.

We’d love your feedback and to hear about creative use cases at info@documentcloud.org.

", + "missing_csrf": "Missing CSRF token", "create": { "button": "Create new email address", "success": "Upload email address succesfully created:
{mailkey}@uploads.documentcloud.org", @@ -1142,6 +1144,7 @@ "processing": { "addons": "Add-Ons", "documents": "Documents", + "unknown": "Unknown", "totalCount": "{n} active {n, plural, one {process} other {processes}}" }, "feedback": { diff --git a/src/lib/api/addons.ts b/src/lib/api/addons.ts index e0f458301..4eaaaed98 100644 --- a/src/lib/api/addons.ts +++ b/src/lib/api/addons.ts @@ -69,10 +69,11 @@ export async function getAddon( const repository = [owner, repo].join("/"); const { data: addons, error } = await getAddons({ repository }, fetch); // there should only be one result, if the addon exists - if (error || addons.results.length < 1) { + + if (error || !addons || addons.results.length < 1) { return null; } - return addons.results[0]; + return addons.results[0] ?? null; } export async function getEvent( @@ -298,17 +299,15 @@ export function buildPayload( const payload: AddOnPayload = { addon: addon.id, parameters }; const selection = formData.get("selection"); + const documents = formData.get("documents"); + const query = formData.get("query"); - if (selection === "selected") { - payload.documents = formData - .get("documents") - .toString() - .split(",") - .map(Number); + if (selection === "selected" && documents) { + payload.documents = documents.toString().split(",").map(Number); } - if (selection === "query") { - payload.query = formData.get("query").toString(); + if (selection === "query" && query) { + payload.query = query.toString(); } if (formData.has("event")) { diff --git a/src/lib/api/documents.ts b/src/lib/api/documents.ts index a83049c18..34e004dc8 100644 --- a/src/lib/api/documents.ts +++ b/src/lib/api/documents.ts @@ -252,7 +252,7 @@ export async function process( }[], csrf_token: string, fetch = globalThis.fetch, -): Promise> { +): Promise> { const endpoint = new URL("documents/process/", BASE_API_URL); const resp = await fetch(endpoint, { @@ -561,9 +561,9 @@ export function pageFromHash(hash: string): Nullable { const re = /^#document\/p(\d+)/; // match pages and notes const match = re.exec(hash); - if (!match) return null; + if (!match || !match[1]) return null; - return +match[1] || null; + return +match[1]; } /** diff --git a/src/lib/api/notes.ts b/src/lib/api/notes.ts index 9635f37d5..0c9062b08 100644 --- a/src/lib/api/notes.ts +++ b/src/lib/api/notes.ts @@ -4,6 +4,8 @@ import type { Document, Note, NoteResults, + Nullable, + Page, ValidationError, } from "./types"; @@ -22,18 +24,19 @@ import { getApiResponse, isErrorCode } from "../utils"; * @example https://api.www.documentcloud.org/api/documents/2622/notes/ * @deprecated */ -export async function list(doc_id: number, fetch = globalThis.fetch) { +export async function list( + doc_id: number, + fetch = globalThis.fetch, +): Promise>> { const endpoint = new URL(`documents/${doc_id}/notes/`, BASE_API_URL); endpoint.searchParams.set("expand", DEFAULT_EXPAND); - const resp = await fetch(endpoint, { credentials: "include" }); - - if (isErrorCode(resp.status)) { - throw new Error(resp.statusText); - } + const resp = await fetch(endpoint, { credentials: "include" }).catch( + console.error, + ); - return resp.json(); + return getApiResponse>(resp); } /** @@ -176,13 +179,12 @@ export function noteHashUrl(note: Note): string { * To get the page number, use pageFromHash * @param hash */ -export function noteFromHash(hash: string): number { +export function noteFromHash(hash: string): Nullable { const re = /^#document\/p(\d+)\/a(\d+)$/; const match = re.exec(hash); - if (!match) return null; - - return +match[2] || null; + if (!match || !match[2]) return null; + return +match[2]; } /** Width of a note, relative to the document */ diff --git a/src/lib/api/sections.ts b/src/lib/api/sections.ts index 25a593713..fddcc8946 100644 --- a/src/lib/api/sections.ts +++ b/src/lib/api/sections.ts @@ -74,11 +74,17 @@ export async function get( export async function create( doc_id: string | number, section: { page_number: number; title: string }, - csrf_token: string, + csrf_token: string | undefined, fetch = globalThis.fetch, ): Promise> { const endpoint = new URL(`documents/${doc_id}/sections/`, BASE_API_URL); + if (!csrf_token) { + return Promise.reject({ + error: { status: 403, message: "CSRF token required" }, + }); + } + const resp = await fetch(endpoint, { credentials: "include", body: JSON.stringify(section), @@ -100,7 +106,7 @@ export async function update( doc_id: string | number, section_id: string | number, section: { page_number?: number; title?: string }, - csrf_token: string, + csrf_token: string | undefined, fetch = globalThis.fetch, ): Promise> { const endpoint = new URL( @@ -108,6 +114,12 @@ export async function update( BASE_API_URL, ); + if (!csrf_token) { + return Promise.reject({ + error: { status: 403, message: "CSRF token required" }, + }); + } + const resp = await fetch(endpoint, { credentials: "include", body: JSON.stringify(section), @@ -128,7 +140,7 @@ export async function update( export async function remove( doc_id: string | number, section_id: string | number, - csrf_token: string, + csrf_token: string | undefined, fetch = globalThis.fetch, ): Promise> { const endpoint = new URL( @@ -136,6 +148,12 @@ export async function remove( BASE_API_URL, ); + if (!csrf_token) { + return Promise.reject({ + error: { status: 403, message: "CSRF token required" }, + }); + } + const resp = await fetch(endpoint, { credentials: "include", method: "DELETE", diff --git a/src/lib/api/tests/addons.test.ts b/src/lib/api/tests/addons.test.ts index 578e9bda6..ab3ccad8c 100644 --- a/src/lib/api/tests/addons.test.ts +++ b/src/lib/api/tests/addons.test.ts @@ -103,7 +103,7 @@ describe("getAddon", async () => { describe("addon payloads", () => { test("buildPayload single dispatch", () => { - const scraper = addonsList.results.find((a) => a.name === "Scraper"); + const scraper = addonsList.results.find((a) => a.name === "Scraper")!; const parameters = { site: "https://www.documentcloud.org", project: "test", @@ -118,7 +118,7 @@ describe("addon payloads", () => { }); test("buildPayload scheduled event", () => { - const scraper = addonsList.results.find((a) => a.name === "Scraper"); + const scraper = addonsList.results.find((a) => a.name === "Scraper")!; const parameters = { site: "https://www.documentcloud.org", project: "test", @@ -140,7 +140,7 @@ describe("addon payloads", () => { test("buildPayload array param", () => { const siteSnapshot = addonsList.results.find( (a) => a.name === "Site Snapshot", - ); + )!; const parameters = { sites: ["https://www.muckrock.com", "https://www.documentcloud.org"], project_id: 1, @@ -159,7 +159,7 @@ describe("addon payloads", () => { }); test("buildPayload remove blank values", () => { - const scraper = addonsList.results.find((a) => a.name === "Scraper"); + const scraper = addonsList.results.find((a) => a.name === "Scraper")!; const parameters = { site: "https://www.documentcloud.org", @@ -187,7 +187,7 @@ describe("addon payloads", () => { test("buildPayload with documents", () => { const translate = addonsList.results.find( (a) => a.name === "Translate Documents", - ); + )!; const parameters = { access_level: "public", project_id: 1, @@ -219,7 +219,7 @@ describe("addon payloads", () => { test("buildPayload with query", () => { const translate = addonsList.results.find( (a) => a.name === "Translate Documents", - ); + )!; const parameters = { access_level: "public", project_id: 1, diff --git a/src/lib/api/tests/collaborators.test.ts b/src/lib/api/tests/collaborators.test.ts index f4622c78f..dfebcf48a 100644 --- a/src/lib/api/tests/collaborators.test.ts +++ b/src/lib/api/tests/collaborators.test.ts @@ -74,7 +74,7 @@ describe("manage project users", () => { const { data } = await collaborators.add( project.id, - { email: me.email, access: "admin" }, + { email: me.email!, access: "admin" }, "token", mockFetch, ); @@ -105,7 +105,7 @@ describe("manage project users", () => { const { user, access } = JSON.parse(options.body); const updated: ProjectUser = users.results.find( (u) => u.user.id === 1020, - ); + )!; return { ok: true, @@ -119,7 +119,7 @@ describe("manage project users", () => { }; }); - const me = users.results.find((u) => u.user.id === 1020); + const me = users.results.find((u) => u.user.id === 1020)!; const { data: updated } = await collaborators.update( project.id, @@ -154,7 +154,7 @@ describe("manage project users", () => { }; }); - const me = users.results.find((u) => u.user.id === 1020); + const me = users.results.find((u) => u.user.id === 1020)!; const { data } = await collaborators.remove( project.id, me.user.id, diff --git a/src/lib/api/tests/documents.test.ts b/src/lib/api/tests/documents.test.ts index 6c1fd8f49..a8c1b0919 100644 --- a/src/lib/api/tests/documents.test.ts +++ b/src/lib/api/tests/documents.test.ts @@ -32,7 +32,7 @@ const test = base.extend({ "@/test/fixtures/documents/search-highlight.json" ); - await use(results as DocumentResults); + await use(results as unknown as DocumentResults); }, document: async ({}, use: Use) => { @@ -299,19 +299,22 @@ describe("document uploads and processing", () => { ); const resp = await documents.upload( - new URL(created.presigned_url), + new URL(created.presigned_url as string), // we know what this is file, mockFetch, ); expect(resp.ok).toBeTruthy(); - expect(mockFetch).toHaveBeenCalledWith(new URL(created.presigned_url), { - body: file, - headers: { - "Content-Type": file.type, + expect(mockFetch).toHaveBeenCalledWith( + new URL(created.presigned_url as string), + { + body: file, + headers: { + "Content-Type": file.type, + }, + method: "PUT", }, - method: "PUT", - }); + ); }); test("documents.process", async ({ created }) => { @@ -506,7 +509,7 @@ describe("document write methods", () => { mockFetch, ); - expect(updated.title).toStrictEqual("Updated title"); + expect(updated?.title).toStrictEqual("Updated title"); }); test("documents.edit_many", async ({ documents: docs }) => { @@ -568,7 +571,7 @@ describe("document write methods", () => { mockFetch, ); - expect(data["_tag"]).toEqual(["one", "two"]); + expect(data?.["_tag"]).toEqual(["one", "two"]); expect(mockFetch).toBeCalledWith( new URL(`documents/${document.id}/data/_tag/`, BASE_API_URL), { diff --git a/src/lib/api/tests/notes.test.ts b/src/lib/api/tests/notes.test.ts index 37bba9d83..3bbaf8a94 100644 --- a/src/lib/api/tests/notes.test.ts +++ b/src/lib/api/tests/notes.test.ts @@ -184,6 +184,7 @@ describe("note helper methods", () => { }); test("isPageLevel", ({ note }) => { + // @ts-ignore const copy: Note = { ...note, x1: null, x2: null, y1: null, y2: null }; expect(notes.isPageLevel(copy)).toBeTruthy(); diff --git a/src/lib/api/tests/sections.test.ts b/src/lib/api/tests/sections.test.ts index 5d4d3718b..5a69eaf5e 100644 --- a/src/lib/api/tests/sections.test.ts +++ b/src/lib/api/tests/sections.test.ts @@ -75,7 +75,7 @@ describe("sections: writing", () => { }); test("sections.update", async ({ document, sectionList }) => { - const section = sectionList.results[0]; + const section = sectionList.results[0]!; const mockFetch = vi.fn().mockImplementation(async (endpoint, options) => { const updated = { ...section, ...JSON.parse(options.body) }; @@ -118,7 +118,7 @@ describe("sections: writing", () => { }); test("sections.remove", async ({ document, sectionList }) => { - const section = sectionList.results[0]; + const section = sectionList.results[0]!; const mockFetch = vi.fn().mockImplementation(async (endpoint, options) => { return { ok: true, diff --git a/src/lib/api/types.d.ts b/src/lib/api/types.d.ts index 9294d6a47..84e8ae827 100644 --- a/src/lib/api/types.d.ts +++ b/src/lib/api/types.d.ts @@ -90,8 +90,8 @@ interface AddOnParameters { // API endpoint https://api.www.documentcloud.org/api/addons/ export interface AddOnListItem { id: number; - user: number; - organization: number; + user: null | number; + organization: null | number; access: "public" | "private"; name: string; repository: string; diff --git a/src/lib/components/accounts/Avatar.svelte b/src/lib/components/accounts/Avatar.svelte index d7ec46879..f4e2b38e6 100644 --- a/src/lib/components/accounts/Avatar.svelte +++ b/src/lib/components/accounts/Avatar.svelte @@ -1,9 +1,9 @@
diff --git a/src/lib/components/accounts/Mailkey.svelte b/src/lib/components/accounts/Mailkey.svelte index 5a3e50857..f9d2b1fff 100644 --- a/src/lib/components/accounts/Mailkey.svelte +++ b/src/lib/components/accounts/Mailkey.svelte @@ -20,6 +20,11 @@ async function create() { reset(); const csrf_token = getCsrfToken(); + if (!csrf_token) { + error = true; + message = $_("mailkey.missing_csrf"); + return; + } const mailkey = await createMailkey(csrf_token, fetch); if (mailkey) { message = $_("mailkey.create.success", { @@ -34,6 +39,11 @@ async function destroy() { reset(); const csrf_token = getCsrfToken(); + if (!csrf_token) { + error = true; + message = $_("mailkey.missing_csrf"); + return; + } if (await destroyMailkey(csrf_token, fetch)) { message = $_("mailkey.destroy.success"); } else { diff --git a/src/lib/components/addons/AddOnMeta.svelte b/src/lib/components/addons/AddOnMeta.svelte index b7f538947..3f87d1064 100644 --- a/src/lib/components/addons/AddOnMeta.svelte +++ b/src/lib/components/addons/AddOnMeta.svelte @@ -2,7 +2,7 @@ import type { AddOnListItem } from "@/addons/types"; import { _ } from "svelte-i18n"; - import { MarkGithub16, Share16 } from "svelte-octicons"; + import { MarkGithub16 } from "svelte-octicons"; import Button from "$lib/components/common/Button.svelte"; import Flex from "@/lib/components/common/Flex.svelte"; @@ -34,10 +34,6 @@

{github_org}

-