feat: add 1.0 migration page (#2224)
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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
109
src/pages/api/migrate.ts
Normal 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')}`;
|
||||
}
|
||||
154
src/pages/manage/tools/migrate.tsx
Normal file
154
src/pages/manage/tools/migrate.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
26
src/server/api/routers/migrate.ts
Normal file
26
src/server/api/routers/migrate.ts
Normal 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;
|
||||
}),
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user