feat: add 1.0 migration page (#2224)

This commit is contained in:
Meier Lukas
2024-12-17 18:39:57 +01:00
committed by GitHub
parent 3737543766
commit d63c610cf5
15 changed files with 913 additions and 17 deletions

View File

@@ -0,0 +1,10 @@
CREATE TABLE `migrate_token` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`boards` integer NOT NULL,
`users` integer NOT NULL,
`integrations` integer NOT NULL,
`expires` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `migrate_token_token_unique` ON `migrate_token` (`token`);

View File

@@ -0,0 +1,527 @@
{
"version": "5",
"dialect": "sqlite",
"id": "9c8971c9-6d33-4d14-b318-b19ff9fbb88f",
"prevId": "32c1bc91-e69f-4e1d-b53c-9c43f2e6c9d3",
"tables": {
"account": {
"name": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"userId_idx": {
"name": "userId_idx",
"columns": [
"userId"
],
"isUnique": false
}
},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": [
"provider",
"providerAccountId"
]
}
},
"uniqueConstraints": {}
},
"invite": {
"name": "invite",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by_id": {
"name": "created_by_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"invite_token_unique": {
"name": "invite_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"invite_created_by_id_user_id_fk": {
"name": "invite_created_by_id_user_id_fk",
"tableFrom": "invite",
"tableTo": "user",
"columnsFrom": [
"created_by_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"migrate_token": {
"name": "migrate_token",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"boards": {
"name": "boards",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"users": {
"name": "users",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"integrations": {
"name": "integrations",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"migrate_token_token_unique": {
"name": "migrate_token_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"session": {
"name": "session",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"user_id_idx": {
"name": "user_id_idx",
"columns": [
"userId"
],
"isUnique": false
}
},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"user_setting": {
"name": "user_setting",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color_scheme": {
"name": "color_scheme",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'environment'"
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"default_board": {
"name": "default_board",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"first_day_of_week": {
"name": "first_day_of_week",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'monday'"
},
"search_template": {
"name": "search_template",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'https://google.com/search?q=%s'"
},
"open_search_in_new_tab": {
"name": "open_search_in_new_tab",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"disable_ping_pulse": {
"name": "disable_ping_pulse",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"replace_ping_with_icons": {
"name": "replace_ping_with_icons",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"use_debug_language": {
"name": "use_debug_language",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"auto_focus_search": {
"name": "auto_focus_search",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"user_setting_user_id_user_id_fk": {
"name": "user_setting_user_id_user_id_fk",
"tableFrom": "user_setting",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"salt": {
"name": "salt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_admin": {
"name": "is_admin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"is_owner": {
"name": "is_owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"verificationToken": {
"name": "verificationToken",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"columns": [
"identifier",
"token"
]
}
},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1695874816934,
"tag": "0000_supreme_the_captain",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1730643218521,
"tag": "0001_brave_mimic",
"breakpoints": true
}
]
}

View File

@@ -25,7 +25,8 @@
"test:coverage": "SKIP_ENV_VALIDATION=1 vitest run --coverage",
"docker:build": "turbo build && docker build . -t homarr:local-dev",
"docker:start": "docker run -p 7575:7575 --name homarr-development homarr:local-dev",
"db:migrate": "dotenv tsx drizzle/migrate/migrate.ts ./drizzle"
"db:migrate": "dotenv tsx drizzle/migrate/migrate.ts ./drizzle",
"db:add": "drizzle-kit generate:sqlite --config ./drizzle.config.ts"
},
"dependencies": {
"@ctrl/deluge": "^4.1.0",
@@ -126,7 +127,7 @@
"@types/cookies": "^0.7.7",
"@types/dockerode": "^3.3.9",
"@types/ldapjs": "^3.0.2",
"@types/node": "18.17.8",
"@types/node": "^20.6.0",
"@types/prismjs": "^1.26.0",
"@types/react": "^18.2.11",
"@types/swagger-ui-react": "^4.18.3",

View File

@@ -26,7 +26,8 @@
"title": "Tools",
"items": {
"docker": "Docker",
"api": "API"
"api": "API",
"migrate": "Migrate to 1.0"
}
},
"about": {

View File

@@ -0,0 +1,33 @@
{
"metaTitle": "Migrate to 1.0",
"pageTitle": "Migrate boards, integrations and users",
"description": "Exports your boards and users to a ZIP-Archive to migrate them to Homarr after version 1.0.0",
"securityNote": {
"title": "Security Note",
"text": "When exporting users and integrations it will also open a modal with an encryption key. This key is required to import the data into Homarr. Keep it safe and do not share it with anyone."
},
"form": {
"label": "Select everything you want to export",
"option": {
"boards": {
"label": "Export boards"
},
"integrations": {
"label": "Export integrations",
"description": "This will include encrypted credentials for integrations. Only available when exporting boards"
},
"users": {
"label": "Export users",
"description": "This will only export credential users, passwords hash and salt are encrypted"
}
}
},
"action": {
"export": "Export data"
},
"modal": {
"title": "Encryption key",
"description": "Your data has been exported. Keep this key safe and do not share it with anyone. You will need this key to import the data into Homarr.",
"copyDismiss": "Copy & dismiss"
}
}

View File

@@ -17,11 +17,13 @@ import {
IconBrandDiscord,
IconBrandDocker,
IconBrandGithub,
IconFileExport,
IconGitFork,
IconHome,
IconInfoSmall,
IconLayoutDashboard,
IconMailForward, IconPlug,
IconMailForward,
IconPlug,
IconQuestionMark,
IconTool,
IconUser,
@@ -103,8 +105,12 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
},
api: {
icon: IconPlug,
href: '/manage/tools/swagger'
}
href: '/manage/tools/swagger',
},
migrate: {
icon: IconFileExport,
href: '/manage/tools/migrate',
},
},
},
help: {

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
export function useSetSafeInterval() {
const timers = useRef<NodeJS.Timer[]>([]);
const timers = useRef<NodeJS.Timeout[]>([]);
function setSafeInterval(callback: () => void, delay: number) {
const newInterval = setInterval(callback, delay);

109
src/pages/api/migrate.ts Normal file
View File

@@ -0,0 +1,109 @@
import AdmZip from 'adm-zip';
import crypto, { randomBytes } from 'crypto';
import { eq, isNotNull } from 'drizzle-orm';
import fs from 'fs';
import { NextApiRequest, NextApiResponse } from 'next';
import { getServerAuthSession } from '~/server/auth';
import { db } from '~/server/db';
import { migrateTokens, users } from '~/server/db/schema';
import { getConfig } from '~/tools/config/getConfig';
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getServerAuthSession({ req, res });
if (!session) {
return res.status(401).end();
}
if (!session.user.isAdmin) {
return res.status(403).end('Not an admin');
}
const token = req.query.token;
if (!token || Array.isArray(token)) {
return res.status(400).end();
}
const dbToken = await db.query.migrateTokens.findFirst({
where: eq(migrateTokens.token, token),
});
if (!dbToken) {
return res.status(403).end('No db token');
}
if (dbToken.expires < new Date()) {
return res.status(403).end('Token expired');
}
const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json'));
const zip = new AdmZip();
for (const file of files) {
const data = await getConfig(file.replace('.json', ''));
const mappedApps = data.apps.map((app) => ({
...app,
integration:
app.integration && dbToken.integrations
? {
...app.integration,
properties: app.integration.properties.map((property) => ({
...property,
value: property.value ? encryptSecret(property.value, dbToken.token) : null,
})),
}
: null,
}));
const content = JSON.stringify(
{
...data,
apps: mappedApps,
},
null,
2
);
zip.addFile(file, Buffer.from(content, 'utf-8'));
}
if (dbToken.users) {
// Only credentials users
const dbUsers = await db.query.users.findMany({
with: { settings: true },
where: isNotNull(users.password),
});
const encryptedUsers = dbUsers.map((user) => ({
...user,
password: user.password ? encryptSecret(user.password, dbToken.token) : null,
salt: user.salt ? encryptSecret(user.salt, dbToken.token) : null,
}));
const content = JSON.stringify(encryptedUsers, null, 2);
zip.addFile('users/users.json', Buffer.from(content, 'utf-8'));
}
if (dbToken.integrations || dbToken.users) {
const checksum = randomBytes(16).toString('hex');
const encryptedChecksum = encryptSecret(checksum, dbToken.token);
const content = `${checksum}\n${encryptedChecksum}`;
zip.addFile('checksum.txt', Buffer.from(content, 'utf-8'));
}
const zipBuffer = zip.toBuffer();
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', 'attachment; filename=migrate-homarr.zip');
res.setHeader('Content-Length', zipBuffer.length.toString());
res.status(200).end(zipBuffer);
};
export default handler;
export function encryptSecret(text: string, encryptionKey: string): `${string}.${string}` {
const key = Buffer.from(encryptionKey, 'hex');
const initializationVector = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(key), initializationVector);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return `${encrypted.toString('hex')}.${initializationVector.toString('hex')}`;
}

View File

@@ -0,0 +1,154 @@
import {
Alert,
Button,
Checkbox,
CopyButton,
Input,
Modal,
PasswordInput,
Stack,
Text,
Title,
} from '@mantine/core';
import { GetServerSideProps } from 'next';
import { useTranslation } from 'next-i18next';
import Head from 'next/head';
import { useState } from 'react';
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { getServerAuthSession } from '~/server/auth';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
import { api } from '~/utils/api';
/**
* 1. Send selected options to the server
* 2. Create a download token and send it back to the client
* 3. Client downloads the ZIP file
* 4. Client shows the encryption key in a modal
*/
const ManagementPage = () => {
const { t } = useTranslation('manage/migrate');
const metaTitle = `${t('metaTitle')} • Homarr`;
const { mutateAsync } = api.migrate.createToken.useMutation();
const [options, setOptions] = useState({
boards: true,
integrations: true,
users: true,
});
const [token, setToken] = useState<string | null>(null);
const [opened, setOpened] = useState(false);
const onClick = async () => {
await mutateAsync(options, {
onSuccess: (token) => {
// Download ZIP file
const link = document.createElement('a');
const baseUrl = window.location.origin;
link.href = `${baseUrl}/api/migrate?token=${token}`;
link.download = 'migration.zip';
link.click();
// Token is only needed when exporting users or integrations
if (options.users || options.integrations) {
setToken(token);
setOpened(true);
}
},
});
};
return (
<ManageLayout>
<Head>
<title>{metaTitle}</title>
</Head>
<Stack>
<Title order={1}>{t('pageTitle')}</Title>
<Text>{t('description')}</Text>
<Alert color="blue" title={t('securityNote.title')}>
{t('securityNote.text')}
</Alert>
<Input.Wrapper label={t('form.label')}>
<Stack ml="md" mt="md">
<Checkbox
label={t('form.option.boards.label')}
checked={options.boards}
onChange={(event) =>
setOptions((prev) => ({
...prev,
boards: event.target.checked,
integrations: false,
}))
}
/>
<Checkbox
label={t('form.option.integrations.label')}
disabled={!options.boards}
checked={options.integrations}
onChange={(event) =>
setOptions((prev) => ({ ...prev, integrations: event.target.checked }))
}
description={t('form.option.integrations.description')}
/>
<Checkbox
label={t('form.option.users.label')}
checked={options.users}
onChange={(event) => setOptions((prev) => ({ ...prev, users: event.target.checked }))}
description={t('form.option.users.description')}
/>
</Stack>
</Input.Wrapper>
<Button onClick={onClick}>{t('action.export')}</Button>
</Stack>
<Modal opened={opened} onClose={() => setOpened(false)} title={t('modal.title')}>
{token && (
<Stack>
<Text>{t('modal.description')}</Text>
<PasswordInput value={token} />
<CopyButton value={token}>
{({ copy }) => (
<Button
onClick={() => {
copy();
setToken(null);
setOpened(false);
}}
>
{t('modal.copyDismiss')}
</Button>
)}
</CopyButton>
</Stack>
)}
</Modal>
</ManageLayout>
);
};
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const session = await getServerAuthSession(ctx);
const result = checkForSessionOrAskForLogin(ctx, session, () => Boolean(session?.user.isAdmin));
if (result) {
return result;
}
const translations = await getServerSideTranslations(
['layout/manage', 'manage/migrate'],
ctx.locale,
ctx.req,
ctx.res
);
return {
props: {
...translations,
},
};
};
export default ManagementPage;

View File

@@ -1,3 +1,4 @@
import { tdarrRouter } from '~/server/api/routers/tdarr';
import { createTRPCRouter } from '~/server/api/trpc';
import { appRouter } from './routers/app';
@@ -14,6 +15,7 @@ import { indexerManagerRouter } from './routers/indexer-manager';
import { inviteRouter } from './routers/invite/invite-router';
import { mediaRequestsRouter } from './routers/media-request';
import { mediaServerRouter } from './routers/media-server';
import { migrateRouter } from './routers/migrate';
import { notebookRouter } from './routers/notebook';
import { overseerrRouter } from './routers/overseerr';
import { passwordRouter } from './routers/password';
@@ -22,7 +24,6 @@ import { smartHomeEntityStateRouter } from './routers/smart-home/entity-state';
import { usenetRouter } from './routers/usenet/router';
import { userRouter } from './routers/user';
import { weatherRouter } from './routers/weather';
import { tdarrRouter } from '~/server/api/routers/tdarr';
/**
* This is the primary router for your server.
@@ -53,6 +54,7 @@ export const rootRouter = createTRPCRouter({
smartHomeEntityState: smartHomeEntityStateRouter,
healthMonitoring: healthMonitoringRouter,
tdarr: tdarrRouter,
migrate: migrateRouter,
});
// export type definition of API

View File

@@ -0,0 +1,26 @@
import { randomBytes } from 'crypto';
import dayjs from 'dayjs';
import { v4 } from 'uuid';
import { z } from 'zod';
import { db } from '~/server/db';
import { migrateTokens } from '~/server/db/schema';
import { adminProcedure, createTRPCRouter } from '../trpc';
export const migrateRouter = createTRPCRouter({
createToken: adminProcedure
.input(z.object({ boards: z.boolean(), users: z.boolean(), integrations: z.boolean() }))
.mutation(async ({ input }) => {
const id = v4();
const token = randomBytes(32).toString('hex');
await db.insert(migrateTokens).values({
id,
token,
...input,
expires: dayjs().add(5, 'minutes').toDate(),
});
return token;
}),
});

View File

@@ -6,7 +6,7 @@ import { getConfig } from '~/tools/config/getConfig';
import { BackendConfigType } from '~/types/config';
import { INotebookWidget } from '~/widgets/notebook/NotebookWidgetTile';
import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
import { adminProcedure, createTRPCRouter } from '../trpc';
export const notebookRouter = createTRPCRouter({
update: adminProcedure

View File

@@ -109,6 +109,17 @@ export const invites = sqliteTable('invite', {
export type Invite = InferSelectModel<typeof invites>;
export const migrateTokens = sqliteTable('migrate_token', {
id: text('id').notNull().primaryKey(),
token: text('token').notNull().unique(),
boards: int('boards', { mode: 'boolean' }).notNull(),
users: int('users', { mode: 'boolean' }).notNull(),
integrations: int('integrations', { mode: 'boolean' }).notNull(),
expires: int('expires', {
mode: 'timestamp',
}).notNull(),
});
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],

View File

@@ -3509,13 +3509,6 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:18.17.8":
version: 18.17.8
resolution: "@types/node@npm:18.17.8"
checksum: ebb71526368c9c58f03e2c2408bfda4aa686c13d84226e2c9b48d9c4aee244fb82e672aaf4aa8ccb6e4993b4274d5f4b2b3d52d0a2e57ab187ae653903376411
languageName: node
linkType: hard
"@types/node@npm:^16.10.2":
version: 16.18.61
resolution: "@types/node@npm:16.18.61"
@@ -3532,6 +3525,15 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:^20.6.0":
version: 20.17.6
resolution: "@types/node@npm:20.17.6"
dependencies:
undici-types: ~6.19.2
checksum: d51dbb9881c94d0310b32b5fd8013e3261595c61bc888fa27258469c93c3dc0b3c4d20a9f28f3f5f79562f6737e28e7f3dd04940dc8b4d966d34aaf318f7f69b
languageName: node
linkType: hard
"@types/object.omit@npm:^3.0.0":
version: 3.0.3
resolution: "@types/object.omit@npm:3.0.3"
@@ -7622,7 +7624,7 @@ __metadata:
"@types/cookies": ^0.7.7
"@types/dockerode": ^3.3.9
"@types/ldapjs": ^3.0.2
"@types/node": 18.17.8
"@types/node": ^20.6.0
"@types/prismjs": ^1.26.0
"@types/react": ^18.2.11
"@types/swagger-ui-react": ^4.18.3
@@ -12567,6 +12569,13 @@ __metadata:
languageName: node
linkType: hard
"undici-types@npm:~6.19.2":
version: 6.19.8
resolution: "undici-types@npm:6.19.8"
checksum: de51f1b447d22571cf155dfe14ff6d12c5bdaec237c765085b439c38ca8518fc360e88c70f99469162bf2e14188a7b0bcb06e1ed2dc031042b984b0bb9544017
languageName: node
linkType: hard
"undici@npm:^5.24.0":
version: 5.28.2
resolution: "undici@npm:5.28.2"