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

@@ -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],