mirror of
https://github.com/ajnart/homarr.git
synced 2025-10-28 09:06:24 +01: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,
|
"when": 1695874816934,
|
||||||
"tag": "0000_supreme_the_captain",
|
"tag": "0000_supreme_the_captain",
|
||||||
"breakpoints": true
|
"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",
|
"test:coverage": "SKIP_ENV_VALIDATION=1 vitest run --coverage",
|
||||||
"docker:build": "turbo build && docker build . -t homarr:local-dev",
|
"docker:build": "turbo build && docker build . -t homarr:local-dev",
|
||||||
"docker:start": "docker run -p 7575:7575 --name homarr-development 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": {
|
"dependencies": {
|
||||||
"@ctrl/deluge": "^4.1.0",
|
"@ctrl/deluge": "^4.1.0",
|
||||||
@@ -126,7 +127,7 @@
|
|||||||
"@types/cookies": "^0.7.7",
|
"@types/cookies": "^0.7.7",
|
||||||
"@types/dockerode": "^3.3.9",
|
"@types/dockerode": "^3.3.9",
|
||||||
"@types/ldapjs": "^3.0.2",
|
"@types/ldapjs": "^3.0.2",
|
||||||
"@types/node": "18.17.8",
|
"@types/node": "^20.6.0",
|
||||||
"@types/prismjs": "^1.26.0",
|
"@types/prismjs": "^1.26.0",
|
||||||
"@types/react": "^18.2.11",
|
"@types/react": "^18.2.11",
|
||||||
"@types/swagger-ui-react": "^4.18.3",
|
"@types/swagger-ui-react": "^4.18.3",
|
||||||
|
|||||||
@@ -26,7 +26,8 @@
|
|||||||
"title": "Tools",
|
"title": "Tools",
|
||||||
"items": {
|
"items": {
|
||||||
"docker": "Docker",
|
"docker": "Docker",
|
||||||
"api": "API"
|
"api": "API",
|
||||||
|
"migrate": "Migrate to 1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"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,
|
IconBrandDiscord,
|
||||||
IconBrandDocker,
|
IconBrandDocker,
|
||||||
IconBrandGithub,
|
IconBrandGithub,
|
||||||
|
IconFileExport,
|
||||||
IconGitFork,
|
IconGitFork,
|
||||||
IconHome,
|
IconHome,
|
||||||
IconInfoSmall,
|
IconInfoSmall,
|
||||||
IconLayoutDashboard,
|
IconLayoutDashboard,
|
||||||
IconMailForward, IconPlug,
|
IconMailForward,
|
||||||
|
IconPlug,
|
||||||
IconQuestionMark,
|
IconQuestionMark,
|
||||||
IconTool,
|
IconTool,
|
||||||
IconUser,
|
IconUser,
|
||||||
@@ -103,8 +105,12 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
|
|||||||
},
|
},
|
||||||
api: {
|
api: {
|
||||||
icon: IconPlug,
|
icon: IconPlug,
|
||||||
href: '/manage/tools/swagger'
|
href: '/manage/tools/swagger',
|
||||||
}
|
},
|
||||||
|
migrate: {
|
||||||
|
icon: IconFileExport,
|
||||||
|
href: '/manage/tools/migrate',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
help: {
|
help: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
export function useSetSafeInterval() {
|
export function useSetSafeInterval() {
|
||||||
const timers = useRef<NodeJS.Timer[]>([]);
|
const timers = useRef<NodeJS.Timeout[]>([]);
|
||||||
|
|
||||||
function setSafeInterval(callback: () => void, delay: number) {
|
function setSafeInterval(callback: () => void, delay: number) {
|
||||||
const newInterval = setInterval(callback, delay);
|
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 { createTRPCRouter } from '~/server/api/trpc';
|
||||||
|
|
||||||
import { appRouter } from './routers/app';
|
import { appRouter } from './routers/app';
|
||||||
@@ -14,6 +15,7 @@ import { indexerManagerRouter } from './routers/indexer-manager';
|
|||||||
import { inviteRouter } from './routers/invite/invite-router';
|
import { inviteRouter } from './routers/invite/invite-router';
|
||||||
import { mediaRequestsRouter } from './routers/media-request';
|
import { mediaRequestsRouter } from './routers/media-request';
|
||||||
import { mediaServerRouter } from './routers/media-server';
|
import { mediaServerRouter } from './routers/media-server';
|
||||||
|
import { migrateRouter } from './routers/migrate';
|
||||||
import { notebookRouter } from './routers/notebook';
|
import { notebookRouter } from './routers/notebook';
|
||||||
import { overseerrRouter } from './routers/overseerr';
|
import { overseerrRouter } from './routers/overseerr';
|
||||||
import { passwordRouter } from './routers/password';
|
import { passwordRouter } from './routers/password';
|
||||||
@@ -22,7 +24,6 @@ import { smartHomeEntityStateRouter } from './routers/smart-home/entity-state';
|
|||||||
import { usenetRouter } from './routers/usenet/router';
|
import { usenetRouter } from './routers/usenet/router';
|
||||||
import { userRouter } from './routers/user';
|
import { userRouter } from './routers/user';
|
||||||
import { weatherRouter } from './routers/weather';
|
import { weatherRouter } from './routers/weather';
|
||||||
import { tdarrRouter } from '~/server/api/routers/tdarr';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
@@ -53,6 +54,7 @@ export const rootRouter = createTRPCRouter({
|
|||||||
smartHomeEntityState: smartHomeEntityStateRouter,
|
smartHomeEntityState: smartHomeEntityStateRouter,
|
||||||
healthMonitoring: healthMonitoringRouter,
|
healthMonitoring: healthMonitoringRouter,
|
||||||
tdarr: tdarrRouter,
|
tdarr: tdarrRouter,
|
||||||
|
migrate: migrateRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// 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 { BackendConfigType } from '~/types/config';
|
||||||
import { INotebookWidget } from '~/widgets/notebook/NotebookWidgetTile';
|
import { INotebookWidget } from '~/widgets/notebook/NotebookWidgetTile';
|
||||||
|
|
||||||
import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
|
import { adminProcedure, createTRPCRouter } from '../trpc';
|
||||||
|
|
||||||
export const notebookRouter = createTRPCRouter({
|
export const notebookRouter = createTRPCRouter({
|
||||||
update: adminProcedure
|
update: adminProcedure
|
||||||
|
|||||||
@@ -109,6 +109,17 @@ export const invites = sqliteTable('invite', {
|
|||||||
|
|
||||||
export type Invite = InferSelectModel<typeof invites>;
|
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 }) => ({
|
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [accounts.userId],
|
fields: [accounts.userId],
|
||||||
|
|||||||
25
yarn.lock
25
yarn.lock
@@ -3509,13 +3509,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@types/node@npm:^16.10.2":
|
||||||
version: 16.18.61
|
version: 16.18.61
|
||||||
resolution: "@types/node@npm:16.18.61"
|
resolution: "@types/node@npm:16.18.61"
|
||||||
@@ -3532,6 +3525,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@types/object.omit@npm:^3.0.0":
|
||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
resolution: "@types/object.omit@npm:3.0.3"
|
resolution: "@types/object.omit@npm:3.0.3"
|
||||||
@@ -7622,7 +7624,7 @@ __metadata:
|
|||||||
"@types/cookies": ^0.7.7
|
"@types/cookies": ^0.7.7
|
||||||
"@types/dockerode": ^3.3.9
|
"@types/dockerode": ^3.3.9
|
||||||
"@types/ldapjs": ^3.0.2
|
"@types/ldapjs": ^3.0.2
|
||||||
"@types/node": 18.17.8
|
"@types/node": ^20.6.0
|
||||||
"@types/prismjs": ^1.26.0
|
"@types/prismjs": ^1.26.0
|
||||||
"@types/react": ^18.2.11
|
"@types/react": ^18.2.11
|
||||||
"@types/swagger-ui-react": ^4.18.3
|
"@types/swagger-ui-react": ^4.18.3
|
||||||
@@ -12567,6 +12569,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"undici@npm:^5.24.0":
|
||||||
version: 5.28.2
|
version: 5.28.2
|
||||||
resolution: "undici@npm:5.28.2"
|
resolution: "undici@npm:5.28.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user