chore(release): automatic release v0.1.0

This commit is contained in:
homarr-releases[bot]
2024-11-29 19:15:06 +00:00
committed by GitHub
163 changed files with 6964 additions and 5417 deletions

View File

@@ -37,17 +37,17 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.14.1",
"@mantine/core": "^7.14.1",
"@mantine/hooks": "^7.14.1",
"@mantine/modals": "^7.14.1",
"@mantine/tiptap": "^7.14.1",
"@million/lint": "1.0.12",
"@mantine/colors-generator": "^7.14.3",
"@mantine/core": "^7.14.3",
"@mantine/hooks": "^7.14.3",
"@mantine/modals": "^7.14.3",
"@mantine/tiptap": "^7.14.3",
"@million/lint": "1.0.13",
"@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.22.0",
"@tanstack/react-query": "^5.61.0",
"@tanstack/react-query-devtools": "^5.61.0",
"@tanstack/react-query-next-experimental": "5.61.0",
"@tabler/icons-react": "^3.23.0",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-query-devtools": "^5.62.0",
"@tanstack/react-query-next-experimental": "5.62.0",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -80,7 +80,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.4",
"@types/node": "^22.9.1",
"@types/node": "^22.10.1",
"@types/prismjs": "^1.26.5",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
@@ -88,7 +88,7 @@
"concurrently": "^9.1.0",
"eslint": "^9.15.0",
"node-loader": "^2.1.0",
"prettier": "^3.3.3",
"typescript": "^5.6.3"
"prettier": "^3.4.1",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,5 @@
import definition from "../boards/(content)/(home)/_definition";
const { layout } = definition;
export default layout;

View File

@@ -0,0 +1,7 @@
import definition from "../boards/(content)/(home)/_definition";
const { generateMetadataAsync: generateMetadata, page } = definition;
export default page;
export { generateMetadata };

View File

@@ -1,14 +0,0 @@
import type { PropsWithChildren } from "react";
import { AppShellMain } from "@mantine/core";
import { MainHeader } from "~/components/layout/header";
import { ClientShell } from "~/components/layout/shell";
export default function MainLayout({ children }: PropsWithChildren) {
return (
<ClientShell hasNavigation={false}>
<MainHeader />
<AppShellMain>{children}</AppShellMain>
</ClientShell>
);
}

View File

@@ -1,9 +0,0 @@
import { Stack, Title } from "@mantine/core";
export default function HomePage() {
return (
<Stack>
<Title>Home</Title>
</Stack>
);
}

View File

@@ -1,6 +1,8 @@
"use client";
import { useCallback } from "react";
import type { MouseEvent } from "react";
import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Group, Menu } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import {
@@ -16,7 +18,7 @@ import {
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -26,6 +28,7 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { HeaderButton } from "~/components/layout/header/button";
import { env } from "~/env.mjs";
import { useEditMode, useRequiredBoard } from "./_context";
export const BoardContentHeaderActions = () => {
@@ -139,6 +142,7 @@ const EditModeMenu = () => {
}, [board, isEditMode, saveBoard, setEditMode]);
useHotkeys([["mod+e", toggle]]);
usePreventLeaveWithDirty(isEditMode);
return (
<HeaderButton onClick={toggle} loading={isPending}>
@@ -146,3 +150,63 @@ const EditModeMenu = () => {
</HeaderButton>
);
};
const usePreventLeaveWithDirty = (isDirty: boolean) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const router = useRouter();
useEffect(() => {
const handleClick = (event: MouseEvent<HTMLElement>) => {
const target = (event.target as HTMLElement).closest("a");
if (!target) return;
if (!isDirty) return;
event.preventDefault();
openConfirmModal({
title: t("board.action.edit.confirmLeave.title"),
children: t("board.action.edit.confirmLeave.message"),
onConfirm() {
router.push(target.href);
},
confirmProps: {
children: t("common.action.discard"),
},
});
};
const handlePopState = (event: Event) => {
if (isDirty) {
window.history.pushState(null, document.title, window.location.href);
event.preventDefault();
} else {
window.history.back();
}
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!isDirty) return;
if (env.NODE_ENV === "development") return; // Allow to reload in development
event.preventDefault();
event.returnValue = true;
};
document.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", handleClick as never);
});
window.addEventListener("popstate", handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
document.querySelectorAll("a").forEach((link) => {
link.removeEventListener("click", handleClick as never);
window.removeEventListener("popstate", handlePopState);
});
window.removeEventListener("beforeunload", handleBeforeUnload);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDirty]);
};

View File

@@ -95,7 +95,7 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
<BoardAccessSettings board={board} initialPermissions={permissions} />
</AccordionItemFor>
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
<DangerZoneSettingsContent hideVisibility={boardSettings.defaultBoardId === board.id} />
<DangerZoneSettingsContent hideVisibility={boardSettings.homeBoardId === board.id} />
</AccordionItemFor>
</>
)}

View File

@@ -1,7 +1,7 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconApps, IconPencil } from "@tabler/icons-react";
import { IconBox, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
@@ -12,6 +12,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { AppDeleteButton } from "./_app-delete-button";
export default async function AppsPage() {
@@ -113,16 +114,14 @@ const AppNoResults = async () => {
const session = await auth();
return (
<Card withBorder bg="transparent">
<Stack align="center" gap="sm">
<IconApps size="2rem" />
<Text fw={500} size="lg">
{t("app.page.list.noResults.title")}
</Text>
{session?.user.permissions.includes("app-create") && (
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor>
)}
</Stack>
</Card>
<NoResults
icon={IconBox}
title={t("app.page.list.noResults.title")}
action={{
label: t("app.page.list.noResults.action"),
href: "/manage/apps/new",
hidden: !session?.user.permissions.includes("app-create"),
}}
/>
);
};

View File

@@ -27,7 +27,7 @@ import {
Text,
Title,
} from "@mantine/core";
import { IconChevronDown, IconChevronUp, IconPencil } from "@tabler/icons-react";
import { IconChevronDown, IconChevronUp, IconPencil, IconPlugX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
@@ -40,6 +40,7 @@ import { CountBadge, IntegrationAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
@@ -120,7 +121,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
if (integrations.length === 0) {
return <div>{t("page.list.empty")}</div>;
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
}
const grouppedIntegrations = integrations.reduce(

View File

@@ -13,6 +13,7 @@ import { z } from "@homarr/validation";
import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
const searchParamsSchema = z.object({
@@ -142,16 +143,14 @@ const SearchEngineNoResults = async () => {
const session = await auth();
return (
<Card withBorder bg="transparent">
<Stack align="center" gap="sm">
<IconSearch size="2rem" />
<Text fw={500} size="lg">
{t("search.engine.page.list.noResults.title")}
</Text>
{session?.user.permissions.includes("search-engine-create") && (
<Anchor href="/manage/search-engines/new">{t("search.engine.page.list.noResults.action")}</Anchor>
)}
</Stack>
</Card>
<NoResults
icon={IconSearch}
title={t("search.engine.page.list.noResults.title")}
action={{
label: t("search.engine.page.list.noResults.action"),
href: "/manage/search-engines/new",
hidden: !session?.user.permissions.includes("search-engine-create"),
}}
/>
);
};

View File

@@ -19,8 +19,8 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
{(form) => (
<>
<SelectWithCustomItems
label={tBoard("defaultBoard.label")}
description={tBoard("defaultBoard.description")}
label={tBoard("homeBoard.label")}
description={tBoard("homeBoard.description")}
data={selectableBoards.map((board) => ({
value: board.id,
label: board.name,
@@ -35,7 +35,7 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
</Text>
</Group>
)}
{...form.getInputProps("defaultBoardId")}
{...form.getInputProps("homeBoardId")}
/>
</>
)}

View File

@@ -1,6 +1,6 @@
"use client";
import type { OpenAPIV3 } from "openapi-types";
import type { OpenAPIObject } from "openapi3-ts/oas31";
import SwaggerUI from "swagger-ui-react";
// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045
@@ -9,7 +9,7 @@ import "../swagger-ui-overrides.css";
import "../swagger-ui.css";
interface SwaggerUIClientProps {
document: OpenAPIV3.Document;
document: OpenAPIObject;
}
export const SwaggerUIClient = ({ document }: SwaggerUIClientProps) => {

View File

@@ -31,7 +31,9 @@ export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
children: t("user.action.delete.confirm", { username: user.name }),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateUserDeletionAsync(user.id);
await mutateUserDeletionAsync({
userId: user.id,
});
},
}),
[user, mutateUserDeletionAsync, openConfirmModal, t],

View File

@@ -1,6 +1,7 @@
"use client";
import { Button, Group, Radio, Stack } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
@@ -43,7 +44,7 @@ export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
});
const form = useZodForm(validation.user.firstDayOfWeek, {
initialValues: {
firstDayOfWeek: user.firstDayOfWeek,
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
},
});

View File

@@ -1,20 +1,48 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { Card, PasswordInput, Stack, Stepper, Text, TextInput, Title } from "@mantine/core";
import { IconUserCheck } from "@tabler/icons-react";
import { startTransition, useCallback, useMemo, useState } from "react";
import {
Badge,
Button,
Card,
Group,
PasswordInput,
Stack,
Stepper,
Table,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { useListState } from "@mantine/hooks";
import { IconPlus, IconUserCheck } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
import { validation, z } from "@homarr/validation";
import { createCustomErrorParams } from "@homarr/validation/form";
import { GroupSelectModal } from "~/components/access/group-select-modal";
import { StepperNavigationComponent } from "./stepper-navigation";
export const UserCreateStepperComponent = () => {
interface GroupWithPermissions {
id: string;
name: string;
permissions: GroupPermissionKey[];
}
interface UserCreateStepperComponentProps {
initialGroups: GroupWithPermissions[];
}
export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperComponentProps) => {
const t = useScopedI18n("management.page.user.create");
const tUserField = useScopedI18n("user.field");
@@ -73,7 +101,18 @@ export const UserCreateStepperComponent = () => {
},
);
const allForms = useMemo(() => [generalForm, securityForm], [generalForm, securityForm]);
const groupsForm = useZodForm(
z.object({
groups: z.array(z.string()),
}),
{
initialValues: {
groups: initialGroups.map((group) => group.id),
},
},
);
const allForms = useMemo(() => [generalForm, securityForm, groupsForm], [generalForm, securityForm, groupsForm]);
const activeForm = allForms[active];
const isCurrentFormValid = activeForm ? activeForm.isValid : () => true;
@@ -86,10 +125,11 @@ export const UserCreateStepperComponent = () => {
email: generalForm.values.email,
password: securityForm.values.password,
confirmPassword: securityForm.values.confirmPassword,
groupIds: groupsForm.values.groups,
});
}
nextStep();
}, [active, generalForm, mutateAsync, securityForm, nextStep]);
}, [active, generalForm, securityForm, groupsForm, mutateAsync, nextStep]);
const reset = useCallback(() => {
setActive(0);
@@ -144,13 +184,18 @@ export const UserCreateStepperComponent = () => {
</Card>
</form>
</Stepper.Step>
<Stepper.Step
label={t("step.permissions.label")}
description={t("step.permissions.description")}
allowStepSelect={false}
allowStepClick={false}
>
3
<Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}>
<Card p="xl">
<GroupsForm
initialGroups={initialGroups}
addGroup={(groupId) =>
groupsForm.setValues((value) => ({ groups: value.groups?.concat(groupId) ?? [groupId] }))
}
removeGroup={(groupId) => {
groupsForm.setValues((value) => ({ groups: value.groups?.filter((group) => group !== groupId) ?? [] }));
}}
/>
</Card>
</Stepper.Step>
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
<Card p="xl">
@@ -183,3 +228,111 @@ export const UserCreateStepperComponent = () => {
</>
);
};
interface GroupsFormProps {
addGroup: (groupId: string) => void;
removeGroup: (groupId: string) => void;
initialGroups: GroupWithPermissions[];
}
const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) => {
const t = useI18n();
const [groups, { append, filter }] = useListState<GroupWithPermissions>(initialGroups);
const { openModal } = useModalAction(GroupSelectModal);
const handleAddClick = () => {
openModal({
presentGroupIds: groups.map((group) => group.id),
withPermissions: true,
onSelect({ id, name, permissions }) {
if (!permissions) return;
startTransition(() => {
addGroup(id);
append({ id, name, permissions });
});
},
});
};
const handleGroupRemove = (id: string) => {
filter((group) => group.id !== id);
removeGroup(id);
};
return (
<form>
<Stack>
<Group justify="space-between">
<Stack gap={0}>
<Text fw={500}>{t("management.page.user.create.step.groups.title")}</Text>
<Text size="sm" c="gray.6">
{t("management.page.user.create.step.groups.description", { everyoneGroup })}
</Text>
</Stack>
<Button
variant="subtle"
color="gray"
leftSection={<IconPlus size={16} stroke={1.5} />}
onClick={handleAddClick}
>
{t("common.action.add")}
</Button>
</Group>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("group.field.name")}</Table.Th>
<Table.Th>{t("permission.title")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{groups.map((group) => (
<Table.Tr key={group.id}>
<Table.Td>{group.name}</Table.Td>
<Table.Td w="100%">
<Group gap="xs">
{Object.entries(groupPermissions)
.flatMap(([key, values]) =>
Array.isArray(values)
? values.map((value) => ({ key, value: value as string }))
: [{ key, value: key }],
)
.filter(({ key, value }) =>
group.permissions.some(
(permission) => permission === (key === value ? key : `${key}-${value}`),
),
)
.map(({ key, value }) => (
<PermissionBadge key={`${key}-${value}`} category={key} value={value} />
))}
</Group>
</Table.Td>
<Table.Td>
{group.name !== everyoneGroup && (
<Button variant="subtle" onClick={() => handleGroupRemove(group.id)}>
{t("common.action.remove")}
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</form>
);
};
const PermissionBadge = ({ category, value }: { category: string; value: string }) => {
const t = useI18n();
return (
<Tooltip label={t(`group.permission.${category}.item.${value}.description` as never)}>
<Badge color={category === "admin" ? "red" : "blue"} size="sm" variant="dot">
{t(`group.permission.${category}.item.${value}.label` as never)}
</Badge>
</Tooltip>
);
};

View File

@@ -2,6 +2,9 @@ import { notFound } from "next/navigation";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { db, inArray } from "@homarr/db";
import { groups } from "@homarr/db/schema/sqlite";
import { everyoneGroup } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -33,10 +36,22 @@ export default async function CreateUserPage() {
return notFound();
}
const initialGroups = await db.query.groups.findMany({
where: inArray(groups.name, [everyoneGroup]),
with: {
permissions: true,
},
});
return (
<>
<DynamicBreadcrumb />
<UserCreateStepperComponent />
<UserCreateStepperComponent
initialGroups={initialGroups.map((group) => ({
...group,
permissions: group.permissions.map(({ permission }) => permission),
}))}
/>
</>
);
}

View File

@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import { Button, useMatches } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -46,7 +47,7 @@ export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) =
userId: id,
},
{
onSuccess() {
async onSuccess() {
setInnerOwnerId(id);
showSuccessNotification({
title: tRoot("common.notification.transfer.success"),
@@ -55,6 +56,7 @@ export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) =
user: name,
}),
});
await revalidatePathActionAsync(`/manage/users/groups/${group.id}`);
},
onError() {
showErrorNotification({

View File

@@ -32,6 +32,7 @@ export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps)
await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`);
},
presentUserIds,
excludeExternalProviders: true,
},
{
title: tMembersAdd("label"),

View File

@@ -11,13 +11,9 @@ import { useModalAction } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { BoardItemAdvancedOptions } from "@homarr/validation";
import {
loadWidgetDynamic,
reduceWidgetOptionsWithDefaultValues,
WidgetEditModal,
widgetImports,
} from "@homarr/widgets";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { Dimensions } from "./_dimension-modal";
import { PreviewDimensionsModal } from "./_dimension-modal";

View File

@@ -1,7 +1,7 @@
import { headers } from "next/headers";
import { userAgent } from "next/server";
import type { NextRequest } from "next/server";
import { createOpenApiFetchHandler } from "trpc-swagger/build/index.mjs";
import { createOpenApiFetchHandler } from "trpc-to-openapi";
import { appRouter, createTRPCContext } from "@homarr/api";
import { hashPasswordAsync } from "@homarr/auth";
@@ -17,6 +17,11 @@ const handlerAsync = async (req: NextRequest) => {
const { ua } = userAgent(req);
const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue, ipAddress, ua);
// Fallback to JSON if no content type is set
if (!req.headers.has("Content-Type")) {
req.headers.set("Content-Type", "application/json");
}
return createOpenApiFetchHandler({
req,
endpoint: "/",
@@ -82,4 +87,10 @@ const getSessionOrDefaultFromHeadersAsync = async (
return await createSessionAsync(db, apiKeyFromDb.user);
};
export { handlerAsync as GET, handlerAsync as POST };
export {
handlerAsync as GET,
handlerAsync as POST,
handlerAsync as PUT,
handlerAsync as DELETE,
handlerAsync as PATCH,
};

View File

@@ -2,13 +2,15 @@ import { useState } from "react";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
withPermissions?: boolean;
presentGroupIds: string[];
onSelect: (props: { id: string; name: string }) => void | Promise<void>;
onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
confirmLabel?: string;
}
@@ -18,7 +20,9 @@ interface GroupSelectFormType {
export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const { data: groups, isPending } = clientApi.group.selectable.useQuery();
const { data: groups, isPending } = clientApi.group.selectable.useQuery({
withPermissions: innerProps.withPermissions,
});
const [loading, setLoading] = useState(false);
const form = useForm<GroupSelectFormType>();
const handleSubmitAsync = async (values: GroupSelectFormType) => {
@@ -28,6 +32,7 @@ export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }
await innerProps.onSelect({
id: currentGroup.id,
name: currentGroup.name,
permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined,
});
setLoading(false);

View File

@@ -12,6 +12,7 @@ import { UserAvatar } from "@homarr/ui";
interface InnerProps {
presentUserIds: string[];
excludeExternalProviders?: boolean;
onSelect: (props: { id: string; name: string; image: string }) => void | Promise<void>;
confirmLabel?: string;
}
@@ -22,7 +23,9 @@ interface UserSelectFormType {
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const { data: users, isPending } = clientApi.user.selectable.useQuery();
const { data: users, isPending } = clientApi.user.selectable.useQuery({
excludeExternalProviders: innerProps.excludeExternalProviders,
});
const [loading, setLoading] = useState(false);
const form = useForm<UserSelectFormType>();
const handleSubmitAsync = async (values: UserSelectFormType) => {

View File

@@ -5,7 +5,8 @@ import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } f
import { clientApi } from "@homarr/api/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { WidgetEditModal, widgetImports } from "@homarr/widgets";
import { widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { Item } from "~/app/[locale]/boards/_types";
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";

View File

@@ -0,0 +1,54 @@
import type { RouterOutputs } from "@homarr/api";
import type { CategorySection } from "~/app/[locale]/boards/_types";
export interface MoveCategoryInput {
id: string;
direction: "up" | "down";
}
export const moveCategoryCallback =
(input: MoveCategoryInput) =>
(previous: RouterOutputs["board"]["getHomeBoard"]): RouterOutputs["board"]["getHomeBoard"] => {
const currentCategory = previous.sections.find(
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
);
if (!currentCategory) {
return previous;
}
if (currentCategory.yOffset === 1 && input.direction === "up") {
return previous;
}
if (currentCategory.yOffset === previous.sections.length - 2 && input.direction === "down") {
return previous;
}
return {
...previous,
sections: previous.sections.map((section) => {
if (section.kind !== "category" && section.kind !== "empty") {
return section;
}
const offset = input.direction === "up" ? -2 : 2;
// Move category and empty section
if (section.yOffset === currentCategory.yOffset || section.yOffset === currentCategory.yOffset + 1) {
return {
...section,
yOffset: section.yOffset + offset,
};
}
if (
section.yOffset === currentCategory.yOffset + offset ||
section.yOffset === currentCategory.yOffset + offset + 1
) {
return {
...section,
yOffset: section.yOffset - offset,
};
}
return section;
}),
};
};

View File

@@ -0,0 +1,76 @@
import { describe, expect, test } from "vitest";
import type { Section } from "~/app/[locale]/boards/_types";
import { moveCategoryCallback } from "../move-category";
describe("Move Category", () => {
test.each([
[3, [0, 3, 4, 1, 2, 5, 6]],
[5, [0, 1, 2, 5, 6, 3, 4]],
])("should move category up", (moveId, expectedOrder) => {
const sections = createSections(3);
const input = {
id: moveId.toString(),
direction: "up" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
});
test.each([
[1, [0, 3, 4, 1, 2, 5, 6]],
[3, [0, 1, 2, 5, 6, 3, 4]],
])("should move category down", (moveId, expectedOrder) => {
const sections = createSections(3);
const input = {
id: moveId.toString(),
direction: "down" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
});
test("should not move category up if it is at the top", () => {
const sections = createSections(3);
const input = {
id: "1",
direction: "up" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
test("should not move category down if it is at the bottom", () => {
const sections = createSections(3);
const input = {
id: "5",
direction: "down" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
});
const createSections = (categoryCount: number) => {
return Array.from({ length: categoryCount * 2 + 1 }, (_, index) => ({
id: index.toString(),
kind: index % 2 === 1 ? ("category" as const) : ("empty" as const),
name: `Category ${index}`,
yOffset: index,
xOffset: 0,
items: [],
})) satisfies Section[];
};
const sortSections = (sections: Section[]) => {
return sections.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
};

View File

@@ -4,6 +4,8 @@ import { createId } from "@homarr/db/client";
import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
import type { MoveCategoryInput } from "./actions/move-category";
import { moveCategoryCallback } from "./actions/move-category";
interface AddCategory {
name: string;
@@ -15,11 +17,6 @@ interface RenameCategory {
name: string;
}
interface MoveCategory {
id: string;
direction: "up" | "down";
}
interface RemoveCategory {
id: string;
}
@@ -128,52 +125,8 @@ export const useCategoryActions = () => {
);
const moveCategory = useCallback(
({ id, direction }: MoveCategory) => {
updateBoard((previous) => {
const currentCategory = previous.sections.find(
(section): section is CategorySection => section.kind === "category" && section.id === id,
);
if (!currentCategory) return previous;
if (currentCategory.yOffset === 1 && direction === "up") return previous;
if (currentCategory.yOffset === previous.sections.length - 2 && direction === "down") return previous;
return {
...previous,
sections: previous.sections.map((section) => {
if (section.kind !== "category" && section.kind !== "empty") return section;
const offset = direction === "up" ? -2 : 2;
// Move category and empty section
if (section.yOffset === currentCategory.yOffset || section.yOffset - 1 === currentCategory.yOffset) {
return {
...section,
yOffset: section.yOffset + offset,
};
}
if (
direction === "up" &&
(section.yOffset === currentCategory.yOffset - 2 || section.yOffset === currentCategory.yOffset - 1)
) {
return {
...section,
position: section.yOffset + 2,
};
}
if (
direction === "down" &&
(section.yOffset === currentCategory.yOffset + 2 || section.yOffset === currentCategory.yOffset + 3)
) {
return {
...section,
position: section.yOffset - 2,
};
}
return section;
}),
};
});
(input: MoveCategoryInput) => {
updateBoard(moveCategoryCallback(input));
},
[updateBoard],
);

View File

@@ -15,6 +15,7 @@ import {
UnstyledButton,
useCombobox,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
@@ -34,16 +35,19 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
const tCommon = useScopedI18n("common");
const [data] = clientApi.icon.findIcons.useSuspenseQuery({
searchText: search,
const [debouncedSearch] = useDebouncedValue(search, 100);
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX.
const { data } = clientApi.icon.findIcons.useQuery({
searchText: debouncedSearch,
});
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
});
const totalOptions = data.icons.reduce((acc, group) => acc + group.icons.length, 0);
const groups = data.icons.map((group) => {
const totalOptions = data?.icons.reduce((acc, group) => acc + group.icons.length, 0) ?? 0;
const groups = data?.icons.map((group) => {
const options = group.icons.map((item) => (
<UnstyledButton
onClick={() => {
@@ -92,8 +96,12 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
<Combobox.Target>
<InputBase
rightSection={<Combobox.Chevron />}
// eslint-disable-next-line @next/next/no-img-element
leftSection={previewUrl ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null}
leftSection={
previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
) : null
}
value={search}
onChange={(event) => {
combobox.openDropdown();
@@ -118,7 +126,7 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
withAsterisk
error={error}
label={tCommon("iconPicker.label")}
placeholder={tCommon("iconPicker.header", { countIcons: data.countIcons })}
placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })}
/>
</Combobox.Target>

View File

@@ -0,0 +1,26 @@
import { Anchor, Card, Stack, Text } from "@mantine/core";
import type { TablerIcon } from "@tabler/icons-react";
interface NoResultsProps {
icon: TablerIcon;
title: string;
action?: {
label: string;
href: string;
hidden?: boolean;
};
}
export const NoResults = ({ icon: Icon, title, action }: NoResultsProps) => {
return (
<Card withBorder bg="transparent">
<Stack align="center" gap="sm">
<Icon size="2rem" />
<Text fw={500} size="lg">
{title}
</Text>
{!action?.hidden && <Anchor href={action?.href}>{action?.label}</Anchor>}
</Stack>
</Card>
);
};

View File

@@ -61,7 +61,8 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
}, [logoutUrl, openModal, router]);
return (
<Menu width={300} withArrow withinPortal>
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withArrow withinPortal keepMounted>
<Menu.Dropdown>
<Menu.Item onClick={toggleColorScheme} leftSection={<ColorSchemeIcon size="1rem" />}>
{colorSchemeText}

View File

@@ -3,6 +3,7 @@ import { createTRPCClient, httpLink } from "@trpc/client";
import SuperJSON from "superjson";
import type { AppRouter } from "@homarr/api";
import { createHeadersCallbackForSource } from "@homarr/api/client";
import { createI18nMiddleware } from "@homarr/translation/middleware";
export async function middleware(request: NextRequest) {
@@ -25,11 +26,7 @@ export const serverFetchApi = createTRPCClient<AppRouter>({
httpLink({
url: `http://${process.env.HOSTNAME ?? "localhost"}:3000/api/trpc`,
transformer: SuperJSON,
headers() {
const headers = new Headers();
headers.set("x-trpc-source", "server-fetch");
return headers;
},
headers: createHeadersCallbackForSource("server-fetch"),
}),
],
});

View File

@@ -10,7 +10,7 @@
"main": "./src/main.ts",
"types": "./src/main.ts",
"scripts": {
"build": "esbuild src/main.ts --bundle --platform=node --outfile=tasks.cjs",
"build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:@opentelemetry/api --outfile=tasks.cjs",
"clean": "rm -rf .turbo node_modules",
"dev": "pnpm with-env tsx ./src/main.ts",
"format": "prettier --check . --ignore-path ../../.gitignore",
@@ -38,17 +38,17 @@
"dayjs": "^1.11.13",
"dotenv": "^16.4.5",
"superjson": "2.2.1",
"undici": "6.21.0"
"undici": "7.0.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.9.1",
"@types/node": "^22.10.1",
"dotenv-cli": "^7.4.4",
"eslint": "^9.15.0",
"prettier": "^3.3.3",
"prettier": "^3.4.1",
"tsx": "4.19.2",
"typescript": "^5.6.3"
"typescript": "^5.7.2"
}
}

View File

@@ -8,7 +8,7 @@ import { LoggingAgent } from "~/undici-log-agent-override";
vi.mock("undici", () => {
return {
Agent: class Agent {
dispatch(_options: Dispatcher.DispatchOptions, _handler: Dispatcher.DispatchHandlers): boolean {
dispatch(_options: Dispatcher.DispatchOptions, _handler: Dispatcher.DispatchHandler): boolean {
return true;
}
},

View File

@@ -8,7 +8,7 @@ export class LoggingAgent extends Agent {
super(...props);
}
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers): boolean {
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
const url = new URL(`${options.origin as string}${options.path}`);
// The below code should prevent sensitive data from being logged as

View File

@@ -7,7 +7,7 @@
"main": "./src/main.ts",
"types": "./src/main.ts",
"scripts": {
"build": "esbuild src/main.ts --bundle --platform=node --outfile=wssServer.cjs --external:bcrypt --external:cpu-features --loader:.html=text --loader:.node=text",
"build": "esbuild src/main.ts --bundle --platform=node --outfile=wssServer.cjs --external:bcrypt --external:@opentelemetry/api --external:cpu-features --loader:.html=text --loader:.scss=text --loader:.node=text",
"clean": "rm -rf .turbo node_modules",
"dev": "pnpm with-env tsx ./src/main.ts",
"format": "prettier --check . --ignore-path ../../.gitignore",
@@ -35,7 +35,7 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.5.13",
"eslint": "^9.15.0",
"prettier": "^3.3.3",
"typescript": "^5.6.3"
"prettier": "^3.4.1",
"typescript": "^5.7.2"
}
}

View File

@@ -19,6 +19,7 @@
"lint": "turbo lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache",
"lint:fix": "turbo lint --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache",
"lint:ws": "pnpm dlx sherif@latest",
"package:new": "turbo gen init",
"test": "cross-env NODE_ENV=development vitest run --exclude e2e --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development vitest e2e",
"test:ui": "cross-env NODE_ENV=development vitest --exclude e2e --ui --coverage.enabled",
@@ -28,26 +29,21 @@
"prettier": "@homarr/prettier-config",
"devDependencies": {
"@homarr/prettier-config": "workspace:^0.1.0",
"@turbo/gen": "^2.3.1",
"@vitejs/plugin-react": "^4.3.3",
"@vitest/coverage-v8": "^2.1.5",
"@vitest/ui": "^2.1.5",
"@turbo/gen": "^2.3.3",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^2.1.6",
"@vitest/ui": "^2.1.6",
"cross-env": "^7.0.3",
"jsdom": "^25.0.1",
"prettier": "^3.3.3",
"prettier": "^3.4.1",
"testcontainers": "^10.15.0",
"turbo": "^2.3.1",
"typescript": "^5.6.3",
"turbo": "^2.3.3",
"typescript": "^5.7.2",
"vite-tsconfig-paths": "^5.1.3",
"vitest": "^2.1.5"
"vitest": "^2.1.6"
},
"packageManager": "pnpm@9.14.2",
"packageManager": "pnpm@9.14.4",
"engines": {
"node": ">=22.11.0"
},
"pnpm": {
"patchedDependencies": {
"trpc-swagger@1.2.6": "patches/trpc-swagger@1.2.6.patch"
}
}
}

View File

@@ -33,6 +33,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0",
"typescript": "^5.6.3"
"typescript": "^5.7.2"
}
}

View File

@@ -33,6 +33,7 @@
"@homarr/old-schema": "workspace:^0.1.0",
"@homarr/ping": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/request-handler": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@trpc/client": "next",
@@ -42,7 +43,7 @@
"next": "^14.2.18",
"react": "^18.3.1",
"superjson": "2.2.1",
"trpc-swagger": "^1.2.6"
"trpc-to-openapi": "^2.0.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
@@ -50,7 +51,7 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.32",
"eslint": "^9.15.0",
"prettier": "^3.3.3",
"typescript": "^5.6.3"
"prettier": "^3.4.1",
"typescript": "^5.7.2"
}
}

View File

@@ -127,7 +127,7 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
if (offset !== 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.join(",")}])`,
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
});
}
@@ -150,83 +150,6 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
});
};
/**
* Creates a middleware that provides the integrations and their items in the context that are of the specified kinds and have the specified item
* It also ensures that the user has permission to perform the specified action on the integrations
* @param action query for showing data or interact for mutating data
* @param kinds kinds of integrations that are supported
* @returns middleware that can be used with trpc
* @example publicProcedure.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", "piHole", "homeAssistant")).query(...)
* @throws TRPCError NOT_FOUND if the integration for the item was not found
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
*/
export const createManyIntegrationOfOneItemMiddleware = <TKind extends IntegrationKind>(
action: IntegrationAction,
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
) => {
return publicProcedure
.input(z.object({ integrationIds: z.array(z.string()).min(1), itemId: z.string() }))
.use(async ({ ctx, input, next }) => {
const dbIntegrations = await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
secrets: true,
items: {
with: {
item: {
with: {
section: {
columns: {
boardId: true,
},
},
},
},
},
},
userPermissions: true,
groupPermissions: true,
},
});
const offset = input.integrationIds.length - dbIntegrations.length;
if (offset !== 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}`,
});
}
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
const dbIntegrationWithItem = dbIntegrations.filter((integration) =>
integration.items.some((item) => item.itemId === input.itemId),
);
if (dbIntegrationWithItem.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integrations for item were not found",
});
}
return next({
ctx: {
integrations: dbIntegrationWithItem.map(
({ secrets, kind, groupPermissions: _ignore1, userPermissions: _ignore2, ...rest }) => ({
...rest,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
}),
),
},
});
});
};
/**
* Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
* @param action action to perform

View File

@@ -1,4 +1,4 @@
import { generateOpenApiDocument } from "trpc-swagger";
import { generateOpenApiDocument } from "trpc-to-openapi";
import { appRouter } from "./root";

View File

@@ -2,25 +2,17 @@ import { TRPCError } from "@trpc/server";
import { asc, createId, eq, inArray, like } from "@homarr/db";
import { apps } from "@homarr/db/schema/sqlite";
import { selectAppSchema } from "@homarr/db/validationSchemas";
import { validation, z } from "@homarr/validation";
import { convertIntersectionToZodObject } from "../schema-merger";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { canUserSeeAppAsync } from "./app/app-access-control";
export const appRouter = createTRPCRouter({
all: protectedProcedure
.input(z.void())
.output(
z.array(
z.object({
name: z.string(),
id: z.string(),
description: z.string().nullable(),
iconUrl: z.string(),
href: z.string().nullable(),
}),
),
)
.output(z.array(selectAppSchema))
.meta({ openapi: { method: "GET", path: "/api/apps", tags: ["apps"], protect: true } })
.query(({ ctx }) => {
return ctx.db.query.apps.findMany({
@@ -29,17 +21,7 @@ export const appRouter = createTRPCRouter({
}),
search: protectedProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.output(
z.array(
z.object({
name: z.string(),
id: z.string(),
description: z.string().nullable(),
iconUrl: z.string(),
href: z.string().nullable(),
}),
),
)
.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({
@@ -50,17 +32,7 @@ export const appRouter = createTRPCRouter({
}),
selectable: protectedProcedure
.input(z.void())
.output(
z.array(
z.object({
name: z.string(),
id: z.string(),
iconUrl: z.string(),
description: z.string().nullable(),
href: z.string().nullable(),
}),
),
)
.output(z.array(selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, description: true })))
.meta({
openapi: {
method: "GET",
@@ -83,15 +55,7 @@ export const appRouter = createTRPCRouter({
}),
byId: publicProcedure
.input(validation.common.byId)
.output(
z.object({
name: z.string(),
id: z.string(),
description: z.string().nullable(),
iconUrl: z.string(),
href: z.string().nullable(),
}),
)
.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({
@@ -136,7 +100,9 @@ export const appRouter = createTRPCRouter({
}),
update: permissionRequiredProcedure
.requiresPermission("app-modify-all")
.input(validation.app.edit)
.input(convertIntersectionToZodObject(validation.app.edit))
.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),

View File

@@ -229,10 +229,10 @@ export const boardRouter = createTRPCRouter({
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
if (input.visibility !== "public" && boardSettings.defaultBoardId === input.id) {
if (input.visibility !== "public" && boardSettings.homeBoardId === input.id) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot make default board private",
message: "Cannot make home board private",
});
}
@@ -259,13 +259,13 @@ export const boardRouter = createTRPCRouter({
})
: null;
// 1. user home board, 2. default board, 3. not found
// 1. user home board, 2. home board, 3. not found
let boardWhere: SQL<unknown> | null = null;
if (user?.homeBoardId) {
boardWhere = eq(boards.id, user.homeBoardId);
} else {
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
boardWhere = boardSettings.defaultBoardId ? eq(boards.id, boardSettings.defaultBoardId) : null;
boardWhere = boardSettings.homeBoardId ? eq(boards.id, boardSettings.homeBoardId) : null;
}
if (!boardWhere) {

View File

@@ -100,14 +100,33 @@ export const groupRouter = createTRPCRouter({
};
}),
// Is protected because also used in board access / integration access forms
selectable: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db.query.groups.findMany({
columns: {
id: true,
name: true,
},
});
}),
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(

View File

@@ -3,36 +3,51 @@ import { TRPCError } from "@trpc/server";
import { asc, createId, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema/sqlite";
import { selectInviteSchema } from "@homarr/db/validationSchemas";
import { z } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { throwIfCredentialsDisabled } from "./invite/checks";
export const inviteRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
throwIfCredentialsDisabled();
const dbInvites = await ctx.db.query.invites.findMany({
orderBy: asc(invites.expirationDate),
columns: {
token: false,
},
with: {
creator: {
columns: {
getAll: protectedProcedure
.output(
z.array(
selectInviteSchema
.pick({
id: true,
name: 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,
},
},
},
},
});
return dbInvites;
}),
});
}),
createInvite: protectedProcedure
.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();
@@ -56,6 +71,8 @@ export const inviteRouter = createTRPCRouter({
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({

View File

@@ -507,7 +507,7 @@ describe("getHomeBoard should return home board", () => {
const fullBoardProps = await createFullBoardAsync(db, "home");
await db.insert(serverSettings).values({
settingKey: "board",
value: SuperJSON.stringify({ defaultBoardId: fullBoardProps.boardId }),
value: SuperJSON.stringify({ homeBoardId: fullBoardProps.boardId }),
});
// Act

View File

@@ -316,7 +316,7 @@ describe("delete should delete user", () => {
await db.insert(schema.users).values(initialUsers);
await caller.delete(defaultOwnerId);
await caller.delete({ userId: defaultOwnerId });
const usersInDb = await db.select().from(schema.users);
expect(usersInDb).toHaveLength(2);

View File

@@ -4,10 +4,12 @@ import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { and, createId, eq, like, schema } from "@homarr/db";
import { groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema/sqlite";
import { selectUserSchema } from "@homarr/db/validationSchemas";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { validation, z } from "@homarr/validation";
import { convertIntersectionToZodObject } from "../schema-merger";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfCredentialsDisabled } from "./invite/checks";
@@ -44,31 +46,34 @@ export const userRouter = createTRPCRouter({
userId,
});
}),
register: publicProcedure.input(validation.user.registrationApi).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",
register: publicProcedure
.input(validation.user.registrationApi)
.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,
});
}
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
if (!dbInvite || dbInvite.expirationDate < new Date()) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid invite",
});
}
await createUserAsync(ctx.db, input);
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
// Delete invite as it's used
await ctx.db.delete(invites).where(inviteWhere);
}),
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 } })
@@ -78,9 +83,15 @@ export const userRouter = createTRPCRouter({
throwIfCredentialsDisabled();
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
await createUserAsync(ctx.db, input);
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(),
@@ -134,17 +145,7 @@ export const userRouter = createTRPCRouter({
getAll: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.void())
.output(
z.array(
z.object({
id: z.string(),
name: z.string().nullable(),
email: z.string().nullable(),
emailVerified: z.date().nullable(),
image: z.string().nullable(),
}),
),
)
.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({
@@ -158,15 +159,20 @@ export const userRouter = createTRPCRouter({
});
}),
// Is protected because also used in board access / integration access forms
selectable: protectedProcedure.query(({ ctx }) => {
return ctx.db.query.users.findMany({
columns: {
id: true,
name: true,
image: true,
},
});
}),
selectable: protectedProcedure
.input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional())
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: 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,
},
where: input?.excludeExternalProviders ? eq(users.provider, "credentials") : undefined,
});
}),
search: permissionRequiredProcedure
.requiresPermission("admin")
.input(
@@ -175,6 +181,8 @@ export const userRouter = createTRPCRouter({
limit: z.number().min(1).max(100).default(10),
}),
)
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: 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: {
@@ -191,16 +199,10 @@ export const userRouter = createTRPCRouter({
image: user.image,
}));
}),
getById: protectedProcedure.input(z.object({ userId: z.string() })).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: {
getById: protectedProcedure
.input(z.object({ userId: z.string() }))
.output(
selectUserSchema.pick({
id: true,
name: true,
email: true,
@@ -210,134 +212,170 @@ export const userRouter = createTRPCRouter({
homeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
},
where: eq(users.id, input.userId),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return user;
}),
editProfile: protectedProcedure.input(validation.user.editProfile).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.string()).mutation(async ({ input, ctx }) => {
// Only admins and user itself can delete a user
if (ctx.session.user.id !== input && !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));
}),
changePassword: protectedProcedure.input(validation.user.changePasswordApi).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(
`User ${user.id} is changing password for user ${input.userId}, previous password is required: ${isPreviousPasswordRequired}`,
);
if (isPreviousPasswordRequired) {
const previousPasswordHash = await hashPasswordAsync(input.previousPassword, dbUser.salt ?? "");
const isValid = previousPasswordHash === dbUser.password;
if (!isValid) {
}),
)
.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: "Invalid password",
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,
firstDayOfWeek: true,
pingIconsEnabled: true,
},
where: eq(users.id, input.userId),
});
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));
}),
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return user;
}),
editProfile: protectedProcedure
.input(validation.user.editProfile)
.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(validation.user.changePasswordApi)
.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(
`User ${user.id} is changing password for user ${input.userId}, previous password is required: ${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));
}),
changeHomeBoardId: protectedProcedure
.input(validation.user.changeHomeBoard.and(z.object({ userId: z.string() })))
.input(convertIntersectionToZodObject(validation.user.changeHomeBoard.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
@@ -369,14 +407,18 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, input.userId));
}),
changeColorScheme: protectedProcedure.input(validation.user.changeColorScheme).mutation(async ({ input, ctx }) => {
await ctx.db
.update(users)
.set({
colorScheme: input.colorScheme,
})
.where(eq(users.id, ctx.session.user.id));
}),
changeColorScheme: protectedProcedure
.input(validation.user.changeColorScheme)
.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));
}),
getPingIconsEnabledOrDefault: publicProcedure.query(async ({ ctx }) => {
if (!ctx.session?.user) {
return false;
@@ -410,7 +452,7 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, ctx.session.user.id));
}),
getFirstDayOfWeekForUserOrDefault: publicProcedure.query(async ({ ctx }) => {
getFirstDayOfWeekForUserOrDefault: publicProcedure.input(z.undefined()).query(async ({ ctx }) => {
if (!ctx.session?.user) {
return 1 as const;
}
@@ -426,7 +468,9 @@ export const userRouter = createTRPCRouter({
return user?.firstDayOfWeek ?? (1 as const);
}),
changeFirstDayOfWeek: protectedProcedure
.input(validation.user.firstDayOfWeek.and(validation.common.byId))
.input(convertIntersectionToZodObject(validation.user.firstDayOfWeek.and(validation.common.byId)))
.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) {
@@ -459,7 +503,7 @@ export const userRouter = createTRPCRouter({
}),
});
const createUserAsync = async (db: Database, input: z.infer<typeof validation.user.create>) => {
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof validation.user.baseCreate>, "groupIds">) => {
const salt = await createSaltAsync();
const hashedPassword = await hashPasswordAsync(input.password, salt);

View File

@@ -1,20 +1,24 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { CalendarEvent } from "@homarr/integrations/types";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { z } from "@homarr/validation";
import { createManyIntegrationOfOneItemMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure
.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx }) => {
const result = await Promise.all(
ctx.integrations.flatMap(async (integration) => {
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
return await cache.getAsync();
.input(z.object({ year: z.number(), month: z.number(), releaseType: z.array(z.enum(radarrReleaseTypes)) }))
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx, input }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = calendarMonthRequestHandler.handler(integration, input);
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
);
return result.filter((item) => item !== null).flatMap((item) => item.data);
return results.flat();
}),
});

View File

@@ -2,31 +2,33 @@ import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema/sqlite";
import type { IntegrationKindByCategory, WidgetKind } from "@homarr/definitions";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import { controlsInputSchema } from "@homarr/integrations/types";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { z } from "@homarr/validation";
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const dnsHoleRouter = createTRPCRouter({
summary: publicProcedure
.input(z.object({ widgetKind: z.enum(["dnsHoleSummary", "dnsHoleControls"]) }))
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.query(async ({ input: { widgetKind }, ctx }) => {
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async ({ decryptedSecrets: _, ...integration }) => {
const channel = createItemAndIntegrationChannel<DnsHoleSummary>(widgetKind, integration.id);
const { data: summary, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) };
ctx.integrations.map(async (integration) => {
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration,
timestamp,
summary,
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
@@ -34,22 +36,19 @@ export const dnsHoleRouter = createTRPCRouter({
}),
subscribeToSummary: publicProcedure
.input(z.object({ widgetKind: z.enum(["dnsHoleSummary", "dnsHoleControls"]) }))
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.subscription(({ input: { widgetKind }, ctx }) => {
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"dnsHole"> }>;
timestamp: Date;
summary: DnsHoleSummary;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const channel = createItemAndIntegrationChannel<DnsHoleSummary>(widgetKind as WidgetKind, integration.id);
const unsubscribe = channel.subscribe((summary) => {
const innerHandler = dnsHoleRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
timestamp: new Date(),
summary,
});
});
@@ -68,6 +67,12 @@ export const dnsHoleRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration } }) => {
const client = integrationCreator(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: publicProcedure
@@ -76,5 +81,11 @@ export const dnsHoleRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration }, input }) => {
const client = integrationCreator(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

@@ -6,7 +6,7 @@ import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { downloadClientItemSchema, integrationCreator } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
import { z } from "@homarr/validation";
import type { IntegrationAction } from "../../middlewares/integration";
@@ -21,12 +21,18 @@ export const downloadsRouter = createTRPCRouter({
.unstable_concat(createDownloadClientIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async ({ decryptedSecrets: _, ...integration }) => {
const channel = createItemAndIntegrationChannel<DownloadClientJobsAndStatus>("downloads", integration.id);
const { data, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) };
ctx.integrations.map(async (integration) => {
const innerHandler = downloadClientRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration,
timestamp,
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
};
}),
@@ -37,17 +43,15 @@ export const downloadsRouter = createTRPCRouter({
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
timestamp: Date;
data: DownloadClientJobsAndStatus;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const channel = createItemAndIntegrationChannel<DownloadClientJobsAndStatus>("downloads", integration.id);
const unsubscribe = channel.subscribe((data) => {
const innerHandler = downloadClientRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((data) => {
emit.next({
integration,
timestamp: new Date(),
data,
});
});

View File

@@ -1,7 +1,7 @@
import { observable } from "@trpc/server/observable";
import type { HealthMonitoring } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -12,14 +12,14 @@ export const healthMonitoringRouter = createTRPCRouter({
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id);
const { data: healthInfo, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) };
const innerHandler = systemInfoRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
healthInfo,
timestamp,
healthInfo: data,
updatedAt: timestamp,
};
}),
);
@@ -31,8 +31,8 @@ export const healthMonitoringRouter = createTRPCRouter({
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) {
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id);
const unsubscribe = channel.subscribe((healthInfo) => {
const innerHandler = systemInfoRequestHandler.handler(integration, {});
const unsubscribe = innerHandler.subscribe((healthInfo) => {
emit.next({
integrationId: integration.id,
healthInfo,

View File

@@ -5,7 +5,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations";
import type { Indexer } from "@homarr/integrations/types";
import { logger } from "@homarr/log";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
@@ -20,14 +20,8 @@ export const indexerManagerRouter = createTRPCRouter({
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const client = integrationCreator(integration);
const indexers = await client.getIndexersAsync().catch((err) => {
logger.error("indexer-manager router - ", err);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to fetch indexers for ${integration.name} (${integration.id})`,
});
});
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
const { data: indexers } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
@@ -43,11 +37,11 @@ export const indexerManagerRouter = createTRPCRouter({
.subscription(({ ctx }) => {
return observable<{ integrationId: string; indexers: Indexer[] }>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) {
const channel = createItemAndIntegrationChannel<Indexer[]>("indexerManager", integration.id);
const unsubscribe = channel.subscribe((indexers) => {
for (const integrationWithSecrets of ctx.integrations) {
const innerHandler = indexerManagerRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((indexers) => {
emit.next({
integrationId: integration.id,
integrationId: integrationWithSecrets.id,
indexers,
});
});
@@ -60,7 +54,6 @@ export const indexerManagerRouter = createTRPCRouter({
};
});
}),
testAllIndexers: publicProcedure
.unstable_concat(createIndexerManagerIntegrationMiddleware("interact"))
.mutation(async ({ ctx }) => {

View File

@@ -1,53 +1,110 @@
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { MediaRequestList, MediaRequestStats } from "@homarr/integrations";
import { integrationCreator } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { integrationCreator, MediaRequestStatus } from "@homarr/integrations";
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 { z } from "@homarr/validation";
import {
createManyIntegrationOfOneItemMiddleware,
createOneIntegrationMiddleware,
} from "../../middlewares/integration";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
export const mediaRequestsRouter = createTRPCRouter({
getLatestRequests: publicProcedure
.unstable_concat(
createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")),
)
.query(async ({ input }) => {
return await Promise.all(
input.integrationIds.map(async (integrationId) => {
const channel = createItemAndIntegrationChannel<MediaRequestList>("mediaRequests-requestList", integrationId);
return await channel.getAsync();
.unstable_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(({ status: statusA }, { status: statusB }) => {
if (statusA === MediaRequestStatus.PendingApproval) {
return -1;
}
if (statusB === MediaRequestStatus.PendingApproval) {
return 1;
}
return 0;
});
}),
subscribeToLatestRequests: publicProcedure
.unstable_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
.unstable_concat(
createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")),
)
.query(async ({ input }) => {
return await Promise.all(
input.integrationIds.map(async (integrationId) => {
const channel = createItemAndIntegrationChannel<MediaRequestStats>(
"mediaRequests-requestStats",
integrationId,
);
return await channel.getAsync();
.unstable_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
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
.input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) }))
.mutation(async ({ ctx: { integration }, input }) => {
const integrationInstance = integrationCreator(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

@@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { StreamSession } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
@@ -17,11 +17,11 @@ export const mediaServerRouter = createTRPCRouter({
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
const channel = createItemAndIntegrationChannel<StreamSession[]>("mediaServer", integration.id);
const data = await channel.getAsync();
const innerHandler = mediaServerRequestHandler.handler(integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
sessions: data?.data ?? [],
sessions: data,
};
}),
);
@@ -32,8 +32,9 @@ export const mediaServerRouter = createTRPCRouter({
return observable<{ integrationId: string; data: StreamSession[] }>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) {
const channel = createItemAndIntegrationChannel<StreamSession[]>("mediaServer", integration.id);
const unsubscribe = channel.subscribe((sessions) => {
const innerHandler = mediaServerRequestHandler.handler(integration, {});
const unsubscribe = innerHandler.subscribe((sessions) => {
emit.next({
integrationId: integration.id,
data: sessions,

View File

@@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations";
import { homeAssistantEntityState } from "@homarr/redis";
import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
import { z } from "@homarr/validation";
import type { IntegrationAction } from "../../middlewares/integration";
@@ -13,29 +13,45 @@ const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer"));
export const smartHomeRouter = createTRPCRouter({
subscribeEntityState: publicProcedure.input(z.object({ entityId: z.string() })).subscription(({ input }) => {
return observable<{
entityId: string;
state: string;
}>((emit) => {
const unsubscribe = homeAssistantEntityState.subscribe((message) => {
if (message.entityId !== input.entityId) {
return;
}
emit.next(message);
});
entityState: publicProcedure
.input(z.object({ entityId: z.string() }))
.unstable_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
.unstable_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();
};
});
}),
return () => {
unsubscribe();
};
});
}),
switchEntity: publicProcedure
.unstable_concat(createSmartHomeIntegrationMiddleware("interact"))
.input(z.object({ entityId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = integrationCreator(integration);
return await client.triggerToggleAsync(input.entityId);
const success = await client.triggerToggleAsync(input.entityId);
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
await innerHandler.invalidateAsync();
return success;
}),
executeAutomation: publicProcedure
.unstable_concat(createSmartHomeIntegrationMiddleware("interact"))

View File

@@ -0,0 +1,22 @@
import type { AnyZodObject, ZodIntersection, ZodObject } from "@homarr/validation";
import { z } from "@homarr/validation";
export function convertIntersectionToZodObject<TIntersection extends ZodIntersection<AnyZodObject, AnyZodObject>>(
intersection: TIntersection,
) {
const { _def } = intersection;
// Merge the shapes
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const mergedShape = { ..._def.left.shape, ..._def.right.shape };
// Return a new ZodObject
return z.object(mergedShape) as unknown as TIntersection extends ZodIntersection<infer TLeft, infer TRight>
? TLeft extends AnyZodObject
? TRight extends AnyZodObject
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
ZodObject<TLeft["shape"] & TRight["shape"], any, any, z.infer<TLeft> & z.infer<TRight>>
: never
: never
: never;
}

View File

@@ -0,0 +1,16 @@
import { expect, test, vi } from "vitest";
import { openApiDocument } from "../open-api";
vi.mock("@homarr/auth", () => ({}));
test("OpenAPI documentation should be generated", () => {
// Arrange
const base = "https://homarr.dev";
// Act
const act = () => openApiDocument(base);
// Assert
expect(act).not.toThrow();
});

View File

@@ -8,7 +8,7 @@
*/
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { OpenApiMeta } from "trpc-swagger";
import type { OpenApiMeta } from "trpc-to-openapi";
import type { Session } from "@homarr/auth";
import { FlattenError } from "@homarr/common";

View File

@@ -3,11 +3,11 @@ import { DrizzleAdapter } from "@auth/drizzle-adapter";
import type { Database } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { accounts, users } from "@homarr/db/schema/sqlite";
import { accounts, sessions, users } from "@homarr/db/schema/sqlite";
import type { SupportedAuthProvider } from "@homarr/definitions";
export const createAdapter = (db: Database, provider: SupportedAuthProvider | "unknown"): Adapter => {
const drizzleAdapter = DrizzleAdapter(db, { usersTable: users, accountsTable: accounts });
const drizzleAdapter = DrizzleAdapter(db, { usersTable: users, sessionsTable: sessions, accountsTable: accounts });
return {
...drizzleAdapter,

View File

@@ -46,7 +46,7 @@
"@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.0",
"eslint": "^9.15.0",
"prettier": "^3.3.3",
"typescript": "^5.6.3"
"prettier": "^3.4.1",
"typescript": "^5.7.2"
}
}

View File

@@ -59,10 +59,11 @@ export class LdapClient {
private static convertEntryPropertyToString(value: Entry[string]) {
const firstValue = Array.isArray(value) ? (value[0] ?? "") : value;
if (firstValue instanceof Buffer) {
return firstValue.toString("utf8");
if (typeof firstValue === "string") {
return firstValue;
}
return firstValue;
return firstValue.toString("utf8");
}
/**

View File

@@ -34,6 +34,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0",
"typescript": "^5.6.3"
"typescript": "^5.7.2"
}
}

View File

@@ -29,13 +29,13 @@
"dayjs": "^1.11.13",
"next": "^14.2.18",
"react": "^18.3.1",
"tldts": "^6.1.63"
"tldts": "^6.1.64"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0",
"typescript": "^5.6.3"
"typescript": "^5.7.2"
}
}

View File

@@ -21,3 +21,20 @@ export const useTimeAgo = (timestamp: Date) => {
return timeAgo;
};
export const useIntegrationConnected = (updatedAt: Date, { timeout = 30000 }) => {
const [connected, setConnected] = useState(Math.abs(dayjs(updatedAt).diff()) < timeout);
useEffect(() => {
setConnected(Math.abs(dayjs(updatedAt).diff()) < timeout);
const delayUntilTimeout = timeout - Math.abs(dayjs(updatedAt).diff());
const timeoutRef = setTimeout(() => {
setConnected(false);
}, delayUntilTimeout);
return () => clearTimeout(timeoutRef);
}, [updatedAt, timeout]);
return connected;
};

View File

@@ -1,3 +1,5 @@
import { hashKey } from "@tanstack/query-core";
export function objectKeys<O extends object>(obj: O): (keyof O)[] {
return Object.keys(obj) as (keyof O)[];
}
@@ -7,3 +9,7 @@ type Entries<T> = {
}[keyof T][];
export const objectEntries = <T extends object>(obj: T) => Object.entries(obj) as Entries<T>;
export const hashObjectBase64 = (obj: object) => {
return Buffer.from(hashKey([obj])).toString("base64");
};

View File

@@ -9,3 +9,5 @@ export type Modify<T, R extends Partial<Record<keyof T, unknown>>> = {
export type RemoveReadonly<T> = {
-readonly [P in keyof T]: T[P] extends Record<string, unknown> ? RemoveReadonly<T[P]> : T[P];
};
export type MaybeArray<T> = T | T[];

View File

@@ -31,6 +31,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0",
"typescript": "^5.6.3"
"typescript": "^5.7.2"
}
}

View File

@@ -30,6 +30,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0",
"typescript": "^5.6.3"
"typescript": "^5.7.2"
}
}

View File

@@ -33,6 +33,6 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11",
"eslint": "^9.15.0",
"typescript": "^5.6.3"
"typescript": "^5.7.2"
}
}

View File

@@ -35,6 +35,7 @@
"@homarr/log": "workspace:^0.1.0",
"@homarr/ping": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/request-handler": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0"
@@ -44,6 +45,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0",
"typescript": "^5.6.3"
"typescript": "^5.7.2"
}
}

View File

@@ -6,7 +6,7 @@ import { healthMonitoringJob } from "./jobs/integrations/health-monitoring";
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
import { mediaRequestsJob } from "./jobs/integrations/media-requests";
import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests";
import { mediaServerJob } from "./jobs/integrations/media-server";
import { pingJob } from "./jobs/ping";
import type { RssFeed } from "./jobs/rss-feeds";
@@ -23,7 +23,8 @@ export const jobGroup = createCronJobGroup({
mediaOrganizer: mediaOrganizerJob,
downloads: downloadsJob,
dnsHole: dnsHoleJob,
mediaRequests: mediaRequestsJob,
mediaRequestStats: mediaRequestStatsJob,
mediaRequestList: mediaRequestListJob,
rssFeeds: rssFeedsJob,
indexerManager: indexerManagerJob,
healthMonitoring: healthMonitoringJob,

View File

@@ -1,28 +1,15 @@
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import { logger } from "@homarr/log";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { createCronJob } from "../../lib";
export const dnsHoleJob = createCronJob("dnsHole", EVERY_5_SECONDS).withCallback(async () => {
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
kinds: ["dnsHoleSummary", "dnsHoleControls"],
});
for (const itemForIntegration of itemsForIntegration) {
for (const { integration } of itemForIntegration.integrations) {
const integrationInstance = integrationCreatorFromSecrets(integration);
await integrationInstance
.getSummaryAsync()
.then(async (data) => {
const channel = createItemAndIntegrationChannel<DnsHoleSummary>(itemForIntegration.kind, integration.id);
await channel.publishAndUpdateLastStateAsync(data);
})
.catch((error) => logger.error(`Could not retrieve data for ${integration.name}: "${error}"`));
}
}
});
export const dnsHoleJob = createCronJob("dnsHole", EVERY_5_SECONDS).withCallback(
createRequestIntegrationJobHandler(dnsHoleRequestHandler.handler, {
widgetKinds: ["dnsHoleSummary", "dnsHoleControls"],
getInput: {
dnsHoleSummary: () => ({}),
dnsHoleControls: () => ({}),
},
}),
);

View File

@@ -1,27 +1,14 @@
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { createCronJob } from "../../lib";
export const downloadsJob = createCronJob("downloads", EVERY_5_SECONDS).withCallback(async () => {
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
kinds: ["downloads"],
});
for (const itemForIntegration of itemsForIntegration) {
for (const { integration } of itemForIntegration.integrations) {
const integrationInstance = integrationCreatorFromSecrets(integration);
await integrationInstance
.getClientJobsAndStatusAsync()
.then(async (data) => {
const channel = createItemAndIntegrationChannel<DownloadClientJobsAndStatus>("downloads", integration.id);
await channel.publishAndUpdateLastStateAsync(data);
})
.catch((error) => console.error(`Could not retrieve data for ${integration.name}: "${error}"`));
}
}
});
export const downloadsJob = createCronJob("downloads", EVERY_5_SECONDS).withCallback(
createRequestIntegrationJobHandler(downloadClientRequestHandler.handler, {
widgetKinds: ["downloads"],
getInput: {
downloads: () => ({}),
},
}),
);

View File

@@ -1,22 +1,14 @@
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { createCronJob } from "../../lib";
export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(async () => {
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
kinds: ["healthMonitoring"],
});
for (const itemForIntegration of itemsForIntegration) {
for (const integration of itemForIntegration.integrations) {
const openmediavault = integrationCreatorFromSecrets(integration.integration);
const healthInfo = await openmediavault.getSystemInfoAsync();
const channel = createItemAndIntegrationChannel("healthMonitoring", integration.integrationId);
await channel.publishAndUpdateLastStateAsync(healthInfo);
}
}
});
export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(
createRequestIntegrationJobHandler(systemInfoRequestHandler.handler, {
widgetKinds: ["healthMonitoring"],
getInput: {
healthMonitoring: () => ({}),
},
}),
);

View File

@@ -1,42 +1,16 @@
import SuperJSON from "superjson";
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { logger } from "@homarr/log";
import { homeAssistantEntityState } from "@homarr/redis";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
// This import is done that way to avoid circular dependencies.
import type { WidgetComponentProps } from "../../../../widgets";
import { createCronJob } from "../../lib";
export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVERY_MINUTE).withCallback(async () => {
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
kinds: ["smartHome-entityState"],
});
for (const itemForIntegration of itemsForIntegration) {
const integration = itemForIntegration.integrations[0]?.integration;
if (!integration) {
continue;
}
const options = SuperJSON.parse<WidgetComponentProps<"smartHome-entityState">["options"]>(
itemForIntegration.options,
);
const homeAssistant = integrationCreatorFromSecrets(integration);
const state = await homeAssistant.getEntityStateAsync(options.entityId);
if (!state.success) {
logger.error("Unable to fetch data from Home Assistant");
continue;
}
await homeAssistantEntityState.publishAsync({
entityId: options.entityId,
state: state.data.state,
});
}
});
export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVERY_MINUTE).withCallback(
createRequestIntegrationJobHandler(smartHomeEntityStateRequestHandler.handler, {
widgetKinds: ["smartHome-entityState"],
getInput: {
"smartHome-entityState": (options) => ({
entityId: options.entityId,
}),
},
}),
);

View File

@@ -1,19 +1,14 @@
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { createCronJob } from "../../lib";
export const indexerManagerJob = createCronJob("indexerManager", EVERY_MINUTE).withCallback(async () => {
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
kinds: ["indexerManager"],
});
for (const itemForIntegration of itemsForIntegration) {
for (const { integration } of itemForIntegration.integrations) {
const integrationInstance = integrationCreatorFromSecrets(integration);
await integrationInstance.getIndexersAsync();
}
}
});
export const indexerManagerJob = createCronJob("indexerManager", EVERY_5_MINUTES).withCallback(
createRequestIntegrationJobHandler(indexerManagerRequestHandler.handler, {
widgetKinds: ["indexerManager"],
getInput: {
indexerManager: () => ({}),
},
}),
);

View File

@@ -1,36 +1,35 @@
import dayjs from "dayjs";
import SuperJSON from "superjson";
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import type { CalendarEvent } from "@homarr/integrations/types";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
// This import is done that way to avoid circular dependencies.
import type { WidgetComponentProps } from "../../../../widgets";
import { createCronJob } from "../../lib";
export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback(async () => {
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
kinds: ["calendar"],
});
export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback(
createRequestIntegrationJobHandler(calendarMonthRequestHandler.handler, {
widgetKinds: ["calendar"],
getInput: {
// Request handler will run for all specified months
calendar: (options) => {
const inputs = [];
for (const itemForIntegration of itemsForIntegration) {
for (const { integration } of itemForIntegration.integrations) {
const options = SuperJSON.parse<WidgetComponentProps<"calendar">["options"]>(itemForIntegration.options);
const startOffset = -Number(options.filterPastMonths);
const endOffset = Number(options.filterFutureMonths);
const start = dayjs().subtract(Number(options.filterPastMonths), "months").toDate();
const end = dayjs().add(Number(options.filterFutureMonths), "months").toDate();
for (let offsetMonths = startOffset; offsetMonths <= endOffset; offsetMonths++) {
const year = dayjs().subtract(offsetMonths, "months").year();
const month = dayjs().subtract(offsetMonths, "months").month();
//Asserting the integration kind until all of them get implemented
const integrationInstance = integrationCreatorFromSecrets(integration);
inputs.push({
year,
month,
releaseType: options.releaseType,
});
}
const events = await integrationInstance.getCalendarEventsAsync(start, end);
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
await cache.setAsync(events);
}
}
});
return inputs;
},
},
}),
);

View File

@@ -1,42 +1,24 @@
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
import type { MediaRequestList, MediaRequestStats } from "@homarr/integrations";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createCronJob } from "../../lib";
export const mediaRequestsJob = createCronJob("mediaRequests", EVERY_5_SECONDS).withCallback(async () => {
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
kinds: ["mediaRequests-requestList", "mediaRequests-requestStats"],
});
export const mediaRequestStatsJob = createCronJob("mediaRequestStats", EVERY_MINUTE).withCallback(
createRequestIntegrationJobHandler(mediaRequestStatsRequestHandler.handler, {
widgetKinds: ["mediaRequests-requestStats"],
getInput: {
"mediaRequests-requestStats": () => ({}),
},
}),
);
for (const itemForIntegration of itemsForIntegration) {
for (const { integration } of itemForIntegration.integrations) {
const requestsIntegration = integrationCreatorFromSecrets(integration);
const mediaRequests = await requestsIntegration.getRequestsAsync();
const requestsStats = await requestsIntegration.getStatsAsync();
const requestsUsers = await requestsIntegration.getUsersAsync();
const requestListChannel = createItemAndIntegrationChannel<MediaRequestList>(
"mediaRequests-requestList",
integration.id,
);
await requestListChannel.publishAndUpdateLastStateAsync({
integration: { id: integration.id },
medias: mediaRequests,
});
const requestStatsChannel = createItemAndIntegrationChannel<MediaRequestStats>(
"mediaRequests-requestStats",
integration.id,
);
await requestStatsChannel.publishAndUpdateLastStateAsync({
integration: { kind: integration.kind, name: integration.name },
stats: requestsStats,
users: requestsUsers,
});
}
}
});
export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE).withCallback(
createRequestIntegrationJobHandler(mediaRequestListRequestHandler.handler, {
widgetKinds: ["mediaRequests-requestList"],
getInput: {
"mediaRequests-requestList": () => ({}),
},
}),
);

View File

@@ -1,22 +1,14 @@
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
import { createCronJob } from "../../lib";
export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).withCallback(async () => {
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
kinds: ["mediaServer"],
});
for (const itemForIntegration of itemsForIntegration) {
for (const { integration } of itemForIntegration.integrations) {
const integrationInstance = integrationCreatorFromSecrets(integration);
const streamSessions = await integrationInstance.getCurrentSessionsAsync();
const channel = createItemAndIntegrationChannel("mediaServer", integration.id);
await channel.publishAndUpdateLastStateAsync(streamSessions);
}
}
});
export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).withCallback(
createRequestIntegrationJobHandler(mediaServerRequestHandler.handler, {
widgetKinds: ["mediaServer"],
getInput: {
mediaServer: () => ({}),
},
}),
);

View File

@@ -7,6 +7,7 @@ dotenv.config({ path: "../../.env" });
export default {
dialect: "mysql",
schema: "./schema",
casing: "snake_case",
dbCredentials: {
host: process.env.DB_HOST!,
user: process.env.DB_USER!,

View File

@@ -6,6 +6,7 @@ dotenv.config({ path: "../../.env" });
export default {
dialect: "sqlite",
schema: "./schema",
casing: "snake_case",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
dbCredentials: { url: process.env.DB_URL! },
out: "./migrations/sqlite",

View File

@@ -40,6 +40,7 @@ const initBetterSqlite = () => {
database = drizzleSqlite(connection, {
schema: sqliteSchema,
logger: new WinstonDrizzleLogger(),
casing: "snake_case",
}) as unknown as never;
};
@@ -61,6 +62,7 @@ const initMySQL2 = () => {
schema: mysqlSchema,
mode: "default",
logger: new WinstonDrizzleLogger(),
casing: "snake_case",
}) as unknown as HomarrDatabase;
};

View File

@@ -0,0 +1,64 @@
ALTER TABLE `account` RENAME COLUMN `userId` TO `user_id`;--> statement-breakpoint
ALTER TABLE `account` RENAME COLUMN `providerAccountId` TO `provider_account_id`;--> statement-breakpoint
ALTER TABLE `apiKey` RENAME COLUMN `apiKey` TO `api_key`;--> statement-breakpoint
ALTER TABLE `apiKey` RENAME COLUMN `userId` TO `user_id`;--> statement-breakpoint
ALTER TABLE `groupMember` RENAME COLUMN `groupId` TO `group_id`;--> statement-breakpoint
ALTER TABLE `groupMember` RENAME COLUMN `userId` TO `user_id`;--> statement-breakpoint
ALTER TABLE `groupPermission` RENAME COLUMN `groupId` TO `group_id`;--> statement-breakpoint
ALTER TABLE `iconRepository` RENAME COLUMN `iconRepository_id` TO `id`;--> statement-breakpoint
ALTER TABLE `iconRepository` RENAME COLUMN `iconRepository_slug` TO `slug`;--> statement-breakpoint
ALTER TABLE `icon` RENAME COLUMN `icon_id` TO `id`;--> statement-breakpoint
ALTER TABLE `icon` RENAME COLUMN `icon_name` TO `name`;--> statement-breakpoint
ALTER TABLE `icon` RENAME COLUMN `icon_url` TO `url`;--> statement-breakpoint
ALTER TABLE `icon` RENAME COLUMN `icon_checksum` TO `checksum`;--> statement-breakpoint
ALTER TABLE `icon` RENAME COLUMN `iconRepository_id` TO `icon_repository_id`;--> statement-breakpoint
ALTER TABLE `serverSetting` RENAME COLUMN `key` TO `setting_key`;--> statement-breakpoint
ALTER TABLE `session` RENAME COLUMN `sessionToken` TO `session_token`;--> statement-breakpoint
ALTER TABLE `session` RENAME COLUMN `userId` TO `user_id`;--> statement-breakpoint
ALTER TABLE `user` RENAME COLUMN `emailVerified` TO `email_verified`;--> statement-breakpoint
ALTER TABLE `user` RENAME COLUMN `homeBoardId` TO `home_board_id`;--> statement-breakpoint
ALTER TABLE `user` RENAME COLUMN `colorScheme` TO `color_scheme`;--> statement-breakpoint
ALTER TABLE `user` RENAME COLUMN `firstDayOfWeek` TO `first_day_of_week`;--> statement-breakpoint
ALTER TABLE `user` RENAME COLUMN `pingIconsEnabled` TO `ping_icons_enabled`;--> statement-breakpoint
ALTER TABLE `serverSetting` DROP INDEX `serverSetting_key_unique`;--> statement-breakpoint
ALTER TABLE `account` DROP FOREIGN KEY `account_userId_user_id_fk`;
--> statement-breakpoint
ALTER TABLE `apiKey` DROP FOREIGN KEY `apiKey_userId_user_id_fk`;
--> statement-breakpoint
ALTER TABLE `groupMember` DROP FOREIGN KEY `groupMember_groupId_group_id_fk`;
--> statement-breakpoint
ALTER TABLE `groupMember` DROP FOREIGN KEY `groupMember_userId_user_id_fk`;
--> statement-breakpoint
ALTER TABLE `groupPermission` DROP FOREIGN KEY `groupPermission_groupId_group_id_fk`;
--> statement-breakpoint
ALTER TABLE `icon` DROP FOREIGN KEY `icon_iconRepository_id_iconRepository_iconRepository_id_fk`;
--> statement-breakpoint
ALTER TABLE `session` DROP FOREIGN KEY `session_userId_user_id_fk`;
--> statement-breakpoint
ALTER TABLE `user` DROP FOREIGN KEY `user_homeBoardId_board_id_fk`;
--> statement-breakpoint
DROP INDEX `userId_idx` ON `account`;--> statement-breakpoint
DROP INDEX `user_id_idx` ON `session`;--> statement-breakpoint
ALTER TABLE `account` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `groupMember` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `iconRepository` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `icon` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `serverSetting` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `session` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `account` ADD PRIMARY KEY(`provider`,`provider_account_id`);--> statement-breakpoint
ALTER TABLE `groupMember` ADD PRIMARY KEY(`group_id`,`user_id`);--> statement-breakpoint
ALTER TABLE `iconRepository` ADD PRIMARY KEY(`id`);--> statement-breakpoint
ALTER TABLE `icon` ADD PRIMARY KEY(`id`);--> statement-breakpoint
ALTER TABLE `serverSetting` ADD PRIMARY KEY(`setting_key`);--> statement-breakpoint
ALTER TABLE `session` ADD PRIMARY KEY(`session_token`);--> statement-breakpoint
ALTER TABLE `serverSetting` ADD CONSTRAINT `serverSetting_settingKey_unique` UNIQUE(`setting_key`);--> statement-breakpoint
ALTER TABLE `account` ADD CONSTRAINT `account_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `apiKey` ADD CONSTRAINT `apiKey_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `groupMember` ADD CONSTRAINT `groupMember_group_id_group_id_fk` FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `groupMember` ADD CONSTRAINT `groupMember_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `groupPermission` ADD CONSTRAINT `groupPermission_group_id_group_id_fk` FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `icon` ADD CONSTRAINT `icon_icon_repository_id_iconRepository_id_fk` FOREIGN KEY (`icon_repository_id`) REFERENCES `iconRepository`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `session` ADD CONSTRAINT `session_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `user` ADD CONSTRAINT `user_home_board_id_board_id_fk` FOREIGN KEY (`home_board_id`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `userId_idx` ON `account` (`user_id`);--> statement-breakpoint
CREATE INDEX `user_id_idx` ON `session` (`user_id`);

File diff suppressed because it is too large Load Diff

View File

@@ -113,6 +113,13 @@
"when": 1730653393442,
"tag": "0015_unknown_firedrake",
"breakpoints": true
},
{
"idx": 16,
"version": "5",
"when": 1732212709518,
"tag": "0016_change_all_to_snake_case",
"breakpoints": true
}
]
}

View File

@@ -25,6 +25,7 @@ const migrateAsync = async () => {
const db = drizzle(mysql2, {
mode: "default",
schema: mysqlSchema,
casing: "snake_case",
});
await migrate(db, { migrationsFolder });

View File

@@ -0,0 +1,102 @@
ALTER TABLE `iconRepository` RENAME COLUMN "iconRepository_id" TO "id";--> statement-breakpoint
ALTER TABLE `iconRepository` RENAME COLUMN "iconRepository_slug" TO "slug";--> statement-breakpoint
ALTER TABLE `serverSetting` RENAME COLUMN "key" TO "setting_key";--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_account` (
`user_id` text NOT NULL,
`type` text NOT NULL,
`provider` text NOT NULL,
`provider_account_id` text NOT NULL,
`refresh_token` text,
`access_token` text,
`expires_at` integer,
`token_type` text,
`scope` text,
`id_token` text,
`session_state` text,
PRIMARY KEY(`provider`, `provider_account_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_account`("user_id", "type", "provider", "provider_account_id", "refresh_token", "access_token", "expires_at", "token_type", "scope", "id_token", "session_state") SELECT "userId", "type", "provider", "providerAccountId", "refresh_token", "access_token", "expires_at", "token_type", "scope", "id_token", "session_state" FROM `account`;--> statement-breakpoint
DROP TABLE `account`;--> statement-breakpoint
ALTER TABLE `__new_account` RENAME TO `account`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `userId_idx` ON `account` (`user_id`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_apiKey` (
`id` text PRIMARY KEY NOT NULL,
`api_key` text NOT NULL,
`salt` text NOT NULL,
`user_id` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_apiKey`("id", "api_key", "salt", "user_id") SELECT "id", "apiKey", "salt", "userId" FROM `apiKey`;--> statement-breakpoint
DROP TABLE `apiKey`;--> statement-breakpoint
ALTER TABLE `__new_apiKey` RENAME TO `apiKey`;--> statement-breakpoint
CREATE TABLE `__new_groupMember` (
`group_id` text NOT NULL,
`user_id` text NOT NULL,
PRIMARY KEY(`group_id`, `user_id`),
FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_groupMember`("group_id", "user_id") SELECT "groupId", "userId" FROM `groupMember`;--> statement-breakpoint
DROP TABLE `groupMember`;--> statement-breakpoint
ALTER TABLE `__new_groupMember` RENAME TO `groupMember`;--> statement-breakpoint
CREATE TABLE `__new_groupPermission` (
`group_id` text NOT NULL,
`permission` text NOT NULL,
FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_groupPermission`("group_id", "permission") SELECT "groupId", "permission" FROM `groupPermission`;--> statement-breakpoint
DROP TABLE `groupPermission`;--> statement-breakpoint
ALTER TABLE `__new_groupPermission` RENAME TO `groupPermission`;--> statement-breakpoint
CREATE TABLE `__new_icon` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`url` text NOT NULL,
`checksum` text NOT NULL,
`icon_repository_id` text NOT NULL,
FOREIGN KEY (`icon_repository_id`) REFERENCES `iconRepository`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_icon`("id", "name", "url", "checksum", "icon_repository_id") SELECT "icon_id", "icon_name", "icon_url", "icon_checksum", "iconRepository_id" FROM `icon`;--> statement-breakpoint
DROP TABLE `icon`;--> statement-breakpoint
ALTER TABLE `__new_icon` RENAME TO `icon`;--> statement-breakpoint
DROP INDEX IF EXISTS `serverSetting_key_unique`;--> statement-breakpoint
CREATE UNIQUE INDEX `serverSetting_settingKey_unique` ON `serverSetting` (`setting_key`);--> statement-breakpoint
CREATE TABLE `__new_session` (
`session_token` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_session`("session_token", "user_id", "expires") SELECT "sessionToken", "userId", "expires" FROM `session`;--> statement-breakpoint
DROP TABLE `session`;--> statement-breakpoint
ALTER TABLE `__new_session` RENAME TO `session`;--> statement-breakpoint
CREATE INDEX `user_id_idx` ON `session` (`user_id`);--> statement-breakpoint
CREATE TABLE `__new_user` (
`id` text PRIMARY KEY NOT NULL,
`name` text,
`email` text,
`email_verified` integer,
`image` text,
`password` text,
`salt` text,
`provider` text DEFAULT 'credentials' NOT NULL,
`home_board_id` text,
`color_scheme` text DEFAULT 'dark' NOT NULL,
`first_day_of_week` integer DEFAULT 1 NOT NULL,
`ping_icons_enabled` integer DEFAULT false NOT NULL,
FOREIGN KEY (`home_board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
INSERT INTO `__new_user`("id", "name", "email", "email_verified", "image", "password", "salt", "provider", "home_board_id", "color_scheme", "first_day_of_week", "ping_icons_enabled") SELECT "id", "name", "email", "emailVerified", "image", "password", "salt", "provider", "homeBoardId", "colorScheme", "firstDayOfWeek", "pingIconsEnabled" FROM `user`;--> statement-breakpoint
DROP TABLE `user`;--> statement-breakpoint
ALTER TABLE `__new_user` RENAME TO `user`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

File diff suppressed because it is too large Load Diff

View File

@@ -113,6 +113,13 @@
"when": 1730653336134,
"tag": "0015_superb_psylocke",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1732210918783,
"tag": "0016_change_all_to_snake_case",
"breakpoints": true
}
]
}

View File

@@ -10,7 +10,7 @@ const migrationsFolder = process.argv[2] ?? ".";
const migrateAsync = async () => {
const sqlite = new Database(process.env.DB_URL?.replace("file:", ""));
const db = drizzle(sqlite, { schema });
const db = drizzle(sqlite, { schema, casing: "snake_case" });
migrate(db, { migrationsFolder });

View File

@@ -9,7 +9,8 @@
"./client": "./client.ts",
"./schema/sqlite": "./schema/sqlite.ts",
"./test": "./test/index.ts",
"./queries": "./queries/index.ts"
"./queries": "./queries/index.ts",
"./validationSchemas": "./validationSchemas.ts"
},
"main": "./index.ts",
"types": "./index.ts",
@@ -42,11 +43,12 @@
"@homarr/server-settings": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^10.15.0",
"better-sqlite3": "^11.5.0",
"better-sqlite3": "^11.6.0",
"dotenv": "^16.4.5",
"drizzle-kit": "^0.28.1",
"drizzle-orm": "^0.36.4",
"mysql2": "3.11.4"
"drizzle-zod": "^0.5.1",
"mysql2": "3.11.5"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
@@ -55,8 +57,8 @@
"@types/better-sqlite3": "7.6.12",
"dotenv-cli": "^7.4.4",
"eslint": "^9.15.0",
"prettier": "^3.3.3",
"prettier": "^3.4.1",
"tsx": "4.19.2",
"typescript": "^5.6.3"
"typescript": "^5.7.2"
}
}

View File

@@ -39,10 +39,10 @@ const customBlob = customType<{ data: Buffer }>({
});
export const apiKeys = mysqlTable("apiKey", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
apiKey: text("apiKey").notNull(),
salt: text("salt").notNull(),
userId: varchar("userId", { length: 64 })
id: varchar({ length: 64 }).notNull().primaryKey(),
apiKey: text().notNull(),
salt: text().notNull(),
userId: varchar({ length: 64 })
.notNull()
.references((): AnyMySqlColumn => users.id, {
onDelete: "cascade",
@@ -50,38 +50,38 @@ export const apiKeys = mysqlTable("apiKey", {
});
export const users = mysqlTable("user", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
name: text("name"),
email: text("email"),
emailVerified: timestamp("emailVerified"),
image: text("image"),
password: text("password"),
salt: text("salt"),
provider: varchar("provider", { length: 64 }).$type<SupportedAuthProvider>().default("credentials").notNull(),
homeBoardId: varchar("homeBoardId", { length: 64 }).references((): AnyMySqlColumn => boards.id, {
id: varchar({ length: 64 }).notNull().primaryKey(),
name: text(),
email: text(),
emailVerified: timestamp(),
image: text(),
password: text(),
salt: text(),
provider: varchar({ length: 64 }).$type<SupportedAuthProvider>().default("credentials").notNull(),
homeBoardId: varchar({ length: 64 }).references((): AnyMySqlColumn => boards.id, {
onDelete: "set null",
}),
colorScheme: varchar("colorScheme", { length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean("pingIconsEnabled").default(false).notNull(),
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
});
export const accounts = mysqlTable(
"account",
{
userId: varchar("userId", { length: 64 })
userId: varchar({ length: 64 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccount["type"]>().notNull(),
provider: varchar("provider", { length: 64 }).notNull(),
providerAccountId: varchar("providerAccountId", { length: 64 }).notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: int("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
type: text().$type<AdapterAccount["type"]>().notNull(),
provider: varchar({ length: 64 }).notNull(),
providerAccountId: varchar({ length: 64 }).notNull(),
refresh_token: text(),
access_token: text(),
expires_at: int(),
token_type: text(),
scope: text(),
id_token: text(),
session_state: text(),
},
(account) => ({
compoundKey: primaryKey({
@@ -94,11 +94,11 @@ export const accounts = mysqlTable(
export const sessions = mysqlTable(
"session",
{
sessionToken: varchar("sessionToken", { length: 512 }).notNull().primaryKey(),
userId: varchar("userId", { length: 64 })
sessionToken: varchar({ length: 512 }).notNull().primaryKey(),
userId: varchar({ length: 64 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: timestamp("expires").notNull(),
expires: timestamp().notNull(),
},
(session) => ({
userIdIdx: index("user_id_idx").on(session.userId),
@@ -108,9 +108,9 @@ export const sessions = mysqlTable(
export const verificationTokens = mysqlTable(
"verificationToken",
{
identifier: varchar("identifier", { length: 64 }).notNull(),
token: varchar("token", { length: 512 }).notNull(),
expires: timestamp("expires").notNull(),
identifier: varchar({ length: 64 }).notNull(),
token: varchar({ length: 512 }).notNull(),
expires: timestamp().notNull(),
},
(verificationToken) => ({
compoundKey: primaryKey({
@@ -122,10 +122,10 @@ export const verificationTokens = mysqlTable(
export const groupMembers = mysqlTable(
"groupMember",
{
groupId: varchar("groupId", { length: 64 })
groupId: varchar({ length: 64 })
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
userId: varchar("userId", { length: 64 })
userId: varchar({ length: 64 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
},
@@ -137,46 +137,46 @@ export const groupMembers = mysqlTable(
);
export const groups = mysqlTable("group", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
name: varchar("name", { length: 64 }).unique().notNull(),
ownerId: varchar("owner_id", { length: 64 }).references(() => users.id, {
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 64 }).unique().notNull(),
ownerId: varchar({ length: 64 }).references(() => users.id, {
onDelete: "set null",
}),
});
export const groupPermissions = mysqlTable("groupPermission", {
groupId: varchar("groupId", { length: 64 })
groupId: varchar({ length: 64 })
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
permission: text("permission").$type<GroupPermissionKey>().notNull(),
permission: text().$type<GroupPermissionKey>().notNull(),
});
export const invites = mysqlTable("invite", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
token: varchar("token", { length: 512 }).notNull().unique(),
expirationDate: timestamp("expiration_date").notNull(),
creatorId: varchar("creator_id", { length: 64 })
id: varchar({ length: 64 }).notNull().primaryKey(),
token: varchar({ length: 512 }).notNull().unique(),
expirationDate: timestamp().notNull(),
creatorId: varchar({ length: 64 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
});
export const medias = mysqlTable("media", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
name: varchar("name", { length: 512 }).notNull(),
content: customBlob("content").notNull(),
contentType: text("content_type").notNull(),
size: int("size").notNull(),
createdAt: timestamp("created_at", { mode: "date" }).notNull().defaultNow(),
creatorId: varchar("creator_id", { length: 64 }).references(() => users.id, { onDelete: "set null" }),
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 512 }).notNull(),
content: customBlob().notNull(),
contentType: text().notNull(),
size: int().notNull(),
createdAt: timestamp({ mode: "date" }).notNull().defaultNow(),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
});
export const integrations = mysqlTable(
"integration",
{
id: varchar("id", { length: 64 }).notNull().primaryKey(),
name: text("name").notNull(),
url: text("url").notNull(),
kind: varchar("kind", { length: 128 }).$type<IntegrationKind>().notNull(),
id: varchar({ length: 64 }).notNull().primaryKey(),
name: text().notNull(),
url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -186,12 +186,12 @@ export const integrations = mysqlTable(
export const integrationSecrets = mysqlTable(
"integrationSecret",
{
kind: varchar("kind", { length: 16 }).$type<IntegrationSecretKind>().notNull(),
value: text("value").$type<`${string}.${string}`>().notNull(),
updatedAt: timestamp("updated_at")
kind: varchar({ length: 16 }).$type<IntegrationSecretKind>().notNull(),
value: text().$type<`${string}.${string}`>().notNull(),
updatedAt: timestamp()
.$onUpdateFn(() => new Date())
.notNull(),
integrationId: varchar("integration_id", { length: 64 })
integrationId: varchar({ length: 64 })
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
},
@@ -207,13 +207,13 @@ export const integrationSecrets = mysqlTable(
export const integrationUserPermissions = mysqlTable(
"integrationUserPermission",
{
integrationId: varchar("integration_id", { length: 64 })
integrationId: varchar({ length: 64 })
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 64 })
userId: varchar({ length: 64 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permission: varchar("permission", { length: 128 }).$type<IntegrationPermission>().notNull(),
permission: varchar({ length: 128 }).$type<IntegrationPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
@@ -225,13 +225,13 @@ export const integrationUserPermissions = mysqlTable(
export const integrationGroupPermissions = mysqlTable(
"integrationGroupPermissions",
{
integrationId: varchar("integration_id", { length: 64 })
integrationId: varchar({ length: 64 })
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
groupId: varchar("group_id", { length: 64 })
groupId: varchar({ length: 64 })
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
permission: varchar("permission", { length: 128 }).$type<IntegrationPermission>().notNull(),
permission: varchar({ length: 128 }).$type<IntegrationPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
@@ -242,46 +242,40 @@ export const integrationGroupPermissions = mysqlTable(
);
export const boards = mysqlTable("board", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
name: varchar("name", { length: 256 }).unique().notNull(),
isPublic: boolean("is_public").default(false).notNull(),
creatorId: varchar("creator_id", { length: 64 }).references(() => users.id, {
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 256 }).unique().notNull(),
isPublic: boolean().default(false).notNull(),
creatorId: varchar({ length: 64 }).references(() => users.id, {
onDelete: "set null",
}),
pageTitle: text("page_title"),
metaTitle: text("meta_title"),
logoImageUrl: text("logo_image_url"),
faviconImageUrl: text("favicon_image_url"),
backgroundImageUrl: text("background_image_url"),
backgroundImageAttachment: text("background_image_attachment")
pageTitle: text(),
metaTitle: text(),
logoImageUrl: text(),
faviconImageUrl: text(),
backgroundImageUrl: text(),
backgroundImageAttachment: text()
.$type<BackgroundImageAttachment>()
.default(backgroundImageAttachments.defaultValue)
.notNull(),
backgroundImageRepeat: text("background_image_repeat")
.$type<BackgroundImageRepeat>()
.default(backgroundImageRepeats.defaultValue)
.notNull(),
backgroundImageSize: text("background_image_size")
.$type<BackgroundImageSize>()
.default(backgroundImageSizes.defaultValue)
.notNull(),
primaryColor: text("primary_color").default("#fa5252").notNull(),
secondaryColor: text("secondary_color").default("#fd7e14").notNull(),
opacity: int("opacity").default(100).notNull(),
customCss: text("custom_css"),
columnCount: int("column_count").default(10).notNull(),
backgroundImageRepeat: text().$type<BackgroundImageRepeat>().default(backgroundImageRepeats.defaultValue).notNull(),
backgroundImageSize: text().$type<BackgroundImageSize>().default(backgroundImageSizes.defaultValue).notNull(),
primaryColor: text().default("#fa5252").notNull(),
secondaryColor: text().default("#fd7e14").notNull(),
opacity: int().default(100).notNull(),
customCss: text(),
columnCount: int().default(10).notNull(),
});
export const boardUserPermissions = mysqlTable(
"boardUserPermission",
{
boardId: varchar("board_id", { length: 64 })
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 64 })
userId: varchar({ length: 64 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permission: varchar("permission", { length: 128 }).$type<BoardPermission>().notNull(),
permission: varchar({ length: 128 }).$type<BoardPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
@@ -293,13 +287,13 @@ export const boardUserPermissions = mysqlTable(
export const boardGroupPermissions = mysqlTable(
"boardGroupPermission",
{
boardId: varchar("board_id", { length: 64 })
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
groupId: varchar("group_id", { length: 64 })
groupId: varchar({ length: 64 })
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
permission: varchar("permission", { length: 128 }).$type<BoardPermission>().notNull(),
permission: varchar({ length: 128 }).$type<BoardPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
@@ -309,50 +303,50 @@ export const boardGroupPermissions = mysqlTable(
);
export const sections = mysqlTable("section", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
boardId: varchar("board_id", { length: 64 })
id: varchar({ length: 64 }).notNull().primaryKey(),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
kind: text("kind").$type<SectionKind>().notNull(),
xOffset: int("x_offset").notNull(),
yOffset: int("y_offset").notNull(),
width: int("width"),
height: int("height"),
name: text("name"),
parentSectionId: varchar("parent_section_id", { length: 64 }).references((): AnyMySqlColumn => sections.id, {
kind: text().$type<SectionKind>().notNull(),
xOffset: int().notNull(),
yOffset: int().notNull(),
width: int(),
height: int(),
name: text(),
parentSectionId: varchar({ length: 64 }).references((): AnyMySqlColumn => sections.id, {
onDelete: "cascade",
}),
});
export const items = mysqlTable("item", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
sectionId: varchar("section_id", { length: 64 })
id: varchar({ length: 64 }).notNull().primaryKey(),
sectionId: varchar({ length: 64 })
.notNull()
.references(() => sections.id, { onDelete: "cascade" }),
kind: text("kind").$type<WidgetKind>().notNull(),
xOffset: int("x_offset").notNull(),
yOffset: int("y_offset").notNull(),
width: int("width").notNull(),
height: int("height").notNull(),
options: text("options").default('{"json": {}}').notNull(), // empty superjson object
advancedOptions: text("advanced_options").default('{"json": {}}').notNull(), // empty superjson object
kind: text().$type<WidgetKind>().notNull(),
xOffset: int().notNull(),
yOffset: int().notNull(),
width: int().notNull(),
height: int().notNull(),
options: text().default('{"json": {}}').notNull(), // empty superjson object
advancedOptions: text().default('{"json": {}}').notNull(), // empty superjson object
});
export const apps = mysqlTable("app", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
name: text("name").notNull(),
description: text("description"),
iconUrl: text("icon_url").notNull(),
href: text("href"),
id: varchar({ length: 64 }).notNull().primaryKey(),
name: text().notNull(),
description: text(),
iconUrl: text().notNull(),
href: text(),
});
export const integrationItems = mysqlTable(
"integration_item",
{
itemId: varchar("item_id", { length: 64 })
itemId: varchar({ length: 64 })
.notNull()
.references(() => items.id, { onDelete: "cascade" }),
integrationId: varchar("integration_id", { length: 64 })
integrationId: varchar({ length: 64 })
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
},
@@ -364,23 +358,23 @@ export const integrationItems = mysqlTable(
);
export const icons = mysqlTable("icon", {
id: varchar("icon_id", { length: 64 }).notNull().primaryKey(),
name: varchar("icon_name", { length: 250 }).notNull(),
url: text("icon_url").notNull(),
checksum: text("icon_checksum").notNull(),
iconRepositoryId: varchar("iconRepository_id", { length: 64 })
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 250 }).notNull(),
url: text().notNull(),
checksum: text().notNull(),
iconRepositoryId: varchar({ length: 64 })
.notNull()
.references(() => iconRepositories.id, { onDelete: "cascade" }),
});
export const iconRepositories = mysqlTable("iconRepository", {
id: varchar("iconRepository_id", { length: 64 }).notNull().primaryKey(),
slug: varchar("iconRepository_slug", { length: 150 }).notNull(),
id: varchar({ length: 64 }).notNull().primaryKey(),
slug: varchar({ length: 150 }).notNull(),
});
export const serverSettings = mysqlTable("serverSetting", {
settingKey: varchar("key", { length: 64 }).notNull().unique().primaryKey(),
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
settingKey: varchar({ length: 64 }).notNull().unique().primaryKey(),
value: text().default('{"json": {}}').notNull(), // empty superjson object
});
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
@@ -391,14 +385,14 @@ export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
}));
export const searchEngines = mysqlTable("search_engine", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
iconUrl: text("icon_url").notNull(),
name: varchar("name", { length: 64 }).notNull(),
short: varchar("short", { length: 8 }).notNull(),
description: text("description"),
urlTemplate: text("url_template"),
type: varchar("type", { length: 64 }).$type<SearchEngineType>().notNull().default("generic"),
integrationId: varchar("integration_id", { length: 64 }).references(() => integrations.id, { onDelete: "cascade" }),
id: varchar({ length: 64 }).notNull().primaryKey(),
iconUrl: text().notNull(),
name: varchar({ length: 64 }).notNull(),
short: varchar({ length: 8 }).notNull(),
description: text(),
urlTemplate: text(),
type: varchar({ length: 64 }).$type<SearchEngineType>().notNull().default("generic"),
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "cascade" }),
});
export const accountRelations = relations(accounts, ({ one }) => ({

View File

@@ -3,7 +3,7 @@ import type { DayOfWeek } from "@mantine/dates";
import type { InferSelectModel } from "drizzle-orm";
import { relations, sql } from "drizzle-orm";
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
import { blob, index, int, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { blob, index, int, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
import type {
@@ -23,10 +23,10 @@ import type {
} from "@homarr/definitions";
export const apiKeys = sqliteTable("apiKey", {
id: text("id").notNull().primaryKey(),
apiKey: text("apiKey").notNull(),
salt: text("salt").notNull(),
userId: text("userId")
id: text().notNull().primaryKey(),
apiKey: text().notNull(),
salt: text().notNull(),
userId: text()
.notNull()
.references((): AnySQLiteColumn => users.id, {
onDelete: "cascade",
@@ -34,38 +34,38 @@ export const apiKeys = sqliteTable("apiKey", {
});
export const users = sqliteTable("user", {
id: text("id").notNull().primaryKey(),
name: text("name"),
email: text("email"),
emailVerified: integer("emailVerified", { mode: "timestamp_ms" }),
image: text("image"),
password: text("password"),
salt: text("salt"),
provider: text("provider").$type<SupportedAuthProvider>().default("credentials").notNull(),
homeBoardId: text("homeBoardId").references((): AnySQLiteColumn => boards.id, {
id: text().notNull().primaryKey(),
name: text(),
email: text(),
emailVerified: int({ mode: "timestamp_ms" }),
image: text(),
password: text(),
salt: text(),
provider: text().$type<SupportedAuthProvider>().default("credentials").notNull(),
homeBoardId: text().references((): AnySQLiteColumn => boards.id, {
onDelete: "set null",
}),
colorScheme: text("colorScheme").$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int("pingIconsEnabled", { mode: "boolean" }).default(false).notNull(),
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
});
export const accounts = sqliteTable(
"account",
{
userId: text("userId")
userId: text()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccount["type"]>().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
type: text().$type<AdapterAccount["type"]>().notNull(),
provider: text().notNull(),
providerAccountId: text().notNull(),
refresh_token: text(),
access_token: text(),
expires_at: int(),
token_type: text(),
scope: text(),
id_token: text(),
session_state: text(),
},
(account) => ({
compoundKey: primaryKey({
@@ -78,11 +78,11 @@ export const accounts = sqliteTable(
export const sessions = sqliteTable(
"session",
{
sessionToken: text("sessionToken").notNull().primaryKey(),
userId: text("userId")
sessionToken: text().notNull().primaryKey(),
userId: text()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
expires: int({ mode: "timestamp_ms" }).notNull(),
},
(session) => ({
userIdIdx: index("user_id_idx").on(session.userId),
@@ -92,9 +92,9 @@ export const sessions = sqliteTable(
export const verificationTokens = sqliteTable(
"verificationToken",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
identifier: text().notNull(),
token: text().notNull(),
expires: int({ mode: "timestamp_ms" }).notNull(),
},
(verificationToken) => ({
compoundKey: primaryKey({
@@ -106,10 +106,10 @@ export const verificationTokens = sqliteTable(
export const groupMembers = sqliteTable(
"groupMember",
{
groupId: text("groupId")
groupId: text()
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
userId: text("userId")
userId: text()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
},
@@ -121,50 +121,50 @@ export const groupMembers = sqliteTable(
);
export const groups = sqliteTable("group", {
id: text("id").notNull().primaryKey(),
name: text("name").unique().notNull(),
ownerId: text("owner_id").references(() => users.id, {
id: text().notNull().primaryKey(),
name: text().unique().notNull(),
ownerId: text().references(() => users.id, {
onDelete: "set null",
}),
});
export const groupPermissions = sqliteTable("groupPermission", {
groupId: text("groupId")
groupId: text()
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
permission: text("permission").$type<GroupPermissionKey>().notNull(),
permission: text().$type<GroupPermissionKey>().notNull(),
});
export const invites = sqliteTable("invite", {
id: text("id").notNull().primaryKey(),
token: text("token").notNull().unique(),
expirationDate: int("expiration_date", {
id: text().notNull().primaryKey(),
token: text().notNull().unique(),
expirationDate: int({
mode: "timestamp",
}).notNull(),
creatorId: text("creator_id")
creatorId: text()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
});
export const medias = sqliteTable("media", {
id: text("id").notNull().primaryKey(),
name: text("name").notNull(),
content: blob("content", { mode: "buffer" }).$type<Buffer>().notNull(),
contentType: text("content_type").notNull(),
size: int("size").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
id: text().notNull().primaryKey(),
name: text().notNull(),
content: blob({ mode: "buffer" }).$type<Buffer>().notNull(),
contentType: text().notNull(),
size: int().notNull(),
createdAt: int({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
creatorId: text("creator_id").references(() => users.id, { onDelete: "set null" }),
creatorId: text().references(() => users.id, { onDelete: "set null" }),
});
export const integrations = sqliteTable(
"integration",
{
id: text("id").notNull().primaryKey(),
name: text("name").notNull(),
url: text("url").notNull(),
kind: text("kind").$type<IntegrationKind>().notNull(),
id: text().notNull().primaryKey(),
name: text().notNull(),
url: text().notNull(),
kind: text().$type<IntegrationKind>().notNull(),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -174,12 +174,12 @@ export const integrations = sqliteTable(
export const integrationSecrets = sqliteTable(
"integrationSecret",
{
kind: text("kind").$type<IntegrationSecretKind>().notNull(),
value: text("value").$type<`${string}.${string}`>().notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" })
kind: text().$type<IntegrationSecretKind>().notNull(),
value: text().$type<`${string}.${string}`>().notNull(),
updatedAt: int({ mode: "timestamp" })
.$onUpdateFn(() => new Date())
.notNull(),
integrationId: text("integration_id")
integrationId: text()
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
},
@@ -195,13 +195,13 @@ export const integrationSecrets = sqliteTable(
export const integrationUserPermissions = sqliteTable(
"integrationUserPermission",
{
integrationId: text("integration_id")
integrationId: text()
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
userId: text("user_id")
userId: text()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permission: text("permission").$type<IntegrationPermission>().notNull(),
permission: text().$type<IntegrationPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
@@ -213,13 +213,13 @@ export const integrationUserPermissions = sqliteTable(
export const integrationGroupPermissions = sqliteTable(
"integrationGroupPermissions",
{
integrationId: text("integration_id")
integrationId: text()
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
groupId: text("group_id")
groupId: text()
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
permission: text("permission").$type<IntegrationPermission>().notNull(),
permission: text().$type<IntegrationPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
@@ -229,46 +229,40 @@ export const integrationGroupPermissions = sqliteTable(
);
export const boards = sqliteTable("board", {
id: text("id").notNull().primaryKey(),
name: text("name").unique().notNull(),
isPublic: int("is_public", { mode: "boolean" }).default(false).notNull(),
creatorId: text("creator_id").references(() => users.id, {
id: text().notNull().primaryKey(),
name: text().unique().notNull(),
isPublic: int({ mode: "boolean" }).default(false).notNull(),
creatorId: text().references(() => users.id, {
onDelete: "set null",
}),
pageTitle: text("page_title"),
metaTitle: text("meta_title"),
logoImageUrl: text("logo_image_url"),
faviconImageUrl: text("favicon_image_url"),
backgroundImageUrl: text("background_image_url"),
backgroundImageAttachment: text("background_image_attachment")
pageTitle: text(),
metaTitle: text(),
logoImageUrl: text(),
faviconImageUrl: text(),
backgroundImageUrl: text(),
backgroundImageAttachment: text()
.$type<BackgroundImageAttachment>()
.default(backgroundImageAttachments.defaultValue)
.notNull(),
backgroundImageRepeat: text("background_image_repeat")
.$type<BackgroundImageRepeat>()
.default(backgroundImageRepeats.defaultValue)
.notNull(),
backgroundImageSize: text("background_image_size")
.$type<BackgroundImageSize>()
.default(backgroundImageSizes.defaultValue)
.notNull(),
primaryColor: text("primary_color").default("#fa5252").notNull(),
secondaryColor: text("secondary_color").default("#fd7e14").notNull(),
opacity: int("opacity").default(100).notNull(),
customCss: text("custom_css"),
columnCount: int("column_count").default(10).notNull(),
backgroundImageRepeat: text().$type<BackgroundImageRepeat>().default(backgroundImageRepeats.defaultValue).notNull(),
backgroundImageSize: text().$type<BackgroundImageSize>().default(backgroundImageSizes.defaultValue).notNull(),
primaryColor: text().default("#fa5252").notNull(),
secondaryColor: text().default("#fd7e14").notNull(),
opacity: int().default(100).notNull(),
customCss: text(),
columnCount: int().default(10).notNull(),
});
export const boardUserPermissions = sqliteTable(
"boardUserPermission",
{
boardId: text("board_id")
boardId: text()
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
userId: text("user_id")
userId: text()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permission: text("permission").$type<BoardPermission>().notNull(),
permission: text().$type<BoardPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
@@ -280,13 +274,13 @@ export const boardUserPermissions = sqliteTable(
export const boardGroupPermissions = sqliteTable(
"boardGroupPermission",
{
boardId: text("board_id")
boardId: text()
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
groupId: text("group_id")
groupId: text()
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
permission: text("permission").$type<BoardPermission>().notNull(),
permission: text().$type<BoardPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
@@ -296,50 +290,50 @@ export const boardGroupPermissions = sqliteTable(
);
export const sections = sqliteTable("section", {
id: text("id").notNull().primaryKey(),
boardId: text("board_id")
id: text().notNull().primaryKey(),
boardId: text()
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
kind: text("kind").$type<SectionKind>().notNull(),
xOffset: int("x_offset").notNull(),
yOffset: int("y_offset").notNull(),
width: int("width"),
height: int("height"),
name: text("name"),
parentSectionId: text("parent_section_id").references((): AnySQLiteColumn => sections.id, {
kind: text().$type<SectionKind>().notNull(),
xOffset: int().notNull(),
yOffset: int().notNull(),
width: int(),
height: int(),
name: text(),
parentSectionId: text().references((): AnySQLiteColumn => sections.id, {
onDelete: "cascade",
}),
});
export const items = sqliteTable("item", {
id: text("id").notNull().primaryKey(),
sectionId: text("section_id")
id: text().notNull().primaryKey(),
sectionId: text()
.notNull()
.references(() => sections.id, { onDelete: "cascade" }),
kind: text("kind").$type<WidgetKind>().notNull(),
xOffset: int("x_offset").notNull(),
yOffset: int("y_offset").notNull(),
width: int("width").notNull(),
height: int("height").notNull(),
options: text("options").default('{"json": {}}').notNull(), // empty superjson object
advancedOptions: text("advanced_options").default('{"json": {}}').notNull(), // empty superjson object
kind: text().$type<WidgetKind>().notNull(),
xOffset: int().notNull(),
yOffset: int().notNull(),
width: int().notNull(),
height: int().notNull(),
options: text().default('{"json": {}}').notNull(), // empty superjson object
advancedOptions: text().default('{"json": {}}').notNull(), // empty superjson object
});
export const apps = sqliteTable("app", {
id: text("id").notNull().primaryKey(),
name: text("name").notNull(),
description: text("description"),
iconUrl: text("icon_url").notNull(),
href: text("href"),
id: text().notNull().primaryKey(),
name: text().notNull(),
description: text(),
iconUrl: text().notNull(),
href: text(),
});
export const integrationItems = sqliteTable(
"integration_item",
{
itemId: text("item_id")
itemId: text()
.notNull()
.references(() => items.id, { onDelete: "cascade" }),
integrationId: text("integration_id")
integrationId: text()
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
},
@@ -351,23 +345,23 @@ export const integrationItems = sqliteTable(
);
export const icons = sqliteTable("icon", {
id: text("icon_id").notNull().primaryKey(),
name: text("icon_name").notNull(),
url: text("icon_url").notNull(),
checksum: text("icon_checksum").notNull(),
iconRepositoryId: text("iconRepository_id")
id: text().notNull().primaryKey(),
name: text().notNull(),
url: text().notNull(),
checksum: text().notNull(),
iconRepositoryId: text()
.notNull()
.references(() => iconRepositories.id, { onDelete: "cascade" }),
});
export const iconRepositories = sqliteTable("iconRepository", {
id: text("iconRepository_id").notNull().primaryKey(),
slug: text("iconRepository_slug").notNull(),
id: text().notNull().primaryKey(),
slug: text().notNull(),
});
export const serverSettings = sqliteTable("serverSetting", {
settingKey: text("key").notNull().unique().primaryKey(),
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
settingKey: text().notNull().unique().primaryKey(),
value: text().default('{"json": {}}').notNull(), // empty superjson object
});
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
@@ -378,14 +372,14 @@ export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
}));
export const searchEngines = sqliteTable("search_engine", {
id: text("id").notNull().primaryKey(),
iconUrl: text("icon_url").notNull(),
name: text("name").notNull(),
short: text("short").notNull(),
description: text("description"),
urlTemplate: text("url_template"),
type: text("type").$type<SearchEngineType>().notNull().default("generic"),
integrationId: text("integration_id").references(() => integrations.id, { onDelete: "cascade" }),
id: text().notNull().primaryKey(),
iconUrl: text().notNull(),
name: text().notNull(),
short: text().notNull(),
description: text(),
urlTemplate: text(),
type: text().$type<SearchEngineType>().notNull().default("generic"),
integrationId: text().references(() => integrations.id, { onDelete: "cascade" }),
});
export const accountRelations = relations(accounts, ({ one }) => ({

View File

@@ -6,7 +6,7 @@ import { schema } from "..";
export const createDb = (debug?: boolean) => {
const sqlite = new Database(":memory:");
const db = drizzle(sqlite, { schema, logger: debug });
const db = drizzle(sqlite, { schema, logger: debug, casing: "snake_case" });
migrate(db, {
migrationsFolder: "./packages/db/migrations/sqlite",
});

View File

@@ -22,6 +22,7 @@ describe("Mysql Migration", () => {
const database = drizzle(connection, {
schema: mysqlSchema,
mode: "default",
casing: "snake_case",
});
// Run migrations and check if it works

View File

@@ -0,0 +1,11 @@
import { createSelectSchema } from "drizzle-zod";
import { apps, boards, groups, invites, searchEngines, serverSettings, users } from "./schema/sqlite";
export const selectAppSchema = createSelectSchema(apps);
export const selectBoardSchema = createSelectSchema(boards);
export const selectGroupSchema = createSelectSchema(groups);
export const selectInviteSchema = createSelectSchema(invites);
export const selectSearchEnginesSchema = createSelectSchema(searchEngines);
export const selectSeverSettingsSchema = createSelectSchema(serverSettings);
export const selectUserSchema = createSelectSchema(users);

View File

@@ -30,6 +30,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0",
"typescript": "^5.6.3"
"typescript": "^5.7.2"
}
}

View File

@@ -24,13 +24,13 @@
"dependencies": {
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^7.14.1"
"@mantine/form": "^7.14.3"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0",
"typescript": "^5.6.3"
"typescript": "^5.7.2"
}
}

View File

@@ -31,6 +31,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.15.0",
"typescript": "^5.6.3"
"typescript": "^5.7.2"
}
}

View File

@@ -42,6 +42,6 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/xml2js": "^0.4.14",
"eslint": "^9.15.0",
"typescript": "^5.6.3"
"typescript": "^5.7.2"
}
}

View File

@@ -1,11 +1,11 @@
export const radarrReleaseTypes = ["inCinemas", "digitalRelease", "physicalRelease"] as const;
type ReleaseType = (typeof radarrReleaseTypes)[number];
export type RadarrReleaseType = (typeof radarrReleaseTypes)[number];
export interface CalendarEvent {
name: string;
subName: string;
date: Date;
dates?: { type: ReleaseType; date: Date }[];
dates?: { type: RadarrReleaseType; date: Date }[];
description?: string;
thumbnail?: string;
mediaInformation?: {

Some files were not shown because too many files have changed in this diff Show More