mirror of
https://github.com/ajnart/homarr.git
synced 2025-10-26 00:56:12 +02:00
feat: add 1.0 migration page (#2224)
This commit is contained in:
10
drizzle/0001_brave_mimic.sql
Normal file
10
drizzle/0001_brave_mimic.sql
Normal 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`);
|
||||
527
drizzle/meta/0001_snapshot.json
Normal file
527
drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"title": "Tools",
|
||||
"items": {
|
||||
"docker": "Docker",
|
||||
"api": "API"
|
||||
"api": "API",
|
||||
"migrate": "Migrate to 1.0"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
|
||||
33
public/locales/en/manage/migrate.json
Normal file
33
public/locales/en/manage/migrate.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
25
yarn.lock
25
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user