From 55271117a2f5ce98d91ef6a7ef15ab77f9852d5b Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Tue, 20 Sep 2022 01:02:55 +0100 Subject: [PATCH] feat: local-seeds draft + commit searcher --- server/src/server/router/api/v1/router.ts | 2 + .../src/server/router/api/v1/seeds/router.ts | 111 ++++++++++++++++++ server/src/utils/git.ts | 21 +++- 3 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 server/src/server/router/api/v1/seeds/router.ts diff --git a/server/src/server/router/api/v1/router.ts b/server/src/server/router/api/v1/router.ts index 9a0651522..5e78aeeab 100644 --- a/server/src/server/router/api/v1/router.ts +++ b/server/src/server/router/api/v1/router.ts @@ -8,6 +8,7 @@ import importsRouter from "./imports/router"; import oauthRouter from "./oauth/router"; import scoresRouter from "./scores/router"; import searchRouter from "./search/router"; +import seedsRouter from "./seeds/router"; import sessionsRouter from "./sessions/router"; import statusRouter from "./status/router"; import usersRouter from "./users/router"; @@ -35,6 +36,7 @@ router.use("/sessions", sessionsRouter); router.use("/oauth", oauthRouter); router.use("/clients", clientsRouter); router.use("/config", configRouter); +router.use("/seeds", seedsRouter); /** * Return a JSON 404 response if an endpoint is hit that does not exist. diff --git a/server/src/server/router/api/v1/seeds/router.ts b/server/src/server/router/api/v1/seeds/router.ts new file mode 100644 index 000000000..564f04305 --- /dev/null +++ b/server/src/server/router/api/v1/seeds/router.ts @@ -0,0 +1,111 @@ +import { Router } from "express"; +import { PullDatabaseSeeds } from "lib/database-seeds/repo"; +import CreateLogCtx from "lib/logger/logger"; +import { Environment } from "lib/setup/config"; +import prValidate from "server/middleware/prudence-validate"; +import { RequireLocalDevelopment } from "server/middleware/type-require"; +import { ListGitCommitsInPath } from "utils/git"; +import { IsString } from "utils/misc"; +import fs from "fs"; +import path from "path"; + +const logger = CreateLogCtx(__filename); + +// Routes for interacting with the `database-seeds` folder in this instance of Tachi. + +// Why do we have this, and why is it limited to only local development? +// The answer is that we have a "Seeds UI" that runs in the client. For local development +// it's useful to be able to see the current state of the seeds on-disk, and diff that +// against various local commits. As such, we need an api such that the client can +// interface with our local seeds. + +// In production/staging, we use GitHub as a source of truth for our git repository. +// In local dev, we have this option available too, but we also enable this API. + +const router: Router = Router({ mergeParams: true }); + +router.use(RequireLocalDevelopment); + +// there's a lady who's sure +// all that glitters is gold +// and she's buying a... +const LOCAL_SEEDS_PATH = path.join(__dirname, "../../../../../../../database-seeds"); + +if (Environment.nodeEnv === "dev" || Environment.nodeEnv === "test") { + if (!fs.existsSync(LOCAL_SEEDS_PATH)) { + logger.error( + `Failed to load seeds routes, could not find any database-seeds checked out at ${LOCAL_SEEDS_PATH}. +These were expected to be present as this is local-development! +All seeds routes will return 500.` + ); + } +} + +/** + * No-Op route for checking whether this feature is supported by this instance of Tachi. + * + * @name GET /api/v1/seeds + */ +router.get("/", (req, res) => { + return res.status(200).json({ + success: true, + description: `Local seeds are available on this instance of Tachi.`, + body: {}, + }); +}); + +/** + * List commits that have affected seeds. + * + * This format is a partial implementation of what GitHub's REST API returns. As such, + * an implementing client has far less work to do with respect to handling local + remote + * servers. + * + * @param file - If provided, only returns commits that have touched this specific file. + * + * @name GET /api/v1/seeds/commits + */ +router.get( + "/commits", + prValidate({ + file: "*string", + }), + async (req, res) => { + // validated by prudence. + const file = req.query.file as string | undefined; + + const seeds = await PullDatabaseSeeds(LOCAL_SEEDS_PATH); + const collections = await seeds.ListCollections(); + + if (IsString(file)) { + // @ts-expect-error it's complaining because collections + // and string might not have overlap; fair enough, but the point of this + // test is to check that! + if (!collections.includes(file)) { + return res.status(400).json({ + success: false, + description: `Invalid file of '${file}' requested. Expected any of ${collections.join( + ", " + )}`, + }); + } + } + + // if we have a file, suffix it with .json + // otherwise, use the do-nothing path. + const realFile = file ? `${file}.json` : "."; + + // only check commits in database-seeds/collections + const commits = await ListGitCommitsInPath( + path.join("database-seeds", "collections", realFile) + ); + + return res.status(200).json({ + success: true, + description: `Found ${commits.length} commits.`, + body: commits, + }); + } +); + +export default router; diff --git a/server/src/utils/git.ts b/server/src/utils/git.ts index a7db4768f..dfe11d275 100644 --- a/server/src/utils/git.ts +++ b/server/src/utils/git.ts @@ -1,4 +1,5 @@ import { asyncExec } from "./misc"; +import path from "path"; /** * We parse git commits into this format, it's convenient to work with and compatible @@ -26,9 +27,15 @@ export interface GitCommit { * that can be easily manipulated, rather than the raw strings that git log normally * returns. * - * @param path - The path to list commits for. Defaults to showing all commits. + * **DO NOT PASS UNTRUSTED USER INPUT INTO THIS FUNCTION, AS IT IS POSSIBLE TO EXECUTE + * ARBITRARY SHELL COMMANDS AS A RESULT.** + * + * @param filepath - The path to list commits for. Defaults to showing all commits. */ -export async function ListGitCommitsInPath(path = "."): Promise> { +export async function ListGitCommitsInPath(filepath = "."): Promise> { + // make sure our path goes from the root of the repository + const realPath = path.join(__dirname, "../../../", filepath); + const format = [ "h", // shorthash "B", // subject + body raw. @@ -57,7 +64,7 @@ export async function ListGitCommitsInPath(path = "."): Promise // the -z flag replaces \n separators with NUL bytes, which aren't legal in commit // messages as far as I'm aware. // everything else should be obvious. - const COMMAND = `PAGER=cat git log -z --pretty="${gitLogPrettyFormat}" -- '${path}'`; + const COMMAND = `PAGER=cat git log -z --pretty="${gitLogPrettyFormat}" -- '${realPath}'`; const { stdout, stderr } = await asyncExec(COMMAND); @@ -67,6 +74,9 @@ export async function ListGitCommitsInPath(path = "."): Promise const rows = stdout.split("\0"); + // last row always has 0 elements because the output ends with a NUL. + rows.pop(); + const commits: Array = rows.map((line) => { const split = line.split(ASCII_GROUP_SEPARATOR); @@ -90,7 +100,10 @@ export async function ListGitCommitsInPath(path = "."): Promise email: cmtEmail, name: cmtName, }, - message, + + // trim off the trailing \n from every message. + // all messages end with an extraneous newline, and we don't want it. + message: message.trim(), }, }; });