Replace entire codebase with homarr-labs/homarr

This commit is contained in:
Thomas Camlong
2026-01-15 21:54:44 +01:00
parent c5bc3b1559
commit 4fdd1fe351
4666 changed files with 409577 additions and 147434 deletions

View File

@@ -0,0 +1,52 @@
import { z } from "zod/v4";
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import { createId } from "@homarr/common";
import { generateSecureRandomToken } from "@homarr/common/server";
import { db, eq } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
export const apiKeysRouter = createTRPCRouter({
getAll: permissionRequiredProcedure.requiresPermission("admin").query(() => {
return db.query.apiKeys.findMany({
columns: {
id: true,
apiKey: false,
salt: false,
},
with: {
user: {
columns: {
id: true,
name: true,
image: true,
email: true,
},
},
},
});
}),
create: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => {
const salt = await createSaltAsync();
const randomToken = generateSecureRandomToken(64);
const hashedRandomToken = await hashPasswordAsync(randomToken, salt);
const id = createId();
await db.insert(apiKeys).values({
id,
apiKey: hashedRandomToken,
salt,
userId: ctx.session.user.id,
});
return {
apiKey: `${id}.${randomToken}`,
};
}),
delete: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ apiKeyId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(apiKeys).where(eq(apiKeys.id, input.apiKeyId)).limit(1);
}),
});

View File

@@ -0,0 +1,189 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
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 { canUserSeeAppAsync } 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: protectedProcedure
.input(paginatedSchema)
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
.meta({ openapi: { method: "GET", path: "/api/apps/paginated", tags: ["apps"], protect: true } })
.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 } })
.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 } })
.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 } })
.query(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",
});
}
const canUserSeeApp = await canUserSeeAppAsync(ctx.session?.user ?? null, app.id);
if (!canUserSeeApp) {
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
});
}
return app;
}),
byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
return await ctx.db.query.apps.findMany({
where: inArray(apps.id, 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 } })
.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 } })
.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 } })
.input(byIdSchema)
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(apps).where(eq(apps.id, input.id));
}),
});

View File

@@ -0,0 +1,45 @@
import SuperJSON from "superjson";
import type { Session } from "@homarr/auth";
import { db, eq, or } from "@homarr/db";
import { items } from "@homarr/db/schema";
import type { WidgetComponentProps } from "../../../../widgets/src";
export const canUserSeeAppAsync = async (user: Session["user"] | null, appId: string) => {
return await canUserSeeAppsAsync(user, [appId]);
};
export const canUserSeeAppsAsync = async (user: Session["user"] | null, appIds: string[]) => {
if (user) return true;
const appIdsOnPublicBoards = await getAllAppIdsOnPublicBoardsAsync();
return appIds.every((appId) => appIdsOnPublicBoards.includes(appId));
};
const getAllAppIdsOnPublicBoardsAsync = async () => {
const itemsWithApps = await db.query.items.findMany({
where: or(eq(items.kind, "app"), eq(items.kind, "bookmarks")),
with: {
board: {
columns: {
isPublic: true,
},
},
},
});
return itemsWithApps
.filter((item) => item.board.isPublic)
.flatMap((item) => {
if (item.kind === "app") {
const parsedOptions = SuperJSON.parse<WidgetComponentProps<"app">["options"]>(item.options);
return [parsedOptions.appId];
} else if (item.kind === "bookmarks") {
const parsedOptions = SuperJSON.parse<WidgetComponentProps<"bookmarks">["options"]>(item.options);
return parsedOptions.items;
}
throw new Error("Failed to get app ids from board. Invalid item kind: 'test'");
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
import { TRPCError } from "@trpc/server";
import type { Session } from "@homarr/auth";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { boardGroupPermissions, boardUserPermissions, groupMembers } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
/**
* Throws NOT_FOUND if user is not allowed to perform action on board
* @param ctx trpc router context
* @param boardWhere where clause for the board
* @param permission permission required to perform action on board
*/
export const throwIfActionForbiddenAsync = async (
ctx: { db: Database; session: Session | null },
boardWhere: SQL<unknown>,
permission: BoardPermission,
) => {
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const board = await db.query.boards.findFirst({
where: boardWhere,
columns: {
id: true,
creatorId: true,
isPublic: true,
},
with: {
userPermissions: {
where: eq(boardUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
},
},
});
if (!board) {
notAllowed();
}
const { hasViewAccess, hasChangeAccess, hasFullAccess } = constructBoardPermissions(board, session);
if (hasFullAccess) {
return; // As full access is required and user has full access, allow
}
if (["modify", "view"].includes(permission) && hasChangeAccess) {
return; // As change access is required and user has change access, allow
}
if (permission === "view" && hasViewAccess) {
return; // As view access is required and user has view access, allow
}
notAllowed();
};
/**
* This method returns NOT_FOUND to prevent snooping on board existence
* A function is used to use the method without return statement
*/
function notAllowed(): never {
throw new TRPCError({
code: "NOT_FOUND",
message: "Board not found",
});
}

View File

@@ -0,0 +1,186 @@
export interface GridAlgorithmItem {
id: string;
type: "item" | "section";
width: number;
height: number;
xOffset: number;
yOffset: number;
sectionId: string;
}
interface GridAlgorithmInput {
items: GridAlgorithmItem[];
width: number;
previousWidth: number;
sectionId: string;
}
interface GridAlgorithmOutput {
height: number;
items: GridAlgorithmItem[];
}
export const generateResponsiveGridFor = ({
items,
previousWidth,
width,
sectionId,
}: GridAlgorithmInput): GridAlgorithmOutput => {
const itemsOfCurrentSection = items
.filter((item) => item.sectionId === sectionId)
.sort((itemA, itemB) =>
itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
);
const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
if (itemsOfCurrentSection.length === 0) {
return {
height: 0,
items: [],
};
}
const newItems: GridAlgorithmItem[] = [];
// Fix height of dynamic sections
const dynamicSectionHeightMap = new Map<string, number>();
const dynamicSectionsOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
for (const dynamicSection of dynamicSectionsOfCurrentSection) {
const result = generateResponsiveGridFor({
items,
previousWidth: dynamicSection.previousWidth,
width: dynamicSection.width,
sectionId: dynamicSection.id,
});
newItems.push(...result.items);
dynamicSectionHeightMap.set(dynamicSection.id, result.height);
}
// Return same positions for items in the current section
if (width >= previousWidth) {
return {
height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
items: newItems.concat(normalizedItems),
};
}
const occupied2d: boolean[][] = [];
for (const item of normalizedItems) {
const itemWithHeight = {
...item,
height: item.type === "section" ? Math.max(dynamicSectionHeightMap.get(item.id) ?? 1, item.height) : item.height,
};
const position = nextFreeSpot(occupied2d, itemWithHeight, width);
if (!position) throw new Error("No free spot available");
addItemToOccupied(occupied2d, itemWithHeight, position, width);
newItems.push({
...itemWithHeight,
xOffset: position.x,
yOffset: position.y,
});
}
return {
height: occupied2d.length,
items: newItems,
};
};
/**
* Reduces the width of the items to fit the new column count.
* @param items items to normalize
* @param columnCount new column count
*/
const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) => {
return items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) }));
};
/**
* Adds the item to the occupied spots.
* @param occupied2d array of occupied spots
* @param item item to place
* @param position position to place the item
*/
const addItemToOccupied = (
occupied2d: boolean[][],
item: GridAlgorithmItem,
position: { x: number; y: number },
columnCount: number,
) => {
for (let yOffset = 0; yOffset < item.height; yOffset++) {
let row = occupied2d[position.y + yOffset];
if (!row) {
addRow(occupied2d, columnCount);
// After adding it, it must exist
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
row = occupied2d[position.y + yOffset]!;
}
for (let xOffset = 0; xOffset < item.width; xOffset++) {
row[position.x + xOffset] = true;
}
}
};
/**
* Adds a new row to the grid.
* @param occupied2d array of occupied spots
* @param columnCount column count of section
*/
const addRow = (occupied2d: boolean[][], columnCount: number) => {
occupied2d.push(new Array<boolean>(columnCount).fill(false));
};
/**
* Searches for the next free spot in the grid.
* @param occupied2d array of occupied spots
* @param item item to place
* @param columnCount column count of section
* @returns the position of the next free spot or null if no spot is available
*/
const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => {
for (let offsetY = 0; offsetY < 99999; offsetY++) {
for (let offsetX = 0; offsetX < columnCount; offsetX++) {
if (hasHorizontalSpace(columnCount, item, offsetX) && isFree(occupied2d, item, { x: offsetX, y: offsetY })) {
return { x: offsetX, y: offsetY };
}
}
}
return null;
};
/**
* Check if the item fits into the grid horizontally.
* @param columnCount available width
* @param item item to place
* @param offsetX current x position
* @returns true if the item fits horizontally
*/
const hasHorizontalSpace = (columnCount: number, item: GridAlgorithmItem, offsetX: number) => {
return offsetX + item.width <= columnCount;
};
/**
* Check if the spot is free.
* @param occupied2d array of occupied spots
* @param item item to place
* @param position position to check
* @returns true if the spot is free
*/
const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => {
for (let yOffset = 0; yOffset < item.height; yOffset++) {
const row = occupied2d[position.y + yOffset];
if (!row) return true; // Empty row is free
for (let xOffset = 0; xOffset < item.width; xOffset++) {
if (row[position.x + xOffset]) {
return false;
}
}
}
return true;
};

View File

@@ -0,0 +1,378 @@
import { createId } from "@paralleldrive/cuid2";
import { describe, expect, test } from "vitest";
import type { GridAlgorithmItem } from "../grid-algorithm";
import { generateResponsiveGridFor } from "../grid-algorithm";
const ROOT_SECTION_ID = "section";
/**
* If you want to see how the layouts progress between the different layouts, you can find images here:
* https://github.com/homarr-labs/architecture-documentation/tree/main/grid-algorithm#graphical-representation-of-the-algorithm
*/
describe("Grid Algorithm", () => {
test.each(itemTests)("should convert a grid with %i columns to a grid with %i columns", (_, _ignored, item) => {
const input = generateInputFromText(item.input);
const result = generateResponsiveGridFor({
items: input,
width: item.outputColumnCount,
previousWidth: item.inputColumnCount,
sectionId: ROOT_SECTION_ID,
});
const output = generateOutputText(result.items, item.outputColumnCount);
expect(output).toBe(item.output);
});
test.each(dynamicSectionTests)(
"should convert a grid with dynamic sections from 16 columns to %i columns",
(_, testInput) => {
const outerDynamicSectionId = "b";
const innerDynamicSectionId = "f";
const items = [
algoItem({ id: "a", width: 2, height: 2 }),
algoItem({ id: outerDynamicSectionId, type: "section", width: 12, height: 3, yOffset: 2 }),
algoItem({ id: "a", width: 2, sectionId: outerDynamicSectionId }),
algoItem({ id: "b", width: 4, sectionId: outerDynamicSectionId, xOffset: 2 }),
algoItem({ id: "c", width: 2, sectionId: outerDynamicSectionId, xOffset: 6 }),
algoItem({ id: "d", width: 1, sectionId: outerDynamicSectionId, xOffset: 8 }),
algoItem({ id: "e", width: 3, sectionId: outerDynamicSectionId, xOffset: 9 }),
algoItem({
id: innerDynamicSectionId,
type: "section",
width: 8,
height: 2,
yOffset: 1,
sectionId: outerDynamicSectionId,
}),
algoItem({ id: "a", width: 2, sectionId: innerDynamicSectionId }),
algoItem({ id: "b", width: 5, xOffset: 2, sectionId: innerDynamicSectionId }),
algoItem({ id: "c", width: 1, height: 2, xOffset: 7, sectionId: innerDynamicSectionId }),
algoItem({ id: "d", width: 7, yOffset: 1, sectionId: innerDynamicSectionId }),
algoItem({ id: "g", width: 4, yOffset: 1, sectionId: outerDynamicSectionId, xOffset: 8 }),
algoItem({ id: "h", width: 3, yOffset: 2, sectionId: outerDynamicSectionId, xOffset: 8 }),
algoItem({ id: "i", width: 1, yOffset: 2, sectionId: outerDynamicSectionId, xOffset: 11 }),
algoItem({ id: "c", width: 5, yOffset: 5 }),
];
const newItems = generateResponsiveGridFor({
items,
width: testInput.outputColumns,
previousWidth: 16,
sectionId: ROOT_SECTION_ID,
});
const rootItems = newItems.items.filter((item) => item.sectionId === ROOT_SECTION_ID);
const outerSection = items.find((item) => item.id === outerDynamicSectionId);
const outerItems = newItems.items.filter((item) => item.sectionId === outerDynamicSectionId);
const innerSection = items.find((item) => item.id === innerDynamicSectionId);
const innerItems = newItems.items.filter((item) => item.sectionId === innerDynamicSectionId);
expect(generateOutputText(rootItems, testInput.outputColumns)).toBe(testInput.root);
expect(generateOutputText(outerItems, Math.min(testInput.outputColumns, outerSection?.width ?? 999))).toBe(
testInput.outer,
);
expect(generateOutputText(innerItems, Math.min(testInput.outputColumns, innerSection?.width ?? 999))).toBe(
testInput.inner,
);
},
);
});
const algoItem = (item: Partial<GridAlgorithmItem>): GridAlgorithmItem => ({
id: createId(),
type: "item",
width: 1,
height: 1,
xOffset: 0,
yOffset: 0,
sectionId: ROOT_SECTION_ID,
...item,
});
const sixteenColumns = `
abbccccddddeeefg
hbbccccddddeeeij
klllmmmmmnneeeop
qlllmmmmmnnrrrst
ulllmmmmmnnrrrvw
xyz äö`;
// Just add two empty columns to the right
const eighteenColumns = sixteenColumns
.split("\n")
.map((line, index) => (index === 0 ? line : `${line} `))
.join("\n");
const tenColumns = `
abbcccceee
fbbcccceee
ddddghieee
ddddjklllo
mmmmmplllq
mmmmmslllt
mmmmmnnrrr
uvwxynnrrr
zäö nn `;
const sixColumns = `
abbfgh
ibbjko
ccccnn
ccccnn
ddddnn
ddddpq
eeelll
eeelll
eeelll
mmmmms
mmmmmt
mmmmmu
rrrvwx
rrryzä
ö `;
const threeColumns = `
abb
fbb
ccc
ccc
ddd
ddd
eee
eee
eee
ghi
jko
lll
lll
lll
mmm
mmm
mmm
nnp
nnq
nns
rrr
rrr
tuv
wxy
zäö`;
const itemTests = [
{
input: sixteenColumns,
inputColumnCount: 16,
output: sixteenColumns,
outputColumnCount: 16,
},
{
input: sixteenColumns,
inputColumnCount: 16,
output: eighteenColumns,
outputColumnCount: 18,
},
{
input: sixteenColumns,
inputColumnCount: 16,
output: tenColumns,
outputColumnCount: 10,
},
{
input: sixteenColumns,
inputColumnCount: 16,
output: sixColumns,
outputColumnCount: 6,
},
{
input: sixteenColumns,
inputColumnCount: 16,
output: threeColumns,
outputColumnCount: 3,
},
].map((item) => [item.inputColumnCount, item.outputColumnCount, item] as const);
const dynamicSectionTests = [
{
outputColumns: 16,
root: `
aa
aa
bbbbbbbbbbbb
bbbbbbbbbbbb
bbbbbbbbbbbb
ccccc `,
outer: `
aabbbbccdeee
ffffffffgggg
ffffffffhhhi`,
inner: `
aabbbbbc
dddddddc`,
},
{
outputColumns: 10,
root: `
aaccccc
aa
bbbbbbbbbb
bbbbbbbbbb
bbbbbbbbbb
bbbbbbbbbb`,
outer: `
aabbbbccdi
eeegggghhh
ffffffff
ffffffff `,
inner: `
aabbbbbc
dddddddc`,
},
{
outputColumns: 6,
root: `
aa
aa
bbbbbb
bbbbbb
bbbbbb
bbbbbb
bbbbbb
bbbbbb
bbbbbb
ccccc `,
outer: `
aabbbb
ccdeee
ffffff
ffffff
ffffff
ggggi
hhh `,
inner: `
aa c
bbbbbc
dddddd`,
},
{
outputColumns: 3,
root: `
aa
aa
bbb
bbb
bbb
bbb
bbb
bbb
bbb
bbb
bbb
bbb
bbb
ccc`,
outer: `
aad
bbb
cci
eee
fff
fff
fff
fff
fff
ggg
hhh`,
inner: `
aa
bbb
c
c
ddd`,
},
].map((item) => [item.outputColumns, item] as const);
const generateInputFromText = (text: string) => {
const lines = text.split("\n").slice(1); // Remove first empty row
const items: GridAlgorithmItem[] = [];
for (let yOffset = 0; yOffset < lines.length; yOffset++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const line = lines[yOffset]!;
for (let xOffset = 0; xOffset < line.length; xOffset++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const char = line[xOffset]!;
if (char === " ") continue;
if (items.some((item) => item.id === char)) continue;
items.push({
id: char,
type: "item",
width: getWidth(line, xOffset, char),
height: getHeight(lines, { x: xOffset, y: yOffset }, char),
xOffset,
yOffset,
sectionId: ROOT_SECTION_ID,
});
}
}
return items;
};
const generateOutputText = (items: GridAlgorithmItem[], columnCount: number) => {
const occupied2d: string[][] = [];
for (const item of items) {
addItemToOccupied(occupied2d, item, { x: item.xOffset, y: item.yOffset }, columnCount);
}
return `\n${occupied2d.map((row) => row.join("")).join("\n")}`;
};
const getWidth = (line: string, offset: number, char: string) => {
const row = line.split("");
let width = 1;
for (let xOffset = offset + 1; xOffset < row.length; xOffset++) {
if (row[xOffset] === char) {
width++;
} else {
break;
}
}
return width;
};
const getHeight = (lines: string[], position: { x: number; y: number }, char: string) => {
let height = 1;
for (let yOffset = position.y + 1; yOffset < lines.length; yOffset++) {
if (lines[yOffset]?.[position.x] === char) {
height++;
} else {
break;
}
}
return height;
};
const addItemToOccupied = (
occupied2d: string[][],
item: GridAlgorithmItem,
position: { x: number; y: number },
columnCount: number,
) => {
for (let yOffset = 0; yOffset < item.height; yOffset++) {
let row = occupied2d[position.y + yOffset];
if (!row) {
addRow(occupied2d, columnCount);
// After adding it, it must exist
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
row = occupied2d[position.y + yOffset]!;
}
for (let xOffset = 0; xOffset < item.width; xOffset++) {
row[position.x + xOffset] = item.id;
}
}
};
const addRow = (occupied2d: string[][], columnCount: number) => {
occupied2d.push(new Array<string>(columnCount).fill(" "));
};

View File

@@ -0,0 +1,131 @@
import { X509Certificate } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { zfd } from "zod-form-data";
import { z } from "zod/v4";
import {
addCustomRootCertificateAsync,
removeCustomRootCertificateAsync,
} from "@homarr/core/infrastructure/certificates";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { and, eq } from "@homarr/db";
import { trustedCertificateHostnames } from "@homarr/db/schema";
import { certificateValidFileNameSchema, checkCertificateFile } from "@homarr/validation/certificates";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
const logger = createLogger({ module: "certificateRouter" });
export const certificateRouter = createTRPCRouter({
addCertificate: permissionRequiredProcedure
.requiresPermission("admin")
.input(
zfd.formData({
file: zfd.file().check(checkCertificateFile),
}),
)
.mutation(async ({ input }) => {
const content = await input.file.text();
// Validate the certificate
let x509Certificate: X509Certificate;
try {
x509Certificate = new X509Certificate(content);
logger.info("Adding trusted certificate", {
subject: x509Certificate.subject,
issuer: x509Certificate.issuer,
});
} catch {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid certificate",
});
}
await addCustomRootCertificateAsync(input.file.name, content);
logger.info("Added trusted certificate", {
subject: x509Certificate.subject,
issuer: x509Certificate.issuer,
});
}),
trustHostnameMismatch: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ hostname: z.string(), certificate: z.string() }))
.mutation(async ({ ctx, input }) => {
// Validate the certificate
let x509Certificate: X509Certificate;
try {
x509Certificate = new X509Certificate(input.certificate);
logger.info("Adding trusted hostname", {
subject: x509Certificate.subject,
issuer: x509Certificate.issuer,
thumbprint: x509Certificate.fingerprint256,
hostname: input.hostname,
});
} catch {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid certificate",
});
}
await ctx.db.insert(trustedCertificateHostnames).values({
hostname: input.hostname,
thumbprint: x509Certificate.fingerprint256,
certificate: input.certificate,
});
logger.info("Added trusted hostname", {
subject: x509Certificate.subject,
issuer: x509Certificate.issuer,
thumbprint: x509Certificate.fingerprint256,
hostname: input.hostname,
});
}),
removeTrustedHostname: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ hostname: z.string(), thumbprint: z.string() }))
.mutation(async ({ ctx, input }) => {
logger.info("Removing trusted hostname", {
hostname: input.hostname,
thumbprint: input.thumbprint,
});
const dbResult = await ctx.db
.delete(trustedCertificateHostnames)
.where(
and(
eq(trustedCertificateHostnames.hostname, input.hostname),
eq(trustedCertificateHostnames.thumbprint, input.thumbprint),
),
);
logger.info("Removed trusted hostname", {
hostname: input.hostname,
thumbprint: input.thumbprint,
count: dbResult.changes,
});
}),
removeCertificate: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ fileName: certificateValidFileNameSchema }))
.mutation(async ({ input, ctx }) => {
logger.info("Removing trusted certificate", {
fileName: input.fileName,
});
const certificate = await removeCustomRootCertificateAsync(input.fileName);
if (!certificate) return;
// Delete all trusted hostnames for this certificate
await ctx.db
.delete(trustedCertificateHostnames)
.where(eq(trustedCertificateHostnames.thumbprint, certificate.fingerprint256));
logger.info("Removed trusted certificate", {
fileName: input.fileName,
subject: certificate.subject,
issuer: certificate.issuer,
});
}),
});

View File

@@ -0,0 +1,80 @@
import { observable } from "@trpc/server/observable";
import z from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { cronExpressionSchema, jobGroupKeys, jobNameSchema } from "@homarr/cron-job-api";
import { cronJobApi } from "@homarr/cron-job-api/client";
import type { TaskStatus } from "@homarr/cron-job-status";
import { createCronJobStatusChannel } from "@homarr/cron-job-status";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
const logger = createLogger({ module: "cronJobsRouter" });
export const cronJobsRouter = createTRPCRouter({
triggerJob: permissionRequiredProcedure
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.trigger.mutate(input);
}),
startJob: permissionRequiredProcedure
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.start.mutate(input);
}),
stopJob: permissionRequiredProcedure
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.stop.mutate(input);
}),
updateJobInterval: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({
name: jobNameSchema,
cron: cronExpressionSchema,
}),
)
.mutation(async ({ input }) => {
await cronJobApi.updateInterval.mutate(input);
}),
disableJob: permissionRequiredProcedure
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.disable.mutate(input);
}),
enableJob: permissionRequiredProcedure
.requiresPermission("admin")
.input(jobNameSchema)
.mutation(async ({ input }) => {
await cronJobApi.enable.mutate(input);
}),
getJobs: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
return await cronJobApi.getAll.query();
}),
subscribeToStatusUpdates: permissionRequiredProcedure.requiresPermission("admin").subscription(() => {
return observable<TaskStatus>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const name of jobGroupKeys) {
const channel = createCronJobStatusChannel(name);
const unsubscribe = channel.subscribe((data) => {
emit.next(data);
});
unsubscribes.push(unsubscribe);
}
logger.info("A tRPC client has connected to the cron job status updates procedure");
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});

View File

@@ -0,0 +1,147 @@
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
import { DockerSingleton } from "@homarr/docker";
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
import { dockerMiddleware } from "../../middlewares/docker";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
export const dockerRouter = createTRPCRouter({
getContainers: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.query(async () => {
const innerHandler = dockerContainersRequestHandler.handler({});
const result = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const { data, timestamp } = result;
return {
containers: data satisfies DockerContainer[],
timestamp,
};
}),
subscribeContainers: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.subscription(() => {
return observable<DockerContainer[]>((emit) => {
const innerHandler = dockerContainersRequestHandler.handler({});
const unsubscribe = innerHandler.subscribe((data) => {
emit.next(data);
});
return unsubscribe;
});
}),
invalidate: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.mutation(async () => {
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}),
startAll: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
await Promise.allSettled(
input.ids.map(async (id) => {
const container = await getContainerOrThrowAsync(id);
await container.start();
}),
);
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}),
stopAll: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
await Promise.allSettled(
input.ids.map(async (id) => {
const container = await getContainerOrThrowAsync(id);
await container.stop();
}),
);
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}),
restartAll: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
await Promise.allSettled(
input.ids.map(async (id) => {
const container = await getContainerOrThrowAsync(id);
await container.restart();
}),
);
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}),
removeAll: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
await Promise.allSettled(
input.ids.map(async (id) => {
const container = await getContainerOrThrowAsync(id);
await container.remove();
}),
);
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}),
});
const getContainerOrDefaultAsync = async (instance: Docker, id: string) => {
const container = instance.getContainer(id);
return await new Promise<Container | null>((resolve) => {
container.inspect((err, data) => {
if (err || !data) {
resolve(null);
} else {
resolve(container);
}
});
});
};
const getContainerOrThrowAsync = async (id: string) => {
const dockerInstances = DockerSingleton.getInstances();
const containers = await Promise.all(dockerInstances.map(({ instance }) => getContainerOrDefaultAsync(instance, id)));
const foundContainer = containers.find((container) => container) ?? null;
if (!foundContainer) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Container not found",
});
}
return foundContainer;
};
interface DockerContainer {
name: string;
id: string;
state: ContainerState;
image: string;
ports: Port[];
iconUrl: string | null;
cpuUsage: number;
memoryUsage: number;
}

View File

@@ -0,0 +1,385 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
import { everyoneGroup } from "@homarr/definitions";
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
import {
groupCreateSchema,
groupSavePartialSettingsSchema,
groupSavePermissionsSchema,
groupSavePositionsSchema,
groupUpdateSchema,
groupUserSchema,
} from "@homarr/validation/group";
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import { throwIfCredentialsDisabled } from "./invite/checks";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
export const groupRouter = createTRPCRouter({
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
const dbGroups = await ctx.db.query.groups.findMany({
with: {
members: {
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
},
},
},
},
},
});
return dbGroups.map((group) => ({
...group,
members: group.members.map((member) => member.user),
}));
}),
getPaginated: permissionRequiredProcedure
.requiresPermission("admin")
.input(paginatedSchema)
.query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined;
const groupCount = await ctx.db.$count(groups, whereQuery);
const dbGroups = await ctx.db.query.groups.findMany({
with: {
members: {
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
},
},
},
},
},
limit: input.pageSize,
offset: (input.page - 1) * input.pageSize,
where: whereQuery,
});
return {
items: dbGroups.map((group) => ({
...group,
members: group.members.map((member) => member.user),
})),
totalCount: groupCount,
};
}),
getById: permissionRequiredProcedure
.requiresPermission("admin")
.input(byIdSchema)
.query(async ({ input, ctx }) => {
const group = await ctx.db.query.groups.findFirst({
where: eq(groups.id, input.id),
with: {
members: {
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
provider: true,
},
},
},
},
permissions: {
columns: {
permission: true,
},
},
owner: {
columns: {
id: true,
name: true,
image: true,
email: true,
},
},
},
});
if (!group) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Group not found",
});
}
return {
...group,
members: group.members.map((member) => member.user),
permissions: group.permissions.map((permission) => permission.permission),
};
}),
// Is protected because also used in board access / integration access forms
selectable: protectedProcedure
.input(z.object({ withPermissions: z.boolean().default(false) }).optional())
.query(async ({ ctx, input }) => {
const withPermissions = input?.withPermissions && ctx.session.user.permissions.includes("admin");
if (!withPermissions) {
return await ctx.db.query.groups.findMany({
columns: {
id: true,
name: true,
},
});
}
const groups = await ctx.db.query.groups.findMany({
columns: {
id: true,
name: true,
},
with: { permissions: { columns: { permission: true } } },
});
return groups.map((group) => ({
...group,
permissions: group.permissions.map((permission) => permission.permission),
}));
}),
search: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({
query: z.string(),
limit: z.number().min(1).max(100).default(10),
}),
)
.query(async ({ input, ctx }) => {
return await ctx.db.query.groups.findMany({
where: like(groups.name, `%${input.query}%`),
columns: {
id: true,
name: true,
},
limit: input.limit,
});
}),
createInitialExternalGroup: onboardingProcedure
.requiresStep("group")
.input(groupCreateSchema)
.mutation(async ({ input, ctx }) => {
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
const groupId = createId();
await ctx.db.insert(groups).values({
id: groupId,
name: input.name,
position: maxPosition + 1,
});
await ctx.db.insert(groupPermissions).values({
groupId,
permission: "admin",
});
await nextOnboardingStepAsync(ctx.db, undefined);
}),
createGroup: permissionRequiredProcedure
.requiresPermission("admin")
.input(groupCreateSchema)
.mutation(async ({ input, ctx }) => {
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
const id = createId();
await ctx.db.insert(groups).values({
id,
name: input.name,
position: maxPosition + 1,
ownerId: ctx.session.user.id,
});
return id;
}),
updateGroup: permissionRequiredProcedure
.requiresPermission("admin")
.input(groupUpdateSchema)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
await throwIfGroupNameIsReservedAsync(ctx.db, input.id);
await checkSimilarNameAndThrowAsync(ctx.db, input.name, input.id);
await ctx.db
.update(groups)
.set({
name: input.name,
})
.where(eq(groups.id, input.id));
}),
savePartialSettings: permissionRequiredProcedure
.requiresPermission("admin")
.input(groupSavePartialSettingsSchema)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
await ctx.db
.update(groups)
.set({
homeBoardId: input.settings.homeBoardId,
mobileHomeBoardId: input.settings.mobileHomeBoardId,
})
.where(eq(groups.id, input.id));
}),
savePositions: permissionRequiredProcedure
.requiresPermission("admin")
.input(groupSavePositionsSchema)
.mutation(async ({ input, ctx }) => {
const positions = input.positions.map((id, index) => ({ id, position: index + 1 }));
await handleTransactionsAsync(ctx.db, {
handleAsync: async (db, schema) => {
await db.transaction(async (trx) => {
for (const { id, position } of positions) {
await trx.update(schema.groups).set({ position }).where(eq(groups.id, id));
}
});
},
handleSync: (db) => {
db.transaction((trx) => {
for (const { id, position } of positions) {
trx.update(groups).set({ position }).where(eq(groups.id, id)).run();
}
});
},
});
}),
savePermissions: permissionRequiredProcedure
.requiresPermission("admin")
.input(groupSavePermissionsSchema)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await ctx.db.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId));
if (input.permissions.length > 0) {
await ctx.db.insert(groupPermissions).values(
input.permissions.map((permission) => ({
groupId: input.groupId,
permission,
})),
);
}
}),
transferOwnership: permissionRequiredProcedure
.requiresPermission("admin")
.input(groupUserSchema)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
await ctx.db
.update(groups)
.set({
ownerId: input.userId,
})
.where(eq(groups.id, input.groupId));
}),
deleteGroup: permissionRequiredProcedure
.requiresPermission("admin")
.input(byIdSchema)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
await throwIfGroupNameIsReservedAsync(ctx.db, input.id);
await ctx.db.delete(groups).where(eq(groups.id, input.id));
}),
addMember: permissionRequiredProcedure
.requiresPermission("admin")
.input(groupUserSchema)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
throwIfCredentialsDisabled();
const user = await ctx.db.query.users.findFirst({
where: eq(groups.id, input.userId),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db.insert(groupMembers).values({
groupId: input.groupId,
userId: input.userId,
});
}),
removeMember: permissionRequiredProcedure
.requiresPermission("admin")
.input(groupUserSchema)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
throwIfCredentialsDisabled();
await ctx.db
.delete(groupMembers)
.where(and(eq(groupMembers.groupId, input.groupId), eq(groupMembers.userId, input.userId)));
}),
});
const checkSimilarNameAndThrowAsync = async (db: Database, name: string, ignoreId?: string) => {
const similar = await db.query.groups.findFirst({
where: and(like(groups.name, `${name}`), not(eq(groups.id, ignoreId ?? ""))),
});
if (similar) {
throw new TRPCError({
code: "CONFLICT",
message: "Found group with similar name",
});
}
};
const throwIfGroupNameIsReservedAsync = async (db: Database, id: string) => {
const count = await db.$count(groups, and(eq(groups.id, id), eq(groups.name, everyoneGroup)));
if (count > 0) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Action is forbidden for reserved group names",
});
}
};
const throwIfGroupNotFoundAsync = async (db: Database, id: string) => {
const group = await db.query.groups.findFirst({
where: eq(groups.id, id),
});
if (!group) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Group not found",
});
}
};

View File

@@ -0,0 +1,147 @@
import { isProviderEnabled } from "@homarr/auth/server";
import { db, eq, inArray, or } from "@homarr/db";
import {
apps,
boards,
boardUserPermissions,
groupMembers,
groups,
integrations,
invites,
medias,
searchEngines,
users,
} from "@homarr/db/schema";
import type { TranslationObject } from "@homarr/translation";
import { createTRPCRouter, publicProcedure } from "../trpc";
interface HomeStatistic {
titleKey: keyof TranslationObject["management"]["page"]["home"]["statistic"];
subtitleKey: keyof TranslationObject["management"]["page"]["home"]["statisticLabel"];
count: number;
path: string;
}
export const homeRouter = createTRPCRouter({
getStats: publicProcedure.query(async ({ ctx }) => {
const isAdmin = ctx.session?.user.permissions.includes("admin") ?? false;
const isCredentialsEnabled = isProviderEnabled("credentials");
const statistics: HomeStatistic[] = [];
const boardIds: string[] = [];
if (ctx.session?.user && !ctx.session.user.permissions.includes("board-view-all")) {
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, ctx.session.user.id),
});
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session.user.id),
with: {
group: {
with: {
boardPermissions: {},
},
},
},
});
boardIds.push(
...permissionsOfCurrentUserWhenPresent
.map((permission) => permission.boardId)
.concat(
permissionsOfCurrentUserGroupsWhenPresent
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
.flat(),
),
);
}
statistics.push({
titleKey: "board",
subtitleKey: "boards",
count: await db.$count(
boards,
ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
),
),
path: "/manage/boards",
});
if (isAdmin) {
statistics.push({
titleKey: "user",
subtitleKey: "authentication",
count: await db.$count(users),
path: "/manage/users",
});
}
if (isAdmin && isCredentialsEnabled) {
statistics.push({
titleKey: "invite",
subtitleKey: "authentication",
count: await db.$count(invites),
path: "/manage/users/invites",
});
}
if (ctx.session?.user.permissions.includes("integration-create")) {
statistics.push({
titleKey: "integration",
subtitleKey: "resources",
count: await db.$count(integrations),
path: "/manage/integrations",
});
}
if (ctx.session?.user) {
statistics.push({
titleKey: "app",
subtitleKey: "resources",
count: await db.$count(apps),
path: "/manage/apps",
});
}
if (isAdmin) {
statistics.push({
titleKey: "group",
subtitleKey: "authorization",
count: await db.$count(groups),
path: "/manage/users/groups",
});
}
if (ctx.session?.user.permissions.includes("search-engine-create")) {
statistics.push({
titleKey: "searchEngine",
subtitleKey: "resources",
count: await db.$count(searchEngines),
path: "/manage/search-engines",
});
}
if (ctx.session?.user.permissions.includes("media-upload")) {
statistics.push({
titleKey: "media",
subtitleKey: "resources",
count: await db.$count(
medias,
ctx.session.user.permissions.includes("media-view-all")
? undefined
: eq(medias.creatorId, ctx.session.user.id),
),
path: "/manage/medias",
});
}
return statistics;
}),
});

View File

@@ -0,0 +1,30 @@
import { and, like } from "@homarr/db";
import { icons } from "@homarr/db/schema";
import { iconsFindSchema } from "@homarr/validation/icons";
import { createTRPCRouter, publicProcedure } from "../trpc";
export const iconsRouter = createTRPCRouter({
findIcons: publicProcedure.input(iconsFindSchema).query(async ({ ctx, input }) => {
return {
icons: await ctx.db.query.iconRepositories.findMany({
with: {
icons: {
columns: {
id: true,
name: true,
url: true,
},
where:
(input.searchText?.length ?? 0) > 0
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
: undefined,
limit: input.limitPerGroup,
},
},
}),
countIcons: await ctx.db.$count(icons),
};
}),
});

View File

@@ -0,0 +1,43 @@
import { z } from "zod/v4";
import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse";
import {
ensureValidTokenOrThrow,
importInitialOldmarrAsync,
importInitialOldmarrInputSchema,
} from "@homarr/old-import/import";
import { createTRPCRouter, onboardingProcedure } from "../../trpc";
import { nextOnboardingStepAsync } from "../onboard/onboard-queries";
export const importRouter = createTRPCRouter({
analyseInitialOldmarrImport: onboardingProcedure
.requiresStep("import")
.input(analyseOldmarrImportInputSchema)
.mutation(async ({ input }) => {
return await analyseOldmarrImportForRouterAsync(input);
}),
validateToken: onboardingProcedure
.requiresStep("import")
.input(
z.object({
checksum: z.string(),
token: z.string(),
}),
)
.mutation(({ input }) => {
try {
ensureValidTokenOrThrow(input.checksum, input.token);
return true;
} catch {
return false;
}
}),
importInitialOldmarrImport: onboardingProcedure
.requiresStep("import")
.input(importInitialOldmarrInputSchema)
.mutation(async ({ ctx, input }) => {
await importInitialOldmarrAsync(ctx.db, input);
await nextOnboardingStepAsync(ctx.db, undefined);
}),
});

View File

@@ -0,0 +1,16 @@
import z from "zod/v4";
import packageJson from "../../../../package.json";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const infoRouter = createTRPCRouter({
getInfo: protectedProcedure
.input(z.void())
.output(z.object({ version: z.string() }))
.meta({ openapi: { method: "GET", path: "/api/info", tags: ["info"] } })
.query(() => {
return {
version: packageJson.version,
};
}),
});

View File

@@ -0,0 +1,73 @@
import { TRPCError } from "@trpc/server";
import type { Session } from "@homarr/auth";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
import type { IntegrationPermission } from "@homarr/definitions";
/**
* Throws NOT_FOUND if user is not allowed to perform action on integration
* @param ctx trpc router context
* @param integrationWhere where clause for the integration
* @param permission permission required to perform action on integration
*/
export const throwIfActionForbiddenAsync = async (
ctx: { db: Database; session: Session | null },
integrationWhere: SQL<unknown>,
permission: IntegrationPermission,
) => {
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const integration = await db.query.integrations.findFirst({
where: integrationWhere,
columns: {
id: true,
},
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
),
},
},
});
if (!integration) {
notAllowed();
}
const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session);
if (hasFullAccess) {
return; // As full access is required and user has full access, allow
}
if (["interact", "use"].includes(permission) && hasInteractAccess) {
return; // As interact access is required and user has interact access, allow
}
if (permission === "use" && hasUseAccess) {
return; // As use access is required and user has use access, allow
}
notAllowed();
};
/**
* This method returns NOT_FOUND to prevent snooping on board existence
* A function is used to use the method without return statement
*/
function notAllowed(): never {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}

View File

@@ -0,0 +1,705 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId, objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database } from "@homarr/db";
import { and, asc, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
import {
apps,
groupMembers,
groupPermissions,
integrationGroupPermissions,
integrations,
integrationSecrets,
integrationUserPermissions,
searchEngines,
} from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import {
getIconUrl,
getIntegrationKindsByCategory,
getPermissionsWithParents,
integrationCategories,
integrationDefs,
integrationKinds,
integrationSecretKindObject,
} from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { byIdSchema } from "@homarr/validation/common";
import {
integrationCreateSchema,
integrationSavePermissionsSchema,
integrationUpdateSchema,
} from "@homarr/validation/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "./integration-access";
import { MissingSecretError, testConnectionAsync } from "./integration-test-connection";
import { mapTestConnectionError } from "./map-test-connection-error";
const logger = createLogger({ module: "integrationRouter" });
export const integrationRouter = createTRPCRouter({
all: publicProcedure.query(async ({ ctx }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
});
const integrations = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
});
return integrations
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
};
})
.sort(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
allThatSupportSearch: publicProcedure.query(async ({ ctx }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
});
const integrationsFromDb = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
where: inArray(
integrations.kind,
objectEntries(integrationDefs)
.filter(([_, integration]) => [...integration.category].includes("search"))
.map(([kind, _]) => kind),
),
});
return integrationsFromDb
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
};
})
.sort(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
allOfGivenCategory: publicProcedure
.input(
z.object({
category: z.enum(integrationCategories),
}),
)
.query(async ({ ctx, input }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
});
const intergrationKinds = getIntegrationKindsByCategory(input.category);
const integrationsFromDb = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
where: inArray(integrations.kind, intergrationKinds),
});
return integrationsFromDb
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
};
})
.sort(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
search: protectedProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.integrations.findMany({
where: like(integrations.name, `%${input.query}%`),
orderBy: asc(integrations.name),
limit: input.limit,
});
}),
// This is used to get the integrations by their ids it's public because it's needed to get integrations data in the boards
byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
return await ctx.db.query.integrations.findMany({
where: inArray(integrations.id, input),
columns: {
id: true,
kind: true,
},
});
}),
byId: protectedProcedure.input(byIdSchema).query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
with: {
secrets: {
columns: {
kind: true,
value: true,
updatedAt: true,
},
},
app: {
columns: {
id: true,
name: true,
iconUrl: true,
href: true,
},
},
},
});
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
secrets: integration.secrets.map((secret) => ({
kind: secret.kind,
// Only return the value if the secret is public, so for example the username
value: integrationSecretKindObject[secret.kind].isPublic ? decryptSecret(secret.value) : null,
updatedAt: secret.updatedAt,
})),
app: integration.app,
};
}),
create: permissionRequiredProcedure
.requiresPermission("integration-create")
.input(integrationCreateSchema)
.mutation(async ({ ctx, input }) => {
logger.info("Creating integration", {
name: input.name,
kind: input.kind,
url: input.url,
});
if (input.app && "name" in input.app && !ctx.session.user.permissions.includes("app-create")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Permission denied",
});
}
const result = await testConnectionAsync({
id: "new",
name: input.name,
url: input.url,
kind: input.kind,
secrets: input.secrets,
}).catch((error) => {
if (!(error instanceof MissingSecretError)) throw error;
throw new TRPCError({
code: "BAD_REQUEST",
message: error.message,
});
});
if (!result.success) {
logger.error(result.error);
return {
error: mapTestConnectionError(result.error),
};
}
const appId = await createAppIfNecessaryAsync(ctx.db, input.app);
const integrationId = createId();
await ctx.db.insert(integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
});
if (input.secrets.length >= 1) {
await ctx.db.insert(integrationSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
})),
);
}
logger.info("Created integration", {
id: integrationId,
name: input.name,
kind: input.kind,
url: input.url,
});
if (
input.attemptSearchEngineCreation &&
integrationDefs[input.kind].category.flatMap((category) => category).includes("search")
) {
const icon = getIconUrl(input.kind);
await ctx.db.insert(searchEngines).values({
id: createId(),
name: input.name,
integrationId,
type: "fromIntegration",
iconUrl: icon,
short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name),
});
}
}),
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
logger.info("Updating integration", {
id: input.id,
});
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
with: {
secrets: true,
},
});
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}
const testResult = await testConnectionAsync(
{
id: input.id,
name: input.name,
url: input.url,
kind: integration.kind,
secrets: input.secrets,
},
integration.secrets,
).catch((error) => {
if (!(error instanceof MissingSecretError)) throw error;
throw new TRPCError({
code: "BAD_REQUEST",
message: error.message,
});
});
if (!testResult.success) {
logger.error(testResult.error);
return {
error: mapTestConnectionError(testResult.error),
};
}
await ctx.db
.update(integrations)
.set({
name: input.name,
url: input.url,
appId: input.appId,
})
.where(eq(integrations.id, input.id));
const changedSecrets = input.secrets.filter(
(secret): secret is { kind: IntegrationSecretKind; value: string } =>
secret.value !== null && // only update secrets that have a value
!integration.secrets.find(
// Checked above
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
(dbSecret) => dbSecret.kind === secret.kind && dbSecret.value === encryptSecret(secret.value!),
),
);
if (changedSecrets.length > 0) {
for (const changedSecret of changedSecrets) {
const secretInput = {
integrationId: input.id,
value: changedSecret.value,
kind: changedSecret.kind,
};
if (!integration.secrets.some((secret) => secret.kind === changedSecret.kind)) {
await addSecretAsync(ctx.db, secretInput);
} else {
await updateSecretAsync(ctx.db, secretInput);
}
}
}
const removedSecrets = integration.secrets.filter(
(dbSecret) => !input.secrets.some((secret) => dbSecret.kind === secret.kind),
);
if (removedSecrets.length >= 1) {
await ctx.db
.delete(integrationSecrets)
.where(
or(
...removedSecrets.map((secret) =>
and(eq(integrationSecrets.integrationId, input.id), eq(integrationSecrets.kind, secret.kind)),
),
),
);
}
logger.info("Updated integration", {
id: input.id,
name: input.name,
kind: integration.kind,
url: input.url,
});
}),
delete: protectedProcedure.input(byIdSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
});
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
}),
getIntegrationPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const dbGroupPermissions = await ctx.db.query.groupPermissions.findMany({
where: inArray(
groupPermissions.permission,
getPermissionsWithParents(["integration-use-all", "integration-interact-all", "integration-full-all"]),
),
columns: {
groupId: false,
},
with: {
group: {
columns: {
id: true,
name: true,
},
},
},
});
const userPermissions = await ctx.db.query.integrationUserPermissions.findMany({
where: eq(integrationUserPermissions.integrationId, input.id),
with: {
user: {
columns: {
id: true,
name: true,
image: true,
email: true,
},
},
},
});
const dbGroupIntegrationPermission = await ctx.db.query.integrationGroupPermissions.findMany({
where: eq(integrationGroupPermissions.integrationId, input.id),
with: {
group: {
columns: {
id: true,
name: true,
},
},
},
});
return {
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
users: userPermissions
.map(({ user, permission }) => ({
user,
permission,
}))
.sort((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}),
groups: dbGroupIntegrationPermission
.map(({ group, permission }) => ({
group: {
id: group.id,
name: group.name,
},
permission,
}))
.sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
};
}),
saveUserIntegrationPermissions: protectedProcedure
.input(integrationSavePermissionsSchema)
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full");
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await ctx.db.transaction(async (transaction) => {
await transaction
.delete(schema.integrationUserPermissions)
.where(eq(schema.integrationUserPermissions.integrationId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(schema.integrationUserPermissions).values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.delete(integrationUserPermissions)
.where(eq(integrationUserPermissions.integrationId, input.entityId))
.run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(integrationUserPermissions)
.values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
)
.run();
});
},
});
}),
saveGroupIntegrationPermissions: protectedProcedure
.input(integrationSavePermissionsSchema)
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full");
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction
.delete(schema.integrationGroupPermissions)
.where(eq(schema.integrationGroupPermissions.integrationId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(schema.integrationGroupPermissions).values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.delete(integrationGroupPermissions)
.where(eq(integrationGroupPermissions.integrationId, input.entityId))
.run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(integrationGroupPermissions)
.values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
)
.run();
});
},
});
}),
searchInIntegration: protectedProcedure
.concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("search")))
.input(z.object({ integrationId: z.string(), query: z.string() }))
.query(async ({ ctx, input }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.searchAsync(encodeURI(input.query));
}),
});
interface UpdateSecretInput {
integrationId: string;
value: string;
kind: IntegrationSecretKind;
}
const updateSecretAsync = async (db: Database, input: UpdateSecretInput) => {
await db
.update(integrationSecrets)
.set({
value: encryptSecret(input.value),
})
.where(and(eq(integrationSecrets.integrationId, input.integrationId), eq(integrationSecrets.kind, input.kind)));
};
interface AddSecretInput {
integrationId: string;
value: string;
kind: IntegrationSecretKind;
}
const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => {
const searchEngines = await db.query.searchEngines.findMany({
columns: {
short: true,
},
});
const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase());
const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase();
if (!usedShortNames.includes(nameByIntegrationName)) {
return nameByIntegrationName;
}
// 8 is max length constraint
for (let i = 2; i < 9999999; i++) {
const generatedName = `${nameByIntegrationName}${i}`;
if (usedShortNames.includes(generatedName)) {
continue;
}
return generatedName;
}
throw new Error(
"Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.",
);
};
const addSecretAsync = async (db: Database, input: AddSecretInput) => {
await db.insert(integrationSecrets).values({
kind: input.kind,
value: encryptSecret(input.value),
integrationId: input.integrationId,
});
};
const createAppIfNecessaryAsync = async (db: Database, app: z.infer<typeof integrationCreateSchema>["app"]) => {
if (!app) return null;
if ("id" in app) return app.id;
logger.info("Creating app", {
name: app.name,
url: app.href,
});
const appId = createId();
await db.insert(apps).values({
id: appId,
name: app.name,
description: app.description,
iconUrl: app.iconUrl,
href: app.href,
pingUrl: app.pingUrl,
});
logger.info("Created app", {
id: appId,
name: app.name,
url: app.href,
});
return appId;
};

View File

@@ -0,0 +1,154 @@
import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
const logger = createLogger({ module: "integrationTestConnection" });
type FormIntegration = Omit<Integration, "appId"> & {
secrets: {
kind: IntegrationSecretKind;
value: string | null;
}[];
};
export const testConnectionAsync = async (
integration: FormIntegration,
dbSecrets: {
kind: IntegrationSecretKind;
value: `${string}.${string}`;
}[] = [],
) => {
logger.info("Testing connection", {
integrationName: integration.name,
integrationKind: integration.kind,
integrationUrl: integration.url,
});
const decryptedDbSecrets = dbSecrets
.map((secret) => {
try {
return {
...secret,
value: decryptSecret(secret.value),
source: "db" as const,
};
} catch (error) {
logger.warn(
new ErrorWithMetadata(
"Failed to decrypt secret from database",
{
integrationName: integration.name,
integrationKind: integration.kind,
secretKind: secret.kind,
},
{ cause: error },
),
);
return null;
}
})
.filter((secret) => secret !== null);
const formSecrets = integration.secrets
.map((secret) => ({
...secret,
// If the value is not defined in the form (because we only changed other values) we use the existing value from the db if it exists
value: secret.value ?? decryptedDbSecrets.find((dbSecret) => dbSecret.kind === secret.kind)?.value ?? null,
source: "form" as const,
}))
.filter((secret): secret is SourcedIntegrationSecret<"form"> => secret.value !== null);
const sourcedSecrets = [...formSecrets, ...decryptedDbSecrets];
const secretKinds = getSecretKindOption(integration.kind, sourcedSecrets);
const decryptedSecrets = secretKinds
.map((kind) => {
const secrets = sourcedSecrets.filter((secret) => secret.kind === kind);
// Will never be undefined because of the check before
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (secrets.length === 1) return secrets[0]!;
// There will always be a matching secret because of the getSecretKindOption function
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return secrets.find((secret) => secret.source === "form") ?? secrets[0]!;
})
.map(({ source: _, ...secret }) => secret);
const { secrets: _, ...baseIntegration } = integration;
const integrationInstance = await createIntegrationAsync({
...baseIntegration,
decryptedSecrets,
externalUrl: null,
});
const result = await integrationInstance.testConnectionAsync();
if (result.success) {
logger.info("Tested connection successfully", {
integrationName: integration.name,
integrationKind: integration.kind,
integrationUrl: integration.url,
});
}
return result;
};
interface SourcedIntegrationSecret<TSource extends string = "db" | "form"> {
kind: IntegrationSecretKind;
value: string;
source: TSource;
}
const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedIntegrationSecret[]) => {
const matchingSecretKindOptions = getAllSecretKindOptions(kind).filter((secretKinds) =>
secretKinds.every((kind) => sourcedSecrets.some((secret) => secret.kind === kind)),
);
if (matchingSecretKindOptions.length === 0) {
throw new MissingSecretError();
}
if (matchingSecretKindOptions.length === 1) {
// Will never be undefined because of the check above
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return matchingSecretKindOptions[0]!;
}
const onlyFormSecretsKindOptions = matchingSecretKindOptions.filter((secretKinds) =>
secretKinds.every((secretKind) =>
sourcedSecrets.find((secret) => secret.kind === secretKind && secret.source === "form"),
),
);
if (onlyFormSecretsKindOptions.length >= 1) {
// If the first option is no secret it would always be selected even if we want to have a secret
if (
onlyFormSecretsKindOptions.length >= 2 &&
onlyFormSecretsKindOptions.some((secretKinds) => secretKinds.length === 0)
) {
return (
// Will never be undefined because of the check above
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
onlyFormSecretsKindOptions.find((secretKinds) => secretKinds.length >= 1) ?? onlyFormSecretsKindOptions[0]!
);
}
// Will never be undefined because of the check above
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return onlyFormSecretsKindOptions[0]!;
}
// Will never be undefined because of the check above
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return matchingSecretKindOptions[0]!;
};
export class MissingSecretError extends Error {
constructor() {
super("No secret defined for this integration");
}
}

View File

@@ -0,0 +1,143 @@
import type { X509Certificate } from "node:crypto";
import type { RequestErrorCode } from "@homarr/common/server";
import type {
AnyTestConnectionError,
TestConnectionErrorDataOfType,
TestConnectionErrorType,
} from "@homarr/integrations/test-connection";
export interface MappedError {
name: string;
message: string;
metadata: { key: string; value: string | number | boolean }[];
cause?: MappedError;
}
const ignoredErrorProperties = ["name", "message", "cause", "stack"];
const mapError = (error: Error): MappedError => {
const metadata = Object.entries(error)
.filter(([key]) => !ignoredErrorProperties.includes(key))
.map(([key, value]) => {
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return { key, value };
}
return null;
})
.filter((value) => value !== null);
return {
name: error.name,
message: error.message,
metadata,
cause: error.cause && error.cause instanceof Error ? mapError(error.cause) : undefined,
};
};
export interface MappedCertificate {
isSelfSigned: boolean;
issuer: string;
issuerCertificate?: MappedCertificate;
subject: string;
serialNumber: string;
validFrom: Date;
validTo: Date;
fingerprint: string;
pem: string;
}
const mapCertificate = (certificate: X509Certificate, code: RequestErrorCode): MappedCertificate => ({
isSelfSigned: certificate.ca || code === "DEPTH_ZERO_SELF_SIGNED_CERT",
issuer: certificate.issuer,
issuerCertificate: certificate.issuerCertificate ? mapCertificate(certificate.issuerCertificate, code) : undefined,
subject: certificate.subject,
serialNumber: certificate.serialNumber,
validFrom: certificate.validFromDate,
validTo: certificate.validToDate,
fingerprint: certificate.fingerprint256,
pem: certificate.toString(),
});
type MappedData<TType extends TestConnectionErrorType> = TType extends "unknown" | "parse"
? undefined
: TType extends "certificate"
? {
type: TestConnectionErrorDataOfType<TType>["requestError"]["type"];
reason: TestConnectionErrorDataOfType<TType>["requestError"]["reason"];
certificate: MappedCertificate;
}
: TType extends "request"
? {
type: TestConnectionErrorDataOfType<TType>["requestError"]["type"];
reason: TestConnectionErrorDataOfType<TType>["requestError"]["reason"];
}
: TType extends "authorization"
? {
statusCode: TestConnectionErrorDataOfType<TType>["statusCode"];
reason: TestConnectionErrorDataOfType<TType>["reason"];
}
: TType extends "statusCode"
? {
statusCode: TestConnectionErrorDataOfType<TType>["statusCode"];
reason: TestConnectionErrorDataOfType<TType>["reason"];
url: TestConnectionErrorDataOfType<TType>["url"];
}
: never;
type AnyMappedData = {
[TType in TestConnectionErrorType]: MappedData<TType>;
}[TestConnectionErrorType];
const mapData = (error: AnyTestConnectionError): AnyMappedData => {
if (error.type === "unknown") return undefined;
if (error.type === "parse") return undefined;
if (error.type === "certificate") {
return {
type: error.data.requestError.type,
reason: error.data.requestError.reason,
certificate: mapCertificate(error.data.certificate, error.data.requestError.code),
};
}
if (error.type === "request") {
return {
type: error.data.requestError.type,
reason: error.data.requestError.reason,
};
}
if (error.type === "authorization") {
return {
statusCode: error.data.statusCode,
reason: error.data.reason,
};
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (error.type === "statusCode") {
return {
statusCode: error.data.statusCode,
reason: error.data.reason,
url: error.data.url,
};
}
throw new Error(`Unsupported error type: ${(error as AnyTestConnectionError).type}`);
};
interface MappedTestConnectionError<TType extends TestConnectionErrorType> {
type: TType;
name: string;
message: string;
data: MappedData<TType>;
cause?: MappedError;
}
export type AnyMappedTestConnectionError = {
[TType in TestConnectionErrorType]: MappedTestConnectionError<TType>;
}[TestConnectionErrorType];
export const mapTestConnectionError = (error: AnyTestConnectionError) => {
return {
type: error.type,
name: error.name,
message: error.message,
data: mapData(error),
cause: error.cause ? mapError(error.cause) : undefined,
} as AnyMappedTestConnectionError;
};

View File

@@ -0,0 +1,95 @@
import { randomBytes } from "crypto";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { asc, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema";
import { selectInviteSchema } from "@homarr/db/validationSchemas";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
import { throwIfCredentialsDisabled } from "./invite/checks";
export const inviteRouter = createTRPCRouter({
getAll: permissionRequiredProcedure
.requiresPermission("admin")
.output(
z.array(
selectInviteSchema
.pick({
id: true,
expirationDate: true,
})
.extend({ creator: z.object({ name: z.string().nullable(), id: z.string() }) }),
),
)
.input(z.undefined())
.meta({ openapi: { method: "GET", path: "/api/invites", tags: ["invites"], protect: true } })
.query(async ({ ctx }) => {
throwIfCredentialsDisabled();
return await ctx.db.query.invites.findMany({
orderBy: asc(invites.expirationDate),
columns: {
token: false,
},
with: {
creator: {
columns: {
id: true,
name: true,
},
},
},
});
}),
createInvite: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({
expirationDate: z.date(),
}),
)
.output(z.object({ id: z.string(), token: z.string() }))
.meta({ openapi: { method: "POST", path: "/api/invites", tags: ["invites"], protect: true } })
.mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
const id = createId();
const token = randomBytes(20).toString("hex");
await ctx.db.insert(invites).values({
id,
expirationDate: input.expirationDate,
creatorId: ctx.session.user.id,
token,
});
return {
id,
token,
};
}),
deleteInvite: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({
id: z.string(),
}),
)
.output(z.undefined())
.meta({ openapi: { method: "DELETE", path: "/api/invites/{id}", tags: ["invites"], protect: true } })
.mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
const dbInvite = await ctx.db.query.invites.findFirst({
where: eq(invites.id, input.id),
});
if (!dbInvite) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invite not found",
});
}
await ctx.db.delete(invites).where(eq(invites.id, input.id));
}),
});

View File

@@ -0,0 +1,12 @@
import { TRPCError } from "@trpc/server";
import { env } from "@homarr/auth/env";
export const throwIfCredentialsDisabled = () => {
if (!env.AUTH_PROVIDERS.includes("credentials")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Credentials provider is disabled",
});
}
};

View File

@@ -0,0 +1,71 @@
import * as fs from "fs";
import { CoreV1Api, KubeConfig, Metrics, NetworkingV1Api, VersionApi } from "@kubernetes/client-node";
import { env } from "../../env";
export class KubernetesClient {
private static instance: KubernetesClient | null = null;
public kubeConfig: KubeConfig;
public coreApi: CoreV1Api;
public networkingApi: NetworkingV1Api;
public metricsApi: Metrics;
public versionApi: VersionApi;
private constructor() {
this.kubeConfig = new KubeConfig();
if (process.env.NODE_ENV === "development") {
this.kubeConfig.loadFromDefault();
} else {
this.kubeConfig.loadFromCluster();
const currentCluster = this.kubeConfig.getCurrentCluster();
if (!currentCluster) throw new Error("No cluster configuration found");
const token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8");
const caData = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", "utf8");
const clusterWithCA = {
...currentCluster,
name: `${currentCluster.name}-service-account`,
caData,
};
const serviceAccountUser = {
name: env.KUBERNETES_SERVICE_ACCOUNT_NAME ?? "default-sa",
token,
};
this.kubeConfig.clusters = [];
this.kubeConfig.users = [];
this.kubeConfig.addCluster(clusterWithCA);
this.kubeConfig.addUser(serviceAccountUser);
const currentContext = this.kubeConfig.getCurrentContext();
const originalContext = this.kubeConfig.getContextObject(currentContext);
if (!originalContext) throw new Error("No context found");
const updatedContext = {
...originalContext,
name: `${originalContext.name}-service-account`,
cluster: clusterWithCA.name,
user: serviceAccountUser.name,
};
this.kubeConfig.contexts = [];
this.kubeConfig.addContext(updatedContext);
this.kubeConfig.setCurrentContext(updatedContext.name);
}
this.coreApi = this.kubeConfig.makeApiClient(CoreV1Api);
this.networkingApi = this.kubeConfig.makeApiClient(NetworkingV1Api);
this.metricsApi = new Metrics(this.kubeConfig);
this.versionApi = this.kubeConfig.makeApiClient(VersionApi);
}
public static getInstance(): KubernetesClient {
KubernetesClient.instance ??= new KubernetesClient();
return KubernetesClient.instance;
}
}

View File

@@ -0,0 +1,41 @@
import type { ResourceParser } from "./resource-parser";
export class CpuResourceParser implements ResourceParser {
private readonly billionthsCore = 1_000_000_000;
private readonly millionthsCore = 1_000_000;
private readonly MiliCore = 1_000;
private readonly ThousandCore = 1_000;
parse(value: string): number {
if (!value.length) {
return NaN;
}
value = value.replace(/,/g, "").trim();
const [, numericValue, unit = ""] = /^([0-9.]+)\s*([a-zA-Z]*)$/.exec(value) ?? [];
if (numericValue === undefined) {
return NaN;
}
const parsedValue = parseFloat(numericValue);
if (isNaN(parsedValue)) {
return NaN;
}
switch (unit.toLowerCase()) {
case "n": // nano-cores (billionths of a core)
return parsedValue / this.billionthsCore; // 1 NanoCPU = 1/1,000,000,000 cores
case "u": // micro-cores (millionths of a core)
return parsedValue / this.millionthsCore; // 1 MicroCPU = 1/1,000,000 cores
case "m": // milli-cores
return parsedValue / this.MiliCore; // 1 milli-core = 1/1000 cores
case "k": // thousands of cores
return parsedValue * this.ThousandCore; // 1 thousand-core = 1000 cores
default: // cores (no unit)
return parsedValue;
}
}
}

View File

@@ -0,0 +1,69 @@
import type { ResourceParser } from "./resource-parser";
export class MemoryResourceParser implements ResourceParser {
private readonly binaryMultipliers: Record<string, number> = {
ki: 1024,
mi: 1024 ** 2,
gi: 1024 ** 3,
ti: 1024 ** 4,
pi: 1024 ** 5,
} as const;
private readonly decimalMultipliers: Record<string, number> = {
k: 1000,
m: 1000 ** 2,
g: 1000 ** 3,
t: 1000 ** 4,
p: 1000 ** 5,
} as const;
parse(value: string): number {
if (!value.length) {
return NaN;
}
value = value.replace(/,/g, "").trim();
const [, numericValue, unit = ""] = /^([0-9.]+)\s*([a-zA-Z]*)$/.exec(value) ?? [];
if (!numericValue) {
return NaN;
}
const parsedValue = parseFloat(numericValue);
if (isNaN(parsedValue)) {
return NaN;
}
const unitLower = unit.toLowerCase();
// Handle binary units (Ki, Mi, Gi, etc.)
if (unitLower in this.binaryMultipliers) {
const multiplier = this.binaryMultipliers[unitLower];
const giMultiplier = this.binaryMultipliers.gi;
if (multiplier !== undefined && giMultiplier !== undefined) {
return (parsedValue * multiplier) / giMultiplier;
}
}
// Handle decimal units (K, M, G, etc.)
if (unitLower in this.decimalMultipliers) {
const multiplier = this.decimalMultipliers[unitLower];
const giMultiplier = this.binaryMultipliers.gi;
if (multiplier !== undefined && giMultiplier !== undefined) {
return (parsedValue * multiplier) / giMultiplier;
}
}
// No unit or unrecognized unit, assume bytes and convert to GiB
const giMultiplier = this.binaryMultipliers.gi;
if (giMultiplier !== undefined) {
return parsedValue / giMultiplier;
}
return NaN; // Return NaN if giMultiplier is undefined
}
}

View File

@@ -0,0 +1,3 @@
export interface ResourceParser {
parse(value: string): number;
}

View File

@@ -0,0 +1,204 @@
import type { V1NodeList, VersionInfo } from "@kubernetes/client-node";
import { TRPCError } from "@trpc/server";
import type { ClusterResourceCount, KubernetesCluster } from "@homarr/definitions";
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
import { CpuResourceParser } from "../resource-parser/cpu-resource-parser";
import { MemoryResourceParser } from "../resource-parser/memory-resource-parser";
export const clusterRouter = createTRPCRouter({
getCluster: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesCluster> => {
const { coreApi, metricsApi, versionApi, kubeConfig } = KubernetesClient.getInstance();
try {
const versionInfo = await versionApi.getCode();
const nodes = await coreApi.listNode();
const nodeMetricsClient = await metricsApi.getNodeMetrics();
const listPodForAllNamespaces = await coreApi.listPodForAllNamespaces();
let totalCPUCapacity = 0;
let totalCPUAllocatable = 0;
let totalCPUUsage = 0;
let totalMemoryCapacity = 0;
let totalMemoryAllocatable = 0;
let totalMemoryUsage = 0;
let totalCapacityPods = 0;
const cpuResourceParser = new CpuResourceParser();
const memoryResourceParser = new MemoryResourceParser();
nodes.items.forEach((node) => {
totalCapacityPods += Number(node.status?.capacity?.pods);
const cpuCapacity = cpuResourceParser.parse(node.status?.capacity?.cpu ?? "0");
const cpuAllocatable = cpuResourceParser.parse(node.status?.allocatable?.cpu ?? "0");
totalCPUCapacity += cpuCapacity;
totalCPUAllocatable += cpuAllocatable;
const memoryCapacity = memoryResourceParser.parse(node.status?.capacity?.memory ?? "0");
const memoryAllocatable = memoryResourceParser.parse(node.status?.allocatable?.memory ?? "0");
totalMemoryCapacity += memoryCapacity;
totalMemoryAllocatable += memoryAllocatable;
const nodeName = node.metadata?.name;
const nodeMetric = nodeMetricsClient.items.find((metric) => metric.metadata.name === nodeName);
if (nodeMetric) {
const cpuUsage = cpuResourceParser.parse(nodeMetric.usage.cpu);
totalCPUUsage += cpuUsage;
const memoryUsage = memoryResourceParser.parse(nodeMetric.usage.memory);
totalMemoryUsage += memoryUsage;
}
});
const reservedCPU = totalCPUCapacity - totalCPUAllocatable;
const reservedMemory = totalMemoryCapacity - totalMemoryAllocatable;
const reservedCPUPercentage = (reservedCPU / totalCPUCapacity) * 100;
const reservedMemoryPercentage = (reservedMemory / totalMemoryCapacity) * 100;
const usagePercentageAllocatable = (totalCPUUsage / totalCPUAllocatable) * 100;
const usagePercentageMemoryAllocatable = (totalMemoryUsage / totalMemoryAllocatable) * 100;
const usedPodsPercentage = (listPodForAllNamespaces.items.length / totalCapacityPods) * 100;
return {
name: kubeConfig.getCurrentContext(),
providers: getProviders(versionInfo, nodes),
kubernetesVersion: versionInfo.gitVersion,
architecture: versionInfo.platform,
nodeCount: nodes.items.length,
capacity: [
{
type: "CPU",
resourcesStats: [
{
percentageValue: Number(reservedCPUPercentage.toFixed(2)),
type: "Reserved",
capacityUnit: "Cores",
usedValue: Number(reservedCPU.toFixed(2)),
maxUsedValue: Number(totalCPUCapacity.toFixed(2)),
},
{
percentageValue: Number(usagePercentageAllocatable.toFixed(2)),
type: "Used",
capacityUnit: "Cores",
usedValue: Number(totalCPUUsage.toFixed(2)),
maxUsedValue: Number(totalCPUAllocatable.toFixed(2)),
},
],
},
{
type: "Memory",
resourcesStats: [
{
percentageValue: Number(reservedMemoryPercentage.toFixed(2)),
type: "Reserved",
capacityUnit: "GiB",
usedValue: Number(reservedMemory.toFixed(2)),
maxUsedValue: Number(totalMemoryCapacity.toFixed(2)),
},
{
percentageValue: Number(usagePercentageMemoryAllocatable.toFixed(2)),
type: "Used",
capacityUnit: "GiB",
usedValue: Number(totalMemoryUsage.toFixed(2)),
maxUsedValue: Number(totalMemoryAllocatable.toFixed(2)),
},
],
},
{
type: "Pods",
resourcesStats: [
{
percentageValue: Number(usedPodsPercentage.toFixed(2)),
type: "Used",
usedValue: listPodForAllNamespaces.items.length,
maxUsedValue: totalCapacityPods,
},
],
},
],
};
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An error occurred while fetching Kubernetes cluster",
cause: error,
});
}
}),
getClusterResourceCounts: permissionRequiredProcedure
.requiresPermission("admin")
.query(async (): Promise<ClusterResourceCount[]> => {
const { coreApi, networkingApi } = KubernetesClient.getInstance();
try {
const [pods, ingresses, services, configMaps, namespaces, nodes, secrets, volumes] = await Promise.all([
coreApi.listPodForAllNamespaces(),
networkingApi.listIngressForAllNamespaces(),
coreApi.listServiceForAllNamespaces(),
coreApi.listConfigMapForAllNamespaces(),
coreApi.listNamespace(),
coreApi.listNode(),
coreApi.listSecretForAllNamespaces(),
coreApi.listPersistentVolumeClaimForAllNamespaces(),
]);
return [
{ label: "nodes", count: nodes.items.length },
{ label: "namespaces", count: namespaces.items.length },
{ label: "ingresses", count: ingresses.items.length },
{ label: "services", count: services.items.length },
{ label: "pods", count: pods.items.length },
{ label: "secrets", count: secrets.items.length },
{ label: "configmaps", count: configMaps.items.length },
{ label: "volumes", count: volumes.items.length },
];
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An error occurred while fetching Kubernetes resources count",
cause: error,
});
}
}),
});
function getProviders(versionInfo: VersionInfo, nodes: V1NodeList) {
const providers = new Set<string>();
if (versionInfo.gitVersion.includes("k3s")) providers.add("k3s");
if (versionInfo.gitVersion.includes("gke")) providers.add("GKE");
if (versionInfo.gitVersion.includes("eks")) providers.add("EKS");
if (versionInfo.gitVersion.includes("aks")) providers.add("AKS");
nodes.items.forEach((node) => {
const labels = node.metadata?.labels ?? {};
const nodeProviderLabel = labels["node.kubernetes.io/instance-type"] ?? labels.provider ?? "";
if (nodeProviderLabel.includes("aws")) providers.add("EKS");
if (nodeProviderLabel.includes("azure")) providers.add("AKS");
if (nodeProviderLabel.includes("gce")) providers.add("GKE");
if (nodeProviderLabel.includes("k3s")) providers.add("k3s");
const nodeInfo = node.status?.nodeInfo;
if (nodeInfo) {
const osImage = nodeInfo.osImage.toLowerCase();
const kernelVersion = nodeInfo.kernelVersion.toLowerCase();
if (osImage.includes("talos") || kernelVersion.includes("talos")) {
providers.add("Talos");
}
}
});
return Array.from(providers).join(", ");
}

View File

@@ -0,0 +1,34 @@
import { TRPCError } from "@trpc/server";
import type { KubernetesBaseResource } from "@homarr/definitions";
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
export const configMapsRouter = createTRPCRouter({
getConfigMaps: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesBaseResource[]> => {
const { coreApi } = KubernetesClient.getInstance();
try {
const configMaps = await coreApi.listConfigMapForAllNamespaces();
return configMaps.items.map((configMap) => {
return {
name: configMap.metadata?.name ?? "unknown",
namespace: configMap.metadata?.namespace ?? "unknown",
creationTimestamp: configMap.metadata?.creationTimestamp,
};
});
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An error occurred while fetching Kubernetes ConfigMaps",
cause: error,
});
}
}),
});

View File

@@ -0,0 +1,52 @@
import type { V1HTTPIngressPath, V1Ingress, V1IngressRule } from "@kubernetes/client-node";
import { TRPCError } from "@trpc/server";
import type { KubernetesIngress, KubernetesIngressPath, KubernetesIngressRuleAndPath } from "@homarr/definitions";
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
export const ingressesRouter = createTRPCRouter({
getIngresses: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesIngress[]> => {
const { networkingApi } = KubernetesClient.getInstance();
try {
const ingresses = await networkingApi.listIngressForAllNamespaces();
const mapIngress = (ingress: V1Ingress): KubernetesIngress => {
return {
name: ingress.metadata?.name ?? "",
namespace: ingress.metadata?.namespace ?? "",
className: ingress.spec?.ingressClassName ?? "",
rulesAndPaths: getIngressRulesAndPaths(ingress.spec?.rules ?? []),
creationTimestamp: ingress.metadata?.creationTimestamp,
};
};
const getIngressRulesAndPaths = (rules: V1IngressRule[] = []): KubernetesIngressRuleAndPath[] => {
return rules.map((rule) => ({
host: rule.host ?? "",
paths: getPaths(rule.http?.paths ?? []),
}));
};
const getPaths = (paths: V1HTTPIngressPath[] = []): KubernetesIngressPath[] => {
return paths.map((path) => ({
serviceName: path.backend.service?.name ?? "",
port: path.backend.service?.port?.number ?? 0,
}));
};
return ingresses.items.map(mapIngress);
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An error occurred while fetching Kubernetes ingresses",
cause: error,
});
}
}),
});

View File

@@ -0,0 +1,22 @@
import { createTRPCRouter } from "../../../trpc";
import { clusterRouter } from "./cluster";
import { configMapsRouter } from "./configMaps";
import { ingressesRouter } from "./ingresses";
import { namespacesRouter } from "./namespaces";
import { nodesRouter } from "./nodes";
import { podsRouter } from "./pods";
import { secretsRouter } from "./secrets";
import { servicesRouter } from "./services";
import { volumesRouter } from "./volumes";
export const kubernetesRouter = createTRPCRouter({
nodes: nodesRouter,
cluster: clusterRouter,
namespaces: namespacesRouter,
ingresses: ingressesRouter,
services: servicesRouter,
pods: podsRouter,
secrets: secretsRouter,
configMaps: configMapsRouter,
volumes: volumesRouter,
});

View File

@@ -0,0 +1,34 @@
import { TRPCError } from "@trpc/server";
import type { KubernetesNamespace, KubernetesNamespaceState } from "@homarr/definitions";
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
export const namespacesRouter = createTRPCRouter({
getNamespaces: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesNamespace[]> => {
const { coreApi } = KubernetesClient.getInstance();
try {
const namespaces = await coreApi.listNamespace();
return namespaces.items.map((namespace) => {
return {
status: namespace.status?.phase as KubernetesNamespaceState,
name: namespace.metadata?.name ?? "unknown",
creationTimestamp: namespace.metadata?.creationTimestamp,
} satisfies KubernetesNamespace;
});
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An error occurred while fetching Kubernetes namespaces",
cause: error,
});
}
}),
});

View File

@@ -0,0 +1,66 @@
import { TRPCError } from "@trpc/server";
import type { KubernetesNode, KubernetesNodeState } from "@homarr/definitions";
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
import { CpuResourceParser } from "../resource-parser/cpu-resource-parser";
import { MemoryResourceParser } from "../resource-parser/memory-resource-parser";
export const nodesRouter = createTRPCRouter({
getNodes: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesNode[]> => {
const { coreApi, metricsApi } = KubernetesClient.getInstance();
try {
const nodes = await coreApi.listNode();
const nodeMetricsClient = await metricsApi.getNodeMetrics();
const cpuResourceParser = new CpuResourceParser();
const memoryResourceParser = new MemoryResourceParser();
return nodes.items.map((node) => {
const name = node.metadata?.name ?? "unknown";
const readyCondition = node.status?.conditions?.find((condition) => condition.type === "Ready");
const status: KubernetesNodeState = readyCondition?.status === "True" ? "Ready" : "NotReady";
const cpuAllocatable = cpuResourceParser.parse(node.status?.allocatable?.cpu ?? "0");
const memoryAllocatable = memoryResourceParser.parse(node.status?.allocatable?.memory ?? "0");
let cpuUsage = 0;
let memoryUsage = 0;
const nodeMetric = nodeMetricsClient.items.find((metric) => metric.metadata.name === name);
if (nodeMetric) {
cpuUsage += cpuResourceParser.parse(nodeMetric.usage.cpu);
memoryUsage += memoryResourceParser.parse(nodeMetric.usage.memory);
}
const usagePercentageCPUAllocatable = (cpuUsage / cpuAllocatable) * 100;
const usagePercentageMemoryAllocatable = (memoryUsage / memoryAllocatable) * 100;
return {
name,
status,
allocatableCpuPercentage: Number(usagePercentageCPUAllocatable.toFixed(0)),
allocatableRamPercentage: Number(usagePercentageMemoryAllocatable.toFixed(0)),
podsCount: Number(node.status?.capacity?.pods),
operatingSystem: node.status?.nodeInfo?.operatingSystem,
architecture: node.status?.nodeInfo?.architecture,
kubernetesVersion: node.status?.nodeInfo?.kubeletVersion,
creationTimestamp: node.metadata?.creationTimestamp,
};
});
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An error occurred while fetching Kubernetes nodes",
cause: error,
});
}
}),
});

View File

@@ -0,0 +1,105 @@
import type { KubeConfig, V1OwnerReference } from "@kubernetes/client-node";
import { AppsV1Api } from "@kubernetes/client-node";
import { TRPCError } from "@trpc/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { KubernetesPod } from "@homarr/definitions";
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
const logger = createLogger({ module: "podsRouter" });
export const podsRouter = createTRPCRouter({
getPods: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesPod[]> => {
const { coreApi, kubeConfig } = KubernetesClient.getInstance();
try {
const podsResp = await coreApi.listPodForAllNamespaces();
const pods: KubernetesPod[] = [];
for (const pod of podsResp.items) {
const labels = pod.metadata?.labels ?? {};
const ownerRefs = pod.metadata?.ownerReferences ?? [];
let applicationType = "Pod";
if (labels["app.kubernetes.io/managed-by"] === "Helm") {
applicationType = "Helm";
} else {
for (const owner of ownerRefs) {
if (["Deployment", "StatefulSet", "DaemonSet"].includes(owner.kind)) {
applicationType = owner.kind;
break;
} else if (owner.kind === "ReplicaSet") {
const ownerType = await getOwnerKind(kubeConfig, owner, pod.metadata?.namespace ?? "");
if (ownerType) {
applicationType = ownerType;
break;
}
}
}
}
pods.push({
name: pod.metadata?.name ?? "",
namespace: pod.metadata?.namespace ?? "",
image: pod.spec?.containers.map((container) => container.image).join(", "),
applicationType,
status: pod.status?.phase ?? "unknown",
creationTimestamp: pod.metadata?.creationTimestamp,
});
}
return pods;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An error occurred while fetching Kubernetes pods",
cause: error,
});
}
}),
});
async function getOwnerKind(
kubeConfig: KubeConfig,
ownerRef: V1OwnerReference,
namespace: string,
): Promise<string | null> {
const { kind, name } = ownerRef;
if (kind === "ReplicaSet") {
const appsApi = kubeConfig.makeApiClient(AppsV1Api);
try {
const rsResp = await appsApi.readNamespacedReplicaSet({
name,
namespace,
});
if (rsResp.metadata?.ownerReferences) {
for (const rsOwner of rsResp.metadata.ownerReferences) {
if (rsOwner.kind === "Deployment") {
return "Deployment";
}
const parentKind = await getOwnerKind(kubeConfig, rsOwner, namespace);
if (parentKind) return parentKind;
}
}
return "ReplicaSet";
} catch (error) {
logger.error("Error reading ReplicaSet:", error);
return null;
}
}
if (["Deployment", "StatefulSet", "DaemonSet"].includes(kind)) {
return kind;
}
return null;
}

View File

@@ -0,0 +1,34 @@
import { TRPCError } from "@trpc/server";
import type { KubernetesSecret } from "@homarr/definitions";
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
export const secretsRouter = createTRPCRouter({
getSecrets: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesSecret[]> => {
const { coreApi } = KubernetesClient.getInstance();
try {
const secrets = await coreApi.listSecretForAllNamespaces();
return secrets.items.map((secret) => {
return {
name: secret.metadata?.name ?? "unknown",
namespace: secret.metadata?.namespace ?? "unknown",
type: secret.type ?? "unknown",
creationTimestamp: secret.metadata?.creationTimestamp,
};
});
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An error occurred while fetching Kubernetes secrets",
cause: error,
});
}
}),
});

View File

@@ -0,0 +1,38 @@
import { TRPCError } from "@trpc/server";
import type { KubernetesService } from "@homarr/definitions";
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
export const servicesRouter = createTRPCRouter({
getServices: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesService[]> => {
const { coreApi } = KubernetesClient.getInstance();
try {
const services = await coreApi.listServiceForAllNamespaces();
return services.items.map((service) => {
return {
name: service.metadata?.name ?? "unknown",
namespace: service.metadata?.namespace ?? "",
type: service.spec?.type ?? "",
ports: service.spec?.ports?.map(({ port, protocol }) => `${port}/${protocol}`),
targetPorts: service.spec?.ports?.map(({ targetPort }) => `${targetPort}`),
clusterIP: service.spec?.clusterIP ?? "",
creationTimestamp: service.metadata?.creationTimestamp,
};
});
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An error occurred while fetching Kubernetes services",
cause: error,
});
}
}),
});

View File

@@ -0,0 +1,40 @@
import { TRPCError } from "@trpc/server";
import type { KubernetesVolume } from "@homarr/definitions";
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client";
export const volumesRouter = createTRPCRouter({
getVolumes: permissionRequiredProcedure
.requiresPermission("admin")
.concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesVolume[]> => {
const { coreApi } = KubernetesClient.getInstance();
try {
const volumes = await coreApi.listPersistentVolumeClaimForAllNamespaces();
return volumes.items.map((volume) => {
return {
name: volume.metadata?.name ?? "unknown",
namespace: volume.metadata?.namespace ?? "unknown",
accessModes: volume.status?.accessModes?.map((accessMode) => accessMode) ?? [],
storage: volume.status?.capacity?.storage ?? "",
storageClassName: volume.spec?.storageClassName ?? "",
volumeMode: volume.spec?.volumeMode ?? "",
volumeName: volume.spec?.volumeName ?? "",
status: volume.status?.phase ?? "",
creationTimestamp: volume.metadata?.creationTimestamp,
};
});
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An error occurred while fetching Kubernetes Volumes",
cause: error,
});
}
}),
});

View File

@@ -0,0 +1,48 @@
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createTRPCRouter, publicProcedure } from "../trpc";
const citySchema = z.object({
id: z.number(),
name: z.string(),
country: z.string().optional(),
country_code: z.string().optional(),
latitude: z.number(),
longitude: z.number(),
population: z.number().optional(),
});
export const locationSearchCityInput = z.object({
query: z.string(),
});
export const locationSearchCityOutput = z
.object({
results: z.array(citySchema),
})
.or(
z
.object({
generationtime_ms: z.number(),
})
.refine((data) => Object.keys(data).length === 1, { message: "Invalid response" })
.transform(() => ({ results: [] })), // We fallback to empty array if no results
);
export const locationRouter = createTRPCRouter({
searchCity: publicProcedure
.input(locationSearchCityInput)
.output(locationSearchCityOutput)
.query(async ({ input }) => {
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`,
{ signal },
);
});
return (await res.json()) as z.infer<typeof locationSearchCityOutput>;
}),
});

View File

@@ -0,0 +1,35 @@
import { observable } from "@trpc/server/observable";
import z from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { logLevels } from "@homarr/core/infrastructure/logs/constants";
import type { LoggerMessage } from "@homarr/redis";
import { loggingChannel } from "@homarr/redis";
import { zodEnumFromArray } from "@homarr/validation/enums";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
const logger = createLogger({ module: "logRouter" });
export const logRouter = createTRPCRouter({
subscribe: permissionRequiredProcedure
.requiresPermission("other-view-logs")
.input(
z.object({
levels: z.array(zodEnumFromArray(logLevels)).default(["info"]),
}),
)
.subscription(({ input }) => {
return observable<LoggerMessage>((emit) => {
const unsubscribe = loggingChannel.subscribe((data) => {
if (!input.levels.includes(data.level)) return;
emit.next(data);
});
logger.info("A tRPC client has connected to the logging procedure");
return () => {
unsubscribe();
};
});
}),
});

View File

@@ -0,0 +1,128 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import type { InferInsertModel } from "@homarr/db";
import { and, desc, eq, like } from "@homarr/db";
import { iconRepositories, icons, medias } from "@homarr/db/schema";
import { createLocalImageUrl, LOCAL_ICON_REPOSITORY_SLUG, mapMediaToIcon } from "@homarr/icons/local";
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
import { mediaUploadSchema } from "@homarr/validation/media";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
export const mediaRouter = createTRPCRouter({
getPaginated: protectedProcedure
.input(
paginatedSchema.and(
z.object({ includeFromAllUsers: z.boolean().default(false), search: z.string().trim().default("") }),
),
)
.query(async ({ ctx, input }) => {
const includeFromAllUsers = ctx.session.user.permissions.includes("media-view-all") && input.includeFromAllUsers;
const where = and(
input.search.length >= 1 ? like(medias.name, `%${input.search}%`) : undefined,
includeFromAllUsers ? undefined : eq(medias.creatorId, ctx.session.user.id),
);
const dbMedias = await ctx.db.query.medias.findMany({
where,
orderBy: desc(medias.createdAt),
limit: input.pageSize,
offset: (input.page - 1) * input.pageSize,
columns: {
content: false,
},
with: {
creator: {
columns: {
id: true,
name: true,
image: true,
email: true,
},
},
},
});
const totalCount = await ctx.db.$count(medias, where);
return {
items: dbMedias,
totalCount,
};
}),
uploadMedia: permissionRequiredProcedure
.requiresPermission("media-upload")
.input(mediaUploadSchema)
.mutation(async ({ ctx, input }) => {
const files = await Promise.all(
input.files.map(async (file) => ({
id: createId(),
meta: file,
content: Buffer.from(await file.arrayBuffer()),
})),
);
const insertMedias = files.map(
(file): InferInsertModel<typeof medias> => ({
id: file.id,
creatorId: ctx.session.user.id,
content: file.content,
size: file.meta.size,
contentType: file.meta.type,
name: file.meta.name,
}),
);
await ctx.db.insert(medias).values(insertMedias);
const localIconRepository = await ctx.db.query.iconRepositories.findFirst({
where: eq(iconRepositories.slug, LOCAL_ICON_REPOSITORY_SLUG),
});
const ids = files.map((file) => file.id);
if (!localIconRepository) return ids;
await ctx.db.insert(icons).values(
insertMedias.map((media) => {
const icon = mapMediaToIcon(media);
return {
id: createId(),
checksum: icon.checksum,
name: icon.fileNameWithExtension,
url: icon.imageUrl,
iconRepositoryId: localIconRepository.id,
};
}),
);
return ids;
}),
deleteMedia: protectedProcedure.input(byIdSchema).mutation(async ({ ctx, input }) => {
const dbMedia = await ctx.db.query.medias.findFirst({
where: eq(medias.id, input.id),
columns: {
id: true,
creatorId: true,
},
});
if (!dbMedia) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Media not found",
});
}
// Only allow users with media-full-all permission and the creator of the media to delete it
if (!ctx.session.user.permissions.includes("media-full-all") && ctx.session.user.id !== dbMedia.creatorId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to delete this media",
});
}
await ctx.db.delete(medias).where(eq(medias.id, input.id));
await ctx.db.delete(icons).where(eq(icons.url, createLocalImageUrl(input.id)));
}),
});

View File

@@ -0,0 +1,81 @@
import { isProviderEnabled } from "@homarr/auth/server";
import { objectEntries } from "@homarr/common";
import type { MaybePromise } from "@homarr/common/types";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { groups, onboarding } from "@homarr/db/schema";
import type { OnboardingStep } from "@homarr/definitions";
import { credentialsAdminGroup } from "@homarr/definitions";
export const nextOnboardingStepAsync = async (db: Database, preferredStep: OnboardingStep | undefined) => {
const { current } = await getOnboardingOrFallbackAsync(db);
const nextStepConfiguration = nextSteps[current];
if (!nextStepConfiguration) return;
for (const conditionalStep of objectEntries(nextStepConfiguration)) {
if (!conditionalStep) continue;
const [nextStep, condition] = conditionalStep;
if (condition === "preferred" && nextStep !== preferredStep) continue;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (typeof condition === "boolean" && !condition) continue;
if (typeof condition === "function" && !(await condition(db))) continue;
await db.update(onboarding).set({
previousStep: current,
step: nextStep,
});
return;
}
};
export const getOnboardingOrFallbackAsync = async (db: Database) => {
const value = await db.query.onboarding.findFirst();
if (!value) return { current: "start" as const, previous: null };
return { current: value.step, previous: value.previousStep };
};
type NextStepCondition = true | "preferred" | ((db: Database) => MaybePromise<boolean>);
/**
* The below object is a definition of which can be the next step of the current one.
* If the value is `true`, it means the step can always be the next one.
* If the value is `preferred`, it means that the step can only be reached if the input `preferredStep` is set to the step.
* If the value is a function, it will be called with the database instance and should return a boolean.
* If the value or result is `false`, the step has to be skipped and the next value or callback should be checked.
*/
const nextSteps: Partial<Record<OnboardingStep, Partial<Record<OnboardingStep, NextStepCondition>>>> = {
start: {
import: "preferred" as const,
user: () => isProviderEnabled("credentials"),
group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
settings: true,
},
import: {
// eslint-disable-next-line no-restricted-syntax
user: async (db: Database) => {
if (!isProviderEnabled("credentials")) return false;
const adminGroup = await db.query.groups.findFirst({
where: eq(groups.name, credentialsAdminGroup),
with: {
members: true,
},
});
return !adminGroup || adminGroup.members.length === 0;
},
group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
settings: true,
},
user: {
group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
settings: true,
},
group: {
settings: true,
},
settings: {
finish: true,
},
};

View File

@@ -0,0 +1,36 @@
import { z } from "zod/v4";
import { onboarding } from "@homarr/db/schema";
import { onboardingSteps } from "@homarr/definitions";
import { zodEnumFromArray } from "@homarr/validation/enums";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries";
export const onboardRouter = createTRPCRouter({
currentStep: publicProcedure.query(async ({ ctx }) => {
return await getOnboardingOrFallbackAsync(ctx.db);
}),
nextStep: publicProcedure
.input(
z.object({
// Preferred step is only needed for 'preferred' conditions
preferredStep: zodEnumFromArray(onboardingSteps).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
await nextOnboardingStepAsync(ctx.db, input.preferredStep);
}),
previousStep: publicProcedure.mutation(async ({ ctx }) => {
const { previous } = await getOnboardingOrFallbackAsync(ctx.db);
if (previous !== "start") {
return;
}
await ctx.db.update(onboarding).set({
previousStep: null,
step: "start",
});
}),
});

View File

@@ -0,0 +1,219 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { asc, eq, like } from "@homarr/db";
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema";
import { createIntegrationAsync } from "@homarr/integrations";
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine";
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "searchEngineRouter" });
export const searchEngineRouter = createTRPCRouter({
getPaginated: protectedProcedure.input(paginatedSchema).query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(searchEngines.name, `%${input.search.trim()}%`) : undefined;
const searchEngineCount = await ctx.db.$count(searchEngines, whereQuery);
const dbSearachEngines = await ctx.db.query.searchEngines.findMany({
limit: input.pageSize,
offset: (input.page - 1) * input.pageSize,
where: whereQuery,
});
return {
items: dbSearachEngines,
totalCount: searchEngineCount,
};
}),
getSelectable: protectedProcedure
.input(z.object({ withIntegrations: z.boolean() }).default({ withIntegrations: true }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines
.findMany({
orderBy: asc(searchEngines.name),
where: input.withIntegrations ? undefined : eq(searchEngines.type, "generic"),
columns: {
id: true,
name: true,
},
})
.then((engines) => engines.map((engine) => ({ value: engine.id, label: engine.name })));
}),
byId: protectedProcedure.input(byIdSchema).query(async ({ ctx, input }) => {
const searchEngine = await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, input.id),
});
if (!searchEngine) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Search engine not found",
});
}
return searchEngine.type === "fromIntegration"
? {
...searchEngine,
type: "fromIntegration" as const,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
integrationId: searchEngine.integrationId!,
}
: {
...searchEngine,
type: "generic" as const,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
urlTemplate: searchEngine.urlTemplate!,
};
}),
getDefaultSearchEngine: publicProcedure.query(async ({ ctx }) => {
const userDefaultId = ctx.session?.user.id
? ((await ctx.db.query.users
.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: {
defaultSearchEngineId: true,
},
})
.then((user) => user?.defaultSearchEngineId)) ?? null)
: null;
if (userDefaultId) {
return await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, userDefaultId),
with: {
integration: {
columns: {
kind: true,
url: true,
id: true,
},
},
},
});
}
const searchSettings = await getServerSettingByKeyAsync(ctx.db, "search");
if (!searchSettings.defaultSearchEngineId) return null;
const serverDefault = await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, searchSettings.defaultSearchEngineId),
with: {
integration: {
columns: {
kind: true,
url: true,
id: true,
},
},
},
});
if (serverDefault) return serverDefault;
// Remove the default search engine ID from settings if it does not longer exist
try {
await updateServerSettingByKeyAsync(ctx.db, "search", {
...searchSettings,
defaultSearchEngineId: null,
});
} catch (error) {
logger.warn(
new Error("Failed to update search settings after default search engine not found", { cause: error }),
);
}
return null;
}),
search: protectedProcedure.input(searchSchema).query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines.findMany({
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
with: {
integration: {
columns: {
kind: true,
url: true,
id: true,
},
},
},
limit: input.limit,
});
}),
getMediaRequestOptions: protectedProcedure
.concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr"))
.input(mediaRequestOptionsSchema)
.query(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
}),
requestMedia: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr"))
.input(mediaRequestRequestSchema)
.mutation(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
}),
create: permissionRequiredProcedure
.requiresPermission("search-engine-create")
.input(searchEngineManageSchema)
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(searchEngines).values({
id: createId(),
name: input.name,
short: input.short.toLowerCase(),
iconUrl: input.iconUrl,
urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
description: input.description,
type: input.type,
integrationId: "integrationId" in input ? input.integrationId : null,
});
}),
update: permissionRequiredProcedure
.requiresPermission("search-engine-modify-all")
.input(searchEngineEditSchema)
.mutation(async ({ ctx, input }) => {
const searchEngine = await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, input.id),
});
if (!searchEngine) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Search engine not found",
});
}
await ctx.db
.update(searchEngines)
.set({
name: input.name,
iconUrl: input.iconUrl,
urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
description: input.description,
integrationId: "integrationId" in input ? input.integrationId : null,
type: input.type,
})
.where(eq(searchEngines.id, input.id));
}),
delete: permissionRequiredProcedure
.requiresPermission("search-engine-full-all")
.input(byIdSchema)
.mutation(async ({ ctx, input }) => {
await ctx.db
.update(users)
.set({
defaultSearchEngineId: null,
})
.where(eq(users.defaultSearchEngineId, input.id));
await ctx.db.delete(searchEngines).where(eq(searchEngines.id, input.id));
}),
});

View File

@@ -0,0 +1,52 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { and, eq } from "@homarr/db";
import { sectionCollapseStates, sections } from "@homarr/db/schema";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
export const sectionRouter = createTRPCRouter({
changeCollapsed: protectedProcedure
.input(
z.object({
sectionId: z.string(),
collapsed: z.boolean(),
}),
)
.mutation(async ({ ctx, input }) => {
const section = await ctx.db.query.sections.findFirst({
where: and(eq(sections.id, input.sectionId), eq(sections.kind, "category")),
with: {
collapseStates: {
where: eq(sectionCollapseStates.userId, ctx.session.user.id),
},
},
});
if (!section) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Section not found id=${input.sectionId}`,
});
}
if (section.collapseStates.length === 0) {
await ctx.db.insert(sectionCollapseStates).values({
sectionId: section.id,
userId: ctx.session.user.id,
collapsed: input.collapsed,
});
return;
}
await ctx.db
.update(sectionCollapseStates)
.set({
collapsed: input.collapsed,
})
.where(
and(eq(sectionCollapseStates.sectionId, section.id), eq(sectionCollapseStates.userId, ctx.session.user.id)),
);
}),
});

View File

@@ -0,0 +1,41 @@
import { z } from "zod/v4";
import { getServerSettingByKeyAsync, getServerSettingsAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings";
import { settingsInitSchema } from "@homarr/validation/settings";
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, publicProcedure } from "../trpc";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
export const serverSettingsRouter = createTRPCRouter({
getCulture: publicProcedure.query(async ({ ctx }) => {
return await getServerSettingByKeyAsync(ctx.db, "culture");
}),
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
return await getServerSettingsAsync(ctx.db);
}),
saveSettings: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({
settingsKey: z.enum(defaultServerSettingsKeys),
value: z.record(z.string(), z.unknown()),
}),
)
.mutation(async ({ ctx, input }) => {
await updateServerSettingByKeyAsync(
ctx.db,
input.settingsKey,
input.value as ServerSettings[keyof ServerSettings],
);
}),
initSettings: onboardingProcedure
.requiresStep("settings")
.input(settingsInitSchema)
.mutation(async ({ ctx, input }) => {
await updateServerSettingByKeyAsync(ctx.db, "analytics", input.analytics);
await updateServerSettingByKeyAsync(ctx.db, "crawlingAndIndexing", input.crawlingAndIndexing);
await nextOnboardingStepAsync(ctx.db, undefined);
}),
});

View File

@@ -0,0 +1,297 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { apps } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import { appRouter } from "../app";
import * as appAccessControl from "../app/app-access-control";
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
const createDefaultSession = (permissions: GroupPermissionKey[] = []): Session => ({
user: { id: createId(), permissions, colorScheme: "light" },
expires: new Date().toISOString(),
});
describe("all should return all apps", () => {
test("should return all apps with session", async () => {
// Arrange
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(),
});
await db.insert(apps).values([
{
id: "2",
name: "Mantine",
description: "React components and hooks library",
iconUrl: "https://mantine.dev/favicon.svg",
href: "https://mantine.dev",
},
{
id: "1",
name: "Tabler Icons",
iconUrl: "https://tabler.io/favicon.ico",
},
]);
const result = await caller.all();
expect(result.length).toBe(2);
expect(result[0]!.id).toBe("2");
expect(result[1]!.id).toBe("1");
expect(result[0]!.href).toBeDefined();
expect(result[0]!.description).toBeDefined();
expect(result[1]!.href).toBeNull();
expect(result[1]!.description).toBeNull();
});
test("should throw UNAUTHORIZED if the user is not authenticated", async () => {
// Arrange
const caller = appRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: null,
});
// Act
const actAsync = async () => await caller.all();
// Assert
await expect(actAsync()).rejects.toThrow("UNAUTHORIZED");
});
});
describe("byId should return an app by id", () => {
test("should return an app by id when canUserSeeAppAsync returns true", async () => {
// Arrange
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
vi.spyOn(appAccessControl, "canUserSeeAppAsync").mockReturnValue(Promise.resolve(true));
await db.insert(apps).values([
{
id: "2",
name: "Mantine",
description: "React components and hooks library",
iconUrl: "https://mantine.dev/favicon.svg",
href: "https://mantine.dev",
},
{
id: "1",
name: "Tabler Icons",
iconUrl: "https://tabler.io/favicon.ico",
},
]);
// Act
const result = await caller.byId({ id: "2" });
// Assert
expect(result.name).toBe("Mantine");
});
test("should throw NOT_FOUND error when canUserSeeAppAsync returns false", async () => {
// Arrange
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
await db.insert(apps).values([
{
id: "2",
name: "Mantine",
description: "React components and hooks library",
iconUrl: "https://mantine.dev/favicon.svg",
href: "https://mantine.dev",
},
]);
vi.spyOn(appAccessControl, "canUserSeeAppAsync").mockReturnValue(Promise.resolve(false));
// Act
const actAsync = async () => await caller.byId({ id: "2" });
// Assert
await expect(actAsync()).rejects.toThrow("App not found");
});
test("should throw an error if the app does not exist", async () => {
// Arrange
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
// Act
const actAsync = async () => await caller.byId({ id: "2" });
// Assert
await expect(actAsync()).rejects.toThrow("App not found");
});
});
describe("create should create a new app with all arguments", () => {
test("should create a new app", async () => {
// Arrange
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-create"]),
});
const input = {
name: "Mantine",
description: "React components and hooks library",
iconUrl: "https://mantine.dev/favicon.svg",
href: "https://mantine.dev",
pingUrl: "https://mantine.dev/a",
};
// Act
await caller.create(input);
// Assert
const dbApp = await db.query.apps.findFirst();
expect(dbApp).toBeDefined();
expect(dbApp!.name).toBe(input.name);
expect(dbApp!.description).toBe(input.description);
expect(dbApp!.iconUrl).toBe(input.iconUrl);
expect(dbApp!.href).toBe(input.href);
expect(dbApp!.pingUrl).toBe(input.pingUrl);
});
test("should create a new app only with required arguments", async () => {
// Arrange
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-create"]),
});
const input = {
name: "Mantine",
description: null,
iconUrl: "https://mantine.dev/favicon.svg",
href: null,
pingUrl: "",
};
// Act
await caller.create(input);
// Assert
const dbApp = await db.query.apps.findFirst();
expect(dbApp).toBeDefined();
expect(dbApp!.name).toBe(input.name);
expect(dbApp!.description).toBe(input.description);
expect(dbApp!.iconUrl).toBe(input.iconUrl);
expect(dbApp!.href).toBe(input.href);
expect(dbApp!.pingUrl).toBe(null);
});
});
describe("update should update an app", () => {
test("should update an app", async () => {
// Arrange
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-modify-all"]),
});
const appId = createId();
const toInsert = {
id: appId,
name: "Mantine",
iconUrl: "https://mantine.dev/favicon.svg",
};
await db.insert(apps).values(toInsert);
const input = {
id: appId,
name: "Mantine2",
description: "React components and hooks library",
iconUrl: "https://mantine.dev/favicon.svg2",
href: "https://mantine.dev",
pingUrl: "https://mantine.dev/a",
};
// Act
await caller.update(input);
// Assert
const dbApp = await db.query.apps.findFirst();
expect(dbApp).toBeDefined();
expect(dbApp!.name).toBe(input.name);
expect(dbApp!.description).toBe(input.description);
expect(dbApp!.iconUrl).toBe(input.iconUrl);
expect(dbApp!.href).toBe(input.href);
});
test("should throw an error if the app does not exist", async () => {
// Arrange
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-modify-all"]),
});
// Act
const actAsync = async () =>
await caller.update({
id: createId(),
name: "Mantine",
iconUrl: "https://mantine.dev/favicon.svg",
description: null,
href: null,
pingUrl: "",
});
// Assert
await expect(actAsync()).rejects.toThrow("App not found");
});
});
describe("delete should delete an app", () => {
test("should delete an app", async () => {
// Arrange
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-full-all"]),
});
const appId = createId();
await db.insert(apps).values({
id: appId,
name: "Mantine",
iconUrl: "https://mantine.dev/favicon.svg",
});
// Act
await caller.delete({ id: appId });
// Assert
const dbApp = await db.query.apps.findFirst();
expect(dbApp).toBeUndefined();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
import { describe, expect, test, vi } from "vitest";
import * as authShared from "@homarr/auth/shared";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import { boards, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { throwIfActionForbiddenAsync } from "../../board/board-access";
const defaultCreatorId = createId();
const expectActToBeAsync = async (act: () => Promise<void>, success: boolean) => {
if (!success) {
await expect(act()).rejects.toThrow("Board not found");
return;
}
await expect(act()).resolves.toBeUndefined();
};
describe("throwIfActionForbiddenAsync should check access to board and return boolean", () => {
test.each([
["full" as const, true],
["modify" as const, true],
["view" as const, true],
])("with permission %s should return %s when hasFullAccess is true", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructBoardPermissions");
spy.mockReturnValue({
hasFullAccess: true,
hasChangeAccess: false,
hasViewAccess: false,
});
await db.insert(users).values({ id: defaultCreatorId });
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "test",
creatorId: defaultCreatorId,
});
// Act
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, boardId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test.each([
["full" as const, false],
["modify" as const, true],
["view" as const, true],
])("with permission %s should return %s when hasChangeAccess is true", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructBoardPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasChangeAccess: true,
hasViewAccess: false,
});
await db.insert(users).values({ id: defaultCreatorId });
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "test",
creatorId: defaultCreatorId,
});
// Act
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, boardId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test.each([
["full" as const, false],
["modify" as const, false],
["view" as const, true],
])("with permission %s should return %s when hasViewAccess is true", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructBoardPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasChangeAccess: false,
hasViewAccess: true,
});
await db.insert(users).values({ id: defaultCreatorId });
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "test",
creatorId: defaultCreatorId,
});
// Act
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, boardId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test.each([
["full" as const, false],
["modify" as const, false],
["view" as const, false],
])("with permission %s should return %s when hasViewAccess is false", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructBoardPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasChangeAccess: false,
hasViewAccess: false,
});
await db.insert(users).values({ id: defaultCreatorId });
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "test",
creatorId: defaultCreatorId,
});
// Act
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, boardId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test("should throw when board is not found", async () => {
// Arrange
const db = createDb();
// Act
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, createId()), "full");
// Assert
await expect(act()).rejects.toThrow("Board not found");
});
});

View File

@@ -0,0 +1,115 @@
import { TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { objectKeys } from "@homarr/common";
import type { Database } from "@homarr/db";
import type { GroupPermissionKey } from "@homarr/definitions";
import { getPermissionsWithChildren } from "@homarr/definitions";
import type { RouterInputs } from "../../..";
import { dockerRouter } from "../../docker/docker-router";
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
vi.mock("@homarr/request-handler/docker", () => ({
dockerContainersRequestHandler: {
handler: () => ({
getCachedOrUpdatedDataAsync: async () => {
return await Promise.resolve({ containers: [] });
},
invalidateAsync: async () => {
return await Promise.resolve();
},
}),
},
}));
vi.mock("@homarr/redis", () => ({
createCacheChannel: () => ({
// eslint-disable-next-line @typescript-eslint/require-await
consumeAsync: async () => ({
timestamp: new Date().toISOString(),
data: { containers: [] },
}),
// eslint-disable-next-line @typescript-eslint/no-empty-function
invalidateAsync: async () => {},
}),
createWidgetOptionsChannel: () => ({}),
}));
vi.mock("@homarr/docker/env", () => ({
env: {
ENABLE_DOCKER: true,
},
}));
const createSessionWithPermissions = (...permissions: GroupPermissionKey[]) =>
({
user: {
id: "1",
permissions,
colorScheme: "light",
},
expires: new Date().toISOString(),
}) satisfies Session;
const procedureKeys = objectKeys(dockerRouter._def.procedures);
const validInputs: {
[key in (typeof procedureKeys)[number]]: RouterInputs["docker"][key];
} = {
getContainers: undefined,
subscribeContainers: undefined,
startAll: { ids: ["1"] },
stopAll: { ids: ["1"] },
restartAll: { ids: ["1"] },
removeAll: { ids: ["1"] },
invalidate: undefined,
};
describe("All procedures should only be accessible for users with admin permission", () => {
test.each(procedureKeys)("Procedure %s should be accessible for users with admin permission", async (procedure) => {
// Arrange
const caller = dockerRouter.createCaller({
db: null as unknown as Database,
deviceType: undefined,
session: createSessionWithPermissions("admin"),
});
// Act
const act = () => caller[procedure](validInputs[procedure] as never);
await expect(act()).resolves.not.toThrow();
});
test.each(procedureKeys)("Procedure %s should not be accessible with other permissions", async (procedure) => {
// Arrange
const groupPermissionsWithoutAdmin = getPermissionsWithChildren(["admin"]).filter(
(permission) => permission !== "admin",
);
const caller = dockerRouter.createCaller({
db: null as unknown as Database,
deviceType: undefined,
session: createSessionWithPermissions(...groupPermissionsWithoutAdmin),
});
// Act
const act = () => caller[procedure](validInputs[procedure] as never);
await expect(act()).rejects.toThrow(new TRPCError({ code: "FORBIDDEN", message: "Permission denied" }));
});
test.each(procedureKeys)("Procedure %s should not be accessible without session", async (procedure) => {
// Arrange
const caller = dockerRouter.createCaller({
db: null as unknown as Database,
deviceType: undefined,
session: null,
});
// Act
const act = () => caller[procedure](validInputs[procedure] as never);
await expect(act()).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" }));
});
});

View File

@@ -0,0 +1,886 @@
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import * as env from "@homarr/auth/env";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import { groupRouter } from "../group";
const defaultOwnerId = createId();
const createSession = (permissions: GroupPermissionKey[]) =>
({
user: {
id: defaultOwnerId,
permissions,
colorScheme: "light",
},
expires: new Date().toISOString(),
}) satisfies Session;
const defaultSession = createSession([]);
const adminSession = createSession(["admin"]);
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", async () => {
const mod = await import("@homarr/auth/security");
return { ...mod, auth: () => ({}) as Session };
});
describe("paginated should return a list of groups with pagination", () => {
test.each([
[1, 3],
[2, 2],
])(
"with 5 groups in database and pageSize set to 3 on page %s it should return %s groups",
async (page, expectedCount) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values(
[1, 2, 3, 4, 5].map((number) => ({
id: number.toString(),
name: `Group ${number}`,
position: number,
})),
);
// Act
const result = await caller.getPaginated({
page,
pageSize: 3,
});
// Assert
expect(result.items.length).toBe(expectedCount);
},
);
test("with 5 groups in database and pagesize set to 3 it should return total count 5", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values(
[1, 2, 3, 4, 5].map((number) => ({
id: number.toString(),
name: `Group ${number}`,
position: number,
})),
);
// Act
const result = await caller.getPaginated({
pageSize: 3,
});
// Assert
expect(result.totalCount).toBe(5);
});
test("groups should contain id, name, email and image of members", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const user = createDummyUser();
await db.insert(users).values(user);
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "Group",
position: 1,
});
await db.insert(groupMembers).values({
groupId,
userId: user.id,
});
// Act
const result = await caller.getPaginated({});
// Assert
const item = result.items[0];
expect(item).toBeDefined();
expect(item?.members.length).toBe(1);
const userKeys = Object.keys(item?.members[0] ?? {});
expect(userKeys.length).toBe(4);
expect(["id", "name", "email", "image"].some((key) => userKeys.includes(key)));
});
test.each([
[undefined, 5, "first"],
["d", 2, "second"],
["th", 3, "third"],
["fi", 2, "first"],
])(
"groups should be searchable by name with contains pattern, query %s should result in %s results",
async (query, expectedCount, firstKey) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values(
["first", "second", "third", "forth", "fifth"].map((key, index) => ({
id: index.toString(),
name: key,
position: index + 1,
})),
);
// Act
const result = await caller.getPaginated({
search: query,
});
// Assert
expect(result.totalCount).toBe(expectedCount);
expect(result.items.at(0)?.name).toBe(firstKey);
},
);
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.getPaginated({});
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
});
describe("byId should return group by id including members and permissions", () => {
test('should return group with id "1" with members and permissions', async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const user = createDummyUser();
const groupId = "1";
await db.insert(users).values(user);
await db.insert(groups).values([
{
id: groupId,
name: "Group",
position: 1,
},
{
id: createId(),
name: "Another group",
position: 2,
},
]);
await db.insert(groupMembers).values({
userId: user.id,
groupId,
});
await db.insert(groupPermissions).values({
groupId,
permission: "admin",
});
// Act
const result = await caller.getById({
id: groupId,
});
// Assert
expect(result.id).toBe(groupId);
expect(result.members.length).toBe(1);
const userKeys = Object.keys(result.members[0] ?? {});
expect(userKeys.length).toBe(5);
expect(["id", "name", "email", "image", "provider"].some((key) => userKeys.includes(key)));
expect(result.permissions.length).toBe(1);
expect(result.permissions[0]).toBe("admin");
});
test("with group id 1 and group 2 in database it should throw NOT_FOUND error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: "2",
name: "Group",
position: 1,
});
// Act
const actAsync = async () => await caller.getById({ id: "1" });
// Assert
await expect(actAsync()).rejects.toThrow("Group not found");
});
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.getById({ id: "1" });
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
});
describe("create should create group in database", () => {
test("with valid input (64 character name) and non existing name it should be successful", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const name = "a".repeat(64);
await db.insert(users).values(defaultSession.user);
// Act
const result = await caller.createGroup({
name,
});
// Assert
const item = await db.query.groups.findFirst({
where: eq(groups.id, result),
});
expect(item).toBeDefined();
expect(item?.id).toBe(result);
expect(item?.ownerId).toBe(defaultOwnerId);
expect(item?.name).toBe(name);
});
test("with more than 64 characters name it should fail while validation", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const longName = "a".repeat(65);
// Act
const actAsync = async () =>
await caller.createGroup({
name: longName,
});
// Assert
await expect(actAsync()).rejects.toThrow("too_big");
});
test.each([
["test", "Test"],
["test", "Test "],
["test", "test"],
["test", " TeSt"],
])("with similar name %s it should fail to create %s", async (similarName, nameToCreate) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
name: similarName,
position: 1,
});
// Act
const actAsync = async () => await caller.createGroup({ name: nameToCreate });
// Assert
await expect(actAsync()).rejects.toThrow("similar name");
});
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.createGroup({ name: "test" });
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
});
describe("update should update name with value that is no duplicate", () => {
test.each([
["first", "second ", "second"],
["first", " first", "first"],
])("update should update name from %s to %s normalized", async (initialValue, updateValue, expectedValue) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values([
{
id: groupId,
name: initialValue,
position: 1,
},
{
id: createId(),
name: "Third",
position: 2,
},
]);
// Act
await caller.updateGroup({
id: groupId,
name: updateValue,
});
// Assert
const value = await db.query.groups.findFirst({
where: eq(groups.id, groupId),
});
expect(value?.name).toBe(expectedValue);
});
test.each([
["Second ", "second"],
[" seCond", "second"],
])("with similar name %s it should fail to update %s", async (updateValue, initialDuplicate) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values([
{
id: groupId,
name: "Something",
position: 1,
},
{
id: createId(),
name: initialDuplicate,
position: 2,
},
]);
// Act
const actAsync = async () =>
await caller.updateGroup({
id: groupId,
name: updateValue,
});
// Assert
await expect(actAsync()).rejects.toThrow("similar name");
});
test("with non existing id it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
name: "something",
position: 1,
});
// Act
const act = () =>
caller.updateGroup({
id: createId(),
name: "something else",
});
// Assert
await expect(act()).rejects.toThrow("Group not found");
});
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
await caller.updateGroup({
id: createId(),
name: "test",
});
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
});
describe("savePermissions should save permissions for group", () => {
test("with existing group and permissions it should save permissions", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "Group",
position: 1,
});
await db.insert(groupPermissions).values({
groupId,
permission: "admin",
});
// Act
await caller.savePermissions({
groupId,
permissions: ["integration-use-all", "board-full-all"],
});
// Assert
const permissions = await db.query.groupPermissions.findMany({
where: eq(groupPermissions.groupId, groupId),
});
expect(permissions.length).toBe(2);
expect(permissions.map(({ permission }) => permission)).toEqual(["integration-use-all", "board-full-all"]);
});
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
name: "Group",
position: 1,
});
// Act
const actAsync = async () =>
await caller.savePermissions({
groupId: createId(),
permissions: ["integration-create", "board-full-all"],
});
// Assert
await expect(actAsync()).rejects.toThrow("Group not found");
});
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
await caller.savePermissions({
groupId: createId(),
permissions: ["integration-create", "board-full-all"],
});
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
});
describe("transferOwnership should transfer ownership of group", () => {
test("with existing group and user it should transfer ownership", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const newUserId = createId();
await db.insert(users).values([
{
id: newUserId,
name: "New user",
},
{
id: defaultOwnerId,
name: "Old user",
},
]);
await db.insert(groups).values({
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
position: 1,
});
// Act
await caller.transferOwnership({
groupId,
userId: newUserId,
});
// Assert
const group = await db.query.groups.findFirst({
where: eq(groups.id, groupId),
});
expect(group?.ownerId).toBe(newUserId);
});
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
name: "Group",
position: 1,
});
// Act
const actAsync = async () =>
await caller.transferOwnership({
groupId: createId(),
userId: createId(),
});
// Assert
await expect(actAsync()).rejects.toThrow("Group not found");
});
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
await caller.transferOwnership({
groupId: createId(),
userId: createId(),
});
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
});
describe("deleteGroup should delete group", () => {
test("with existing group it should delete group", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values([
{
id: groupId,
name: "Group",
position: 1,
},
{
id: createId(),
name: "Another group",
position: 2,
},
]);
// Act
await caller.deleteGroup({
id: groupId,
});
// Assert
const dbGroups = await db.query.groups.findMany();
expect(dbGroups.length).toBe(1);
expect(dbGroups[0]?.id).not.toBe(groupId);
});
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
name: "Group",
position: 1,
});
// Act
const actAsync = async () =>
await caller.deleteGroup({
id: createId(),
});
// Assert
await expect(actAsync()).rejects.toThrow("Group not found");
});
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
await caller.deleteGroup({
id: createId(),
});
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
});
describe("addMember should add member to group", () => {
test("with existing group and user it should add member", async () => {
// Arrange
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never);
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
await db.insert(users).values([
{
id: userId,
name: "User",
},
{
id: defaultOwnerId,
name: "Creator",
},
]);
await db.insert(groups).values({
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
position: 1,
});
// Act
await caller.addMember({
groupId,
userId,
});
// Assert
const members = await db.query.groupMembers.findMany({
where: eq(groupMembers.groupId, groupId),
});
expect(members.length).toBe(1);
expect(members[0]?.userId).toBe(userId);
});
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(users).values({
id: createId(),
name: "User",
});
// Act
const actAsync = async () =>
await caller.addMember({
groupId: createId(),
userId: createId(),
});
// Assert
await expect(actAsync()).rejects.toThrow("Group not found");
});
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
await caller.addMember({
groupId: createId(),
userId: createId(),
});
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
test("without credentials provider it should throw FORBIDDEN error", async () => {
// Arrange
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never);
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
await db.insert(users).values([
{
id: userId,
name: "User",
},
{
id: defaultOwnerId,
name: "Creator",
},
]);
await db.insert(groups).values({
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
position: 1,
});
// Act
const actAsync = async () =>
await caller.addMember({
groupId,
userId,
});
// Assert
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
});
});
describe("removeMember should remove member from group", () => {
test("with existing group and user it should remove member", async () => {
// Arrange
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never);
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
await db.insert(users).values([
{
id: userId,
name: "User",
},
{
id: defaultOwnerId,
name: "Creator",
},
]);
await db.insert(groups).values({
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
position: 1,
});
await db.insert(groupMembers).values({
groupId,
userId,
});
// Act
await caller.removeMember({
groupId,
userId,
});
// Assert
const members = await db.query.groupMembers.findMany({
where: eq(groupMembers.groupId, groupId),
});
expect(members.length).toBe(0);
});
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(users).values({
id: createId(),
name: "User",
});
// Act
const actAsync = async () =>
await caller.removeMember({
groupId: createId(),
userId: createId(),
});
// Assert
await expect(actAsync()).rejects.toThrow("Group not found");
});
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
await caller.removeMember({
groupId: createId(),
userId: createId(),
});
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
test("without credentials provider it should throw FORBIDDEN error", async () => {
// Arrange
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never);
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
await db.insert(users).values([
{
id: userId,
name: "User",
},
{
id: defaultOwnerId,
name: "Creator",
},
]);
await db.insert(groups).values({
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
position: 1,
});
await db.insert(groupMembers).values({
groupId,
userId,
});
// Act
const actAsync = async () =>
await caller.removeMember({
groupId,
userId,
});
// Assert
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
});
});
const createDummyUser = () => ({
id: createId(),
name: "username",
email: "user@gmail.com",
image: "example",
password: "secret",
salt: "secret",
});

View File

@@ -0,0 +1,11 @@
import { expect } from "vitest";
export const expectToBeDefined = <T>(value: T) => {
if (value === undefined) {
expect(value).toBeDefined();
}
if (value === null) {
expect(value).not.toBeNull();
}
return value as Exclude<T, undefined | null>;
};

View File

@@ -0,0 +1,156 @@
import { describe, expect, test, vi } from "vitest";
import * as authShared from "@homarr/auth/shared";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import { integrations, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { throwIfActionForbiddenAsync } from "../../integration/integration-access";
const defaultCreatorId = createId();
const expectActToBeAsync = async (act: () => Promise<void>, success: boolean) => {
if (!success) {
await expect(act()).rejects.toThrow("Integration not found");
return;
}
await expect(act()).resolves.toBeUndefined();
};
describe("throwIfActionForbiddenAsync should check access to integration and return boolean", () => {
test.each([
["full" as const, true],
["interact" as const, true],
["use" as const, true],
])("with permission %s should return %s when hasFullAccess is true", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: true,
hasInteractAccess: false,
hasUseAccess: false,
});
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "test",
kind: "adGuardHome",
url: "http://localhost:3000",
});
// Act
const act = () =>
throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test.each([
["full" as const, false],
["interact" as const, true],
["use" as const, true],
])("with permission %s should return %s when hasInteractAccess is true", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasInteractAccess: true,
hasUseAccess: false,
});
await db.insert(users).values({ id: defaultCreatorId });
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "test",
kind: "adGuardHome",
url: "http://localhost:3000",
});
// Act
const act = () =>
throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test.each([
["full" as const, false],
["interact" as const, false],
["use" as const, true],
])("with permission %s should return %s when hasUseAccess is true", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasInteractAccess: false,
hasUseAccess: true,
});
await db.insert(users).values({ id: defaultCreatorId });
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "test",
kind: "adGuardHome",
url: "http://localhost:3000",
});
// Act
const act = () =>
throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test.each([
["full" as const, false],
["interact" as const, false],
["use" as const, false],
])("with permission %s should return %s when hasUseAccess is false", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasInteractAccess: false,
hasUseAccess: false,
});
await db.insert(users).values({ id: defaultCreatorId });
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "test",
kind: "adGuardHome",
url: "http://localhost:3000",
});
// Act
const act = () =>
throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test("should throw when integration is not found", async () => {
// Arrange
const db = createDb();
// Act
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, createId()), "full");
// Assert
await expect(act()).rejects.toThrow("Integration not found");
});
});

View File

@@ -0,0 +1,580 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { apps, integrations, integrationSecrets } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import { integrationRouter } from "../../integration/integration-router";
import { expectToBeDefined } from "../helper";
const defaultUserId = createId();
const defaultSessionWithPermissions = (permissions: GroupPermissionKey[] = []) =>
({
user: {
id: defaultUserId,
permissions,
colorScheme: "light",
},
expires: new Date().toISOString(),
}) satisfies Session;
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
vi.mock("../../integration/integration-test-connection", () => ({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
}));
describe("all should return all integrations", () => {
test("with any session should return all integrations", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values([
{
id: "1",
name: "Home assistant",
kind: "homeAssistant",
url: "http://homeassist.local",
},
{
id: "2",
name: "Home plex server",
kind: "plex",
url: "http://plex.local",
},
]);
const result = await caller.all();
expect(result.length).toBe(2);
expect(result[0]!.kind).toBe("plex");
expect(result[1]!.kind).toBe("homeAssistant");
});
});
describe("byId should return an integration by id", () => {
test("with full access should return an integration by id", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
await db.insert(integrations).values([
{
id: "1",
name: "Home assistant",
kind: "homeAssistant",
url: "http://homeassist.local",
},
{
id: "2",
name: "Home plex server",
kind: "plex",
url: "http://plex.local",
},
]);
const result = await caller.byId({ id: "2" });
expect(result.kind).toBe("plex");
});
test("with full access should throw an error if the integration does not exist", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
const actAsync = async () => await caller.byId({ id: "2" });
await expect(actAsync()).rejects.toThrow("Integration not found");
});
test("with full access should only return the public secret values", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
await db.insert(integrations).values([
{
id: "1",
name: "Home assistant",
kind: "homeAssistant",
url: "http://homeassist.local",
},
]);
await db.insert(integrationSecrets).values([
{
kind: "username",
value: encryptSecret("musterUser"),
integrationId: "1",
updatedAt: new Date(),
},
{
kind: "password",
value: encryptSecret("Password123!"),
integrationId: "1",
updatedAt: new Date(),
},
{
kind: "apiKey",
value: encryptSecret("1234567890"),
integrationId: "1",
updatedAt: new Date(),
},
]);
const result = await caller.byId({ id: "1" });
expect(result.secrets.length).toBe(3);
const username = expectToBeDefined(result.secrets.find((secret) => secret.kind === "username"));
expect(username.value).not.toBeNull();
const password = expectToBeDefined(result.secrets.find((secret) => secret.kind === "password"));
expect(password.value).toBeNull();
const apiKey = expectToBeDefined(result.secrets.find((secret) => secret.kind === "apiKey"));
expect(apiKey.value).toBeNull();
});
test("without full access should throw integration not found error", async () => {
// Arrange
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
await db.insert(integrations).values([
{
id: "1",
name: "Home assistant",
kind: "homeAssistant",
url: "http://homeassist.local",
},
]);
// Act
const actAsync = async () => await caller.byId({ id: "1" });
// Assert
await expect(actAsync()).rejects.toThrow("Integration not found");
});
});
describe("create should create a new integration", () => {
test("with create integration access should create a new integration", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
url: "http://jellyfin.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: false,
};
const fakeNow = new Date("2023-07-01T00:00:00Z");
vi.useFakeTimers();
vi.setSystemTime(fakeNow);
await caller.create(input);
vi.useRealTimers();
const dbIntegration = await db.query.integrations.findFirst();
const dbSecret = await db.query.integrationSecrets.findFirst();
expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow);
});
test("with create integration access should create a new integration when creating search engine", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
const input = {
name: "Jellyseerr",
kind: "jellyseerr" as const,
url: "http://jellyseerr.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: true,
};
const fakeNow = new Date("2023-07-01T00:00:00Z");
vi.useFakeTimers();
vi.setSystemTime(fakeNow);
await caller.create(input);
vi.useRealTimers();
const dbIntegration = await db.query.integrations.findFirst();
const dbSecret = await db.query.integrationSecrets.findFirst();
const dbSearchEngine = await db.query.searchEngines.findFirst();
expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow);
expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id);
expect(dbSearchEngine!.short).toBe("j");
expect(dbSearchEngine!.name).toBe(input.name);
expect(dbSearchEngine!.iconUrl).toBe(
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
);
});
test("with create integration access should create a new integration with new linked app", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create", "app-create"]),
});
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
url: "http://jellyfin.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: false,
app: {
name: "Jellyfin",
description: null,
pingUrl: "http://jellyfin.local",
href: "https://jellyfin.home",
iconUrl: "logo.png",
},
};
const fakeNow = new Date("2023-07-01T00:00:00Z");
vi.useFakeTimers();
vi.setSystemTime(fakeNow);
await caller.create(input);
vi.useRealTimers();
const dbIntegration = await db.query.integrations.findFirst({
with: {
app: true,
},
});
const dbSecret = await db.query.integrationSecrets.findFirst();
expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.app!.name).toBe(input.app.name);
expect(dbIntegration!.app!.pingUrl).toBe(input.app.pingUrl);
expect(dbIntegration!.app!.href).toBe(input.app.href);
expect(dbIntegration!.app!.iconUrl).toBe(input.app.iconUrl);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow);
});
test("with create integration access should create a new integration with existing linked app", async () => {
const db = createDb();
const appId = createId();
await db.insert(apps).values({
id: appId,
name: "Existing Jellyfin",
iconUrl: "logo.png",
});
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
url: "http://jellyfin.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: false,
app: {
id: appId,
},
};
const fakeNow = new Date("2023-07-01T00:00:00Z");
vi.useFakeTimers();
vi.setSystemTime(fakeNow);
await caller.create(input);
vi.useRealTimers();
const dbIntegration = await db.query.integrations.findFirst();
const dbSecret = await db.query.integrationSecrets.findFirst();
expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.appId).toBe(appId);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow);
});
test("without create integration access should throw permission error", async () => {
// Arrange
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
url: "http://jellyfin.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: false,
};
// Act
const actAsync = async () => await caller.create(input);
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
test("without create app access should throw permission error with new linked app", async () => {
// Arrange
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
url: "http://jellyfin.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: false,
app: {
name: "Jellyfin",
description: null,
href: "https://jellyfin.home",
iconUrl: "logo.png",
pingUrl: null,
},
};
// Act
const actAsync = async () => await caller.create(input);
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
});
describe("update should update an integration", () => {
test("with full access should update an integration", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
const lastWeek = new Date("2023-06-24T00:00:00Z");
const appId = createId();
const integrationId = createId();
const toInsert = {
id: integrationId,
name: "Pi Hole",
kind: "piHole" as const,
url: "http://hole.local",
};
await db.insert(apps).values({
id: appId,
name: "Previous",
iconUrl: "logo.png",
});
await db.insert(integrations).values(toInsert);
const usernameToInsert = {
kind: "username" as const,
value: encryptSecret("musterUser"),
integrationId,
updatedAt: lastWeek,
};
const passwordToInsert = {
kind: "password" as const,
value: encryptSecret("Password123!"),
integrationId,
updatedAt: lastWeek,
};
await db.insert(integrationSecrets).values([usernameToInsert, passwordToInsert]);
const input = {
id: integrationId,
name: "Milky Way Pi Hole",
kind: "piHole" as const,
url: "http://milkyway.local",
secrets: [
{ kind: "username" as const, value: "newUser" },
{ kind: "password" as const, value: null },
{ kind: "apiKey" as const, value: "1234567890" },
],
appId,
};
const fakeNow = new Date("2023-07-01T00:00:00Z");
vi.useFakeTimers();
vi.setSystemTime(fakeNow);
await caller.update(input);
vi.useRealTimers();
const dbIntegration = await db.query.integrations.findFirst();
const dbSecrets = await db.query.integrationSecrets.findMany();
expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.appId).toBe(appId);
expect(dbSecrets.length).toBe(3);
const username = expectToBeDefined(dbSecrets.find((secret) => secret.kind === "username"));
const password = expectToBeDefined(dbSecrets.find((secret) => secret.kind === "password"));
const apiKey = expectToBeDefined(dbSecrets.find((secret) => secret.kind === "apiKey"));
expect(username.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(password.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(apiKey.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(username.updatedAt).toEqual(fakeNow);
expect(password.updatedAt).toEqual(lastWeek);
expect(apiKey.updatedAt).toEqual(fakeNow);
expect(username.value).not.toEqual(usernameToInsert.value);
expect(password.value).toEqual(passwordToInsert.value);
expect(apiKey.value).not.toEqual(input.secrets[2]!.value);
});
test("with full access should throw an error if the integration does not exist", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
const actAsync = async () =>
await caller.update({
id: createId(),
name: "Pi Hole",
url: "http://hole.local",
secrets: [],
appId: null,
});
await expect(actAsync()).rejects.toThrow("Integration not found");
});
test("without full access should throw permission error", async () => {
// Arrange
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
// Act
const actAsync = async () =>
await caller.update({
id: createId(),
name: "Pi Hole",
url: "http://hole.local",
secrets: [],
appId: null,
});
// Assert
await expect(actAsync()).rejects.toThrow("Integration not found");
});
});
describe("delete should delete an integration", () => {
test("with full access should delete an integration", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "Home assistant",
kind: "homeAssistant",
url: "http://homeassist.local",
});
await db.insert(integrationSecrets).values([
{
kind: "username",
value: encryptSecret("example"),
integrationId,
updatedAt: new Date(),
},
]);
await caller.delete({ id: integrationId });
const dbIntegration = await db.query.integrations.findFirst();
expect(dbIntegration).toBeUndefined();
const dbSecrets = await db.query.integrationSecrets.findMany();
expect(dbSecrets.length).toBe(0);
});
test("without full access should throw permission error", async () => {
// Arrange
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
// Act
const actAsync = async () => await caller.delete({ id: createId() });
// Assert
await expect(actAsync()).rejects.toThrow("Integration not found");
});
});

View File

@@ -0,0 +1,347 @@
import { describe, expect, test, vi } from "vitest";
import * as homarrDefinitions from "@homarr/definitions";
import * as homarrIntegrations from "@homarr/integrations";
import { testConnectionAsync } from "../../integration/integration-test-connection";
vi.mock("@homarr/common/server", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/common/server")>();
return {
...actual,
decryptSecret: (value: string) => value.split(".")[0],
};
});
describe("testConnectionAsync should run test connection of integration", () => {
test("with input of only form secrets matching api key kind it should use form apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["apiKey"]]);
const integration = {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole" as const,
secrets: [
{
kind: "apiKey" as const,
value: "secret",
},
],
};
// Act
await testConnectionAsync(integration);
// Assert
expect(factorySpy).toHaveBeenCalledWith({
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole",
decryptedSecrets: [
expect.objectContaining({
kind: "apiKey",
value: "secret",
}),
],
externalUrl: null,
});
});
test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["apiKey"]]);
const integration = {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole" as const,
secrets: [
{
kind: "apiKey" as const,
value: null,
},
],
};
const dbSecrets = [
{
kind: "apiKey" as const,
value: "dbSecret.encrypted" as const,
},
];
// Act
await testConnectionAsync(integration, dbSecrets);
// Assert
expect(factorySpy).toHaveBeenCalledWith({
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole",
decryptedSecrets: [
expect.objectContaining({
kind: "apiKey",
value: "dbSecret",
}),
],
externalUrl: null,
});
});
test("with input of form and db secrets matching api key kind it should use form apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["apiKey"]]);
const integration = {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole" as const,
secrets: [
{
kind: "apiKey" as const,
value: "secret",
},
],
};
const dbSecrets = [
{
kind: "apiKey" as const,
value: "dbSecret.encrypted" as const,
},
];
// Act
await testConnectionAsync(integration, dbSecrets);
// Assert
expect(factorySpy).toHaveBeenCalledWith({
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole",
decryptedSecrets: [
expect.objectContaining({
kind: "apiKey",
value: "secret",
}),
],
externalUrl: null,
});
});
test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
const integration = {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole" as const,
secrets: [
{
kind: "apiKey" as const,
value: "secret",
},
],
};
const dbSecrets = [
{
kind: "username" as const,
value: "dbUsername.encrypted" as const,
},
{
kind: "password" as const,
value: "dbPassword.encrypted" as const,
},
];
// Act
await testConnectionAsync(integration, dbSecrets);
// Assert
expect(factorySpy).toHaveBeenCalledWith({
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole",
decryptedSecrets: [
expect.objectContaining({
kind: "apiKey",
value: "secret",
}),
],
externalUrl: null,
});
});
test("with input of null form apiKey and db secrets for username and password it should use db username and password when both is allowed", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
const integration = {
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole" as const,
secrets: [
{
kind: "apiKey" as const,
value: null,
},
],
};
const dbSecrets = [
{
kind: "username" as const,
value: "dbUsername.encrypted" as const,
},
{
kind: "password" as const,
value: "dbPassword.encrypted" as const,
},
];
// Act
await testConnectionAsync(integration, dbSecrets);
// Assert
expect(factorySpy).toHaveBeenCalledWith({
id: "new",
name: "Pi Hole",
url: "http://pi.hole",
kind: "piHole",
decryptedSecrets: [
expect.objectContaining({
kind: "username",
value: "dbUsername",
}),
expect.objectContaining({
kind: "password",
value: "dbPassword",
}),
],
externalUrl: null,
});
});
test("with input of existing github app", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([[], ["githubAppId", "githubInstallationId", "privateKey"]]);
const integration = {
id: "new",
name: "GitHub",
url: "https://api.github.com",
kind: "github" as const,
secrets: [
{
kind: "githubAppId" as const,
value: "345",
},
{
kind: "githubInstallationId" as const,
value: "456",
},
{
kind: "privateKey" as const,
value: null,
},
],
};
const dbSecrets = [
{
kind: "githubAppId" as const,
value: "123.encrypted" as const,
},
{
kind: "githubInstallationId" as const,
value: "234.encrypted" as const,
},
{
kind: "privateKey" as const,
value: "privateKey.encrypted" as const,
},
];
// Act
await testConnectionAsync(integration, dbSecrets);
// Assert
expect(factorySpy).toHaveBeenCalledWith({
id: "new",
name: "GitHub",
url: "https://api.github.com",
kind: "github" as const,
decryptedSecrets: [
expect.objectContaining({
kind: "githubAppId",
value: "345",
}),
expect.objectContaining({
kind: "githubInstallationId",
value: "456",
}),
expect.objectContaining({
kind: "privateKey",
value: "privateKey",
}),
],
externalUrl: null,
});
});
});

View File

@@ -0,0 +1,207 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { invites, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { inviteRouter } from "../invite";
const defaultSession = {
user: {
id: createId(),
permissions: ["admin"],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", async () => {
const mod = await import("@homarr/auth/security");
return { ...mod, auth: () => ({}) as Session };
});
// Mock the env module to return the credentials provider
vi.mock("@homarr/auth/env", () => {
return {
env: {
AUTH_PROVIDERS: ["credentials"],
},
};
});
describe("all should return all existing invites without sensitive informations", () => {
test("invites should not contain sensitive informations", async () => {
// Arrange
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const userId = createId();
await db.insert(users).values({
id: userId,
name: "someone",
});
const inviteId = createId();
await db.insert(invites).values({
id: inviteId,
creatorId: userId,
expirationDate: new Date(2022, 5, 1),
token: "token",
});
// Act
const result = await caller.getAll();
// Assert
expect(result.length).toBe(1);
expect(result[0]?.id).toBe(inviteId);
expect(result[0]?.expirationDate).toEqual(new Date(2022, 5, 1));
expect(result[0]?.creator.id).toBe(userId);
expect(result[0]?.creator.name).toBe("someone");
expect("token" in result[0]!).toBe(false);
});
test("invites should be sorted ascending by expiration date", async () => {
// Arrange
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const userId = createId();
await db.insert(users).values({
id: userId,
name: "someone",
});
const inviteId = createId();
await db.insert(invites).values({
id: inviteId,
creatorId: userId,
expirationDate: new Date(2022, 5, 1),
token: "token",
});
await db.insert(invites).values({
id: createId(),
creatorId: userId,
expirationDate: new Date(2022, 5, 2),
token: "token2",
});
// Act
const result = await caller.getAll();
// Assert
expect(result.length).toBe(2);
expect(result[0]?.expirationDate.getDate()).toBe(1);
expect(result[1]?.expirationDate.getDate()).toBe(2);
});
});
describe("create should create a new invite expiring on the specified date with a token and id returned to generate url", () => {
test("creation should work with a date in the future, but less than 6 months.", async () => {
// Arrange
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await db.insert(users).values({
id: defaultSession.user.id,
});
const expirationDate = new Date(2024, 5, 1); // TODO: add mock date
// Act
const result = await caller.createInvite({
expirationDate,
});
// Assert
expect(result.id.length).toBeGreaterThan(10);
expect(result.token.length).toBeGreaterThan(20);
const createdInvite = await db.query.invites.findFirst();
expect(createdInvite).toBeDefined();
expect(createdInvite?.id).toBe(result.id);
expect(createdInvite?.token).toBe(result.token);
expect(createdInvite?.expirationDate).toEqual(expirationDate);
expect(createdInvite?.creatorId).toBe(defaultSession.user.id);
});
});
describe("delete should remove invite by id", () => {
test("deletion should remove present invite", async () => {
// Arrange
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const userId = createId();
await db.insert(users).values({
id: userId,
});
const inviteId = createId();
await db.insert(invites).values([
{
id: createId(),
creatorId: userId,
expirationDate: new Date(2023, 1, 1),
token: "first-token",
},
{
id: inviteId,
creatorId: userId,
expirationDate: new Date(2023, 1, 1),
token: "second-token",
},
]);
// Act
await caller.deleteInvite({ id: inviteId });
// Assert
const dbInvites = await db.query.invites.findMany();
expect(dbInvites.length).toBe(1);
expect(dbInvites[0]?.id).not.toBe(inviteId);
});
test("deletion should throw with NOT_FOUND code when specified invite not present", async () => {
// Arrange
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const userId = createId();
await db.insert(users).values({
id: userId,
});
await db.insert(invites).values({
id: createId(),
creatorId: userId,
expirationDate: new Date(2023, 1, 1),
token: "first-token",
});
// Act
const actAsync = async () => await caller.deleteInvite({ id: createId() });
// Assert
await expect(actAsync()).rejects.toThrow("not found");
});
});

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { CpuResourceParser } from "../../../kubernetes/resource-parser/cpu-resource-parser";
describe("CpuResourceParser", () => {
const parser = new CpuResourceParser();
it("should return NaN for empty or invalid input", () => {
expect(parser.parse("")).toBeNaN();
expect(parser.parse(" ")).toBeNaN();
expect(parser.parse("abc")).toBeNaN();
});
it("should parse CPU values without a unit (cores)", () => {
expect(parser.parse("1")).toBe(1);
expect(parser.parse("2.5")).toBe(2.5);
expect(parser.parse("10")).toBe(10);
});
it("should parse CPU values with milli-core unit ('m')", () => {
expect(parser.parse("500m")).toBe(0.5); // 500 milli-cores = 0.5 cores
expect(parser.parse("250m")).toBe(0.25);
expect(parser.parse("1000m")).toBe(1);
});
it("should parse CPU values with kilo-core unit ('k')", () => {
expect(parser.parse("1k")).toBe(1000); // 1 kilo-core = 1000 cores
expect(parser.parse("2k")).toBe(2000);
expect(parser.parse("0.5k")).toBe(500);
});
it("should parse CPU values with nano-core unit ('n')", () => {
// Adjust the expected values for nano-cores to account for floating-point precision
expect(parser.parse("1000000000n")).toBe(1); // 1 NanoCPU = 1/1,000,000,000 cores
expect(parser.parse("500000000n")).toBe(0.5);
expect(parser.parse("0.000000001n")).toBe(0.000000000000000001); // Tiny value
});
it("should parse CPU values with micro-core unit ('u')", () => {
// Adjust the expected values for micro-cores to account for floating-point precision
expect(parser.parse("1000000u")).toBe(1); // 1 MicroCPU = 1/1,000,000 cores
expect(parser.parse("500000u")).toBe(0.5);
expect(parser.parse("0.000001u")).toBe(0.000000000001); // Tiny value
});
it("should handle input with commas", () => {
expect(parser.parse("1,000")).toBe(1000); // 1,000 cores
expect(parser.parse("1,500m")).toBe(1.5); // 1,500 milli-cores = 1.5 cores
});
it("should ignore leading and trailing whitespace", () => {
expect(parser.parse(" 1 ")).toBe(1);
expect(parser.parse(" 500m ")).toBe(0.5);
expect(parser.parse(" 2k ")).toBe(2000);
});
});

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { MemoryResourceParser } from "../../../kubernetes/resource-parser/memory-resource-parser";
const BYTES_IN_GIB = 1024 ** 3; // 1 GiB in bytes
const BYTES_IN_MIB = 1024 ** 2; // 1 MiB in bytes
const BYTES_IN_KIB = 1024; // 1 KiB in bytes
const KI = "Ki";
const MI = "Mi";
const GI = "Gi";
const TI = "Ti";
const PI = "Pi";
describe("MemoryResourceParser", () => {
const parser = new MemoryResourceParser();
it("should parse values without units as bytes and convert to GiB", () => {
expect(parser.parse("1073741824")).toBe(1); // 1 GiB
expect(parser.parse("2147483648")).toBe(2); // 2 GiB
});
it("should parse binary units (Ki, Mi, Gi, Ti, Pi) into GiB", () => {
expect(parser.parse(`1024${KI}`)).toBeCloseTo(1 / 1024); // 1 MiB = 1/1024 GiB
expect(parser.parse(`1${MI}`)).toBeCloseTo(1 / 1024); // 1 MiB = 1/1024 GiB
expect(parser.parse(`1${GI}`)).toBe(1); // 1 GiB
expect(parser.parse(`1${TI}`)).toBe(BYTES_IN_KIB); // 1 TiB = 1024 GiB
expect(parser.parse(`1${PI}`)).toBe(BYTES_IN_MIB); // 1 PiB = 1024^2 GiB
});
it("should parse decimal units (K, M, G, T, P) into GiB", () => {
expect(parser.parse("1000K")).toBeCloseTo(1000 / BYTES_IN_GIB); // 1000 KB
expect(parser.parse("1M")).toBeCloseTo(1 / BYTES_IN_KIB); // 1 MB = 1/1024 GiB
expect(parser.parse("1G")).toBeCloseTo(0.9313225746154785); // 1 GB ≈ 0.931 GiB
expect(parser.parse("1T")).toBeCloseTo(931.3225746154785); // 1 TB ≈ 931.32 GiB
expect(parser.parse("1P")).toBeCloseTo(931322.5746154785); // 1 PB ≈ 931,322.57 GiB
});
it("should handle invalid input and return NaN", () => {
expect(parser.parse("")).toBeNaN();
expect(parser.parse(" ")).toBeNaN();
expect(parser.parse("abc")).toBeNaN();
});
it("should handle commas in input and convert to GiB", () => {
expect(parser.parse("1,073,741,824")).toBe(1); // 1 GiB
expect(parser.parse("1,024Ki")).toBeCloseTo(1 / BYTES_IN_KIB); // 1 MiB
});
it("should handle lowercase and uppercase units", () => {
expect(parser.parse("1ki")).toBeCloseTo(1 / BYTES_IN_KIB); // 1 MiB
expect(parser.parse("1KI")).toBeCloseTo(1 / BYTES_IN_KIB);
expect(parser.parse("1Mi")).toBeCloseTo(1 / BYTES_IN_KIB);
expect(parser.parse("1m")).toBeCloseTo(1 / BYTES_IN_KIB);
});
it("should assume bytes for unrecognized or no units and convert to GiB", () => {
expect(parser.parse("1073741824")).toBe(1); // 1 GiB
expect(parser.parse("42")).toBeCloseTo(42 / BYTES_IN_GIB); // 42 bytes in GiB
expect(parser.parse("42unknown")).toBeCloseTo(42 / BYTES_IN_GIB); // Invalid unit = bytes
});
});

View File

@@ -0,0 +1,97 @@
import SuperJSON from "superjson";
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { serverSettings } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import { serverSettingsRouter } from "../serverSettings";
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
const defaultSession = {
user: {
id: createId(),
permissions: ["admin"],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
describe("getAll server settings", () => {
test("getAll should throw error when unauthorized", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
await db.insert(serverSettings).values([
{
settingKey: defaultServerSettingsKeys[0],
value: SuperJSON.stringify(defaultServerSettings.analytics),
},
]);
const actAsync = async () => await caller.getAll();
await expect(actAsync()).rejects.toThrow();
});
test("getAll should return default server settings when nothing in database", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const result = await caller.getAll();
expect(result).toStrictEqual(defaultServerSettings);
});
});
describe("saveSettings", () => {
test("saveSettings should update settings and return true when it updated only one", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await db.insert(serverSettings).values([
{
settingKey: defaultServerSettingsKeys[0],
value: SuperJSON.stringify(defaultServerSettings.analytics),
},
]);
await caller.saveSettings({
settingsKey: "analytics",
value: {
enableGeneral: true,
enableWidgetData: true,
enableIntegrationData: true,
enableUserData: true,
},
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
{
settingKey: "analytics",
value: SuperJSON.stringify({
enableGeneral: true,
enableWidgetData: true,
enableIntegrationData: true,
enableUserData: true,
}),
},
]);
});
});

View File

@@ -0,0 +1,323 @@
import { describe, expect, it, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { invites, onboarding, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
import { userRouter } from "../user";
const defaultOwnerId = createId();
const createSession = (permissions: GroupPermissionKey[]) =>
({
user: {
id: defaultOwnerId,
permissions,
colorScheme: "light",
},
expires: new Date().toISOString(),
}) satisfies Session;
const defaultSession = createSession([]);
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", async () => {
const mod = await import("@homarr/auth/security");
return { ...mod, auth: () => ({}) as Session };
});
// Mock the env module to return the credentials provider
vi.mock("@homarr/auth/env", () => {
return {
env: {
AUTH_PROVIDERS: ["credentials"],
},
};
});
describe("initUser should initialize the first user", () => {
it("should create a user if none exists", async () => {
const db = createDb();
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
await caller.initUser({
username: "test",
password: "123ABCdef+/-",
confirmPassword: "123ABCdef+/-",
});
const user = await db.query.users.findFirst({
columns: {
id: true,
},
});
expect(user).toBeDefined();
});
it("should not create a user if the password and confirmPassword do not match", async () => {
const db = createDb();
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
const actAsync = async () =>
await caller.initUser({
username: "test",
password: "123ABCdef+/-",
confirmPassword: "456ABCdef+/-",
});
await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch");
});
it.each([
["aB2%"], // too short
["abc123DEF"], // does not contain special characters
["abcDEFghi+"], // does not contain numbers
["ABC123+/-"], // does not contain lowercase
["abc123+/-"], // does not contain uppercase
])("should throw error that password requirements do not match for '%s' as password", async (password) => {
const db = createDb();
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
const actAsync = async () =>
await caller.initUser({
username: "test",
password,
confirmPassword: password,
});
await expect(actAsync()).rejects.toThrow("passwordRequirements");
});
});
describe("register should create a user with valid invitation", () => {
test("register should create a user with valid invitation", async () => {
// Arrange
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
const userId = createId();
const inviteId = createId();
const inviteToken = "123";
vi.useFakeTimers();
vi.setSystemTime(new Date(2024, 0, 3));
await db.insert(users).values({
id: userId,
});
await db.insert(invites).values({
id: inviteId,
token: inviteToken,
creatorId: userId,
expirationDate: new Date(2024, 0, 5),
});
// Act
await caller.register({
inviteId,
token: inviteToken,
username: "test",
password: "123ABCdef+/-",
confirmPassword: "123ABCdef+/-",
});
// Assert
const user = await db.query.users.findMany({
columns: {
name: true,
},
});
const invite = await db.query.invites.findMany({
columns: {
id: true,
},
});
expect(user).toHaveLength(2);
expect(invite).toHaveLength(0);
});
test.each([
[{ token: "fakeToken" }, new Date(2024, 0, 3)],
[{ inviteId: "fakeInviteId" }, new Date(2024, 0, 3)],
[{}, new Date(2024, 0, 5, 0, 0, 1)],
])(
"register should throw an error with input %s and date %s if the invitation is invalid",
async (partialInput, systemTime) => {
// Arrange
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
const userId = createId();
const inviteId = createId();
const inviteToken = "123";
vi.useFakeTimers();
vi.setSystemTime(systemTime);
await db.insert(users).values({
id: userId,
});
await db.insert(invites).values({
id: inviteId,
token: inviteToken,
creatorId: userId,
expirationDate: new Date(2024, 0, 5),
});
// Act
const actAsync = async () =>
await caller.register({
inviteId,
token: inviteToken,
username: "test",
password: "123ABCdef+/-",
confirmPassword: "123ABCdef+/-",
...partialInput,
});
// Assert
await expect(actAsync()).rejects.toThrow("Invalid invite");
},
);
});
describe("editProfile shoud update user", () => {
test("editProfile should update users and not update emailVerified when email not dirty", async () => {
// arrange
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const emailVerified = new Date(2024, 0, 5);
await db.insert(users).values({
id: defaultOwnerId,
name: "TEST 1",
email: "abc@gmail.com",
emailVerified,
});
// act
await caller.editProfile({
id: defaultOwnerId,
name: "ABC",
email: "",
});
// assert
const user = await db.select().from(users).where(eq(users.id, defaultOwnerId));
expect(user).toHaveLength(1);
expect(user[0]).containSubset({
id: defaultOwnerId,
name: "abc",
email: "abc@gmail.com",
emailVerified,
});
});
test("editProfile should update users and update emailVerified when email dirty", async () => {
// arrange
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await db.insert(users).values({
id: defaultOwnerId,
name: "TEST 1",
email: "abc@gmail.com",
emailVerified: new Date(2024, 0, 5),
});
// act
await caller.editProfile({
id: defaultOwnerId,
name: "ABC",
email: "myNewEmail@gmail.com",
});
// assert
const user = await db.select().from(users).where(eq(users.id, defaultOwnerId));
expect(user).toHaveLength(1);
expect(user[0]).containSubset({
id: defaultOwnerId,
name: "abc",
email: "myNewEmail@gmail.com",
emailVerified: null,
});
});
});
describe("delete should delete user", () => {
test("delete should delete user", async () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const initialUsers = [
{
id: createId(),
name: "User 1",
},
{
id: defaultOwnerId,
name: "User 2",
},
{
id: createId(),
name: "User 3",
},
];
await db.insert(users).values(initialUsers);
await caller.delete({ userId: defaultOwnerId });
const usersInDb = await db.select().from(users);
expect(usersInDb).toHaveLength(2);
expect(usersInDb[0]).containSubset(initialUsers[0]);
expect(usersInDb[1]).containSubset(initialUsers[2]);
});
});
const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) => {
await db.insert(onboarding).values({
id: createId(),
step,
});
};

View File

@@ -0,0 +1,53 @@
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createDb } from "@homarr/db/test";
import * as ping from "@homarr/ping";
import { appRouter } from "../../widgets/app";
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
vi.mock("@homarr/ping", () => ({ sendPingRequestAsync: async () => await Promise.resolve(null) }));
describe("ping should call sendPingRequestAsync with url and return result", () => {
test("ping with error response should return error and url", async () => {
// Arrange
const spy = vi.spyOn(ping, "sendPingRequestAsync");
const url = "http://localhost";
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
spy.mockImplementation(() => Promise.resolve({ error: "error" }));
// Act
const result = await caller.ping({ url });
// Assert
expect(result.url).toBe(url);
expect("error" in result).toBe(true);
});
test("ping with success response should return statusCode and url", async () => {
// Arrange
const spy = vi.spyOn(ping, "sendPingRequestAsync");
const url = "http://localhost";
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
spy.mockImplementation(() => Promise.resolve({ statusCode: 200, durationMs: 123 }));
// Act
const result = await caller.ping({ url });
// Assert
expect(result.url).toBe(url);
expect("statusCode" in result).toBe(true);
});
});

View File

@@ -0,0 +1,19 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
const logger = createLogger({ module: "updateCheckerRouter" });
export const updateCheckerRouter = createTRPCRouter({
getAvailableUpdates: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
try {
const handler = updateCheckerRequestHandler.handler({});
const data = await handler.getCachedOrUpdatedDataAsync({});
return data.data.availableUpdates;
} catch (error) {
logger.error(new Error("Failed to get available updates", { cause: error }));
return undefined; // We return undefined to not show the indicator in the UI
}
}),
});

View File

@@ -0,0 +1,570 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database } from "@homarr/db";
import { and, eq, like } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
import { selectUserSchema } from "@homarr/db/validationSchemas";
import { credentialsAdminGroup } from "@homarr/definitions";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { byIdSchema } from "@homarr/validation/common";
import type { userBaseCreateSchema } from "@homarr/validation/user";
import {
userChangeColorSchemeSchema,
userChangeHomeBoardsSchema,
userChangePasswordApiSchema,
userChangeSearchPreferencesSchema,
userCreateSchema,
userEditProfileSchema,
userFirstDayOfWeekSchema,
userInitSchema,
userPingIconsEnabledSchema,
userRegistrationApiSchema,
} from "@homarr/validation/user";
import { convertIntersectionToZodObject } from "../schema-merger";
import {
createTRPCRouter,
onboardingProcedure,
permissionRequiredProcedure,
protectedProcedure,
publicProcedure,
} from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { throwIfCredentialsDisabled } from "./invite/checks";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences";
const logger = createLogger({ module: "userRouter" });
export const userRouter = createTRPCRouter({
initUser: onboardingProcedure
.requiresStep("user")
.input(userInitSchema)
.mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
const userId = await createUserAsync(ctx.db, input);
const groupId = createId();
await ctx.db.insert(groups).values({
id: groupId,
name: credentialsAdminGroup,
ownerId: userId,
position: maxPosition + 1,
});
await ctx.db.insert(groupPermissions).values({
groupId,
permission: "admin",
});
await ctx.db.insert(groupMembers).values({
groupId,
userId,
});
await nextOnboardingStepAsync(ctx.db, undefined);
}),
register: publicProcedure
.input(userRegistrationApiSchema)
.output(z.void())
.mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
const inviteWhere = and(eq(invites.id, input.inviteId), eq(invites.token, input.token));
const dbInvite = await ctx.db.query.invites.findFirst({
columns: {
id: true,
expirationDate: true,
},
where: inviteWhere,
});
if (!dbInvite || dbInvite.expirationDate < new Date()) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid invite",
});
}
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
await createUserAsync(ctx.db, input);
// Delete invite as it's used
await ctx.db.delete(invites).where(inviteWhere);
}),
create: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"], protect: true } })
.input(userCreateSchema)
.output(z.void())
.mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
const userId = await createUserAsync(ctx.db, input);
if (input.groupIds.length >= 1) {
await ctx.db.insert(groupMembers).values(input.groupIds.map((groupId) => ({ groupId, userId })));
}
}),
setProfileImage: protectedProcedure
.output(z.void())
.meta({ openapi: { method: "PUT", path: "/api/users/profileImage", tags: ["users"], protect: true } })
.input(
z.object({
userId: z.string(),
image: z
.string()
.regex(/^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9/+]+=*$/g)
.max(350000) // approximately 256KB in base64 (256 * 1024 * 4 / 3 + prefixes)
.nullable(),
}),
)
.mutation(async ({ input, ctx }) => {
// Only admins can change other users profile images
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to change other users profile images",
});
}
const user = await ctx.db.query.users.findFirst({
columns: {
id: true,
image: true,
provider: true,
},
where: eq(users.id, input.userId),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
image: input.image,
})
.where(eq(users.id, input.userId));
}),
getAll: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.void())
.output(z.array(selectUserSchema.pick({ id: true, name: true, email: true, emailVerified: true, image: true })))
.meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"], protect: true } })
.query(({ ctx }) => {
return ctx.db.query.users.findMany({
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
},
});
}),
// Is protected because also used in board access / integration access forms
selectable: protectedProcedure
.input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional())
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: true, email: true })))
.meta({ openapi: { method: "GET", path: "/api/users/selectable", tags: ["users"], protect: true } })
.query(({ ctx, input }) => {
return ctx.db.query.users.findMany({
columns: {
id: true,
name: true,
image: true,
email: true,
},
where: input?.excludeExternalProviders ? eq(users.provider, "credentials") : undefined,
});
}),
search: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({
query: z.string(),
limit: z.number().min(1).max(100).default(10),
}),
)
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: true, email: true })))
.meta({ openapi: { method: "POST", path: "/api/users/search", tags: ["users"], protect: true } })
.query(async ({ input, ctx }) => {
const dbUsers = await ctx.db.query.users.findMany({
columns: {
id: true,
name: true,
image: true,
email: true,
},
where: like(users.name, `%${input.query}%`),
limit: input.limit,
});
return dbUsers.map((user) => ({
id: user.id,
name: user.name ?? "",
image: user.image,
email: user.email,
}));
}),
getById: protectedProcedure
.input(z.object({ userId: z.string() }))
.output(
selectUserSchema.pick({
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
provider: true,
homeBoardId: true,
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
}),
)
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
.query(async ({ input, ctx }) => {
// Only admins can view other users details
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to view other users details",
});
}
const user = await ctx.db.query.users.findFirst({
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
provider: true,
homeBoardId: true,
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
},
where: eq(users.id, input.userId),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return user;
}),
editProfile: protectedProcedure
.input(userEditProfileSchema)
.output(z.void())
.meta({ openapi: { method: "PUT", path: "/api/users/profile", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
// Only admins can view other users details
if (ctx.session.user.id !== input.id && !ctx.session.user.permissions.includes("admin")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to edit other users details",
});
}
const user = await ctx.db.query.users.findFirst({
columns: { email: true, provider: true },
where: eq(users.id, input.id),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (user.provider !== "credentials") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Username and email can not be changed for users with external providers",
});
}
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.name, input.id);
const emailDirty = input.email && user.email !== input.email;
await ctx.db
.update(users)
.set({
name: input.name,
email: emailDirty === true ? input.email : undefined,
emailVerified: emailDirty === true ? null : undefined,
})
.where(eq(users.id, input.id));
}),
delete: protectedProcedure
.input(z.object({ userId: z.string() }))
.output(z.void())
.meta({ openapi: { method: "DELETE", path: "/api/users/{userId}", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
// Only admins and user itself can delete a user
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to delete other users",
});
}
await ctx.db.delete(users).where(eq(users.id, input.userId));
}),
changePassword: protectedProcedure
.input(userChangePasswordApiSchema)
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/{userId}/changePassword", tags: ["users"], protect: true } })
.mutation(async ({ ctx, input }) => {
const user = ctx.session.user;
// Only admins can change other users' passwords
if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
password: true,
salt: true,
provider: true,
},
where: eq(users.id, input.userId),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (dbUser.provider !== "credentials") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Password can not be changed for users with external providers",
});
}
// Admins can change the password of other users without providing the previous password
const isPreviousPasswordRequired = ctx.session.user.id === input.userId;
logger.info("Changing user password", {
actorId: ctx.session.user.id,
targetUserId: input.userId,
previousPasswordRequired: isPreviousPasswordRequired,
});
if (isPreviousPasswordRequired) {
const previousPasswordHash = await hashPasswordAsync(input.previousPassword, dbUser.salt ?? "");
const isValid = previousPasswordHash === dbUser.password;
if (!isValid) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid password",
});
}
}
const salt = await createSaltAsync();
const hashedPassword = await hashPasswordAsync(input.password, salt);
await ctx.db
.update(users)
.set({
password: hashedPassword,
})
.where(eq(users.id, input.userId));
}),
changeHomeBoards: protectedProcedure
.input(convertIntersectionToZodObject(userChangeHomeBoardsSchema.and(z.object({ userId: z.string() }))))
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/changeHome", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
const user = ctx.session.user;
// Only admins can change other users passwords
if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
where: eq(users.id, input.userId),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
// Only allow user to select boards they have access to
if (input.homeBoardId) {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.homeBoardId), "view");
}
if (input.mobileHomeBoardId) {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.mobileHomeBoardId), "view");
}
await ctx.db
.update(users)
.set({
homeBoardId: input.homeBoardId,
mobileHomeBoardId: input.mobileHomeBoardId,
})
.where(eq(users.id, input.userId));
}),
changeDefaultSearchEngine: protectedProcedure
.input(
convertIntersectionToZodObject(
userChangeSearchPreferencesSchema.omit({ openInNewTab: true }).and(z.object({ userId: z.string() })),
),
)
.output(z.void())
.meta({
openapi: {
method: "PATCH",
path: "/api/users/changeSearchEngine",
tags: ["users"],
protect: true,
deprecated: true,
},
})
.mutation(async ({ input, ctx }) => {
await changeSearchPreferencesAsync(ctx.db, ctx.session, {
...input,
openInNewTab: undefined,
});
}),
changeSearchPreferences: protectedProcedure
.input(convertIntersectionToZodObject(changeSearchPreferencesInputSchema))
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/search-preferences", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
await changeSearchPreferencesAsync(ctx.db, ctx.session, input);
}),
changeColorScheme: protectedProcedure
.input(userChangeColorSchemeSchema)
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/changeScheme", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
await ctx.db
.update(users)
.set({
colorScheme: input.colorScheme,
})
.where(eq(users.id, ctx.session.user.id));
}),
changePingIconsEnabled: protectedProcedure
.input(userPingIconsEnabledSchema.and(byIdSchema))
.mutation(async ({ input, ctx }) => {
// Only admins can change other users ping icons enabled
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
pingIconsEnabled: input.pingIconsEnabled,
})
.where(eq(users.id, ctx.session.user.id));
}),
changeFirstDayOfWeek: protectedProcedure
.input(convertIntersectionToZodObject(userFirstDayOfWeekSchema.and(byIdSchema)))
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/firstDayOfWeek", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
// Only admins can change other users first day of week
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
where: eq(users.id, input.id),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
firstDayOfWeek: input.firstDayOfWeek,
})
.where(eq(users.id, ctx.session.user.id));
}),
});
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
const salt = await createSaltAsync();
const hashedPassword = await hashPasswordAsync(input.password, salt);
const userId = createId();
await db.insert(users).values({
id: userId,
name: input.username,
email: input.email,
password: hashedPassword,
salt,
});
return userId;
};
const checkUsernameAlreadyTakenAndThrowAsync = async (
db: Database,
provider: SupportedAuthProvider,
username: string,
ignoreId?: string,
) => {
const user = await db.query.users.findFirst({
where: and(eq(users.name, username), eq(users.provider, provider)),
});
if (!user) return;
if (ignoreId && user.id === ignoreId) return;
throw new TRPCError({
code: "CONFLICT",
message: "Username already taken",
});
};

View File

@@ -0,0 +1,50 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import type { Session } from "@homarr/auth";
import type { Modify } from "@homarr/common/types";
import { eq } from "@homarr/db";
import type { Database } from "@homarr/db";
import { users } from "@homarr/db/schema";
import { userChangeSearchPreferencesSchema } from "@homarr/validation/user";
export const changeSearchPreferencesInputSchema = userChangeSearchPreferencesSchema.and(
z.object({ userId: z.string() }),
);
export const changeSearchPreferencesAsync = async (
db: Database,
session: Session,
input: Modify<z.infer<typeof changeSearchPreferencesInputSchema>, { openInNewTab: boolean | undefined }>,
) => {
const user = session.user;
// Only admins can change other users passwords
if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const dbUser = await db.query.users.findFirst({
columns: {
id: true,
},
where: eq(users.id, input.userId),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await db
.update(users)
.set({
defaultSearchEngineId: input.defaultSearchEngineId,
openSearchInNewTab: input.openInNewTab,
})
.where(eq(users.id, input.userId));
};

View File

@@ -0,0 +1,45 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { sendPingRequestAsync } from "@homarr/ping";
import { pingChannel, pingUrlChannel } from "@homarr/redis";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const appRouter = createTRPCRouter({
ping: publicProcedure.input(z.object({ url: z.string() })).query(async ({ input }) => {
const pingResult = await sendPingRequestAsync(input.url);
return {
url: input.url,
...pingResult,
};
}),
updatedPing: publicProcedure
.input(
z.object({
url: z.string(),
}),
)
.subscription(async ({ input }) => {
await pingUrlChannel.addAsync(input.url);
const pingResult = await sendPingRequestAsync(input.url);
return observable<{ url: string; statusCode: number; durationMs: number } | { url: string; error: string }>(
(emit) => {
emit.next({ url: input.url, ...pingResult });
const unsubscribe = pingChannel.subscribe((message) => {
// Only emit if same url
if (message.url !== input.url) return;
emit.next(message);
});
return () => {
unsubscribe();
void pingUrlChannel.removeAsync(input.url);
};
},
);
}),
});

View File

@@ -0,0 +1,79 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { CalendarEvent } from "@homarr/integrations/types";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure
.input(
z.object({
year: z.number(),
month: z.number(),
releaseType: z.array(z.enum(radarrReleaseTypes)),
showUnmonitored: z.boolean(),
}),
)
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx, input }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
events: data,
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
};
}),
);
}),
subscribeToEvents: publicProcedure
.input(
z.object({
year: z.number(),
month: z.number(),
releaseType: z.array(z.enum(radarrReleaseTypes)),
showUnmonitored: z.boolean(),
}),
)
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.subscription(({ ctx, input }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"calendar"> }>;
events: CalendarEvent[];
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integrationWithSecrets, handlerInput);
const unsubscribe = innerHandler.subscribe((events) => {
emit.next({
integration,
events,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});

View File

@@ -0,0 +1,95 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
export const dnsHoleRouter = createTRPCRouter({
summary: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
return results;
}),
subscribeToSummary: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"dnsHole"> }>;
summary: DnsHoleSummary;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = dnsHoleRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
enable: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.mutation(async ({ ctx: { integration } }) => {
const client = await createIntegrationAsync(integration);
await client.enableAsync();
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
// We need to wait for the integration to be enabled before invalidating the cache
await new Promise<void>((resolve) => {
setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000);
});
}),
disable: protectedProcedure
.input(
z.object({
duration: z.number().optional(),
}),
)
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
await client.disableAsync(input.duration);
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
// We need to wait for the integration to be disabled before invalidating the cache
await new Promise<void>((resolve) => {
setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000);
});
}),
});

View File

@@ -0,0 +1,120 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("downloadClient"));
export const downloadsRouter = createTRPCRouter({
getJobsAndStatuses: publicProcedure
.concat(createDownloadClientIntegrationMiddleware("query"))
.input(z.object({ limitPerIntegration: z.number().default(50) }))
.query(async ({ ctx, input }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
};
}),
);
}),
subscribeToJobsAndStatuses: publicProcedure
.concat(createDownloadClientIntegrationMiddleware("query"))
.input(z.object({ limitPerIntegration: z.number().default(50) }))
.subscription(({ ctx, input }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
data: DownloadClientJobsAndStatus;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = downloadClientRequestHandler.handler(integrationWithSecrets, {
limit: input.limitPerIntegration,
});
const unsubscribe = innerHandler.subscribe((data) => {
emit.next({
integration,
data,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
pause: protectedProcedure.concat(createDownloadClientIntegrationMiddleware("interact")).mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseQueueAsync();
}),
);
}),
pauseItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.input(z.object({ item: downloadClientItemSchema }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseItemAsync(input.item);
}),
);
}),
resume: protectedProcedure.concat(createDownloadClientIntegrationMiddleware("interact")).mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeQueueAsync();
}),
);
}),
resumeItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.input(z.object({ item: downloadClientItemSchema }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeItemAsync(input.item);
}),
);
}),
deleteItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.input(z.object({ item: downloadClientItemSchema, fromDisk: z.boolean() }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
}),
);
}),
});

View File

@@ -0,0 +1,215 @@
import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type {
FirewallCpuSummary,
FirewallInterfacesSummary,
FirewallMemorySummary,
FirewallVersionSummary,
} from "@homarr/integrations";
import {
firewallCpuRequestHandler,
firewallInterfacesRequestHandler,
firewallMemoryRequestHandler,
firewallVersionRequestHandler,
} from "@homarr/request-handler/firewall";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const firewallRouter = createTRPCRouter({
getFirewallCpuStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = firewallCpuRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
return results;
}),
subscribeFirewallCpuStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
summary: FirewallCpuSummary;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = firewallCpuRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
getFirewallInterfacesStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = firewallInterfacesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
return results;
}),
subscribeFirewallInterfacesStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
summary: FirewallInterfacesSummary[];
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = firewallInterfacesRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
getFirewallVersionStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = firewallVersionRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
return results;
}),
subscribeFirewallVersionStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
summary: FirewallVersionSummary;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = firewallVersionRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
getFirewallMemoryStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = firewallMemoryRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
return results;
}),
subscribeFirewallMemoryStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
summary: FirewallMemorySummary;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = firewallMemoryRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});

View File

@@ -0,0 +1,73 @@
import { observable } from "@trpc/server/observable";
import type { SystemHealthMonitoring } from "@homarr/integrations";
import type { ProxmoxClusterInfo } from "@homarr/integrations/types";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const healthMonitoringRouter = createTRPCRouter({
getSystemHealthStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "mock"))
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = systemInfoRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
healthInfo: data,
updatedAt: timestamp,
};
}),
);
}),
subscribeSystemHealthStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "mock"))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) {
const innerHandler = systemInfoRequestHandler.handler(integration, {});
const unsubscribe = innerHandler.subscribe((healthInfo) => {
emit.next({
integrationId: integration.id,
healthInfo,
timestamp: new Date(),
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
getClusterHealthStatus: publicProcedure
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
subscribeClusterHealthStatus: publicProcedure
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.subscription(({ ctx }) => {
return observable<ProxmoxClusterInfo>((emit) => {
const unsubscribes: (() => void)[] = [];
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const unsubscribe = innerHandler.subscribe((healthInfo) => {
emit.next(healthInfo);
});
unsubscribes.push(unsubscribe);
return () => {
unsubscribe();
};
});
}),
});

View File

@@ -0,0 +1,46 @@
import { createTRPCRouter } from "../../trpc";
import { appRouter } from "./app";
import { calendarRouter } from "./calendar";
import { dnsHoleRouter } from "./dns-hole";
import { downloadsRouter } from "./downloads";
import { firewallRouter } from "./firewall";
import { healthMonitoringRouter } from "./health-monitoring";
import { indexerManagerRouter } from "./indexer-manager";
import { mediaReleaseRouter } from "./media-release";
import { mediaRequestsRouter } from "./media-requests";
import { mediaServerRouter } from "./media-server";
import { mediaTranscodingRouter } from "./media-transcoding";
import { minecraftRouter } from "./minecraft";
import { networkControllerRouter } from "./network-controller";
import { notebookRouter } from "./notebook";
import { notificationsRouter } from "./notifications";
import { optionsRouter } from "./options";
import { releasesRouter } from "./releases";
import { rssFeedRouter } from "./rssFeed";
import { smartHomeRouter } from "./smart-home";
import { stockPriceRouter } from "./stocks";
import { weatherRouter } from "./weather";
export const widgetRouter = createTRPCRouter({
notebook: notebookRouter,
weather: weatherRouter,
app: appRouter,
dnsHole: dnsHoleRouter,
smartHome: smartHomeRouter,
stockPrice: stockPriceRouter,
mediaServer: mediaServerRouter,
mediaRelease: mediaReleaseRouter,
calendar: calendarRouter,
downloads: downloadsRouter,
mediaRequests: mediaRequestsRouter,
rssFeed: rssFeedRouter,
indexerManager: indexerManagerRouter,
healthMonitoring: healthMonitoringRouter,
mediaTranscoding: mediaTranscodingRouter,
minecraft: minecraftRouter,
options: optionsRouter,
releases: releasesRouter,
networkController: networkControllerRouter,
firewall: firewallRouter,
notifications: notificationsRouter,
});

View File

@@ -0,0 +1,72 @@
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { Indexer } from "@homarr/integrations/types";
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("indexerManager"));
export const indexerManagerRouter = createTRPCRouter({
getIndexersStatus: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
const { data: indexers } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
indexers,
};
}),
);
return results;
}),
subscribeIndexersStatus: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query"))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; indexers: Indexer[] }>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const innerHandler = indexerManagerRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((indexers) => {
emit.next({
integrationId: integrationWithSecrets.id,
indexers,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
testAllIndexers: protectedProcedure
.concat(createIndexerManagerIntegrationMiddleware("interact"))
.mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const client = await createIntegrationAsync(integration);
await client.testAllAsync().catch((err) => {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to test all indexers for ${integration.name} (${integration.id})`,
cause: err,
});
});
}),
);
}),
});

View File

@@ -0,0 +1,67 @@
import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { MediaRelease } from "@homarr/integrations/types";
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaReleaseRouter = createTRPCRouter({
getMediaReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
releases: data,
};
}),
);
return results.flatMap((result) =>
result.releases.map((release) => ({
...release,
integration: result.integration,
})),
);
}),
subscribeToReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"mediaRelease"> }>;
releases: MediaRelease[];
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = mediaReleaseRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((releases) => {
emit.next({
integration,
releases,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});

View File

@@ -0,0 +1,112 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import type { MediaRequest } from "@homarr/integrations/types";
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
export const mediaRequestsRouter = createTRPCRouter({
getLatestRequests: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
data,
};
}),
);
return results
.flatMap(({ data, integration }) => data.map((request) => ({ ...request, integrationId: integration.id })))
.sort((dataA, dataB) => {
if (dataA.status === dataB.status) {
return dataB.createdAt.getTime() - dataA.createdAt.getTime();
}
return (
mediaRequestStatusConfiguration[dataA.status].position -
mediaRequestStatusConfiguration[dataB.status].position
);
});
}),
subscribeToLatestRequests: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.subscription(({ ctx }) => {
return observable<{
integrationId: string;
requests: MediaRequest[];
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = mediaRequestListRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((requests) => {
emit.next({
integrationId: integration.id,
requests,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
getStats: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = mediaRequestStatsRequestHandler.handler(integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
data,
};
}),
);
return {
stats: results.flatMap((result) => result.data.stats),
users: results
.map((result) => result.data.users.map((user) => ({ ...user, integration: result.integration })))
.flat()
.sort(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
integrations: results.map((result) => result.integration),
};
}),
answerRequest: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
.input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) }))
.mutation(async ({ ctx: { integration }, input }) => {
const integrationInstance = await createIntegrationAsync(integration);
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
if (input.answer === "approve") {
await integrationInstance.approveRequestAsync(input.requestId);
await innerHandler.invalidateAsync();
return;
}
await integrationInstance.declineRequestAsync(input.requestId);
await innerHandler.invalidateAsync();
}),
});

View File

@@ -0,0 +1,60 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { StreamSession } from "@homarr/integrations";
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaService"));
export const mediaServerRouter = createTRPCRouter({
getCurrentStreams: publicProcedure
.concat(createMediaServerIntegrationMiddleware("query"))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = mediaServerRequestHandler.handler(integration, {
showOnlyPlaying: input.showOnlyPlaying,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationKind: integration.kind,
sessions: data,
};
}),
);
}),
subscribeToCurrentStreams: publicProcedure
.concat(createMediaServerIntegrationMiddleware("query"))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.subscription(({ ctx, input }) => {
return observable<{ integrationId: string; data: StreamSession[] }>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) {
const innerHandler = mediaServerRequestHandler.handler(integration, {
showOnlyPlaying: input.showOnlyPlaying,
});
const unsubscribe = innerHandler.subscribe((sessions) => {
emit.next({
integrationId: integration.id,
data: sessions,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});

View File

@@ -0,0 +1,46 @@
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { MediaTranscoding } from "@homarr/request-handler/media-transcoding";
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
import { paginatedSchema } from "@homarr/validation/common";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaTranscoding"));
export const mediaTranscodingRouter = createTRPCRouter({
getDataAsync: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query"))
.input(paginatedSchema.pick({ page: true, pageSize: true }))
.query(async ({ ctx, input }) => {
const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, {
pageOffset: (input.page - 1) * input.pageSize,
pageSize: input.pageSize,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: ctx.integration.id,
data,
};
}),
subscribeData: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query"))
.input(paginatedSchema.pick({ page: true, pageSize: true }))
.subscription(({ ctx, input }) => {
return observable<{ integrationId: string; data: MediaTranscoding }>((emit) => {
const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, {
pageOffset: (input.page - 1) * input.pageSize,
pageSize: input.pageSize,
});
const unsubscribe = innerHandler.subscribe((data) => {
emit.next({ integrationId: input.integrationId, data });
});
return unsubscribe;
});
}),
});

View File

@@ -0,0 +1,36 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import type { MinecraftServerStatus } from "@homarr/request-handler/minecraft-server-status";
import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const serverStatusInputSchema = z.object({
domain: z.string().nonempty(),
isBedrockServer: z.boolean(),
});
export const minecraftRouter = createTRPCRouter({
getServerStatus: publicProcedure.input(serverStatusInputSchema).query(async ({ input }) => {
const innerHandler = minecraftServerStatusRequestHandler.handler({
isBedrockServer: input.isBedrockServer,
domain: input.domain,
});
return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
}),
subscribeServerStatus: publicProcedure.input(serverStatusInputSchema).subscription(({ input }) => {
return observable<MinecraftServerStatus>((emit) => {
const innerHandler = minecraftServerStatusRequestHandler.handler({
isBedrockServer: input.isBedrockServer,
domain: input.domain,
});
const unsubscribe = innerHandler.subscribe((data) => {
emit.next(data);
});
return () => {
unsubscribe();
};
});
}),
});

View File

@@ -0,0 +1,62 @@
import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { NetworkControllerSummary } from "@homarr/integrations/types";
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const networkControllerRouter = createTRPCRouter({
summary: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = networkControllerRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
summary: data,
updatedAt: timestamp,
};
}),
);
return results;
}),
subscribeToSummary: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"networkController"> }>;
summary: NetworkControllerSummary;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = networkControllerRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});

View File

@@ -0,0 +1,41 @@
import { TRPCError } from "@trpc/server";
import SuperJSON from "superjson";
import { z } from "zod/v4";
import { eq } from "@homarr/db";
import { boards, items } from "@homarr/db/schema";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
export const notebookRouter = createTRPCRouter({
updateContent: protectedProcedure
.input(
z.object({
itemId: z.string(),
content: z.string(),
boardId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
const item = await ctx.db.query.items.findFirst({
where: eq(items.id, input.itemId),
});
if (item?.boardId !== input.boardId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Specified item was not found",
});
}
const options = SuperJSON.parse<{ content: string }>(item.options);
options.content = input.content;
await ctx.db
.update(items)
.set({ options: SuperJSON.stringify(options) })
.where(eq(items.id, input.itemId));
}),
});

View File

@@ -0,0 +1,64 @@
import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { Notification } from "@homarr/integrations";
import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("notifications"));
export const notificationsRouter = createTRPCRouter({
getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = notificationsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
};
}),
);
}),
subscribeNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"notifications"> }>;
data: Notification[];
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = notificationsRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((data) => {
emit.next({
integration,
data,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});

View File

@@ -0,0 +1,19 @@
import { getServerSettingsAsync } from "@homarr/db/queries";
import type { WidgetOptionsSettings } from "../../../../widgets/src";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const optionsRouter = createTRPCRouter({
getWidgetOptionSettings: publicProcedure.query(async ({ ctx }): Promise<WidgetOptionsSettings> => {
const serverSettings = await getServerSettingsAsync(ctx.db);
return {
server: {
board: {
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
},
},
};
}),
});

View File

@@ -0,0 +1,62 @@
import { escapeForRegEx } from "@tiptap/react";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { releasesRequestHandler } from "@homarr/request-handler/releases";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | 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
.concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("releasesProvider")))
.input(
z.object({
repositories: z.array(
z.object({
id: z.string(),
identifier: z.string(),
versionFilter: releaseVersionFilterSchema.optional(),
}),
),
}),
)
.query(async ({ ctx, input }) => {
return await Promise.all(
input.repositories.map(async (repository) => {
const response = await releasesRequestHandler
.handler(ctx.integration, {
id: repository.id,
identifier: repository.identifier,
versionRegex: formatVersionFilterRegex(repository.versionFilter),
})
.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
return {
id: repository.id,
integration: { name: ctx.integration.name, kind: ctx.integration.kind },
timestamp: response.timestamp,
...response.data,
};
}),
);
}),
});

View File

@@ -0,0 +1,37 @@
import { z } from "zod/v4";
import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const rssFeedRouter = createTRPCRouter({
getFeeds: publicProcedure
.input(
z.object({
urls: z.array(z.string()),
maximumAmountPosts: z.number(),
}),
)
.query(async ({ input }) => {
const rssFeeds = await Promise.all(
input.urls.map(async (url) => {
const innerHandler = rssFeedsRequestHandler.handler({
url,
count: input.maximumAmountPosts,
});
return await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
}),
);
return rssFeeds
.flatMap((rssFeed) => rssFeed.data.entries)
.slice(0, input.maximumAmountPosts)
.sort((entryA, entryB) => {
return entryA.published && entryB.published
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
: 0;
});
}),
});

View File

@@ -0,0 +1,63 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer"));
export const smartHomeRouter = createTRPCRouter({
entityState: publicProcedure
.input(z.object({ entityId: z.string() }))
.concat(createSmartHomeIntegrationMiddleware("query"))
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
subscribeEntityState: publicProcedure
.concat(createSmartHomeIntegrationMiddleware("query"))
.input(z.object({ entityId: z.string() }))
.subscription(({ input, ctx }) => {
return observable<{
entityId: string;
state: string;
}>((emit) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(ctx.integration, {
entityId: input.entityId,
});
const unsubscribe = innerHandler.subscribe((state) => {
emit.next({ state, entityId: input.entityId });
});
return () => {
unsubscribe();
};
});
}),
switchEntity: protectedProcedure
.concat(createSmartHomeIntegrationMiddleware("interact"))
.input(z.object({ entityId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
const success = await client.triggerToggleAsync(input.entityId);
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
await innerHandler.invalidateAsync();
return success;
}),
executeAutomation: protectedProcedure
.concat(createSmartHomeIntegrationMiddleware("interact"))
.input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
await client.triggerAutomationAsync(input.automationId);
}),
});

View File

@@ -0,0 +1,23 @@
import { z } from "zod/v4";
import { fetchStockPriceHandler } from "@homarr/request-handler/stock-price";
import { stockPriceTimeFrames } from "../../../../widgets/src/stocks";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const stockPriceInputSchema = z.object({
stock: z.string().nonempty(),
timeRange: z.enum(stockPriceTimeFrames.range),
timeInterval: z.enum(stockPriceTimeFrames.interval),
});
export const stockPriceRouter = createTRPCRouter({
getPriceHistory: publicProcedure.input(stockPriceInputSchema).query(async ({ input }) => {
const innerHandler = fetchStockPriceHandler.handler({
stock: input.stock,
timeRange: input.timeRange,
timeInterval: input.timeInterval,
});
return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
}),
});

View File

@@ -0,0 +1,29 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import type { Weather } from "@homarr/request-handler/weather";
import { weatherRequestHandler } from "@homarr/request-handler/weather";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const atLocationInput = z.object({
longitude: z.number(),
latitude: z.number(),
});
export const weatherRouter = createTRPCRouter({
atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => {
const handler = weatherRequestHandler.handler(input);
return await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }).then((result) => result.data);
}),
subscribeAtLocation: publicProcedure.input(atLocationInput).subscription(({ input }) => {
return observable<Weather>((emit) => {
const handler = weatherRequestHandler.handler(input);
const unsubscribe = handler.subscribe((data) => {
emit.next(data);
});
return unsubscribe;
});
}),
});