import SuperJSON from "superjson"; import { escapeForRegEx } from "@tiptap/react"; import { z } from "zod/v4"; import { decryptSecret } from "@homarr/common/server"; import { eq } from "@homarr/db"; import { boards, items, widgetSecrets } from "@homarr/db/schema"; import { releaseProviderKinds } from "@homarr/definitions"; import { releasesRequestHandler } from "@homarr/request-handler/releases"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; const formatVersionFilterRegex = (versionFilter: z.infer | undefined) => { if (!versionFilter) return undefined; const escapedPrefix = versionFilter.prefix ? escapeForRegEx(versionFilter.prefix) : ""; const precision = "[0-9]+\\.".repeat(versionFilter.precision).slice(0, -2); const escapedSuffix = versionFilter.suffix ? escapeForRegEx(versionFilter.suffix) : ""; return `^${escapedPrefix}${precision}${escapedSuffix}$`; }; const releaseVersionFilterSchema = z.object({ prefix: z.string().optional(), precision: z.number(), suffix: z.string().optional(), }); export const releasesRouter = createTRPCRouter({ getLatest: publicProcedure .input( z.object({ itemId: z.string().optional(), repositories: z.array( z.object({ id: z.string().optional(), provider: z.enum(releaseProviderKinds), identifier: z.string(), versionFilter: releaseVersionFilterSchema.optional(), providerUrl: z.string().url().optional(), }), ), }), ) .query(async ({ ctx, input }) => { const tokensByProvider = new Map(); const savedRepos = new Map(); if (input.itemId) { try { const item = await ctx.db.query.items.findFirst({ where: eq(items.id, input.itemId) }); if (item && item.kind === "releases") { await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view"); const options = SuperJSON.parse>(item.options); const repos = options.repositories as | Array<{ provider?: string; identifier: string; providerUrl?: string }> | undefined; if (repos) { for (const repo of repos) { if (repo.provider && repo.identifier) { savedRepos.set(`${repo.provider}:${repo.identifier}`, { providerUrl: repo.providerUrl }); } } } const secrets = await ctx.db.query.widgetSecrets.findMany({ where: eq(widgetSecrets.itemId, input.itemId), }); for (const secret of secrets) { try { tokensByProvider.set(secret.kind, decryptSecret(secret.value)); } catch { // Skip corrupt secrets } } } } catch { // Access denied or table not yet migrated -- proceed without tokens } } return await Promise.all( input.repositories.map(async (repository) => { const repositoryId = repository.id ?? repository.identifier; const savedRepo = savedRepos.get(`${repository.provider}:${repository.identifier}`); try { const response = await releasesRequestHandler .handler({ id: repositoryId, provider: repository.provider, identifier: repository.identifier, versionRegex: formatVersionFilterRegex(repository.versionFilter), providerUrl: savedRepo?.providerUrl ?? repository.providerUrl, token: savedRepo ? tokensByProvider.get(repository.provider) : undefined, }) .getDataAsync(); return { id: repositoryId, provider: repository.provider, timestamp: response.timestamp, ...response.data, }; } catch (error) { return { id: repositoryId, provider: repository.provider, timestamp: new Date(), success: false as const, error: { code: "unexpected" as const, message: String(error) }, }; } }), ); }), });