From fb757a53db9fe9172b18209bef9f695b7884e5ab Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Sun, 17 Nov 2024 22:54:26 +1100 Subject: [PATCH 001/149] Increase version to 0.7.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eb94a25..f7dab9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minifolio", - "version": "0.6.3", + "version": "0.7.0", "private": true, "license": "GPL-3.0-only", "scripts": { From f76e1a14f682486a6b9b3c353fca234a16d99aa2 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Sun, 17 Nov 2024 22:55:33 +1100 Subject: [PATCH 002/149] Move `item` info to `itemOld.ts` --- src/endpoints/group/item.ts | 2 +- src/lib/links.ts | 2 +- src/lib/server/data/index.ts | 2 +- src/lib/server/data/itemOld.ts | 222 ++++++++++++++++++ src/lib/server/data/migrations/v0.2.0.ts | 2 +- src/lib/server/data/migrations/v0.4.0.ts | 2 +- src/lib/server/links.ts | 2 +- .../group/[groupId]/item/[itemId]/+server.ts | 2 +- .../[groupId]/item/[itemId]/link/+server.ts | 2 +- .../[groupId]/item/[itemId]/readme/+server.ts | 2 +- tests/backend/group/item/create.test.ts | 2 +- tests/backend/helpers.ts | 2 +- 12 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 src/lib/server/data/itemOld.ts diff --git a/src/endpoints/group/item.ts b/src/endpoints/group/item.ts index b0d3484..114948e 100644 --- a/src/endpoints/group/item.ts +++ b/src/endpoints/group/item.ts @@ -1,7 +1,7 @@ /** Item management endpoints */ import { apiFetch, json } from '$endpoints/fetch'; -import type { ItemInfoBrief, ItemInfoFull } from '$lib/server/data/item'; +import type { ItemInfoBrief, ItemInfoFull } from '$lib/server/data/itemOld'; export default function makeItemFunctions(token: string | undefined, groupId: string) { /** diff --git a/src/lib/links.ts b/src/lib/links.ts index fe28a3e..61c3d58 100644 --- a/src/lib/links.ts +++ b/src/lib/links.ts @@ -1,4 +1,4 @@ -import type { ItemInfoFull } from './server/data/item'; +import type { ItemInfoFull } from './server/data/itemOld'; /** Returns whether the given item links to the target */ export function itemHasLink(item: ItemInfoFull, targetGroup: string, targetItem: string) { diff --git a/src/lib/server/data/index.ts b/src/lib/server/data/index.ts index 340a91f..8ed2629 100644 --- a/src/lib/server/data/index.ts +++ b/src/lib/server/data/index.ts @@ -13,7 +13,7 @@ import { version } from '$app/environment'; import semver from 'semver'; import { getConfig, getConfigVersion, type ConfigJson } from './config'; import { getGroupData, listGroups, type GroupData } from './group'; -import { getItemData, listItems, type ItemData } from './item'; +import { getItemData, listItems, type ItemData } from './itemOld'; import { invalidateLocalConfigCache } from './localConfig'; import migrate from './migrations'; import { getReadme } from './readme'; diff --git a/src/lib/server/data/itemOld.ts b/src/lib/server/data/itemOld.ts new file mode 100644 index 0000000..fb05d89 --- /dev/null +++ b/src/lib/server/data/itemOld.ts @@ -0,0 +1,222 @@ +/** + * Access item data + */ + +import { mkdir, readdir, readFile, writeFile } from 'fs/promises'; +import { array, intersection, object, nullable, string, tuple, type, validate, type Infer, enums } from 'superstruct'; +import { getDataDir } from './dataDir'; +import { rimraf } from 'rimraf'; +import { RepoInfoStruct } from './itemRepo'; +import { PackageInfoStruct } from './itemPackage'; +import formatTemplate from '../formatTemplate'; + +const DEFAULT_README = ` +# {{item}} + +{{description}} + +This is the \`README.md\` file for the item {{item}}. Go ahead and modify it to +tell everyone more about it. Is it something you made, or something you use? +How does it demonstrate your abilities? +`; + +/** Brief info about an item */ +export const ItemInfoBriefStruct = type({ + /** User-facing name of the item */ + name: string(), + + /** Short description of the item */ + description: string(), + + /** Description to use for the webpage of the item, used in SEO */ + pageDescription: string(), + + /** + * SEO keywords to use for this group. These are combined with the site and + * group keywords. + */ + keywords: array(string()), + + /** Color */ + color: string(), + + /** Icon to display in lists */ + icon: nullable(string()), + + /** Banner image to display on item page */ + banner: nullable(string()), +}); + +/** Brief info about an item */ +export type ItemInfoBrief = Infer; + +export const LinkStyleStruct = enums(['chip', 'card']); + +/** + * Links (associations) with other items. + * + * Array of `[group, [...items]]` + * + * * Each `group` is a group ID + * * Each item in `items` is an item ID within that group + */ +export const LinksArray = array( + tuple([ + object({ + groupId: string(), + style: LinkStyleStruct, + title: string(), + }), + array(string()), + ]) +); + +/** Full information about an item */ +export const ItemInfoFullStruct = intersection([ + ItemInfoBriefStruct, + type({ + /** Links to other items */ + links: LinksArray, + + /** URLs associated with the label */ + urls: object({ + /** URL of the source repository of the label */ + repo: nullable(RepoInfoStruct), + + /** URL of the site demonstrating the label */ + site: nullable(string()), + + /** URL of the documentation site for the label */ + docs: nullable(string()), + }), + + /** Information about the package distribution of the label */ + package: nullable(PackageInfoStruct), + }), +]); + +/** Full information about an item */ +export type ItemInfoFull = Infer; + +/** + * Return the full list of items within a group. + * + * This includes items not included in the main list. + */ +export async function listItems(groupId: string): Promise { + return (await readdir(`${getDataDir()}/${groupId}`, { withFileTypes: true })) + // Only keep directories + .filter(d => d.isDirectory()) + .map(d => d.name); +} + +/** Return the full info about the item from the given group with the given ID */ +export async function getItemInfo(groupId: string, itemId: string): Promise { + const infoJsonPath = `${getDataDir()}/${groupId}/${itemId}/info.json`; + const data = await readFile( + infoJsonPath, + { encoding: 'utf-8' } + ); + + // Validate data + const [err, parsed] = validate(JSON.parse(data), ItemInfoFullStruct); + if (err) { + console.log(`Error while parsing '${infoJsonPath}'`); + console.error(err); + throw err; + } + + return parsed; +} + +/** Return the brief info about the item with the given ID */ +export async function getItemInfoBrief(groupId: string, itemId: string): Promise { + const info = await getItemInfo(groupId, itemId); + + return { + name: info.name, + description: info.description, + pageDescription: info.pageDescription, + keywords: info.keywords, + color: info.color, + icon: info.icon, + banner: info.banner, + }; +} + +/** Update the full info about the item with the given ID */ +export async function setItemInfo(groupId: string, itemId: string, info: ItemInfoFull) { + const infoJsonPath = `${getDataDir()}/${groupId}/${itemId}/info.json`; + await writeFile( + infoJsonPath, + JSON.stringify(info, undefined, 2), + ); +} + +/** Returns the contents of the item's README.md */ +export async function getItemReadme(groupId: string, itemId: string): Promise { + return readFile( + `${getDataDir()}/${groupId}/${itemId}/README.md`, + { encoding: 'utf-8' }, + ); +} + +/** Update the contents of the item's README.md */ +export async function setItemReadme(groupId: string, itemId: string, readme: string) { + await writeFile( + `${getDataDir()}/${groupId}/${itemId}/README.md`, + readme, + ); +} + +/** Creates a new item with the given ID and name */ +export async function createItem(groupId: string, itemId: string, name: string, description: string) { + await mkdir(`${getDataDir()}/${groupId}/${itemId}`); + + // If there is a description, add it to the readme text + const readme = formatTemplate(DEFAULT_README, [['item', name], ['description', description]]) + // If the description was empty, we'll end up with extra newlines -- get + // rid of them. + .replace('\n\n\n', ''); + + await setItemInfo(groupId, itemId, { + name, + description, + pageDescription: '', + keywords: [name], + // TODO: Generate a random color for the new item + color: '#aa00aa', + links: [], + urls: { + repo: null, + site: null, + docs: null + }, + package: null, + icon: null, + banner: null, + }); + await setItemReadme(groupId, itemId, readme); +} + +/** Removes the item with the given ID */ +export async function deleteItem(groupId: string, itemId: string) { + await rimraf(`${getDataDir()}/${groupId}/${itemId}`); +} + +/** + * Overall data for an item, comprised of the item's `info.json`, `README.md` + * and potentially other data as required. + */ +export type ItemData = { + info: ItemInfoFull, + readme: string, +} + +/** Return full data for the item */ +export async function getItemData(groupId: string, itemId: string) { + return { + info: await getItemInfo(groupId, itemId), + readme: await getItemReadme(groupId, itemId), + }; +} diff --git a/src/lib/server/data/migrations/v0.2.0.ts b/src/lib/server/data/migrations/v0.2.0.ts index 4bf55a6..e979fe9 100644 --- a/src/lib/server/data/migrations/v0.2.0.ts +++ b/src/lib/server/data/migrations/v0.2.0.ts @@ -24,7 +24,7 @@ import { setConfig, type ConfigJson } from '../config'; import { listGroups, setGroupInfo } from '../group'; import type { RepoInfo } from '../itemRepo'; import type { PackageInfo } from '../itemPackage'; -import { LinksArray, listItems, setItemInfo } from '../item'; +import { LinksArray, listItems, setItemInfo } from '../itemOld'; import type { Infer } from 'superstruct'; import { version } from '$app/environment'; import { capitalize } from '$lib/util'; diff --git a/src/lib/server/data/migrations/v0.4.0.ts b/src/lib/server/data/migrations/v0.4.0.ts index 7771d17..a030b6e 100644 --- a/src/lib/server/data/migrations/v0.4.0.ts +++ b/src/lib/server/data/migrations/v0.4.0.ts @@ -2,7 +2,7 @@ import { setConfig, type ConfigJson } from '../config'; import { listGroups, setGroupInfo, type GroupInfo } from '../group'; -import { listItems, setItemInfo, type ItemInfoFull } from '../item'; +import { listItems, setItemInfo, type ItemInfoFull } from '../itemOld'; import { unsafeLoadConfig, unsafeLoadGroupInfo, unsafeLoadItemInfo } from './unsafeLoad'; import migrateV060 from './v0.6.0'; diff --git a/src/lib/server/links.ts b/src/lib/server/links.ts index 551893e..6883d79 100644 --- a/src/lib/server/links.ts +++ b/src/lib/server/links.ts @@ -4,7 +4,7 @@ import { error } from '@sveltejs/kit'; import type { PortfolioGlobals } from './data'; -import { setItemInfo } from './data/item'; +import { setItemInfo } from './data/itemOld'; import { itemHasLink } from '../links'; /** Add a link from groupId/itemId to otherGroupId/otherItemId */ diff --git a/src/routes/api/group/[groupId]/item/[itemId]/+server.ts b/src/routes/api/group/[groupId]/item/[itemId]/+server.ts index 0f2c7ea..6ab604f 100644 --- a/src/routes/api/group/[groupId]/item/[itemId]/+server.ts +++ b/src/routes/api/group/[groupId]/item/[itemId]/+server.ts @@ -2,7 +2,7 @@ import { error, json } from '@sveltejs/kit'; import { getGroupInfo, setGroupInfo } from '$lib/server/data/group'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { object, string, validate } from 'superstruct'; -import { createItem, setItemInfo, ItemInfoFullStruct, deleteItem } from '$lib/server/data/item'; +import { createItem, setItemInfo, ItemInfoFullStruct, deleteItem } from '$lib/server/data/itemOld.js'; import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/data/index'; import { validateId, validateName } from '$lib/validators'; import { removeAllLinksToItem } from '$lib/server/links'; diff --git a/src/routes/api/group/[groupId]/item/[itemId]/link/+server.ts b/src/routes/api/group/[groupId]/item/[itemId]/link/+server.ts index bcbf781..0b93067 100644 --- a/src/routes/api/group/[groupId]/item/[itemId]/link/+server.ts +++ b/src/routes/api/group/[groupId]/item/[itemId]/link/+server.ts @@ -2,7 +2,7 @@ import { error, json } from '@sveltejs/kit'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/data/index'; import { object, string, validate } from 'superstruct'; -import { LinkStyleStruct } from '$lib/server/data/item'; +import { LinkStyleStruct } from '$lib/server/data/itemOld.js'; import { changeLinkStyle, createLink, removeLinkFromItem } from '$lib/server/links'; export async function POST({ params, request, cookies }: import('./$types.js').RequestEvent) { diff --git a/src/routes/api/group/[groupId]/item/[itemId]/readme/+server.ts b/src/routes/api/group/[groupId]/item/[itemId]/readme/+server.ts index 7432d15..b058e97 100644 --- a/src/routes/api/group/[groupId]/item/[itemId]/readme/+server.ts +++ b/src/routes/api/group/[groupId]/item/[itemId]/readme/+server.ts @@ -1,7 +1,7 @@ import { error, json } from '@sveltejs/kit'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { assert, string } from 'superstruct'; -import { getItemInfo, setItemReadme } from '$lib/server/data/item'; +import { getItemInfo, setItemReadme } from '$lib/server/data/itemOld.js'; import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/data/index'; export async function GET({ params }: import('./$types.js').RequestEvent) { diff --git a/tests/backend/group/item/create.test.ts b/tests/backend/group/item/create.test.ts index 0f99f79..b33590c 100644 --- a/tests/backend/group/item/create.test.ts +++ b/tests/backend/group/item/create.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, test } from 'vitest'; import { makeGroup, setup } from '../../helpers'; import type { ApiClient } from '$endpoints'; -import type { ItemInfoFull } from '$lib/server/data/item'; +import type { ItemInfoFull } from '$lib/server/data/itemOld'; import genCreationTests from '../creationCases'; import genTokenTests from '../../tokenCase'; diff --git a/tests/backend/helpers.ts b/tests/backend/helpers.ts index 9678726..98e51fc 100644 --- a/tests/backend/helpers.ts +++ b/tests/backend/helpers.ts @@ -1,7 +1,7 @@ import api, { type ApiClient } from '$endpoints'; import type { ConfigJson } from '$lib/server/data/config'; import type { GroupInfo } from '$lib/server/data/group'; -import type { ItemInfoFull } from '$lib/server/data/item'; +import type { ItemInfoFull } from '$lib/server/data/itemOld'; import { version } from '$app/environment'; import simpleGit from 'simple-git'; import { getDataDir } from '$lib/server/data/dataDir'; From 99fcbe1ccbe2d3f30d4b6a00d426c83948f47c96 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Sun, 17 Nov 2024 22:55:43 +1100 Subject: [PATCH 003/149] Make new item struct definition --- src/lib/server/data/item.ts | 255 ++++++--------------------------- src/lib/server/data/itemId.ts | 11 ++ src/lib/server/data/section.ts | 52 +++++++ 3 files changed, 106 insertions(+), 212 deletions(-) create mode 100644 src/lib/server/data/itemId.ts create mode 100644 src/lib/server/data/section.ts diff --git a/src/lib/server/data/item.ts b/src/lib/server/data/item.ts index fb05d89..af9cdce 100644 --- a/src/lib/server/data/item.ts +++ b/src/lib/server/data/item.ts @@ -1,222 +1,53 @@ /** - * Access item data + * An item represents an entry within the portfolio. + * + * This file contains type definitions and helper functions for accessing and modifying items. */ -import { mkdir, readdir, readFile, writeFile } from 'fs/promises'; -import { array, intersection, object, nullable, string, tuple, type, validate, type Infer, enums } from 'superstruct'; -import { getDataDir } from './dataDir'; -import { rimraf } from 'rimraf'; -import { RepoInfoStruct } from './itemRepo'; -import { PackageInfoStruct } from './itemPackage'; -import formatTemplate from '../formatTemplate'; - -const DEFAULT_README = ` -# {{item}} - -{{description}} - -This is the \`README.md\` file for the item {{item}}. Go ahead and modify it to -tell everyone more about it. Is it something you made, or something you use? -How does it demonstrate your abilities? -`; +import { array, nullable, string, type } from 'superstruct'; +import { ItemIdStruct } from './itemId'; -/** Brief info about an item */ -export const ItemInfoBriefStruct = type({ - /** User-facing name of the item */ +/** Information about an item, stored in its `info.json` */ +export const ItemInfoStruct = type({ + /** + * The name of the item, displayed in the navigator when on this page, as well as on Card + * elements. + */ name: string(), - - /** Short description of the item */ - description: string(), - - /** Description to use for the webpage of the item, used in SEO */ - pageDescription: string(), - /** - * SEO keywords to use for this group. These are combined with the site and - * group keywords. + * A shortened name of the item, displayed in the navigator when on a child page, as well as on + * Chip elements. */ - keywords: array(string()), - - /** Color */ - color: string(), - - /** Icon to display in lists */ + shortName: nullable(string()), + /** + * A short description to use for this item. This is shown on Card elements, and as a tooltip for + * Chip elements. + */ + description: string(), + /** The icon image to use for this item, as a path relative to this item's root location. */ icon: nullable(string()), - - /** Banner image to display on item page */ + /** The banner image to use for this item, as a path relative to this item's root location. */ banner: nullable(string()), -}); - -/** Brief info about an item */ -export type ItemInfoBrief = Infer; - -export const LinkStyleStruct = enums(['chip', 'card']); - -/** - * Links (associations) with other items. - * - * Array of `[group, [...items]]` - * - * * Each `group` is a group ID - * * Each item in `items` is an item ID within that group - */ -export const LinksArray = array( - tuple([ - object({ - groupId: string(), - style: LinkStyleStruct, - title: string(), - }), - array(string()), - ]) -); - -/** Full information about an item */ -export const ItemInfoFullStruct = intersection([ - ItemInfoBriefStruct, - type({ - /** Links to other items */ - links: LinksArray, - - /** URLs associated with the label */ - urls: object({ - /** URL of the source repository of the label */ - repo: nullable(RepoInfoStruct), - - /** URL of the site demonstrating the label */ - site: nullable(string()), - - /** URL of the documentation site for the label */ - docs: nullable(string()), - }), - - /** Information about the package distribution of the label */ - package: nullable(PackageInfoStruct), + /** A hexadecimal color to use for the item. */ + color: string(), + /** + * Items to list as children of this item. Items not in this list will be unlisted, but still + * accessible if their URL is accessed directly. + */ + children: array(string()), + /** Array of item IDs whose children should be used as filters for children of this item. */ + filters: array(ItemIdStruct), + /** SEO properties, placed in the document `` to improve placement in search engines. */ + seo: type({ + /** + * A description of the page, presented to search engines. If null, a simple template is + * generated based on the template of a parent. + */ + description: nullable(string()), + /** + * Keywords to use for the group. These are presented to search engines for this item, and for + * its children. + */ + keywords: array(string()), }), -]); - -/** Full information about an item */ -export type ItemInfoFull = Infer; - -/** - * Return the full list of items within a group. - * - * This includes items not included in the main list. - */ -export async function listItems(groupId: string): Promise { - return (await readdir(`${getDataDir()}/${groupId}`, { withFileTypes: true })) - // Only keep directories - .filter(d => d.isDirectory()) - .map(d => d.name); -} - -/** Return the full info about the item from the given group with the given ID */ -export async function getItemInfo(groupId: string, itemId: string): Promise { - const infoJsonPath = `${getDataDir()}/${groupId}/${itemId}/info.json`; - const data = await readFile( - infoJsonPath, - { encoding: 'utf-8' } - ); - - // Validate data - const [err, parsed] = validate(JSON.parse(data), ItemInfoFullStruct); - if (err) { - console.log(`Error while parsing '${infoJsonPath}'`); - console.error(err); - throw err; - } - - return parsed; -} - -/** Return the brief info about the item with the given ID */ -export async function getItemInfoBrief(groupId: string, itemId: string): Promise { - const info = await getItemInfo(groupId, itemId); - - return { - name: info.name, - description: info.description, - pageDescription: info.pageDescription, - keywords: info.keywords, - color: info.color, - icon: info.icon, - banner: info.banner, - }; -} - -/** Update the full info about the item with the given ID */ -export async function setItemInfo(groupId: string, itemId: string, info: ItemInfoFull) { - const infoJsonPath = `${getDataDir()}/${groupId}/${itemId}/info.json`; - await writeFile( - infoJsonPath, - JSON.stringify(info, undefined, 2), - ); -} - -/** Returns the contents of the item's README.md */ -export async function getItemReadme(groupId: string, itemId: string): Promise { - return readFile( - `${getDataDir()}/${groupId}/${itemId}/README.md`, - { encoding: 'utf-8' }, - ); -} - -/** Update the contents of the item's README.md */ -export async function setItemReadme(groupId: string, itemId: string, readme: string) { - await writeFile( - `${getDataDir()}/${groupId}/${itemId}/README.md`, - readme, - ); -} - -/** Creates a new item with the given ID and name */ -export async function createItem(groupId: string, itemId: string, name: string, description: string) { - await mkdir(`${getDataDir()}/${groupId}/${itemId}`); - - // If there is a description, add it to the readme text - const readme = formatTemplate(DEFAULT_README, [['item', name], ['description', description]]) - // If the description was empty, we'll end up with extra newlines -- get - // rid of them. - .replace('\n\n\n', ''); - - await setItemInfo(groupId, itemId, { - name, - description, - pageDescription: '', - keywords: [name], - // TODO: Generate a random color for the new item - color: '#aa00aa', - links: [], - urls: { - repo: null, - site: null, - docs: null - }, - package: null, - icon: null, - banner: null, - }); - await setItemReadme(groupId, itemId, readme); -} - -/** Removes the item with the given ID */ -export async function deleteItem(groupId: string, itemId: string) { - await rimraf(`${getDataDir()}/${groupId}/${itemId}`); -} - -/** - * Overall data for an item, comprised of the item's `info.json`, `README.md` - * and potentially other data as required. - */ -export type ItemData = { - info: ItemInfoFull, - readme: string, -} - -/** Return full data for the item */ -export async function getItemData(groupId: string, itemId: string) { - return { - info: await getItemInfo(groupId, itemId), - readme: await getItemReadme(groupId, itemId), - }; -} +}); diff --git a/src/lib/server/data/itemId.ts b/src/lib/server/data/itemId.ts new file mode 100644 index 0000000..0f89263 --- /dev/null +++ b/src/lib/server/data/itemId.ts @@ -0,0 +1,11 @@ +/** + * Item ID type definitions + */ + +import { array, string, type Infer } from 'superstruct'; + +/** The ID of an Item. An array of `string`s representing the path to that item within the data. */ +export const ItemIdStruct = array(string()); + +/** The ID of an Item. An array of `string`s representing the path to that item within the data. */ +export type ItemId = Infer; diff --git a/src/lib/server/data/section.ts b/src/lib/server/data/section.ts new file mode 100644 index 0000000..6e1ffd8 --- /dev/null +++ b/src/lib/server/data/section.ts @@ -0,0 +1,52 @@ +/** + * Represents a section of an item page. + * + * This file contains definitions for various sections. + */ + +import { array, enums, literal, string, type } from 'superstruct'; +import { ItemIdStruct } from './itemId'; +import { RepoInfoStruct } from './itemRepo'; +import { PackageInfoStruct } from './itemPackage'; + +/** Links from this item to other items. */ +export const LinkSection = type({ + /** The type of section (in this case 'links') */ + type: literal('links'), + /** The style in which to present the links ('chip' or 'card') */ + style: enums(['chip', 'card']), + /** The array of item IDs to display as links */ + items: array(ItemIdStruct), +}); + +/** Code repository link */ +export const RepoSection = type({ + /** The type of section (in this case 'repo') */ + type: literal('repo'), + /** Information about the repository being linked */ + info: RepoInfoStruct, +}); + +/** Website link */ +export const SiteSection = type({ + /** The type of section (in this case 'site') */ + type: literal('site'), + /** The URL of the site being linked */ + url: string(), +}); + +/** Documentation link */ +export const DocumentationSection = type({ + /** The type of section (in this case 'docs') */ + type: literal('docs'), + /** The URL of the documentation being linked */ + url: string(), +}); + +/** Package information section */ +export const PackageSection = type({ + /** The type of section (in this case 'package') */ + type: literal('package'), + /** The URL of the site being linked */ + info: PackageInfoStruct, +}); From 4c0aa5999f98b75ca75b956b5742a2cf10221dea Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Sun, 22 Dec 2024 23:36:16 +1100 Subject: [PATCH 004/149] Progress on new API --- src/lib/server/data/item.ts | 5 ++- src/lib/server/data/itemId.ts | 5 +++ src/routes/api/[...path]/+server.ts | 10 +++++ src/routes/api/[...path]/file.ts | 68 +++++++++++++++++++++++++++++ src/routes/api/[...path]/info.ts | 14 ++++++ 5 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 src/routes/api/[...path]/+server.ts create mode 100644 src/routes/api/[...path]/file.ts create mode 100644 src/routes/api/[...path]/info.ts diff --git a/src/lib/server/data/item.ts b/src/lib/server/data/item.ts index af9cdce..bf77d48 100644 --- a/src/lib/server/data/item.ts +++ b/src/lib/server/data/item.ts @@ -4,7 +4,7 @@ * This file contains type definitions and helper functions for accessing and modifying items. */ -import { array, nullable, string, type } from 'superstruct'; +import { array, nullable, string, type, type Infer } from 'superstruct'; import { ItemIdStruct } from './itemId'; /** Information about an item, stored in its `info.json` */ @@ -51,3 +51,6 @@ export const ItemInfoStruct = type({ keywords: array(string()), }), }); + +/** Information about an item, stored in its `info.json` */ +export type ItemInfo = Infer; diff --git a/src/lib/server/data/itemId.ts b/src/lib/server/data/itemId.ts index 0f89263..2592346 100644 --- a/src/lib/server/data/itemId.ts +++ b/src/lib/server/data/itemId.ts @@ -4,6 +4,11 @@ import { array, string, type Infer } from 'superstruct'; +/** Return an item ID given its path in URL form */ +export function fromUrl(path: string): ItemId { + return path.split('/'); +} + /** The ID of an Item. An array of `string`s representing the path to that item within the data. */ export const ItemIdStruct = array(string()); diff --git a/src/routes/api/[...path]/+server.ts b/src/routes/api/[...path]/+server.ts new file mode 100644 index 0000000..4a509c8 --- /dev/null +++ b/src/routes/api/[...path]/+server.ts @@ -0,0 +1,10 @@ +export type Request = import('./$types.js').RequestEvent; + + +export async function GET(req: Request) { + // TODO: Determine how to handle request +} + +export async function POST(req: Request) { + // TODO: Determine how to handle request +} diff --git a/src/routes/api/[...path]/file.ts b/src/routes/api/[...path]/file.ts new file mode 100644 index 0000000..9793eb1 --- /dev/null +++ b/src/routes/api/[...path]/file.ts @@ -0,0 +1,68 @@ +import type { ItemId } from '$lib/server/data/itemId'; +import sanitize from 'sanitize-filename'; +import fs from 'fs/promises'; +import path from 'path'; +import { error, json } from '@sveltejs/kit'; +import mime from 'mime-types'; +import { getDataDir } from '$lib/server/data/dataDir'; +import type { Request } from './+server'; +import { invalidatePortfolioGlobals } from '$lib/server'; + +export async function getFile(req: Request, item: ItemId, file: string): Promise { + // Sanitize the filename to prevent unwanted access to the server's filesystem + const filename = sanitize(file); + + // Get the path of the file to serve + const filePath = path.join(getDataDir(), ...item, filename); + + // Ensure file exists + await fs.access(filePath, fs.constants.R_OK).catch(() => error(404)); + + // Read the contents of the file + const content = await fs.readFile(filePath); + let mimeType = mime.contentType(filename); + if (!mimeType) { + mimeType = 'text/plain'; + } + + req.setHeaders({ + 'Content-Type': mimeType, + 'Content-Length': content.length.toString(), + }); + + return new Response(content); +} + +export async function updateFile(item: ItemId, file: string, req: Request): Promise { + // Sanitize the filename to prevent unwanted access to the server's filesystem + const filename = sanitize(file); + + // Get the path of the file to update + const filePath = path.join(getDataDir(), ...item, filename); + + // Ensure Content-Type header matches expected mimetype of header + const fileMimeType = mime.contentType(filename); + const reqMimeType = req.request.headers.get('Content-Type'); + + if (req.request.body === null) { + error(400, 'Request body must be given'); + } + + // Only check if the mimetype of the file is known + if (fileMimeType && fileMimeType !== reqMimeType) { + error(400, `Incorrect mimetype for file ${filename}. Expected ${fileMimeType}, got ${reqMimeType}`); + } + + // It would probably be nicer to pipe the data directly to the disk as it is received + // but I am unable to make TypeScript happy. + // Even then, this way, EsLint claims I'm calling an error-type value, which is cursed. + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const data = await req.request.bytes(); + + // Write the file + await fs.writeFile(filePath, data).catch(e => error(404, `${e}`)); + + invalidatePortfolioGlobals(); + + return json({}); +} diff --git a/src/routes/api/[...path]/info.ts b/src/routes/api/[...path]/info.ts new file mode 100644 index 0000000..8f93b1e --- /dev/null +++ b/src/routes/api/[...path]/info.ts @@ -0,0 +1,14 @@ +import { getDataDir } from '$lib/server/data/dataDir'; +import type { ItemInfo } from '$lib/server/data/item'; +import fs from 'fs/promises'; +import path from 'path'; + +export async function getInfo(item: ItemId): Promise { + // Currently load from the disk every time -- should implement caching at some point + const result = JSON.parse(await fs.readFile(path.join(getDataDir(), ...item, 'info.json'), { encoding: 'utf-8' })); + return result as ItemInfo; +} + +export async function setInfo(item: ItemId, info: any) { + // TODO: Validate and save new info.json +} From 5a0caa7e282d4fda24fbdf3d1880b66b90db4ac5 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Fri, 27 Dec 2024 13:21:44 +1100 Subject: [PATCH 005/149] More work on item functions --- src/lib/server/data/item.ts | 42 +++++++- src/lib/validate.ts | 95 +++++++++++++++++++ src/lib/validators.ts | 49 ---------- src/routes/api/[...path]/info.ts | 13 ++- .../api/admin/firstrun/account/+server.ts | 2 +- src/routes/api/group/[groupId]/+server.ts | 2 +- .../group/[groupId]/item/[itemId]/+server.ts | 2 +- 7 files changed, 144 insertions(+), 61 deletions(-) create mode 100644 src/lib/validate.ts delete mode 100644 src/lib/validators.ts diff --git a/src/lib/server/data/item.ts b/src/lib/server/data/item.ts index bf77d48..d000fec 100644 --- a/src/lib/server/data/item.ts +++ b/src/lib/server/data/item.ts @@ -3,9 +3,13 @@ * * This file contains type definitions and helper functions for accessing and modifying items. */ - +import fs from 'fs/promises'; import { array, nullable, string, type, type Infer } from 'superstruct'; -import { ItemIdStruct } from './itemId'; +import { ItemIdStruct, type ItemId } from './itemId'; +import { applyStruct } from '../util'; +import path from 'path'; +import { getDataDir } from './dataDir'; +import validate from '$lib/validate'; /** Information about an item, stored in its `info.json` */ export const ItemInfoStruct = type({ @@ -54,3 +58,37 @@ export const ItemInfoStruct = type({ /** Information about an item, stored in its `info.json` */ export type ItemInfo = Infer; + +/** Returns the path to an item's `info.json` file */ +export function itemInfoPath(item: ItemId): string { + return path.join(getDataDir(), ...item, 'info.json'); +} + +/** Return the item's info.json */ +export async function getItemInfo(item: ItemId): Promise { + // Currently load from the disk every time -- should implement caching at some point + const result = JSON.parse(await fs.readFile(itemInfoPath(item), { encoding: 'utf-8' })); + return applyStruct(result, ItemInfoStruct); +} + +export async function setItemInfo(item: ItemId, data: any): Promise { + // Validate new info.json + const info = applyStruct(data, ItemInfoStruct); + + // name + validate.name(info.name); + // shortName + if (info.shortName !== null) { + validate.name(info.shortName); + } + // Icon and banner images + if (info.icon) { + await validate.image(item, info.icon); + } + if (info.banner) { + await validate.image(item, info.banner); + } + validate.color(info.color); + + await fs.writeFile(itemInfoPath(item), JSON.stringify(info), { encoding: 'utf-8' }); +} diff --git a/src/lib/validate.ts b/src/lib/validate.ts new file mode 100644 index 0000000..35dbb12 --- /dev/null +++ b/src/lib/validate.ts @@ -0,0 +1,95 @@ +import { error } from '@sveltejs/kit'; +import fs from 'fs/promises'; +import mime from 'mime-types'; +import sanitize from 'sanitize-filename'; +import type { ItemId } from './server/data/itemId'; +import { getDataDir } from './server/data/dataDir'; +import path from 'path'; + +/** Regex for matching ID strings */ +export const idValidatorRegex = /^[a-z0-9-.]+$/; + +/** Ensure that the given ID string is valid */ +export function validateId(type: string, id: string): string { + if (!id.trim().length) { + error(400, `${type} '${id}' is empty`); + } + if (!idValidatorRegex.test(id)) { + error(400, `${type} '${id}' is contains illegal characters`); + } + if (id.startsWith('.')) { + error(400, `${type} '${id}' has a leading dot`); + } + if (id.endsWith('.')) { + error(400, `${type} '${id}' has a trailing dot`); + } + if (id.startsWith('-')) { + error(400, `${type} '${id}' has a leading dash`); + } + if (id.endsWith('-')) { + error(400, `${type} '${id}' has a trailing dash`); + } + return id; +} + +/** Array of illegal characters that cannot be used in names */ +const illegalNameChars = ['\t', '\n', '\f', '\r']; + +/** Ensure that the given name is valid */ +export function validateName(name: string): string { + if (!name) { + error(400, 'Name cannot be empty'); + } + if (name.trim().length !== name.length) { + error(400, 'Name cannot contain leading or trailing whitespace'); + } + if ( + illegalNameChars + .reduce((n, c) => n.replace(c, ''), name) + .length + !== name.length + ) { + error(400, 'Name contains illegal whitespace characters'); + } + + return name; +} + +const colorValidatorRegex = /^#([0-9])\1{6}$/; + +/** Validate a color is in hex form */ +export function validateColor(color: string): string { + if (!colorValidatorRegex.test(color)) { + error(400, 'Colors must be given in hex form (#RRGGBB)'); + } + return color; +} + +/** Validate that the given file exists, and is readable */ +export async function validateFile(itemId: ItemId, filename: string): Promise { + const sanitized = sanitize(filename); + if (!sanitized) { + error(400, 'File path cannot be empty'); + } + await fs.access(path.join(getDataDir(), ...itemId, sanitized), fs.constants.R_OK) + .catch(e => error(400, `Error accessing ${sanitized}: ${e}`)); + return sanitized; +} + +/** Validate that an image path is valid */ +export async function validateImage(itemId: ItemId, filename: string): Promise { + const file = await validateFile(itemId, filename); + const mimeType = mime.contentType(file); + if (!mimeType || !mimeType.startsWith('image/')) { + error(400, 'Image path must have a mimetype beginning with "image/"'); + } + return file; +} + +export default { + id: validateId, + name: validateName, + color: validateColor, + file: validateFile, + image: validateImage, +}; diff --git a/src/lib/validators.ts b/src/lib/validators.ts deleted file mode 100644 index 64fcce0..0000000 --- a/src/lib/validators.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { error } from '@sveltejs/kit'; - -/** Regex for matching ID strings */ -export const idValidatorRegex = /^[a-z0-9-.]+$/; - -/** Ensure that the given ID string is valid */ -export function validateId(type: string, id: string): string { - if (!id.trim().length) { - return error(400, `${type} '${id}' is empty`); - } - if (!idValidatorRegex.test(id)) { - return error(400, `${type} '${id}' is contains illegal characters`); - } - if (id.startsWith('.')) { - return error(400, `${type} '${id}' has a leading dot`); - } - if (id.endsWith('.')) { - return error(400, `${type} '${id}' has a trailing dot`); - } - if (id.startsWith('-')) { - return error(400, `${type} '${id}' has a leading dash`); - } - if (id.endsWith('-')) { - return error(400, `${type} '${id}' has a trailing dash`); - } - return id; -} - -/** Array of illegal characters that cannot be used in names */ -const illegalNameChars = ['\t', '\n', '\f', '\r']; - -export function validateName(name: string): string { - if (!name) { - return error(400, 'Name cannot be empty'); - } - if (name.trim().length !== name.length) { - return error(400, 'Name cannot contain leading or trailing whitespace'); - } - if ( - illegalNameChars - .reduce((n, c) => n.replace(c, ''), name) - .length - !== name.length - ) { - return error(400, 'Name contains illegal whitespace characters'); - } - - return name; -} diff --git a/src/routes/api/[...path]/info.ts b/src/routes/api/[...path]/info.ts index 8f93b1e..93c5e39 100644 --- a/src/routes/api/[...path]/info.ts +++ b/src/routes/api/[...path]/info.ts @@ -1,14 +1,13 @@ import { getDataDir } from '$lib/server/data/dataDir'; -import type { ItemInfo } from '$lib/server/data/item'; +import { getItemInfo, ItemInfoStruct, type ItemInfo } from '$lib/server/data/item'; +import type { ItemId } from '$lib/server/data/itemId'; +import { applyStruct } from '$lib/server/util'; import fs from 'fs/promises'; import path from 'path'; -export async function getInfo(item: ItemId): Promise { - // Currently load from the disk every time -- should implement caching at some point - const result = JSON.parse(await fs.readFile(path.join(getDataDir(), ...item, 'info.json'), { encoding: 'utf-8' })); - return result as ItemInfo; +export function getInfo(item: ItemId): Promise { + return getItemInfo(item); } -export async function setInfo(item: ItemId, info: any) { - // TODO: Validate and save new info.json +export async function setInfo(item: ItemId, data: any): Promise { } diff --git a/src/routes/api/admin/firstrun/account/+server.ts b/src/routes/api/admin/firstrun/account/+server.ts index a4a9205..00dffcb 100644 --- a/src/routes/api/admin/firstrun/account/+server.ts +++ b/src/routes/api/admin/firstrun/account/+server.ts @@ -3,7 +3,7 @@ import { authIsSetUp } from '$lib/server/data/dataDir'; import { authSetup } from '$lib/server/auth/setup'; import { object, string, type Infer } from 'superstruct'; import { applyStruct } from '$lib/server/util'; -import { validateId } from '$lib/validators'; +import { validateId } from '$lib/validate.js'; import validator from 'validator'; const FirstRunAuthOptionsStruct = object({ diff --git a/src/routes/api/group/[groupId]/+server.ts b/src/routes/api/group/[groupId]/+server.ts index 58e79a9..4ca3bc6 100644 --- a/src/routes/api/group/[groupId]/+server.ts +++ b/src/routes/api/group/[groupId]/+server.ts @@ -3,7 +3,7 @@ import { createGroup, deleteGroup, GroupInfoStruct, setGroupInfo } from '$lib/se import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { object, string, validate } from 'superstruct'; import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/data/index'; -import { validateId, validateName } from '$lib/validators'; +import { validateId, validateName } from '$lib/validate.js'; import { removeAllLinksToItem } from '$lib/server/links'; import { setConfig } from '$lib/server/data/config'; diff --git a/src/routes/api/group/[groupId]/item/[itemId]/+server.ts b/src/routes/api/group/[groupId]/item/[itemId]/+server.ts index 6ab604f..c68d4c1 100644 --- a/src/routes/api/group/[groupId]/item/[itemId]/+server.ts +++ b/src/routes/api/group/[groupId]/item/[itemId]/+server.ts @@ -4,7 +4,7 @@ import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { object, string, validate } from 'superstruct'; import { createItem, setItemInfo, ItemInfoFullStruct, deleteItem } from '$lib/server/data/itemOld.js'; import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/data/index'; -import { validateId, validateName } from '$lib/validators'; +import { validateId, validateName } from '$lib/validate.js'; import { removeAllLinksToItem } from '$lib/server/links'; export async function GET({ params }: import('./$types.js').RequestEvent) { From 73ae1b6aacceb7d07e64a4b9800aec0e176cfd7e Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Fri, 27 Dec 2024 15:04:14 +1100 Subject: [PATCH 006/149] More improvements to data structure design and validation --- src/lib/server/data/item.ts | 70 +++++++++++++++++++++++++++------- src/lib/server/data/itemId.ts | 8 +++- src/lib/server/data/section.ts | 48 +++++++++++++++++++++-- 3 files changed, 108 insertions(+), 18 deletions(-) diff --git a/src/lib/server/data/item.ts b/src/lib/server/data/item.ts index d000fec..2889132 100644 --- a/src/lib/server/data/item.ts +++ b/src/lib/server/data/item.ts @@ -4,12 +4,14 @@ * This file contains type definitions and helper functions for accessing and modifying items. */ import fs from 'fs/promises'; -import { array, nullable, string, type, type Infer } from 'superstruct'; -import { ItemIdStruct, type ItemId } from './itemId'; -import { applyStruct } from '../util'; import path from 'path'; -import { getDataDir } from './dataDir'; +import { error } from '@sveltejs/kit'; +import { array, nullable, string, type, type Infer } from 'superstruct'; import validate from '$lib/validate'; +import { formatItemId, ItemIdStruct, type ItemId } from './itemId'; +import { getDataDir } from './dataDir'; +import { ItemSectionStruct, validateSection } from './section'; +import { applyStruct } from '../util'; /** Information about an item, stored in its `info.json` */ export const ItemInfoStruct = type({ @@ -34,6 +36,8 @@ export const ItemInfoStruct = type({ banner: nullable(string()), /** A hexadecimal color to use for the item. */ color: string(), + /** Sections to display on the item's page */ + sections: array(ItemSectionStruct), /** * Items to list as children of this item. Items not in this list will be unlisted, but still * accessible if their URL is accessed directly. @@ -64,14 +68,15 @@ export function itemInfoPath(item: ItemId): string { return path.join(getDataDir(), ...item, 'info.json'); } -/** Return the item's info.json */ -export async function getItemInfo(item: ItemId): Promise { - // Currently load from the disk every time -- should implement caching at some point - const result = JSON.parse(await fs.readFile(itemInfoPath(item), { encoding: 'utf-8' })); - return applyStruct(result, ItemInfoStruct); +/** Returns whether the given item exists */ +export async function itemExists(item: ItemId): Promise { + return await fs.access(itemInfoPath(item), fs.constants.R_OK) + .then(() => true) + .catch(() => false); } -export async function setItemInfo(item: ItemId, data: any): Promise { +/** Validate that the given item info is valid */ +export async function validateItemInfo(item: ItemId, data: any): Promise { // Validate new info.json const info = applyStruct(data, ItemInfoStruct); @@ -82,13 +87,52 @@ export async function setItemInfo(item: ItemId, data: any): Promise { validate.name(info.shortName); } // Icon and banner images - if (info.icon) { + if (info.icon !== null) { await validate.image(item, info.icon); } - if (info.banner) { + if (info.banner !== null) { await validate.image(item, info.banner); } + // Item color validate.color(info.color); - await fs.writeFile(itemInfoPath(item), JSON.stringify(info), { encoding: 'utf-8' }); + // Validate each section + for (const section of info.sections) { + await validateSection(section); + } + + // Ensure each child exists + for (const child of info.children) { + if (!await itemExists([...item, child])) { + error(400, `Child item '${formatItemId([...item, child])}' does not exist`); + } + } + // Ensure each filter item exists + for (const filterItem of info.filters) { + if (!await itemExists(filterItem)) { + error(400, `Filter item '${formatItemId(filterItem)}' does not exist`); + } + } + + // SEO description + if (info.seo.description !== null) { + if (info.seo.description.length == 0) { + error(400, 'SEO description cannot be an empty string (use null instead)'); + } + } + + return info; +} + +/** Return the item's `info.json` */ +export async function getItemInfo(item: ItemId): Promise { + // Currently load from the disk every time -- should implement caching at some point + const result = JSON.parse(await fs.readFile(itemInfoPath(item), { encoding: 'utf-8' })); + // Don't fully validate info when loading data, or we'll get infinite recursion + return applyStruct(result, ItemInfoStruct); +} + +/** Update the given item's `info.json` */ +export async function setItemInfo(item: ItemId, data: any): Promise { + await fs.writeFile(itemInfoPath(item), JSON.stringify(validateItemInfo(item, data)), { encoding: 'utf-8' }); } diff --git a/src/lib/server/data/itemId.ts b/src/lib/server/data/itemId.ts index 2592346..a2ee0ae 100644 --- a/src/lib/server/data/itemId.ts +++ b/src/lib/server/data/itemId.ts @@ -1,7 +1,6 @@ /** - * Item ID type definitions + * Item ID type definitions and helper functions */ - import { array, string, type Infer } from 'superstruct'; /** Return an item ID given its path in URL form */ @@ -9,6 +8,11 @@ export function fromUrl(path: string): ItemId { return path.split('/'); } +/** Format the given ItemId for displaying to users */ +export function formatItemId(itemId: ItemId): string { + return `'${itemId.join('/')}'`; +} + /** The ID of an Item. An array of `string`s representing the path to that item within the data. */ export const ItemIdStruct = array(string()); diff --git a/src/lib/server/data/section.ts b/src/lib/server/data/section.ts index 6e1ffd8..4db0ec3 100644 --- a/src/lib/server/data/section.ts +++ b/src/lib/server/data/section.ts @@ -3,26 +3,42 @@ * * This file contains definitions for various sections. */ - -import { array, enums, literal, string, type } from 'superstruct'; +import { array, enums, literal, string, type, union, type Infer } from 'superstruct'; +import { error } from '@sveltejs/kit'; +import validate from '$lib/validate'; import { ItemIdStruct } from './itemId'; import { RepoInfoStruct } from './itemRepo'; import { PackageInfoStruct } from './itemPackage'; +import { itemExists } from './item'; /** Links from this item to other items. */ -export const LinkSection = type({ +export const LinksSection = type({ /** The type of section (in this case 'links') */ type: literal('links'), + /** The text to display for the section (eg "See also") */ + title: string(), /** The style in which to present the links ('chip' or 'card') */ style: enums(['chip', 'card']), /** The array of item IDs to display as links */ items: array(ItemIdStruct), }); +/** Validate a links section of an item */ +async function validateLinksSection(data: Infer) { + validate.name(data.title); + for (const item of data.items) { + if (!await itemExists(item)) { + error(400, `Linked item ${item} does not exist`); + } + } +} + /** Code repository link */ export const RepoSection = type({ /** The type of section (in this case 'repo') */ type: literal('repo'), + /** The text to display for the section (eg "View the code") */ + title: string(), /** Information about the repository being linked */ info: RepoInfoStruct, }); @@ -31,6 +47,8 @@ export const RepoSection = type({ export const SiteSection = type({ /** The type of section (in this case 'site') */ type: literal('site'), + /** The text to display for the section (eg "Visit the website") */ + title: string(), /** The URL of the site being linked */ url: string(), }); @@ -39,6 +57,8 @@ export const SiteSection = type({ export const DocumentationSection = type({ /** The type of section (in this case 'docs') */ type: literal('docs'), + /** The text to display for the section (eg "View the documentation") */ + title: string(), /** The URL of the documentation being linked */ url: string(), }); @@ -47,6 +67,28 @@ export const DocumentationSection = type({ export const PackageSection = type({ /** The type of section (in this case 'package') */ type: literal('package'), + /** The text to display for the section (eg "Install the package") */ + title: string(), /** The URL of the site being linked */ info: PackageInfoStruct, }); + +/** A section on the item page */ +export const ItemSectionStruct = union([ + LinksSection, + RepoSection, + SiteSection, + DocumentationSection, + PackageSection, +]); + +export type ItemSection = Infer; + +/** Validate the given section data */ +export async function validateSection(data: ItemSection) { + validate.name(data.title); + // `links` section needs additional validation + if (data.type === 'links') { + await validateLinksSection(data); + } +} From 6b9b33e54a9174546228f744787e96dc90e71fc9 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Fri, 27 Dec 2024 19:48:08 +1100 Subject: [PATCH 007/149] More updates to content management endpoints --- src/lib/server/data/item.ts | 34 +++-- src/lib/server/data/itemId.ts | 19 ++- src/lib/server/formatTemplate.ts | 4 +- src/routes/api/[...path]/+server.ts | 10 -- src/routes/api/[...path]/file.ts | 68 ---------- src/routes/api/[...path]/info.ts | 13 -- .../data/[...item]/README.md/+server.ts | 42 +++++++ .../data/[...item]/[filename]/+server.ts | 114 +++++++++++++++++ .../data/[...item]/info.json/+server.ts | 117 ++++++++++++++++++ 9 files changed, 317 insertions(+), 104 deletions(-) delete mode 100644 src/routes/api/[...path]/+server.ts delete mode 100644 src/routes/api/[...path]/file.ts delete mode 100644 src/routes/api/[...path]/info.ts create mode 100644 src/routes/data/[...item]/README.md/+server.ts create mode 100644 src/routes/data/[...item]/[filename]/+server.ts create mode 100644 src/routes/data/[...item]/info.json/+server.ts diff --git a/src/lib/server/data/item.ts b/src/lib/server/data/item.ts index 2889132..e665d06 100644 --- a/src/lib/server/data/item.ts +++ b/src/lib/server/data/item.ts @@ -12,9 +12,14 @@ import { formatItemId, ItemIdStruct, type ItemId } from './itemId'; import { getDataDir } from './dataDir'; import { ItemSectionStruct, validateSection } from './section'; import { applyStruct } from '../util'; +import { rimraf } from 'rimraf'; -/** Information about an item, stored in its `info.json` */ -export const ItemInfoStruct = type({ +/** + * Information about an item, stored in its `info.json`. + * + * IMPORTANT: Do not validate using this struct alone -- instead, call `validateItemInfo` + */ +const ItemInfoStruct = type({ /** * The name of the item, displayed in the navigator when on this page, as well as on Card * elements. @@ -63,14 +68,15 @@ export const ItemInfoStruct = type({ /** Information about an item, stored in its `info.json` */ export type ItemInfo = Infer; -/** Returns the path to an item's `info.json` file */ -export function itemInfoPath(item: ItemId): string { - return path.join(getDataDir(), ...item, 'info.json'); +/** Returns the path to an item's directory */ +export function itemPath(item: ItemId, file?: string): string { + // Note, path.join() with an empty string has no effect + return path.join(getDataDir(), ...item, file ?? ''); } /** Returns whether the given item exists */ export async function itemExists(item: ItemId): Promise { - return await fs.access(itemInfoPath(item), fs.constants.R_OK) + return await fs.access(itemPath(item, 'info.json'), fs.constants.R_OK) .then(() => true) .catch(() => false); } @@ -127,12 +133,22 @@ export async function validateItemInfo(item: ItemId, data: any): Promise { // Currently load from the disk every time -- should implement caching at some point - const result = JSON.parse(await fs.readFile(itemInfoPath(item), { encoding: 'utf-8' })); + const result = JSON.parse(await fs.readFile(itemPath(item, 'info.json'), { encoding: 'utf-8' })); // Don't fully validate info when loading data, or we'll get infinite recursion return applyStruct(result, ItemInfoStruct); } /** Update the given item's `info.json` */ -export async function setItemInfo(item: ItemId, data: any): Promise { - await fs.writeFile(itemInfoPath(item), JSON.stringify(validateItemInfo(item, data)), { encoding: 'utf-8' }); +export async function setItemInfo(item: ItemId, data: ItemInfo): Promise { + await fs.writeFile( + itemPath(item, 'info.json'), + JSON.stringify(data, undefined, 2), + { encoding: 'utf-8' } + ); +} + +/** Delete the given item, and all references to it */ +export async function deleteItem(item: ItemId): Promise { + await rimraf(itemPath(item)); + // TODO: Clean up references } diff --git a/src/lib/server/data/itemId.ts b/src/lib/server/data/itemId.ts index a2ee0ae..bdeed42 100644 --- a/src/lib/server/data/itemId.ts +++ b/src/lib/server/data/itemId.ts @@ -4,13 +4,28 @@ import { array, string, type Infer } from 'superstruct'; /** Return an item ID given its path in URL form */ -export function fromUrl(path: string): ItemId { +export function itemIdFromUrl(path: string): ItemId { return path.split('/'); } /** Format the given ItemId for displaying to users */ export function formatItemId(itemId: ItemId): string { - return `'${itemId.join('/')}'`; + const path = itemId.join('/'); + return `'${path ? path : '/'}'`; +} + +/** Returns the ItemId for the parent of the given item */ +export function itemParent(itemId: ItemId): ItemId { + return itemId.slice(0, -1) +} + +/** + * Returns the "tail" of the item ID (the final element). + * + * Yes, I know this isn't `tail` in the Haskell sense, but I couldn't think of a better name for it. + */ +export function itemIdTail(itemId: ItemId): string { + return itemId.at(-1)!; } /** The ID of an Item. An array of `string`s representing the path to that item within the data. */ diff --git a/src/lib/server/formatTemplate.ts b/src/lib/server/formatTemplate.ts index 9d1bc52..4baaac9 100644 --- a/src/lib/server/formatTemplate.ts +++ b/src/lib/server/formatTemplate.ts @@ -6,9 +6,9 @@ */ export default function formatTemplate( input: string, - replacements: [string, string][], + replacements: Record, ): string { - for (const [matcher, replacement] of replacements) { + for (const [matcher, replacement] of Object.entries(replacements)) { input = input.replaceAll(`{{${matcher}}}`, replacement); } return input.trimStart(); diff --git a/src/routes/api/[...path]/+server.ts b/src/routes/api/[...path]/+server.ts deleted file mode 100644 index 4a509c8..0000000 --- a/src/routes/api/[...path]/+server.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type Request = import('./$types.js').RequestEvent; - - -export async function GET(req: Request) { - // TODO: Determine how to handle request -} - -export async function POST(req: Request) { - // TODO: Determine how to handle request -} diff --git a/src/routes/api/[...path]/file.ts b/src/routes/api/[...path]/file.ts deleted file mode 100644 index 9793eb1..0000000 --- a/src/routes/api/[...path]/file.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { ItemId } from '$lib/server/data/itemId'; -import sanitize from 'sanitize-filename'; -import fs from 'fs/promises'; -import path from 'path'; -import { error, json } from '@sveltejs/kit'; -import mime from 'mime-types'; -import { getDataDir } from '$lib/server/data/dataDir'; -import type { Request } from './+server'; -import { invalidatePortfolioGlobals } from '$lib/server'; - -export async function getFile(req: Request, item: ItemId, file: string): Promise { - // Sanitize the filename to prevent unwanted access to the server's filesystem - const filename = sanitize(file); - - // Get the path of the file to serve - const filePath = path.join(getDataDir(), ...item, filename); - - // Ensure file exists - await fs.access(filePath, fs.constants.R_OK).catch(() => error(404)); - - // Read the contents of the file - const content = await fs.readFile(filePath); - let mimeType = mime.contentType(filename); - if (!mimeType) { - mimeType = 'text/plain'; - } - - req.setHeaders({ - 'Content-Type': mimeType, - 'Content-Length': content.length.toString(), - }); - - return new Response(content); -} - -export async function updateFile(item: ItemId, file: string, req: Request): Promise { - // Sanitize the filename to prevent unwanted access to the server's filesystem - const filename = sanitize(file); - - // Get the path of the file to update - const filePath = path.join(getDataDir(), ...item, filename); - - // Ensure Content-Type header matches expected mimetype of header - const fileMimeType = mime.contentType(filename); - const reqMimeType = req.request.headers.get('Content-Type'); - - if (req.request.body === null) { - error(400, 'Request body must be given'); - } - - // Only check if the mimetype of the file is known - if (fileMimeType && fileMimeType !== reqMimeType) { - error(400, `Incorrect mimetype for file ${filename}. Expected ${fileMimeType}, got ${reqMimeType}`); - } - - // It would probably be nicer to pipe the data directly to the disk as it is received - // but I am unable to make TypeScript happy. - // Even then, this way, EsLint claims I'm calling an error-type value, which is cursed. - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const data = await req.request.bytes(); - - // Write the file - await fs.writeFile(filePath, data).catch(e => error(404, `${e}`)); - - invalidatePortfolioGlobals(); - - return json({}); -} diff --git a/src/routes/api/[...path]/info.ts b/src/routes/api/[...path]/info.ts deleted file mode 100644 index 93c5e39..0000000 --- a/src/routes/api/[...path]/info.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { getDataDir } from '$lib/server/data/dataDir'; -import { getItemInfo, ItemInfoStruct, type ItemInfo } from '$lib/server/data/item'; -import type { ItemId } from '$lib/server/data/itemId'; -import { applyStruct } from '$lib/server/util'; -import fs from 'fs/promises'; -import path from 'path'; - -export function getInfo(item: ItemId): Promise { - return getItemInfo(item); -} - -export async function setInfo(item: ItemId, data: any): Promise { -} diff --git a/src/routes/data/[...item]/README.md/+server.ts b/src/routes/data/[...item]/README.md/+server.ts new file mode 100644 index 0000000..a164816 --- /dev/null +++ b/src/routes/data/[...item]/README.md/+server.ts @@ -0,0 +1,42 @@ +/** + * API endpoints for accessing and modifying an item's README.md. + * + * Note that POST and DELETE methods are unavailable, as the lifetime of the README.md file should + * match that of the item itself. + */ +import { formatItemId, itemIdFromUrl, type ItemId } from '$lib/server/data/itemId'; +import fs from 'fs/promises'; +import { error, json } from '@sveltejs/kit'; +import { validateTokenFromRequest } from '$lib/server/auth/tokens.js'; +import { itemExists, itemPath } from '$lib/server/data/item.js'; +type Request = import('./$types.js').RequestEvent; + +/** GET request handler, returns README text */ +export async function GET(req: Request) { + const item: ItemId = itemIdFromUrl(req.params.item); + const filePath = itemPath(item, 'README.md'); + if (!await itemExists(item)) { + error(404, `Item ${formatItemId(item)} does not exist`); + } + const readme = await fs.readFile(filePath, { encoding: 'utf-8' }); + req.setHeaders({ + 'Content-Type': 'text/markdown', + 'Content-Length': readme.length.toString(), + }); + + return new Response(readme); +} + +/** PUT request handler, updates README text */ +export async function PUT(req: Request) { + await validateTokenFromRequest(req); + const item: ItemId = itemIdFromUrl(req.params.item); + const filePath = itemPath(item, 'README.md'); + if (!await itemExists(item)) { + error(404, `Item ${formatItemId(item)} does not exist`); + } + const readme = await req.request.text(); + await fs.writeFile(filePath, readme, { encoding: 'utf-8' }); + + return json({}); +} diff --git a/src/routes/data/[...item]/[filename]/+server.ts b/src/routes/data/[...item]/[filename]/+server.ts new file mode 100644 index 0000000..419bfb5 --- /dev/null +++ b/src/routes/data/[...item]/[filename]/+server.ts @@ -0,0 +1,114 @@ +/** + * API endpoints for accessing and modifying generic files. + */ +import { formatItemId, itemIdFromUrl, type ItemId } from '$lib/server/data/itemId'; +import sanitize from 'sanitize-filename'; +import fs from 'fs/promises'; +import { error, json } from '@sveltejs/kit'; +import mime from 'mime-types'; +import { fileExists } from '$lib/server/index.js'; +import { validateTokenFromRequest } from '$lib/server/auth/tokens.js'; +import { itemPath } from '$lib/server/data/item.js'; +type Request = import('./$types.js').RequestEvent; + +/** GET request handler, returns file contents */ +export async function GET(req: Request) { + const item: ItemId = itemIdFromUrl(req.params.item); + // Sanitize the filename to prevent unwanted access to the server's filesystem + const filename = sanitize(req.params.filename); + // Get the path of the file to serve + const filePath = itemPath(item, filename); + + // Ensure file exists + await fs.access(filePath, fs.constants.R_OK).catch(() => error(404)); + + // Read the contents of the file + const content = await fs.readFile(filePath); + let mimeType = mime.contentType(filename); + if (!mimeType) { + mimeType = 'text/plain'; + } + + req.setHeaders({ + 'Content-Type': mimeType, + 'Content-Length': content.length.toString(), + }); + + return new Response(content); +} + +/** + * Update the file at the given path using data from the given request. + * + * Note: this does not sanitize the given file path, so that must be done by the caller. + */ +async function updateFileFromRequest(file: string, req: Request): Promise { + // Ensure Content-Type header matches expected mimetype of header + const fileMimeType = mime.contentType(file); + const reqMimeType = req.request.headers.get('Content-Type'); + + if (req.request.body === null) { + error(400, 'Request body must be given'); + } + + // Only check if the mimetype of the file is known + if (fileMimeType && fileMimeType !== reqMimeType) { + error(400, `Incorrect mimetype for file ${file}. Expected ${fileMimeType}, got ${reqMimeType}`); + } + + // It would probably be nicer to pipe the data directly to the disk as it is received + // but I am unable to make TypeScript happy. + // Even then, this way, ESLint claims I'm calling an error-type value, which is cursed. + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const data = await req.request.bytes(); + + // Write the file + await fs.writeFile(file, data).catch(e => error(400, `${e}`)); +} + +/** POST request handler, creates file (if it doesn't exist) */ +export async function POST(req: Request) { + await validateTokenFromRequest(req); + const item: ItemId = itemIdFromUrl(req.params.item); + const filename = sanitize(req.params.filename); + const file = itemPath(item, filename); + + if (await fileExists(file)) { + error(400, `File '${filename}' already exists for item ${formatItemId(item)}`); + } + + await updateFileFromRequest(file, req); + return json({}); +} + +/** PUT request handler, updates file */ +export async function PUT(req: Request) { + await validateTokenFromRequest(req); + const item: ItemId = itemIdFromUrl(req.params.item); + const filename = sanitize(req.params.filename); + const file = itemPath(item, filename); + + if (!await fileExists(file)) { + error(404, `File '${filename}' does not exist for item ${formatItemId(item)}`); + } + + await updateFileFromRequest(file, req); + return json({}); +} + +/** DELETE request handler, removes file */ +export async function DELETE(req: Request) { + await validateTokenFromRequest(req); + const item: ItemId = itemIdFromUrl(req.params.item); + const filename = sanitize(req.params.filename); + const file = itemPath(item, filename); + + if (!await fileExists(file)) { + error(404, `File '${filename}' does not exist for item ${formatItemId(item)}`); + } + + // TODO: Update properties of info.json to remove references to the file + + await fs.unlink(file); + return json({}); +} diff --git a/src/routes/data/[...item]/info.json/+server.ts b/src/routes/data/[...item]/info.json/+server.ts new file mode 100644 index 0000000..a2912e6 --- /dev/null +++ b/src/routes/data/[...item]/info.json/+server.ts @@ -0,0 +1,117 @@ +import fs from 'fs/promises'; +import { json, error } from '@sveltejs/kit'; +import { object, string } from 'superstruct'; +import { formatItemId, itemIdFromUrl, itemIdTail, itemParent } from '$lib/server/data/itemId'; +import { deleteItem, getItemInfo, itemExists, itemPath, setItemInfo, validateItemInfo } from '$lib/server/data/item'; +import { validateTokenFromRequest } from '$lib/server/auth/tokens.js'; +import { applyStruct } from '$lib/server/util.js'; +import { validateName } from '$lib/validate.js'; +import formatTemplate from '$lib/server/formatTemplate.js'; + +/** + * API endpoints for accessing info.json + */ +type Request = import('./$types.js').RequestEvent; + +/** Get item info.json */ +export async function GET(req: Request) { + const item = itemIdFromUrl(req.params.item); + return json(await getItemInfo(item)); +} + +/** Allowed options when creating a new item */ +const NewItemOptions = object({ + name: string(), + description: string(), +}); + +const DEFAULT_README = ` +# {{item}} + +{{description}} + +This is the \`README.md\` file for the item {{item}}. Go ahead and modify it to +tell everyone more about it. Is it something you made, or something you use? +How does it demonstrate your abilities? +`; + +/** Create new item */ +export async function POST(req: Request) { + await validateTokenFromRequest(req); + const item = itemIdFromUrl(req.params.item); + + // Ensure parent exists + const parent = await getItemInfo(itemParent(item)) + .catch(() => error(404, `Parent of ${formatItemId(item)} does not exist`)); + // Check if item exists + if (await itemExists(item)) { + error(400, `Item ${formatItemId(item)} already exists`); + } + // Validate item properties + const { name, description } = applyStruct(await req.request.json(), NewItemOptions); + validateName(name); + + const itemInfo = await validateItemInfo(item, { + name, + description, + shortName: null, + icon: null, + banner: null, + color: parent.color, + sections: [], + children: [], + filters: [], + seo: { + description: null, + keywords: [name], + }, + }); + + // mkdir + await fs.mkdir(itemPath(item)); + // Set info.json + await setItemInfo(item, itemInfo); + // Set README.md + const readme = formatTemplate(DEFAULT_README, { item: name, description }) + // If the description was empty, we'll end up with extra newlines -- get + // rid of them. + .replace('\n\n\n', ''); + + await fs.writeFile(itemPath(item, 'README.md'), readme, { encoding: 'utf-8' }); + + // Add item to parent's children + parent.children.push(itemIdTail(item)); + await setItemInfo(itemParent(item), parent); + + return json({}); +} + +/** Update item info.json */ +export async function PUT(req: Request) { + await validateTokenFromRequest(req); + const item = itemIdFromUrl(req.params.item); + // Check if item exists + if (!await itemExists(item)) { + error(404, `Item ${formatItemId(item)} does not exist`); + } + // Validate properties + const itemInfo = await validateItemInfo(item, await req.request.json()); + await setItemInfo(item, itemInfo); + return json({}); +} + +/** Delete item */ +export async function DELETE(req: Request) { + await validateTokenFromRequest(req); + const item = itemIdFromUrl(req.params.item); + // Prevent the Minifolio equivalent of `rm -rf /` + if (item.length == 0) { + error(403, 'Cannot delete root item'); + } + // Check if item exists + if (!await itemExists(item)) { + error(404, `Item ${formatItemId(item)} does not exist`); + } + await deleteItem(item); + return json({}); +} From d70224a2bc8c7ab3ef46a9fa8ff406101880bed3 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Fri, 27 Dec 2024 23:16:24 +1100 Subject: [PATCH 008/149] Implement item deletion --- src/lib/server/data/item.ts | 65 ++++++++++++++++++++++++++++++++--- src/lib/server/data/itemId.ts | 5 +++ src/lib/util.ts | 7 +++- src/lib/validate.ts | 5 +++ 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/lib/server/data/item.ts b/src/lib/server/data/item.ts index e665d06..07848a1 100644 --- a/src/lib/server/data/item.ts +++ b/src/lib/server/data/item.ts @@ -8,7 +8,7 @@ import path from 'path'; import { error } from '@sveltejs/kit'; import { array, nullable, string, type, type Infer } from 'superstruct'; import validate from '$lib/validate'; -import { formatItemId, ItemIdStruct, type ItemId } from './itemId'; +import { formatItemId, itemIdsEqual, ItemIdStruct, itemIdTail, itemParent, type ItemId } from './itemId'; import { getDataDir } from './dataDir'; import { ItemSectionStruct, validateSection } from './section'; import { applyStruct } from '../util'; @@ -147,8 +147,65 @@ export async function setItemInfo(item: ItemId, data: ItemInfo): Promise { ); } +/** Remove links to the target within the given item */ +function removeLinkToItem(target: ItemId, item: ItemInfo): ItemInfo { + for (const section of item.sections) { + if (section.type === 'links') { + section.items = section.items.filter(link => !itemIdsEqual(link, target)); + } + } + item.filters = item.filters.filter(filter => !itemIdsEqual(target, filter)); + return item; +} + /** Delete the given item, and all references to it */ -export async function deleteItem(item: ItemId): Promise { - await rimraf(itemPath(item)); - // TODO: Clean up references +export async function deleteItem(itemToDelete: ItemId): Promise { + await rimraf(itemPath(itemToDelete)); + // Remove from parent + const parent = await getItemInfo(itemParent(itemToDelete)); + parent.children = parent.children.filter(child => child !== itemIdTail(itemToDelete)); + await setItemInfo(itemParent(itemToDelete), parent); + // Clean up references in other items + for await (const otherItemId of iterItems()) { + const otherItem = await getItemInfo(otherItemId); + await setItemInfo(otherItemId, removeLinkToItem(itemToDelete, otherItem)); + } +} + +/** + * Async generator function that yields ItemIds of direct child items to the given item. + */ +export async function* itemChildren(item: ItemId): AsyncIterableIterator { + for await (const dirent of await fs.opendir(itemPath(item))) { + if (dirent.isDirectory()) { + const child = [...item, dirent.name]; + if (await itemExists(child)) { + yield child; + } + } + } +} + +/** + * Async generator function that yields ItemIds of all descendants of the given item. + * + * Performs depth-first iteration over the directory tree. + * + * ```txt + * parent + * - child 1 + * - grandchild 1 + * - grandchild 2 + * - child 2 + * - grandchild 3 + * ...etc + * ``` + */ +export async function* iterItems(item: ItemId = []): AsyncIterableIterator { + yield item; + for await (const child of itemChildren(item)) { + for await (const descendant of iterItems(child)) { + yield descendant; + } + } } diff --git a/src/lib/server/data/itemId.ts b/src/lib/server/data/itemId.ts index bdeed42..0336841 100644 --- a/src/lib/server/data/itemId.ts +++ b/src/lib/server/data/itemId.ts @@ -1,6 +1,7 @@ /** * Item ID type definitions and helper functions */ +import { zip } from '$lib/util'; import { array, string, type Infer } from 'superstruct'; /** Return an item ID given its path in URL form */ @@ -19,6 +20,10 @@ export function itemParent(itemId: ItemId): ItemId { return itemId.slice(0, -1) } +export function itemIdsEqual(first: ItemId, second: ItemId): boolean { + return zip(first, second).find(([a, b]) => a !== b) === undefined; +} + /** * Returns the "tail" of the item ID (the final element). * diff --git a/src/lib/util.ts b/src/lib/util.ts index ceab6d2..8c00bb1 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -7,8 +7,13 @@ export function unixTime(): number { return Math.floor(Date.now() / 1000); } -/** Capitalise the given string */ +/** Capitalize the given string */ export function capitalize(str: string): string { // https://stackoverflow.com/a/1026087/6335363 return str.charAt(0).toUpperCase() + str.slice(1); } + +/** Simple zip iterator */ +export function zip(first: F[], second: S[]): [F, S][] { + return first.slice(0, second.length).map((f, i) => [f, second[i]]); +} diff --git a/src/lib/validate.ts b/src/lib/validate.ts index 35dbb12..8cb27d6 100644 --- a/src/lib/validate.ts +++ b/src/lib/validate.ts @@ -1,3 +1,8 @@ +/** + * validate.ts + * + * Contains common validator functions shared throughout the app. + */ import { error } from '@sveltejs/kit'; import fs from 'fs/promises'; import mime from 'mime-types'; From 44a2fb29e061610fa61ce4eb0d6d0f0026ffb0f2 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Fri, 27 Dec 2024 23:16:33 +1100 Subject: [PATCH 009/149] Add more constants --- src/lib/color.ts | 11 +++++++++++ src/lib/consts.ts | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 src/lib/color.ts diff --git a/src/lib/color.ts b/src/lib/color.ts new file mode 100644 index 0000000..5889b38 --- /dev/null +++ b/src/lib/color.ts @@ -0,0 +1,11 @@ +/** Code for generating colors */ +import Color from 'color'; + +/** Generate a random (hopefully) nice-looking color */ +export function randomColor(): string { + return Color.hsv( + Math.random(), + Math.random(), + 1, + ).hex(); +} diff --git a/src/lib/consts.ts b/src/lib/consts.ts index b6e05f9..ab058fc 100644 --- a/src/lib/consts.ts +++ b/src/lib/consts.ts @@ -1,7 +1,24 @@ +/** + * Constants shared across the app + */ + +/** Name of the app */ export const APP_NAME = 'Minifolio'; +/** Link to app's GitHub repo */ export const APP_GITHUB = 'https://github.com/MaddyGuthridge/Minifolio'; +/** Author info -- `[name, URL]` */ +type AuthorInfo = [string, string]; + +/** Primary author of the project */ +export const APP_AUTHOR: AuthorInfo = ['Maddy Guthridge', 'https://maddyguthridge.com']; + +/** Additional contributors to the project */ +export const APP_CONTRIBUTORS: AuthorInfo[] = []; + export default { APP_NAME, APP_GITHUB, + APP_AUTHOR, + APP_CONTRIBUTORS, }; From 83ff10bbbb0e4b8529c93c04ff429454307e4d37 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Tue, 31 Dec 2024 13:22:41 +1100 Subject: [PATCH 010/149] Update endpoints module to use new API --- src/endpoints/admin/auth.ts | 71 +++++------- src/endpoints/admin/config.ts | 55 ++++----- src/endpoints/admin/firstrun.ts | 48 +++----- src/endpoints/admin/git.ts | 76 ++++++------ src/endpoints/admin/index.ts | 10 +- src/endpoints/admin/keys.ts | 70 +++++------- src/endpoints/debug.ts | 33 +++--- src/endpoints/fetch.ts | 146 ----------------------- src/endpoints/{ => fetch}/ApiError.ts | 4 +- src/endpoints/fetch/fetch.ts | 85 ++++++++++++++ src/endpoints/fetch/index.ts | 4 + src/endpoints/fetch/payload.ts | 33 ++++++ src/endpoints/fetch/response.ts | 67 +++++++++++ src/endpoints/group/index.ts | 126 -------------------- src/endpoints/group/item.ts | 159 -------------------------- src/endpoints/index.ts | 9 +- src/endpoints/item.ts | 66 +++++++++++ src/endpoints/readme.ts | 36 ------ src/lib/server/data/itemId.ts | 8 +- 19 files changed, 420 insertions(+), 686 deletions(-) delete mode 100644 src/endpoints/fetch.ts rename src/endpoints/{ => fetch}/ApiError.ts (80%) create mode 100644 src/endpoints/fetch/fetch.ts create mode 100644 src/endpoints/fetch/index.ts create mode 100644 src/endpoints/fetch/payload.ts create mode 100644 src/endpoints/fetch/response.ts delete mode 100644 src/endpoints/group/index.ts delete mode 100644 src/endpoints/group/item.ts create mode 100644 src/endpoints/item.ts delete mode 100644 src/endpoints/readme.ts diff --git a/src/endpoints/admin/auth.ts b/src/endpoints/admin/auth.ts index b099816..51cefe9 100644 --- a/src/endpoints/admin/auth.ts +++ b/src/endpoints/admin/auth.ts @@ -1,35 +1,32 @@ /** Authentication endpoints */ -import { apiFetch, json } from '../fetch'; +import { apiFetch, payload } from '../fetch'; -export default function auth(token: string | undefined) { +export default (token: string | undefined) => ({ /** * Log in as an administrator for the site * * @param username The username of the admin account * @param password The password of the admin account */ - const login = async (username: string, password: string) => { - return json(apiFetch( + login: async (username: string, password: string) => { + return apiFetch( 'POST', '/api/admin/auth/login', - undefined, - { username, password } - )) as Promise<{ token: string }>; - }; - + payload.json({ username, password }), + ).json() as Promise<{ token: string }>; + }, /** * Log out, invalidating the token * * @param token The token to invalidate */ - const logout = async () => { - return json(apiFetch( + logout: async () => { + return apiFetch( 'POST', '/api/admin/auth/logout', - token, - )); - }; - + { token }, + ).json(); + }, /** * Change the authentication of the admin account * @@ -37,28 +34,25 @@ export default function auth(token: string | undefined) { * @param oldPassword The currently-active password * @param newPassword The new replacement password */ - const change = async (newUsername: string, oldPassword: string, newPassword: string) => { - return json(apiFetch( + change: async (newUsername: string, oldPassword: string, newPassword: string) => { + return apiFetch( 'POST', '/api/admin/auth/change', - token, - { newUsername, oldPassword, newPassword } - )) as Promise>; - }; - + { token, ...payload.json({ newUsername, oldPassword, newPassword }) }, + ).json() as Promise>; + }, /** * Revoke all current API tokens * * @param token The auth token */ - const revoke = async () => { - return json(apiFetch( + revoke: async () => { + return apiFetch( 'POST', '/api/admin/auth/revoke', - token - )) as Promise>; - }; - + { token } + ).json() as Promise>; + }, /** * Disable authentication, meaning that users can no-longer log into the * system. @@ -66,20 +60,11 @@ export default function auth(token: string | undefined) { * @param token The auth token * @param password The password to the admin account */ - const disable = async (username: string, password: string) => { - return json(apiFetch( + disable: async (username: string, password: string) => { + return apiFetch( 'POST', '/api/admin/auth/disable', - token, - { username, password } - )) as Promise>; - }; - - return { - login, - logout, - change, - disable, - revoke, - }; -} + { token, ...payload.json({ username, password }) }, + ).json() as Promise>; + }, +}); diff --git a/src/endpoints/admin/config.ts b/src/endpoints/admin/config.ts index ca517c8..51e384a 100644 --- a/src/endpoints/admin/config.ts +++ b/src/endpoints/admin/config.ts @@ -1,38 +1,31 @@ /** Configuration endpoints */ -import { apiFetch, json } from '../fetch'; +import { apiFetch, payload } from '../fetch'; import type { ConfigJson } from '$lib/server/data/config'; -export default function config(token: string | undefined) { - const get = async () => { - return json(apiFetch( +export default (token: string | undefined) => ({ + /** + * Retrieve the site configuration. + * + * @param token The authentication token + */ + get: async () => { + return apiFetch( 'GET', '/api/admin/config', - token, - )) as Promise; - }; - - const put = async (config: ConfigJson) => { - return json(apiFetch( + { token }, + ).json() as Promise; + }, + /** + * Update the site configuration. + * + * @param token The authentication token + * @param config The updated configuration + */ + put: async (config: ConfigJson) => { + return apiFetch( 'PUT', '/api/admin/config', - token, - config, - )) as Promise>; - }; - - return { - /** - * Retrieve the site configuration. - * - * @param token The authentication token - */ - get, - /** - * Update the site configuration. - * - * @param token The authentication token - * @param config The updated configuration - */ - put, - }; -} + { token, ...payload.json(config) }, + ).json() as Promise>; + }, +}) diff --git a/src/endpoints/admin/firstrun.ts b/src/endpoints/admin/firstrun.ts index 2a8ed2b..39b9f14 100644 --- a/src/endpoints/admin/firstrun.ts +++ b/src/endpoints/admin/firstrun.ts @@ -1,39 +1,21 @@ /** Git repository endpoints */ -import { apiFetch, json } from '../fetch'; +import { apiFetch, payload } from '../fetch'; -export default function firstrun(token: string | undefined) { - const account = async (username: string, password: string) => { - return json(apiFetch( +export default (token: string | undefined) => ({ + /** Set up the first account */ + account: async (username: string, password: string) => { + return apiFetch( 'POST', '/api/admin/firstrun/account', - token, - { username, password }, - )) as Promise<{ token: string }>; - }; - - const data = async (repoUrl: string | null = null, branch: string | null = null) => { - return json(apiFetch( + { token, ...payload.json({ username, password }) }, + ).json() as Promise<{ token: string }>; + }, + /** Set up the site data */ + data: async (repoUrl: string | null = null, branch: string | null = null) => { + return apiFetch( 'POST', '/api/admin/firstrun/data', - token, - { repoUrl, branch }, - )) as Promise<{ firstTime: boolean }>; - }; - - return { - /** - * Set up account information. - * - * @param username The username to use - * @param password A strong and secure password - */ - account, - /** - * Set up the site's data repository. - * - * @param repoUrl The clone URL of the git repo - * @param branch The branch to check-out - */ - data, - }; -} + { token, ...payload.json({ repoUrl, branch }) }, + ).json() as Promise<{ firstTime: boolean }>; + }, +}) diff --git a/src/endpoints/admin/git.ts b/src/endpoints/admin/git.ts index 224fd5a..80e1330 100644 --- a/src/endpoints/admin/git.ts +++ b/src/endpoints/admin/git.ts @@ -1,60 +1,50 @@ /** Git repository endpoints */ import type { RepoStatus } from '$lib/server/git'; -import { apiFetch, json } from '../fetch'; +import { apiFetch, payload } from '../fetch'; -export default function git(token: string | undefined) { - const status = async () => { - return json(apiFetch( +export default (token: string | undefined) => ({ + /** Retrieve information about the data repository */ + status: async () => { + return apiFetch( 'GET', '/api/admin/git', - token, - )) as Promise<{ repo: RepoStatus }>; - }; + { token }, + ).json() as Promise<{ repo: RepoStatus }>; + }, - const init = async (url: string) => { - return json(apiFetch( + /** Initialize a git repo */ + init: async (url: string) => { + return apiFetch( 'POST', '/api/admin/git/init', - token, - { url }, - )) as Promise; - }; + { token, ...payload.json({ url }) }, + ).json() as Promise; + }, - const commit = async (message: string) => { - return json(apiFetch( + /** Perform a git commit */ + commit: async (message: string) => { + return apiFetch( 'POST', '/api/admin/git/commit', - token, - { message }, - )) as Promise; - }; + { token, ...payload.json({ message },) }, + ).json() as Promise; + }, - const push = async () => { - return json(apiFetch( + /** Perform a git push */ + push: async () => { + return apiFetch( 'POST', '/api/admin/git/push', - token, - )) as Promise; - }; + { token }, + ).json() as Promise; + }, - const pull = async () => { - return json(apiFetch( + /** Perform a git pull */ + pull: async () => { + return apiFetch( 'POST', '/api/admin/git/pull', - token, - )) as Promise; - }; - - return { - /** Retrieve information about the data repository */ - status, - /** Initialise a git repo */ - init, - /** Perform a git commit */ - commit, - /** Perform a git push */ - push, - /** Perform a git pull */ - pull, - }; -} + { token }, + ).json() as Promise; + }, +}); diff --git a/src/endpoints/admin/index.ts b/src/endpoints/admin/index.ts index 5bea7dd..f7a09aa 100644 --- a/src/endpoints/admin/index.ts +++ b/src/endpoints/admin/index.ts @@ -3,20 +3,26 @@ import auth from './auth'; import config from './config'; import git from './git'; import firstrun from './firstrun'; -import { apiFetch, json } from '$endpoints/fetch'; +import { apiFetch } from '$endpoints/fetch'; import keys from './keys'; export async function refresh(token: string | undefined) { - return await json(apiFetch('POST', '/api/admin/data/refresh', token)) as Record; + return await apiFetch('POST', '/api/admin/data/refresh', { token }).json() as Record; } export default function admin(token: string | undefined) { return { + /** Authentication options */ auth: auth(token), + /** Site configuration */ config: config(token), + /** Git actions */ git: git(token), + /** Key management (used for git operations) */ keys: keys(token), + /** Firstrun endpoints */ firstrun: firstrun(token), + /** Manage server data */ data: { /** Refresh the data store */ refresh: () => refresh(token), diff --git a/src/endpoints/admin/keys.ts b/src/endpoints/admin/keys.ts index 67fa6b9..fd36ae5 100644 --- a/src/endpoints/admin/keys.ts +++ b/src/endpoints/admin/keys.ts @@ -1,50 +1,40 @@ -import { apiFetch, json } from '$endpoints/fetch' +import { apiFetch, payload } from '$endpoints/fetch' -export default function keys(token: string | undefined) { - const get = async () => { - return json(apiFetch( +export default (token: string | undefined) => ({ + /** + * Returns the server's SSH public key, and the path to the private key + * file + */ + get: async () => { + return apiFetch( 'GET', '/api/admin/keys', - token, - )) as Promise<{ publicKey: string, keyPath: string }>; - }; - - const setKeyPath = async (keyPath: string) => { - return json(apiFetch( + { token }, + ).json() as Promise<{ publicKey: string, keyPath: string }>; + }, + /** Sets the path to the file the server should use as the private key */ + setKeyPath: async (keyPath: string) => { + return apiFetch( 'POST', '/api/admin/keys', - token, - { keyPath } - )) as Promise<{ publicKey: string, keyPath: string }>; - }; + { token, ...payload.json({ keyPath }) }, - const disable = async () => { - return json(apiFetch( + ).json() as Promise<{ publicKey: string, keyPath: string }>; + }, + /** Disables SSH key-based authentication */ + disable: async () => { + return apiFetch( 'DELETE', '/api/admin/keys', - token, - )) as Promise>; - } - - const generate = async () => { - return json(apiFetch( + { token }, + ).json() as Promise>; + }, + /** Generate an SSH key-pair */ + generate: async () => { + return apiFetch( 'POST', '/api/admin/keys/generate', - token, - )) as Promise<{ publicKey: string, keyPath: string }>; - } - - return { - /** - * Returns the server's SSH public key, and the path to the private key - * file - */ - get, - /** Sets the path to the file the server should use as the private key */ - setKeyPath, - /** Disables SSH key-based authentication */ - disable, - /** Generate an SSH key-pair */ - generate, - }; -} + { token }, + ).json() as Promise<{ publicKey: string, keyPath: string }>; + }, +}); diff --git a/src/endpoints/debug.ts b/src/endpoints/debug.ts index b7ebe6c..057a38d 100644 --- a/src/endpoints/debug.ts +++ b/src/endpoints/debug.ts @@ -1,44 +1,37 @@ /** Debug endpoints */ -import { apiFetch, json } from './fetch'; +import { apiFetch, payload } from './fetch'; export default function debug(token: string | undefined) { const clear = async () => { - return json(apiFetch( + return apiFetch( 'DELETE', '/api/debug/clear', - token, - )) as Promise>; + { token }, + ).json() as Promise>; }; const echo = async (text: string) => { - return json(apiFetch( + return apiFetch( 'POST', '/api/debug/echo', - token, - { text } - )) as Promise>; + { token, ...payload.json({ text }) }, + ).json() as Promise>; }; const dataRefresh = async () => { - return json(apiFetch( + return apiFetch( 'POST', '/api/debug/data/refresh', - token, - )) as Promise>; + { token }, + ).json() as Promise>; }; return { - /** - * Reset the app to its default state. - */ + /** Reset the app to its default state, deleting all data */ clear, - /** - * Echo text to the server's console - */ + /** Echo text to the server's console */ echo, - /** - * Invalidate cached data - */ + /** Invalidate cached data */ dataRefresh, }; } diff --git a/src/endpoints/fetch.ts b/src/endpoints/fetch.ts deleted file mode 100644 index 515af2c..0000000 --- a/src/endpoints/fetch.ts +++ /dev/null @@ -1,146 +0,0 @@ -import dotenv from 'dotenv'; -import ApiError from './ApiError'; -// import fetch from 'cross-fetch'; -import { browser } from '$app/environment'; - -export type HttpVerb = 'GET' | 'POST' | 'PUT' | 'DELETE'; - -export function getUrl() { - if (browser) { - // Running in browser (request to whatever origin we are running in) - return ''; - } else { - // Running in node - dotenv.config(); - - const PORT = process.env.PORT!; - const HOST = process.env.HOST!; - return `http://${HOST}:${PORT}`; - } -} - -/** - * Fetch some data from the backend - * - * @param method Type of request - * @param route route to request to - * @param token auth token (note this is only needed if the token wasn't set in - * the cookies) - * @param bodyParams request body or params - * - * @returns promise of the resolved data. - */ -export async function apiFetch( - method: HttpVerb, - route: string, - token?: string, - bodyParams?: object -): Promise { - const URL = getUrl(); - if (bodyParams === undefined) { - bodyParams = {}; - } - - const tokenHeader = token ? { Authorization: `Bearer ${token}` } : {}; - const contentType = ['POST', 'PUT'].includes(method) ? { 'Content-Type': 'application/json' } : {}; - - const headers = new Headers({ - ...tokenHeader, - ...contentType, - } as Record); - - let url: string; - let body: string | null; // JSON string - - if (['POST', 'PUT'].includes(method)) { - // POST and PUT use a body - url = `${URL}${route}`; - body = JSON.stringify(bodyParams); - } else { - // GET and DELETE use params - url - = `${URL}${route}?` - + new URLSearchParams(bodyParams as Record).toString(); - body = null; - } - - // Now send the request - let res: Response; - try { - res = await fetch(url, { - method, - body, - headers, - // Include credentials so that the token cookie is sent with the request - // https://stackoverflow.com/a/76562495/6335363 - credentials: 'same-origin', - }); - } catch (err) { - // Likely a network issue - if (err instanceof Error) { - throw new ApiError(null, err.message); - } else { - throw new ApiError(null, `Unknown request error ${err}`); - } - } - - return res; -} - -/** Process a text response, returning the text as a string */ -export async function text(response: Promise): Promise { - const res = await response; - if ([404, 405].includes(res.status)) { - throw new ApiError(404, `Error ${res.status} at ${res.url}`); - } - if (![200, 304].includes(res.status)) { - // Unknown error - throw new ApiError(res.status, `Request got status code ${res.status}`); - } - - const text = await res.text(); - - if ([400, 401, 403].includes(res.status)) { - throw new ApiError(res.status, text); - } - - return text; -} - -/** Process a JSON response, returning the data as a JS object */ -export async function json(response: Promise): Promise { - const res = await response; - if ([404, 405].includes(res.status)) { - throw new ApiError(404, `Error ${res.status} at ${res.url}`); - } - if (res.status >= 500) { - // Unknown error - throw new ApiError(res.status, `Request got status code ${res.status}`); - } - // Decode the data - let json: object; - try { - json = await res.json(); - } catch (err) { - // JSON parse error - if (err instanceof Error) { - throw new ApiError(null, err.message); - } else { - throw new ApiError(null, `Unknown JSON error ${err}`); - } - } - - if ([400, 401, 403].includes(res.status)) { - // All 400, 401 and 403 errors have an error message - const message = (json as { message: string }).message; - throw new ApiError(res.status, message); - } - - // Got valid data - // Assign it to a new object, because otherwise it'll fail to match using - // `.toStrictEqual`, likely due to some weirdness with Jest - // Seems to be similar to the issues described at these URLs - // https://github.com/jestjs/jest/issues/8446 - // https://github.com/nktnet1/jewire?tab=readme-ov-file#53-rewire-and-jest - return Object.assign({}, json); -} diff --git a/src/endpoints/ApiError.ts b/src/endpoints/fetch/ApiError.ts similarity index 80% rename from src/endpoints/ApiError.ts rename to src/endpoints/fetch/ApiError.ts index 95358d8..bf49983 100644 --- a/src/endpoints/ApiError.ts +++ b/src/endpoints/fetch/ApiError.ts @@ -2,13 +2,15 @@ * Custom error class for errors that happen when fetching data */ class ApiError extends Error { + /** Error message */ error: string; + /** Status code */ code: number | null; /** * Custom error class for errors that happen when fetching data */ - constructor (code: number | null, error: string | object) { + constructor(code: number | null, error: string | object) { if (typeof error === 'object') { error = JSON.stringify(error); } diff --git a/src/endpoints/fetch/fetch.ts b/src/endpoints/fetch/fetch.ts new file mode 100644 index 0000000..790a699 --- /dev/null +++ b/src/endpoints/fetch/fetch.ts @@ -0,0 +1,85 @@ +import dotenv from 'dotenv'; +import ApiError from './ApiError'; +// import fetch from 'cross-fetch'; +import { browser } from '$app/environment'; +import response from './response'; + +export type HttpVerb = 'GET' | 'POST' | 'PUT' | 'DELETE'; + +/** Determine which URL to request to */ +export function getUrl() { + if (browser) { + // Running in browser (request to whatever origin we are running in) + return ''; + } else { + // Running in node + dotenv.config(); + + const PORT = process.env.PORT!; + const HOST = process.env.HOST!; + return `http://${HOST}:${PORT}`; + } +} + +/** + * Fetch some data from the backend + * + * @param method Type of request + * @param route Endpoint to request to + * @param options options for the request + * + * @returns object giving access to the response in various formats + */ +export function apiFetch( + method: HttpVerb, + route: string, + options?: { + token?: string, + query?: Record, + contentType?: string, + payload?: string, + } +) { + const baseUrl = getUrl(); + + const contentType = options?.contentType; + const query = options?.query ?? {}; + const body = options?.payload; + + const tokenHeader = options?.token ? { Authorization: `Bearer ${options.token}` } : {}; + const contentTypeHeader = (contentType && ['POST', 'PUT'].includes(method)) ? { 'Content-Type': contentType } : {}; + + const headers = new Headers({ + ...tokenHeader, + ...contentTypeHeader, + } as Record); + + let url: string; + + if (Object.keys(query).length) { + url + = `${baseUrl}${route}?` + + new URLSearchParams(query).toString(); + } else { + url = `${baseUrl}${route}`; + } + + // Now send the request + try { + return response(fetch(url, { + method, + body, + headers, + // Include credentials so that the token cookie is sent with the request + // https://stackoverflow.com/a/76562495/6335363 + credentials: 'same-origin', + })); + } catch (err) { + // Likely a network issue + if (err instanceof Error) { + throw new ApiError(null, err.message); + } else { + throw new ApiError(null, `Unknown request error ${err}`); + } + } +} diff --git a/src/endpoints/fetch/index.ts b/src/endpoints/fetch/index.ts new file mode 100644 index 0000000..af03ce7 --- /dev/null +++ b/src/endpoints/fetch/index.ts @@ -0,0 +1,4 @@ +import payload from './payload'; + +export { apiFetch } from './fetch'; +export { payload }; diff --git a/src/endpoints/fetch/payload.ts b/src/endpoints/fetch/payload.ts new file mode 100644 index 0000000..08955e7 --- /dev/null +++ b/src/endpoints/fetch/payload.ts @@ -0,0 +1,33 @@ +/** Information about a request payload */ +export type PayloadInfo = { + /** `Content-Type` header to use */ + contentType: string, + /** Payload converted to a string */ + payload: string, +} + +/** Generate a function to handle payloads of the given type */ +function generatePayloadFn( + contentType: string, + converter: (body: T) => string, +): (body: T) => PayloadInfo { + return (body) => ({ + contentType, + payload: converter(body), + }); +} + +/** No transformation on the payload */ +const noop = (body: string) => body; + +/** Send a request payload of the given type */ +export default { + /** Request payload in JSON format */ + json: generatePayloadFn('application/json', JSON.stringify), + /** Request payload in Markdown format */ + markdown: generatePayloadFn('text/markdown', noop), + /** Request payload in plain text format */ + text: generatePayloadFn('text/plain', noop), + /** Request using a custom mime-type */ + custom: (contentType: string, body: string) => ({ contentType, payload: body }) +}; diff --git a/src/endpoints/fetch/response.ts b/src/endpoints/fetch/response.ts new file mode 100644 index 0000000..933997c --- /dev/null +++ b/src/endpoints/fetch/response.ts @@ -0,0 +1,67 @@ +import ApiError from './ApiError'; + +/** Process a text response, returning the text as a string */ +export async function text(response: Promise): Promise { + const res = await response; + if ([404, 405].includes(res.status)) { + throw new ApiError(404, `Error ${res.status} at ${res.url}`); + } + const text = await res.text(); + if ([400, 401, 403].includes(res.status)) { + throw new ApiError(res.status, text); + } + if (![200, 304].includes(res.status)) { + // Unknown error + throw new ApiError(res.status, `Request got status code ${res.status}`); + } + return text; +} + +/** Process a JSON response, returning the data as a JS object */ +export async function json(response: Promise): Promise { + const res = await response; + if ([404, 405].includes(res.status)) { + throw new ApiError(404, `Error ${res.status} at ${res.url}`); + } + if (res.status >= 500) { + // Unknown error + throw new ApiError(res.status, `Request got status code ${res.status}`); + } + // Decode the data + let json: object; + try { + json = await res.json(); + } catch (err) { + // JSON parse error + if (err instanceof Error) { + throw new ApiError(null, err.message); + } else { + throw new ApiError(null, `Unknown JSON error ${err}`); + } + } + + if ([400, 401, 403].includes(res.status)) { + // All 400, 401 and 403 errors have an error message + const message = (json as { message: string }).message; + throw new ApiError(res.status, message); + } + + // Got valid data + // Assign it to a new object, because otherwise it'll fail to match using + // `.toStrictEqual`, likely due to some weirdness with Jest + // Seems to be similar to the issues described at these URLs + // https://github.com/jestjs/jest/issues/8446 + // https://github.com/nktnet1/jewire?tab=readme-ov-file#53-rewire-and-jest + // TODO: Is this still an issue when working with Vitest? + return Object.assign({}, json); +} + +/** + * Handler for responses, allowing users to `.` to get the response data in the format they + * desire + */ +export default (response: Promise) => ({ + json: () => json(response), + text: () => text(response), + response, +}) diff --git a/src/endpoints/group/index.ts b/src/endpoints/group/index.ts deleted file mode 100644 index 2128d08..0000000 --- a/src/endpoints/group/index.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** Group management endpoints */ -import type { GroupInfo } from '$lib/server/data/group'; -import { apiFetch, json } from '../fetch'; -import makeItemFunctions from './item'; - -export default function group(token: string | undefined) { - /** - * Return brief info about all groups - * - * @returns mappings of groups - */ - const all = async () => { - return json(apiFetch( - 'GET', - '/api/group', - token, - )) as Promise>; - }; - - /** Access a group with the given ID */ - const withId = (groupId: string) => { - const create = async (name: string, description: string) => { - return json(apiFetch( - 'POST', - `/api/group/${groupId}`, - token, - { name, description }, - )) as Promise>; - }; - - const remove = async () => { - return json(apiFetch( - 'DELETE', - `/api/group/${groupId}`, - token, - )) as Promise>; - }; - - const getInfo = async () => { - return json(apiFetch( - 'GET', - `/api/group/${groupId}`, - token, - )) as Promise; - }; - - const setInfo = async (newInfo: GroupInfo) => { - return json(apiFetch( - 'PUT', - `/api/group/${groupId}`, - token, - newInfo, - )) as Promise>; - }; - - const getReadme = async () => { - return json(apiFetch( - 'GET', - `/api/group/${groupId}/readme`, - token, - )) as Promise<{ readme: string }>; - }; - - const setReadme = async (readme: string) => { - return json(apiFetch( - 'PUT', - `/api/group/${groupId}/readme`, - token, - { readme }, - )) as Promise>; - }; - - return { - /** - * Create a new group - * - * @param token The authentication token - * @param name The name of the group - * @param description The description of the group - */ - create, - /** - * Remove a group - * - * @param token The authentication token - */ - remove, - info: { - /** - * Return info on a particular group - * - * @returns info about the group - */ - get: getInfo, - /** - * Update info on a particular group - * - * @param info info about the group - */ - set: setInfo, - }, - readme: { - /** - * Returns the README.md of the given group - * - * @returns readme.md of group - */ - get: getReadme, - /** - * Updates the README.md of the given group - * - * @param token Auth token - * @param readme README.md contents - */ - set: setReadme, - }, - /** Access properties of items within this group */ - item: makeItemFunctions(token, groupId), - }; - }; - - return { - all, - withId, - }; -} diff --git a/src/endpoints/group/item.ts b/src/endpoints/group/item.ts deleted file mode 100644 index 114948e..0000000 --- a/src/endpoints/group/item.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** Item management endpoints */ - -import { apiFetch, json } from '$endpoints/fetch'; -import type { ItemInfoBrief, ItemInfoFull } from '$lib/server/data/itemOld'; - -export default function makeItemFunctions(token: string | undefined, groupId: string) { - /** - * Return brief info about all items - * - * @returns mappings of items - */ - const all = async () => { - return json(apiFetch( - 'GET', - `/api/group/${groupId}/item`, - token, - )) as Promise>; - }; - - const withId = (itemId: string) => { - const create = async (name: string, description: string) => { - return json(apiFetch( - 'POST', - `/api/group/${groupId}/item/${itemId}`, - token, - { name, description }, - )) as Promise>; - }; - - const remove = async () => { - return json(apiFetch( - 'DELETE', - `/api/group/${groupId}/item/${itemId}`, - token, - )) as Promise>; - }; - - const getInfo = async () => { - return json(apiFetch( - 'GET', - `/api/group/${groupId}/item/${itemId}`, - token, - )) as Promise; - }; - - const setInfo = async (newInfo: ItemInfoFull) => { - return json(apiFetch( - 'PUT', - `/api/group/${groupId}/item/${itemId}`, - token, - newInfo, - )) as Promise>; - }; - - const getReadme = async () => { - return json(apiFetch( - 'GET', - `/api/group/${groupId}/item/${itemId}/readme`, - token, - )) as Promise<{ readme: string }>; - }; - - const setReadme = async (readme: string) => { - return json(apiFetch( - 'PUT', - `/api/group/${groupId}/item/${itemId}/readme`, - token, - { readme }, - )) as Promise>; - }; - - const createLink = async (otherGroupId: string, otherItemId: string) => { - return json(apiFetch( - 'POST', - `/api/group/${groupId}/item/${itemId}/link`, - token, - { otherGroupId, otherItemId }, - )) as Promise>; - }; - - const updateLinkStyle = async (otherGroupId: string, style: 'chip' | 'card') => { - return json(apiFetch( - 'PUT', - `/api/group/${groupId}/item/${itemId}/link`, - token, - { otherGroupId, style }, - )) as Promise>; - }; - - const removeLink = async (otherGroupId: string, otherItemId: string) => { - return json(apiFetch( - 'DELETE', - `/api/group/${groupId}/item/${itemId}/link`, - token, - { otherGroupId, otherItemId }, - )) as Promise>; - }; - - return { - /** - * Create a new item - * - * @param token The authentication token - * @param name The name of the item - * @param description The description of the item - */ - create, - /** - * Remove a item - * - * @param token The authentication token - */ - remove, - info: { - /** - * Return info on a particular item - * - * @returns info about the item - */ - get: getInfo, - /** - * Update info on a particular item - * - * @param info info about the item - */ - set: setInfo, - }, - readme: { - /** - * Returns the README.md of the given item - * - * @returns readme.md of item - */ - get: getReadme, - /** - * Updates the README.md of the given item - * - * @param token Auth token - * @param readme README.md contents - */ - set: setReadme, - }, - /** Manage links to other items */ - links: { - /** Create a link between this item and another item */ - create: createLink, - /** Remove a link between this item and another item */ - remove: removeLink, - /** Change the style for links to items in a group */ - style: updateLinkStyle, - } - }; - }; - - return { - all, - withId, - }; -} diff --git a/src/endpoints/index.ts b/src/endpoints/index.ts index ccc2c82..f818bc7 100644 --- a/src/endpoints/index.ts +++ b/src/endpoints/index.ts @@ -1,16 +1,15 @@ /** API endpoints */ +import type { ItemId } from '$lib/server/data/itemId'; import admin from './admin'; import debug from './debug'; -import group from './group'; -import readme from './readme'; +import item from './item'; /** Create an instance of the API client with the given token */ -export default function api(token?: string ) { +export default function api(token?: string) { return { admin: admin(token), debug: debug(token), - group: group(token), - readme: readme(token), + item: (itemId: ItemId) => item(token, itemId), /** Create a new API client with the given token */ withToken: (token: string | undefined) => api(token), /** The token currently being used for this API client */ diff --git a/src/endpoints/item.ts b/src/endpoints/item.ts new file mode 100644 index 0000000..fee5518 --- /dev/null +++ b/src/endpoints/item.ts @@ -0,0 +1,66 @@ +import type { ItemInfo } from '$lib/server/data/item'; +import { itemIdToUrl, type ItemId } from '$lib/server/data/itemId'; +import { apiFetch, payload } from './fetch'; + +export default function item(token: string | undefined, itemId: ItemId) { + const info = { + /** Get the `info.json` content of the given item. */ + get: async () => { + return apiFetch( + 'GET', + `/data/${itemIdToUrl(itemId)}/info.json`, + { token }, + ).json() as Promise; + }, + /** Create a new item with the given properties. */ + post: async (name: string, description: string) => { + return apiFetch( + 'POST', + `/data/${itemIdToUrl(itemId)}/info.json`, + { token, ...payload.json({ name, description }) }, + ).json(); + }, + /** Update the `info.json` of the given item. */ + put: async (info: ItemInfo) => { + return apiFetch( + 'PUT', + `/data/${itemIdToUrl(itemId)}/info.json`, + { token, ...payload.json(info) }, + ).json(); + }, + /** Delete the given item. */ + delete: async () => { + return apiFetch( + 'DELETE', + `/data/${itemIdToUrl(itemId)}/info.json`, + { token }, + ).json(); + }, + }; + + const readme = { + /** Get the `README.md` of the given item */ + get: async () => { + return apiFetch( + 'GET', + `/data/${itemIdToUrl(itemId)}/info.json`, + { token }, + ).text(); + }, + /** Update the `README.md` of the given item */ + put: async (readme: string) => { + return apiFetch( + 'PUT', + `/data/${itemIdToUrl(itemId)}/info.json`, + { token, ...payload.markdown(readme) }, + ).text(); + }, + }; + + return { + /** `info.json` of the item */ + info, + /** `README.md` of the item */ + readme, + }; +} diff --git a/src/endpoints/readme.ts b/src/endpoints/readme.ts deleted file mode 100644 index 3faf6bb..0000000 --- a/src/endpoints/readme.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { apiFetch, json } from './fetch'; - -export default function readme(token: string | undefined) { - const get = async () => { - return json(apiFetch( - 'GET', - '/api/readme', - token, - )) as Promise<{ readme: string }>; - }; - - const set = async (readme: string) => { - return json(apiFetch( - 'PUT', - '/api/readme', - token, - { readme }, - )) as Promise>; - }; - - return { - /** - * Get the readme. - * - * @returns the primary readme of the data repo - */ - get, - /** - * Set the readme - * - * @param token Authorization token - * @param readme new README file - */ - set, - }; -} diff --git a/src/lib/server/data/itemId.ts b/src/lib/server/data/itemId.ts index 0336841..93c5742 100644 --- a/src/lib/server/data/itemId.ts +++ b/src/lib/server/data/itemId.ts @@ -11,15 +11,21 @@ export function itemIdFromUrl(path: string): ItemId { /** Format the given ItemId for displaying to users */ export function formatItemId(itemId: ItemId): string { - const path = itemId.join('/'); + const path = itemIdToUrl(itemId); return `'${path ? path : '/'}'`; } +/** Update the ItemId to its URL path */ +export function itemIdToUrl(itemId: ItemId): string { + return itemId.join('/'); +} + /** Returns the ItemId for the parent of the given item */ export function itemParent(itemId: ItemId): ItemId { return itemId.slice(0, -1) } +/** Return whether the given ItemIds are equal */ export function itemIdsEqual(first: ItemId, second: ItemId): boolean { return zip(first, second).find(([a, b]) => a !== b) === undefined; } From 6ecca6dca4e7d4d087788b12c9f3616ff0d594eb Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Tue, 31 Dec 2024 13:59:53 +1100 Subject: [PATCH 011/149] Other general refactoring --- src/lib/blankConfig.ts | 16 -- src/lib/server/data/config.ts | 29 +-- src/lib/server/data/group.ts | 168 ------------- src/lib/server/data/index.ts | 104 +------- src/lib/server/data/itemOld.ts | 222 ------------------ src/lib/server/data/readme.ts | 30 --- src/lib/server/data/setup.ts | 34 ++- src/lib/server/data/text.ts | 25 ++ src/lib/server/git.ts | 1 + src/lib/server/index.ts | 10 +- src/lib/server/links.ts | 126 ---------- src/lib/server/util.ts | 8 + .../data/[...item]/info.json/+server.ts | 13 +- 13 files changed, 62 insertions(+), 724 deletions(-) delete mode 100644 src/lib/blankConfig.ts delete mode 100644 src/lib/server/data/group.ts delete mode 100644 src/lib/server/data/itemOld.ts delete mode 100644 src/lib/server/data/readme.ts create mode 100644 src/lib/server/data/text.ts delete mode 100644 src/lib/server/links.ts diff --git a/src/lib/blankConfig.ts b/src/lib/blankConfig.ts deleted file mode 100644 index ce82fb9..0000000 --- a/src/lib/blankConfig.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { version } from '$app/environment'; -import consts from './consts'; -import type { ConfigJson } from './server/data/config'; - -const blankConfig: ConfigJson = { - siteName: consts.APP_NAME, - siteShortName: consts.APP_NAME, - siteDescription: '', - siteKeywords: [], - siteIcon: null, - listedGroups: [], - color: '#ffaaff', - version, -}; - -export default blankConfig; diff --git a/src/lib/server/data/config.ts b/src/lib/server/data/config.ts index 88c73f3..58403f0 100644 --- a/src/lib/server/data/config.ts +++ b/src/lib/server/data/config.ts @@ -1,5 +1,5 @@ import { readFile, writeFile } from 'fs/promises'; -import { array, nullable, object, string, validate, type Infer } from 'superstruct'; +import { nullable, object, string, validate, type Infer } from 'superstruct'; import { getDataDir } from './dataDir'; import { version } from '$app/environment'; import { unsafeLoadConfig } from './migrations/unsafeLoad'; @@ -9,29 +9,8 @@ const CONFIG_JSON = () => `${getDataDir()}/config.json`; /** Validator for config.json file */ export const ConfigJsonStruct = object({ - /** Long-form name of the site, displayed in the navigator on the main page */ - siteName: string(), - /** Short-form name of the site, displayed in the navigator on other pages */ - siteShortName: string(), - /** - * Description of the site, used for SEO on the main page. This will appear - * as the description in web search results. - */ - siteDescription: string(), - /** Keywords of the site, used for SEO */ - siteKeywords: array(string()), /** Filename of icon to use for the site */ siteIcon: nullable(string()), - /** - * The groups to list on the main page, in the order in which they should - * appear. - * - * Any groups not within this array will still be accessible through their - * pages, but won't be shown on the main page. - */ - listedGroups: array(string()), - /** The default color to use for the site */ - color: string(), /** Version of server that last accessed the config.json */ version: string(), }); @@ -75,13 +54,7 @@ export async function setConfig(newConfig: ConfigJson) { /** Set up the default server configuration */ export async function initConfig() { await setConfig({ - siteName: 'My portfolio', - siteShortName: 'Portfolio', - siteDescription: 'View my portfolio', - siteKeywords: ['portfolio'], siteIcon: null, - listedGroups: [], - color: '#ffaaff', version, }); } diff --git a/src/lib/server/data/group.ts b/src/lib/server/data/group.ts deleted file mode 100644 index 35f1618..0000000 --- a/src/lib/server/data/group.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Access group data - */ - -import { mkdir, readdir, readFile, writeFile } from 'fs/promises'; -import { array, nullable, string, type, validate, type Infer } from 'superstruct'; -import { getDataDir } from './dataDir'; -import { rimraf } from 'rimraf'; -import formatTemplate from '../formatTemplate'; - -const DEFAULT_README = ` -# {{group}} - -{{description}} - -This is the \`README.md\` file for the group {{group}}. Go ahead and modify it to -tell everyone more about it. Is this for your projects? Your skills? Tools you -know how to use? -`; - -/** Brief info about a group */ -export const GroupInfoStruct = type({ - /** User-facing name of the group */ - name: string(), - - /** Short description of the group */ - description: string(), - - /** Description to use for the webpage of the group, used in SEO */ - pageDescription: string(), - - /** - * SEO keywords to use for this group. These are combined with the site - * keywords. - */ - keywords: array(string()), - - /** Color */ - color: string(), - - /** Icon to display in lists */ - icon: nullable(string()), - - /** Banner image to display on item page */ - banner: nullable(string()), - - // TODO: Support icons for groups here - - /** - * Groups whose items should be used for filtering on this group - */ - filterGroups: array(string()), - - /** - * Array of item IDs to display for this page. - */ - listedItems: array(string()), - - /** Array of item IDs to use as filters when this group is used as a filter for other group's items */ - filterItems: array(string()), -}); - -/** Brief info about a group */ -export type GroupInfo = Infer; - -/** - * Return the full list of groups. - * - * This includes groups not included in the main list. - */ -export async function listGroups(): Promise { - return (await readdir(getDataDir(), { withFileTypes: true })) - // Only keep directories - .filter(d => d.isDirectory()) - // .git isn't a valid group - .filter(d => d.name !== '.git') - .map(d => d.name); -} - -/** Return the full info about the group with the given ID */ -export async function getGroupInfo(groupId: string): Promise { - const data = await readFile( - `${getDataDir()}/${groupId}/info.json`, - { encoding: 'utf-8' } - ); - - // Validate data - const [err, parsed] = validate(JSON.parse(data), GroupInfoStruct); - if (err) { - console.log(`Error while parsing '${getDataDir()}/${groupId}/info.json'`); - console.error(err); - throw err; - } - - return parsed; -} - -/** Update the full info about the group with the given ID */ -export async function setGroupInfo(groupId: string, info: GroupInfo) { - await writeFile( - `${getDataDir()}/${groupId}/info.json`, - JSON.stringify(info, undefined, 2), - ); -} - -/** Returns the contents of the group's README.md */ -export async function getGroupReadme(groupId: string): Promise { - return readFile( - `${getDataDir()}/${groupId}/README.md`, - { encoding: 'utf-8' }, - ); -} - -/** Update the contents of the group's README.md */ -export async function setGroupReadme(groupId: string, readme: string) { - await writeFile( - `${getDataDir()}/${groupId}/README.md`, - readme, - ); -} - -/** Creates a new group with the given ID and name */ -export async function createGroup(id: string, name: string, description: string) { - await mkdir(`${getDataDir()}/${id}`); - - // If there is a description, add it to the readme text - const readme = formatTemplate(DEFAULT_README, [['group', name], ['description', description]]) - // If the description was empty, we'll end up with extra newlines -- get - // rid of them. - .replace('\n\n\n', ''); - - await setGroupInfo(id, { - name, - description, - pageDescription: '', - keywords: [name], - // TODO: Generate a random color for the new group - color: '#aa00aa', - icon: null, - banner: null, - filterGroups: [], - listedItems: [], - filterItems: [], - }); - await setGroupReadme(id, readme); -} - -/** Removes the group with the given ID */ -export async function deleteGroup(id: string) { - await rimraf(`${getDataDir()}/${id}`); -} - -/** - * Overall data for a group, comprised of the group's `info.json`, `README.md` - * and potentially other data as required. - */ -export type GroupData = { - info: GroupInfo, - readme: string, -} - -/** Return full data for the group */ -export async function getGroupData(id: string): Promise { - return { - info: await getGroupInfo(id), - readme: await getGroupReadme(id), - }; -} diff --git a/src/lib/server/data/index.ts b/src/lib/server/data/index.ts index 8ed2629..70d3c69 100644 --- a/src/lib/server/data/index.ts +++ b/src/lib/server/data/index.ts @@ -1,105 +1,3 @@ /** - * Overall data for the server. - * - * This loads all of the data into one giant object, with the idea of massively - * speeding up read operations. Since write operations are comparatively - * uncommon, it's fine if we just invalidate the entire data structure when - * one of those happens -- I may experiment with only invalidating parts of the - * data, or having the objects live in memory as references, but that is a lot - * of effort for very little gain. + * Data for the server. */ - -import { version } from '$app/environment'; -import semver from 'semver'; -import { getConfig, getConfigVersion, type ConfigJson } from './config'; -import { getGroupData, listGroups, type GroupData } from './group'; -import { getItemData, listItems, type ItemData } from './itemOld'; -import { invalidateLocalConfigCache } from './localConfig'; -import migrate from './migrations'; -import { getReadme } from './readme'; -import { invalidateAuthSecret } from '../auth/secret'; -import { invalidatePublicKey } from '../keys'; - -/** Public global data for the portfolio */ -export type PortfolioGlobals = { - config: ConfigJson, - readme: string, - groups: Record, - items: Record>, -} - -/** - * Load all portfolio data into memory. - * - * Note that this does not perform deep data validation (eg ensuring all name - * references are valid). - */ -async function loadPortfolioGlobals(): Promise { - // Check if a migration is needed - const dataVersion = await getConfigVersion(); - - if (semver.lt(dataVersion, version)) { - await migrate(dataVersion); - } else if (semver.gt(dataVersion, version)) { - throw new Error(`Data dir version is invalid (got ${dataVersion}, expected <= ${version})`); - } - - const config = await getConfig(); - const readme = await getReadme(); - - // Load list of groups - const groupIds = await listGroups(); - - // Load all group data - const groups: Record = Object.fromEntries(await Promise.all( - groupIds.map(async g => [g, await getGroupData(g)]) - )); - - // Load all item data - // Yucky nesting, but it should be somewhat parallel at least - // Basically, this is a nested version of the code for loading all group data - const items: Record> = Object.fromEntries(await Promise.all( - groupIds.map(async (g): Promise<[string, Record]> => [ - g, - Object.fromEntries(await Promise.all( - (await listItems(g)).map(async i => [i, await getItemData(g, i)]) - )), - ]) - )); - - return { - config, - readme, - groups, - items, - }; -} - -let portfolioGlobals: PortfolioGlobals | undefined; - -/** - * Return a reference to the portfolio globals present in-memory, or load it - * if it hasn't been loaded yet. - */ -export async function getPortfolioGlobals(): Promise { - if (portfolioGlobals) { - return portfolioGlobals; - } - - // console.log('Cache miss, reloading data'); - - portfolioGlobals = await loadPortfolioGlobals(); - return portfolioGlobals; -} - -/** - * Invalidate the cached data. This should be performed whenever the data has - * been modified. - */ -export function invalidatePortfolioGlobals() { - portfolioGlobals = undefined; - invalidateLocalConfigCache(); - invalidateAuthSecret(); - invalidatePublicKey(); - // console.log('Data invalidated'); -} diff --git a/src/lib/server/data/itemOld.ts b/src/lib/server/data/itemOld.ts deleted file mode 100644 index fb05d89..0000000 --- a/src/lib/server/data/itemOld.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * Access item data - */ - -import { mkdir, readdir, readFile, writeFile } from 'fs/promises'; -import { array, intersection, object, nullable, string, tuple, type, validate, type Infer, enums } from 'superstruct'; -import { getDataDir } from './dataDir'; -import { rimraf } from 'rimraf'; -import { RepoInfoStruct } from './itemRepo'; -import { PackageInfoStruct } from './itemPackage'; -import formatTemplate from '../formatTemplate'; - -const DEFAULT_README = ` -# {{item}} - -{{description}} - -This is the \`README.md\` file for the item {{item}}. Go ahead and modify it to -tell everyone more about it. Is it something you made, or something you use? -How does it demonstrate your abilities? -`; - -/** Brief info about an item */ -export const ItemInfoBriefStruct = type({ - /** User-facing name of the item */ - name: string(), - - /** Short description of the item */ - description: string(), - - /** Description to use for the webpage of the item, used in SEO */ - pageDescription: string(), - - /** - * SEO keywords to use for this group. These are combined with the site and - * group keywords. - */ - keywords: array(string()), - - /** Color */ - color: string(), - - /** Icon to display in lists */ - icon: nullable(string()), - - /** Banner image to display on item page */ - banner: nullable(string()), -}); - -/** Brief info about an item */ -export type ItemInfoBrief = Infer; - -export const LinkStyleStruct = enums(['chip', 'card']); - -/** - * Links (associations) with other items. - * - * Array of `[group, [...items]]` - * - * * Each `group` is a group ID - * * Each item in `items` is an item ID within that group - */ -export const LinksArray = array( - tuple([ - object({ - groupId: string(), - style: LinkStyleStruct, - title: string(), - }), - array(string()), - ]) -); - -/** Full information about an item */ -export const ItemInfoFullStruct = intersection([ - ItemInfoBriefStruct, - type({ - /** Links to other items */ - links: LinksArray, - - /** URLs associated with the label */ - urls: object({ - /** URL of the source repository of the label */ - repo: nullable(RepoInfoStruct), - - /** URL of the site demonstrating the label */ - site: nullable(string()), - - /** URL of the documentation site for the label */ - docs: nullable(string()), - }), - - /** Information about the package distribution of the label */ - package: nullable(PackageInfoStruct), - }), -]); - -/** Full information about an item */ -export type ItemInfoFull = Infer; - -/** - * Return the full list of items within a group. - * - * This includes items not included in the main list. - */ -export async function listItems(groupId: string): Promise { - return (await readdir(`${getDataDir()}/${groupId}`, { withFileTypes: true })) - // Only keep directories - .filter(d => d.isDirectory()) - .map(d => d.name); -} - -/** Return the full info about the item from the given group with the given ID */ -export async function getItemInfo(groupId: string, itemId: string): Promise { - const infoJsonPath = `${getDataDir()}/${groupId}/${itemId}/info.json`; - const data = await readFile( - infoJsonPath, - { encoding: 'utf-8' } - ); - - // Validate data - const [err, parsed] = validate(JSON.parse(data), ItemInfoFullStruct); - if (err) { - console.log(`Error while parsing '${infoJsonPath}'`); - console.error(err); - throw err; - } - - return parsed; -} - -/** Return the brief info about the item with the given ID */ -export async function getItemInfoBrief(groupId: string, itemId: string): Promise { - const info = await getItemInfo(groupId, itemId); - - return { - name: info.name, - description: info.description, - pageDescription: info.pageDescription, - keywords: info.keywords, - color: info.color, - icon: info.icon, - banner: info.banner, - }; -} - -/** Update the full info about the item with the given ID */ -export async function setItemInfo(groupId: string, itemId: string, info: ItemInfoFull) { - const infoJsonPath = `${getDataDir()}/${groupId}/${itemId}/info.json`; - await writeFile( - infoJsonPath, - JSON.stringify(info, undefined, 2), - ); -} - -/** Returns the contents of the item's README.md */ -export async function getItemReadme(groupId: string, itemId: string): Promise { - return readFile( - `${getDataDir()}/${groupId}/${itemId}/README.md`, - { encoding: 'utf-8' }, - ); -} - -/** Update the contents of the item's README.md */ -export async function setItemReadme(groupId: string, itemId: string, readme: string) { - await writeFile( - `${getDataDir()}/${groupId}/${itemId}/README.md`, - readme, - ); -} - -/** Creates a new item with the given ID and name */ -export async function createItem(groupId: string, itemId: string, name: string, description: string) { - await mkdir(`${getDataDir()}/${groupId}/${itemId}`); - - // If there is a description, add it to the readme text - const readme = formatTemplate(DEFAULT_README, [['item', name], ['description', description]]) - // If the description was empty, we'll end up with extra newlines -- get - // rid of them. - .replace('\n\n\n', ''); - - await setItemInfo(groupId, itemId, { - name, - description, - pageDescription: '', - keywords: [name], - // TODO: Generate a random color for the new item - color: '#aa00aa', - links: [], - urls: { - repo: null, - site: null, - docs: null - }, - package: null, - icon: null, - banner: null, - }); - await setItemReadme(groupId, itemId, readme); -} - -/** Removes the item with the given ID */ -export async function deleteItem(groupId: string, itemId: string) { - await rimraf(`${getDataDir()}/${groupId}/${itemId}`); -} - -/** - * Overall data for an item, comprised of the item's `info.json`, `README.md` - * and potentially other data as required. - */ -export type ItemData = { - info: ItemInfoFull, - readme: string, -} - -/** Return full data for the item */ -export async function getItemData(groupId: string, itemId: string) { - return { - info: await getItemInfo(groupId, itemId), - readme: await getItemReadme(groupId, itemId), - }; -} diff --git a/src/lib/server/data/readme.ts b/src/lib/server/data/readme.ts deleted file mode 100644 index 659a191..0000000 --- a/src/lib/server/data/readme.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { readFile, writeFile } from 'fs/promises'; -import { getDataDir } from './dataDir'; - -const DEFAULT_README = ` -# Your portfolio - -Welcome to your brand new data-driven portfolio website! You're reading the -site's \`README.md\`, which is shown as the site's landing page. - -Go ahead and edit this file to give your users a nice landing page. You may -want to [learn Markdown](https://www.markdownguide.org/basic-syntax/). -`.trimStart(); - -/** Path to README.md */ -const README_MD = () => `${getDataDir()}/README.md`; - -/** Return the readme file */ -export async function getReadme(): Promise { - return readFile(README_MD(), { encoding: 'utf-8' }); -} - -/** Update the readme file */ -export async function setReadme(newReadme: string) { - await writeFile(README_MD(), newReadme); -} - -/** Set up the default server README */ -export async function initReadme() { - await setReadme(DEFAULT_README); -} diff --git a/src/lib/server/data/setup.ts b/src/lib/server/data/setup.ts index dd8d2c7..ee6d0c8 100644 --- a/src/lib/server/data/setup.ts +++ b/src/lib/server/data/setup.ts @@ -2,13 +2,15 @@ * Code for setting up public data. */ -import { mkdir } from 'fs/promises'; +import { mkdir, writeFile } from 'fs/promises'; import { runSshKeyscan, setupGitRepo, urlRequiresSsh } from '../git'; import { dataIsSetUp, getDataDir } from './dataDir'; import { initConfig } from './config'; -import { initReadme } from './readme'; -import { getPortfolioGlobals, invalidatePortfolioGlobals } from '.'; import { error } from '@sveltejs/kit'; +import { itemPath, setItemInfo } from './item'; +import { randomColor } from '$lib/color'; +import { LANDING_README } from './text'; +import consts from '$lib/consts'; /** * Set up the data directory. @@ -27,9 +29,6 @@ export async function setupData(repoUrl?: string, branch?: string): Promise { }); - // Currently, gitignore is not needed, since private data is now stored - // separately - // await setupGitignore(); } /** @@ -42,14 +41,27 @@ export async function setupData(repoUrl?: string, branch?: string): Promise { - return fs.access(path, fs.constants.F_OK) - .then(() => true) - .catch(() => false); -} +/** Server scripts */ diff --git a/src/lib/server/links.ts b/src/lib/server/links.ts deleted file mode 100644 index 6883d79..0000000 --- a/src/lib/server/links.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Code for managing links between items. - */ - -import { error } from '@sveltejs/kit'; -import type { PortfolioGlobals } from './data'; -import { setItemInfo } from './data/itemOld'; -import { itemHasLink } from '../links'; - -/** Add a link from groupId/itemId to otherGroupId/otherItemId */ -export async function createLink( - globals: PortfolioGlobals, - groupId: string, - itemId: string, - otherGroupId: string, - otherItemId: string, -) { - const item = globals.items[groupId][itemId].info; - - // First hunt to see if it is already there - for (const [{ groupId: linkedGroup }, items] of item.links) { - if (linkedGroup === otherGroupId) { - // Only add it if it isn't present - if (!items.includes(otherItemId)) { - items.push(otherItemId); - } - await setItemInfo(groupId, itemId, item); - return; - } - } - // If we reach this point, no links to that group have been made yet, create - // one in the default style (chip) - item.links.push([ - { - groupId: otherGroupId, - style: 'chip', - title: globals.groups[otherGroupId].info.name - }, - [otherItemId], - ]); - - await setItemInfo(groupId, itemId, item); -} - -/** Change the display style for links from the given item to another group */ -export async function changeLinkStyle( - globals: PortfolioGlobals, - groupId: string, - itemId: string, - otherGroupId: string, - // FIXME: Stop hard-coding this type - style: 'chip' | 'card', -) { - const item = globals.items[groupId][itemId].info; - // Find linked group and update style - // This code is sorta yucky imo - let foundMatch = false; - for (const [linkInfo, /* items */] of item.links) { - if (linkInfo.groupId === otherGroupId) { - linkInfo.style = style; - foundMatch = true; - break; - } - } - if (!foundMatch) { - error(400, `Group ${otherGroupId} has not been linked to in this item`); - } - - await setItemInfo(groupId, itemId, item); -} - -/** Remove the link from groupId/itemId to otherGroupId/otherItemId */ -export async function removeLinkFromItem( - globals: PortfolioGlobals, - groupId: string, - itemId: string, - otherGroupId: string, - otherItemId: string, -) { - const item = globals.items[groupId][itemId].info; - for (const [{ groupId: linkedGroup }, items] of item.links) { - if (linkedGroup === otherGroupId) { - // Only remove it if it is present - if (items.includes(otherItemId)) { - items.splice(items.indexOf(otherItemId), 1); - } - // Now if the linked group is empty, remove it from the item links - if (!items.length) { - // This feels yucky but I can't think of a prettier way of doing it - // without making a copy - item.links.splice( - item.links.findIndex(l => l[0].groupId === otherGroupId), - 1, - ); - } - await setItemInfo(groupId, itemId, item); - return; - } - } -} - -/** Removes all links that point to the given item */ -export async function removeAllLinksToItem( - globals: PortfolioGlobals, - groupId: string, - itemId: string, -) { - // We can't reliably do a reverse lookup, as it is possible to create links - // that aren't two-way - // Instead, we need to manually search through all items - const itemsToUnlink: [string, string][] = []; - - for (const otherGroupId of Object.keys(globals.groups)) { - for (const otherItemId of Object.keys(globals.items[otherGroupId])) { - const otherItem = globals.items[otherGroupId][otherItemId]; - if (itemHasLink(otherItem.info, groupId, itemId)) { - itemsToUnlink.push([otherGroupId, otherItemId]); - } - } - } - - await Promise.all( - itemsToUnlink.map(([otherGroup, otherItem]) => - removeLinkFromItem(globals, otherGroup, otherItem, groupId, itemId)) - ); -} diff --git a/src/lib/server/util.ts b/src/lib/server/util.ts index bbbdf77..2955dc5 100644 --- a/src/lib/server/util.ts +++ b/src/lib/server/util.ts @@ -1,5 +1,6 @@ import { error } from '@sveltejs/kit'; import { create, type Struct } from 'superstruct'; +import fs from 'fs/promises'; /** * A wrapper around superstruct's assert, making it async to make error @@ -18,3 +19,10 @@ export function applyStruct( error(400, `${e}`); } } + +/** Returns whether a file exists at the given path */ +export async function fileExists(path: string): Promise { + return fs.access(path, fs.constants.F_OK) + .then(() => true) + .catch(() => false); +} diff --git a/src/routes/data/[...item]/info.json/+server.ts b/src/routes/data/[...item]/info.json/+server.ts index a2912e6..083dfba 100644 --- a/src/routes/data/[...item]/info.json/+server.ts +++ b/src/routes/data/[...item]/info.json/+server.ts @@ -7,6 +7,7 @@ import { validateTokenFromRequest } from '$lib/server/auth/tokens.js'; import { applyStruct } from '$lib/server/util.js'; import { validateName } from '$lib/validate.js'; import formatTemplate from '$lib/server/formatTemplate.js'; +import { ITEM_README } from '$lib/server/data/text.js'; /** * API endpoints for accessing info.json @@ -25,16 +26,6 @@ const NewItemOptions = object({ description: string(), }); -const DEFAULT_README = ` -# {{item}} - -{{description}} - -This is the \`README.md\` file for the item {{item}}. Go ahead and modify it to -tell everyone more about it. Is it something you made, or something you use? -How does it demonstrate your abilities? -`; - /** Create new item */ export async function POST(req: Request) { await validateTokenFromRequest(req); @@ -72,7 +63,7 @@ export async function POST(req: Request) { // Set info.json await setItemInfo(item, itemInfo); // Set README.md - const readme = formatTemplate(DEFAULT_README, { item: name, description }) + const readme = formatTemplate(ITEM_README, { item: name, description }) // If the description was empty, we'll end up with extra newlines -- get // rid of them. .replace('\n\n\n', ''); From 5dc9e2a6a07fab0b425369d2f27bc89ed7769c9d Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Tue, 31 Dec 2024 14:11:57 +1100 Subject: [PATCH 012/149] More refactor-y stuff --- src/lib/server/data/dataDir.ts | 2 +- src/routes/[group]/+page.server.ts | 16 - src/routes/[group]/+page.svelte | 252 ------------- src/routes/[group]/[item]/+page.server.ts | 21 -- src/routes/[group]/[item]/+page.svelte | 335 ------------------ src/routes/[group]/[item]/[file]/+server.ts | 32 -- src/routes/api/admin/config/+server.ts | 23 +- src/routes/api/admin/data/refresh/+server.ts | 8 +- src/routes/api/admin/git/+server.ts | 6 +- src/routes/api/admin/git/commit/+server.ts | 10 +- src/routes/api/admin/git/init/+server.ts | 6 +- src/routes/api/admin/git/pull/+server.ts | 8 +- src/routes/api/admin/git/push/+server.ts | 8 +- src/routes/api/admin/keys/+server.ts | 8 +- src/routes/api/admin/keys/generate/+server.ts | 6 +- src/routes/api/debug/clear/+server.ts | 2 - src/routes/api/debug/data/refresh/+server.ts | 7 +- src/routes/api/group/+server.ts | 11 - src/routes/api/group/[groupId]/+server.ts | 101 ------ .../api/group/[groupId]/item/+server.ts | 1 - .../group/[groupId]/item/[itemId]/+server.ts | 107 ------ .../[groupId]/item/[itemId]/link/+server.ts | 108 ------ .../[groupId]/item/[itemId]/readme/+server.ts | 40 --- .../api/group/[groupId]/readme/+server.ts | 41 --- src/routes/api/readme/+server.ts | 29 -- src/routes/favicon/+server.ts | 6 +- 26 files changed, 50 insertions(+), 1144 deletions(-) delete mode 100644 src/routes/[group]/+page.server.ts delete mode 100644 src/routes/[group]/+page.svelte delete mode 100644 src/routes/[group]/[item]/+page.server.ts delete mode 100644 src/routes/[group]/[item]/+page.svelte delete mode 100644 src/routes/[group]/[item]/[file]/+server.ts delete mode 100644 src/routes/api/group/+server.ts delete mode 100644 src/routes/api/group/[groupId]/+server.ts delete mode 100644 src/routes/api/group/[groupId]/item/+server.ts delete mode 100644 src/routes/api/group/[groupId]/item/[itemId]/+server.ts delete mode 100644 src/routes/api/group/[groupId]/item/[itemId]/link/+server.ts delete mode 100644 src/routes/api/group/[groupId]/item/[itemId]/readme/+server.ts delete mode 100644 src/routes/api/group/[groupId]/readme/+server.ts delete mode 100644 src/routes/api/readme/+server.ts diff --git a/src/lib/server/data/dataDir.ts b/src/lib/server/data/dataDir.ts index 11e11ce..aab32b0 100644 --- a/src/lib/server/data/dataDir.ts +++ b/src/lib/server/data/dataDir.ts @@ -1,6 +1,6 @@ import path from 'path'; import simpleGit, { CheckRepoActions } from 'simple-git'; -import { fileExists } from '..'; +import { fileExists } from '../util'; /** Returns the path to the data repository */ export function getDataDir(): string { diff --git a/src/routes/[group]/+page.server.ts b/src/routes/[group]/+page.server.ts deleted file mode 100644 index 1799e4e..0000000 --- a/src/routes/[group]/+page.server.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { error } from '@sveltejs/kit'; -import { getPortfolioGlobals } from '$lib/server'; -import { isRequestAuthorized } from '$lib/server/auth/tokens'; - -export async function load(req: import('./$types.js').RequestEvent) { - const globals = await getPortfolioGlobals(); - // Give a 404 if the group doesn't exist - if (!(req.params.group in globals.groups)) { - error(404, `Group '${req.params.group}' does not exist`); - } - return { - groupId: req.params.group, - globals, - loggedIn: await isRequestAuthorized(req) - }; -} diff --git a/src/routes/[group]/+page.svelte b/src/routes/[group]/+page.svelte deleted file mode 100644 index 4a08565..0000000 --- a/src/routes/[group]/+page.svelte +++ /dev/null @@ -1,252 +0,0 @@ - - - - - {groupData.info.name} - {data.globals.config.siteShortName} - - - - - - - - - - -
- -
-
- finishEditing(true)} - /> -
-
- - - {#if !editing} -
- { - filterSelections = options; - }} - onclick={() => {}} - /> -
- {:else} -
-

Groups used for filtering

- {#each filterGroups as [filterGroup, selected]} - { - // Toggle filtering for this group - const g = filterGroups.find(([g]) => g === filterGroup); - if (g) { - g[1] = !selected; - } - filterGroups = [...filterGroups]; - }} - /> - {/each} -
- {/if} - - -
- { - if (editing) { - shownItems = shownItems.filter((i) => i !== itemId); - hiddenItems = [...hiddenItems, itemId]; - } - }} - /> -
- {#if editing} -
-

Hidden items

- { - shownItems = [...shownItems, itemId]; - hiddenItems = hiddenItems.filter((i) => i !== itemId); - }} - /> -
- {/if} -
- - diff --git a/src/routes/[group]/[item]/+page.server.ts b/src/routes/[group]/[item]/+page.server.ts deleted file mode 100644 index 06bfcef..0000000 --- a/src/routes/[group]/[item]/+page.server.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { error } from '@sveltejs/kit'; -import { getPortfolioGlobals } from '$lib/server'; -import { isRequestAuthorized } from '$lib/server/auth/tokens'; - -export async function load(req: import('./$types.js').RequestEvent) { - const globals = await getPortfolioGlobals(); - // Give a 404 if the group doesn't exist - if (!(req.params.group in globals.groups)) { - error(404, `Group '${req.params.group}' does not exist`); - } - // And also if the item doesn't exist - if (!(req.params.item in globals.items[req.params.group])) { - error(404, `Item '${req.params.item}' does not exist within group '${req.params.group}`); - } - return { - groupId: req.params.group, - itemId: req.params.item, - globals, - loggedIn: await isRequestAuthorized(req), - }; -} diff --git a/src/routes/[group]/[item]/+page.svelte b/src/routes/[group]/[item]/+page.svelte deleted file mode 100644 index 952d823..0000000 --- a/src/routes/[group]/[item]/+page.svelte +++ /dev/null @@ -1,335 +0,0 @@ - - - - - {itemData.info.name} - {groupData.info.name} - {data.globals.config - .siteShortName} - - - - - - - - - - -
- - {#if itemData.info.banner} - - {/if} -
- finishEditing(true)} - /> - - -
- - -
- - {#if itemData.info.urls.site} - - {#snippet icon()} - - {/snippet} - - {/if} - {#if itemData.info.urls.docs} - - {#snippet icon()} - - {/snippet} - - {/if} - {#if itemData.info.urls.repo} - - {/if} - {#if itemData.info.package} - - {/if} - -
- - - - - - -
- - diff --git a/src/routes/[group]/[item]/[file]/+server.ts b/src/routes/[group]/[item]/[file]/+server.ts deleted file mode 100644 index e55c0e5..0000000 --- a/src/routes/[group]/[item]/[file]/+server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import sanitize from 'sanitize-filename'; -import fs from 'fs/promises'; -import { error } from '@sveltejs/kit'; -import mime from 'mime-types'; -import { getDataDir } from '$lib/server/data/dataDir'; - -export async function GET(req: import('./$types.js').RequestEvent) { - const { group, item, file } = req.params; - - // Sanitise the filename to prevent unwanted access to the server's filesystem - const filename = sanitize(file); - - // Get the path of the file to serve - const filePath = `${getDataDir()}/${group}/${item}/${filename}`; - - // Ensure file exists - await fs.access(filePath, fs.constants.R_OK).catch(() => error(404)); - - // Read the contents of the file - const content = await fs.readFile(filePath); - let mimeType = mime.contentType(filename); - if (!mimeType) { - mimeType = 'text/plain'; - } - - req.setHeaders({ - 'Content-Type': mimeType, - 'Content-Length': content.length.toString(), - }); - - return new Response(content); -} diff --git a/src/routes/api/admin/config/+server.ts b/src/routes/api/admin/config/+server.ts index a8316f1..fa4b376 100644 --- a/src/routes/api/admin/config/+server.ts +++ b/src/routes/api/admin/config/+server.ts @@ -1,21 +1,24 @@ import { error, json } from '@sveltejs/kit'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; -import { ConfigJsonStruct, setConfig } from '$lib/server/data/config'; +import { ConfigJsonStruct, getConfig, setConfig } from '$lib/server/data/config'; import { validate } from 'superstruct'; import { version } from '$app/environment'; -import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/data/index'; import fs from 'fs/promises'; -import { getDataDir } from '$lib/server/data/dataDir'; +import { dataIsSetUp, getDataDir } from '$lib/server/data/dataDir'; export async function GET({ request, cookies }: import('./$types.js').RequestEvent) { - const data = await getPortfolioGlobals().catch(e => error(400, e)); + if (await dataIsSetUp()) { + error(400, 'Data is not set up'); + } await validateTokenFromRequest({ request, cookies }); - return json(data.config, { status: 200 }); + return json(getConfig(), { status: 200 }); } export async function PUT({ request, cookies }: import('./$types.js').RequestEvent) { - const globals = await getPortfolioGlobals().catch(e => error(400, e)); + if (await dataIsSetUp()) { + error(400, 'Data is not set up'); + } await validateTokenFromRequest({ request, cookies }); const [err, newConfig] = validate(await request.json(), ConfigJsonStruct); @@ -31,13 +34,6 @@ export async function PUT({ request, cookies }: import('./$types.js').RequestEve ); } - // Check for invalid listedGroups - for (const groupId of newConfig.listedGroups) { - if (!(groupId in globals.groups)) { - error(400, `Group '${groupId}' does not exist`); - } - } - // Check for invalid icon if (newConfig.siteIcon) { await fs.access(`${getDataDir()}/${newConfig.siteIcon}`, fs.constants.R_OK) @@ -45,7 +41,6 @@ export async function PUT({ request, cookies }: import('./$types.js').RequestEve } await setConfig(newConfig); - invalidatePortfolioGlobals(); return json({}, { status: 200 }); } diff --git a/src/routes/api/admin/data/refresh/+server.ts b/src/routes/api/admin/data/refresh/+server.ts index 5f1ee10..ef0bb3f 100644 --- a/src/routes/api/admin/data/refresh/+server.ts +++ b/src/routes/api/admin/data/refresh/+server.ts @@ -1,12 +1,12 @@ import { validateTokenFromRequest } from '$lib/server/auth/tokens'; -import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/index'; +import { dataIsSetUp } from '$lib/server/data/dataDir.js'; import { error, json } from '@sveltejs/kit'; export async function POST({ request, cookies }: import('./$types.js').RequestEvent) { - await getPortfolioGlobals().catch(e => error(400, e)); + if (await dataIsSetUp()) { + error(400, 'Data is not set up'); + } await validateTokenFromRequest({ request, cookies }); - invalidatePortfolioGlobals(); - return json({}, { status: 200 }); } diff --git a/src/routes/api/admin/git/+server.ts b/src/routes/api/admin/git/+server.ts index 92abcc0..233e33b 100644 --- a/src/routes/api/admin/git/+server.ts +++ b/src/routes/api/admin/git/+server.ts @@ -1,11 +1,13 @@ import { error, json } from '@sveltejs/kit'; import { dataDirUsesGit } from '$lib/server/data/dataDir'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; -import { getPortfolioGlobals } from '$lib/server/data/index'; +import { dataIsSetUp } from '$lib/server/data/dataDir.js'; import { getRepoStatus } from '$lib/server/git'; export async function GET({ request, cookies }: import('./$types.js').RequestEvent) { - await getPortfolioGlobals().catch(e => error(400, e)); + if (await dataIsSetUp()) { + error(400, 'Data is not set up'); + } await validateTokenFromRequest({ request, cookies }); if (!await dataDirUsesGit()) { diff --git a/src/routes/api/admin/git/commit/+server.ts b/src/routes/api/admin/git/commit/+server.ts index 3ee84f4..c9d75a8 100644 --- a/src/routes/api/admin/git/commit/+server.ts +++ b/src/routes/api/admin/git/commit/+server.ts @@ -1,12 +1,14 @@ +import { object, string, validate } from 'superstruct'; +import { error, json } from '@sveltejs/kit'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { dataDirUsesGit } from '$lib/server/data/dataDir'; import { commit, getRepoStatus } from '$lib/server/git'; -import { getPortfolioGlobals } from '$lib/server/index'; -import { error, json } from '@sveltejs/kit'; -import { object, string, validate } from 'superstruct'; +import { dataIsSetUp } from '$lib/server/data/dataDir.js'; export async function POST({ request, cookies }: import('./$types.js').RequestEvent) { - await getPortfolioGlobals().catch(e => error(400, e)); + if (await dataIsSetUp()) { + error(400, 'Data is not set up'); + } await validateTokenFromRequest({ request, cookies }); if (!await dataDirUsesGit()) { diff --git a/src/routes/api/admin/git/init/+server.ts b/src/routes/api/admin/git/init/+server.ts index faa980c..b5f69f3 100644 --- a/src/routes/api/admin/git/init/+server.ts +++ b/src/routes/api/admin/git/init/+server.ts @@ -1,12 +1,14 @@ import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { dataDirUsesGit } from '$lib/server/data/dataDir'; import { getRepoStatus, initRepo } from '$lib/server/git'; -import { getPortfolioGlobals } from '$lib/server/index'; +import { dataIsSetUp } from '$lib/server/data/dataDir.js'; import { error, json } from '@sveltejs/kit'; import { object, string, validate } from 'superstruct'; export async function POST({ request, cookies }: import('./$types.js').RequestEvent) { - await getPortfolioGlobals().catch(e => error(400, e)); + if (await dataIsSetUp()) { + error(400, 'Data is not set up'); + } await validateTokenFromRequest({ request, cookies }); if (await dataDirUsesGit()) { diff --git a/src/routes/api/admin/git/pull/+server.ts b/src/routes/api/admin/git/pull/+server.ts index 69cbec6..b1d2c0e 100644 --- a/src/routes/api/admin/git/pull/+server.ts +++ b/src/routes/api/admin/git/pull/+server.ts @@ -1,11 +1,13 @@ import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { dataDirUsesGit } from '$lib/server/data/dataDir'; import { getRepoStatus, pull } from '$lib/server/git'; -import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/index'; +import { dataIsSetUp } from '$lib/server/data/dataDir.js'; import { error, json } from '@sveltejs/kit'; export async function POST({ request, cookies }: import('./$types.js').RequestEvent) { - await getPortfolioGlobals().catch(e => error(400, e)); + if (await dataIsSetUp()) { + error(400, 'Data is not set up'); + } await validateTokenFromRequest({ request, cookies }); if (!await dataDirUsesGit()) { @@ -13,7 +15,5 @@ export async function POST({ request, cookies }: import('./$types.js').RequestEv } await pull(); - - invalidatePortfolioGlobals(); return json(getRepoStatus(), { status: 200 }); } diff --git a/src/routes/api/admin/git/push/+server.ts b/src/routes/api/admin/git/push/+server.ts index 9a08a7e..7735c6d 100644 --- a/src/routes/api/admin/git/push/+server.ts +++ b/src/routes/api/admin/git/push/+server.ts @@ -1,11 +1,13 @@ import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { dataDirUsesGit } from '$lib/server/data/dataDir'; import { getRepoStatus, push } from '$lib/server/git.js'; -import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/index'; +import { dataIsSetUp } from '$lib/server/data/dataDir.js'; import { error, json } from '@sveltejs/kit'; export async function POST({ request, cookies }: import('./$types.js').RequestEvent) { - await getPortfolioGlobals().catch(e => error(400, e)); + if (await dataIsSetUp()) { + error(400, 'Data is not set up'); + } await validateTokenFromRequest({ request, cookies }); if (!await dataDirUsesGit()) { @@ -13,7 +15,5 @@ export async function POST({ request, cookies }: import('./$types.js').RequestEv } await push(); - - invalidatePortfolioGlobals(); return json(await getRepoStatus(), { status: 200 }); } diff --git a/src/routes/api/admin/keys/+server.ts b/src/routes/api/admin/keys/+server.ts index 600f883..5b52894 100644 --- a/src/routes/api/admin/keys/+server.ts +++ b/src/routes/api/admin/keys/+server.ts @@ -1,9 +1,9 @@ +import { error, json } from '@sveltejs/kit'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { authIsSetUp } from '$lib/server/data/dataDir'; -import { getLocalConfig } from '$lib/server/data/localConfig.js'; -import { fileExists } from '$lib/server/index.js'; -import { disableKey, getPublicKey, setKeyPath } from '$lib/server/keys.js'; -import { error, json } from '@sveltejs/kit'; +import { getLocalConfig } from '$lib/server/data/localConfig'; +import { fileExists } from '$lib/server/util'; +import { disableKey, getPublicKey, setKeyPath } from '$lib/server/keys'; /** Return the current public key */ export async function GET(req: import('./$types.js').RequestEvent) { diff --git a/src/routes/api/admin/keys/generate/+server.ts b/src/routes/api/admin/keys/generate/+server.ts index e916e11..9fc5643 100644 --- a/src/routes/api/admin/keys/generate/+server.ts +++ b/src/routes/api/admin/keys/generate/+server.ts @@ -1,8 +1,8 @@ +import { error, json } from '@sveltejs/kit'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { authIsSetUp } from '$lib/server/data/dataDir'; -import { getLocalConfig } from '$lib/server/data/localConfig.js'; -import { generateKey } from '$lib/server/keys.js'; -import { error, json } from '@sveltejs/kit'; +import { getLocalConfig } from '$lib/server/data/localConfig'; +import { generateKey } from '$lib/server/keys'; /** Generate an SSH key */ export async function POST(req: import('./$types.js').RequestEvent) { diff --git a/src/routes/api/debug/clear/+server.ts b/src/routes/api/debug/clear/+server.ts index a7ed95b..3bda6f5 100644 --- a/src/routes/api/debug/clear/+server.ts +++ b/src/routes/api/debug/clear/+server.ts @@ -1,6 +1,5 @@ import { dev } from '$app/environment'; import { getDataDir, getPrivateDataDir } from '$lib/server/data/dataDir'; -import { invalidatePortfolioGlobals } from '$lib/server/data/index'; import { error, json } from '@sveltejs/kit'; import { rimraf } from 'rimraf'; @@ -9,7 +8,6 @@ export async function DELETE({ cookies }: import('./$types.js').RequestEvent) { // Delete data directory await rimraf(getDataDir()); await rimraf(getPrivateDataDir()); - invalidatePortfolioGlobals(); // Also remove token from their cookies cookies.delete('token', { path: '/' }); diff --git a/src/routes/api/debug/data/refresh/+server.ts b/src/routes/api/debug/data/refresh/+server.ts index e15b7cb..bb4aef2 100644 --- a/src/routes/api/debug/data/refresh/+server.ts +++ b/src/routes/api/debug/data/refresh/+server.ts @@ -5,12 +5,13 @@ * POST /api/admin/data/refresh, but without authentication required. */ import { dev } from '$app/environment'; -import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/index'; +// import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/index'; import { error, json } from '@sveltejs/kit'; export async function POST() { if (!dev) error(404); - await getPortfolioGlobals().catch(e => error(400, e)); - invalidatePortfolioGlobals(); + // await getPortfolioGlobals().catch(e => error(400, e)); + // invalidatePortfolioGlobals(); + await Promise.resolve(); return json({}, { status: 200 }); } diff --git a/src/routes/api/group/+server.ts b/src/routes/api/group/+server.ts deleted file mode 100644 index 657f90e..0000000 --- a/src/routes/api/group/+server.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { error, json } from '@sveltejs/kit'; -import { getPortfolioGlobals } from '$lib/server/data/index'; - -export async function GET() { - const data = await getPortfolioGlobals().catch(e => error(400, e)); - - return json( - Object.fromEntries(Object.entries(data.groups).map(([id, groupData]) => [id, groupData.info])), - { status: 200 }, - ); -} diff --git a/src/routes/api/group/[groupId]/+server.ts b/src/routes/api/group/[groupId]/+server.ts deleted file mode 100644 index 4ca3bc6..0000000 --- a/src/routes/api/group/[groupId]/+server.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { error, json } from '@sveltejs/kit'; -import { createGroup, deleteGroup, GroupInfoStruct, setGroupInfo } from '$lib/server/data/group'; -import { validateTokenFromRequest } from '$lib/server/auth/tokens'; -import { object, string, validate } from 'superstruct'; -import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/data/index'; -import { validateId, validateName } from '$lib/validate.js'; -import { removeAllLinksToItem } from '$lib/server/links'; -import { setConfig } from '$lib/server/data/config'; - -export async function GET({ params }: import('./$types.js').RequestEvent) { - const groupId = params.groupId; - - const data = await getPortfolioGlobals().catch(e => error(400, e)); - - try { - return json(data.groups[groupId].info, { status: 200 }); - } catch { - // Catch "cannot read properties of undefined" - return error(404, `Group with ID ${groupId} doesn't exist`); - } -} - -export async function POST({ params, request, cookies }: import('./$types.js').RequestEvent) { - const data = await getPortfolioGlobals().catch(e => error(400, e)); - await validateTokenFromRequest({ request, cookies }); - - // Validate group ID - const groupId = validateId('Group ID', params.groupId); - - const [err, body] = validate(await request.json(), object({ name: string(), description: string() })); - if (err) { - return error(400, err); - } - const { name, description } = body; - validateName(name); - - if (data.groups[groupId]) { - return error(400, `Group with ID ${groupId} already exists`); - } - - await createGroup(groupId, name, description); - data.config.listedGroups.push(groupId); - await setConfig(data.config); - invalidatePortfolioGlobals(); - - return json({}, { status: 200 }); -} - -export async function PUT({ params, request, cookies }: import('./$types.js').RequestEvent) { - const data = await getPortfolioGlobals().catch(e => error(400, e)); - await validateTokenFromRequest({ request, cookies }); - - const groupId = params.groupId; - - if (!data.groups[groupId]) { - return error(404, `Group with ID ${groupId} doesn't exist`); - } - - const [err, info] = validate(await request.json(), GroupInfoStruct); - - if (err) { - return error(400, err); - } - - // Validate name - validateName(info.name); - - // Check for invalid listedItems - for (const itemId of info.listedItems) { - if (!(itemId in data.items[groupId])) { - error(400, `Item '${itemId}' does not exist in group '${groupId}'`); - } - } - - // TODO: Other validation - - await setGroupInfo(groupId, info); - invalidatePortfolioGlobals(); - - return json({}, { status: 200 }); -} - -export async function DELETE({ params, request, cookies }: import('./$types.js').RequestEvent) { - const data = await getPortfolioGlobals().catch(e => error(400, e)); - await validateTokenFromRequest({ request, cookies }); - - const groupId = params.groupId; - - if (!data.groups[groupId]) { - return error(404, `Group with ID ${groupId} doesn't exist`); - } - - // Remove all links to each item in the group - await Promise.all(Object.keys(data.items[groupId]).map( - itemId => removeAllLinksToItem(data, groupId, itemId))); - - // Now delete the group - await deleteGroup(groupId); - invalidatePortfolioGlobals(); - return json({}, { status: 200 }); -} diff --git a/src/routes/api/group/[groupId]/item/+server.ts b/src/routes/api/group/[groupId]/item/+server.ts deleted file mode 100644 index ca6754e..0000000 --- a/src/routes/api/group/[groupId]/item/+server.ts +++ /dev/null @@ -1 +0,0 @@ -// TODO: All items diff --git a/src/routes/api/group/[groupId]/item/[itemId]/+server.ts b/src/routes/api/group/[groupId]/item/[itemId]/+server.ts deleted file mode 100644 index c68d4c1..0000000 --- a/src/routes/api/group/[groupId]/item/[itemId]/+server.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { error, json } from '@sveltejs/kit'; -import { getGroupInfo, setGroupInfo } from '$lib/server/data/group'; -import { validateTokenFromRequest } from '$lib/server/auth/tokens'; -import { object, string, validate } from 'superstruct'; -import { createItem, setItemInfo, ItemInfoFullStruct, deleteItem } from '$lib/server/data/itemOld.js'; -import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/data/index'; -import { validateId, validateName } from '$lib/validate.js'; -import { removeAllLinksToItem } from '$lib/server/links'; - -export async function GET({ params }: import('./$types.js').RequestEvent) { - const data = await getPortfolioGlobals().catch(e => error(400, e)); - - const { groupId, itemId } = params; - - try { - return json(data.items[groupId][itemId].info, { status: 200 }); - } catch (e) { - return error(404, `Item at ID ${groupId}/item/${itemId} doesn't exist\n${e}`); - } -} - -export async function POST({ params, request, cookies }: import('./$types.js').RequestEvent) { - const data = await getPortfolioGlobals().catch(e => error(400, e)); - await validateTokenFromRequest({ request, cookies }); - - // Validate group ID - const { groupId, itemId } = params; - - // Ensure group exists - await getGroupInfo(groupId).catch(e => error(404, e)); - - validateId('Item ID', itemId); - - const [err, body] = validate(await request.json(), object({ name: string(), description: string() })); - if (err) { - return error(400, err); - } - const { name, description } = body; - - // Validate name - validateName(name); - - if (data.items[groupId]?.[itemId]) { - return error(400, `Group with ID ${groupId} already exists`); - } - - await createItem(groupId, itemId, name, description); - - const groupInfo = data.groups[groupId].info; - groupInfo.listedItems.push(itemId); - groupInfo.filterItems.push(itemId); - await setGroupInfo(groupId, groupInfo); - invalidatePortfolioGlobals(); - - return json({}, { status: 200 }); -} - -export async function PUT({ params, request, cookies }: import('./$types.js').RequestEvent) { - const data = await getPortfolioGlobals().catch(e => error(400, e)); - await validateTokenFromRequest({ request, cookies }); - - const { groupId, itemId } = params; - - if (!data.groups[groupId]) { - error(404, `Group ${groupId} does not exist`); - } - if (!data.items[groupId][itemId]) { - error(404, `Item ${itemId} does not exist in group ${groupId}`); - } - - const [err, info] = validate(await request.json(), ItemInfoFullStruct); - if (err) { - return error(400, err); - } - - // Validate name - validateName(info.name); - - // TODO: Other validation - - await setItemInfo(groupId, itemId, info); - invalidatePortfolioGlobals(); - - return json({}, { status: 200 }); -} - -export async function DELETE({ params, request, cookies }: import('./$types.js').RequestEvent) { - const data = await getPortfolioGlobals().catch(e => error(400, e)); - await validateTokenFromRequest({ request, cookies }); - - const { groupId, itemId } = params; - - if (!data.groups[groupId]) { - error(404, `Group ${groupId} does not exist`); - } - if (!data.items[groupId][itemId]) { - error(404, `Item ${itemId} does not exist in group ${groupId}`); - } - - // Remove all links to this item - await removeAllLinksToItem(data, groupId, itemId); - - // Now delete the item - await deleteItem(groupId, itemId); - invalidatePortfolioGlobals(); - return json({}, { status: 200 }); -} diff --git a/src/routes/api/group/[groupId]/item/[itemId]/link/+server.ts b/src/routes/api/group/[groupId]/item/[itemId]/link/+server.ts deleted file mode 100644 index 0b93067..0000000 --- a/src/routes/api/group/[groupId]/item/[itemId]/link/+server.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { error, json } from '@sveltejs/kit'; -import { validateTokenFromRequest } from '$lib/server/auth/tokens'; -import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/data/index'; -import { object, string, validate } from 'superstruct'; -import { LinkStyleStruct } from '$lib/server/data/itemOld.js'; -import { changeLinkStyle, createLink, removeLinkFromItem } from '$lib/server/links'; - -export async function POST({ params, request, cookies }: import('./$types.js').RequestEvent) { - const data = await getPortfolioGlobals().catch(e => error(400, e)); - await validateTokenFromRequest({ request, cookies }); - - const { groupId, itemId } = params; - if (!data.groups[groupId]) { - error(404, `Group ${groupId} does not exist`); - } - if (!data.items[groupId][itemId]) { - error(404, `Item ${itemId} does not exist in group ${groupId}`); - } - - const [err, body] = validate( - await request.json(), - object({ otherGroupId: string(), otherItemId: string() }) - ); - if (err) { - return error(400, err); - } - const { otherGroupId, otherItemId } = body; - - if (!data.groups[otherGroupId]) { - error(400, `Group ${otherGroupId} does not exist`); - } - if (!data.items[otherGroupId][otherItemId]) { - error(400, `Item ${otherItemId} does not exist in group ${otherGroupId}`); - } - - // Check for self-links - if (groupId === otherGroupId && itemId === otherItemId) { - error(400, 'Cannot link to same item'); - } - - await createLink(data, groupId, itemId, otherGroupId, otherItemId); - await createLink(data, otherGroupId, otherItemId, groupId, itemId); - - invalidatePortfolioGlobals(); - return json({}, { status: 200 }); -} - -export async function PUT({ params, request, cookies }: import('./$types.js').RequestEvent) { - const data = await getPortfolioGlobals().catch(e => error(400, e)); - await validateTokenFromRequest({ request, cookies }); - - const { groupId, itemId } = params; - if (!data.groups[groupId]) { - error(404, `Group ${groupId} does not exist`); - } - if (!data.items[groupId][itemId]) { - error(404, `Item ${itemId} does not exist in group ${groupId}`); - } - - const [err, body] = validate( - await request.json(), - object({ otherGroupId: string(), style: LinkStyleStruct }) - ); - if (err) { - return error(400, `${err}`); - } - const { otherGroupId, style: newStyle } = body; - if (!data.groups[otherGroupId]) { - error(400, `Group ${otherGroupId} does not exist`); - } - - await changeLinkStyle(data, groupId, itemId, otherGroupId, newStyle); - invalidatePortfolioGlobals(); - return json({}, { status: 200 }); -} - -export async function DELETE(req: import('./$types.js').RequestEvent) { - const data = await getPortfolioGlobals().catch(e => error(400, e)); - await validateTokenFromRequest(req); - - const { groupId, itemId } = req.params; - if (!data.groups[groupId]) { - error(404, `Group ${groupId} does not exist`); - } - if (!data.items[groupId][itemId]) { - error(404, `Item ${itemId} does not exist in group ${groupId}`); - } - - const otherGroupId = req.url.searchParams.get('otherGroupId'); - const otherItemId = req.url.searchParams.get('otherItemId'); - - if (!otherGroupId || !otherItemId) { - error(400, 'Requires query params otherGroupId and otherItemId'); - } - - if (!data.groups[otherGroupId]) { - error(400, `Group ${otherGroupId} does not exist`); - } - if (!data.items[otherGroupId][otherItemId]) { - error(400, `Item ${otherItemId} does not exist in group ${otherGroupId}`); - } - - await removeLinkFromItem(data, groupId, itemId, otherGroupId, otherItemId); - await removeLinkFromItem(data, otherGroupId, otherItemId, groupId, itemId); - - invalidatePortfolioGlobals(); - return json({}, { status: 200 }); -} diff --git a/src/routes/api/group/[groupId]/item/[itemId]/readme/+server.ts b/src/routes/api/group/[groupId]/item/[itemId]/readme/+server.ts deleted file mode 100644 index b058e97..0000000 --- a/src/routes/api/group/[groupId]/item/[itemId]/readme/+server.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { error, json } from '@sveltejs/kit'; -import { validateTokenFromRequest } from '$lib/server/auth/tokens'; -import { assert, string } from 'superstruct'; -import { getItemInfo, setItemReadme } from '$lib/server/data/itemOld.js'; -import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/data/index'; - -export async function GET({ params }: import('./$types.js').RequestEvent) { - const data = await getPortfolioGlobals().catch(e => error(400, e)); - - const { groupId, itemId } = params; - - try { - return json({ readme: data.items[groupId][itemId].readme }, { status: 200 }); - } catch { - return error(400, `Item with ID ${itemId} in group ${groupId} doesn't exist`); - } -} - -export async function PUT({ params, request, cookies }: import('./$types.js').RequestEvent) { - await getPortfolioGlobals().catch(e => error(400, e)); - await validateTokenFromRequest({ request, cookies }); - - const { groupId, itemId } = params; - - await getItemInfo(groupId, itemId) - .catch(() => error(404, `Item with ID ${itemId} in group ${groupId} doesn't exist`)); - - let readme: string; - try { - const newReadme = (await request.json()).readme; - assert(newReadme, string()); - readme = newReadme; - } catch (e) { - return error(400, `${e}`); - } - - await setItemReadme(groupId, itemId, readme); - invalidatePortfolioGlobals(); - return json({}, { status: 200 }); -} diff --git a/src/routes/api/group/[groupId]/readme/+server.ts b/src/routes/api/group/[groupId]/readme/+server.ts deleted file mode 100644 index a13819e..0000000 --- a/src/routes/api/group/[groupId]/readme/+server.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { error, json } from '@sveltejs/kit'; -import { setGroupReadme } from '$lib/server/data/group'; -import { validateTokenFromRequest } from '$lib/server/auth/tokens'; -import { assert, string } from 'superstruct'; -import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/data/index'; - -export async function GET({ params }: import('./$types.js').RequestEvent) { - const groupId = params.groupId; - const data = await getPortfolioGlobals().catch(e => error(400, e)); - - try { - return json({ readme: data.groups[groupId].readme }, { status: 200 }); - } catch (e) { - return error(400, `Group with ID ${groupId} doesn't exist\n${e}`); - } -} - -export async function PUT({ params, request, cookies }: import('./$types.js').RequestEvent) { - const data = await getPortfolioGlobals().catch(e => error(400, e)); - await validateTokenFromRequest({ request, cookies }); - - const groupId = params.groupId; - - if (!data.groups[groupId]) { - return error(404, `Group with ID ${groupId} doesn't exist`); - } - - let readme: string; - try { - const newReadme = (await request.json()).readme; - assert(newReadme, string()); - readme = newReadme; - } catch (e) { - return error(400, `${e}`); - } - - await setGroupReadme(groupId, readme); - invalidatePortfolioGlobals(); - - return json({}, { status: 200 }); -} diff --git a/src/routes/api/readme/+server.ts b/src/routes/api/readme/+server.ts deleted file mode 100644 index e5fb2a2..0000000 --- a/src/routes/api/readme/+server.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** Endpoint for get/setting the README */ - -import { error, json } from '@sveltejs/kit'; -import { validateTokenFromRequest } from '$lib/server/auth/tokens'; -import { object, string, validate } from 'superstruct'; -import { setReadme } from '$lib/server/data/readme'; -import { getPortfolioGlobals, invalidatePortfolioGlobals } from '$lib/server/data/index'; - -export async function GET() { - const data = await getPortfolioGlobals().catch(e => error(400, e)); - - return json({ readme: data.readme }, { status: 200 }); -} - -export async function PUT(req: import('./$types.js').RequestEvent) { - await getPortfolioGlobals().catch(e => error(400, e)); - await validateTokenFromRequest(req); - - const [err, newConfig] = validate(await req.request.json(), object({ readme: string() })); - - if (err) { - return error(400, `${err}`); - } - - await setReadme(newConfig.readme); - invalidatePortfolioGlobals(); - - return json({}, { status: 200 }); -} diff --git a/src/routes/favicon/+server.ts b/src/routes/favicon/+server.ts index f6ccd9f..fce8958 100644 --- a/src/routes/favicon/+server.ts +++ b/src/routes/favicon/+server.ts @@ -2,14 +2,14 @@ import fs from 'fs/promises'; import { error } from '@sveltejs/kit'; import mime from 'mime-types'; import { dataIsSetUp, getDataDir } from '$lib/server/data/dataDir'; -import { getPortfolioGlobals } from '$lib/server/index'; +import { getConfig } from '$lib/server/data/config'; export async function GET(req: import('./$types.js').RequestEvent) { if (!await dataIsSetUp()) { error(404, 'Favicon not set up'); } - const globals = await getPortfolioGlobals(); - const siteIcon = globals.config.siteIcon; + const config = await getConfig(); + const siteIcon = config.siteIcon; if (!siteIcon) { error(404, 'Favicon not set up'); } From 6824919adfd7fbe923761e5cd21c0f371488264a Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Tue, 31 Dec 2024 14:19:21 +1100 Subject: [PATCH 013/149] Update firstrun test cases --- tests/backend/admin/firstrun/account.test.ts | 2 +- tests/backend/admin/firstrun/data.test.ts | 39 +------------------- 2 files changed, 2 insertions(+), 39 deletions(-) diff --git a/tests/backend/admin/firstrun/account.test.ts b/tests/backend/admin/firstrun/account.test.ts index 0433b31..33e3a62 100644 --- a/tests/backend/admin/firstrun/account.test.ts +++ b/tests/backend/admin/firstrun/account.test.ts @@ -1,5 +1,5 @@ /** - * Test cases for POST /api/admin/repo + * Test cases for POST /api/admin/firstrun/account */ import api from '$endpoints'; import { it, describe, expect } from 'vitest'; diff --git a/tests/backend/admin/firstrun/data.test.ts b/tests/backend/admin/firstrun/data.test.ts index 2dd90d5..37da7eb 100644 --- a/tests/backend/admin/firstrun/data.test.ts +++ b/tests/backend/admin/firstrun/data.test.ts @@ -1,5 +1,5 @@ /** - * Test cases for POST /api/admin/repo + * Test cases for POST /api/admin/firstrun/data */ import api, { type ApiClient } from '$endpoints'; import { it, describe, expect, vi, beforeEach } from 'vitest'; @@ -38,43 +38,6 @@ async function firstrunData(token: string, options: Partial ); } -// describe('git', () => { -// it('Clones repo to the default branch when URL is provided', async () => { -// const token = await accountSetup(); -// await firstrunData(token, { repoUrl: gitRepos.TEST_REPO_RW }); -// await expect(repo().checkIsRepo(CheckRepoActions.IS_REPO_ROOT)) -// .resolves.toStrictEqual(true); -// // Default branch for this repo is 'main' -// await expect(repo().status()).resolves.toMatchObject({ current: 'main' }); -// }); -// -// it("Gives an error if the repo doesn't contain a config.json, but isn't empty", async () => { -// const token = await accountSetup(); -// await expect(firstrunData(token, { repoUrl: gitRepos.NON_PORTFOLIO })) -// .rejects.toMatchObject({ code: 400 }); -// }, 10000); -// -// it("Doesn't give an error if the repository is entirely empty", async () => { -// const token = await accountSetup(); -// await firstrunData(token, { repoUrl: gitRepos.EMPTY }); -// await expect(repo().checkIsRepo(CheckRepoActions.IS_REPO_ROOT)) -// .resolves.toStrictEqual(true); -// }); -// -// it('Checks out a branch when one is given', async () => { -// const token = await accountSetup(); -// await firstrunData(token, { repoUrl: gitRepos.TEST_REPO_RW, branch: 'example' }); -// // Check branch name matches -// await expect(repo().status()).resolves.toMatchObject({ current: 'example' }); -// }); -// -// it('Gives an error if the repo URL cannot be cloned', async () => { -// const token = await accountSetup(); -// await expect(firstrunData(token, { repoUrl: gitRepos.INVALID })) -// .rejects.toMatchObject({ code: 400 }); -// }); -// }); - it('Blocks access if data is already set up', async () => { const token = await accountSetup(); await firstrunData(token); From f1067cb4faef475a17a78ad1373686c0abd8d0ab Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Tue, 31 Dec 2024 14:26:03 +1100 Subject: [PATCH 014/149] Fix auth tests by removing local config caching --- src/lib/server/data/localConfig.ts | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/src/lib/server/data/localConfig.ts b/src/lib/server/data/localConfig.ts index a15cf82..4ead9ff 100644 --- a/src/lib/server/data/localConfig.ts +++ b/src/lib/server/data/localConfig.ts @@ -96,14 +96,8 @@ export const ConfigLocalJsonStruct = object({ /** Type definition for config.local.json file */ export type ConfigLocalJson = Infer; -/** Cache of the local config to speed up operations */ -let localConfigCache: ConfigLocalJson | undefined; - /** Return the local configuration, stored in `/private-data/config.local.json` */ export async function getLocalConfig(): Promise { - if (localConfigCache) { - return localConfigCache; - } const data = await readFile(CONFIG_LOCAL_JSON(), { encoding: 'utf-8' }); // Validate data @@ -113,21 +107,10 @@ export async function getLocalConfig(): Promise { console.error(err); throw err; } - - localConfigCache = parsed; - - return localConfigCache; + return parsed; } /** Update the local configuration, stored in `/data/config.local.json` */ export async function setLocalConfig(newConfig: ConfigLocalJson) { - localConfigCache = newConfig; await writeFile(CONFIG_LOCAL_JSON(), JSON.stringify(newConfig, undefined, 2)); } - -/** - * Invalidate the local config cache -- should be used if the data was erased - */ -export function invalidateLocalConfigCache() { - localConfigCache = undefined; -} From a6c9b7c3ae27b14a500990849b49530ee6abc528 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Tue, 31 Dec 2024 17:12:07 +1100 Subject: [PATCH 015/149] Add test skeletons for info.json endpoints --- src/endpoints/fetch/index.ts | 2 +- src/endpoints/item.ts | 16 +++--- src/lib/server/data/itemId.ts | 8 ++- tests/backend/admin/firstrun/data.test.ts | 7 +++ tests/backend/helpers.ts | 61 ++++++----------------- tests/backend/item/info.delete.test.ts | 41 +++++++++++++++ tests/backend/item/info.get.test.ts | 17 +++++++ tests/backend/item/info.post.test.ts | 43 ++++++++++++++++ tests/backend/item/info.put.test.ts | 60 ++++++++++++++++++++++ tests/backend/item/readme.get.test.ts | 3 ++ 10 files changed, 201 insertions(+), 57 deletions(-) create mode 100644 tests/backend/item/info.delete.test.ts create mode 100644 tests/backend/item/info.get.test.ts create mode 100644 tests/backend/item/info.post.test.ts create mode 100644 tests/backend/item/info.put.test.ts create mode 100644 tests/backend/item/readme.get.test.ts diff --git a/src/endpoints/fetch/index.ts b/src/endpoints/fetch/index.ts index af03ce7..30383f2 100644 --- a/src/endpoints/fetch/index.ts +++ b/src/endpoints/fetch/index.ts @@ -1,4 +1,4 @@ import payload from './payload'; -export { apiFetch } from './fetch'; +export { apiFetch, getUrl } from './fetch'; export { payload }; diff --git a/src/endpoints/item.ts b/src/endpoints/item.ts index fee5518..0624f6d 100644 --- a/src/endpoints/item.ts +++ b/src/endpoints/item.ts @@ -8,23 +8,23 @@ export default function item(token: string | undefined, itemId: ItemId) { get: async () => { return apiFetch( 'GET', - `/data/${itemIdToUrl(itemId)}/info.json`, + `/data${itemIdToUrl(itemId, 'info.json')}`, { token }, ).json() as Promise; }, /** Create a new item with the given properties. */ - post: async (name: string, description: string) => { + post: async (name: string, description?: string) => { return apiFetch( 'POST', - `/data/${itemIdToUrl(itemId)}/info.json`, - { token, ...payload.json({ name, description }) }, + `/data/${itemIdToUrl(itemId, 'info.json')}`, + { token, ...payload.json({ name, description: description ?? '' }) }, ).json(); }, /** Update the `info.json` of the given item. */ put: async (info: ItemInfo) => { return apiFetch( 'PUT', - `/data/${itemIdToUrl(itemId)}/info.json`, + `/data/${itemIdToUrl(itemId, 'info.json')}`, { token, ...payload.json(info) }, ).json(); }, @@ -32,7 +32,7 @@ export default function item(token: string | undefined, itemId: ItemId) { delete: async () => { return apiFetch( 'DELETE', - `/data/${itemIdToUrl(itemId)}/info.json`, + `/data/${itemIdToUrl(itemId, 'info.json')}`, { token }, ).json(); }, @@ -43,7 +43,7 @@ export default function item(token: string | undefined, itemId: ItemId) { get: async () => { return apiFetch( 'GET', - `/data/${itemIdToUrl(itemId)}/info.json`, + `/data/${itemIdToUrl(itemId, 'README.md')}`, { token }, ).text(); }, @@ -51,7 +51,7 @@ export default function item(token: string | undefined, itemId: ItemId) { put: async (readme: string) => { return apiFetch( 'PUT', - `/data/${itemIdToUrl(itemId)}/info.json`, + `/data/${itemIdToUrl(itemId, 'README.md')}`, { token, ...payload.markdown(readme) }, ).text(); }, diff --git a/src/lib/server/data/itemId.ts b/src/lib/server/data/itemId.ts index 93c5742..8a8b98a 100644 --- a/src/lib/server/data/itemId.ts +++ b/src/lib/server/data/itemId.ts @@ -16,8 +16,12 @@ export function formatItemId(itemId: ItemId): string { } /** Update the ItemId to its URL path */ -export function itemIdToUrl(itemId: ItemId): string { - return itemId.join('/'); +export function itemIdToUrl(itemId: ItemId, file?: string): string { + if (file) { + return [...itemId, file].join('/'); + } else { + return itemId.join('/'); + } } /** Returns the ItemId for the parent of the given item */ diff --git a/tests/backend/admin/firstrun/data.test.ts b/tests/backend/admin/firstrun/data.test.ts index 37da7eb..6679191 100644 --- a/tests/backend/admin/firstrun/data.test.ts +++ b/tests/backend/admin/firstrun/data.test.ts @@ -51,6 +51,13 @@ it("Doesn't clone repo when no URL provided", async () => { .resolves.toStrictEqual(false); }); +it.only('Generates root item by default', async () => { + const token = await accountSetup(); + await firstrunData(token); + const client = api(token); + await expect(client.item([]).info.get()).toResolve(); +}); + describe('token cases', () => { let client: ApiClient; diff --git a/tests/backend/helpers.ts b/tests/backend/helpers.ts index 98e51fc..2ef256d 100644 --- a/tests/backend/helpers.ts +++ b/tests/backend/helpers.ts @@ -1,10 +1,10 @@ import api, { type ApiClient } from '$endpoints'; import type { ConfigJson } from '$lib/server/data/config'; -import type { GroupInfo } from '$lib/server/data/group'; -import type { ItemInfoFull } from '$lib/server/data/itemOld'; import { version } from '$app/environment'; import simpleGit from 'simple-git'; import { getDataDir } from '$lib/server/data/dataDir'; +import type { ItemInfo } from '$lib/server/data/item'; +import type { ItemId } from '$lib/server/data/itemId'; /** Set up the server, returning (amongst other things) an API client */ export async function setup(repoUrl?: string, branch?: string) { @@ -23,64 +23,33 @@ export async function setup(repoUrl?: string, branch?: string) { /** Create custom config.json object */ export function makeConfig(options: Partial = {}): ConfigJson { const config: ConfigJson = { - siteName: 'My site', - siteShortName: 'Site', - siteDescription: 'This is the description for my site', - siteKeywords: ['Keyword', 'Another keyword'], siteIcon: null, - listedGroups: [], - color: '#ffaaff', version, }; - return { ...config, ...options }; } -/** Create a group with the given ID */ -export async function makeGroup(api: ApiClient, id: string) { - await api.group.withId(id).create(id, id); -} - -/** Creates custom group properties object */ -export function makeGroupInfo(options: Partial = {}): GroupInfo { - const group: GroupInfo = { - name: 'My group', - description: 'Group description', - pageDescription: 'View this group page in the portfolio', - keywords: [], - color: '#aa00aa', - filterGroups: [], - listedItems: [], - filterItems: [], - banner: null, - icon: null, - }; - - return { ...group, ...options }; -} - /** Create an item with the given ID */ -export async function makeItem(api: ApiClient, groupId: string, id: string) { - await api.group.withId(groupId).item.withId(id).create(id, id); +export async function makeItem(api: ApiClient, id: ItemId, name = 'My item') { + await api.item(id).info.post(name); } /** Creates custom item properties object */ -export function makeItemInfo(options: Partial = {}): ItemInfoFull { - const item: ItemInfoFull = { +export function makeItemInfo(options: Partial = {}): ItemInfo { + const item: ItemInfo = { name: 'My item', + shortName: null, description: 'Item description', - pageDescription: 'View this item page in the portfolio', - keywords: [], + icon: null, + banner: null, color: '#aa00aa', - links: [], - urls: { - docs: null, - repo: null, - site: null, + sections: [], + children: [], + filters: [], + seo: { + description: 'View this item page in the portfolio', + keywords: [] }, - package: null, - banner: null, - icon: null, }; return { ...item, ...options }; diff --git a/tests/backend/item/info.delete.test.ts b/tests/backend/item/info.delete.test.ts new file mode 100644 index 0000000..5a369f3 --- /dev/null +++ b/tests/backend/item/info.delete.test.ts @@ -0,0 +1,41 @@ +/** + * Test cases for deleting items. + */ +import type { ApiClient } from '$endpoints'; +import { beforeEach, describe, it } from 'vitest'; +import { setup } from '../helpers'; +import genTokenTests from '../tokenCase'; + +describe('Success', () => { + it.todo('Successfully deletes items'); + + it.todo('Removes child items'); + + it.todo('Removes links to this item'); + + it.todo('Removes links to child items'); + + it.todo('Removes this item from the children of its parent'); +}); + +describe('401', () => { + let api: ApiClient; + const itemId = ['item']; + beforeEach(async () => { + api = (await setup()).api; + await api.item(itemId).info.post('My item'); + }); + + genTokenTests( + () => api, + api => api.item(itemId).info.delete(), + ); +}); + +describe('403', () => { + it.todo('Blocks deletion of the root item'); +}); + +describe('404', () => { + it.todo("Rejects if item doesn't exist"); +}); diff --git a/tests/backend/item/info.get.test.ts b/tests/backend/item/info.get.test.ts new file mode 100644 index 0000000..92371f0 --- /dev/null +++ b/tests/backend/item/info.get.test.ts @@ -0,0 +1,17 @@ +/** + * Test cases for getting item info + */ + +import { describe, it } from 'vitest'; + +describe('Success', () => { + it.todo('Correctly returns info'); +}); + +describe('400', () => { + it.todo('Gives an error if data is not set up'); +}) + +describe('404', () => { + it.todo("Rejects when an item doesn't exist"); +}); diff --git a/tests/backend/item/info.post.test.ts b/tests/backend/item/info.post.test.ts new file mode 100644 index 0000000..979b898 --- /dev/null +++ b/tests/backend/item/info.post.test.ts @@ -0,0 +1,43 @@ +/** + * Test cases for creating new items + */ +import { beforeEach, describe, it } from 'vitest'; +import { setup } from '../helpers'; +import type { ApiClient } from '$endpoints'; +import genTokenTests from '../tokenCase'; + +describe('Success', () => { + it.todo('Allows valid IDs'); + + it.todo('Allows valid item names'); + + it.todo('Generates item with a valid `info.json`'); + + it.todo('Generates item with a valid `README.md`'); + + it.todo('Adds new items as children by default'); +}); + +describe('400', () => { + it.todo('Fails if the data is not set up'); + + it.todo('Rejects invalid item IDs'); + + it.todo('Rejects duplicate IDs'); + + it.todo('Rejects invalid item names'); + + it.todo("Rejects new items when the parent doesn't exist"); +}); + +describe('401', () => { + let api: ApiClient; + beforeEach(async () => { + api = (await setup()).api; + }); + + genTokenTests( + () => api, + api => api.item(['my-item']).info.post('My item', ''), + ); +}); diff --git a/tests/backend/item/info.put.test.ts b/tests/backend/item/info.put.test.ts new file mode 100644 index 0000000..45ad566 --- /dev/null +++ b/tests/backend/item/info.put.test.ts @@ -0,0 +1,60 @@ +/** + * Test cases for updating item info + */ + +import type { ApiClient } from '$endpoints'; +import { beforeEach, describe, it } from 'vitest'; +import { makeItemInfo, setup } from '../helpers'; +import genTokenTests from '../tokenCase'; + +describe('Success', () => { + it.todo('Successfully updates item info'); +}); + +describe('400', () => { + it.todo('Rejects invalid item names'); + + it.todo('Rejects invalid item short names'); + + it.todo('Rejects non-existent item icons'); + + it.todo('Rejects non-existent item banners'); + + it.todo('Rejects non-image item icons'); + + it.todo('Rejects non-image item banners'); + + it.todo('Rejects invalid item colors'); + + describe('Section data', () => { + it.todo('Rejects unrecognized item sections'); + + it.todo('Rejects sections with invalid titles'); + + it.todo('Rejects link sections with links to invalid items'); + }); + + it.todo('Rejects if listed child does not exist'); + + it.todo('Rejects if filter item does not exist'); + + it.todo('Rejects empty string SEO description'); +}); + +describe('401', () => { + let api: ApiClient; + const itemId = ['item']; + beforeEach(async () => { + api = (await setup()).api; + await api.item(itemId).info.post('My item'); + }); + + genTokenTests( + () => api, + api => api.item(itemId).info.put(makeItemInfo()), + ); +}); + +describe('404', () => { + it.todo('Rejects if item does not exist'); +}) diff --git a/tests/backend/item/readme.get.test.ts b/tests/backend/item/readme.get.test.ts new file mode 100644 index 0000000..7c9e68f --- /dev/null +++ b/tests/backend/item/readme.get.test.ts @@ -0,0 +1,3 @@ +/** + * Test cases for getting the README.md of an item. + */ From 1d43881b27562f4b81de1ca4846805a124b3b725 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Tue, 31 Dec 2024 17:44:18 +1100 Subject: [PATCH 016/149] Implement tests for item creation --- src/endpoints/item.ts | 2 +- src/lib/server/data/item.ts | 2 +- src/lib/server/data/itemId.ts | 7 ++ src/lib/validate.ts | 9 ++- .../data/[...item]/info.json/+server.ts | 12 +++- tests/backend/item/info.post.test.ts | 69 ++++++++++++++----- 6 files changed, 78 insertions(+), 23 deletions(-) diff --git a/src/endpoints/item.ts b/src/endpoints/item.ts index 0624f6d..4f8e810 100644 --- a/src/endpoints/item.ts +++ b/src/endpoints/item.ts @@ -8,7 +8,7 @@ export default function item(token: string | undefined, itemId: ItemId) { get: async () => { return apiFetch( 'GET', - `/data${itemIdToUrl(itemId, 'info.json')}`, + `/data/${itemIdToUrl(itemId, 'info.json')}`, { token }, ).json() as Promise; }, diff --git a/src/lib/server/data/item.ts b/src/lib/server/data/item.ts index 07848a1..1a549f4 100644 --- a/src/lib/server/data/item.ts +++ b/src/lib/server/data/item.ts @@ -19,7 +19,7 @@ import { rimraf } from 'rimraf'; * * IMPORTANT: Do not validate using this struct alone -- instead, call `validateItemInfo` */ -const ItemInfoStruct = type({ +export const ItemInfoStruct = type({ /** * The name of the item, displayed in the navigator when on this page, as well as on Card * elements. diff --git a/src/lib/server/data/itemId.ts b/src/lib/server/data/itemId.ts index 8a8b98a..3e708cb 100644 --- a/src/lib/server/data/itemId.ts +++ b/src/lib/server/data/itemId.ts @@ -2,6 +2,7 @@ * Item ID type definitions and helper functions */ import { zip } from '$lib/util'; +import validate from '$lib/validate'; import { array, string, type Infer } from 'superstruct'; /** Return an item ID given its path in URL form */ @@ -24,6 +25,12 @@ export function itemIdToUrl(itemId: ItemId, file?: string): string { } } +export function validateItemId(itemId: ItemId) { + for (const component of itemId) { + validate.id('ItemId', component); + } +} + /** Returns the ItemId for the parent of the given item */ export function itemParent(itemId: ItemId): ItemId { return itemId.slice(0, -1) diff --git a/src/lib/validate.ts b/src/lib/validate.ts index 8cb27d6..2cffb1a 100644 --- a/src/lib/validate.ts +++ b/src/lib/validate.ts @@ -14,7 +14,12 @@ import path from 'path'; /** Regex for matching ID strings */ export const idValidatorRegex = /^[a-z0-9-.]+$/; -/** Ensure that the given ID string is valid */ +/** + * Ensure that the given ID string is valid. + * + * @param type the type of ID being validated (used to produce helpful error messages). + * @param id the ID string to validate. + */ export function validateId(type: string, id: string): string { if (!id.trim().length) { error(400, `${type} '${id}' is empty`); @@ -60,7 +65,7 @@ export function validateName(name: string): string { return name; } -const colorValidatorRegex = /^#([0-9])\1{6}$/; +const colorValidatorRegex = /^#[0-9a-fA-F]{3,6}$/; /** Validate a color is in hex form */ export function validateColor(color: string): string { diff --git a/src/routes/data/[...item]/info.json/+server.ts b/src/routes/data/[...item]/info.json/+server.ts index 083dfba..e91883b 100644 --- a/src/routes/data/[...item]/info.json/+server.ts +++ b/src/routes/data/[...item]/info.json/+server.ts @@ -1,13 +1,14 @@ import fs from 'fs/promises'; import { json, error } from '@sveltejs/kit'; import { object, string } from 'superstruct'; -import { formatItemId, itemIdFromUrl, itemIdTail, itemParent } from '$lib/server/data/itemId'; +import { formatItemId, itemIdFromUrl, validateItemId, itemIdTail, itemParent } from '$lib/server/data/itemId'; import { deleteItem, getItemInfo, itemExists, itemPath, setItemInfo, validateItemInfo } from '$lib/server/data/item'; import { validateTokenFromRequest } from '$lib/server/auth/tokens.js'; import { applyStruct } from '$lib/server/util.js'; import { validateName } from '$lib/validate.js'; import formatTemplate from '$lib/server/formatTemplate.js'; import { ITEM_README } from '$lib/server/data/text.js'; +import { dataIsSetUp } from '$lib/server/data/dataDir.js'; /** * API endpoints for accessing info.json @@ -17,6 +18,12 @@ type Request = import('./$types.js').RequestEvent; /** Get item info.json */ export async function GET(req: Request) { const item = itemIdFromUrl(req.params.item); + if (!await dataIsSetUp()) { + error(400, 'Data is not set up'); + } + if (!await itemExists(item)) { + error(404, `Item '${req.params.item}' does not exist`); + } return json(await getItemInfo(item)); } @@ -30,6 +37,7 @@ const NewItemOptions = object({ export async function POST(req: Request) { await validateTokenFromRequest(req); const item = itemIdFromUrl(req.params.item); + validateItemId(item); // Ensure parent exists const parent = await getItemInfo(itemParent(item)) @@ -74,7 +82,7 @@ export async function POST(req: Request) { parent.children.push(itemIdTail(item)); await setItemInfo(itemParent(item), parent); - return json({}); + return json(itemInfo); } /** Update item info.json */ diff --git a/tests/backend/item/info.post.test.ts b/tests/backend/item/info.post.test.ts index 979b898..b1d5424 100644 --- a/tests/backend/item/info.post.test.ts +++ b/tests/backend/item/info.post.test.ts @@ -1,43 +1,78 @@ /** * Test cases for creating new items */ -import { beforeEach, describe, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { setup } from '../helpers'; import type { ApiClient } from '$endpoints'; import genTokenTests from '../tokenCase'; +import { invalidIds, invalidNames, validIds, validNames } from '../consts'; +import { assert } from 'superstruct' +import { ItemInfoStruct } from '$lib/server/data/item'; + +let api: ApiClient; +beforeEach(async () => { + api = (await setup()).api; +}); describe('Success', () => { - it.todo('Allows valid IDs'); + it.each(validIds)('Allows valid IDs ($case)', async ({ id }) => { + await expect(api.item([id]).info.post('My item')).toResolve(); + }); - it.todo('Allows valid item names'); + it.each(validNames)('Allows valid item names ($case)', async ({ name }) => { + await expect(api.item(['item']).info.post(name)).toResolve(); + }); - it.todo('Generates item with a valid `info.json`'); + it("Returns the new item's `info.json`", async () => { + const info = await api.item(['item']).info.post('My item'); + // Returned info should validate as `ItemInfoStruct` + // This doesn't check that the data is fully valid, but just that it has the right shape + expect(() => assert(info, ItemInfoStruct)).not.toThrow(); + }); - it.todo('Generates item with a valid `README.md`'); + it('Generates item with a valid `README.md`', async () => { + await api.item(['item']).info.post('My item'); + await expect(api.item(['item']).readme.get()).resolves.toStrictEqual(expect.any(String)); + }); - it.todo('Adds new items as children by default'); + it('Adds new items as children by default', async () => { + await api.item(['item']).info.post('My item'); + // Parent item is `/` (root) + await expect(api.item([]).info.get()).resolves.toMatchObject({ + children: ['item'], + }); + }); }); describe('400', () => { - it.todo('Fails if the data is not set up'); - - it.todo('Rejects invalid item IDs'); + it('Fails if the data is not set up', async () => { + await api.debug.clear(); + await expect(api.item(['item']).info.get()).rejects.toMatchObject({ code: 400 }); + }); - it.todo('Rejects duplicate IDs'); + it.each(invalidIds)('Rejects invalid item IDs ($case)', async ({ id }) => { + await expect(api.item([id]).info.post('My item')).rejects.toMatchObject({ code: 400 }) + }); - it.todo('Rejects invalid item names'); + it('Rejects duplicate IDs', async () => { + await api.item(['item']).info.post('My item'); + await expect(api.item(['item']).info.post('My item')).rejects.toMatchObject({ code: 400 }); + }); - it.todo("Rejects new items when the parent doesn't exist"); + it.each(invalidNames)('Rejects invalid item names ($case)', async ({ name }) => { + await expect(api.item(['item']).info.post(name)).rejects.toMatchObject({ code: 400 }) + }); }); describe('401', () => { - let api: ApiClient; - beforeEach(async () => { - api = (await setup()).api; - }); - genTokenTests( () => api, api => api.item(['my-item']).info.post('My item', ''), ); }); + +describe('404', () => { + it("Rejects new items when the parent doesn't exist", async () => { + await expect(api.item(['invalid', 'parent']).info.post('My item')).rejects.toMatchObject({ code: 404 }); + }); +}); From c1df8f240cab0f8dbb653a22261dde1ad849519c Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Tue, 31 Dec 2024 21:27:28 +1100 Subject: [PATCH 017/149] Write test cases for readme endpoints --- src/endpoints/item.ts | 2 +- .../data/[...item]/README.md/+server.ts | 7 +++ tests/backend/item/readme.get.test.ts | 32 +++++++++++++ tests/backend/item/readme.set.test.ts | 46 +++++++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 tests/backend/item/readme.set.test.ts diff --git a/src/endpoints/item.ts b/src/endpoints/item.ts index 4f8e810..54e0654 100644 --- a/src/endpoints/item.ts +++ b/src/endpoints/item.ts @@ -53,7 +53,7 @@ export default function item(token: string | undefined, itemId: ItemId) { 'PUT', `/data/${itemIdToUrl(itemId, 'README.md')}`, { token, ...payload.markdown(readme) }, - ).text(); + ).json(); }, }; diff --git a/src/routes/data/[...item]/README.md/+server.ts b/src/routes/data/[...item]/README.md/+server.ts index a164816..08c546f 100644 --- a/src/routes/data/[...item]/README.md/+server.ts +++ b/src/routes/data/[...item]/README.md/+server.ts @@ -9,10 +9,14 @@ import fs from 'fs/promises'; import { error, json } from '@sveltejs/kit'; import { validateTokenFromRequest } from '$lib/server/auth/tokens.js'; import { itemExists, itemPath } from '$lib/server/data/item.js'; +import { dataIsSetUp } from '$lib/server/data/dataDir.js'; type Request = import('./$types.js').RequestEvent; /** GET request handler, returns README text */ export async function GET(req: Request) { + if (!await dataIsSetUp()) { + error(400, 'Data is not set up'); + } const item: ItemId = itemIdFromUrl(req.params.item); const filePath = itemPath(item, 'README.md'); if (!await itemExists(item)) { @@ -29,6 +33,9 @@ export async function GET(req: Request) { /** PUT request handler, updates README text */ export async function PUT(req: Request) { + if (!await dataIsSetUp()) { + error(400, 'Data is not set up'); + } await validateTokenFromRequest(req); const item: ItemId = itemIdFromUrl(req.params.item); const filePath = itemPath(item, 'README.md'); diff --git a/tests/backend/item/readme.get.test.ts b/tests/backend/item/readme.get.test.ts index 7c9e68f..07d5470 100644 --- a/tests/backend/item/readme.get.test.ts +++ b/tests/backend/item/readme.get.test.ts @@ -1,3 +1,35 @@ /** * Test cases for getting the README.md of an item. */ + +import apiClient, { type ApiClient } from '$endpoints'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { setup } from '../helpers'; + +let api: ApiClient; + +beforeEach(async () => { + api = (await setup()).api; +}); + +describe('Success', () => { + it('Correctly returns the README for the root item', async () => { + await expect(api.item([]).readme.get()) + .resolves.toStrictEqual(expect.any(String)); + }) +}); + +describe('400', () => { + it('Errors if the server has not been set up', async () => { + await apiClient().debug.clear(); + await expect(apiClient().item([]).readme.get()) + .rejects.toMatchObject({ code: 400 }); + }); +}); + +describe('404', () => { + it('Errors if the item does not exist', async () => { + await expect(api.item(['unknown', 'item']).readme.get()) + .rejects.toMatchObject({ code: 404 }); + }); +}); diff --git a/tests/backend/item/readme.set.test.ts b/tests/backend/item/readme.set.test.ts new file mode 100644 index 0000000..05750d5 --- /dev/null +++ b/tests/backend/item/readme.set.test.ts @@ -0,0 +1,46 @@ +/** + * Test cases for setting the README.md of an item. + */ + +import apiClient, { type ApiClient } from '$endpoints'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { setup } from '../helpers'; +import genTokenTests from '../tokenCase'; + +let api: ApiClient; + +beforeEach(async () => { + api = (await setup()).api; +}); + +describe('Success', () => { + it('Correctly updates the README for the root item', async () => { + await expect(api.item([]).readme.put('New readme')) + .resolves.toStrictEqual({}); + // Now when we request the README, it should have the new content + await expect(api.item([]).readme.get()) + .resolves.toStrictEqual('New readme'); + }); +}); + +describe('400', () => { + it('Errors if the server has not been set up', async () => { + await apiClient().debug.clear(); + await expect(apiClient().item([]).readme.put('New readme')) + .rejects.toMatchObject({ code: 400 }); + }); +}); + +describe('401', () => { + genTokenTests( + () => api, + api => api.item([]).readme.put('Hi'), + ); +}); + +describe('404', () => { + it('Errors if the item does not exist', async () => { + await expect(api.item(['unknown', 'item']).readme.put('New readme')) + .rejects.toMatchObject({ code: 404 }); + }); +}); From e8ddc035127b03f05807736d668ba19239411ea9 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Tue, 31 Dec 2024 22:31:09 +1100 Subject: [PATCH 018/149] Write test cases for updating item info --- src/lib/server/data/item.ts | 5 +- src/lib/server/data/itemId.ts | 3 +- src/lib/server/data/section.ts | 17 +- src/lib/validate.ts | 3 +- tests/backend/consts.ts | 12 ++ tests/backend/item/info.put.test.ts | 269 +++++++++++++++++++++++++--- 6 files changed, 275 insertions(+), 34 deletions(-) diff --git a/src/lib/server/data/item.ts b/src/lib/server/data/item.ts index 1a549f4..cb2046a 100644 --- a/src/lib/server/data/item.ts +++ b/src/lib/server/data/item.ts @@ -104,7 +104,7 @@ export async function validateItemInfo(item: ItemId, data: any): Promise a !== b) === undefined; + return first.length == second.length + && zip(first, second).find(([a, b]) => a !== b) === undefined; } /** diff --git a/src/lib/server/data/section.ts b/src/lib/server/data/section.ts index 4db0ec3..5cf7623 100644 --- a/src/lib/server/data/section.ts +++ b/src/lib/server/data/section.ts @@ -6,7 +6,7 @@ import { array, enums, literal, string, type, union, type Infer } from 'superstruct'; import { error } from '@sveltejs/kit'; import validate from '$lib/validate'; -import { ItemIdStruct } from './itemId'; +import { itemIdsEqual, ItemIdStruct, type ItemId } from './itemId'; import { RepoInfoStruct } from './itemRepo'; import { PackageInfoStruct } from './itemPackage'; import { itemExists } from './item'; @@ -24,11 +24,14 @@ export const LinksSection = type({ }); /** Validate a links section of an item */ -async function validateLinksSection(data: Infer) { +async function validateLinksSection(itemId: ItemId, data: Infer) { validate.name(data.title); - for (const item of data.items) { - if (!await itemExists(item)) { - error(400, `Linked item ${item} does not exist`); + for (const otherItem of data.items) { + if (!await itemExists(otherItem)) { + error(400, `Linked item ${otherItem} does not exist`); + } + if (itemIdsEqual(otherItem, itemId)) { + error(400, 'Links cannot be self-referencing'); } } } @@ -85,10 +88,10 @@ export const ItemSectionStruct = union([ export type ItemSection = Infer; /** Validate the given section data */ -export async function validateSection(data: ItemSection) { +export async function validateSection(itemId: ItemId, data: ItemSection) { validate.name(data.title); // `links` section needs additional validation if (data.type === 'links') { - await validateLinksSection(data); + await validateLinksSection(itemId, data); } } diff --git a/src/lib/validate.ts b/src/lib/validate.ts index 2cffb1a..f0a07b2 100644 --- a/src/lib/validate.ts +++ b/src/lib/validate.ts @@ -65,7 +65,8 @@ export function validateName(name: string): string { return name; } -const colorValidatorRegex = /^#[0-9a-fA-F]{3,6}$/; +// Can't find a clean way to match either 3 or 6 chars, but not 4 or 5 +const colorValidatorRegex = /^#[0-9a-fA-F]{6}$/; /** Validate a color is in hex form */ export function validateColor(color: string): string { diff --git a/tests/backend/consts.ts b/tests/backend/consts.ts index c37e5ab..e0ee022 100644 --- a/tests/backend/consts.ts +++ b/tests/backend/consts.ts @@ -36,3 +36,15 @@ export const validNames = [ { name: '🙃', case: 'Emoji' }, { name: 'Español', case: 'Foreign characters' }, ]; + +export const invalidColors = [ + { color: 'ABCDEF', case: 'Missing "#"' }, + { color: '#12345G', case: 'Invalid chars' }, + { color: '#12345', case: 'Incorrect length' }, +] + +export const validColors = [ + { color: '#ABCDEF', case: 'Uppercase' }, + { color: '#abcdef', case: 'Lowercase' }, + // { color: '#123', case: '3 chars' }, +] diff --git a/tests/backend/item/info.put.test.ts b/tests/backend/item/info.put.test.ts index 45ad566..abf5bab 100644 --- a/tests/backend/item/info.put.test.ts +++ b/tests/backend/item/info.put.test.ts @@ -3,52 +3,270 @@ */ import type { ApiClient } from '$endpoints'; -import { beforeEach, describe, it } from 'vitest'; +import { beforeEach, describe, expect, it, test } from 'vitest'; import { makeItemInfo, setup } from '../helpers'; import genTokenTests from '../tokenCase'; +import { invalidColors, invalidNames, validColors, validNames } from '../consts'; + +let api: ApiClient; +const itemId = ['item']; + +beforeEach(async () => { + api = (await setup()).api; + await api.item(itemId).info.post('My item'); +}); describe('Success', () => { - it.todo('Successfully updates item info'); + it('Successfully updates item info', async () => { + await expect(api.item(itemId).info.put(makeItemInfo())) + .resolves.toStrictEqual({}); + // Info has been updated + await expect(api.item(itemId).info.get()) + .resolves.toStrictEqual(makeItemInfo()); + }); + + it.each(validNames)('Accepts valid item names ($case)', async ({ name }) => { + await expect(api.item(itemId).info.put(makeItemInfo({ name }))) + .resolves.toStrictEqual({}); + }); + + it.each(validNames)('Accepts valid item short names ($case)', async ({ name }) => { + await expect(api.item(itemId).info.put(makeItemInfo({ shortName: name }))) + .resolves.toStrictEqual({}); + }); + + it.each(validColors)('Accepts valid colors ($case)', async ({ color }) => { + await expect(api.item(itemId).info.put(makeItemInfo({ color }))) + .resolves.toStrictEqual({}); + }); + + it.todo('Accepts valid icon images'); + it.todo('Accepts valid banner images'); + + it('Accepts valid children', async () => { + await expect(api.item([]).info.put(makeItemInfo({ + children: [itemId.at(-1)!] + }))) + .resolves.toStrictEqual({}); + }); + + it('Accepts valid filter items', async () => { + await expect(api.item([]).info.put(makeItemInfo({ + filters: [itemId], + }))) + .resolves.toStrictEqual({}); + }); + + describe('Sections', () => { + it('Accepts valid website info', async () => { + const info = makeItemInfo({ + sections: [ + { + type: 'site', + title: 'Visit the website', + url: 'https://example.com', + } + ] + }); + await expect(api.item(itemId).info.put(info)) + .resolves.toStrictEqual({}); + }); + it('Accepts valid repo info', async () => { + const info = makeItemInfo({ + sections: [ + { + type: 'repo', + title: 'Check out the code', + info: { + provider: 'github', + path: 'MaddyGuthridge/Minifolio', + } + } + ] + }); + await expect(api.item(itemId).info.put(info)) + .resolves.toStrictEqual({}); + }); + it('Accepts valid package info', async () => { + const info = makeItemInfo({ + sections: [ + { + type: 'package', + title: 'Install the app', + info: { + provider: 'npm', + id: 'everything', + } + } + ] + }); + await expect(api.item(itemId).info.put(info)) + .resolves.toStrictEqual({}); + }); + + it('Accepts valid links to other items', async () => { + const info = makeItemInfo({ + sections: [ + { + type: 'links', + style: 'chip', + title: 'See also', + items: [ + // Root + [], + ] + } + ] + }); + + await expect(api.item(itemId).info.put(info)) + .resolves.toStrictEqual({}); + }); + }); + }); describe('400', () => { - it.todo('Rejects invalid item names'); + it.each(invalidNames)('Rejects invalid item names ($case)', async ({ name }) => { + await expect(api.item(itemId).info.put(makeItemInfo({ name }))) + .rejects.toMatchObject({ code: 400 }); + }); - it.todo('Rejects invalid item short names'); + it.each(invalidNames)('Rejects invalid item short names ($case)', async ({ name }) => { + await expect(api.item(itemId).info.put(makeItemInfo({ shortName: name }))) + .rejects.toMatchObject({ code: 400 }); + }); - it.todo('Rejects non-existent item icons'); + it('Rejects non-existent item icons', async () => { + await expect(api.item(itemId).info.put(makeItemInfo({ icon: 'nope.jpg' }))) + .rejects.toMatchObject({ code: 400 }); + }); - it.todo('Rejects non-existent item banners'); + it('Rejects non-existent item banners', async () => { + await expect(api.item(itemId).info.put(makeItemInfo({ banner: 'nope.jpg' }))) + .rejects.toMatchObject({ code: 400 }); + }); - it.todo('Rejects non-image item icons'); + it('Rejects non-image item icons', async () => { + await expect(api.item(itemId).info.put(makeItemInfo({ icon: 'info.json' }))) + .rejects.toMatchObject({ code: 400 }); + }); - it.todo('Rejects non-image item banners'); + it('Rejects non-image item banners', async () => { + await expect(api.item(itemId).info.put(makeItemInfo({ banner: 'info.json' }))) + .rejects.toMatchObject({ code: 400 }); + }); - it.todo('Rejects invalid item colors'); + it.each(invalidColors)('Rejects invalid item colors ($case)', async ({ color }) => { + await expect(api.item(itemId).info.put(makeItemInfo({ color }))) + .rejects.toMatchObject({ code: 400 }); + }); describe('Section data', () => { - it.todo('Rejects unrecognized item sections'); + it('Rejects unrecognized item sections', async () => { + const info = makeItemInfo({ + sections: [ + // Intentionally incorrect 'type' field + { + type: 'unknown', + title: 'Title', + } as any + // ^^^ needed or TypeScript will (correctly) identify that this is wrong + ] + }); + await expect(api.item(itemId).info.put(info)) + .rejects.toMatchObject({ code: 400 }); + }); + + it.each(invalidNames)('Rejects sections with invalid titles ($case)', async ({ name }) => { + const info = makeItemInfo({ + sections: [ + { + type: 'site', + title: name, + url: 'https://example.com', + } + ] + }) + await expect(api.item(itemId).info.put(info)) + .rejects.toMatchObject({ code: 400 }); + }); + + it('Rejects link sections with links to invalid items', async () => { + const info = makeItemInfo({ + sections: [ + { + type: 'links', + style: 'chip', + title: 'See also', + items: [ + ['invalid'], + ] + } + ] + }); + + await expect(api.item(itemId).info.put(info)) + .rejects.toMatchObject({ code: 400 }); + }); - it.todo('Rejects sections with invalid titles'); + it('Rejects link sections with self-referencing links', async () => { + const info = makeItemInfo({ + sections: [ + { + type: 'links', + style: 'chip', + title: 'See also', + items: [ + itemId, + ] + } + ] + }); - it.todo('Rejects link sections with links to invalid items'); + await expect(api.item(itemId).info.put(info)) + .rejects.toMatchObject({ code: 400 }); + }); }); - it.todo('Rejects if listed child does not exist'); + it('Rejects if listed child does not exist', async () => { + await expect(api.item(itemId).info.put(makeItemInfo({ children: ['invalid'] }))) + .rejects.toMatchObject({ code: 400 }); + }); - it.todo('Rejects if filter item does not exist'); + describe('Filter items', () => { + test('Item does not exist', async () => { + await expect(api.item(itemId).info.put(makeItemInfo({ + filters: [ + ['invalid', 'item'], + ] + }))) + .rejects.toMatchObject({ code: 400 }); + }); - it.todo('Rejects empty string SEO description'); -}); + test('Self-referencing item', async () => { + const info = makeItemInfo({ + filters: [ + itemId + ] + }); -describe('401', () => { - let api: ApiClient; - const itemId = ['item']; - beforeEach(async () => { - api = (await setup()).api; - await api.item(itemId).info.post('My item'); + await expect(api.item(itemId).info.put(info)) + .rejects.toMatchObject({ code: 400 }); + }); }); + it('Rejects empty string SEO description', async () => { + await expect(api.item(itemId).info.put(makeItemInfo({ + seo: { + description: '', + keywords: [], + } + }))).rejects.toMatchObject({ code: 400 }); + }); +}); + +describe('401', () => { genTokenTests( () => api, api => api.item(itemId).info.put(makeItemInfo()), @@ -56,5 +274,8 @@ describe('401', () => { }); describe('404', () => { - it.todo('Rejects if item does not exist'); -}) + it('Rejects if item does not exist', async () => { + await expect(api.item(['invalid']).info.put(makeItemInfo())) + .rejects.toMatchObject({ code: 404 }); + }); +}); From 978886232738db29f7f430fa56e2ecce6a182623 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 12:16:51 +1100 Subject: [PATCH 019/149] Implement tests for getting item info.json --- tests/backend/item/info.get.test.ts | 38 ++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/tests/backend/item/info.get.test.ts b/tests/backend/item/info.get.test.ts index 92371f0..bb5a010 100644 --- a/tests/backend/item/info.get.test.ts +++ b/tests/backend/item/info.get.test.ts @@ -1,17 +1,47 @@ /** * Test cases for getting item info */ +import type { ApiClient } from '$endpoints'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { setup } from '../helpers'; -import { describe, it } from 'vitest'; +let api: ApiClient; +const itemId = ['item']; + +beforeEach(async () => { + api = (await setup()).api; + await api.item(itemId).info.post('My item'); +}); describe('Success', () => { - it.todo('Correctly returns info'); + it('Correctly returns info', async () => { + await expect(api.item(itemId).info.get()).resolves.toStrictEqual({ + name: expect.any(String), + shortName: null, + description: '', + color: expect.toSatisfy(c => /^#[0-9a-fA-F]{6}$/.test(c)), + icon: null, + banner: null, + children: [], + filters: [], + sections: [], + seo: { + description: null, + keywords: [expect.any(String)], + }, + }) + }); }); describe('400', () => { - it.todo('Gives an error if data is not set up'); + it('Gives an error if data is not set up', async () => { + await api.debug.clear(); + await expect(api.item([]).info.get()).rejects.toMatchObject({ code: 400 }); + }); }) describe('404', () => { - it.todo("Rejects when an item doesn't exist"); + it("Rejects when an item doesn't exist", async () => { + await expect(api.item(['invalid']).info.get()).rejects.toMatchObject({ code: 404 }); + }); }); From 001d3c16e332ecae5058d224703306d044978e9b Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 12:45:13 +1100 Subject: [PATCH 020/149] Implement files as apiFetch payload --- src/endpoints/fetch/fetch.ts | 5 ++--- src/endpoints/fetch/payload.ts | 27 +++++++++++++++++++-------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/endpoints/fetch/fetch.ts b/src/endpoints/fetch/fetch.ts index 790a699..67dbbdd 100644 --- a/src/endpoints/fetch/fetch.ts +++ b/src/endpoints/fetch/fetch.ts @@ -3,6 +3,7 @@ import ApiError from './ApiError'; // import fetch from 'cross-fetch'; import { browser } from '$app/environment'; import response from './response'; +import type { PayloadInfo } from './payload'; export type HttpVerb = 'GET' | 'POST' | 'PUT' | 'DELETE'; @@ -36,9 +37,7 @@ export function apiFetch( options?: { token?: string, query?: Record, - contentType?: string, - payload?: string, - } + } & Partial, ) { const baseUrl = getUrl(); diff --git a/src/endpoints/fetch/payload.ts b/src/endpoints/fetch/payload.ts index 08955e7..b74b58b 100644 --- a/src/endpoints/fetch/payload.ts +++ b/src/endpoints/fetch/payload.ts @@ -1,16 +1,21 @@ -/** Information about a request payload */ -export type PayloadInfo = { +type TextPayload = { /** `Content-Type` header to use */ contentType: string, /** Payload converted to a string */ payload: string, -} +}; + +/** Information about a request payload */ +export type PayloadInfo = TextPayload | { + contentType: undefined, + payload: FormData, +}; /** Generate a function to handle payloads of the given type */ -function generatePayloadFn( +function generateTextPayloadFn( contentType: string, converter: (body: T) => string, -): (body: T) => PayloadInfo { +): (body: T) => TextPayload { return (body) => ({ contentType, payload: converter(body), @@ -23,11 +28,17 @@ const noop = (body: string) => body; /** Send a request payload of the given type */ export default { /** Request payload in JSON format */ - json: generatePayloadFn('application/json', JSON.stringify), + json: generateTextPayloadFn('application/json', JSON.stringify), /** Request payload in Markdown format */ - markdown: generatePayloadFn('text/markdown', noop), + markdown: generateTextPayloadFn('text/markdown', noop), /** Request payload in plain text format */ - text: generatePayloadFn('text/plain', noop), + text: generateTextPayloadFn('text/plain', noop), + /** Send a file given its form data */ + file: (filename: string, file: File) => { + const form = new FormData(); + form.append(filename, file); + return { contentType: undefined, payload: form }; + }, /** Request using a custom mime-type */ custom: (contentType: string, body: string) => ({ contentType, payload: body }) }; From 4d0fb3fee4bb1d4d8ac9c9fbbd31f77e8b03af8c Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 14:55:35 +1100 Subject: [PATCH 021/149] Implement file uploads --- package-lock.json | 4 +- src/endpoints/fetch/payload.ts | 6 +-- src/endpoints/fetch/response.ts | 3 ++ src/endpoints/item.ts | 37 +++++++++++++ .../data/[...item]/[filename]/+server.ts | 54 ++++++++++--------- svelte.config.js | 6 +++ tests/backend/fileRequest.ts | 25 +++++++++ tests/backend/item/file.delete.test.ts | 31 +++++++++++ tests/backend/item/file.get.test.ts | 24 +++++++++ tests/backend/item/file.post.test.ts | 44 +++++++++++++++ tests/backend/item/file.put.test.ts | 32 +++++++++++ 11 files changed, 236 insertions(+), 30 deletions(-) create mode 100644 tests/backend/fileRequest.ts create mode 100644 tests/backend/item/file.delete.test.ts create mode 100644 tests/backend/item/file.get.test.ts create mode 100644 tests/backend/item/file.post.test.ts create mode 100644 tests/backend/item/file.put.test.ts diff --git a/package-lock.json b/package-lock.json index a680a0a..1af6163 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "minifolio", - "version": "0.6.3", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "minifolio", - "version": "0.6.3", + "version": "0.7.0", "license": "GPL-3.0-only", "dependencies": { "child-process-promise": "^2.2.1", diff --git a/src/endpoints/fetch/payload.ts b/src/endpoints/fetch/payload.ts index b74b58b..454c998 100644 --- a/src/endpoints/fetch/payload.ts +++ b/src/endpoints/fetch/payload.ts @@ -33,10 +33,10 @@ export default { markdown: generateTextPayloadFn('text/markdown', noop), /** Request payload in plain text format */ text: generateTextPayloadFn('text/plain', noop), - /** Send a file given its form data */ - file: (filename: string, file: File) => { + /** Send a file */ + file: (file: File) => { const form = new FormData(); - form.append(filename, file); + form.append('content', file); return { contentType: undefined, payload: form }; }, /** Request using a custom mime-type */ diff --git a/src/endpoints/fetch/response.ts b/src/endpoints/fetch/response.ts index 933997c..34346ce 100644 --- a/src/endpoints/fetch/response.ts +++ b/src/endpoints/fetch/response.ts @@ -28,9 +28,12 @@ export async function json(response: Promise): Promise { throw new ApiError(res.status, `Request got status code ${res.status}`); } // Decode the data + // const text = await res.text(); + // console.log(text); let json: object; try { json = await res.json(); + // json = JSON.parse(text); } catch (err) { // JSON parse error if (err instanceof Error) { diff --git a/src/endpoints/item.ts b/src/endpoints/item.ts index 54e0654..49181bf 100644 --- a/src/endpoints/item.ts +++ b/src/endpoints/item.ts @@ -57,10 +57,47 @@ export default function item(token: string | undefined, itemId: ItemId) { }, }; + const file = (filename: string) => ({ + /** Get the contents of the given file */ + get: async () => { + return apiFetch( + 'GET', + `/data/${itemIdToUrl(itemId, filename)}`, + { token } + ).response; + }, + /** Create a file at the given path */ + post: async (file: File) => { + return apiFetch( + 'POST', + `/data/${itemIdToUrl(itemId, filename)}`, + { token, ...payload.file(file) }, + ).json(); + }, + /** Update the contents of a file at the given path */ + put: async (file: File) => { + return apiFetch( + 'PUT', + `/data/${itemIdToUrl(itemId, filename)}`, + { token, ...payload.file(file) }, + ).json(); + }, + /** Remove the file at the given path */ + delete: async () => { + return apiFetch( + 'DELETE', + `/data/${itemIdToUrl(itemId, filename)}`, + { token }, + ).json(); + }, + }); + return { /** `info.json` of the item */ info, /** `README.md` of the item */ readme, + /** A file belonging to the item */ + file, }; } diff --git a/src/routes/data/[...item]/[filename]/+server.ts b/src/routes/data/[...item]/[filename]/+server.ts index 419bfb5..35d634d 100644 --- a/src/routes/data/[...item]/[filename]/+server.ts +++ b/src/routes/data/[...item]/[filename]/+server.ts @@ -1,19 +1,22 @@ /** * API endpoints for accessing and modifying generic files. */ -import { formatItemId, itemIdFromUrl, type ItemId } from '$lib/server/data/itemId'; -import sanitize from 'sanitize-filename'; import fs from 'fs/promises'; import { error, json } from '@sveltejs/kit'; +import sanitize from 'sanitize-filename'; import mime from 'mime-types'; -import { fileExists } from '$lib/server/index.js'; -import { validateTokenFromRequest } from '$lib/server/auth/tokens.js'; -import { itemPath } from '$lib/server/data/item.js'; -type Request = import('./$types.js').RequestEvent; +import { formatItemId, itemIdFromUrl, type ItemId } from '$lib/server/data/itemId'; +import { fileExists } from '$lib/server/util'; +import { validateTokenFromRequest } from '$lib/server/auth/tokens'; +import { itemExists, itemPath } from '$lib/server/data/item.js'; +type Request = import('./$types').RequestEvent; /** GET request handler, returns file contents */ export async function GET(req: Request) { const item: ItemId = itemIdFromUrl(req.params.item); + if (!await itemExists(item)) { + error(404, `Item ${formatItemId(item)} does not exist`); + } // Sanitize the filename to prevent unwanted access to the server's filesystem const filename = sanitize(req.params.filename); // Get the path of the file to serve @@ -42,34 +45,29 @@ export async function GET(req: Request) { * * Note: this does not sanitize the given file path, so that must be done by the caller. */ -async function updateFileFromRequest(file: string, req: Request): Promise { - // Ensure Content-Type header matches expected mimetype of header - const fileMimeType = mime.contentType(file); - const reqMimeType = req.request.headers.get('Content-Type'); - - if (req.request.body === null) { - error(400, 'Request body must be given'); +async function updateFileFromRequest(filename: string, req: Request): Promise { + // Load the full form data + // Kinda annoying that we have to load it all into memory, but I was unable to convince libraries + // like `busboy` and `formidable` to work nicely. + const form = await req.request.formData(); + const content = form.get('content')!; + if (!(content instanceof File)) { + error(400, '"content" field of form must have type `File`'); } - // Only check if the mimetype of the file is known - if (fileMimeType && fileMimeType !== reqMimeType) { - error(400, `Incorrect mimetype for file ${file}. Expected ${fileMimeType}, got ${reqMimeType}`); - } - - // It would probably be nicer to pipe the data directly to the disk as it is received - // but I am unable to make TypeScript happy. - // Even then, this way, ESLint claims I'm calling an error-type value, which is cursed. + // No idea why ESLint claims that `content.bytes` has an "error" type. It works fine, and + // TypeScript is perfectly happy with it. // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const data = await req.request.bytes(); - - // Write the file - await fs.writeFile(file, data).catch(e => error(400, `${e}`)); + await fs.writeFile(filename, await content.bytes()); } /** POST request handler, creates file (if it doesn't exist) */ export async function POST(req: Request) { await validateTokenFromRequest(req); const item: ItemId = itemIdFromUrl(req.params.item); + if (!await itemExists(item)) { + error(404, `Item ${formatItemId(item)} does not exist`); + } const filename = sanitize(req.params.filename); const file = itemPath(item, filename); @@ -85,6 +83,9 @@ export async function POST(req: Request) { export async function PUT(req: Request) { await validateTokenFromRequest(req); const item: ItemId = itemIdFromUrl(req.params.item); + if (!await itemExists(item)) { + error(404, `Item ${formatItemId(item)} does not exist`); + } const filename = sanitize(req.params.filename); const file = itemPath(item, filename); @@ -100,6 +101,9 @@ export async function PUT(req: Request) { export async function DELETE(req: Request) { await validateTokenFromRequest(req); const item: ItemId = itemIdFromUrl(req.params.item); + if (!await itemExists(item)) { + error(404, `Item ${formatItemId(item)} does not exist`); + } const filename = sanitize(req.params.filename); const file = itemPath(item, filename); diff --git a/svelte.config.js b/svelte.config.js index cd6ff41..0179729 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -19,6 +19,12 @@ const config = { version: { name: process.env.npm_package_version, }, + // Disable origin checking, so that form submission works when using an API client. + // This may cause security issues, but since a valid token is required for all file uploads it + // should be ok. + csrf: { + checkOrigin: false, + }, // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // If your environment is not supported, or you settled on a specific environment, switch out the adapter. // See https://kit.svelte.dev/docs/adapters for more information about adapters. diff --git a/tests/backend/fileRequest.ts b/tests/backend/fileRequest.ts new file mode 100644 index 0000000..bbeb9f0 --- /dev/null +++ b/tests/backend/fileRequest.ts @@ -0,0 +1,25 @@ +/** + * Code for creating a `File` object from a file on the file system. + */ +import fs from 'fs/promises'; +import path from 'path'; +import { contentType } from 'mime-types'; + +/** + * Create a `File` object from a file on the file system. + * + * Derived from https://github.com/abrwn/get-file-object-from-local-path/blob/main/index.js + * + * Modified to make it async. + * + * MIT License + * * Copyright (c) 2014 Jonathan Ong + * * Copyright (c) 2015 Douglas Christopher Wilson + * * Copyright (c) 2024 Maddy Guthridge + */ +export default async function fromFileSystem(file: string): Promise { + const buffer = await fs.readFile(file); + const arrayBuffer = [buffer.subarray(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)]; + const mimetype = contentType(file) || undefined; + return new File(arrayBuffer, path.basename(file), { type: mimetype }); +} diff --git a/tests/backend/item/file.delete.test.ts b/tests/backend/item/file.delete.test.ts new file mode 100644 index 0000000..85a2271 --- /dev/null +++ b/tests/backend/item/file.delete.test.ts @@ -0,0 +1,31 @@ +/** + * Test cases for uploading files to an item. + */ + +import { it, describe, beforeEach } from 'vitest'; +import genTokenTests from '../tokenCase'; +import type { ApiClient } from '$endpoints'; +import { setup } from '../helpers'; +import fromFileSystem from '../fileRequest'; + +let api: ApiClient; +beforeEach(async () => { + api = (await setup()).api; +}); + + +describe('Success', () => { + it.todo('Deletes the file'); +}); + +describe('401', () => { + genTokenTests( + () => api, + async api => api.item([]).file('example.md').post(await fromFileSystem('README.md')), + ); +}); + +describe('404', () => { + it.todo('Errors if the item does not exist'); + it.todo('Errors if the file does not exist'); +}); diff --git a/tests/backend/item/file.get.test.ts b/tests/backend/item/file.get.test.ts new file mode 100644 index 0000000..d0c7f2a --- /dev/null +++ b/tests/backend/item/file.get.test.ts @@ -0,0 +1,24 @@ +/** + * Test cases for getting a file associated with an item. + */ + +import { it, describe, beforeEach } from 'vitest'; +import type { ApiClient } from '$endpoints'; +import { setup } from '../helpers'; +import fromFileSystem from '../fileRequest'; + +let api: ApiClient; +beforeEach(async () => { + api = (await setup()).api; + await api.item([]).file('example.md').put(await fromFileSystem('README.md')); +}); + + +describe('Success', () => { + it.todo('Returns the file'); +}); + +describe('404', () => { + it.todo('Errors if the item does not exist'); + it.todo('Errors if the file does not exist'); +}); diff --git a/tests/backend/item/file.post.test.ts b/tests/backend/item/file.post.test.ts new file mode 100644 index 0000000..2c4ae36 --- /dev/null +++ b/tests/backend/item/file.post.test.ts @@ -0,0 +1,44 @@ +/** + * Test cases for uploading files to an item. + */ + +import { it, describe, beforeEach, expect } from 'vitest'; +import genTokenTests from '../tokenCase'; +import type { ApiClient } from '$endpoints'; +import { setup } from '../helpers'; +import fromFileSystem from '../fileRequest'; + +let api: ApiClient; +beforeEach(async () => { + api = (await setup()).api; +}); + + +describe('Success', () => { + it('Creates the file', async () => { + await expect(api.item([]).file('example.md').post(await fromFileSystem('README.md'))) + .resolves.toStrictEqual({}); + }); +}); + +describe('400', () => { + it('Errors if the file already exists', async () => { + await api.item([]).file('example.md').post(await fromFileSystem('README.md')); + await expect(api.item([]).file('example.md').post(await fromFileSystem('README.md'))) + .rejects.toMatchObject({ code: 400 }); + }); +}); + +describe('401', () => { + genTokenTests( + () => api, + async api => api.item([]).file('example.md').post(await fromFileSystem('README.md')), + ); +}); + +describe('404', () => { + it('Errors if the item does not exist', async () => { + await expect(api.item(['unknown']).file('example.md').post(await fromFileSystem('README.md'))) + .rejects.toMatchObject({ code: 404 }); + }); +}); diff --git a/tests/backend/item/file.put.test.ts b/tests/backend/item/file.put.test.ts new file mode 100644 index 0000000..af23547 --- /dev/null +++ b/tests/backend/item/file.put.test.ts @@ -0,0 +1,32 @@ +/** + * Test cases for uploading files to an item. + */ + +import { it, describe, beforeEach } from 'vitest'; +import genTokenTests from '../tokenCase'; +import type { ApiClient } from '$endpoints'; +import { setup } from '../helpers'; +import fromFileSystem from '../fileRequest'; + +let api: ApiClient; +beforeEach(async () => { + api = (await setup()).api; + await api.item([]).file('example.md').put(await fromFileSystem('README.md')); +}); + + +describe('Success', () => { + it.todo('Updates the file'); +}); + +describe('401', () => { + genTokenTests( + () => api, + async api => api.item([]).file('example.md').put(await fromFileSystem('README.md')), + ); +}); + +describe('404', () => { + it.todo('Errors if the item does not exist'); + it.todo('Errors if the file does not exist'); +}); From 3171019969e8c883b7b3d54745f9e551a36d7c48 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 15:06:53 +1100 Subject: [PATCH 022/149] Write tests for getting file contents --- src/endpoints/fetch/response.ts | 19 +++++++++++++++++++ src/endpoints/item.ts | 2 +- tests/backend/item/file.get.test.ts | 20 +++++++++++++++----- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/endpoints/fetch/response.ts b/src/endpoints/fetch/response.ts index 34346ce..b0b1155 100644 --- a/src/endpoints/fetch/response.ts +++ b/src/endpoints/fetch/response.ts @@ -59,6 +59,24 @@ export async function json(response: Promise): Promise { return Object.assign({}, json); } +export async function buffer(response: Promise): Promise { + const res = await response; + if ([404, 405].includes(res.status)) { + throw new ApiError(404, `Error ${res.status} at ${res.url}`); + } + // As per usual, this ESLint error is not correct. + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const buf = await res.bytes(); + if ([400, 401, 403].includes(res.status)) { + throw new ApiError(res.status, buf); + } + if (![200, 304].includes(res.status)) { + // Unknown error + throw new ApiError(res.status, `Request got status code ${res.status}`); + } + return Buffer.from(buf); +} + /** * Handler for responses, allowing users to `.` to get the response data in the format they * desire @@ -66,5 +84,6 @@ export async function json(response: Promise): Promise { export default (response: Promise) => ({ json: () => json(response), text: () => text(response), + buffer: () => buffer(response), response, }) diff --git a/src/endpoints/item.ts b/src/endpoints/item.ts index 49181bf..e713daf 100644 --- a/src/endpoints/item.ts +++ b/src/endpoints/item.ts @@ -64,7 +64,7 @@ export default function item(token: string | undefined, itemId: ItemId) { 'GET', `/data/${itemIdToUrl(itemId, filename)}`, { token } - ).response; + ).buffer(); }, /** Create a file at the given path */ post: async (file: File) => { diff --git a/tests/backend/item/file.get.test.ts b/tests/backend/item/file.get.test.ts index d0c7f2a..26e18d4 100644 --- a/tests/backend/item/file.get.test.ts +++ b/tests/backend/item/file.get.test.ts @@ -2,23 +2,33 @@ * Test cases for getting a file associated with an item. */ -import { it, describe, beforeEach } from 'vitest'; +import { it, describe, beforeEach, expect } from 'vitest'; import type { ApiClient } from '$endpoints'; import { setup } from '../helpers'; import fromFileSystem from '../fileRequest'; +import { readFile } from 'fs/promises'; let api: ApiClient; beforeEach(async () => { api = (await setup()).api; - await api.item([]).file('example.md').put(await fromFileSystem('README.md')); + await api.item([]).file('example.md').post(await fromFileSystem('README.md')); }); describe('Success', () => { - it.todo('Returns the file'); + it('Returns the file', async () => { + const content = await api.item([]).file('example.md').get().then(buf => buf.toString()); + expect(content).toStrictEqual(await readFile('README.md', { encoding: 'utf-8' })); + }); }); describe('404', () => { - it.todo('Errors if the item does not exist'); - it.todo('Errors if the file does not exist'); + it('Errors if the item does not exist', async () => { + await expect(api.item(['invalid']).file('example.md').get()) + .rejects.toMatchObject({ code: 404 }); + }); + it('Errors if the file does not exist', async () => { + await expect(api.item([]).file('invalid').get()) + .rejects.toMatchObject({ code: 404 }); + }); }); From 3d76121c38dfc36507f8f01809277bf337f4dd02 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 15:30:12 +1100 Subject: [PATCH 023/149] Implement tests for delete and put for files --- tests/backend/item/file.delete.test.ts | 23 ++++++++++++++++++----- tests/backend/item/file.put.test.ts | 22 +++++++++++++++++----- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/tests/backend/item/file.delete.test.ts b/tests/backend/item/file.delete.test.ts index 85a2271..8b1f25b 100644 --- a/tests/backend/item/file.delete.test.ts +++ b/tests/backend/item/file.delete.test.ts @@ -2,20 +2,27 @@ * Test cases for uploading files to an item. */ -import { it, describe, beforeEach } from 'vitest'; -import genTokenTests from '../tokenCase'; +import { it, describe, beforeEach, expect } from 'vitest'; import type { ApiClient } from '$endpoints'; import { setup } from '../helpers'; import fromFileSystem from '../fileRequest'; +import genTokenTests from '../tokenCase'; let api: ApiClient; beforeEach(async () => { api = (await setup()).api; + await api.item([]).file('example.md').post(await fromFileSystem('README.md')); }); describe('Success', () => { - it.todo('Deletes the file'); + it('Deletes the file', async () => { + await expect(api.item([]).file('example.md').delete()) + .resolves.toStrictEqual({}); + // Now requesting the file should give a 404 + await expect(api.item([]).file('example.md').get()) + .rejects.toMatchObject({ code: 404 }); + }); }); describe('401', () => { @@ -26,6 +33,12 @@ describe('401', () => { }); describe('404', () => { - it.todo('Errors if the item does not exist'); - it.todo('Errors if the file does not exist'); + it('Errors if the item does not exist', async () => { + await expect(api.item(['invalid']).file('example.md').delete()) + .rejects.toMatchObject({ code: 404 }); + }); + it('Errors if the file does not exist', async () => { + await expect(api.item([]).file('invalid').delete()) + .rejects.toMatchObject({ code: 404 }); + }); }); diff --git a/tests/backend/item/file.put.test.ts b/tests/backend/item/file.put.test.ts index af23547..eb401c3 100644 --- a/tests/backend/item/file.put.test.ts +++ b/tests/backend/item/file.put.test.ts @@ -2,21 +2,27 @@ * Test cases for uploading files to an item. */ -import { it, describe, beforeEach } from 'vitest'; +import { it, describe, beforeEach, expect } from 'vitest'; import genTokenTests from '../tokenCase'; import type { ApiClient } from '$endpoints'; import { setup } from '../helpers'; import fromFileSystem from '../fileRequest'; +import { readFile } from 'fs/promises'; let api: ApiClient; beforeEach(async () => { api = (await setup()).api; - await api.item([]).file('example.md').put(await fromFileSystem('README.md')); + await api.item([]).file('example.md').post(await fromFileSystem('README.md')); }); describe('Success', () => { - it.todo('Updates the file'); + it('Updates the file', async () => { + await api.item([]).file('example.md').put(await fromFileSystem('LICENSE.md')); + // Contents should be updated + const content = await api.item([]).file('example.md').get().then(buf => buf.toString()); + expect(content).toStrictEqual(await readFile('LICENSE.md', { encoding: 'utf-8' })); + }); }); describe('401', () => { @@ -27,6 +33,12 @@ describe('401', () => { }); describe('404', () => { - it.todo('Errors if the item does not exist'); - it.todo('Errors if the file does not exist'); + it('Errors if the item does not exist', async () => { + await expect(api.item(['file']).file('file').put(await fromFileSystem('LICENSE.md'))) + .rejects.toMatchObject({ code: 404 }); + }); + it('Errors if the file does not exist', async () => { + await expect(api.item([]).file('invalid').put(await fromFileSystem('LICENSE.md'))) + .rejects.toMatchObject({ code: 404 }); + }); }); From 1d145a0933b886c1c8fd96a410e6e77816adbba7 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 15:34:16 +1100 Subject: [PATCH 024/149] Remove '.js' extension from imports --- src/routes/+page.server.ts | 2 +- src/routes/about/+page.server.ts | 2 +- src/routes/admin/+page.server.ts | 4 ++-- src/routes/admin/firstrun/data/+page.server.ts | 4 ++-- src/routes/admin/login/+page.server.ts | 2 +- src/routes/api/admin/auth/change/+server.ts | 8 ++++---- src/routes/api/admin/auth/disable/+server.ts | 4 ++-- src/routes/api/admin/auth/login/+server.ts | 8 ++++---- src/routes/api/admin/auth/logout/+server.ts | 4 ++-- src/routes/api/admin/auth/revoke/+server.ts | 4 ++-- src/routes/api/admin/config/+server.ts | 4 ++-- src/routes/api/admin/data/refresh/+server.ts | 4 ++-- src/routes/api/admin/firstrun/account/+server.ts | 4 ++-- src/routes/api/admin/firstrun/data/+server.ts | 6 +++--- src/routes/api/admin/git/+server.ts | 4 ++-- src/routes/api/admin/git/commit/+server.ts | 4 ++-- src/routes/api/admin/git/init/+server.ts | 4 ++-- src/routes/api/admin/git/pull/+server.ts | 4 ++-- src/routes/api/admin/git/push/+server.ts | 6 +++--- src/routes/api/admin/keys/+server.ts | 6 +++--- src/routes/api/admin/keys/generate/+server.ts | 2 +- src/routes/api/debug/clear/+server.ts | 2 +- src/routes/api/debug/echo/+server.ts | 2 +- src/routes/data/[...item]/README.md/+server.ts | 8 ++++---- src/routes/data/[...item]/[filename]/+server.ts | 2 +- src/routes/data/[...item]/info.json/+server.ts | 14 +++++++------- src/routes/favicon/+server.ts | 2 +- tests/backend/item/file.delete.test.ts | 2 -- 28 files changed, 60 insertions(+), 62 deletions(-) diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index fb9f418..5d67974 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -3,7 +3,7 @@ import { getPortfolioGlobals } from '$lib/server'; import { authIsSetUp, dataIsSetUp } from '$lib/server/data/dataDir'; import { isRequestAuthorized } from '$lib/server/auth/tokens'; -export async function load(req: import('./$types.js').RequestEvent) { +export async function load(req: import('./$types').RequestEvent) { if (!await authIsSetUp()) { redirect(303, '/admin/firstrun/account'); } diff --git a/src/routes/about/+page.server.ts b/src/routes/about/+page.server.ts index 55f5394..1e8e3ef 100644 --- a/src/routes/about/+page.server.ts +++ b/src/routes/about/+page.server.ts @@ -8,7 +8,7 @@ import { version } from '$app/environment'; // import { version as VITE_VERSION } from 'vite'; import os from 'os'; -export async function load(req: import('./$types.js').RequestEvent) { +export async function load(req: import('./$types').RequestEvent) { // If config fails to load (eg firstrun), just give a blank config let config: ConfigJson; let isInit = true; diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts index 7ad9b1e..19a5957 100644 --- a/src/routes/admin/+page.server.ts +++ b/src/routes/admin/+page.server.ts @@ -2,9 +2,9 @@ import { getPortfolioGlobals } from '$lib/server'; import { redirectOnInvalidToken } from '$lib/server/auth/tokens'; import { dataDirUsesGit } from '$lib/server/data/dataDir'; import { getRepoStatus } from '$lib/server/git'; -import { getPrivateKeyPath, getPublicKey } from '$lib/server/keys.js'; +import { getPrivateKeyPath, getPublicKey } from '$lib/server/keys'; -export async function load(req: import('./$types.js').RequestEvent) { +export async function load(req: import('./$types').RequestEvent) { const globals = await getPortfolioGlobals(); await redirectOnInvalidToken(req, '/admin/login'); const repo = await dataDirUsesGit() ? await getRepoStatus() : null; diff --git a/src/routes/admin/firstrun/data/+page.server.ts b/src/routes/admin/firstrun/data/+page.server.ts index 7cd885c..954c36e 100644 --- a/src/routes/admin/firstrun/data/+page.server.ts +++ b/src/routes/admin/firstrun/data/+page.server.ts @@ -1,9 +1,9 @@ import { redirect } from '@sveltejs/kit'; import { authIsSetUp, dataIsSetUp } from '$lib/server/data/dataDir'; import { redirectOnInvalidToken } from '$lib/server/auth/tokens'; -import { getPrivateKeyPath, getPublicKey } from '$lib/server/keys.js'; +import { getPrivateKeyPath, getPublicKey } from '$lib/server/keys'; -export async function load(req: import('./$types.js').RequestEvent) { +export async function load(req: import('./$types').RequestEvent) { if (!await authIsSetUp()) { redirect(303, '/admin/firstrun/account'); } diff --git a/src/routes/admin/login/+page.server.ts b/src/routes/admin/login/+page.server.ts index ff28ca6..42325f9 100644 --- a/src/routes/admin/login/+page.server.ts +++ b/src/routes/admin/login/+page.server.ts @@ -2,7 +2,7 @@ import { getPortfolioGlobals } from '$lib/server'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { redirect } from '@sveltejs/kit'; -export async function load(req: import('./$types.js').RequestEvent) { +export async function load(req: import('./$types').RequestEvent) { // Users that are already logged in should be redirected to the main admin // page let loggedIn = false; diff --git a/src/routes/api/admin/auth/change/+server.ts b/src/routes/api/admin/auth/change/+server.ts index 05a0762..e4a2fba 100644 --- a/src/routes/api/admin/auth/change/+server.ts +++ b/src/routes/api/admin/auth/change/+server.ts @@ -1,8 +1,8 @@ -import { hashAndSalt } from '$lib/server/auth/passwords.js'; +import { hashAndSalt } from '$lib/server/auth/passwords'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; -import { authIsSetUp } from '$lib/server/data/dataDir.js'; +import { authIsSetUp } from '$lib/server/data/dataDir'; import { getLocalConfig, setLocalConfig } from '$lib/server/data/localConfig'; -import { applyStruct } from '$lib/server/util.js'; +import { applyStruct } from '$lib/server/util'; import { error, json } from '@sveltejs/kit'; import { nanoid } from 'nanoid'; import { object, string } from 'superstruct'; @@ -14,7 +14,7 @@ const NewCredentials = object({ newPassword: string(), }); -export async function POST({ request, cookies }: import('./$types.js').RequestEvent) { +export async function POST({ request, cookies }: import('./$types').RequestEvent) { if (!await authIsSetUp()) error(400, 'Auth is not set up yet'); const uid = await validateTokenFromRequest({ request, cookies }); diff --git a/src/routes/api/admin/auth/disable/+server.ts b/src/routes/api/admin/auth/disable/+server.ts index c39ba53..a5dc1b3 100644 --- a/src/routes/api/admin/auth/disable/+server.ts +++ b/src/routes/api/admin/auth/disable/+server.ts @@ -1,11 +1,11 @@ import { validateCredentials } from '$lib/server/auth/passwords'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; -import { authIsSetUp } from '$lib/server/data/dataDir.js'; +import { authIsSetUp } from '$lib/server/data/dataDir'; import { getLocalConfig, setLocalConfig } from '$lib/server/data/localConfig'; import { error, json } from '@sveltejs/kit'; // TODO: Remove this when setting up user management -export async function POST({ request, cookies }: import('./$types.js').RequestEvent) { +export async function POST({ request, cookies }: import('./$types').RequestEvent) { if (!await authIsSetUp()) error(400, 'Auth is not set up yet'); const uid = await validateTokenFromRequest({ request, cookies }); diff --git a/src/routes/api/admin/auth/login/+server.ts b/src/routes/api/admin/auth/login/+server.ts index 9cf374d..a66317a 100644 --- a/src/routes/api/admin/auth/login/+server.ts +++ b/src/routes/api/admin/auth/login/+server.ts @@ -1,12 +1,12 @@ -import { isIpBanned, notifyFailedLogin } from '$lib/server/auth/fail2ban.js'; +import { isIpBanned, notifyFailedLogin } from '$lib/server/auth/fail2ban'; import { validateCredentials } from '$lib/server/auth/passwords'; import { generateToken } from '$lib/server/auth/tokens'; -import { authIsSetUp } from '$lib/server/data/dataDir.js'; -import { getIpFromRequest } from '$lib/server/request.js'; +import { authIsSetUp } from '$lib/server/data/dataDir'; +import { getIpFromRequest } from '$lib/server/request'; import { error, json } from '@sveltejs/kit'; -export async function POST(req: import('./$types.js').RequestEvent) { +export async function POST(req: import('./$types').RequestEvent) { if (!await authIsSetUp()) error(400, 'Auth is not set up yet'); const ip = await getIpFromRequest(req); diff --git a/src/routes/api/admin/auth/logout/+server.ts b/src/routes/api/admin/auth/logout/+server.ts index b4cf73f..5d0c1ed 100644 --- a/src/routes/api/admin/auth/logout/+server.ts +++ b/src/routes/api/admin/auth/logout/+server.ts @@ -1,8 +1,8 @@ import { getTokenFromRequest, revokeSession } from '$lib/server/auth/tokens'; -import { authIsSetUp } from '$lib/server/data/dataDir.js'; +import { authIsSetUp } from '$lib/server/data/dataDir'; import { error, json } from '@sveltejs/kit'; -export async function POST(req: import('./$types.js').RequestEvent) { +export async function POST(req: import('./$types').RequestEvent) { if (!await authIsSetUp()) error(400, 'Auth is not set up yet'); const token = getTokenFromRequest(req); diff --git a/src/routes/api/admin/auth/revoke/+server.ts b/src/routes/api/admin/auth/revoke/+server.ts index 964a62b..fdfaa69 100644 --- a/src/routes/api/admin/auth/revoke/+server.ts +++ b/src/routes/api/admin/auth/revoke/+server.ts @@ -1,10 +1,10 @@ import { validateTokenFromRequest } from '$lib/server/auth/tokens'; -import { authIsSetUp } from '$lib/server/data/dataDir.js'; +import { authIsSetUp } from '$lib/server/data/dataDir'; import { getLocalConfig, setLocalConfig } from '$lib/server/data/localConfig'; import { unixTime } from '$lib/util'; import { error, json } from '@sveltejs/kit'; -export async function POST({ request, cookies }: import('./$types.js').RequestEvent) { +export async function POST({ request, cookies }: import('./$types').RequestEvent) { if (!await authIsSetUp()) error(400, 'Auth is not set up yet'); const local = await getLocalConfig(); diff --git a/src/routes/api/admin/config/+server.ts b/src/routes/api/admin/config/+server.ts index fa4b376..f2c1ea8 100644 --- a/src/routes/api/admin/config/+server.ts +++ b/src/routes/api/admin/config/+server.ts @@ -6,7 +6,7 @@ import { version } from '$app/environment'; import fs from 'fs/promises'; import { dataIsSetUp, getDataDir } from '$lib/server/data/dataDir'; -export async function GET({ request, cookies }: import('./$types.js').RequestEvent) { +export async function GET({ request, cookies }: import('./$types').RequestEvent) { if (await dataIsSetUp()) { error(400, 'Data is not set up'); } @@ -15,7 +15,7 @@ export async function GET({ request, cookies }: import('./$types.js').RequestEve return json(getConfig(), { status: 200 }); } -export async function PUT({ request, cookies }: import('./$types.js').RequestEvent) { +export async function PUT({ request, cookies }: import('./$types').RequestEvent) { if (await dataIsSetUp()) { error(400, 'Data is not set up'); } diff --git a/src/routes/api/admin/data/refresh/+server.ts b/src/routes/api/admin/data/refresh/+server.ts index ef0bb3f..ee31384 100644 --- a/src/routes/api/admin/data/refresh/+server.ts +++ b/src/routes/api/admin/data/refresh/+server.ts @@ -1,8 +1,8 @@ import { validateTokenFromRequest } from '$lib/server/auth/tokens'; -import { dataIsSetUp } from '$lib/server/data/dataDir.js'; +import { dataIsSetUp } from '$lib/server/data/dataDir'; import { error, json } from '@sveltejs/kit'; -export async function POST({ request, cookies }: import('./$types.js').RequestEvent) { +export async function POST({ request, cookies }: import('./$types').RequestEvent) { if (await dataIsSetUp()) { error(400, 'Data is not set up'); } diff --git a/src/routes/api/admin/firstrun/account/+server.ts b/src/routes/api/admin/firstrun/account/+server.ts index 00dffcb..447944e 100644 --- a/src/routes/api/admin/firstrun/account/+server.ts +++ b/src/routes/api/admin/firstrun/account/+server.ts @@ -3,7 +3,7 @@ import { authIsSetUp } from '$lib/server/data/dataDir'; import { authSetup } from '$lib/server/auth/setup'; import { object, string, type Infer } from 'superstruct'; import { applyStruct } from '$lib/server/util'; -import { validateId } from '$lib/validate.js'; +import { validateId } from '$lib/validate'; import validator from 'validator'; const FirstRunAuthOptionsStruct = object({ @@ -13,7 +13,7 @@ const FirstRunAuthOptionsStruct = object({ export type FirstRunAuthOptions = Infer; -export async function POST({ request, cookies }: import('./$types.js').RequestEvent) { +export async function POST({ request, cookies }: import('./$types').RequestEvent) { const options = applyStruct(await request.json(), FirstRunAuthOptionsStruct); if (await authIsSetUp()) { diff --git a/src/routes/api/admin/firstrun/data/+server.ts b/src/routes/api/admin/firstrun/data/+server.ts index 5e9a417..4561bca 100644 --- a/src/routes/api/admin/firstrun/data/+server.ts +++ b/src/routes/api/admin/firstrun/data/+server.ts @@ -2,8 +2,8 @@ import { error, json } from '@sveltejs/kit'; import { dataIsSetUp } from '$lib/server/data/dataDir'; import { nullable, object, optional, string, type Infer } from 'superstruct'; import { applyStruct } from '$lib/server/util'; -import { setupData } from '$lib/server/data/setup.js'; -import { validateTokenFromRequest } from '$lib/server/auth/tokens.js'; +import { setupData } from '$lib/server/data/setup'; +import { validateTokenFromRequest } from '$lib/server/auth/tokens'; const FirstRunDataOptionsStruct = object({ repoUrl: optional(nullable(string())), @@ -12,7 +12,7 @@ const FirstRunDataOptionsStruct = object({ export type FirstRunDataOptions = Infer; -export async function POST({ request, cookies }: import('./$types.js').RequestEvent) { +export async function POST({ request, cookies }: import('./$types').RequestEvent) { if (await dataIsSetUp()) { error(403, 'Data directory is already set up'); } diff --git a/src/routes/api/admin/git/+server.ts b/src/routes/api/admin/git/+server.ts index 233e33b..86c9e5a 100644 --- a/src/routes/api/admin/git/+server.ts +++ b/src/routes/api/admin/git/+server.ts @@ -1,10 +1,10 @@ import { error, json } from '@sveltejs/kit'; import { dataDirUsesGit } from '$lib/server/data/dataDir'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; -import { dataIsSetUp } from '$lib/server/data/dataDir.js'; +import { dataIsSetUp } from '$lib/server/data/dataDir'; import { getRepoStatus } from '$lib/server/git'; -export async function GET({ request, cookies }: import('./$types.js').RequestEvent) { +export async function GET({ request, cookies }: import('./$types').RequestEvent) { if (await dataIsSetUp()) { error(400, 'Data is not set up'); } diff --git a/src/routes/api/admin/git/commit/+server.ts b/src/routes/api/admin/git/commit/+server.ts index c9d75a8..99ce9ac 100644 --- a/src/routes/api/admin/git/commit/+server.ts +++ b/src/routes/api/admin/git/commit/+server.ts @@ -3,9 +3,9 @@ import { error, json } from '@sveltejs/kit'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { dataDirUsesGit } from '$lib/server/data/dataDir'; import { commit, getRepoStatus } from '$lib/server/git'; -import { dataIsSetUp } from '$lib/server/data/dataDir.js'; +import { dataIsSetUp } from '$lib/server/data/dataDir'; -export async function POST({ request, cookies }: import('./$types.js').RequestEvent) { +export async function POST({ request, cookies }: import('./$types').RequestEvent) { if (await dataIsSetUp()) { error(400, 'Data is not set up'); } diff --git a/src/routes/api/admin/git/init/+server.ts b/src/routes/api/admin/git/init/+server.ts index b5f69f3..ecdba6a 100644 --- a/src/routes/api/admin/git/init/+server.ts +++ b/src/routes/api/admin/git/init/+server.ts @@ -1,11 +1,11 @@ import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { dataDirUsesGit } from '$lib/server/data/dataDir'; import { getRepoStatus, initRepo } from '$lib/server/git'; -import { dataIsSetUp } from '$lib/server/data/dataDir.js'; +import { dataIsSetUp } from '$lib/server/data/dataDir'; import { error, json } from '@sveltejs/kit'; import { object, string, validate } from 'superstruct'; -export async function POST({ request, cookies }: import('./$types.js').RequestEvent) { +export async function POST({ request, cookies }: import('./$types').RequestEvent) { if (await dataIsSetUp()) { error(400, 'Data is not set up'); } diff --git a/src/routes/api/admin/git/pull/+server.ts b/src/routes/api/admin/git/pull/+server.ts index b1d2c0e..a6c6fa5 100644 --- a/src/routes/api/admin/git/pull/+server.ts +++ b/src/routes/api/admin/git/pull/+server.ts @@ -1,10 +1,10 @@ import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { dataDirUsesGit } from '$lib/server/data/dataDir'; import { getRepoStatus, pull } from '$lib/server/git'; -import { dataIsSetUp } from '$lib/server/data/dataDir.js'; +import { dataIsSetUp } from '$lib/server/data/dataDir'; import { error, json } from '@sveltejs/kit'; -export async function POST({ request, cookies }: import('./$types.js').RequestEvent) { +export async function POST({ request, cookies }: import('./$types').RequestEvent) { if (await dataIsSetUp()) { error(400, 'Data is not set up'); } diff --git a/src/routes/api/admin/git/push/+server.ts b/src/routes/api/admin/git/push/+server.ts index 7735c6d..66542a7 100644 --- a/src/routes/api/admin/git/push/+server.ts +++ b/src/routes/api/admin/git/push/+server.ts @@ -1,10 +1,10 @@ import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { dataDirUsesGit } from '$lib/server/data/dataDir'; -import { getRepoStatus, push } from '$lib/server/git.js'; -import { dataIsSetUp } from '$lib/server/data/dataDir.js'; +import { getRepoStatus, push } from '$lib/server/git'; +import { dataIsSetUp } from '$lib/server/data/dataDir'; import { error, json } from '@sveltejs/kit'; -export async function POST({ request, cookies }: import('./$types.js').RequestEvent) { +export async function POST({ request, cookies }: import('./$types').RequestEvent) { if (await dataIsSetUp()) { error(400, 'Data is not set up'); } diff --git a/src/routes/api/admin/keys/+server.ts b/src/routes/api/admin/keys/+server.ts index 5b52894..2bb8469 100644 --- a/src/routes/api/admin/keys/+server.ts +++ b/src/routes/api/admin/keys/+server.ts @@ -6,7 +6,7 @@ import { fileExists } from '$lib/server/util'; import { disableKey, getPublicKey, setKeyPath } from '$lib/server/keys'; /** Return the current public key */ -export async function GET(req: import('./$types.js').RequestEvent) { +export async function GET(req: import('./$types').RequestEvent) { if (!await authIsSetUp()) { error(400); } @@ -21,7 +21,7 @@ export async function GET(req: import('./$types.js').RequestEvent) { } /** Set the path to the key */ -export async function POST(req: import('./$types.js').RequestEvent) { +export async function POST(req: import('./$types').RequestEvent) { if (!await authIsSetUp()) { error(400); } @@ -46,7 +46,7 @@ export async function POST(req: import('./$types.js').RequestEvent) { } /** Disable SSH key-based authentication */ -export async function DELETE(req: import('./$types.js').RequestEvent) { +export async function DELETE(req: import('./$types').RequestEvent) { if (!await authIsSetUp()) { error(400); } diff --git a/src/routes/api/admin/keys/generate/+server.ts b/src/routes/api/admin/keys/generate/+server.ts index 9fc5643..d999fd3 100644 --- a/src/routes/api/admin/keys/generate/+server.ts +++ b/src/routes/api/admin/keys/generate/+server.ts @@ -5,7 +5,7 @@ import { getLocalConfig } from '$lib/server/data/localConfig'; import { generateKey } from '$lib/server/keys'; /** Generate an SSH key */ -export async function POST(req: import('./$types.js').RequestEvent) { +export async function POST(req: import('./$types').RequestEvent) { if (!await authIsSetUp()) { error(400); } diff --git a/src/routes/api/debug/clear/+server.ts b/src/routes/api/debug/clear/+server.ts index 3bda6f5..3cdfa71 100644 --- a/src/routes/api/debug/clear/+server.ts +++ b/src/routes/api/debug/clear/+server.ts @@ -3,7 +3,7 @@ import { getDataDir, getPrivateDataDir } from '$lib/server/data/dataDir'; import { error, json } from '@sveltejs/kit'; import { rimraf } from 'rimraf'; -export async function DELETE({ cookies }: import('./$types.js').RequestEvent) { +export async function DELETE({ cookies }: import('./$types').RequestEvent) { if (!dev) error(404); // Delete data directory await rimraf(getDataDir()); diff --git a/src/routes/api/debug/echo/+server.ts b/src/routes/api/debug/echo/+server.ts index 345fa5e..122ae6b 100644 --- a/src/routes/api/debug/echo/+server.ts +++ b/src/routes/api/debug/echo/+server.ts @@ -2,7 +2,7 @@ import { dev } from '$app/environment'; import { error, json } from '@sveltejs/kit'; import chalk from 'chalk'; -export async function POST({ request }: import('./$types.js').RequestEvent) { +export async function POST({ request }: import('./$types').RequestEvent) { if (!dev) error(404); const { text } = await request.json(); console.log(chalk.bgMagenta.gray.bold(' ECHO '), text); diff --git a/src/routes/data/[...item]/README.md/+server.ts b/src/routes/data/[...item]/README.md/+server.ts index 08c546f..5d5b253 100644 --- a/src/routes/data/[...item]/README.md/+server.ts +++ b/src/routes/data/[...item]/README.md/+server.ts @@ -7,10 +7,10 @@ import { formatItemId, itemIdFromUrl, type ItemId } from '$lib/server/data/itemId'; import fs from 'fs/promises'; import { error, json } from '@sveltejs/kit'; -import { validateTokenFromRequest } from '$lib/server/auth/tokens.js'; -import { itemExists, itemPath } from '$lib/server/data/item.js'; -import { dataIsSetUp } from '$lib/server/data/dataDir.js'; -type Request = import('./$types.js').RequestEvent; +import { validateTokenFromRequest } from '$lib/server/auth/tokens'; +import { itemExists, itemPath } from '$lib/server/data/item'; +import { dataIsSetUp } from '$lib/server/data/dataDir'; +type Request = import('./$types').RequestEvent; /** GET request handler, returns README text */ export async function GET(req: Request) { diff --git a/src/routes/data/[...item]/[filename]/+server.ts b/src/routes/data/[...item]/[filename]/+server.ts index 35d634d..d3a2122 100644 --- a/src/routes/data/[...item]/[filename]/+server.ts +++ b/src/routes/data/[...item]/[filename]/+server.ts @@ -8,7 +8,7 @@ import mime from 'mime-types'; import { formatItemId, itemIdFromUrl, type ItemId } from '$lib/server/data/itemId'; import { fileExists } from '$lib/server/util'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; -import { itemExists, itemPath } from '$lib/server/data/item.js'; +import { itemExists, itemPath } from '$lib/server/data/item'; type Request = import('./$types').RequestEvent; /** GET request handler, returns file contents */ diff --git a/src/routes/data/[...item]/info.json/+server.ts b/src/routes/data/[...item]/info.json/+server.ts index e91883b..1b64deb 100644 --- a/src/routes/data/[...item]/info.json/+server.ts +++ b/src/routes/data/[...item]/info.json/+server.ts @@ -3,17 +3,17 @@ import { json, error } from '@sveltejs/kit'; import { object, string } from 'superstruct'; import { formatItemId, itemIdFromUrl, validateItemId, itemIdTail, itemParent } from '$lib/server/data/itemId'; import { deleteItem, getItemInfo, itemExists, itemPath, setItemInfo, validateItemInfo } from '$lib/server/data/item'; -import { validateTokenFromRequest } from '$lib/server/auth/tokens.js'; -import { applyStruct } from '$lib/server/util.js'; -import { validateName } from '$lib/validate.js'; -import formatTemplate from '$lib/server/formatTemplate.js'; -import { ITEM_README } from '$lib/server/data/text.js'; -import { dataIsSetUp } from '$lib/server/data/dataDir.js'; +import { validateTokenFromRequest } from '$lib/server/auth/tokens'; +import { applyStruct } from '$lib/server/util'; +import { validateName } from '$lib/validate'; +import formatTemplate from '$lib/server/formatTemplate'; +import { ITEM_README } from '$lib/server/data/text'; +import { dataIsSetUp } from '$lib/server/data/dataDir'; /** * API endpoints for accessing info.json */ -type Request = import('./$types.js').RequestEvent; +type Request = import('./$types').RequestEvent; /** Get item info.json */ export async function GET(req: Request) { diff --git a/src/routes/favicon/+server.ts b/src/routes/favicon/+server.ts index fce8958..4e96a75 100644 --- a/src/routes/favicon/+server.ts +++ b/src/routes/favicon/+server.ts @@ -4,7 +4,7 @@ import mime from 'mime-types'; import { dataIsSetUp, getDataDir } from '$lib/server/data/dataDir'; import { getConfig } from '$lib/server/data/config'; -export async function GET(req: import('./$types.js').RequestEvent) { +export async function GET(req: import('./$types').RequestEvent) { if (!await dataIsSetUp()) { error(404, 'Favicon not set up'); } diff --git a/tests/backend/item/file.delete.test.ts b/tests/backend/item/file.delete.test.ts index 8b1f25b..c9db89a 100644 --- a/tests/backend/item/file.delete.test.ts +++ b/tests/backend/item/file.delete.test.ts @@ -1,7 +1,6 @@ /** * Test cases for uploading files to an item. */ - import { it, describe, beforeEach, expect } from 'vitest'; import type { ApiClient } from '$endpoints'; import { setup } from '../helpers'; @@ -14,7 +13,6 @@ beforeEach(async () => { await api.item([]).file('example.md').post(await fromFileSystem('README.md')); }); - describe('Success', () => { it('Deletes the file', async () => { await expect(api.item([]).file('example.md').delete()) From 24d90c3c5cf9399822566f05635938075f67de71 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 15:35:33 +1100 Subject: [PATCH 025/149] Remove dead tests for old API --- tests/backend/group/all.test.ts | 29 --- tests/backend/group/create.test.ts | 66 ------- tests/backend/group/creationCases.ts | 70 ------- tests/backend/group/info.test.ts | 70 ------- tests/backend/group/item/create.test.ts | 88 --------- tests/backend/group/item/info.test.ts | 61 ------ tests/backend/group/item/link.test.ts | 235 ------------------------ tests/backend/group/item/readme.test.ts | 59 ------ tests/backend/group/item/remove.test.ts | 83 --------- tests/backend/group/readme.test.ts | 48 ----- tests/backend/group/remove.test.ts | 75 -------- tests/backend/group/removeCases.ts | 44 ----- 12 files changed, 928 deletions(-) delete mode 100644 tests/backend/group/all.test.ts delete mode 100644 tests/backend/group/create.test.ts delete mode 100644 tests/backend/group/creationCases.ts delete mode 100644 tests/backend/group/info.test.ts delete mode 100644 tests/backend/group/item/create.test.ts delete mode 100644 tests/backend/group/item/info.test.ts delete mode 100644 tests/backend/group/item/link.test.ts delete mode 100644 tests/backend/group/item/readme.test.ts delete mode 100644 tests/backend/group/item/remove.test.ts delete mode 100644 tests/backend/group/readme.test.ts delete mode 100644 tests/backend/group/remove.test.ts delete mode 100644 tests/backend/group/removeCases.ts diff --git a/tests/backend/group/all.test.ts b/tests/backend/group/all.test.ts deleted file mode 100644 index 7096a9c..0000000 --- a/tests/backend/group/all.test.ts +++ /dev/null @@ -1,29 +0,0 @@ - -import { it, expect, test } from 'vitest'; -import { makeGroup, setup } from '../helpers'; -import api from '$endpoints'; - -test('No groups exist by default', async () => { - const { api } = await setup(); - await expect(api.group.all()).resolves.toStrictEqual({}); -}); - -it('Lists existing groups', async () => { - const { api } = await setup(); - await makeGroup(api, 'my-group'); - await expect(api.group.all()).resolves.toStrictEqual({ - 'my-group': expect.objectContaining({ - name: 'my-group', - description: 'my-group', - }), - }); -}); - -// it('Ignores the .git directory', { timeout: 15_000 }, async () => { -// await setup(gitRepos.EMPTY); -// await expect(api().group.all()).resolves.toStrictEqual({}); -// }); - -it("Errors when the server hasn't been set up", async () => { - await expect(api().group.all()).rejects.toMatchObject({ code: 400 }); -}); diff --git a/tests/backend/group/create.test.ts b/tests/backend/group/create.test.ts deleted file mode 100644 index 33e0454..0000000 --- a/tests/backend/group/create.test.ts +++ /dev/null @@ -1,66 +0,0 @@ - -import { beforeEach, describe, expect, it, test } from 'vitest'; -import { setup } from '../helpers'; -import { type ApiClient } from '$endpoints'; -import type { GroupInfo } from '$lib/server/data/group'; -import genCreationTests from './creationCases'; -import genTokenTests from '../tokenCase'; - -// Generate repeated test cases between groups and items -describe('Generated test cases', () => { - let api: ApiClient; - - genCreationTests( - 'group', - async () => { - api = (await setup()).api; - }, - async (id: string, name: string, description: string) => { - await api.group.withId(id).create(name, description); - } - ); -}); - -describe('Sets up basic group properties', () => { - let api: ApiClient; - const groupId = 'my-group'; - let info: GroupInfo; - beforeEach(async () => { - api = (await setup()).api; - await api.group.withId(groupId).create('Group name', 'Group description'); - info = await api.group.withId(groupId).info.get(); - }); - - test('Name matches', () => { - expect(info.name).toStrictEqual('Group name'); - }); - - test('Description matches', () => { - expect(info.description).toStrictEqual('Group description'); - }); - - it('Chooses a random color for the group', () => { - expect(info.color).toSatisfy((s: string) => /^#[0-9a-f]{6}$/.test(s)); - }); -}); - -describe('Other test cases', () => { - let api: ApiClient; - const groupId = 'my-group'; - beforeEach(async () => { - api = (await setup()).api; - }); - - genTokenTests( - () => api, - api => api.group.withId(groupId).create('Group name', 'Group description'), - ); - - test('New groups are listed by default', async () => { - await api.group.withId(groupId).create('Group name', 'Group description'); - // Group should be listed - await expect(api.admin.config.get()).resolves.toMatchObject({ - listedGroups: [groupId] - }); - }); -}); diff --git a/tests/backend/group/creationCases.ts b/tests/backend/group/creationCases.ts deleted file mode 100644 index 12a4114..0000000 --- a/tests/backend/group/creationCases.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** Shared test cases for creating groups and items */ -import { beforeEach, describe, expect, it } from 'vitest'; -import api from '$endpoints'; -import { invalidIds, invalidNames, validIds, validNames } from '../consts'; - -/** - * Generate shared test cases for creating data on the server. - * - * Note that there any global `beforeEach` in the test suite will also be - * applied to this test suite, which you probably don't want. - * - * Additionally, this test case should be placed in its own `describe`, so - * that its setup doesn't affect other test cases. - * - * @param typeName the name to use when generating the test cases (eg `"item"` - * or `"group"`) - * @param setup setup function to generate required data for the test cases to - * run. This should initialise the server and create any required data (stored - * in the caller's scope). - * @param create function to create the group or item. It should make use of - * the setup data from the caller's scope. - */ -export default function genCreationTests( - typeName: string, - setup: () => Promise, - create: (id: string, name: string, description: string) => Promise, -) { - beforeEach(async () => { await setup(); }); - - describe(`${typeName} ID`, () => { - // Invalid group IDs - it.each(invalidIds)(`Rejects invalid ${typeName} IDs ($case)`, async ({ id }) => { - await expect(create(id, 'Example', '')) - .rejects.toMatchObject({ code: 400 }); - }); - - // Valid group IDs - it.each(validIds)(`Allows valid ${typeName} IDs ($case)'`, async ({ id }) => { - await expect(create(id, `Example ${typeName}`, '')) - .toResolve(); - }); - - it(`Fails if a ${typeName} with a matching ID already exists`, async () => { - await create('example', 'Example', ''); - // ID of this group matches - await expect(create('example', 'Other example', '')) - .rejects.toMatchObject({ code: 400 }); - }); - }); - - describe(`${typeName} name`, () => { - // Invalid group names - it.each(invalidNames)(`Rejects invalid ${typeName} names ($case)`, async ({ name }) => { - await expect(create('id', name, '')) - .rejects.toMatchObject({ code: 400 }); - }); - - // Valid group names - it.each(validNames)(`Allows valid ${typeName} names ($case)`, async ({ name }) => { - await expect(create('id', name, '')) - .toResolve(); - }); - }); - - it('Fails if the data is not set up', async () => { - await api().debug.clear(); - await expect(create('id', 'Example', '')) - .rejects.toMatchObject({ code: 400 }); - }); -} diff --git a/tests/backend/group/info.test.ts b/tests/backend/group/info.test.ts deleted file mode 100644 index f5fb3c3..0000000 --- a/tests/backend/group/info.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Tests for getting and setting the info of groups. - * - * These are very similar to those for get/setting the info of items, but the - * code to generate test cases for both in one go was more complex than it was - * worth, so I decided to keep the duplicate code. - */ -import type { ApiClient } from '$endpoints'; -import { beforeEach, expect, it, describe } from 'vitest'; -import { makeGroupInfo, makeGroup, setup } from '../helpers'; -import { invalidNames, validNames } from '../consts'; -import genTokenTests from '../tokenCase'; - -let api: ApiClient; -let groupId: string; - -beforeEach(async () => { - api = (await setup()).api; - groupId = 'group-id'; - await makeGroup(api, groupId); -}); - -it.each([ - { name: 'Get info', fn: () => api.group.withId(groupId).info.get() }, - { name: 'Set info', fn: () => api.group.withId(groupId).info.set(makeGroupInfo()) }, -])('Gives an error if the server is not set up', async ({ fn }) => { - await api.debug.clear(); - await expect(fn()) - .rejects.toMatchObject({ code: 400 }); -}); - -genTokenTests( - () => api, - api => api.group.withId(groupId).info.set(makeGroupInfo()), -); - -it.each([ - { name: 'Get info', fn: (id: string) => api.group.withId(id).info.get() }, - { name: 'Set info', fn: (id: string) => api.group.withId(id).info.set(makeGroupInfo()) }, -])("Gives an error if the group doesn't exist ($name)", async ({ fn }) => { - await expect(fn('invalid')) - .rejects.toMatchObject({ code: 404 }); -}); - -it('Gives an error if the "listedItems" field contains invalid item IDs', async () => { - await expect(api.group.withId(groupId).info.set(makeGroupInfo({ listedItems: ['invalid-item'] }))) - .rejects.toMatchObject({ code: 400 }); -}); - -it('Successfully updates the group info', async () => { - const newInfo = makeGroupInfo({ name: 'New name' }); - await expect(api.group.withId(groupId).info.set(newInfo)) - .toResolve(); - await expect(api.group.withId(groupId).info.get()) - .resolves.toStrictEqual(newInfo); -}); - -describe('Group name', () => { - // Invalid group names - it.each(invalidNames)('Rejects invalid group names ($case)', async ({ name }) => { - await expect(api.group.withId(groupId).info.set(makeGroupInfo({ name }))) - .rejects.toMatchObject({ code: 400 }); - }); - - // Valid group names - it.each(validNames)('Allows valid group names ($case)', async ({ name }) => { - await expect(api.group.withId(groupId).info.set(makeGroupInfo({ name }))) - .toResolve(); - }); -}); diff --git a/tests/backend/group/item/create.test.ts b/tests/backend/group/item/create.test.ts deleted file mode 100644 index b33590c..0000000 --- a/tests/backend/group/item/create.test.ts +++ /dev/null @@ -1,88 +0,0 @@ - -import { beforeEach, describe, expect, it, test } from 'vitest'; -import { makeGroup, setup } from '../../helpers'; -import type { ApiClient } from '$endpoints'; -import type { ItemInfoFull } from '$lib/server/data/itemOld'; -import genCreationTests from '../creationCases'; -import genTokenTests from '../../tokenCase'; - -// Generate repeated test cases between groups and items -describe('Generated test cases', () => { - let api: ApiClient; - const groupId = 'group-id'; - - genCreationTests( - 'item', - async () => { - api = (await setup()).api; - await api.group.withId(groupId).create('Group', ''); - }, - async (id: string, name: string, description: string) => { - await api.group.withId(groupId).item.withId(id).create(name, description); - } - ); -}); - -describe('Sets up basic item properties', () => { - let api: ApiClient; - const groupId = 'group'; - - beforeEach(async () => { - api = (await setup()).api; - await makeGroup(api, groupId); - }); - const itemId = 'my-item'; - let info: ItemInfoFull; - beforeEach(async () => { - await api.group.withId(groupId).item.withId(itemId).create('Item name', 'Item description'); - info = await api.group.withId(groupId).item.withId(itemId).info.get(); - }); - - test('Name matches', () => { - expect(info.name).toStrictEqual('Item name'); - }); - - test('Description matches', () => { - expect(info.description).toStrictEqual('Item description'); - }); - - it('Chooses a random color for the item', () => { - expect(info.color).toSatisfy((s: string) => /^#[0-9a-f]{6}$/.test(s)); - }); -}); - -describe('Other test cases', () => { - let api: ApiClient; - const groupId = 'group'; - - beforeEach(async () => { - api = (await setup()).api; - await makeGroup(api, groupId); - }); - - it("Fails if the group doesn't exist", async () => { - await expect(api.group.withId('invalid').item.withId('item-id').create('My item', '')) - .rejects.toMatchObject({ code: 404 }); - }); - - test('New items are listed in their group by default', async () => { - await api.group.withId(groupId).item.withId('item-id').create('Item name', 'Item description'); - // Item should be listed - await expect(api.group.withId(groupId).info.get()).resolves.toMatchObject({ - listedItems: ['item-id'], - }); - }); - - test('New items are used as filters by their group by default', async () => { - await api.group.withId(groupId).item.withId('item-id').create('Item name', 'Item description'); - // Item should be listed - await expect(api.group.withId(groupId).info.get()).resolves.toMatchObject({ - filterItems: ['item-id'], - }); - }); - - genTokenTests( - () => api, - api => api.group.withId(groupId).item.withId('item-id').create('My item', ''), - ); -}); diff --git a/tests/backend/group/item/info.test.ts b/tests/backend/group/item/info.test.ts deleted file mode 100644 index a4ffa62..0000000 --- a/tests/backend/group/item/info.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { beforeEach, expect, it, describe } from 'vitest'; -import { makeItemInfo, makeGroup, makeItem, setup } from '../../helpers'; -import { invalidNames, validNames } from '../../consts'; -import type { ApiClient } from '$endpoints'; -import genTokenTests from '../../tokenCase'; - -let api: ApiClient; -let groupId: string; -let itemId: string; - -beforeEach(async () => { - api = (await setup()).api; - groupId = 'group-id'; - itemId = 'item-id'; - await makeGroup(api, groupId); - await makeItem(api, groupId, itemId); -}); - -it.each([ - { name: 'Get info', fn: () => api.group.withId(groupId).item.withId(itemId).info.get() }, - { name: 'Set info', fn: () => api.group.withId(groupId).item.withId(itemId).info.set(makeItemInfo()) }, -])('Gives an error if the server is not set up ($name)', async ({ fn }) => { - await api.debug.clear(); - await expect(fn()) - .rejects.toMatchObject({ code: 400 }); -}); - -genTokenTests( - () => api, - api => api.group.withId(groupId).item.withId(itemId).info.set(makeItemInfo()), -); - -it.each([ - { name: 'Get info', fn: (id: string) => api.group.withId(groupId).item.withId(id).info.get() }, - { name: 'Set info', fn: (id: string) => api.group.withId(groupId).item.withId(id).info.set(makeItemInfo()) }, -])("Gives an error if the item doesn't exist ($name)", async ({ fn }) => { - await expect(fn('invalid')) - .rejects.toMatchObject({ code: 404 }); -}); - -it('Successfully updates the item info', async () => { - const newInfo = makeItemInfo({ name: 'New name' }); - await expect(api.group.withId(groupId).item.withId(itemId).info.set(newInfo)) - .toResolve(); - await expect(api.group.withId(groupId).item.withId(itemId).info.get()) - .resolves.toStrictEqual(newInfo); -}); - -describe('Item name', () => { - // Invalid item names - it.each(invalidNames)('Rejects invalid item names ($case)', async ({ name }) => { - await expect(api.group.withId(groupId).item.withId(itemId).info.set(makeItemInfo({ name }))) - .rejects.toMatchObject({ code: 400 }); - }); - - // Valid item names - it.each(validNames)('Allows valid item names ($case)', async ({ name }) => { - await expect(api.group.withId(groupId).item.withId(itemId).info.set(makeItemInfo({ name }))) - .toResolve(); - }); -}); diff --git a/tests/backend/group/item/link.test.ts b/tests/backend/group/item/link.test.ts deleted file mode 100644 index 4175224..0000000 --- a/tests/backend/group/item/link.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { makeGroup, makeItem, setup } from '../../helpers'; -import type { ApiClient } from '$endpoints'; -import genTokenTests from '../../tokenCase'; - -let api: ApiClient; -let groupId: string; -let itemId: string; -let otherItemId: string; - -beforeEach(async () => { - api = (await setup()).api; - groupId = 'group-id'; - itemId = 'item-id'; - otherItemId = 'other-item-id'; - await makeGroup(api, groupId); - await makeItem(api, groupId, itemId); - await makeItem(api, groupId, otherItemId); -}); - -describe('Create link', () => { - it('Creates links between items', async () => { - await expect(api.group.withId(groupId).item.withId(itemId).links.create(groupId, otherItemId)) - .resolves.toStrictEqual({}); - // Check side effect - await expect(api.group.withId(groupId).item.withId(itemId).info.get()) - .resolves.toMatchObject({ - // By default link style is chips - links: [ - [{ groupId, style: 'chip' }, [otherItemId]], - ], - }); - }); - - it('Creates the reverse link', async () => { - await api.group.withId(groupId).item.withId(itemId).links.create(groupId, otherItemId); - await expect(api.group.withId(groupId).item.withId(otherItemId).info.get()) - .resolves.toMatchObject({ - links: [ - [{ groupId, style: 'chip' }, [itemId]], - ], - }); - }); - - it('Does nothing if the link already exists', async () => { - await api.group.withId(groupId).item.withId(itemId).links.create(groupId, otherItemId); - await expect(api.group.withId(groupId).item.withId(itemId).links.create(groupId, otherItemId)) - .resolves.toStrictEqual({}); - // Other item should only appear once - await expect(api.group.withId(groupId).item.withId(itemId).info.get()) - .resolves.toMatchObject({ - links: [ - [{ groupId, style: 'chip' }, [otherItemId]], - ], - }); - }); - - it('Keeps links to items in the same group in the same section', async () => { - await api.group.withId(groupId).item.withId(itemId).links.create(groupId, otherItemId); - const yetAnotherItemId = 'yet-another-item'; - await makeItem(api, groupId, yetAnotherItemId); - await expect(api.group.withId(groupId).item.withId(itemId).links.create(groupId, yetAnotherItemId)) - .resolves.toStrictEqual({}); - // Check data is stored correctly - await expect(api.group.withId(groupId).item.withId(itemId).info.get()) - .resolves.toMatchObject({ - links: [ - [{ groupId, style: 'chip' }, [otherItemId, yetAnotherItemId]], - ], - }); - }); - - it('Rejects links to self', async () => { - await expect(api.group.withId(groupId).item.withId(itemId).links.create(groupId, itemId)) - .rejects.toMatchObject({ code: 400 }); - }); - - it("Errors if the target group doesn't exist", async () => { - await expect(api.group.withId(groupId).item.withId(itemId).links.create('invalid-group', otherItemId)) - .rejects.toMatchObject({ code: 400 }); - }); - - it("Errors if the target item doesn't exist", async () => { - await expect(api.group.withId(groupId).item.withId(itemId).links.create(groupId, 'invalid-item')) - .rejects.toMatchObject({ code: 400 }); - }); - - it("Errors if the source group doesn't exist", async () => { - await expect(api.group.withId('invalid-group').item.withId(itemId).links.create(groupId, otherItemId)) - .rejects.toMatchObject({ code: 404 }); - }); - - it("Errors if the source item doesn't exist", async () => { - await expect(api.group.withId(groupId).item.withId('invalid-item').links.create(groupId, otherItemId)) - .rejects.toMatchObject({ code: 404 }); - }); - - genTokenTests( - () => api, - api => api.group.withId(groupId).item.withId(itemId).links.create(groupId, otherItemId), - ); - - it('Respects existing display style preference for group', async () => { - await api.group.withId(groupId).item.withId(itemId).links.create(groupId, otherItemId); - // Update link style - await api.group.withId(groupId).item.withId(itemId).links.style(groupId, 'card'); - // Create a link to another item - const yetAnotherItemId = 'yet-another-item'; - await makeItem(api, groupId, yetAnotherItemId); - await api.group.withId(groupId).item.withId(itemId).links.create(groupId, yetAnotherItemId); - await expect(api.group.withId(groupId).item.withId(itemId).info.get()) - .resolves.toMatchObject({ - links: [ - [{ groupId, style: 'card' }, [otherItemId, yetAnotherItemId]], - ], - }); - }); -}); - -describe('Update link style', () => { - beforeEach(async () => { - // Create the link - await api.group.withId(groupId).item.withId(itemId).links.create(groupId, otherItemId); - }); - - it('Allows link display style to be updated', async () => { - await expect(api.group.withId(groupId).item.withId(itemId).links.style(groupId, 'card')) - .resolves.toStrictEqual({}); - // Style got updated - await expect(api.group.withId(groupId).item.withId(itemId).info.get()) - .resolves.toMatchObject({ - links: [ - [{ groupId, style: 'card' }, [otherItemId]], - ], - }); - }); - - it('Rejects invalid style names', async () => { - await expect(api.group.withId(groupId).item.withId(itemId).links.style( - groupId, - // Deliberate type safety awfulness -- we intentionally want to send an - // invalid request despite TypeScript's best efforts - 'invalid-style' as string as 'card', - )) - .rejects.toMatchObject({ code: 400 }); - }); - - it("Errors if the target group doesn't exist", async () => { - await expect(api.group.withId(groupId).item.withId(itemId).links.style('invalid-group', 'card')) - .rejects.toMatchObject({ code: 400 }); - }); - - it("Errors if the target group hasn't been linked to exist", async () => { - const otherGroupId = 'other-group-id'; - await makeGroup(api, otherGroupId); - await expect(api.group.withId(groupId).item.withId(itemId).links.style(otherGroupId, 'card')) - .rejects.toMatchObject({ code: 400 }); - }); - - it('Rejects invalid tokens', async () => { - await expect(api.withToken('invalid').group.withId(groupId).item.withId(itemId).links.style(groupId, 'card')) - .rejects.toMatchObject({ code: 401 }); - }); -}); - -describe('Remove link', () => { - beforeEach(async () => { - // Create the link - await api.group.withId(groupId).item.withId(itemId).links.create(groupId, otherItemId); - }); - - it('Removes links between items', async () => { - await expect(api.group.withId(groupId).item.withId(itemId).links.remove(groupId, otherItemId)) - .resolves.toStrictEqual({}); - // Check side effect - await expect(api.group.withId(groupId).item.withId(itemId).info.get()) - .resolves.toMatchObject({ - links: [], - }); - }); - - it('Removes the reverse link', async () => { - await expect(api.group.withId(groupId).item.withId(itemId).links.remove(groupId, otherItemId)) - .resolves.toStrictEqual({}); - await expect(api.group.withId(groupId).item.withId(otherItemId).info.get()) - .resolves.toMatchObject({ - links: [], - }); - }); - - it('Leaves the group entry if there are multiple links present', async () => { - const yetAnotherItemId = 'yet-another-item'; - await makeItem(api, groupId, yetAnotherItemId); - await api.group.withId(groupId).item.withId(itemId).links.create(groupId, yetAnotherItemId); - await expect(api.group.withId(groupId).item.withId(itemId).links.remove(groupId, otherItemId)) - .resolves.toStrictEqual({}); - await expect(api.group.withId(groupId).item.withId(itemId).info.get()) - .resolves.toMatchObject({ - links: [ - [{ groupId, style: 'chip' }, [yetAnotherItemId]], - ], - }); - }); - - it("Does nothing if the link doesn't exist", async () => { - await api.group.withId(groupId).item.withId(itemId).links.remove(groupId, otherItemId); - await expect(api.group.withId(groupId).item.withId(itemId).links.remove(groupId, otherItemId)) - .resolves.toStrictEqual({}); - }); - - it("Errors if the target group doesn't exist", async () => { - await expect(api.group.withId(groupId).item.withId(itemId).links.remove('invalid-group', otherItemId)) - .rejects.toMatchObject({ code: 400 }); - }); - - it("Errors if the target item doesn't exist", async () => { - await expect(api.group.withId(groupId).item.withId(itemId).links.remove(groupId, 'invalid-item')) - .rejects.toMatchObject({ code: 400 }); - }); - - it("Errors if the source group doesn't exist", async () => { - await expect(api.group.withId('invalid-group').item.withId(itemId).links.remove(groupId, otherItemId)) - .rejects.toMatchObject({ code: 404 }); - }); - - it("Errors if the source item doesn't exist", async () => { - await expect(api.group.withId(groupId).item.withId('invalid-item').links.remove(groupId, otherItemId)) - .rejects.toMatchObject({ code: 404 }); - }); - - it('Rejects invalid tokens', async () => { - await expect(api.withToken('invalid').group.withId(groupId).item.withId(itemId).links.remove(groupId, otherItemId)) - .rejects.toMatchObject({ code: 401 }); - }); -}); diff --git a/tests/backend/group/item/readme.test.ts b/tests/backend/group/item/readme.test.ts deleted file mode 100644 index d385b27..0000000 --- a/tests/backend/group/item/readme.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { beforeEach, expect, it, describe } from 'vitest'; -import { makeGroup, makeItem, setup } from '../../helpers'; -import genReadmeTests from '../../readmeCases'; -import { readFile } from 'fs/promises'; -import { getDataDir } from '$lib/server/data/dataDir'; -import type { ApiClient } from '$endpoints'; -import genTokenTests from '../../tokenCase'; - -describe('Generated test cases', () => { - let api: ApiClient; - let groupId: string; - let itemId: string; - - genReadmeTests( - // Setup - async () => { - api = (await setup()).api; - groupId = 'group-id'; - itemId = 'item-id'; - await makeGroup(api, groupId); - await makeItem(api, groupId, itemId); - }, - // Get readme - () => api.group.withId(groupId).item.withId(itemId).readme.get(), - // Set readme - async (newReadme) => { - await api.group.withId(groupId).item.withId(itemId).readme.set(newReadme); - }, - // Get readme from disk - () => readFile(`${getDataDir()}/${groupId}/${itemId}/README.md`, { encoding: 'utf-8' }), - ); -}); - -describe('Other cases', () => { - let api: ApiClient; - const groupId = 'group-id'; - const itemId = 'item-id'; - - beforeEach(async () => { - api = (await setup()).api; - await makeGroup(api, groupId); - await makeItem(api, groupId, itemId); - }); - - it("Errors if the group doesn't exist", async () => { - await expect(api.group.withId('invalid-group').item.withId(itemId).readme.set('New readme')) - .rejects.toMatchObject({ code: 404 }); - }); - - it("Errors if the item doesn't exist", async () => { - await expect(api.group.withId(groupId).item.withId('invalid-item').readme.set('New readme')) - .rejects.toMatchObject({ code: 404 }); - }); - - genTokenTests( - () => api, - api => api.group.withId(groupId).item.withId(itemId).readme.set('New readme'), - ); -}); diff --git a/tests/backend/group/item/remove.test.ts b/tests/backend/group/item/remove.test.ts deleted file mode 100644 index e122b4a..0000000 --- a/tests/backend/group/item/remove.test.ts +++ /dev/null @@ -1,83 +0,0 @@ - -import { describe, it, beforeEach, expect } from 'vitest'; -import { makeItemInfo, makeGroup, makeItem, setup } from '../../helpers'; -import type { ApiClient } from '$endpoints'; -import genRemoveTests from '../removeCases'; -import genTokenTests from '../../tokenCase'; - -describe('Generated test cases', () => { - let api: ApiClient; - const groupId = 'group-id'; - - genRemoveTests( - // Setup - async () => { - api = (await setup()).api; - await makeGroup(api, groupId); - }, - // Create group - async (id: string) => { - await api.group.withId(groupId).item.withId(id).create('Group', ''); - }, - // Get group info - (id) => api.group.withId(groupId).item.withId(id).info.get(), - // Delete group - id => api.group.withId(groupId).item.withId(id).remove(), - ); -}); - -describe('Other test cases', () => { - let api: ApiClient; - const group = 'my-group'; - const item = 'item-1'; - - beforeEach(async () => { - api = (await setup()).api; - await makeGroup(api, group); - await makeItem(api, group, item); - }); - - genTokenTests( - () => api, - api => api.group.withId(group).item.withId(item).remove(), - ); - - it("Rejects if item's group was removed", async () => { - await api.group.withId(group).remove(); - // Item was deleted too - await expect(api.group.withId(group).item.withId('item-id').remove()) - .rejects.toMatchObject({ code: 404 }); - }); - - it('Removes links to this item', async () => { - await makeGroup(api, 'group-2'); - await makeItem(api, 'group-2', 'item-2'); - // Create the link - await api.group.withId('group-2').item.withId('item-2').links.create(group, item); - // Removing item removes link from group-2/item-2 - await api.group.withId(group).item.withId('item-1').remove(); - await expect(api.group.withId('group-2').item.withId('item-2').info.get()) - .resolves.toMatchObject({ - // Links were removed - links: [], - }); - }); - - /** Edge case: one-way links can be created by setting group info manually */ - it('Removes one-way links to this item', async () => { - await makeGroup(api, 'group-2'); - await makeItem(api, 'group-2', 'item-2'); - // Create the link - await api.group.withId('group-2').item.withId('item-2').info.set( - makeItemInfo({ - links: [[{ groupId: group, style: 'chip', title: group }, [item]]], - }), - ); - await api.group.withId(group).item.withId(item).remove(); - await expect(api.group.withId('group-2').item.withId('item-2').info.get()) - .resolves.toMatchObject({ - // Links were removed - links: [], - }); - }); -}); diff --git a/tests/backend/group/readme.test.ts b/tests/backend/group/readme.test.ts deleted file mode 100644 index e0d0eda..0000000 --- a/tests/backend/group/readme.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { ApiClient } from '$endpoints'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { makeGroup, setup } from '../helpers'; -import genReadmeTests from '../readmeCases'; -import { readFile } from 'fs/promises'; -import { getDataDir } from '$lib/server/data/dataDir'; -import genTokenTests from '../tokenCase'; - -describe('Generated test cases', () => { - let api: ApiClient; - const groupId = 'group-id'; - - genReadmeTests( - // Setup - async () => { - api = (await setup()).api; - await makeGroup(api, groupId); - }, - // Get readme - () => api.group.withId(groupId).readme.get(), - // Set readme - async (newReadme) => { - await api.group.withId(groupId).readme.set(newReadme); - }, - // Get readme from disk - () => readFile(`${getDataDir()}/${groupId}/README.md`, { encoding: 'utf-8' }), - ); -}); - -describe('Other test cases', () => { - let api: ApiClient; - const groupId = 'group-id'; - - beforeEach(async () => { - api = (await setup()).api; - await makeGroup(api, groupId); - }); - - it("Errors if the group doesn't exist", async () => { - await expect(api.group.withId('invalid-group').readme.set('New readme')) - .rejects.toMatchObject({ code: 404 }); - }); - - genTokenTests( - () => api, - api => api.group.withId(groupId).readme.set('New readme'), - ); -}); diff --git a/tests/backend/group/remove.test.ts b/tests/backend/group/remove.test.ts deleted file mode 100644 index 4fabe1b..0000000 --- a/tests/backend/group/remove.test.ts +++ /dev/null @@ -1,75 +0,0 @@ - -import { describe, it, beforeEach, expect } from 'vitest'; -import { makeItemInfo, makeGroup, makeItem, setup } from '../helpers'; -import genRemoveTests from './removeCases'; -import type { ApiClient } from '$endpoints'; -import genTokenTests from '../tokenCase'; - -describe('Generated test cases', () => { - let api: ApiClient; - - genRemoveTests( - // Setup - async () => { - api = (await setup()).api; - }, - // Create group - async (id: string) => { - await api.group.withId(id).create('Group', ''); - }, - // Get group info - (id) => api.group.withId(id).info.get(), - // Delete group - id => api.group.withId(id).remove(), - ); -}); - -describe('Other test cases', () => { - let api: ApiClient; - const group = 'my-group'; - - beforeEach(async () => { - api = (await setup()).api; - await makeGroup(api, group); - }); - - genTokenTests( - () => api, - api => api.group.withId(group).remove(), - ); - - it('Removes links to items in the group', async () => { - await makeItem(api, group, 'item-1'); - await makeGroup(api, 'group-2'); - await makeItem(api, 'group-2', 'item-2'); - // Create the link - await api.group.withId('group-2').item.withId('item-2').links.create(group, 'item-1'); - // Removing group removes link from group-2/item-2 - await api.group.withId(group).remove(); - await expect(api.group.withId('group-2').item.withId('item-2').info.get()) - .resolves.toMatchObject({ - // Links were removed - links: [], - }); - }); - - /** Edge case: one-way links can be created by setting group info manually */ - it('Removes one-way links to this item', async () => { - await makeItem(api, group, 'item-1'); - await makeGroup(api, 'group-2'); - await makeItem(api, 'group-2', 'item-2'); - // Create the link - await api.group.withId('group-2').item.withId('item-2').info.set( - makeItemInfo({ - links: [[{ groupId: group, style: 'chip', title: group }, ['item-1']]], - }), - ); - // Removing group removes link from group-2/item-2 - await api.group.withId(group).remove(); - await expect(api.group.withId('group-2').item.withId('item-2').info.get()) - .resolves.toMatchObject({ - // Links were removed - links: [], - }); - }); -}); diff --git a/tests/backend/group/removeCases.ts b/tests/backend/group/removeCases.ts deleted file mode 100644 index 53953b6..0000000 --- a/tests/backend/group/removeCases.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** Shared test cases for deleting items/groups */ - -import api from '$endpoints'; -import { beforeEach, expect, it, test } from 'vitest'; - -/** - * Generate shared test cases for deleting items or groups. - * - * Similar design to `generateTestCases` from `creationCases.ts`, see that - * documentation for usage. - */ -export default function genRemoveTests( - setup: () => Promise, - create: (id: string) => Promise, - getInfo: (id: string) => Promise, - remove: (id: string) => Promise>, -) { - beforeEach(async () => { await setup(); }); - - test('Successful removal', async () => { - await create('example'); - await expect(remove('example')).resolves.toStrictEqual({}); - // Group is removed - await expect(getInfo('example')).rejects.toMatchObject({ code: 404 }); - }); - - test('Can be recreated after deletion', async () => { - await create('example'); - await remove('example'); - await expect(create('example')).toResolve(); - }); - - it("Gives an error if subject doesn't exist", async () => { - await expect(remove('invalid-group')) - .rejects.toMatchObject({ code: 404 }); - }); - - it("Gives an error if the server isn't initialized", async () => { - await create('example'); - await api().debug.clear(); - await expect(remove('example')) - .rejects.toMatchObject({ code: 400 }); - }); -} From c9f3293f27a353f5365931150c0e8b244b9c6a04 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 15:38:14 +1100 Subject: [PATCH 026/149] Remove unwanted .only test --- tests/backend/admin/firstrun/data.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/backend/admin/firstrun/data.test.ts b/tests/backend/admin/firstrun/data.test.ts index 6679191..07468c2 100644 --- a/tests/backend/admin/firstrun/data.test.ts +++ b/tests/backend/admin/firstrun/data.test.ts @@ -51,7 +51,7 @@ it("Doesn't clone repo when no URL provided", async () => { .resolves.toStrictEqual(false); }); -it.only('Generates root item by default', async () => { +it('Generates root item by default', async () => { const token = await accountSetup(); await firstrunData(token); const client = api(token); From 13625a1b00a1c4cd61053e4b1bcbd18a2b08bc22 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 15:49:42 +1100 Subject: [PATCH 027/149] Move public config endpoints to `data/` route --- src/endpoints/admin/index.ts | 3 - src/endpoints/{admin => }/config.ts | 6 +- src/endpoints/index.ts | 6 ++ .../config => data/config.json}/+server.ts | 10 ++-- tests/backend/admin/config/get.test.ts | 40 ------------- tests/backend/admin/config/put.test.ts | 57 ------------------- tests/backend/config/get.test.ts | 28 +++++++++ tests/backend/config/put.test.ts | 43 ++++++++++++++ 8 files changed, 84 insertions(+), 109 deletions(-) rename src/endpoints/{admin => }/config.ts (86%) rename src/routes/{api/admin/config => data/config.json}/+server.ts (83%) delete mode 100644 tests/backend/admin/config/get.test.ts delete mode 100644 tests/backend/admin/config/put.test.ts create mode 100644 tests/backend/config/get.test.ts create mode 100644 tests/backend/config/put.test.ts diff --git a/src/endpoints/admin/index.ts b/src/endpoints/admin/index.ts index f7a09aa..0bb36d8 100644 --- a/src/endpoints/admin/index.ts +++ b/src/endpoints/admin/index.ts @@ -1,6 +1,5 @@ /** Admin endpoints */ import auth from './auth'; -import config from './config'; import git from './git'; import firstrun from './firstrun'; import { apiFetch } from '$endpoints/fetch'; @@ -14,8 +13,6 @@ export default function admin(token: string | undefined) { return { /** Authentication options */ auth: auth(token), - /** Site configuration */ - config: config(token), /** Git actions */ git: git(token), /** Key management (used for git operations) */ diff --git a/src/endpoints/admin/config.ts b/src/endpoints/config.ts similarity index 86% rename from src/endpoints/admin/config.ts rename to src/endpoints/config.ts index 51e384a..03f4a88 100644 --- a/src/endpoints/admin/config.ts +++ b/src/endpoints/config.ts @@ -1,5 +1,5 @@ /** Configuration endpoints */ -import { apiFetch, payload } from '../fetch'; +import { apiFetch, payload } from './fetch'; import type { ConfigJson } from '$lib/server/data/config'; export default (token: string | undefined) => ({ @@ -11,7 +11,7 @@ export default (token: string | undefined) => ({ get: async () => { return apiFetch( 'GET', - '/api/admin/config', + '/data/config.json', { token }, ).json() as Promise; }, @@ -24,7 +24,7 @@ export default (token: string | undefined) => ({ put: async (config: ConfigJson) => { return apiFetch( 'PUT', - '/api/admin/config', + '/data/config.json', { token, ...payload.json(config) }, ).json() as Promise>; }, diff --git a/src/endpoints/index.ts b/src/endpoints/index.ts index f818bc7..992e635 100644 --- a/src/endpoints/index.ts +++ b/src/endpoints/index.ts @@ -1,14 +1,20 @@ /** API endpoints */ import type { ItemId } from '$lib/server/data/itemId'; import admin from './admin'; +import config from './config'; import debug from './debug'; import item from './item'; /** Create an instance of the API client with the given token */ export default function api(token?: string) { return { + /** Admin endpoints */ admin: admin(token), + /** Debug endpoints (only available in dev mode) */ debug: debug(token), + /** Site configuration */ + config: config(token), + /** Item data endpoints */ item: (itemId: ItemId) => item(token, itemId), /** Create a new API client with the given token */ withToken: (token: string | undefined) => api(token), diff --git a/src/routes/api/admin/config/+server.ts b/src/routes/data/config.json/+server.ts similarity index 83% rename from src/routes/api/admin/config/+server.ts rename to src/routes/data/config.json/+server.ts index f2c1ea8..b07916b 100644 --- a/src/routes/api/admin/config/+server.ts +++ b/src/routes/data/config.json/+server.ts @@ -6,17 +6,15 @@ import { version } from '$app/environment'; import fs from 'fs/promises'; import { dataIsSetUp, getDataDir } from '$lib/server/data/dataDir'; -export async function GET({ request, cookies }: import('./$types').RequestEvent) { - if (await dataIsSetUp()) { +export async function GET() { + if (!await dataIsSetUp()) { error(400, 'Data is not set up'); } - await validateTokenFromRequest({ request, cookies }); - - return json(getConfig(), { status: 200 }); + return json(await getConfig(), { status: 200 }); } export async function PUT({ request, cookies }: import('./$types').RequestEvent) { - if (await dataIsSetUp()) { + if (!await dataIsSetUp()) { error(400, 'Data is not set up'); } await validateTokenFromRequest({ request, cookies }); diff --git a/tests/backend/admin/config/get.test.ts b/tests/backend/admin/config/get.test.ts deleted file mode 100644 index 30095d6..0000000 --- a/tests/backend/admin/config/get.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Test cases for GET /api/admin/config - * - * Returns the current site configuration. - */ -import { type ApiClient } from '$endpoints'; -import { beforeEach, expect, it } from 'vitest'; -import { setup } from '../../helpers'; -import { version } from '$app/environment'; - -let api: ApiClient; - -beforeEach(async () => { - api = (await setup()).api; -}); - -it('Returns the current config contents', async () => { - await expect(api.admin.config.get()).resolves.toStrictEqual({ - siteName: 'My portfolio', - siteShortName: expect.any(String), - siteDescription: expect.any(String), - // Annoying that I can't specifically expect a string[] - siteKeywords: expect.toBeArray(), - siteIcon: null, - listedGroups: [], - color: expect.any(String), - version, - }); -}); - -it('Errors if given an invalid token', async () => { - await expect(api.withToken('invalid').admin.config.get()) - .rejects.toMatchObject({ code: 401 }); -}); - -it("Errors if the data isn't set up", async () => { - await api.debug.clear(); - await expect(api.admin.config.get()) - .rejects.toMatchObject({ code: 400 }); -}); diff --git a/tests/backend/admin/config/put.test.ts b/tests/backend/admin/config/put.test.ts deleted file mode 100644 index 3522287..0000000 --- a/tests/backend/admin/config/put.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Test cases for PUT /api/admin/config - * - * Allows users to edit the site configuration - */ -import { beforeEach, expect, it } from 'vitest'; -import { makeConfig, setup } from '../../helpers'; -import { version } from '$app/environment'; -import type { ApiClient } from '$endpoints'; -import genTokenTests from '../../tokenCase'; - -let api: ApiClient; - -beforeEach(async () => { - api = (await setup()).api; -}); - -it('Updates the current config contents', async () => { - const newConfig = makeConfig(); - await expect(api.admin.config.put(newConfig)).resolves.toStrictEqual({}); - // Config should have updated - await expect(api.admin.config.get()).resolves.toStrictEqual(newConfig); -}); - -it('Errors if the new config has an incorrect version', async () => { - await expect(api.admin.config.put(makeConfig({ version: version + 'invalid' }))) - .rejects.toMatchObject({ code: 400 }); -}); - -it('Errors if listedGroups contains non-existent groups', async () => { - await expect(api.admin.config.put(makeConfig({ listedGroups: ['invalid'] }))) - .rejects.toMatchObject({ code: 400 }); -}); - -it('Allows listedGroups to contain existing groups', async () => { - await api.group.withId('my-group').create('my-group', ''); - await expect(api.admin.config.put(makeConfig({ listedGroups: ['my-group'] }))) - .resolves.toStrictEqual({}); - // Config should have updated - await expect(api.admin.config.get()) - .resolves.toMatchObject({ listedGroups: ['my-group'] }); -}); - -it("Errors if the icon doesn't exist within the data dir", async () => { - await expect(api.admin.config.put(makeConfig({ siteIcon: 'not-a-file.jpg' }))) - .rejects.toMatchObject({ code: 400 }); -}); - -genTokenTests( - () => api, - api => api.admin.config.put(makeConfig()), -); - -it('Errors if site is not set up', async () => { - await api.debug.clear(); - await expect(api.admin.config.put(makeConfig())).rejects.toMatchObject({ code: 400 }); -}); diff --git a/tests/backend/config/get.test.ts b/tests/backend/config/get.test.ts new file mode 100644 index 0000000..939f3db --- /dev/null +++ b/tests/backend/config/get.test.ts @@ -0,0 +1,28 @@ +/** + * Test cases for GET /api/admin/config + * + * Returns the current site configuration. + */ +import { type ApiClient } from '$endpoints'; +import { beforeEach, expect, it } from 'vitest'; +import { setup } from '../helpers'; +import { version } from '$app/environment'; + +let api: ApiClient; + +beforeEach(async () => { + api = (await setup()).api; +}); + +it('Returns the current config contents', async () => { + await expect(api.config.get()).resolves.toStrictEqual({ + siteIcon: null, + version, + }); +}); + +it("Errors if the data isn't set up", async () => { + await api.debug.clear(); + await expect(api.config.get()) + .rejects.toMatchObject({ code: 400 }); +}); diff --git a/tests/backend/config/put.test.ts b/tests/backend/config/put.test.ts new file mode 100644 index 0000000..7f28a25 --- /dev/null +++ b/tests/backend/config/put.test.ts @@ -0,0 +1,43 @@ +/** + * Test cases for PUT /api/admin/config + * + * Allows users to edit the site configuration + */ +import { beforeEach, expect, it } from 'vitest'; +import { makeConfig, setup } from '../helpers'; +import { version } from '$app/environment'; +import type { ApiClient } from '$endpoints'; +import genTokenTests from '../tokenCase'; + +let api: ApiClient; + +beforeEach(async () => { + api = (await setup()).api; +}); + +it('Updates the current config contents', async () => { + const newConfig = makeConfig(); + await expect(api.config.put(newConfig)).resolves.toStrictEqual({}); + // Config should have updated + await expect(api.config.get()).resolves.toStrictEqual(newConfig); +}); + +it('Errors if the new config has an incorrect version', async () => { + await expect(api.config.put(makeConfig({ version: version + 'invalid' }))) + .rejects.toMatchObject({ code: 400 }); +}); + +it("Errors if the icon doesn't exist within the data dir", async () => { + await expect(api.config.put(makeConfig({ siteIcon: 'not-a-file.jpg' }))) + .rejects.toMatchObject({ code: 400 }); +}); + +genTokenTests( + () => api, + api => api.config.put(makeConfig()), +); + +it('Errors if site is not set up', async () => { + await api.debug.clear(); + await expect(api.config.put(makeConfig())).rejects.toMatchObject({ code: 400 }); +}); From 386110f0c1d0e98d42875a3e8014f06c2adfa6cf Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 15:54:12 +1100 Subject: [PATCH 028/149] Fix failing test cases --- src/routes/api/admin/data/refresh/+server.ts | 2 +- tests/backend/admin/refresh.test.ts | 6 ++- tests/backend/readme.test.ts | 43 ------------------- tests/backend/readmeCases.ts | 44 -------------------- 4 files changed, 5 insertions(+), 90 deletions(-) delete mode 100644 tests/backend/readme.test.ts delete mode 100644 tests/backend/readmeCases.ts diff --git a/src/routes/api/admin/data/refresh/+server.ts b/src/routes/api/admin/data/refresh/+server.ts index ee31384..65a078b 100644 --- a/src/routes/api/admin/data/refresh/+server.ts +++ b/src/routes/api/admin/data/refresh/+server.ts @@ -3,7 +3,7 @@ import { dataIsSetUp } from '$lib/server/data/dataDir'; import { error, json } from '@sveltejs/kit'; export async function POST({ request, cookies }: import('./$types').RequestEvent) { - if (await dataIsSetUp()) { + if (!await dataIsSetUp()) { error(400, 'Data is not set up'); } await validateTokenFromRequest({ request, cookies }); diff --git a/tests/backend/admin/refresh.test.ts b/tests/backend/admin/refresh.test.ts index 2a176e5..da326a1 100644 --- a/tests/backend/admin/refresh.test.ts +++ b/tests/backend/admin/refresh.test.ts @@ -12,14 +12,16 @@ beforeEach(async () => { api = (await setup()).api; }); +// Since caching currently isn't implemented with the new system, this doesn't really +// test anything, but it should be useful if I do re-implement caching it('Reloads data from the file system', async () => { // Manually modify the config data const config = await getConfig(); - config.siteName = 'New name'; + config.siteIcon = 'icon.png'; await setConfig(config); // After we refresh the data, the new site name is shown await expect(api.admin.data.refresh()).resolves.toStrictEqual({}); - await expect(api.admin.config.get()).resolves.toMatchObject({ siteName: 'New name' }); + await expect(api.config.get()).resolves.toMatchObject({ siteIcon: 'icon.png' }); }); genTokenTests( diff --git a/tests/backend/readme.test.ts b/tests/backend/readme.test.ts deleted file mode 100644 index 8553a20..0000000 --- a/tests/backend/readme.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { ApiClient } from '$endpoints'; -import { beforeEach, describe, expect, test } from 'vitest'; -import { setup } from './helpers'; -import genReadmeTests from './readmeCases'; -import { readFile } from 'fs/promises'; -import { getDataDir } from '$lib/server/data/dataDir'; -import genTokenTests from './tokenCase'; - -describe('Generated test cases', () => { - let api: ApiClient; - genReadmeTests( - // Setup - async () => { - api = (await setup()).api; - }, - // Get readme - () => api.readme.get(), - // Set readme - async (newReadme) => { - await api.readme.set(newReadme); - }, - // Get readme from disk - () => readFile(`${getDataDir()}/README.md`, { encoding: 'utf-8' }), - ); -}); - -describe('Other test cases', () => { - let api: ApiClient; - - beforeEach(async () => { - const res = await setup(); - api = res.api; - }); - - test('The readme is set up by default', async () => { - await expect(api.readme.get()).resolves.toMatchObject({ readme: expect.any(String) }); - }); - - genTokenTests( - () => api, - api => api.readme.set('New readme'), - ); -}); diff --git a/tests/backend/readmeCases.ts b/tests/backend/readmeCases.ts deleted file mode 100644 index 6671c6e..0000000 --- a/tests/backend/readmeCases.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** Shared test cases for getting/setting the readme files of items/groups */ - -import api from '$endpoints'; -import { beforeEach, expect, it } from 'vitest'; - -/** - * Generate shared test cases for getting/setting the readme of a group or - * item. - * - * Similar design to `generateTestCases` from `creationCases.ts`, see that - * documentation for usage. - */ -export default function genReadmeTests( - setup: () => Promise, - getReadme: () => Promise<{ readme: string }>, - setReadme: (newReadme: string) => Promise, - getReadmeFromDisk: () => Promise -) { - beforeEach(async () => { await setup(); }); - - it.each([ - { name: 'Get readme', fn: () => getReadme() }, - { name: 'Set readme', fn: () => setReadme('New readme') }, - ])('Errors if the server is not set up ($name)', async ({ fn }) => { - await api().debug.clear(); - await expect(fn()) - .rejects.toMatchObject({ code: 400 }); - }); - - it('Correctly updates the readme contents', async () => { - await expect(setReadme('New readme')) - .toResolve(); - // API shows it correctly - await expect(getReadme()) - .resolves.toStrictEqual({ readme: 'New readme' }); - // Stored correctly on the disk - await expect(getReadmeFromDisk()).resolves.toStrictEqual('New readme'); - }); - - it('Sets up a default readme', async () => { - await expect(getReadme()) - .resolves.toStrictEqual({ readme: expect.any(String) }); - }); -} From d13dc2485e35fdfcdfce0b84174ef6ef2e04e6a7 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 18:30:43 +1100 Subject: [PATCH 029/149] Update apiFetch to allow custom fetch functions --- src/endpoints/admin/auth.ts | 7 ++++++- src/endpoints/admin/firstrun.ts | 4 +++- src/endpoints/admin/git.ts | 7 ++++++- src/endpoints/admin/index.ts | 21 +++++++++++++-------- src/endpoints/admin/keys.ts | 6 +++++- src/endpoints/config.ts | 4 +++- src/endpoints/debug.ts | 5 ++++- src/endpoints/fetch/fetch.ts | 3 ++- src/endpoints/index.ts | 12 ++++++------ src/endpoints/item.ts | 12 +++++++++++- tests/backend/helpers.ts | 5 +++-- 11 files changed, 62 insertions(+), 24 deletions(-) diff --git a/src/endpoints/admin/auth.ts b/src/endpoints/admin/auth.ts index 51cefe9..3e1fe13 100644 --- a/src/endpoints/admin/auth.ts +++ b/src/endpoints/admin/auth.ts @@ -1,7 +1,7 @@ /** Authentication endpoints */ import { apiFetch, payload } from '../fetch'; -export default (token: string | undefined) => ({ +export default (fetchFn: typeof fetch, token: string | undefined) => ({ /** * Log in as an administrator for the site * @@ -10,6 +10,7 @@ export default (token: string | undefined) => ({ */ login: async (username: string, password: string) => { return apiFetch( + fetchFn, 'POST', '/api/admin/auth/login', payload.json({ username, password }), @@ -22,6 +23,7 @@ export default (token: string | undefined) => ({ */ logout: async () => { return apiFetch( + fetchFn, 'POST', '/api/admin/auth/logout', { token }, @@ -36,6 +38,7 @@ export default (token: string | undefined) => ({ */ change: async (newUsername: string, oldPassword: string, newPassword: string) => { return apiFetch( + fetchFn, 'POST', '/api/admin/auth/change', { token, ...payload.json({ newUsername, oldPassword, newPassword }) }, @@ -48,6 +51,7 @@ export default (token: string | undefined) => ({ */ revoke: async () => { return apiFetch( + fetchFn, 'POST', '/api/admin/auth/revoke', { token } @@ -62,6 +66,7 @@ export default (token: string | undefined) => ({ */ disable: async (username: string, password: string) => { return apiFetch( + fetchFn, 'POST', '/api/admin/auth/disable', { token, ...payload.json({ username, password }) }, diff --git a/src/endpoints/admin/firstrun.ts b/src/endpoints/admin/firstrun.ts index 39b9f14..0f1a081 100644 --- a/src/endpoints/admin/firstrun.ts +++ b/src/endpoints/admin/firstrun.ts @@ -1,10 +1,11 @@ /** Git repository endpoints */ import { apiFetch, payload } from '../fetch'; -export default (token: string | undefined) => ({ +export default (fetchFn: typeof fetch, token: string | undefined) => ({ /** Set up the first account */ account: async (username: string, password: string) => { return apiFetch( + fetchFn, 'POST', '/api/admin/firstrun/account', { token, ...payload.json({ username, password }) }, @@ -13,6 +14,7 @@ export default (token: string | undefined) => ({ /** Set up the site data */ data: async (repoUrl: string | null = null, branch: string | null = null) => { return apiFetch( + fetchFn, 'POST', '/api/admin/firstrun/data', { token, ...payload.json({ repoUrl, branch }) }, diff --git a/src/endpoints/admin/git.ts b/src/endpoints/admin/git.ts index 80e1330..f9695e4 100644 --- a/src/endpoints/admin/git.ts +++ b/src/endpoints/admin/git.ts @@ -2,10 +2,11 @@ import type { RepoStatus } from '$lib/server/git'; import { apiFetch, payload } from '../fetch'; -export default (token: string | undefined) => ({ +export default (fetchFn: typeof fetch, token: string | undefined) => ({ /** Retrieve information about the data repository */ status: async () => { return apiFetch( + fetchFn, 'GET', '/api/admin/git', { token }, @@ -15,6 +16,7 @@ export default (token: string | undefined) => ({ /** Initialize a git repo */ init: async (url: string) => { return apiFetch( + fetchFn, 'POST', '/api/admin/git/init', { token, ...payload.json({ url }) }, @@ -24,6 +26,7 @@ export default (token: string | undefined) => ({ /** Perform a git commit */ commit: async (message: string) => { return apiFetch( + fetchFn, 'POST', '/api/admin/git/commit', { token, ...payload.json({ message },) }, @@ -33,6 +36,7 @@ export default (token: string | undefined) => ({ /** Perform a git push */ push: async () => { return apiFetch( + fetchFn, 'POST', '/api/admin/git/push', { token }, @@ -42,6 +46,7 @@ export default (token: string | undefined) => ({ /** Perform a git pull */ pull: async () => { return apiFetch( + fetchFn, 'POST', '/api/admin/git/pull', { token }, diff --git a/src/endpoints/admin/index.ts b/src/endpoints/admin/index.ts index 0bb36d8..7cf1111 100644 --- a/src/endpoints/admin/index.ts +++ b/src/endpoints/admin/index.ts @@ -5,24 +5,29 @@ import firstrun from './firstrun'; import { apiFetch } from '$endpoints/fetch'; import keys from './keys'; -export async function refresh(token: string | undefined) { - return await apiFetch('POST', '/api/admin/data/refresh', { token }).json() as Record; +export async function refresh(fetchFn: typeof fetch, token: string | undefined) { + return await apiFetch( + fetchFn, + 'POST', + '/api/admin/data/refresh', + { token } + ).json() as Record; } -export default function admin(token: string | undefined) { +export default function admin(fetchFn: typeof fetch, token: string | undefined) { return { /** Authentication options */ - auth: auth(token), + auth: auth(fetchFn, token), /** Git actions */ - git: git(token), + git: git(fetchFn, token), /** Key management (used for git operations) */ - keys: keys(token), + keys: keys(fetchFn, token), /** Firstrun endpoints */ - firstrun: firstrun(token), + firstrun: firstrun(fetchFn, token), /** Manage server data */ data: { /** Refresh the data store */ - refresh: () => refresh(token), + refresh: () => refresh(fetchFn, token), } }; } diff --git a/src/endpoints/admin/keys.ts b/src/endpoints/admin/keys.ts index fd36ae5..ff50c40 100644 --- a/src/endpoints/admin/keys.ts +++ b/src/endpoints/admin/keys.ts @@ -1,12 +1,13 @@ import { apiFetch, payload } from '$endpoints/fetch' -export default (token: string | undefined) => ({ +export default (fetchFn: typeof fetch, token: string | undefined) => ({ /** * Returns the server's SSH public key, and the path to the private key * file */ get: async () => { return apiFetch( + fetchFn, 'GET', '/api/admin/keys', { token }, @@ -15,6 +16,7 @@ export default (token: string | undefined) => ({ /** Sets the path to the file the server should use as the private key */ setKeyPath: async (keyPath: string) => { return apiFetch( + fetchFn, 'POST', '/api/admin/keys', { token, ...payload.json({ keyPath }) }, @@ -24,6 +26,7 @@ export default (token: string | undefined) => ({ /** Disables SSH key-based authentication */ disable: async () => { return apiFetch( + fetchFn, 'DELETE', '/api/admin/keys', { token }, @@ -32,6 +35,7 @@ export default (token: string | undefined) => ({ /** Generate an SSH key-pair */ generate: async () => { return apiFetch( + fetchFn, 'POST', '/api/admin/keys/generate', { token }, diff --git a/src/endpoints/config.ts b/src/endpoints/config.ts index 03f4a88..fe0004b 100644 --- a/src/endpoints/config.ts +++ b/src/endpoints/config.ts @@ -2,7 +2,7 @@ import { apiFetch, payload } from './fetch'; import type { ConfigJson } from '$lib/server/data/config'; -export default (token: string | undefined) => ({ +export default (fetchFn: typeof fetch, token: string | undefined) => ({ /** * Retrieve the site configuration. * @@ -10,6 +10,7 @@ export default (token: string | undefined) => ({ */ get: async () => { return apiFetch( + fetchFn, 'GET', '/data/config.json', { token }, @@ -23,6 +24,7 @@ export default (token: string | undefined) => ({ */ put: async (config: ConfigJson) => { return apiFetch( + fetchFn, 'PUT', '/data/config.json', { token, ...payload.json(config) }, diff --git a/src/endpoints/debug.ts b/src/endpoints/debug.ts index 057a38d..f7b042d 100644 --- a/src/endpoints/debug.ts +++ b/src/endpoints/debug.ts @@ -1,9 +1,10 @@ /** Debug endpoints */ import { apiFetch, payload } from './fetch'; -export default function debug(token: string | undefined) { +export default function debug(fetchFn: typeof fetch, token: string | undefined) { const clear = async () => { return apiFetch( + fetchFn, 'DELETE', '/api/debug/clear', { token }, @@ -12,6 +13,7 @@ export default function debug(token: string | undefined) { const echo = async (text: string) => { return apiFetch( + fetchFn, 'POST', '/api/debug/echo', { token, ...payload.json({ text }) }, @@ -20,6 +22,7 @@ export default function debug(token: string | undefined) { const dataRefresh = async () => { return apiFetch( + fetchFn, 'POST', '/api/debug/data/refresh', { token }, diff --git a/src/endpoints/fetch/fetch.ts b/src/endpoints/fetch/fetch.ts index 67dbbdd..48b0204 100644 --- a/src/endpoints/fetch/fetch.ts +++ b/src/endpoints/fetch/fetch.ts @@ -32,6 +32,7 @@ export function getUrl() { * @returns object giving access to the response in various formats */ export function apiFetch( + fetchFn: typeof fetch, method: HttpVerb, route: string, options?: { @@ -65,7 +66,7 @@ export function apiFetch( // Now send the request try { - return response(fetch(url, { + return response(fetchFn(url, { method, body, headers, diff --git a/src/endpoints/index.ts b/src/endpoints/index.ts index 992e635..e543ea9 100644 --- a/src/endpoints/index.ts +++ b/src/endpoints/index.ts @@ -6,18 +6,18 @@ import debug from './debug'; import item from './item'; /** Create an instance of the API client with the given token */ -export default function api(token?: string) { +export default function api(fetchFn: typeof fetch = fetch, token?: string) { return { /** Admin endpoints */ - admin: admin(token), + admin: admin(fetchFn, token), /** Debug endpoints (only available in dev mode) */ - debug: debug(token), + debug: debug(fetchFn, token), /** Site configuration */ - config: config(token), + config: config(fetchFn, token), /** Item data endpoints */ - item: (itemId: ItemId) => item(token, itemId), + item: (itemId: ItemId) => item(fetchFn, token, itemId), /** Create a new API client with the given token */ - withToken: (token: string | undefined) => api(token), + withToken: (token: string | undefined) => api(fetchFn, token), /** The token currently being used for this API client */ token, }; diff --git a/src/endpoints/item.ts b/src/endpoints/item.ts index e713daf..2e00fd9 100644 --- a/src/endpoints/item.ts +++ b/src/endpoints/item.ts @@ -2,11 +2,12 @@ import type { ItemInfo } from '$lib/server/data/item'; import { itemIdToUrl, type ItemId } from '$lib/server/data/itemId'; import { apiFetch, payload } from './fetch'; -export default function item(token: string | undefined, itemId: ItemId) { +export default function item(fetchFn: typeof fetch, token: string | undefined, itemId: ItemId) { const info = { /** Get the `info.json` content of the given item. */ get: async () => { return apiFetch( + fetchFn, 'GET', `/data/${itemIdToUrl(itemId, 'info.json')}`, { token }, @@ -15,6 +16,7 @@ export default function item(token: string | undefined, itemId: ItemId) { /** Create a new item with the given properties. */ post: async (name: string, description?: string) => { return apiFetch( + fetchFn, 'POST', `/data/${itemIdToUrl(itemId, 'info.json')}`, { token, ...payload.json({ name, description: description ?? '' }) }, @@ -23,6 +25,7 @@ export default function item(token: string | undefined, itemId: ItemId) { /** Update the `info.json` of the given item. */ put: async (info: ItemInfo) => { return apiFetch( + fetchFn, 'PUT', `/data/${itemIdToUrl(itemId, 'info.json')}`, { token, ...payload.json(info) }, @@ -31,6 +34,7 @@ export default function item(token: string | undefined, itemId: ItemId) { /** Delete the given item. */ delete: async () => { return apiFetch( + fetchFn, 'DELETE', `/data/${itemIdToUrl(itemId, 'info.json')}`, { token }, @@ -42,6 +46,7 @@ export default function item(token: string | undefined, itemId: ItemId) { /** Get the `README.md` of the given item */ get: async () => { return apiFetch( + fetchFn, 'GET', `/data/${itemIdToUrl(itemId, 'README.md')}`, { token }, @@ -50,6 +55,7 @@ export default function item(token: string | undefined, itemId: ItemId) { /** Update the `README.md` of the given item */ put: async (readme: string) => { return apiFetch( + fetchFn, 'PUT', `/data/${itemIdToUrl(itemId, 'README.md')}`, { token, ...payload.markdown(readme) }, @@ -61,6 +67,7 @@ export default function item(token: string | undefined, itemId: ItemId) { /** Get the contents of the given file */ get: async () => { return apiFetch( + fetchFn, 'GET', `/data/${itemIdToUrl(itemId, filename)}`, { token } @@ -69,6 +76,7 @@ export default function item(token: string | undefined, itemId: ItemId) { /** Create a file at the given path */ post: async (file: File) => { return apiFetch( + fetchFn, 'POST', `/data/${itemIdToUrl(itemId, filename)}`, { token, ...payload.file(file) }, @@ -77,6 +85,7 @@ export default function item(token: string | undefined, itemId: ItemId) { /** Update the contents of a file at the given path */ put: async (file: File) => { return apiFetch( + fetchFn, 'PUT', `/data/${itemIdToUrl(itemId, filename)}`, { token, ...payload.file(file) }, @@ -85,6 +94,7 @@ export default function item(token: string | undefined, itemId: ItemId) { /** Remove the file at the given path */ delete: async () => { return apiFetch( + fetchFn, 'DELETE', `/data/${itemIdToUrl(itemId, filename)}`, { token }, diff --git a/tests/backend/helpers.ts b/tests/backend/helpers.ts index 2ef256d..140b05b 100644 --- a/tests/backend/helpers.ts +++ b/tests/backend/helpers.ts @@ -11,9 +11,10 @@ export async function setup(repoUrl?: string, branch?: string) { const username = 'admin'; const password = 'abc123ABC!'; const { token } = await api().admin.firstrun.account(username, password); - await api(token).admin.firstrun.data(repoUrl, branch); + const client = api(fetch, token); + await client.admin.firstrun.data(repoUrl, branch); return { - api: api(token), + api: client, token, username, password, From 746e2cb04dd99cfd92342bcbfa5405d4b95d4e67 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 19:52:52 +1100 Subject: [PATCH 030/149] Require Node >= 22 --- .nvmrc | 1 + package-lock.json | 3 +++ package.json | 3 +++ 3 files changed, 7 insertions(+) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..dc0bb0f --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v22.12.0 diff --git a/package-lock.json b/package-lock.json index 1af6163..cc36cb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,9 @@ "typescript-eslint": "^8.7.0", "vite": "^5.4.6", "vitest": "^2.0.5" + }, + "engines": { + "node": ">=22" } }, "node_modules/@ampproject/remapping": { diff --git a/package.json b/package.json index f7dab9d..7ef47a2 100644 --- a/package.json +++ b/package.json @@ -75,5 +75,8 @@ "@sveltejs/kit": { "cookie": "^0.7.0" } + }, + "engines": { + "node": ">=22" } } From 1348f4339a86ec0d685c0251676d31c0a87ca94b Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 19:53:44 +1100 Subject: [PATCH 031/149] Fix node versions in CI --- .github/workflows/node.js.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 7b5ac81..2effb86 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -14,10 +14,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.x + - name: Use Node.js 22.x uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x cache: 'npm' - run: npm ci # Set up SSH access, as the test suite needs to be able to access git @@ -63,10 +63,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.x + - name: Use Node.js 22.x uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x cache: 'npm' - run: npm ci - name: Run linting @@ -76,10 +76,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.x + - name: Use Node.js 22.x uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x cache: 'npm' - run: npm ci - name: Run type-checking From df1859b9e48cab8339d17ffca895b8f6ccde13a6 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 20:08:26 +1100 Subject: [PATCH 032/149] Implement endpoints for getting recursive item data --- src/lib/server/data/item.ts | 27 +++++++++++++++++++ src/routes/data/+server.ts | 6 +++++ .../data/[...item]/[filename]/+server.ts | 10 ++++++- 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/routes/data/+server.ts diff --git a/src/lib/server/data/item.ts b/src/lib/server/data/item.ts index cb2046a..7738524 100644 --- a/src/lib/server/data/item.ts +++ b/src/lib/server/data/item.ts @@ -212,3 +212,30 @@ export async function* iterItems(item: ItemId = []): AsyncIterableIterator, +} + +/** Returns the full text data for the given item */ +export async function getItemData(itemId: ItemId): Promise { + const info = await getItemInfo(itemId); + const readme = await fs.readFile(itemPath(itemId, 'README.md'), { encoding: 'utf-8' }); + + const children: Record = {}; + for await (const child of itemChildren(itemId)) { + children[itemIdTail(child)] = await getItemData(child); + } + + return { + info, + readme, + children, + }; +} diff --git a/src/routes/data/+server.ts b/src/routes/data/+server.ts new file mode 100644 index 0000000..a77544b --- /dev/null +++ b/src/routes/data/+server.ts @@ -0,0 +1,6 @@ +import { getItemData } from '$lib/server/data/item'; +import { json } from '@sveltejs/kit'; + +export async function GET() { + return json(await getItemData([])); +} diff --git a/src/routes/data/[...item]/[filename]/+server.ts b/src/routes/data/[...item]/[filename]/+server.ts index d3a2122..89a2e3e 100644 --- a/src/routes/data/[...item]/[filename]/+server.ts +++ b/src/routes/data/[...item]/[filename]/+server.ts @@ -8,17 +8,25 @@ import mime from 'mime-types'; import { formatItemId, itemIdFromUrl, type ItemId } from '$lib/server/data/itemId'; import { fileExists } from '$lib/server/util'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; -import { itemExists, itemPath } from '$lib/server/data/item'; +import { getItemData, itemExists, itemPath } from '$lib/server/data/item'; type Request = import('./$types').RequestEvent; /** GET request handler, returns file contents */ export async function GET(req: Request) { const item: ItemId = itemIdFromUrl(req.params.item); + if (!await itemExists(item)) { error(404, `Item ${formatItemId(item)} does not exist`); } // Sanitize the filename to prevent unwanted access to the server's filesystem const filename = sanitize(req.params.filename); + + // If this is a request to an item directory (not a file within it), then return the full info on + // the item. + if (!await itemExists([...item, filename])) { + return json(await getItemData([...item, filename])); + } + // Get the path of the file to serve const filePath = itemPath(item, filename); From 817f3844f24dbb5e907bd05940810172b10a76a1 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 21:22:19 +1100 Subject: [PATCH 033/149] Add tests for getting recursive item data --- src/endpoints/item.ts | 11 ++++- .../data/[...item]/[filename]/+server.ts | 2 +- tests/backend/item/data.test.ts | 45 +++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 tests/backend/item/data.test.ts diff --git a/src/endpoints/item.ts b/src/endpoints/item.ts index 2e00fd9..0e0281b 100644 --- a/src/endpoints/item.ts +++ b/src/endpoints/item.ts @@ -1,4 +1,4 @@ -import type { ItemInfo } from '$lib/server/data/item'; +import type { ItemData, ItemInfo } from '$lib/server/data/item'; import { itemIdToUrl, type ItemId } from '$lib/server/data/itemId'; import { apiFetch, payload } from './fetch'; @@ -109,5 +109,14 @@ export default function item(fetchFn: typeof fetch, token: string | undefined, i readme, /** A file belonging to the item */ file, + /** The full recursive item data */ + data: async () => { + return apiFetch( + fetchFn, + 'GET', + `/data/${itemIdToUrl(itemId)}`, + { token }, + ).json() as Promise; + } }; } diff --git a/src/routes/data/[...item]/[filename]/+server.ts b/src/routes/data/[...item]/[filename]/+server.ts index 89a2e3e..24df230 100644 --- a/src/routes/data/[...item]/[filename]/+server.ts +++ b/src/routes/data/[...item]/[filename]/+server.ts @@ -23,7 +23,7 @@ export async function GET(req: Request) { // If this is a request to an item directory (not a file within it), then return the full info on // the item. - if (!await itemExists([...item, filename])) { + if (await itemExists([...item, filename])) { return json(await getItemData([...item, filename])); } diff --git a/tests/backend/item/data.test.ts b/tests/backend/item/data.test.ts new file mode 100644 index 0000000..9abeb8b --- /dev/null +++ b/tests/backend/item/data.test.ts @@ -0,0 +1,45 @@ +/** + * Test cases for getting the full data + */ + +import type { ApiClient } from '$endpoints'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { setup } from '../helpers'; + +let api: ApiClient; +beforeEach(async () => { + api = (await setup()).api; + await api.item(['child']).info.post('Child item'); +}); + +describe('Success', () => { + it('Shows full information for the given item', async () => { + await expect(api.item(['child']).data()).resolves.toStrictEqual({ + info: expect.any(Object), + readme: expect.any(String), + children: {}, + }); + }); + + it('Recursively shows information about the child items', async () => { + await expect(api.item([]).data()).resolves.toStrictEqual({ + info: expect.any(Object), + readme: expect.any(String), + children: { + // Child object's info + child: { + info: expect.any(Object), + readme: expect.any(String), + children: {}, + } + }, + }); + }); +}); + +describe('404', () => { + it("Rejects requests for items that don't exist", async () => { + await expect(api.item(['invalid']).data()) + .rejects.toMatchObject({ code: 404 }); + }); +}); From 6bf198eb2f4d080d34af9d5060aadcf929e9b931 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 21:27:08 +1100 Subject: [PATCH 034/149] Move `ItemId` to public code --- src/endpoints/index.ts | 2 +- src/endpoints/item.ts | 2 +- src/lib/{server/data => }/itemId.ts | 0 src/lib/server/data/item.ts | 2 +- src/lib/server/data/section.ts | 2 +- src/lib/validate.ts | 2 +- src/routes/data/[...item]/README.md/+server.ts | 2 +- src/routes/data/[...item]/[filename]/+server.ts | 2 +- src/routes/data/[...item]/info.json/+server.ts | 2 +- tests/backend/helpers.ts | 2 +- 10 files changed, 9 insertions(+), 9 deletions(-) rename src/lib/{server/data => }/itemId.ts (100%) diff --git a/src/endpoints/index.ts b/src/endpoints/index.ts index e543ea9..5fb581a 100644 --- a/src/endpoints/index.ts +++ b/src/endpoints/index.ts @@ -1,5 +1,5 @@ /** API endpoints */ -import type { ItemId } from '$lib/server/data/itemId'; +import type { ItemId } from '$lib/itemId'; import admin from './admin'; import config from './config'; import debug from './debug'; diff --git a/src/endpoints/item.ts b/src/endpoints/item.ts index 0e0281b..a1cfc17 100644 --- a/src/endpoints/item.ts +++ b/src/endpoints/item.ts @@ -1,5 +1,5 @@ import type { ItemData, ItemInfo } from '$lib/server/data/item'; -import { itemIdToUrl, type ItemId } from '$lib/server/data/itemId'; +import { itemIdToUrl, type ItemId } from '$lib/itemId'; import { apiFetch, payload } from './fetch'; export default function item(fetchFn: typeof fetch, token: string | undefined, itemId: ItemId) { diff --git a/src/lib/server/data/itemId.ts b/src/lib/itemId.ts similarity index 100% rename from src/lib/server/data/itemId.ts rename to src/lib/itemId.ts diff --git a/src/lib/server/data/item.ts b/src/lib/server/data/item.ts index 7738524..f4b928f 100644 --- a/src/lib/server/data/item.ts +++ b/src/lib/server/data/item.ts @@ -8,7 +8,7 @@ import path from 'path'; import { error } from '@sveltejs/kit'; import { array, nullable, string, type, type Infer } from 'superstruct'; import validate from '$lib/validate'; -import { formatItemId, itemIdsEqual, ItemIdStruct, itemIdTail, itemParent, type ItemId } from './itemId'; +import { formatItemId, itemIdsEqual, ItemIdStruct, itemIdTail, itemParent, type ItemId } from '../../itemId'; import { getDataDir } from './dataDir'; import { ItemSectionStruct, validateSection } from './section'; import { applyStruct } from '../util'; diff --git a/src/lib/server/data/section.ts b/src/lib/server/data/section.ts index 5cf7623..67a57bf 100644 --- a/src/lib/server/data/section.ts +++ b/src/lib/server/data/section.ts @@ -6,7 +6,7 @@ import { array, enums, literal, string, type, union, type Infer } from 'superstruct'; import { error } from '@sveltejs/kit'; import validate from '$lib/validate'; -import { itemIdsEqual, ItemIdStruct, type ItemId } from './itemId'; +import { itemIdsEqual, ItemIdStruct, type ItemId } from '../../itemId'; import { RepoInfoStruct } from './itemRepo'; import { PackageInfoStruct } from './itemPackage'; import { itemExists } from './item'; diff --git a/src/lib/validate.ts b/src/lib/validate.ts index f0a07b2..74e8b65 100644 --- a/src/lib/validate.ts +++ b/src/lib/validate.ts @@ -7,7 +7,7 @@ import { error } from '@sveltejs/kit'; import fs from 'fs/promises'; import mime from 'mime-types'; import sanitize from 'sanitize-filename'; -import type { ItemId } from './server/data/itemId'; +import type { ItemId } from './itemId'; import { getDataDir } from './server/data/dataDir'; import path from 'path'; diff --git a/src/routes/data/[...item]/README.md/+server.ts b/src/routes/data/[...item]/README.md/+server.ts index 5d5b253..a2a7d66 100644 --- a/src/routes/data/[...item]/README.md/+server.ts +++ b/src/routes/data/[...item]/README.md/+server.ts @@ -4,7 +4,7 @@ * Note that POST and DELETE methods are unavailable, as the lifetime of the README.md file should * match that of the item itself. */ -import { formatItemId, itemIdFromUrl, type ItemId } from '$lib/server/data/itemId'; +import { formatItemId, itemIdFromUrl, type ItemId } from '$lib/itemId'; import fs from 'fs/promises'; import { error, json } from '@sveltejs/kit'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; diff --git a/src/routes/data/[...item]/[filename]/+server.ts b/src/routes/data/[...item]/[filename]/+server.ts index 24df230..b03e1c2 100644 --- a/src/routes/data/[...item]/[filename]/+server.ts +++ b/src/routes/data/[...item]/[filename]/+server.ts @@ -5,7 +5,7 @@ import fs from 'fs/promises'; import { error, json } from '@sveltejs/kit'; import sanitize from 'sanitize-filename'; import mime from 'mime-types'; -import { formatItemId, itemIdFromUrl, type ItemId } from '$lib/server/data/itemId'; +import { formatItemId, itemIdFromUrl, type ItemId } from '$lib/itemId'; import { fileExists } from '$lib/server/util'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { getItemData, itemExists, itemPath } from '$lib/server/data/item'; diff --git a/src/routes/data/[...item]/info.json/+server.ts b/src/routes/data/[...item]/info.json/+server.ts index 1b64deb..08207ce 100644 --- a/src/routes/data/[...item]/info.json/+server.ts +++ b/src/routes/data/[...item]/info.json/+server.ts @@ -1,7 +1,7 @@ import fs from 'fs/promises'; import { json, error } from '@sveltejs/kit'; import { object, string } from 'superstruct'; -import { formatItemId, itemIdFromUrl, validateItemId, itemIdTail, itemParent } from '$lib/server/data/itemId'; +import { formatItemId, itemIdFromUrl, validateItemId, itemIdTail, itemParent } from '$lib/itemId'; import { deleteItem, getItemInfo, itemExists, itemPath, setItemInfo, validateItemInfo } from '$lib/server/data/item'; import { validateTokenFromRequest } from '$lib/server/auth/tokens'; import { applyStruct } from '$lib/server/util'; diff --git a/tests/backend/helpers.ts b/tests/backend/helpers.ts index 140b05b..9b5285b 100644 --- a/tests/backend/helpers.ts +++ b/tests/backend/helpers.ts @@ -4,7 +4,7 @@ import { version } from '$app/environment'; import simpleGit from 'simple-git'; import { getDataDir } from '$lib/server/data/dataDir'; import type { ItemInfo } from '$lib/server/data/item'; -import type { ItemId } from '$lib/server/data/itemId'; +import type { ItemId } from '$lib/itemId'; /** Set up the server, returning (amongst other things) an API client */ export async function setup(repoUrl?: string, branch?: string) { From 6c8262aca64ba5b38ac84f38d36ea3106ead4cf0 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 21:52:17 +1100 Subject: [PATCH 035/149] Get basic page rendering --- src/lib/itemData.ts | 17 +++ src/lib/itemId.ts | 3 + src/lib/seo.ts | 17 ++- src/routes/+page.server.ts | 15 -- src/routes/+page.svelte | 217 --------------------------- src/routes/[...item]/+page.server.ts | 29 ++++ src/routes/[...item]/+page.svelte | 88 +++++++++++ 7 files changed, 146 insertions(+), 240 deletions(-) create mode 100644 src/lib/itemData.ts delete mode 100644 src/routes/+page.server.ts delete mode 100644 src/routes/+page.svelte create mode 100644 src/routes/[...item]/+page.server.ts create mode 100644 src/routes/[...item]/+page.svelte diff --git a/src/lib/itemData.ts b/src/lib/itemData.ts new file mode 100644 index 0000000..a2caac4 --- /dev/null +++ b/src/lib/itemData.ts @@ -0,0 +1,17 @@ +/** + * Functions for navigating and operating on the `ItemData` type + */ + +import type { ItemId } from './itemId'; +import type { ItemData } from './server/data/item'; + +/** + * Return a descendant of the given item data, using the given relative ItemId + */ +export function getDescendant(data: ItemData, itemId: ItemId) { + if (itemId.length === 0) { + return data; + } else { + return getDescendant(data.children[itemId[0]], itemId.slice(1)); + } +} diff --git a/src/lib/itemId.ts b/src/lib/itemId.ts index 5a9ab86..ba58abf 100644 --- a/src/lib/itemId.ts +++ b/src/lib/itemId.ts @@ -7,6 +7,9 @@ import { array, string, type Infer } from 'superstruct'; /** Return an item ID given its path in URL form */ export function itemIdFromUrl(path: string): ItemId { + if (path === '') { + return []; + } return path.split('/'); } diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 998732c..3a2dbc5 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -2,15 +2,16 @@ * Helper functions used in SEO (search engine optimization). */ -import type { PortfolioGlobals } from './server'; +import type { ItemId } from './itemId'; +import type { ItemData } from './server/data/item'; -export function generateKeywords(globals: PortfolioGlobals, groupId?: string, itemId?: string): string { - const keywords = [...globals.config.siteKeywords]; - if (groupId) { - keywords.push(...globals.groups[groupId].info.keywords); - if (itemId) { - keywords.push(...globals.items[groupId][itemId].info.keywords); - } +export function generateKeywords(data: ItemData, itemId: ItemId): string { + const keywords: string[] = []; + + for (const child of itemId) { + keywords.push(...data.info.seo.keywords); + data = data.children[child]; } + return keywords.join(', '); } diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts deleted file mode 100644 index 5d67974..0000000 --- a/src/routes/+page.server.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import { getPortfolioGlobals } from '$lib/server'; -import { authIsSetUp, dataIsSetUp } from '$lib/server/data/dataDir'; -import { isRequestAuthorized } from '$lib/server/auth/tokens'; - -export async function load(req: import('./$types').RequestEvent) { - if (!await authIsSetUp()) { - redirect(303, '/admin/firstrun/account'); - } - if (!await dataIsSetUp()) { - redirect(303, '/admin/firstrun/data'); - } - const globals = await getPortfolioGlobals(); - return { globals, loggedIn: await isRequestAuthorized(req) }; -} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte deleted file mode 100644 index e18f107..0000000 --- a/src/routes/+page.svelte +++ /dev/null @@ -1,217 +0,0 @@ - - - - {data.globals.config.siteName} - - - - - - - - - - -
- { - editing = true; - }} - onfinish={finishEditing} - /> - - {#if editing} -
{ - e.preventDefault(); - void finishEditing(true); - }} - > -

Site name

- -

- This is the name of your portfolio site. It is shown on the home page of - your portfolio. -

-

Site short name

- -

- This is the short name of your portfolio site. It is shown on most pages - within your portfolio. -

-

Site description

- -

This is the description of your portfolio shown to search engines.

-

Site keywords

- -

- These are the keywords for your portfolio shown to search engines. Place - each keyword on a new line. -

-

Theme color

- -

- This is the main theme color for your portfolio site. It is subtly shown - in the background on many pages. -

-
- {/if} - -
-
- finishEditing(true)} - /> -
-
- - -
- { - if (editing) { - listedGroups = listedGroups.filter((g) => g !== groupId); - hiddenGroups = [...hiddenGroups, groupId]; - } - }} - /> -
- {#if editing} -
-

Hidden groups

- { - listedGroups = [...listedGroups, groupId]; - hiddenGroups = hiddenGroups.filter((g) => g !== groupId); - }} - /> -
- {/if} -
- - diff --git a/src/routes/[...item]/+page.server.ts b/src/routes/[...item]/+page.server.ts new file mode 100644 index 0000000..bec6310 --- /dev/null +++ b/src/routes/[...item]/+page.server.ts @@ -0,0 +1,29 @@ +import { error, redirect } from '@sveltejs/kit'; +import { authIsSetUp, dataIsSetUp } from '$lib/server/data/dataDir'; +import { isRequestAuthorized } from '$lib/server/auth/tokens'; +import { formatItemId, itemIdFromUrl } from '$lib/itemId'; +import { getItemData, itemExists } from '$lib/server/data/item'; +import { getConfig } from '$lib/server/data/config'; + +export async function load(req: import('./$types').RequestEvent) { + if (!await authIsSetUp()) { + redirect(303, '/admin/firstrun/account'); + } + if (!await dataIsSetUp()) { + redirect(303, '/admin/firstrun/data'); + } + const itemId = itemIdFromUrl(req.params.item); + + if (!await itemExists(itemId)) { + error(404, `Item ${formatItemId(itemId)} does not exist`); + } + const portfolio = await getItemData([]); + const config = await getConfig(); + + return { + itemId, + portfolio, + config, + loggedIn: await isRequestAuthorized(req) + }; +} diff --git a/src/routes/[...item]/+page.svelte b/src/routes/[...item]/+page.svelte new file mode 100644 index 0000000..71b7518 --- /dev/null +++ b/src/routes/[...item]/+page.svelte @@ -0,0 +1,88 @@ + + + + {data.portfolio.info.name} + {#if data.portfolio.info.seo.description} + + {/if} + + + + {#if data.config.siteIcon} + + {/if} + + + + + + +
+
+
+ {}} + /> +
+
+
+ + From ea83bcc20b01bcd4afe311383b4f60a54464879d Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Wed, 1 Jan 2025 21:54:04 +1100 Subject: [PATCH 036/149] Remove SSH from CI, since testing it was too tedious --- .github/workflows/node.js.yml | 31 ---------------- .github/workflows/secrets/.gitignore | 3 -- .github/workflows/secrets/README.md | 44 ----------------------- .github/workflows/secrets/id_ed25519.enc | Bin 358 -> 0 bytes .github/workflows/secrets/id_ed25519.pub | 1 - 5 files changed, 79 deletions(-) delete mode 100644 .github/workflows/secrets/.gitignore delete mode 100644 .github/workflows/secrets/README.md delete mode 100644 .github/workflows/secrets/id_ed25519.enc delete mode 100644 .github/workflows/secrets/id_ed25519.pub diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 2effb86..dd898fb 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -20,44 +20,13 @@ jobs: node-version: 22.x cache: 'npm' - run: npm ci - # Set up SSH access, as the test suite needs to be able to access git - # repos to validate functionality. - - name: Decrypt secrets - env: - PASSWORD: ${{ secrets.SSH_ENCRYPTION_KEY }} - run: | - gpg --batch --passphrase $PASSWORD --output .github/workflows/secrets/id_ed25519 --decrypt .github/workflows/secrets/id_ed25519.enc - - name: Setup SSH agent - env: - SSH_AUTH_SOCK: /tmp/ssh_agent.sock - run: | - mkdir -p ~/.ssh - ssh-keyscan github.com >> ~/.ssh/known_hosts - - ssh-agent -a $SSH_AUTH_SOCK > /dev/null - chmod 0600 .github/workflows/secrets/id_ed25519 - ssh-add .github/workflows/secrets/id_ed25519 - - name: Set up git config - run: | - git config --global user.name "MadGutsBot" - git config --global user.email "103484332+MadGutsBot@users.noreply.github.com" - name: Set up .env run: cp .env.example .env - name: Run test suite - env: - SSH_AUTH_SOCK: /tmp/ssh_agent.sock run: npm test - name: Show server output if: always() run: cat server.log - - name: Cleanup SSH Agent - if: always() - env: - SSH_AUTH_SOCK: /tmp/ssh_agent.sock - run: | - ssh-add -D - rm -Rf ~/.ssh - rm .github/workflows/secrets/id_ed25519 lint: runs-on: ubuntu-latest diff --git a/.github/workflows/secrets/.gitignore b/.github/workflows/secrets/.gitignore deleted file mode 100644 index b1991db..0000000 --- a/.github/workflows/secrets/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -id_ed25519 -# Just in case -id_ed25519.new diff --git a/.github/workflows/secrets/README.md b/.github/workflows/secrets/README.md deleted file mode 100644 index 074c24b..0000000 --- a/.github/workflows/secrets/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Repo secrets - -This directory contains secrets used during CI. - -## `id_ed25519` - -SSH key. Since the app has Git integration, this SSH key is used to access a -small number of repos used for testing purposes. - -### Granting access to a GitHub repo - -1. Copy the public key from `.github/workflows/secrets/id_ed25519.pub` -2. Visit the "Deploy keys" settings for the repo you wish to grant access to. -3. Choose to add a new key, and paste the public key. Ensure you allow write - access. - -The test suite should then be able to clone and push to the repo in CI. - -### Regenerating the key - -```sh -# Generate SSH key -ssh-keygen -t ed25519 -f .github/workflows/secrets/id_ed25519 -C "maddy-portfolio" -N "" -# Generate encryption password -export PASSWORD=$(pwgen 32 1) -# And encrypt it -gpg --passphrase $PASSWORD --cipher-algo AES256 --output .github/workflows/secrets/id_ed25519.enc --symmetric --batch .github/workflows/secrets/id_ed25519C -# Copy the password to your clipboard -echo $PASSWORD -``` - -Make sure to update the `SSH_ENCRYPTION_KEY` in the repo's GitHub Actions -secrets settings. Its value should be set to the password you copied. - -### Decrypting the key - -```sh -gpg --batch --passphrase $PASSWORD --output .github/workflows/secrets/id_ed25519.new --decrypt .github/workflows/secrets/id_ed25519.enc -``` - -### Sources - -* [GitHub actions](https://stackoverflow.com/a/76888551/6335363) -* [Encrypting the keys](https://stackoverflow.com/a/31552829/6335363) diff --git a/.github/workflows/secrets/id_ed25519.enc b/.github/workflows/secrets/id_ed25519.enc deleted file mode 100644 index d6d879b5329f3c53a738fa4293ef374c61c6f792..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 358 zcmV-s0h#`c4Fm}T2rO^>Fp{bR*7nlClmXjf&_6j8m-<>1;39&>OiGf%Ka`V9WXJmh zn=vrcMQ;=zjXp>v#T1?T2U+Myr{b6p)X$y6e#PS~8p2H5zHol8**AF7=rdaR8{(<_ z>e)Bb!!@P`b!MAkzwugB_=WYoL;;K;+R1?(L2W2DQiR)Xa=#(@N^6$Olhh&N7)@;$ z4`Nj3kEvVAgI6pN)x{~g#y+))xcUGB+g4mUJ#w_25)V^jnHQXW%OQT)$;GZwumx>8 zxPe6zthikavU}O-lW%69gHOvb5n+=Bc2q}4zpyK{0r=n1Va=dPfY?`RswXOi&hooA zgsqOcBg+vFc|*Ph^O#RAoi}f$Wfb#KF96@2HiL|G>)~%w3*PM)sr$jsqSU@w)rEHr z^K|QR;U1KWFr0W(esZ5p< Date: Wed, 1 Jan 2025 22:38:35 +1100 Subject: [PATCH 037/149] Get navbar working --- src/components/navbar/Navbar.svelte | 71 +++++++++++++++++++++-------- src/lib/server/data/item.ts | 5 +- src/lib/server/serverValidate.ts | 37 +++++++++++++++ src/lib/validate.ts | 29 ------------ src/routes/[...item]/+page.svelte | 3 +- 5 files changed, 95 insertions(+), 50 deletions(-) create mode 100644 src/lib/server/serverValidate.ts diff --git a/src/components/navbar/Navbar.svelte b/src/components/navbar/Navbar.svelte index 30ee2e0..1825c61 100644 --- a/src/components/navbar/Navbar.svelte +++ b/src/components/navbar/Navbar.svelte @@ -2,17 +2,57 @@ import { dev } from '$app/environment'; import { goto } from '$app/navigation'; import api from '$endpoints'; - import type { ConfigJson } from '$lib/server/data/config'; import Separator from '$components/Separator.svelte'; + import type { ItemId } from '$lib/itemId'; + import type { ItemData } from '$lib/server/data/item'; + import { getDescendant } from '$lib/itemData'; + + type NavbarPath = { url: string; txt: string }[]; type Props = { - path: { url: string; txt: string }[]; - config: ConfigJson; + path: ItemId | NavbarPath; + data: ItemData; /** Whether the user is logged in. Set to undefined if auth is disabled */ loggedIn: boolean | undefined; }; - let { path, config, loggedIn }: Props = $props(); + let { path, data, loggedIn }: Props = $props(); + + function itemIdToPath(itemId: ItemId): NavbarPath { + const tailPath = itemId.map((p, i) => { + const descendant = getDescendant(data, itemId.slice(0, i)).info; + return { + url: p, + txt: + i === itemId.length - 1 + ? descendant.name + : (descendant.shortName ?? descendant.name), + }; + }); + return [ + { + url: '', + txt: itemId.length + ? (data.info.shortName ?? data.info.name) + : data.info.name, + }, + ...tailPath, + ]; + } + + let overallPath: NavbarPath = $derived.by(() => { + if (path.length === 0 || typeof path[0] === 'string') { + // Path is ItemId + return itemIdToPath(path as ItemId); + } else { + return [ + { url: '', txt: data.info.shortName ?? data.info.name }, + ...(path as NavbarPath), + ]; + } + }); + + $inspect(overallPath); /** Log out, then reload the page */ async function logOut() { @@ -34,8 +74,8 @@ } // This function needs to accept `path` as an input, otherwise the links - // stop being reactive due to cacheing or something - function pathTo(path: { url: string; txt: string }[], i: number) { + // stop being reactive due to caching or something + function pathTo(path: NavbarPath, i: number) { return path .slice(0, i + 1) .map((p) => p.url) @@ -45,18 +85,13 @@ @@ -158,20 +159,10 @@ #control-buttons { display: flex; + gap: 5px; align-items: center; justify-content: center; grid-area: control-buttons; margin-right: 20px; } - #control-buttons button { - /* margin: 10px; */ - padding: 10px; - background-color: transparent; - border-radius: 5px; - border: none; - } - #control-buttons button:hover { - cursor: pointer; - background-color: rgba(124, 124, 124, 0.253); - } diff --git a/src/components/pickers/FilePicker.svelte b/src/components/pickers/FilePicker.svelte index 12670b1..4413afd 100644 --- a/src/components/pickers/FilePicker.svelte +++ b/src/components/pickers/FilePicker.svelte @@ -1,4 +1,6 @@ - {#if !forceSelection} {/if} {#each files as file} {/each} - + diff --git a/src/routes/[...item]/ItemFile.svelte b/src/routes/[...item]/ItemFile.svelte index 6f7ae07..1d29514 100644 --- a/src/routes/[...item]/ItemFile.svelte +++ b/src/routes/[...item]/ItemFile.svelte @@ -1,4 +1,5 @@ @@ -55,11 +56,9 @@ {/each}
-
+ + import { Button, Select } from '$components/base'; import type { ItemSection, SectionType } from '$lib/server/data/item/section'; + import { capitalize } from '$lib/util'; type Props = { oncreate: (info: ItemSection) => void; @@ -60,12 +62,12 @@ }} > Add new section: - {#each Object.keys(defaultSections) as type} - + {/each} - - + +
diff --git a/src/routes/admin/ChangePassword.svelte b/src/routes/admin/ChangePassword.svelte index 54ae36e..17a68c1 100644 --- a/src/routes/admin/ChangePassword.svelte +++ b/src/routes/admin/ChangePassword.svelte @@ -1,4 +1,5 @@

Reload data from disk

If you have edited your data manually, you can use this button to refresh it. - +
From 8e3afb3bece86335f6d56f6df6a6ca79af65e507 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Tue, 28 Jan 2025 22:49:50 +1100 Subject: [PATCH 086/149] Add color modes to button control --- src/components/EditControls.svelte | 2 +- src/components/base/Button.svelte | 58 +++++++++++++++++++++++++---- src/components/navbar/Navbar.svelte | 2 +- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/components/EditControls.svelte b/src/components/EditControls.svelte index 87baad8..74ef399 100644 --- a/src/components/EditControls.svelte +++ b/src/components/EditControls.svelte @@ -18,7 +18,7 @@ {#if loggedIn}
{#if editing} - + {:else} {/if} diff --git a/src/components/base/Button.svelte b/src/components/base/Button.svelte index 917a93f..08bd0a1 100644 --- a/src/components/base/Button.svelte +++ b/src/components/base/Button.svelte @@ -3,8 +3,13 @@ import type { Snippet } from 'svelte'; type Props = { - children: Snippet; + /** Display mode for button (controls background color) */ + mode?: 'default' | 'warning' | 'confirm'; + /** Hint for button (controls tooltip and aria-label) */ hint?: string; + + // Standard button props + children: Snippet; type?: 'submit'; /** Whether button is disabled */ disabled?: boolean; @@ -12,7 +17,37 @@ onclick?: () => any; }; - const { children, hint, type, disabled, onclick }: Props = $props(); + const { + children, + hint, + type, + disabled, + onclick, + mode = 'default', + }: Props = $props(); + + let { color, hoverColor, clickColor } = $derived.by(() => { + if (mode === 'warning') { + return { + color: '#FF808040', + hoverColor: '#FF8080FF', + clickColor: '#FF2222FF', + }; + } + if (mode === 'confirm') { + return { + color: '#8080FF40', + hoverColor: '#8080FFFF', + clickColor: '#2222FFFF', + }; + } + // mode === 'default' + return { + color: '#00000000', + hoverColor: '#80808040', + clickColor: '#FFFFFFFF', + }; + }); {#if hint} @@ -22,11 +57,20 @@ {disabled} use:tooltip={{ content: hint }} aria-label={hint} + style:--color={color} + style:--hoverColor={hoverColor} + style:--clickColor={clickColor} > {@render children()} {:else} - {/if} @@ -40,21 +84,21 @@ all: unset; /* margin: 10px; */ padding: 5px 10px; - background-color: transparent; + background-color: var(--color); border-radius: 5px; border: 1px solid rgba(0, 0, 0, 0.15); transition: background-color 0.5s; } button:focus { border: 1px solid black; - background-color: rgba(124, 124, 124, 0.25); + background-color: var(--hoverColor); } button:hover { cursor: pointer; - background-color: rgba(124, 124, 124, 0.25); + background-color: var(--hoverColor); } button:active { - background-color: rgba(255, 255, 255, 0.9); + background-color: var(--clickColor); transition: background-color 0s; } diff --git a/src/components/navbar/Navbar.svelte b/src/components/navbar/Navbar.svelte index bd649ec..bca234d 100644 --- a/src/components/navbar/Navbar.svelte +++ b/src/components/navbar/Navbar.svelte @@ -133,7 +133,7 @@ {#if dev} - + {/if} From 3cd340968e39637cd33df4ee17af2cebed6d256f Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Tue, 28 Jan 2025 23:03:58 +1100 Subject: [PATCH 087/149] Switch from `color` to `colord` --- package-lock.json | 72 ++------------------ package.json | 3 +- src/components/Background.svelte | 9 +-- src/components/card/Card.svelte | 6 +- src/components/chip/Chip.svelte | 12 ++-- src/routes/[...item]/sections/Section.svelte | 2 +- src/routes/admin/LogOutAll.svelte | 2 +- 7 files changed, 19 insertions(+), 87 deletions(-) diff --git a/package-lock.json b/package-lock.json index 56acccb..6b97121 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "GPL-3.0-only", "dependencies": { "child-process-promise": "^2.2.1", - "color": "^4.2.3", + "colord": "^2.9.3", "dotenv": "^16.4.5", "highlight.js": "^11.9.0", "jsonwebtoken": "^9.0.2", @@ -33,7 +33,6 @@ "@sveltejs/vite-plugin-svelte": "^4.0.0", "@testing-library/svelte": "^5.1.0", "@types/child-process-promise": "^2.2.6", - "@types/color": "^3.0.6", "@types/debug": "^4.1.12", "@types/eslint": "^9.6.1", "@types/eslint__js": "^8.42.3", @@ -1421,33 +1420,6 @@ "@types/node": "*" } }, - "node_modules/@types/color": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/color/-/color-3.0.6.tgz", - "integrity": "sha512-NMiNcZFRUAiUUCCf7zkAelY8eV3aKqfbzyFQlXpPIEeoNDbsEHGpb854V3gzTsGKYj830I5zPuOwU/TP5/cW6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/color-convert": "*" - } - }, - "node_modules/@types/color-convert": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/color-convert/-/color-convert-2.0.4.tgz", - "integrity": "sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/color-name": "^1.1.0" - } - }, - "node_modules/@types/color-name": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.5.tgz", - "integrity": "sha512-j2K5UJqGTxeesj6oQuGpMgifpT5k9HprgQd8D1Y0lOFqKHl3PJu5GMeS4Y5EgjS55AE6OQxf8mPED9uaGbf4Cg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -2155,19 +2127,6 @@ "node": ">=6" } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2186,15 +2145,11 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "license": "MIT" }, "node_modules/commondir": { "version": "1.0.1", @@ -3118,12 +3073,6 @@ "node": ">=0.8.19" } }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "license": "MIT" - }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -4451,15 +4400,6 @@ "url": "https://github.com/steveukx/git-js?sponsor=1" } }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/sirv": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.0.tgz", diff --git a/package.json b/package.json index 8525d4b..5a51bba 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "type": "module", "dependencies": { "child-process-promise": "^2.2.1", - "color": "^4.2.3", + "colord": "^2.9.3", "dotenv": "^16.4.5", "highlight.js": "^11.9.0", "jsonwebtoken": "^9.0.2", @@ -41,7 +41,6 @@ "@sveltejs/vite-plugin-svelte": "^4.0.0", "@testing-library/svelte": "^5.1.0", "@types/child-process-promise": "^2.2.6", - "@types/color": "^3.0.6", "@types/debug": "^4.1.12", "@types/eslint": "^9.6.1", "@types/eslint__js": "^8.42.3", diff --git a/src/components/Background.svelte b/src/components/Background.svelte index 1ea74aa..4b32116 100644 --- a/src/components/Background.svelte +++ b/src/components/Background.svelte @@ -1,5 +1,5 @@ diff --git a/src/routes/[...item]/sections/Section.svelte b/src/routes/[...item]/sections/Section.svelte index 288f487..5c11325 100644 --- a/src/routes/[...item]/sections/Section.svelte +++ b/src/routes/[...item]/sections/Section.svelte @@ -43,7 +43,7 @@
{#if editing} - + From e5eef5ba59b25cf7b775104bd5825612d1efa6e2 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Tue, 28 Jan 2025 23:33:59 +1100 Subject: [PATCH 088/149] Add ColorPicker component --- src/components/pickers/ColorPicker.svelte | 47 +++++++++++++++++++++++ src/components/pickers/index.ts | 2 + src/lib/color.ts | 47 ++++++++++++++++++++--- src/routes/[...item]/ItemInfoEdit.svelte | 5 +-- 4 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 src/components/pickers/ColorPicker.svelte create mode 100644 src/components/pickers/index.ts diff --git a/src/components/pickers/ColorPicker.svelte b/src/components/pickers/ColorPicker.svelte new file mode 100644 index 0000000..17652a1 --- /dev/null +++ b/src/components/pickers/ColorPicker.svelte @@ -0,0 +1,47 @@ + + +
+ + +
+ + diff --git a/src/components/pickers/index.ts b/src/components/pickers/index.ts new file mode 100644 index 0000000..81e2980 --- /dev/null +++ b/src/components/pickers/index.ts @@ -0,0 +1,2 @@ +export { default as ColorPicker } from './ColorPicker.svelte'; +export { default as FilePicker } from './FilePicker.svelte'; diff --git a/src/lib/color.ts b/src/lib/color.ts index 3180270..5a44d5d 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -1,11 +1,46 @@ /** Code for generating colors */ -import Color from 'color'; +import { colord, extend } from 'colord'; +import namesPlugin from 'colord/plugins/names'; +import { capitalize } from './util'; + +extend([namesPlugin]); /** Generate a random (hopefully) nice-looking color */ export function randomColor(): string { - return Color.hsv( - Math.random() * 360, - Math.random() * 100, - 100, - ).hex(); + return colord({ + h: Math.random() * 360, + s: Math.random() * 100, + v: 100, + }).toHex(); +} + +/** Return the name of the given color */ +export function colorName(color: string): string { + const rawName = colord(color).toName({ closest: true })!; + + const suffixes: (string | [string, string])[] = [ + 'red', + 'blue', + ['seagreen', 'sea-green'], + 'green', + 'orchid', + 'turquoise', + ]; + + for (const suffix of suffixes) { + let suffixSearch; + let suffixReplace; + if (typeof suffix === 'string') { + suffixSearch = suffixReplace = suffix; + } else { + [suffixSearch, suffixReplace] = suffix; + } + if (rawName.endsWith(suffixSearch)) { + // Eww, Python would make this so much less painful + const suffixRegex = new RegExp(`${suffixSearch}$`); + return capitalize(`${rawName.replace(suffixRegex, '')} ${suffixReplace}`); + } + } + // No matches found, just retyurn the name as-is + return capitalize(rawName); } diff --git a/src/routes/[...item]/ItemInfoEdit.svelte b/src/routes/[...item]/ItemInfoEdit.svelte index 40a09f8..81003b4 100644 --- a/src/routes/[...item]/ItemInfoEdit.svelte +++ b/src/routes/[...item]/ItemInfoEdit.svelte @@ -1,5 +1,5 @@ {#if hint} - + +
+ +
{:else} - {:else} - - {/if} - -{/if} - - diff --git a/src/components/navbar/Navbar.svelte b/src/components/navbar/Navbar.svelte index bca234d..331e7ca 100644 --- a/src/components/navbar/Navbar.svelte +++ b/src/components/navbar/Navbar.svelte @@ -29,9 +29,25 @@ data: ItemData; /** Whether the user is logged in. Set to undefined if auth is disabled */ loggedIn: boolean | undefined; + editable?: boolean; + /** Whether edit mode is active */ + editing?: boolean; + /** Called when beginning edits */ + onEditBegin?: () => void; + /** Called when finishing edits */ + onEditFinish?: () => void; } & (PropsItem | PropsOther); - let { path, data, loggedIn, lastItem }: Props = $props(); + let { + path, + data, + loggedIn, + editable = false, + editing = false, + onEditBegin, + onEditFinish, + lastItem, + }: Props = $props(); function itemIdToPath(itemId: ItemId, lastItem: ItemData): NavbarPath { if (itemId.length === 0) { @@ -106,8 +122,17 @@ .map((p) => p.url) .join('/'); } + + /** Set document data when scroll isn't at top of page */ + function onscroll() { + // https://css-tricks.com/styling-based-on-scroll-position/ + document.documentElement.dataset.scroll = + window.scrollY < 20 ? 'top' : 'page'; + } + +