Files
homarr/packages/api/src/router/app.ts
T

293 lines
8.5 KiB
TypeScript

import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database, InferSelectModel } from "@homarr/db";
import { asc, eq, inArray, like } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import { selectAppSchema } from "@homarr/db/validationSchemas";
import { getIconForName } from "@homarr/icons";
import { appCreateManySchema, appEditSchema, appManageSchema } from "@homarr/validation/app";
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
import { convertIntersectionToZodObject } from "../schema-merger";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { AppAccessControl } from "./app/app-access-control";
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
export const appRouter = createTRPCRouter({
getPaginated: permissionRequiredProcedure
.requiresPermission("board-modify-all")
.input(paginatedSchema)
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
.meta({
openapi: {
method: "GET",
path: "/api/apps/paginated",
tags: ["apps"],
protect: true,
},
mcp: {
enabled: true,
description:
"List apps with pagination. OPTIONAL: search (string to filter by name), pageSize (number, default 10), page (number, default 1). All fields are optional — call with no arguments to get the first page",
},
})
.query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(apps.name, `%${input.search.trim()}%`) : undefined;
const totalCount = await ctx.db.$count(apps, whereQuery);
const dbApps = await ctx.db.query.apps.findMany({
limit: input.pageSize,
offset: (input.page - 1) * input.pageSize,
where: whereQuery,
orderBy: asc(apps.name),
});
return {
items: dbApps,
totalCount,
};
}),
all: protectedProcedure
.input(z.void())
.output(z.array(selectAppSchema))
.meta({
openapi: {
method: "GET",
path: "/api/apps",
tags: ["apps"],
protect: true,
},
mcp: { enabled: true, description: "List all apps" },
})
.query(({ ctx }) => {
return ctx.db.query.apps.findMany({
orderBy: asc(apps.name),
});
}),
search: protectedProcedure
.input(
z.object({
query: z.string(),
limit: z.number().min(1).max(100).default(10),
}),
)
.output(z.array(selectAppSchema))
.meta({
openapi: {
method: "GET",
path: "/api/apps/search",
tags: ["apps"],
protect: true,
},
mcp: {
enabled: true,
description: "Search apps by name. REQUIRED: query (search string). OPTIONAL: limit (number, default 10)",
},
})
.query(({ ctx, input }) => {
return ctx.db.query.apps.findMany({
where: like(apps.name, `%${input.query}%`),
orderBy: asc(apps.name),
limit: input.limit,
});
}),
selectable: protectedProcedure
.input(z.void())
.output(
z.array(
selectAppSchema.pick({
id: true,
name: true,
iconUrl: true,
href: true,
pingUrl: true,
description: true,
}),
),
)
.meta({
openapi: {
method: "GET",
path: "/api/apps/selectable",
tags: ["apps"],
protect: true,
},
})
.query(({ ctx }) => {
return ctx.db.query.apps.findMany({
columns: {
id: true,
name: true,
iconUrl: true,
description: true,
href: true,
pingUrl: true,
},
orderBy: asc(apps.name),
});
}),
byId: publicProcedure
.input(byIdSchema)
.output(selectAppSchema)
.meta({
openapi: {
method: "GET",
path: "/api/apps/{id}",
tags: ["apps"],
protect: true,
},
mcp: { enabled: true, description: "Get a single app by its ID. REQUIRED: id (app ID string)" },
})
.query(async ({ ctx, input }) => {
const repository = new AppRepository(ctx.db, ctx.session?.user ?? null);
const app = await repository.getByIdAsync(input.id);
if (!app) {
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
});
}
return app;
}),
byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
const repository = new AppRepository(ctx.db, ctx.session?.user ?? null);
return await repository.getByIdsAsync(input);
}),
create: permissionRequiredProcedure
.requiresPermission("app-create")
.input(appManageSchema)
.output(z.object({ appId: z.string() }).and(selectAppSchema))
.meta({
openapi: {
method: "POST",
path: "/api/apps",
tags: ["apps"],
protect: true,
},
mcp: {
enabled: true,
description:
"Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL, http/https or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)",
},
})
.mutation(async ({ ctx, input }) => {
const id = createId();
const insertValues = {
id,
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
href: input.href,
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
};
await ctx.db.insert(apps).values(insertValues);
// TODO: breaking change necessary for removing appId property
return { appId: id, ...insertValues };
}),
createMany: permissionRequiredProcedure
.requiresPermission("app-create")
.input(appCreateManySchema)
.output(z.void())
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(apps).values(
input.map((app) => ({
id: createId(),
name: app.name,
description: app.description,
iconUrl: app.iconUrl ?? getIconForName(ctx.db, app.name).sync()?.url ?? defaultIcon,
href: app.href,
})),
);
}),
update: permissionRequiredProcedure
.requiresPermission("app-modify-all")
.input(convertIntersectionToZodObject(appEditSchema))
.output(z.void())
.meta({
openapi: {
method: "PATCH",
path: "/api/apps/{id}",
tags: ["apps"],
protect: true,
},
mcp: {
enabled: true,
description:
"Update an existing app. REQUIRED: id (app ID), name, iconUrl, href. OPTIONAL: description (string or null), pingUrl (URL or empty string)",
},
})
.mutation(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
});
if (!app) {
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
});
}
await ctx.db
.update(apps)
.set({
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
href: input.href,
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
})
.where(eq(apps.id, input.id));
}),
delete: permissionRequiredProcedure
.requiresPermission("app-full-all")
.output(z.void())
.meta({
openapi: {
method: "DELETE",
path: "/api/apps/{id}",
tags: ["apps"],
protect: true,
},
mcp: { enabled: true, description: "Delete an app by ID. REQUIRED: id (app ID string)" },
})
.input(byIdSchema)
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(apps).where(eq(apps.id, input.id));
}),
});
type App = InferSelectModel<typeof apps>;
export class AppRepository {
private readonly accessControl: AppAccessControl;
constructor(
private db: Database,
user: Session["user"] | null,
) {
this.accessControl = new AppAccessControl(db, user);
}
public async getByIdAsync(id: string): Promise<App | null> {
const apps = await this.getByIdsAsync([id]);
return apps[0] ?? null;
}
public async getByIdsAsync(ids: string[]): Promise<App[]> {
const canUserSeeApps = await this.accessControl.canUserSeeAppsAsync(ids);
const dbApps = await this.db.query.apps.findMany({
where: inArray(apps.id, ids),
});
return canUserSeeApps ? dbApps : [];
}
}