[args]')
- .command('reset-owner-password', 'Resets the current owner password without UI access', async () => {
- await resetPasswordForOwner();
- })
- .command(
- 'reset-password',
- 'Reset the password of a specific user without UI access',
- (yargs) => {
- yargs.option('username', {
- type: 'string',
- describe: 'Username of user',
- demandOption: true
- });
- },
- async (argv) => {
- await resetPasswordForUsername(argv.username);
- }
- )
- .version(false)
- .showHelpOnFail(true)
- .alias('h', 'help')
- .demandCommand()
- .help().argv;
\ No newline at end of file
diff --git a/cli/commands/reset-owner-password.js b/cli/commands/reset-owner-password.js
deleted file mode 100644
index d7d1fdc6d..000000000
--- a/cli/commands/reset-owner-password.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import bcrypt from 'bcryptjs';
-
-import Database from 'better-sqlite3';
-
-import boxen from 'boxen';
-
-import chalk from 'chalk';
-
-import Consola from 'consola';
-
-import crypto from 'crypto';
-
-import { sql } from 'drizzle-orm';
-import { drizzle } from 'drizzle-orm/better-sqlite3';
-
-export async function resetPasswordForOwner() {
- if (!process.env.DATABASE_URL) {
- Consola.error('Unable to connect to database due to missing database URL environment variable');
- return;
- }
-
- Consola.info('Connecting to the database...');
- const sqlite = new Database(process.env.DATABASE_URL.replace('file:', ''));
- const db = drizzle(sqlite);
-
- Consola.info('Connected to the database ' + chalk.green('✓'));
- Consola.info('Generating new random password...');
-
- const newPassword = crypto.randomUUID();
- const salt = bcrypt.genSaltSync(10);
- const hashedPassword = bcrypt.hashSync(newPassword, salt);
-
- try {
- await db.transaction((tx) => {
- tx.run(
- sql`DELETE FROM session WHERE userId = (SELECT id FROM user WHERE is_owner = 1 LIMIT 1)`
- );
- tx.run(sql`UPDATE user SET password = ${hashedPassword} WHERE is_owner = 1 LIMIT 1;`);
- });
- console.log(
- boxen(`New owner password is '${chalk.red(newPassword)}'. You can now log in with this password.\nExising sessions have been destroyed and need to login again with the new passowrd.`, {
- dimBorder: true,
- borderStyle: 'round',
- padding: {
- left: 1,
- right: 1
- }
- })
- );
- } catch (err) {
- Consola.error('Failed to update password', err);
- } finally {
- Consola.info('Command has completed');
- }
-}
diff --git a/cli/commands/reset-password.js b/cli/commands/reset-password.js
deleted file mode 100644
index c4e011faa..000000000
--- a/cli/commands/reset-password.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import bcrypt from 'bcryptjs';
-
-import Database from 'better-sqlite3';
-
-import Consola from 'consola';
-
-import crypto from 'crypto';
-
-import boxen from 'boxen';
-
-import chalk from 'chalk';
-
-import { sql } from 'drizzle-orm';
-import { drizzle } from 'drizzle-orm/better-sqlite3';
-
-export async function resetPasswordForUsername(username) {
- if (!process.env.DATABASE_URL) {
- Consola.error('Unable to connect to database due to missing database URL environment variable');
- return;
- }
-
- Consola.info('Connecting to the database...');
- const sqlite = new Database(process.env.DATABASE_URL.replace('file:', ''));
- const db = drizzle(sqlite);
-
- Consola.info('Generating new random password...');
-
- const newPassword = crypto.randomUUID();
- const salt = bcrypt.genSaltSync(10);
- const hashedPassword = bcrypt.hashSync(newPassword, salt);
-
- Consola.info(`Updating password for user '${username}'`);
-
- try {
- await db.transaction((tx) => {
- tx.run(
- sql`DELETE FROM session WHERE userId = (SELECT id FROM user WHERE name = ${username} LIMIT 1)`
- );
- tx.run(sql`UPDATE user SET password = ${hashedPassword} WHERE id = (SELECT id FROM user WHERE name = ${username} LIMIT 1) LIMIT 1`);
- });
- console.log(
- boxen(`New password for '${username}' is '${chalk.red(newPassword)}'. You can now log in with this password.\nExising sessions have been destroyed and need to login again with the new passowrd.`, {
- dimBorder: true,
- borderStyle: 'round',
- padding: {
- left: 1,
- right: 1
- }
- })
- );
- } catch (err) {
- Consola.error('Failed to update password', err);
- } finally {
- Consola.info('Command has completed');
- }
-}
diff --git a/cli/package.json b/cli/package.json
deleted file mode 100644
index f1ee09e18..000000000
--- a/cli/package.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "dependencies": {
- "bcryptjs": "^2.4.3",
- "better-sqlite3": "^8.6.0",
- "boxen": "^7.1.1",
- "chalk": "^5.3.0",
- "consola": "^3.0.0",
- "drizzle-orm": "^0.28.6",
- "yargs": "^17.7.2"
- },
- "type": "module"
-}
diff --git a/cli/yarn.lock b/cli/yarn.lock
deleted file mode 100644
index d99d60b33..000000000
--- a/cli/yarn.lock
+++ /dev/null
@@ -1,1371 +0,0 @@
-# This file is generated by running "yarn install" inside your project.
-# Manual changes might be lost - proceed with caution!
-
-__metadata:
- version: 6
- cacheKey: 8
-
-"@isaacs/cliui@npm:^8.0.2":
- version: 8.0.2
- resolution: "@isaacs/cliui@npm:8.0.2"
- dependencies:
- string-width: ^5.1.2
- string-width-cjs: "npm:string-width@^4.2.0"
- strip-ansi: ^7.0.1
- strip-ansi-cjs: "npm:strip-ansi@^6.0.1"
- wrap-ansi: ^8.1.0
- wrap-ansi-cjs: "npm:wrap-ansi@^7.0.0"
- checksum: 4a473b9b32a7d4d3cfb7a614226e555091ff0c5a29a1734c28c72a182c2f6699b26fc6b5c2131dfd841e86b185aea714c72201d7c98c2fba5f17709333a67aeb
- languageName: node
- linkType: hard
-
-"@npmcli/agent@npm:^2.0.0":
- version: 2.2.0
- resolution: "@npmcli/agent@npm:2.2.0"
- dependencies:
- agent-base: ^7.1.0
- http-proxy-agent: ^7.0.0
- https-proxy-agent: ^7.0.1
- lru-cache: ^10.0.1
- socks-proxy-agent: ^8.0.1
- checksum: 3b25312edbdfaa4089af28e2d423b6f19838b945e47765b0c8174c1395c79d43c3ad6d23cb364b43f59fd3acb02c93e3b493f72ddbe3dfea04c86843a7311fc4
- languageName: node
- linkType: hard
-
-"@npmcli/fs@npm:^3.1.0":
- version: 3.1.0
- resolution: "@npmcli/fs@npm:3.1.0"
- dependencies:
- semver: ^7.3.5
- checksum: a50a6818de5fc557d0b0e6f50ec780a7a02ab8ad07e5ac8b16bf519e0ad60a144ac64f97d05c443c3367235d337182e1d012bbac0eb8dbae8dc7b40b193efd0e
- languageName: node
- linkType: hard
-
-"@pkgjs/parseargs@npm:^0.11.0":
- version: 0.11.0
- resolution: "@pkgjs/parseargs@npm:0.11.0"
- checksum: 6ad6a00fc4f2f2cfc6bff76fb1d88b8ee20bc0601e18ebb01b6d4be583733a860239a521a7fbca73b612e66705078809483549d2b18f370eb346c5155c8e4a0f
- languageName: node
- linkType: hard
-
-"abbrev@npm:^2.0.0":
- version: 2.0.0
- resolution: "abbrev@npm:2.0.0"
- checksum: 0e994ad2aa6575f94670d8a2149afe94465de9cedaaaac364e7fb43a40c3691c980ff74899f682f4ca58fa96b4cbd7421a015d3a6defe43a442117d7821a2f36
- languageName: node
- linkType: hard
-
-"agent-base@npm:^7.0.2, agent-base@npm:^7.1.0":
- version: 7.1.0
- resolution: "agent-base@npm:7.1.0"
- dependencies:
- debug: ^4.3.4
- checksum: f7828f991470a0cc22cb579c86a18cbae83d8a3cbed39992ab34fc7217c4d126017f1c74d0ab66be87f71455318a8ea3e757d6a37881b8d0f2a2c6aa55e5418f
- languageName: node
- linkType: hard
-
-"aggregate-error@npm:^3.0.0":
- version: 3.1.0
- resolution: "aggregate-error@npm:3.1.0"
- dependencies:
- clean-stack: ^2.0.0
- indent-string: ^4.0.0
- checksum: 1101a33f21baa27a2fa8e04b698271e64616b886795fd43c31068c07533c7b3facfcaf4e9e0cab3624bd88f729a592f1c901a1a229c9e490eafce411a8644b79
- languageName: node
- linkType: hard
-
-"ansi-align@npm:^3.0.1":
- version: 3.0.1
- resolution: "ansi-align@npm:3.0.1"
- dependencies:
- string-width: ^4.1.0
- checksum: 6abfa08f2141d231c257162b15292467081fa49a208593e055c866aa0455b57f3a86b5a678c190c618faa79b4c59e254493099cb700dd9cf2293c6be2c8f5d8d
- languageName: node
- linkType: hard
-
-"ansi-regex@npm:^5.0.1":
- version: 5.0.1
- resolution: "ansi-regex@npm:5.0.1"
- checksum: 2aa4bb54caf2d622f1afdad09441695af2a83aa3fe8b8afa581d205e57ed4261c183c4d3877cee25794443fde5876417d859c108078ab788d6af7e4fe52eb66b
- languageName: node
- linkType: hard
-
-"ansi-regex@npm:^6.0.1":
- version: 6.0.1
- resolution: "ansi-regex@npm:6.0.1"
- checksum: 1ff8b7667cded1de4fa2c9ae283e979fc87036864317da86a2e546725f96406746411d0d85e87a2d12fa5abd715d90006de7fa4fa0477c92321ad3b4c7d4e169
- languageName: node
- linkType: hard
-
-"ansi-styles@npm:^4.0.0":
- version: 4.3.0
- resolution: "ansi-styles@npm:4.3.0"
- dependencies:
- color-convert: ^2.0.1
- checksum: 513b44c3b2105dd14cc42a19271e80f386466c4be574bccf60b627432f9198571ebf4ab1e4c3ba17347658f4ee1711c163d574248c0c1cdc2d5917a0ad582ec4
- languageName: node
- linkType: hard
-
-"ansi-styles@npm:^6.1.0":
- version: 6.2.1
- resolution: "ansi-styles@npm:6.2.1"
- checksum: ef940f2f0ced1a6347398da88a91da7930c33ecac3c77b72c5905f8b8fe402c52e6fde304ff5347f616e27a742da3f1dc76de98f6866c69251ad0b07a66776d9
- languageName: node
- linkType: hard
-
-"balanced-match@npm:^1.0.0":
- version: 1.0.2
- resolution: "balanced-match@npm:1.0.2"
- checksum: 9706c088a283058a8a99e0bf91b0a2f75497f185980d9ffa8b304de1d9e58ebda7c72c07ebf01dadedaac5b2907b2c6f566f660d62bd336c3468e960403b9d65
- languageName: node
- linkType: hard
-
-"base64-js@npm:^1.3.1":
- version: 1.5.1
- resolution: "base64-js@npm:1.5.1"
- checksum: 669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005
- languageName: node
- linkType: hard
-
-"bcryptjs@npm:^2.4.3":
- version: 2.4.3
- resolution: "bcryptjs@npm:2.4.3"
- checksum: 0e80ed852a41f5dfb1853f53ee14a7390b0ef263ce05dba6e2ef3cd919dfad025a7c21ebcfe5bc7fa04b100990edf90c7a877ff7fe623d3e479753253131b629
- languageName: node
- linkType: hard
-
-"better-sqlite3@npm:^8.6.0":
- version: 8.7.0
- resolution: "better-sqlite3@npm:8.7.0"
- dependencies:
- bindings: ^1.5.0
- node-gyp: latest
- prebuild-install: ^7.1.1
- checksum: f1fa38a9a0e4fcd59ececb67c60371b9638d29c19ce9af034421e8a56c9a77e799bb1411b1c3cb08bb9678e15dfb8985553a9ef4098cf5558e7207a3e019f211
- languageName: node
- linkType: hard
-
-"bindings@npm:^1.5.0":
- version: 1.5.0
- resolution: "bindings@npm:1.5.0"
- dependencies:
- file-uri-to-path: 1.0.0
- checksum: 65b6b48095717c2e6105a021a7da4ea435aa8d3d3cd085cb9e85bcb6e5773cf318c4745c3f7c504412855940b585bdf9b918236612a1c7a7942491de176f1ae7
- languageName: node
- linkType: hard
-
-"bl@npm:^4.0.3":
- version: 4.1.0
- resolution: "bl@npm:4.1.0"
- dependencies:
- buffer: ^5.5.0
- inherits: ^2.0.4
- readable-stream: ^3.4.0
- checksum: 9e8521fa7e83aa9427c6f8ccdcba6e8167ef30cc9a22df26effcc5ab682ef91d2cbc23a239f945d099289e4bbcfae7a192e9c28c84c6202e710a0dfec3722662
- languageName: node
- linkType: hard
-
-"boxen@npm:^7.1.1":
- version: 7.1.1
- resolution: "boxen@npm:7.1.1"
- dependencies:
- ansi-align: ^3.0.1
- camelcase: ^7.0.1
- chalk: ^5.2.0
- cli-boxes: ^3.0.0
- string-width: ^5.1.2
- type-fest: ^2.13.0
- widest-line: ^4.0.1
- wrap-ansi: ^8.1.0
- checksum: ad8833d5f2845b0a728fdf8a0bc1505dff0c518edcb0fd56979a08774b1f26cf48b71e66532179ccdfb9ed95b64aa008689cca26f7776f93f002b8000a683d76
- languageName: node
- linkType: hard
-
-"brace-expansion@npm:^2.0.1":
- version: 2.0.1
- resolution: "brace-expansion@npm:2.0.1"
- dependencies:
- balanced-match: ^1.0.0
- checksum: a61e7cd2e8a8505e9f0036b3b6108ba5e926b4b55089eeb5550cd04a471fe216c96d4fe7e4c7f995c728c554ae20ddfc4244cad10aef255e72b62930afd233d1
- languageName: node
- linkType: hard
-
-"buffer@npm:^5.5.0":
- version: 5.7.1
- resolution: "buffer@npm:5.7.1"
- dependencies:
- base64-js: ^1.3.1
- ieee754: ^1.1.13
- checksum: e2cf8429e1c4c7b8cbd30834ac09bd61da46ce35f5c22a78e6c2f04497d6d25541b16881e30a019c6fd3154150650ccee27a308eff3e26229d788bbdeb08ab84
- languageName: node
- linkType: hard
-
-"cacache@npm:^18.0.0":
- version: 18.0.1
- resolution: "cacache@npm:18.0.1"
- dependencies:
- "@npmcli/fs": ^3.1.0
- fs-minipass: ^3.0.0
- glob: ^10.2.2
- lru-cache: ^10.0.1
- minipass: ^7.0.3
- minipass-collect: ^2.0.1
- minipass-flush: ^1.0.5
- minipass-pipeline: ^1.2.4
- p-map: ^4.0.0
- ssri: ^10.0.0
- tar: ^6.1.11
- unique-filename: ^3.0.0
- checksum: 5a0b3b2ea451a0379814dc1d3c81af48c7c6db15cd8f7d72e028501ae0036a599a99bbac9687bfec307afb2760808d1c7708e9477c8c70d2b166e7d80b162a23
- languageName: node
- linkType: hard
-
-"camelcase@npm:^7.0.1":
- version: 7.0.1
- resolution: "camelcase@npm:7.0.1"
- checksum: 86ab8f3ebf08bcdbe605a211a242f00ed30d8bfb77dab4ebb744dd36efbc84432d1c4adb28975ba87a1b8be40a80fbd1e60e2f06565315918fa7350011a26d3d
- languageName: node
- linkType: hard
-
-"chalk@npm:^5.2.0, chalk@npm:^5.3.0":
- version: 5.3.0
- resolution: "chalk@npm:5.3.0"
- checksum: 623922e077b7d1e9dedaea6f8b9e9352921f8ae3afe739132e0e00c275971bdd331268183b2628cf4ab1727c45ea1f28d7e24ac23ce1db1eb653c414ca8a5a80
- languageName: node
- linkType: hard
-
-"chownr@npm:^1.1.1":
- version: 1.1.4
- resolution: "chownr@npm:1.1.4"
- checksum: 115648f8eb38bac5e41c3857f3e663f9c39ed6480d1349977c4d96c95a47266fcacc5a5aabf3cb6c481e22d72f41992827db47301851766c4fd77ac21a4f081d
- languageName: node
- linkType: hard
-
-"chownr@npm:^2.0.0":
- version: 2.0.0
- resolution: "chownr@npm:2.0.0"
- checksum: c57cf9dd0791e2f18a5ee9c1a299ae6e801ff58fee96dc8bfd0dcb4738a6ce58dd252a3605b1c93c6418fe4f9d5093b28ffbf4d66648cb2a9c67eaef9679be2f
- languageName: node
- linkType: hard
-
-"clean-stack@npm:^2.0.0":
- version: 2.2.0
- resolution: "clean-stack@npm:2.2.0"
- checksum: 2ac8cd2b2f5ec986a3c743935ec85b07bc174d5421a5efc8017e1f146a1cf5f781ae962618f416352103b32c9cd7e203276e8c28241bbe946160cab16149fb68
- languageName: node
- linkType: hard
-
-"cli-boxes@npm:^3.0.0":
- version: 3.0.0
- resolution: "cli-boxes@npm:3.0.0"
- checksum: 637d84419d293a9eac40a1c8c96a2859e7d98b24a1a317788e13c8f441be052fc899480c6acab3acc82eaf1bccda6b7542d7cdcf5c9c3cc39227175dc098d5b2
- languageName: node
- linkType: hard
-
-"cliui@npm:^8.0.1":
- version: 8.0.1
- resolution: "cliui@npm:8.0.1"
- dependencies:
- string-width: ^4.2.0
- strip-ansi: ^6.0.1
- wrap-ansi: ^7.0.0
- checksum: 79648b3b0045f2e285b76fb2e24e207c6db44323581e421c3acbd0e86454cba1b37aea976ab50195a49e7384b871e6dfb2247ad7dec53c02454ac6497394cb56
- languageName: node
- linkType: hard
-
-"color-convert@npm:^2.0.1":
- version: 2.0.1
- resolution: "color-convert@npm:2.0.1"
- dependencies:
- color-name: ~1.1.4
- checksum: 79e6bdb9fd479a205c71d89574fccfb22bd9053bd98c6c4d870d65c132e5e904e6034978e55b43d69fcaa7433af2016ee203ce76eeba9cfa554b373e7f7db336
- languageName: node
- linkType: hard
-
-"color-name@npm:~1.1.4":
- version: 1.1.4
- resolution: "color-name@npm:1.1.4"
- checksum: b0445859521eb4021cd0fb0cc1a75cecf67fceecae89b63f62b201cca8d345baf8b952c966862a9d9a2632987d4f6581f0ec8d957dfacece86f0a7919316f610
- languageName: node
- linkType: hard
-
-"consola@npm:^3.0.0":
- version: 3.2.3
- resolution: "consola@npm:3.2.3"
- checksum: 32ec70e177dd2385c42e38078958cc7397be91db21af90c6f9faa0b16168b49b1c61d689338604bbb2d64370b9347a35f42a9197663a913d3a405bb0ce728499
- languageName: node
- linkType: hard
-
-"cross-spawn@npm:^7.0.0":
- version: 7.0.3
- resolution: "cross-spawn@npm:7.0.3"
- dependencies:
- path-key: ^3.1.0
- shebang-command: ^2.0.0
- which: ^2.0.1
- checksum: 671cc7c7288c3a8406f3c69a3ae2fc85555c04169e9d611def9a675635472614f1c0ed0ef80955d5b6d4e724f6ced67f0ad1bb006c2ea643488fcfef994d7f52
- languageName: node
- linkType: hard
-
-"debug@npm:4, debug@npm:^4.3.4":
- version: 4.3.4
- resolution: "debug@npm:4.3.4"
- dependencies:
- ms: 2.1.2
- peerDependenciesMeta:
- supports-color:
- optional: true
- checksum: 3dbad3f94ea64f34431a9cbf0bafb61853eda57bff2880036153438f50fb5a84f27683ba0d8e5426bf41a8c6ff03879488120cf5b3a761e77953169c0600a708
- languageName: node
- linkType: hard
-
-"decompress-response@npm:^6.0.0":
- version: 6.0.0
- resolution: "decompress-response@npm:6.0.0"
- dependencies:
- mimic-response: ^3.1.0
- checksum: d377cf47e02d805e283866c3f50d3d21578b779731e8c5072d6ce8c13cc31493db1c2f6784da9d1d5250822120cefa44f1deab112d5981015f2e17444b763812
- languageName: node
- linkType: hard
-
-"deep-extend@npm:^0.6.0":
- version: 0.6.0
- resolution: "deep-extend@npm:0.6.0"
- checksum: 7be7e5a8d468d6b10e6a67c3de828f55001b6eb515d014f7aeb9066ce36bd5717161eb47d6a0f7bed8a9083935b465bc163ee2581c8b128d29bf61092fdf57a7
- languageName: node
- linkType: hard
-
-"detect-libc@npm:^2.0.0":
- version: 2.0.2
- resolution: "detect-libc@npm:2.0.2"
- checksum: 2b2cd3649b83d576f4be7cc37eb3b1815c79969c8b1a03a40a4d55d83bc74d010753485753448eacb98784abf22f7dbd3911fd3b60e29fda28fed2d1a997944d
- languageName: node
- linkType: hard
-
-"drizzle-orm@npm:^0.28.6":
- version: 0.28.6
- resolution: "drizzle-orm@npm:0.28.6"
- peerDependencies:
- "@aws-sdk/client-rds-data": ">=3"
- "@cloudflare/workers-types": ">=3"
- "@libsql/client": "*"
- "@neondatabase/serverless": ">=0.1"
- "@opentelemetry/api": ^1.4.1
- "@planetscale/database": ">=1"
- "@types/better-sqlite3": "*"
- "@types/pg": "*"
- "@types/sql.js": "*"
- "@vercel/postgres": "*"
- better-sqlite3: ">=7"
- bun-types: "*"
- knex: "*"
- kysely: "*"
- mysql2: ">=2"
- pg: ">=8"
- postgres: ">=3"
- sql.js: ">=1"
- sqlite3: ">=5"
- peerDependenciesMeta:
- "@aws-sdk/client-rds-data":
- optional: true
- "@cloudflare/workers-types":
- optional: true
- "@libsql/client":
- optional: true
- "@neondatabase/serverless":
- optional: true
- "@opentelemetry/api":
- optional: true
- "@planetscale/database":
- optional: true
- "@types/better-sqlite3":
- optional: true
- "@types/pg":
- optional: true
- "@types/sql.js":
- optional: true
- "@vercel/postgres":
- optional: true
- better-sqlite3:
- optional: true
- bun-types:
- optional: true
- knex:
- optional: true
- kysely:
- optional: true
- mysql2:
- optional: true
- pg:
- optional: true
- postgres:
- optional: true
- sql.js:
- optional: true
- sqlite3:
- optional: true
- checksum: 1e079be9e969c1e9d325d68164006d976f093f296f6e058222a921debd047aa2369703b2142de03366e38baba3d8ef322f2e094bc0509d16f0577509b1f9bad7
- languageName: node
- linkType: hard
-
-"eastasianwidth@npm:^0.2.0":
- version: 0.2.0
- resolution: "eastasianwidth@npm:0.2.0"
- checksum: 7d00d7cd8e49b9afa762a813faac332dee781932d6f2c848dc348939c4253f1d4564341b7af1d041853bc3f32c2ef141b58e0a4d9862c17a7f08f68df1e0f1ed
- languageName: node
- linkType: hard
-
-"emoji-regex@npm:^8.0.0":
- version: 8.0.0
- resolution: "emoji-regex@npm:8.0.0"
- checksum: d4c5c39d5a9868b5fa152f00cada8a936868fd3367f33f71be515ecee4c803132d11b31a6222b2571b1e5f7e13890156a94880345594d0ce7e3c9895f560f192
- languageName: node
- linkType: hard
-
-"emoji-regex@npm:^9.2.2":
- version: 9.2.2
- resolution: "emoji-regex@npm:9.2.2"
- checksum: 8487182da74aabd810ac6d6f1994111dfc0e331b01271ae01ec1eb0ad7b5ecc2bbbbd2f053c05cb55a1ac30449527d819bbfbf0e3de1023db308cbcb47f86601
- languageName: node
- linkType: hard
-
-"encoding@npm:^0.1.13":
- version: 0.1.13
- resolution: "encoding@npm:0.1.13"
- dependencies:
- iconv-lite: ^0.6.2
- checksum: bb98632f8ffa823996e508ce6a58ffcf5856330fde839ae42c9e1f436cc3b5cc651d4aeae72222916545428e54fd0f6aa8862fd8d25bdbcc4589f1e3f3715e7f
- languageName: node
- linkType: hard
-
-"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1":
- version: 1.4.4
- resolution: "end-of-stream@npm:1.4.4"
- dependencies:
- once: ^1.4.0
- checksum: 530a5a5a1e517e962854a31693dbb5c0b2fc40b46dad2a56a2deec656ca040631124f4795823acc68238147805f8b021abbe221f4afed5ef3c8e8efc2024908b
- languageName: node
- linkType: hard
-
-"env-paths@npm:^2.2.0":
- version: 2.2.1
- resolution: "env-paths@npm:2.2.1"
- checksum: 65b5df55a8bab92229ab2b40dad3b387fad24613263d103a97f91c9fe43ceb21965cd3392b1ccb5d77088021e525c4e0481adb309625d0cb94ade1d1fb8dc17e
- languageName: node
- linkType: hard
-
-"err-code@npm:^2.0.2":
- version: 2.0.3
- resolution: "err-code@npm:2.0.3"
- checksum: 8b7b1be20d2de12d2255c0bc2ca638b7af5171142693299416e6a9339bd7d88fc8d7707d913d78e0993176005405a236b066b45666b27b797252c771156ace54
- languageName: node
- linkType: hard
-
-"escalade@npm:^3.1.1":
- version: 3.1.1
- resolution: "escalade@npm:3.1.1"
- checksum: a3e2a99f07acb74b3ad4989c48ca0c3140f69f923e56d0cba0526240ee470b91010f9d39001f2a4a313841d237ede70a729e92125191ba5d21e74b106800b133
- languageName: node
- linkType: hard
-
-"expand-template@npm:^2.0.3":
- version: 2.0.3
- resolution: "expand-template@npm:2.0.3"
- checksum: 588c19847216421ed92befb521767b7018dc88f88b0576df98cb242f20961425e96a92cbece525ef28cc5becceae5d544ae0f5b9b5e2aa05acb13716ca5b3099
- languageName: node
- linkType: hard
-
-"exponential-backoff@npm:^3.1.1":
- version: 3.1.1
- resolution: "exponential-backoff@npm:3.1.1"
- checksum: 3d21519a4f8207c99f7457287291316306255a328770d320b401114ec8481986e4e467e854cb9914dd965e0a1ca810a23ccb559c642c88f4c7f55c55778a9b48
- languageName: node
- linkType: hard
-
-"file-uri-to-path@npm:1.0.0":
- version: 1.0.0
- resolution: "file-uri-to-path@npm:1.0.0"
- checksum: b648580bdd893a008c92c7ecc96c3ee57a5e7b6c4c18a9a09b44fb5d36d79146f8e442578bc0e173dc027adf3987e254ba1dfd6e3ec998b7c282873010502144
- languageName: node
- linkType: hard
-
-"foreground-child@npm:^3.1.0":
- version: 3.1.1
- resolution: "foreground-child@npm:3.1.1"
- dependencies:
- cross-spawn: ^7.0.0
- signal-exit: ^4.0.1
- checksum: 139d270bc82dc9e6f8bc045fe2aae4001dc2472157044fdfad376d0a3457f77857fa883c1c8b21b491c6caade9a926a4bed3d3d2e8d3c9202b151a4cbbd0bcd5
- languageName: node
- linkType: hard
-
-"fs-constants@npm:^1.0.0":
- version: 1.0.0
- resolution: "fs-constants@npm:1.0.0"
- checksum: 18f5b718371816155849475ac36c7d0b24d39a11d91348cfcb308b4494824413e03572c403c86d3a260e049465518c4f0d5bd00f0371cdfcad6d4f30a85b350d
- languageName: node
- linkType: hard
-
-"fs-minipass@npm:^2.0.0":
- version: 2.1.0
- resolution: "fs-minipass@npm:2.1.0"
- dependencies:
- minipass: ^3.0.0
- checksum: 1b8d128dae2ac6cc94230cc5ead341ba3e0efaef82dab46a33d171c044caaa6ca001364178d42069b2809c35a1c3c35079a32107c770e9ffab3901b59af8c8b1
- languageName: node
- linkType: hard
-
-"fs-minipass@npm:^3.0.0":
- version: 3.0.3
- resolution: "fs-minipass@npm:3.0.3"
- dependencies:
- minipass: ^7.0.3
- checksum: 8722a41109130851d979222d3ec88aabaceeaaf8f57b2a8f744ef8bd2d1ce95453b04a61daa0078822bc5cd21e008814f06fe6586f56fef511e71b8d2394d802
- languageName: node
- linkType: hard
-
-"get-caller-file@npm:^2.0.5":
- version: 2.0.5
- resolution: "get-caller-file@npm:2.0.5"
- checksum: b9769a836d2a98c3ee734a88ba712e62703f1df31b94b784762c433c27a386dd6029ff55c2a920c392e33657d80191edbf18c61487e198844844516f843496b9
- languageName: node
- linkType: hard
-
-"github-from-package@npm:0.0.0":
- version: 0.0.0
- resolution: "github-from-package@npm:0.0.0"
- checksum: 14e448192a35c1e42efee94c9d01a10f42fe790375891a24b25261246ce9336ab9df5d274585aedd4568f7922246c2a78b8a8cd2571bfe99c693a9718e7dd0e3
- languageName: node
- linkType: hard
-
-"glob@npm:^10.2.2, glob@npm:^10.3.10":
- version: 10.3.10
- resolution: "glob@npm:10.3.10"
- dependencies:
- foreground-child: ^3.1.0
- jackspeak: ^2.3.5
- minimatch: ^9.0.1
- minipass: ^5.0.0 || ^6.0.2 || ^7.0.0
- path-scurry: ^1.10.1
- bin:
- glob: dist/esm/bin.mjs
- checksum: 4f2fe2511e157b5a3f525a54092169a5f92405f24d2aed3142f4411df328baca13059f4182f1db1bf933e2c69c0bd89e57ae87edd8950cba8c7ccbe84f721cf3
- languageName: node
- linkType: hard
-
-"graceful-fs@npm:^4.2.6":
- version: 4.2.11
- resolution: "graceful-fs@npm:4.2.11"
- checksum: ac85f94da92d8eb6b7f5a8b20ce65e43d66761c55ce85ac96df6865308390da45a8d3f0296dd3a663de65d30ba497bd46c696cc1e248c72b13d6d567138a4fc7
- languageName: node
- linkType: hard
-
-"http-cache-semantics@npm:^4.1.1":
- version: 4.1.1
- resolution: "http-cache-semantics@npm:4.1.1"
- checksum: 83ac0bc60b17a3a36f9953e7be55e5c8f41acc61b22583060e8dedc9dd5e3607c823a88d0926f9150e571f90946835c7fe150732801010845c72cd8bbff1a236
- languageName: node
- linkType: hard
-
-"http-proxy-agent@npm:^7.0.0":
- version: 7.0.0
- resolution: "http-proxy-agent@npm:7.0.0"
- dependencies:
- agent-base: ^7.1.0
- debug: ^4.3.4
- checksum: 48d4fac997917e15f45094852b63b62a46d0c8a4f0b9c6c23ca26d27b8df8d178bed88389e604745e748bd9a01f5023e25093722777f0593c3f052009ff438b6
- languageName: node
- linkType: hard
-
-"https-proxy-agent@npm:^7.0.1":
- version: 7.0.2
- resolution: "https-proxy-agent@npm:7.0.2"
- dependencies:
- agent-base: ^7.0.2
- debug: 4
- checksum: 088969a0dd476ea7a0ed0a2cf1283013682b08f874c3bc6696c83fa061d2c157d29ef0ad3eb70a2046010bb7665573b2388d10fdcb3e410a66995e5248444292
- languageName: node
- linkType: hard
-
-"iconv-lite@npm:^0.6.2":
- version: 0.6.3
- resolution: "iconv-lite@npm:0.6.3"
- dependencies:
- safer-buffer: ">= 2.1.2 < 3.0.0"
- checksum: 3f60d47a5c8fc3313317edfd29a00a692cc87a19cac0159e2ce711d0ebc9019064108323b5e493625e25594f11c6236647d8e256fbe7a58f4a3b33b89e6d30bf
- languageName: node
- linkType: hard
-
-"ieee754@npm:^1.1.13":
- version: 1.2.1
- resolution: "ieee754@npm:1.2.1"
- checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e
- languageName: node
- linkType: hard
-
-"imurmurhash@npm:^0.1.4":
- version: 0.1.4
- resolution: "imurmurhash@npm:0.1.4"
- checksum: 7cae75c8cd9a50f57dadd77482359f659eaebac0319dd9368bcd1714f55e65badd6929ca58569da2b6494ef13fdd5598cd700b1eba23f8b79c5f19d195a3ecf7
- languageName: node
- linkType: hard
-
-"indent-string@npm:^4.0.0":
- version: 4.0.0
- resolution: "indent-string@npm:4.0.0"
- checksum: 824cfb9929d031dabf059bebfe08cf3137365e112019086ed3dcff6a0a7b698cb80cf67ccccde0e25b9e2d7527aa6cc1fed1ac490c752162496caba3e6699612
- languageName: node
- linkType: hard
-
-"inherits@npm:^2.0.3, inherits@npm:^2.0.4":
- version: 2.0.4
- resolution: "inherits@npm:2.0.4"
- checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1
- languageName: node
- linkType: hard
-
-"ini@npm:~1.3.0":
- version: 1.3.8
- resolution: "ini@npm:1.3.8"
- checksum: dfd98b0ca3a4fc1e323e38a6c8eb8936e31a97a918d3b377649ea15bdb15d481207a0dda1021efbd86b464cae29a0d33c1d7dcaf6c5672bee17fa849bc50a1b3
- languageName: node
- linkType: hard
-
-"ip@npm:^2.0.0":
- version: 2.0.0
- resolution: "ip@npm:2.0.0"
- checksum: cfcfac6b873b701996d71ec82a7dd27ba92450afdb421e356f44044ed688df04567344c36cbacea7d01b1c39a4c732dc012570ebe9bebfb06f27314bca625349
- languageName: node
- linkType: hard
-
-"is-fullwidth-code-point@npm:^3.0.0":
- version: 3.0.0
- resolution: "is-fullwidth-code-point@npm:3.0.0"
- checksum: 44a30c29457c7fb8f00297bce733f0a64cd22eca270f83e58c105e0d015e45c019491a4ab2faef91ab51d4738c670daff901c799f6a700e27f7314029e99e348
- languageName: node
- linkType: hard
-
-"is-lambda@npm:^1.0.1":
- version: 1.0.1
- resolution: "is-lambda@npm:1.0.1"
- checksum: 93a32f01940220532e5948538699ad610d5924ac86093fcee83022252b363eb0cc99ba53ab084a04e4fb62bf7b5731f55496257a4c38adf87af9c4d352c71c35
- languageName: node
- linkType: hard
-
-"isexe@npm:^2.0.0":
- version: 2.0.0
- resolution: "isexe@npm:2.0.0"
- checksum: 26bf6c5480dda5161c820c5b5c751ae1e766c587b1f951ea3fcfc973bafb7831ae5b54a31a69bd670220e42e99ec154475025a468eae58ea262f813fdc8d1c62
- languageName: node
- linkType: hard
-
-"isexe@npm:^3.1.1":
- version: 3.1.1
- resolution: "isexe@npm:3.1.1"
- checksum: 7fe1931ee4e88eb5aa524cd3ceb8c882537bc3a81b02e438b240e47012eef49c86904d0f0e593ea7c3a9996d18d0f1f3be8d3eaa92333977b0c3a9d353d5563e
- languageName: node
- linkType: hard
-
-"jackspeak@npm:^2.3.5":
- version: 2.3.6
- resolution: "jackspeak@npm:2.3.6"
- dependencies:
- "@isaacs/cliui": ^8.0.2
- "@pkgjs/parseargs": ^0.11.0
- dependenciesMeta:
- "@pkgjs/parseargs":
- optional: true
- checksum: 57d43ad11eadc98cdfe7496612f6bbb5255ea69fe51ea431162db302c2a11011642f50cfad57288bd0aea78384a0612b16e131944ad8ecd09d619041c8531b54
- languageName: node
- linkType: hard
-
-"lru-cache@npm:^10.0.1, lru-cache@npm:^9.1.1 || ^10.0.0":
- version: 10.1.0
- resolution: "lru-cache@npm:10.1.0"
- checksum: 58056d33e2500fbedce92f8c542e7c11b50d7d086578f14b7074d8c241422004af0718e08a6eaae8705cee09c77e39a61c1c79e9370ba689b7010c152e6a76ab
- languageName: node
- linkType: hard
-
-"lru-cache@npm:^6.0.0":
- version: 6.0.0
- resolution: "lru-cache@npm:6.0.0"
- dependencies:
- yallist: ^4.0.0
- checksum: f97f499f898f23e4585742138a22f22526254fdba6d75d41a1c2526b3b6cc5747ef59c5612ba7375f42aca4f8461950e925ba08c991ead0651b4918b7c978297
- languageName: node
- linkType: hard
-
-"make-fetch-happen@npm:^13.0.0":
- version: 13.0.0
- resolution: "make-fetch-happen@npm:13.0.0"
- dependencies:
- "@npmcli/agent": ^2.0.0
- cacache: ^18.0.0
- http-cache-semantics: ^4.1.1
- is-lambda: ^1.0.1
- minipass: ^7.0.2
- minipass-fetch: ^3.0.0
- minipass-flush: ^1.0.5
- minipass-pipeline: ^1.2.4
- negotiator: ^0.6.3
- promise-retry: ^2.0.1
- ssri: ^10.0.0
- checksum: 7c7a6d381ce919dd83af398b66459a10e2fe8f4504f340d1d090d3fa3d1b0c93750220e1d898114c64467223504bd258612ba83efbc16f31b075cd56de24b4af
- languageName: node
- linkType: hard
-
-"mimic-response@npm:^3.1.0":
- version: 3.1.0
- resolution: "mimic-response@npm:3.1.0"
- checksum: 25739fee32c17f433626bf19f016df9036b75b3d84a3046c7d156e72ec963dd29d7fc8a302f55a3d6c5a4ff24259676b15d915aad6480815a969ff2ec0836867
- languageName: node
- linkType: hard
-
-"minimatch@npm:^9.0.1":
- version: 9.0.3
- resolution: "minimatch@npm:9.0.3"
- dependencies:
- brace-expansion: ^2.0.1
- checksum: 253487976bf485b612f16bf57463520a14f512662e592e95c571afdab1442a6a6864b6c88f248ce6fc4ff0b6de04ac7aa6c8bb51e868e99d1d65eb0658a708b5
- languageName: node
- linkType: hard
-
-"minimist@npm:^1.2.0, minimist@npm:^1.2.3":
- version: 1.2.8
- resolution: "minimist@npm:1.2.8"
- checksum: 75a6d645fb122dad29c06a7597bddea977258957ed88d7a6df59b5cd3fe4a527e253e9bbf2e783e4b73657f9098b96a5fe96ab8a113655d4109108577ecf85b0
- languageName: node
- linkType: hard
-
-"minipass-collect@npm:^2.0.1":
- version: 2.0.1
- resolution: "minipass-collect@npm:2.0.1"
- dependencies:
- minipass: ^7.0.3
- checksum: b251bceea62090f67a6cced7a446a36f4cd61ee2d5cea9aee7fff79ba8030e416327a1c5aa2908dc22629d06214b46d88fdab8c51ac76bacbf5703851b5ad342
- languageName: node
- linkType: hard
-
-"minipass-fetch@npm:^3.0.0":
- version: 3.0.4
- resolution: "minipass-fetch@npm:3.0.4"
- dependencies:
- encoding: ^0.1.13
- minipass: ^7.0.3
- minipass-sized: ^1.0.3
- minizlib: ^2.1.2
- dependenciesMeta:
- encoding:
- optional: true
- checksum: af7aad15d5c128ab1ebe52e043bdf7d62c3c6f0cecb9285b40d7b395e1375b45dcdfd40e63e93d26a0e8249c9efd5c325c65575aceee192883970ff8cb11364a
- languageName: node
- linkType: hard
-
-"minipass-flush@npm:^1.0.5":
- version: 1.0.5
- resolution: "minipass-flush@npm:1.0.5"
- dependencies:
- minipass: ^3.0.0
- checksum: 56269a0b22bad756a08a94b1ffc36b7c9c5de0735a4dd1ab2b06c066d795cfd1f0ac44a0fcae13eece5589b908ecddc867f04c745c7009be0b566421ea0944cf
- languageName: node
- linkType: hard
-
-"minipass-pipeline@npm:^1.2.4":
- version: 1.2.4
- resolution: "minipass-pipeline@npm:1.2.4"
- dependencies:
- minipass: ^3.0.0
- checksum: b14240dac0d29823c3d5911c286069e36d0b81173d7bdf07a7e4a91ecdef92cdff4baaf31ea3746f1c61e0957f652e641223970870e2353593f382112257971b
- languageName: node
- linkType: hard
-
-"minipass-sized@npm:^1.0.3":
- version: 1.0.3
- resolution: "minipass-sized@npm:1.0.3"
- dependencies:
- minipass: ^3.0.0
- checksum: 79076749fcacf21b5d16dd596d32c3b6bf4d6e62abb43868fac21674078505c8b15eaca4e47ed844985a4514854f917d78f588fcd029693709417d8f98b2bd60
- languageName: node
- linkType: hard
-
-"minipass@npm:^3.0.0":
- version: 3.3.6
- resolution: "minipass@npm:3.3.6"
- dependencies:
- yallist: ^4.0.0
- checksum: a30d083c8054cee83cdcdc97f97e4641a3f58ae743970457b1489ce38ee1167b3aaf7d815cd39ec7a99b9c40397fd4f686e83750e73e652b21cb516f6d845e48
- languageName: node
- linkType: hard
-
-"minipass@npm:^5.0.0":
- version: 5.0.0
- resolution: "minipass@npm:5.0.0"
- checksum: 425dab288738853fded43da3314a0b5c035844d6f3097a8e3b5b29b328da8f3c1af6fc70618b32c29ff906284cf6406b6841376f21caaadd0793c1d5a6a620ea
- languageName: node
- linkType: hard
-
-"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3":
- version: 7.0.4
- resolution: "minipass@npm:7.0.4"
- checksum: 87585e258b9488caf2e7acea242fd7856bbe9a2c84a7807643513a338d66f368c7d518200ad7b70a508664d408aa000517647b2930c259a8b1f9f0984f344a21
- languageName: node
- linkType: hard
-
-"minizlib@npm:^2.1.1, minizlib@npm:^2.1.2":
- version: 2.1.2
- resolution: "minizlib@npm:2.1.2"
- dependencies:
- minipass: ^3.0.0
- yallist: ^4.0.0
- checksum: f1fdeac0b07cf8f30fcf12f4b586795b97be856edea22b5e9072707be51fc95d41487faec3f265b42973a304fe3a64acd91a44a3826a963e37b37bafde0212c3
- languageName: node
- linkType: hard
-
-"mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3":
- version: 0.5.3
- resolution: "mkdirp-classic@npm:0.5.3"
- checksum: 3f4e088208270bbcc148d53b73e9a5bd9eef05ad2cbf3b3d0ff8795278d50dd1d11a8ef1875ff5aea3fa888931f95bfcb2ad5b7c1061cfefd6284d199e6776ac
- languageName: node
- linkType: hard
-
-"mkdirp@npm:^1.0.3":
- version: 1.0.4
- resolution: "mkdirp@npm:1.0.4"
- bin:
- mkdirp: bin/cmd.js
- checksum: a96865108c6c3b1b8e1d5e9f11843de1e077e57737602de1b82030815f311be11f96f09cce59bd5b903d0b29834733e5313f9301e3ed6d6f6fba2eae0df4298f
- languageName: node
- linkType: hard
-
-"ms@npm:2.1.2":
- version: 2.1.2
- resolution: "ms@npm:2.1.2"
- checksum: 673cdb2c3133eb050c745908d8ce632ed2c02d85640e2edb3ace856a2266a813b30c613569bf3354fdf4ea7d1a1494add3bfa95e2713baa27d0c2c71fc44f58f
- languageName: node
- linkType: hard
-
-"napi-build-utils@npm:^1.0.1":
- version: 1.0.2
- resolution: "napi-build-utils@npm:1.0.2"
- checksum: 06c14271ee966e108d55ae109f340976a9556c8603e888037145d6522726aebe89dd0c861b4b83947feaf6d39e79e08817559e8693deedc2c94e82c5cbd090c7
- languageName: node
- linkType: hard
-
-"negotiator@npm:^0.6.3":
- version: 0.6.3
- resolution: "negotiator@npm:0.6.3"
- checksum: b8ffeb1e262eff7968fc90a2b6767b04cfd9842582a9d0ece0af7049537266e7b2506dfb1d107a32f06dd849ab2aea834d5830f7f4d0e5cb7d36e1ae55d021d9
- languageName: node
- linkType: hard
-
-"node-abi@npm:^3.3.0":
- version: 3.51.0
- resolution: "node-abi@npm:3.51.0"
- dependencies:
- semver: ^7.3.5
- checksum: 3fabc9d58f0478767157560249f79c4a9e95082b96700cd8cc470f517bd566dbab82a37c862db3f78d3187be9f19f5cd9822b6f1b7ac7a3254fa70c3e3b38a83
- languageName: node
- linkType: hard
-
-"node-gyp@npm:latest":
- version: 10.0.1
- resolution: "node-gyp@npm:10.0.1"
- dependencies:
- env-paths: ^2.2.0
- exponential-backoff: ^3.1.1
- glob: ^10.3.10
- graceful-fs: ^4.2.6
- make-fetch-happen: ^13.0.0
- nopt: ^7.0.0
- proc-log: ^3.0.0
- semver: ^7.3.5
- tar: ^6.1.2
- which: ^4.0.0
- bin:
- node-gyp: bin/node-gyp.js
- checksum: 60a74e66d364903ce02049966303a57f898521d139860ac82744a5fdd9f7b7b3b61f75f284f3bfe6e6add3b8f1871ce305a1d41f775c7482de837b50c792223f
- languageName: node
- linkType: hard
-
-"nopt@npm:^7.0.0":
- version: 7.2.0
- resolution: "nopt@npm:7.2.0"
- dependencies:
- abbrev: ^2.0.0
- bin:
- nopt: bin/nopt.js
- checksum: a9c0f57fb8cb9cc82ae47192ca2b7ef00e199b9480eed202482c962d61b59a7fbe7541920b2a5839a97b42ee39e288c0aed770e38057a608d7f579389dfde410
- languageName: node
- linkType: hard
-
-"once@npm:^1.3.1, once@npm:^1.4.0":
- version: 1.4.0
- resolution: "once@npm:1.4.0"
- dependencies:
- wrappy: 1
- checksum: cd0a88501333edd640d95f0d2700fbde6bff20b3d4d9bdc521bdd31af0656b5706570d6c6afe532045a20bb8dc0849f8332d6f2a416e0ba6d3d3b98806c7db68
- languageName: node
- linkType: hard
-
-"p-map@npm:^4.0.0":
- version: 4.0.0
- resolution: "p-map@npm:4.0.0"
- dependencies:
- aggregate-error: ^3.0.0
- checksum: cb0ab21ec0f32ddffd31dfc250e3afa61e103ef43d957cc45497afe37513634589316de4eb88abdfd969fe6410c22c0b93ab24328833b8eb1ccc087fc0442a1c
- languageName: node
- linkType: hard
-
-"path-key@npm:^3.1.0":
- version: 3.1.1
- resolution: "path-key@npm:3.1.1"
- checksum: 55cd7a9dd4b343412a8386a743f9c746ef196e57c823d90ca3ab917f90ab9f13dd0ded27252ba49dbdfcab2b091d998bc446f6220cd3cea65db407502a740020
- languageName: node
- linkType: hard
-
-"path-scurry@npm:^1.10.1":
- version: 1.10.1
- resolution: "path-scurry@npm:1.10.1"
- dependencies:
- lru-cache: ^9.1.1 || ^10.0.0
- minipass: ^5.0.0 || ^6.0.2 || ^7.0.0
- checksum: e2557cff3a8fb8bc07afdd6ab163a92587884f9969b05bbbaf6fe7379348bfb09af9ed292af12ed32398b15fb443e81692047b786d1eeb6d898a51eb17ed7d90
- languageName: node
- linkType: hard
-
-"prebuild-install@npm:^7.1.1":
- version: 7.1.1
- resolution: "prebuild-install@npm:7.1.1"
- dependencies:
- detect-libc: ^2.0.0
- expand-template: ^2.0.3
- github-from-package: 0.0.0
- minimist: ^1.2.3
- mkdirp-classic: ^0.5.3
- napi-build-utils: ^1.0.1
- node-abi: ^3.3.0
- pump: ^3.0.0
- rc: ^1.2.7
- simple-get: ^4.0.0
- tar-fs: ^2.0.0
- tunnel-agent: ^0.6.0
- bin:
- prebuild-install: bin.js
- checksum: dbf96d0146b6b5827fc8f67f72074d2e19c69628b9a7a0a17d0fad1bf37e9f06922896972e074197fc00a52eae912993e6ef5a0d471652f561df5cb516f3f467
- languageName: node
- linkType: hard
-
-"proc-log@npm:^3.0.0":
- version: 3.0.0
- resolution: "proc-log@npm:3.0.0"
- checksum: 02b64e1b3919e63df06f836b98d3af002b5cd92655cab18b5746e37374bfb73e03b84fe305454614b34c25b485cc687a9eebdccf0242cda8fda2475dd2c97e02
- languageName: node
- linkType: hard
-
-"promise-retry@npm:^2.0.1":
- version: 2.0.1
- resolution: "promise-retry@npm:2.0.1"
- dependencies:
- err-code: ^2.0.2
- retry: ^0.12.0
- checksum: f96a3f6d90b92b568a26f71e966cbbc0f63ab85ea6ff6c81284dc869b41510e6cdef99b6b65f9030f0db422bf7c96652a3fff9f2e8fb4a0f069d8f4430359429
- languageName: node
- linkType: hard
-
-"pump@npm:^3.0.0":
- version: 3.0.0
- resolution: "pump@npm:3.0.0"
- dependencies:
- end-of-stream: ^1.1.0
- once: ^1.3.1
- checksum: e42e9229fba14732593a718b04cb5e1cfef8254544870997e0ecd9732b189a48e1256e4e5478148ecb47c8511dca2b09eae56b4d0aad8009e6fac8072923cfc9
- languageName: node
- linkType: hard
-
-"rc@npm:^1.2.7":
- version: 1.2.8
- resolution: "rc@npm:1.2.8"
- dependencies:
- deep-extend: ^0.6.0
- ini: ~1.3.0
- minimist: ^1.2.0
- strip-json-comments: ~2.0.1
- bin:
- rc: ./cli.js
- checksum: 2e26e052f8be2abd64e6d1dabfbd7be03f80ec18ccbc49562d31f617d0015fbdbcf0f9eed30346ea6ab789e0fdfe4337f033f8016efdbee0df5354751842080e
- languageName: node
- linkType: hard
-
-"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0":
- version: 3.6.2
- resolution: "readable-stream@npm:3.6.2"
- dependencies:
- inherits: ^2.0.3
- string_decoder: ^1.1.1
- util-deprecate: ^1.0.1
- checksum: bdcbe6c22e846b6af075e32cf8f4751c2576238c5043169a1c221c92ee2878458a816a4ea33f4c67623c0b6827c8a400409bfb3cf0bf3381392d0b1dfb52ac8d
- languageName: node
- linkType: hard
-
-"require-directory@npm:^2.1.1":
- version: 2.1.1
- resolution: "require-directory@npm:2.1.1"
- checksum: fb47e70bf0001fdeabdc0429d431863e9475e7e43ea5f94ad86503d918423c1543361cc5166d713eaa7029dd7a3d34775af04764bebff99ef413111a5af18c80
- languageName: node
- linkType: hard
-
-"retry@npm:^0.12.0":
- version: 0.12.0
- resolution: "retry@npm:0.12.0"
- checksum: 623bd7d2e5119467ba66202d733ec3c2e2e26568074923bc0585b6b99db14f357e79bdedb63cab56cec47491c4a0da7e6021a7465ca6dc4f481d3898fdd3158c
- languageName: node
- linkType: hard
-
-"root-workspace-0b6124@workspace:.":
- version: 0.0.0-use.local
- resolution: "root-workspace-0b6124@workspace:."
- dependencies:
- bcryptjs: ^2.4.3
- better-sqlite3: ^8.6.0
- boxen: ^7.1.1
- chalk: ^5.3.0
- consola: ^3.0.0
- drizzle-orm: ^0.28.6
- yargs: ^17.7.2
- languageName: unknown
- linkType: soft
-
-"safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0":
- version: 5.2.1
- resolution: "safe-buffer@npm:5.2.1"
- checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491
- languageName: node
- linkType: hard
-
-"safer-buffer@npm:>= 2.1.2 < 3.0.0":
- version: 2.1.2
- resolution: "safer-buffer@npm:2.1.2"
- checksum: cab8f25ae6f1434abee8d80023d7e72b598cf1327164ddab31003c51215526801e40b66c5e65d658a0af1e9d6478cadcb4c745f4bd6751f97d8644786c0978b0
- languageName: node
- linkType: hard
-
-"semver@npm:^7.3.5":
- version: 7.5.4
- resolution: "semver@npm:7.5.4"
- dependencies:
- lru-cache: ^6.0.0
- bin:
- semver: bin/semver.js
- checksum: 12d8ad952fa353b0995bf180cdac205a4068b759a140e5d3c608317098b3575ac2f1e09182206bf2eb26120e1c0ed8fb92c48c592f6099680de56bb071423ca3
- languageName: node
- linkType: hard
-
-"shebang-command@npm:^2.0.0":
- version: 2.0.0
- resolution: "shebang-command@npm:2.0.0"
- dependencies:
- shebang-regex: ^3.0.0
- checksum: 6b52fe87271c12968f6a054e60f6bde5f0f3d2db483a1e5c3e12d657c488a15474121a1d55cd958f6df026a54374ec38a4a963988c213b7570e1d51575cea7fa
- languageName: node
- linkType: hard
-
-"shebang-regex@npm:^3.0.0":
- version: 3.0.0
- resolution: "shebang-regex@npm:3.0.0"
- checksum: 1a2bcae50de99034fcd92ad4212d8e01eedf52c7ec7830eedcf886622804fe36884278f2be8be0ea5fde3fd1c23911643a4e0f726c8685b61871c8908af01222
- languageName: node
- linkType: hard
-
-"signal-exit@npm:^4.0.1":
- version: 4.1.0
- resolution: "signal-exit@npm:4.1.0"
- checksum: 64c757b498cb8629ffa5f75485340594d2f8189e9b08700e69199069c8e3070fb3e255f7ab873c05dc0b3cec412aea7402e10a5990cb6a050bd33ba062a6c549
- languageName: node
- linkType: hard
-
-"simple-concat@npm:^1.0.0":
- version: 1.0.1
- resolution: "simple-concat@npm:1.0.1"
- checksum: 4d211042cc3d73a718c21ac6c4e7d7a0363e184be6a5ad25c8a1502e49df6d0a0253979e3d50dbdd3f60ef6c6c58d756b5d66ac1e05cda9cacd2e9fc59e3876a
- languageName: node
- linkType: hard
-
-"simple-get@npm:^4.0.0":
- version: 4.0.1
- resolution: "simple-get@npm:4.0.1"
- dependencies:
- decompress-response: ^6.0.0
- once: ^1.3.1
- simple-concat: ^1.0.0
- checksum: e4132fd27cf7af230d853fa45c1b8ce900cb430dd0a3c6d3829649fe4f2b26574c803698076c4006450efb0fad2ba8c5455fbb5755d4b0a5ec42d4f12b31d27e
- languageName: node
- linkType: hard
-
-"smart-buffer@npm:^4.2.0":
- version: 4.2.0
- resolution: "smart-buffer@npm:4.2.0"
- checksum: b5167a7142c1da704c0e3af85c402002b597081dd9575031a90b4f229ca5678e9a36e8a374f1814c8156a725d17008ae3bde63b92f9cfd132526379e580bec8b
- languageName: node
- linkType: hard
-
-"socks-proxy-agent@npm:^8.0.1":
- version: 8.0.2
- resolution: "socks-proxy-agent@npm:8.0.2"
- dependencies:
- agent-base: ^7.0.2
- debug: ^4.3.4
- socks: ^2.7.1
- checksum: 4fb165df08f1f380881dcd887b3cdfdc1aba3797c76c1e9f51d29048be6e494c5b06d68e7aea2e23df4572428f27a3ec22b3d7c75c570c5346507433899a4b6d
- languageName: node
- linkType: hard
-
-"socks@npm:^2.7.1":
- version: 2.7.1
- resolution: "socks@npm:2.7.1"
- dependencies:
- ip: ^2.0.0
- smart-buffer: ^4.2.0
- checksum: 259d9e3e8e1c9809a7f5c32238c3d4d2a36b39b83851d0f573bfde5f21c4b1288417ce1af06af1452569cd1eb0841169afd4998f0e04ba04656f6b7f0e46d748
- languageName: node
- linkType: hard
-
-"ssri@npm:^10.0.0":
- version: 10.0.5
- resolution: "ssri@npm:10.0.5"
- dependencies:
- minipass: ^7.0.3
- checksum: 0a31b65f21872dea1ed3f7c200d7bc1c1b91c15e419deca14f282508ba917cbb342c08a6814c7f68ca4ca4116dd1a85da2bbf39227480e50125a1ceffeecb750
- languageName: node
- linkType: hard
-
-"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3":
- version: 4.2.3
- resolution: "string-width@npm:4.2.3"
- dependencies:
- emoji-regex: ^8.0.0
- is-fullwidth-code-point: ^3.0.0
- strip-ansi: ^6.0.1
- checksum: e52c10dc3fbfcd6c3a15f159f54a90024241d0f149cf8aed2982a2d801d2e64df0bf1dc351cf8e95c3319323f9f220c16e740b06faecd53e2462df1d2b5443fb
- languageName: node
- linkType: hard
-
-"string-width@npm:^5.0.1, string-width@npm:^5.1.2":
- version: 5.1.2
- resolution: "string-width@npm:5.1.2"
- dependencies:
- eastasianwidth: ^0.2.0
- emoji-regex: ^9.2.2
- strip-ansi: ^7.0.1
- checksum: 7369deaa29f21dda9a438686154b62c2c5f661f8dda60449088f9f980196f7908fc39fdd1803e3e01541970287cf5deae336798337e9319a7055af89dafa7193
- languageName: node
- linkType: hard
-
-"string_decoder@npm:^1.1.1":
- version: 1.3.0
- resolution: "string_decoder@npm:1.3.0"
- dependencies:
- safe-buffer: ~5.2.0
- checksum: 8417646695a66e73aefc4420eb3b84cc9ffd89572861fe004e6aeb13c7bc00e2f616247505d2dbbef24247c372f70268f594af7126f43548565c68c117bdeb56
- languageName: node
- linkType: hard
-
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1":
- version: 6.0.1
- resolution: "strip-ansi@npm:6.0.1"
- dependencies:
- ansi-regex: ^5.0.1
- checksum: f3cd25890aef3ba6e1a74e20896c21a46f482e93df4a06567cebf2b57edabb15133f1f94e57434e0a958d61186087b1008e89c94875d019910a213181a14fc8c
- languageName: node
- linkType: hard
-
-"strip-ansi@npm:^7.0.1":
- version: 7.1.0
- resolution: "strip-ansi@npm:7.1.0"
- dependencies:
- ansi-regex: ^6.0.1
- checksum: 859c73fcf27869c22a4e4d8c6acfe690064659e84bef9458aa6d13719d09ca88dcfd40cbf31fd0be63518ea1a643fe070b4827d353e09533a5b0b9fd4553d64d
- languageName: node
- linkType: hard
-
-"strip-json-comments@npm:~2.0.1":
- version: 2.0.1
- resolution: "strip-json-comments@npm:2.0.1"
- checksum: 1074ccb63270d32ca28edfb0a281c96b94dc679077828135141f27d52a5a398ef5e78bcf22809d23cadc2b81dfbe345eb5fd8699b385c8b1128907dec4a7d1e1
- languageName: node
- linkType: hard
-
-"tar-fs@npm:^2.0.0":
- version: 2.1.1
- resolution: "tar-fs@npm:2.1.1"
- dependencies:
- chownr: ^1.1.1
- mkdirp-classic: ^0.5.2
- pump: ^3.0.0
- tar-stream: ^2.1.4
- checksum: f5b9a70059f5b2969e65f037b4e4da2daf0fa762d3d232ffd96e819e3f94665dbbbe62f76f084f1acb4dbdcce16c6e4dac08d12ffc6d24b8d76720f4d9cf032d
- languageName: node
- linkType: hard
-
-"tar-stream@npm:^2.1.4":
- version: 2.2.0
- resolution: "tar-stream@npm:2.2.0"
- dependencies:
- bl: ^4.0.3
- end-of-stream: ^1.4.1
- fs-constants: ^1.0.0
- inherits: ^2.0.3
- readable-stream: ^3.1.1
- checksum: 699831a8b97666ef50021c767f84924cfee21c142c2eb0e79c63254e140e6408d6d55a065a2992548e72b06de39237ef2b802b99e3ece93ca3904a37622a66f3
- languageName: node
- linkType: hard
-
-"tar@npm:^6.1.11, tar@npm:^6.1.2":
- version: 6.2.0
- resolution: "tar@npm:6.2.0"
- dependencies:
- chownr: ^2.0.0
- fs-minipass: ^2.0.0
- minipass: ^5.0.0
- minizlib: ^2.1.1
- mkdirp: ^1.0.3
- yallist: ^4.0.0
- checksum: db4d9fe74a2082c3a5016630092c54c8375ff3b280186938cfd104f2e089c4fd9bad58688ef6be9cf186a889671bf355c7cda38f09bbf60604b281715ca57f5c
- languageName: node
- linkType: hard
-
-"tunnel-agent@npm:^0.6.0":
- version: 0.6.0
- resolution: "tunnel-agent@npm:0.6.0"
- dependencies:
- safe-buffer: ^5.0.1
- checksum: 05f6510358f8afc62a057b8b692f05d70c1782b70db86d6a1e0d5e28a32389e52fa6e7707b6c5ecccacc031462e4bc35af85ecfe4bbc341767917b7cf6965711
- languageName: node
- linkType: hard
-
-"type-fest@npm:^2.13.0":
- version: 2.19.0
- resolution: "type-fest@npm:2.19.0"
- checksum: a4ef07ece297c9fba78fc1bd6d85dff4472fe043ede98bd4710d2615d15776902b595abf62bd78339ed6278f021235fb28a96361f8be86ed754f778973a0d278
- languageName: node
- linkType: hard
-
-"unique-filename@npm:^3.0.0":
- version: 3.0.0
- resolution: "unique-filename@npm:3.0.0"
- dependencies:
- unique-slug: ^4.0.0
- checksum: 8e2f59b356cb2e54aab14ff98a51ac6c45781d15ceaab6d4f1c2228b780193dc70fae4463ce9e1df4479cb9d3304d7c2043a3fb905bdeca71cc7e8ce27e063df
- languageName: node
- linkType: hard
-
-"unique-slug@npm:^4.0.0":
- version: 4.0.0
- resolution: "unique-slug@npm:4.0.0"
- dependencies:
- imurmurhash: ^0.1.4
- checksum: 0884b58365af59f89739e6f71e3feacb5b1b41f2df2d842d0757933620e6de08eff347d27e9d499b43c40476cbaf7988638d3acb2ffbcb9d35fd035591adfd15
- languageName: node
- linkType: hard
-
-"util-deprecate@npm:^1.0.1":
- version: 1.0.2
- resolution: "util-deprecate@npm:1.0.2"
- checksum: 474acf1146cb2701fe3b074892217553dfcf9a031280919ba1b8d651a068c9b15d863b7303cb15bd00a862b498e6cf4ad7b4a08fb134edd5a6f7641681cb54a2
- languageName: node
- linkType: hard
-
-"which@npm:^2.0.1":
- version: 2.0.2
- resolution: "which@npm:2.0.2"
- dependencies:
- isexe: ^2.0.0
- bin:
- node-which: ./bin/node-which
- checksum: 1a5c563d3c1b52d5f893c8b61afe11abc3bab4afac492e8da5bde69d550de701cf9806235f20a47b5c8fa8a1d6a9135841de2596535e998027a54589000e66d1
- languageName: node
- linkType: hard
-
-"which@npm:^4.0.0":
- version: 4.0.0
- resolution: "which@npm:4.0.0"
- dependencies:
- isexe: ^3.1.1
- bin:
- node-which: bin/which.js
- checksum: f17e84c042592c21e23c8195108cff18c64050b9efb8459589116999ea9da6dd1509e6a1bac3aeebefd137be00fabbb61b5c2bc0aa0f8526f32b58ee2f545651
- languageName: node
- linkType: hard
-
-"widest-line@npm:^4.0.1":
- version: 4.0.1
- resolution: "widest-line@npm:4.0.1"
- dependencies:
- string-width: ^5.0.1
- checksum: 64c48cf27171221be5f86fc54b94dd29879165bdff1a7aa92dde723d9a8c99fb108312768a5d62c8c2b80b701fa27bbd36a1ddc58367585cd45c0db7920a0cba
- languageName: node
- linkType: hard
-
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0":
- version: 7.0.0
- resolution: "wrap-ansi@npm:7.0.0"
- dependencies:
- ansi-styles: ^4.0.0
- string-width: ^4.1.0
- strip-ansi: ^6.0.0
- checksum: a790b846fd4505de962ba728a21aaeda189b8ee1c7568ca5e817d85930e06ef8d1689d49dbf0e881e8ef84436af3a88bc49115c2e2788d841ff1b8b5b51a608b
- languageName: node
- linkType: hard
-
-"wrap-ansi@npm:^8.1.0":
- version: 8.1.0
- resolution: "wrap-ansi@npm:8.1.0"
- dependencies:
- ansi-styles: ^6.1.0
- string-width: ^5.0.1
- strip-ansi: ^7.0.1
- checksum: 371733296dc2d616900ce15a0049dca0ef67597d6394c57347ba334393599e800bab03c41d4d45221b6bc967b8c453ec3ae4749eff3894202d16800fdfe0e238
- languageName: node
- linkType: hard
-
-"wrappy@npm:1":
- version: 1.0.2
- resolution: "wrappy@npm:1.0.2"
- checksum: 159da4805f7e84a3d003d8841557196034155008f817172d4e986bd591f74aa82aa7db55929a54222309e01079a65a92a9e6414da5a6aa4b01ee44a511ac3ee5
- languageName: node
- linkType: hard
-
-"y18n@npm:^5.0.5":
- version: 5.0.8
- resolution: "y18n@npm:5.0.8"
- checksum: 54f0fb95621ee60898a38c572c515659e51cc9d9f787fb109cef6fde4befbe1c4602dc999d30110feee37456ad0f1660fa2edcfde6a9a740f86a290999550d30
- languageName: node
- linkType: hard
-
-"yallist@npm:^4.0.0":
- version: 4.0.0
- resolution: "yallist@npm:4.0.0"
- checksum: 343617202af32df2a15a3be36a5a8c0c8545208f3d3dfbc6bb7c3e3b7e8c6f8e7485432e4f3b88da3031a6e20afa7c711eded32ddfb122896ac5d914e75848d5
- languageName: node
- linkType: hard
-
-"yargs-parser@npm:^21.1.1":
- version: 21.1.1
- resolution: "yargs-parser@npm:21.1.1"
- checksum: ed2d96a616a9e3e1cc7d204c62ecc61f7aaab633dcbfab2c6df50f7f87b393993fe6640d017759fe112d0cb1e0119f2b4150a87305cc873fd90831c6a58ccf1c
- languageName: node
- linkType: hard
-
-"yargs@npm:^17.7.2":
- version: 17.7.2
- resolution: "yargs@npm:17.7.2"
- dependencies:
- cliui: ^8.0.1
- escalade: ^3.1.1
- get-caller-file: ^2.0.5
- require-directory: ^2.1.1
- string-width: ^4.2.3
- y18n: ^5.0.5
- yargs-parser: ^21.1.1
- checksum: 73b572e863aa4a8cbef323dd911d79d193b772defd5a51aab0aca2d446655216f5002c42c5306033968193bdbf892a7a4c110b0d77954a7fdf563e653967b56a
- languageName: node
- linkType: hard
diff --git a/crowdin.yml b/crowdin.yml
index c55eb4eff..77e335a73 100644
--- a/crowdin.yml
+++ b/crowdin.yml
@@ -1,5 +1,3 @@
-project_id: "534422"
-api_token_env: "CROWDIN_PERSONAL_TOKEN"
files:
- - source: /public/locales/en/**/*.json
- translation: /public/locales/%two_letters_code%/**/%original_file_name%
+ - source: /packages/translation/src/lang/en.json
+ translation: /packages/translation/src/lang/%two_letters_code%.json
diff --git a/data/configs/default.json b/data/configs/default.json
deleted file mode 100644
index b77e3869d..000000000
--- a/data/configs/default.json
+++ /dev/null
@@ -1,513 +0,0 @@
-{
- "schemaVersion": 1,
- "configProperties": {
- "name": "default"
- },
- "categories": [],
- "wrappers": [
- {
- "id": "default",
- "position": 0
- }
- ],
- "apps": [
- {
- "id": "5df743d9-5cb1-457c-85d2-64ff86855652",
- "name": "Documentation",
- "url": "https://homarr.dev",
- "behaviour": {
- "onClickUrl": "https://homarr.dev",
- "externalUrl": "https://homarr.dev",
- "isOpeningNewTab": true
- },
- "network": {
- "enabledStatusChecker": false,
- "statusCodes": [
- "200"
- ]
- },
- "appearance": {
- "iconUrl": "/imgs/logo/logo.png",
- "appNameStatus": "normal",
- "positionAppName": "column",
- "lineClampAppName": 1
- },
- "integration": {
- "type": null,
- "properties": []
- },
- "area": {
- "type": "wrapper",
- "properties": {
- "id": "default"
- }
- },
- "shape": {
- "md": {
- "location": {
- "x": 5,
- "y": 1
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "sm": {
- "location": {
- "x": 0,
- "y": 1
- },
- "size": {
- "width": 1,
- "height": 2
- }
- },
- "lg": {
- "location": {
- "x": 6,
- "y": 1
- },
- "size": {
- "width": 2,
- "height": 2
- }
- }
- }
- },
- {
- "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a337",
- "name": "Discord",
- "url": "https://discord.com/invite/aCsmEV5RgA",
- "behaviour": {
- "onClickUrl": "https://discord.com/invite/aCsmEV5RgA",
- "isOpeningNewTab": true,
- "externalUrl": "https://discord.com/invite/aCsmEV5RgA",
- "tooltipDescription": "Join our Discord server! We're waiting for your ideas and feedback. "
- },
- "network": {
- "enabledStatusChecker": false,
- "statusCodes": [
- "200"
- ]
- },
- "appearance": {
- "iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/discord.png",
- "appNameStatus": "normal",
- "positionAppName": "row-reverse",
- "lineClampAppName": 1
- },
- "integration": {
- "type": null,
- "properties": []
- },
- "area": {
- "type": "wrapper",
- "properties": {
- "id": "default"
- }
- },
- "shape": {
- "md": {
- "location": {
- "x": 3,
- "y": 1
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "sm": {
- "location": {
- "x": 1,
- "y": 4
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "lg": {
- "location": {
- "x": 4,
- "y": 0
- },
- "size": {
- "width": 2,
- "height": 1
- }
- }
- }
- },
- {
- "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a330",
- "name": "Contribute",
- "url": "https://github.com/ajnart/homarr",
- "behaviour": {
- "onClickUrl": "https://github.com/ajnart/homarr",
- "externalUrl": "https://github.com/ajnart/homarr",
- "isOpeningNewTab": true,
- "tooltipDescription": ""
- },
- "network": {
- "enabledStatusChecker": false,
- "statusCodes": []
- },
- "appearance": {
- "iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/github.png",
- "appNameStatus": "normal",
- "positionAppName": "row-reverse",
- "lineClampAppName": 2
- },
- "integration": {
- "type": null,
- "properties": []
- },
- "area": {
- "type": "wrapper",
- "properties": {
- "id": "default"
- }
- },
- "shape": {
- "md": {
- "location": {
- "x": 3,
- "y": 2
- },
- "size": {
- "width": 2,
- "height": 1
- }
- },
- "sm": {
- "location": {
- "x": 1,
- "y": 3
- },
- "size": {
- "width": 2,
- "height": 1
- }
- },
- "lg": {
- "location": {
- "x": 2,
- "y": 0
- },
- "size": {
- "width": 2,
- "height": 1
- }
- }
- }
- },
- {
- "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a990",
- "name": "Donate",
- "url": "https://ko-fi.com/ajnart",
- "behaviour": {
- "onClickUrl": "https://ko-fi.com/ajnart",
- "externalUrl": "https://ko-fi.com/ajnart",
- "isOpeningNewTab": true,
- "tooltipDescription": "Please consider making a donation"
- },
- "network": {
- "enabledStatusChecker": false,
- "statusCodes": [
- "200"
- ]
- },
- "appearance": {
- "iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/ko-fi.png",
- "appNameStatus": "normal",
- "positionAppName": "row-reverse",
- "lineClampAppName": 1
- },
- "integration": {
- "type": null,
- "properties": []
- },
- "area": {
- "type": "wrapper",
- "properties": {
- "id": "default"
- }
- },
- "shape": {
- "md": {
- "location": {
- "x": 4,
- "y": 1
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "sm": {
- "location": {
- "x": 2,
- "y": 4
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "lg": {
- "location": {
- "x": 6,
- "y": 0
- },
- "size": {
- "width": 2,
- "height": 1
- }
- }
- }
- }
- ],
- "widgets": [
- {
- "id": "e3004052-6b83-480e-b458-56e8ccdca5f0",
- "type": "weather",
- "properties": {
- "displayInFahrenheit": false,
- "location": {
- "name": "Paris",
- "latitude": 48.85341,
- "longitude": 2.3488
- },
- "displayCityName": true
- },
- "area": {
- "type": "wrapper",
- "properties": {
- "id": "default"
- }
- },
- "shape": {
- "md": {
- "location": {
- "x": 5,
- "y": 0
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "sm": {
- "location": {
- "x": 2,
- "y": 0
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "lg": {
- "location": {
- "x": 0,
- "y": 0
- },
- "size": {
- "width": 2,
- "height": 1
- }
- }
- }
- },
- {
- "id": "971aa859-8570-49a1-8d34-dd5c7b3638d1",
- "type": "date",
- "properties": {
- "display24HourFormat": true,
- "dateFormat": "hide",
- "enableTimezone": false,
- "timezoneLocation": {
- "name": "Paris",
- "latitude": 48.85341,
- "longitude": 2.3488
- },
- "titleState": "city"
- },
- "area": {
- "type": "wrapper",
- "properties": {
- "id": "default"
- }
- },
- "shape": {
- "sm": {
- "location": {
- "x": 1,
- "y": 0
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "md": {
- "location": {
- "x": 4,
- "y": 0
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "lg": {
- "location": {
- "x": 8,
- "y": 0
- },
- "size": {
- "width": 2,
- "height": 1
- }
- }
- }
- },
- {
- "id": "f252768d-9e69-491b-b6b4-8cad04fa30e8",
- "type": "date",
- "properties": {
- "display24HourFormat": true,
- "dateFormat": "hide",
- "enableTimezone": true,
- "timezoneLocation": {
- "name": "Tokyo",
- "latitude": 35.6895,
- "longitude": 139.69171
- },
- "titleState": "city"
- },
- "area": {
- "type": "wrapper",
- "properties": {
- "id": "default"
- }
- },
- "shape": {
- "sm": {
- "location": {
- "x": 0,
- "y": 0
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "md": {
- "location": {
- "x": 3,
- "y": 0
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "lg": {
- "location": {
- "x": 8,
- "y": 1
- },
- "size": {
- "width": 2,
- "height": 1
- }
- }
- }
- },
- {
- "id": "86b1921f-efa7-410f-92dd-79553bf3264d",
- "type": "notebook",
- "properties": {
- "showToolbar": true,
- "content": "Welcome to Homarr 🚀👋
We're glad that you're here! Homarr is a modern and easy to use dashboard that helps you to organize and manage your home network from one place. Control is at your fingertips.
We recommend you to read the getting started guide first. To edit this board you must enter the edit mode - only administrators can do this. Adding an app is the first step you should take. You can do this by clicking the Add tile button at the top right and select App. After you provided an internal URL, external URL and selected an icon you can drag it around when holding down the left mouse button. Make it bigger or smaller using the drag icon at the bottom right. When you're happy with it's position, you must exit edit mode to save your board. Adding widgets works the same way but may require additional configuration - read the documentation for more information.
To remove this widget, you must log in to your administrator account and click on the menu to delete it.
Your TODO list:
"
- },
- "area": {
- "type": "wrapper",
- "properties": {
- "id": "default"
- }
- },
- "shape": {
- "sm": {
- "location": {
- "x": 0,
- "y": 0
- },
- "size": {
- "width": 3,
- "height": 2
- }
- },
- "md": {
- "location": {
- "x": 0,
- "y": 0
- },
- "size": {
- "width": 3,
- "height": 4
- }
- },
- "lg": {
- "location": {
- "x": 0,
- "y": 1
- },
- "size": {
- "width": 6,
- "height": 3
- }
- }
- }
- }
- ],
- "settings": {
- "common": {
- "searchEngine": {
- "type": "google",
- "properties": {}
- }
- },
- "customization": {
- "layout": {
- "enabledLeftSidebar": false,
- "enabledRightSidebar": false,
- "enabledDocker": false,
- "enabledPing": false,
- "enabledSearchbar": true
- },
- "pageTitle": "Homarr ⭐️",
- "logoImageUrl": "/imgs/logo/logo.png",
- "faviconUrl": "/imgs/favicon/favicon-squared.png",
- "backgroundImageUrl": "",
- "customCss": "",
- "colors": {
- "primary": "red",
- "secondary": "yellow",
- "shade": 7
- },
- "appOpacity": 100,
- "gridstack": {
- "columnCountSmall": 3,
- "columnCountMedium": 6,
- "columnCountLarge": 10
- }
- },
- "access": {
- "allowGuests": false
- }
- }
-}
diff --git a/data/constants.ts b/data/constants.ts
deleted file mode 100644
index 6c43f365d..000000000
--- a/data/constants.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export const REPO_URL = 'ajnart/homarr';
-export const ICON_PICKER_SLICE_LIMIT = 36;
-export const COOKIE_LOCALE_KEY = 'config-locale';
-export const COOKIE_COLOR_SCHEME_KEY = 'color-scheme';
diff --git a/data/crowdin-report.json b/data/crowdin-report.json
deleted file mode 100644
index dc43a0c17..000000000
--- a/data/crowdin-report.json
+++ /dev/null
@@ -1,2828 +0,0 @@
-{
- "name": "homarr Top Members Report",
- "url": "https://translate.homarr.dev/project/homarr",
- "unit": "words",
- "dateRange": {
- "from": "2022-08-25",
- "to": "2024-01-01"
- },
- "language": "All",
- "data": [
- {
- "user": {
- "id": "15491798",
- "username": "lupineDK",
- "fullName": "Anders Ecklon (lupineDK)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15491798/medium/461bd501e8371c062bf29ea171aedd36_default.png",
- "joined": "2022-10-15 01:14:33"
- },
- "languages": [
- {
- "id": "da",
- "name": "Danish"
- }
- ],
- "translated": 5893,
- "target": 5686,
- "approved": 5911,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 5893
- },
- {
- "user": {
- "id": "15492732",
- "username": "hillaliy",
- "fullName": "Yossi Hillali (hillaliy)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15492732/medium/0bae17b421604892d888e3fc70cf0587.jpeg",
- "joined": "2022-10-15 15:18:50"
- },
- "languages": [
- {
- "id": "he",
- "name": "Hebrew"
- }
- ],
- "translated": 5815,
- "target": 5068,
- "approved": 5848,
- "voted": 0,
- "positiveVotes": 12,
- "negativeVotes": 0,
- "winning": 5806
- },
- {
- "user": {
- "id": "15554645",
- "username": "crendasien",
- "fullName": "Nicole (crendasien)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15554645/medium/598ab1d4aaf6b8dccd5ba16be92da7b9.jpeg",
- "joined": "2022-11-28 14:18:44"
- },
- "languages": [
- {
- "id": "it",
- "name": "Italian"
- }
- ],
- "translated": 5288,
- "target": 5378,
- "approved": 5613,
- "voted": 0,
- "positiveVotes": 11,
- "negativeVotes": 0,
- "winning": 5285
- },
- {
- "user": {
- "id": "15202182",
- "username": "Walkx",
- "fullName": "Walkx",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15202182/medium/5c37361ae45aeed487b34582c1f7ca37.png",
- "joined": "2022-08-25 07:28:51"
- },
- "languages": [
- {
- "id": "nl",
- "name": "Dutch"
- },
- {
- "id": "en",
- "name": "English"
- },
- {
- "id": "de",
- "name": "German"
- },
- {
- "id": "lol",
- "name": "LOLCAT"
- },
- {
- "id": "en-PT",
- "name": "Pirate English"
- }
- ],
- "translated": 5065,
- "target": 5027,
- "approved": 5618,
- "voted": 0,
- "positiveVotes": 2,
- "negativeVotes": 1,
- "winning": 5074
- },
- {
- "user": {
- "id": "12701640",
- "username": "SmartPhoneLover",
- "fullName": "Sergio (SmartPhoneLover)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/12701640/medium/ec95486662ec875cda080e778c3ff702.jpg",
- "joined": "2022-09-04 10:29:30"
- },
- "languages": [
- {
- "id": "en",
- "name": "English"
- },
- {
- "id": "es-ES",
- "name": "Spanish"
- }
- ],
- "translated": 4822,
- "target": 5078,
- "approved": 0,
- "voted": 166,
- "positiveVotes": 30,
- "negativeVotes": 0,
- "winning": 1017
- },
- {
- "user": {
- "id": "15445560",
- "username": "Bims0n",
- "fullName": "Bims0n",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15445560/medium/603220b603eeb3367e0f0d3fa675247c.jpg",
- "joined": "2022-09-13 05:55:32"
- },
- "languages": [
- {
- "id": "de",
- "name": "German"
- }
- ],
- "translated": 4652,
- "target": 4751,
- "approved": 4371,
- "voted": 0,
- "positiveVotes": 25,
- "negativeVotes": 0,
- "winning": 4092
- },
- {
- "user": {
- "id": "15428516",
- "username": "Steken",
- "fullName": "Steken",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15428516/medium/d5aea5653c769c3a523182bdb60d1664.png",
- "joined": "2022-08-31 10:52:11"
- },
- "languages": [
- {
- "id": "sv-SE",
- "name": "Swedish"
- }
- ],
- "translated": 4557,
- "target": 4273,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15722911",
- "username": "GkhnG",
- "fullName": "GkhnG",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15722911/medium/71a027caec489ef6ce82bcf1888329d0_default.png",
- "joined": "2023-04-28 22:50:37"
- },
- "languages": [
- {
- "id": "tr",
- "name": "Turkish"
- }
- ],
- "translated": 4384,
- "target": 3701,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15674593",
- "username": "Marty88",
- "fullName": "Marty (Marty88)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15674593/medium/492b1509d52bd2809dea768121217125.jpeg",
- "joined": "2023-02-08 16:28:53"
- },
- "languages": [
- {
- "id": "sk",
- "name": "Slovak"
- }
- ],
- "translated": 4347,
- "target": 3995,
- "approved": 3777,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 3771
- },
- {
- "user": {
- "id": "15709853",
- "username": "RJSkudra",
- "fullName": "RJS (RJSkudra)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15709853/medium/c3abf2774913dc4e81fb261d36d7668c.png",
- "joined": "2023-04-08 13:07:46"
- },
- "languages": [
- {
- "id": "lv",
- "name": "Latvian"
- }
- ],
- "translated": 4280,
- "target": 3758,
- "approved": 4195,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 4184
- },
- {
- "user": {
- "id": "16077170",
- "username": "Topbcy",
- "fullName": "Turbo (Topbcy)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16077170/medium/d3aed33ea56330338756cfcd89477cfe.jpeg",
- "joined": "2023-10-29 07:14:20"
- },
- "languages": [
- {
- "id": "zh-TW",
- "name": "Chinese Traditional"
- }
- ],
- "translated": 4171,
- "target": 6555,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15951759",
- "username": "Sandor-dev",
- "fullName": "Sandor-dev",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15951759/medium/0216c2da4eb028164ebbecf1c72f6271_default.png",
- "joined": "2023-08-05 03:35:17"
- },
- "languages": [
- {
- "id": "hu",
- "name": "Hungarian"
- }
- ],
- "translated": 4135,
- "target": 3788,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15617065",
- "username": "somerlev",
- "fullName": "somerlev",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15617065/medium/f4b13513e311ec902d90b2f718412c55.jpg",
- "joined": "2023-01-01 15:03:01"
- },
- "languages": [
- {
- "id": "ru",
- "name": "Russian"
- }
- ],
- "translated": 3866,
- "target": 3432,
- "approved": 4640,
- "voted": 160,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 3655
- },
- {
- "user": {
- "id": "15644717",
- "username": "suming",
- "fullName": "宿命 (suming)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15644717/medium/244159dfe10fa03436205506f80c9e25.png",
- "joined": "2023-01-19 12:37:25"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 3836,
- "target": 5983,
- "approved": 4206,
- "voted": 1,
- "positiveVotes": 1,
- "negativeVotes": 2,
- "winning": 3413
- },
- {
- "user": {
- "id": "15677023",
- "username": "Spillebulle",
- "fullName": "Spillebulle",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15677023/medium/096cf68fccf4b666954a0a57a974af64_default.png",
- "joined": "2023-02-08 02:51:18"
- },
- "languages": [
- {
- "id": "no",
- "name": "Norwegian"
- }
- ],
- "translated": 3234,
- "target": 3063,
- "approved": 4451,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 3225
- },
- {
- "user": {
- "id": "14799754",
- "username": "cretzen",
- "fullName": "Cretzen (cretzen)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14799754/medium/038eeb7de2d7869a17e402864bfeab24.png",
- "joined": "2022-10-18 23:39:24"
- },
- "languages": [
- {
- "id": "vi",
- "name": "Vietnamese"
- }
- ],
- "translated": 3001,
- "target": 4174,
- "approved": 23,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 4
- },
- {
- "user": {
- "id": "15875457",
- "username": "raelyan",
- "fullName": "Raelyan (raelyan)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15875457/medium/2f4fda1d1aaa5dcc79b328baf3f03151.jpeg",
- "joined": "2023-06-14 12:51:04"
- },
- "languages": [
- {
- "id": "gl",
- "name": "Galician"
- },
- {
- "id": "es-ES",
- "name": "Spanish"
- }
- ],
- "translated": 2924,
- "target": 3268,
- "approved": 3791,
- "voted": 5,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 2901
- },
- {
- "user": {
- "id": "15428592",
- "username": "flar.anton",
- "fullName": "Anton Chernyshev (flar.anton)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15428592/medium/b01fdb365d892e9f811f77fcb50a80a2.jpeg",
- "joined": "2022-08-31 11:31:25"
- },
- "languages": [
- {
- "id": "uk",
- "name": "Ukrainian"
- }
- ],
- "translated": 2883,
- "target": 2551,
- "approved": 2748,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 2681
- },
- {
- "user": {
- "id": "15419914",
- "username": "benniblot",
- "fullName": "Benjamin Engler (benniblot)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15419914/medium/7d0ba7a7c4b62dab3e5f570d858759d4.png",
- "joined": "2022-08-25 07:49:07"
- },
- "languages": [
- {
- "id": "en",
- "name": "English"
- },
- {
- "id": "de",
- "name": "German"
- }
- ],
- "translated": 2607,
- "target": 2595,
- "approved": 0,
- "voted": 27,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 2101
- },
- {
- "user": {
- "id": "15865139",
- "username": "Beardy",
- "fullName": "Beardy",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15865139/medium/fca6b9d2b3f52e286d1568f52b83b6a0_default.png",
- "joined": "2023-06-07 06:24:20"
- },
- "languages": [
- {
- "id": "el",
- "name": "Greek"
- }
- ],
- "translated": 2386,
- "target": 2567,
- "approved": 0,
- "voted": 3,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15118857",
- "username": "tomislav.kraljevic",
- "fullName": "Tomislav Kraljević (tomislav.kraljevic)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15118857/medium/e133f1061cc92850b854d05d8faaeafd.png",
- "joined": "2023-07-04 11:04:04"
- },
- "languages": [
- {
- "id": "hr",
- "name": "Croatian"
- }
- ],
- "translated": 2109,
- "target": 2031,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15647517",
- "username": "nick.gher",
- "fullName": "nick.gher",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15647517/medium/5374a2c6fef60a3fbf0edf86b997c351_default.png",
- "joined": "2023-01-22 09:16:52"
- },
- "languages": [
- {
- "id": "el",
- "name": "Greek"
- }
- ],
- "translated": 2064,
- "target": 2219,
- "approved": 2064,
- "voted": 0,
- "positiveVotes": 3,
- "negativeVotes": 0,
- "winning": 2055
- },
- {
- "user": {
- "id": "16045554",
- "username": "rpieja",
- "fullName": "rpieja",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16045554/medium/bd55baca2ef8b92502a760cc9ee7c505_default.png",
- "joined": "2023-10-09 07:56:18"
- },
- "languages": [
- {
- "id": "pl",
- "name": "Polish"
- }
- ],
- "translated": 1987,
- "target": 1808,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 5,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15149958",
- "username": "DimitriDR",
- "fullName": "Dimitri (DimitriDR)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15149958/medium/a7b4224bce318334510b708a5ccda604.png",
- "joined": "2023-01-06 18:49:19"
- },
- "languages": [
- {
- "id": "fr",
- "name": "French"
- }
- ],
- "translated": 1753,
- "target": 1978,
- "approved": 1103,
- "voted": 20,
- "positiveVotes": 16,
- "negativeVotes": 0,
- "winning": 774
- },
- {
- "user": {
- "id": "12572682",
- "username": "THJ",
- "fullName": "Andrej Kralj (THJ)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/12572682/medium/57fda59b7c2b9d100064e6c02953ebbe_default.png",
- "joined": "2022-08-25 07:50:35"
- },
- "languages": [
- {
- "id": "sl",
- "name": "Slovenian"
- }
- ],
- "translated": 1707,
- "target": 1602,
- "approved": 2614,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 1610
- },
- {
- "user": {
- "id": "15981895",
- "username": "azurite928",
- "fullName": "Azurite (azurite928)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15981895/medium/547ccc459ee123e78b5401c499f1022d.png",
- "joined": "2023-08-25 08:00:31"
- },
- "languages": [
- {
- "id": "ja",
- "name": "Japanese"
- }
- ],
- "translated": 1685,
- "target": 4598,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15270132",
- "username": "ajnart",
- "fullName": "Thomas Camlong (ajnart)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15270132/medium/a0f107a463c8910ee96bc2fa843a17e3.jpeg",
- "joined": "2022-08-25 06:01:00"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- },
- {
- "id": "hr",
- "name": "Croatian"
- },
- {
- "id": "da",
- "name": "Danish"
- },
- {
- "id": "nl",
- "name": "Dutch"
- },
- {
- "id": "en",
- "name": "English"
- },
- {
- "id": "fr",
- "name": "French"
- },
- {
- "id": "de",
- "name": "German"
- },
- {
- "id": "it",
- "name": "Italian"
- },
- {
- "id": "ja",
- "name": "Japanese"
- },
- {
- "id": "ko",
- "name": "Korean"
- },
- {
- "id": "lol",
- "name": "LOLCAT"
- },
- {
- "id": "lv",
- "name": "Latvian"
- },
- {
- "id": "no",
- "name": "Norwegian"
- },
- {
- "id": "pl",
- "name": "Polish"
- },
- {
- "id": "pt-BR",
- "name": "Portuguese, Brazilian"
- },
- {
- "id": "ru",
- "name": "Russian"
- },
- {
- "id": "sk",
- "name": "Slovak"
- },
- {
- "id": "sl",
- "name": "Slovenian"
- },
- {
- "id": "es-ES",
- "name": "Spanish"
- },
- {
- "id": "sv-SE",
- "name": "Swedish"
- },
- {
- "id": "tr",
- "name": "Turkish"
- },
- {
- "id": "uk",
- "name": "Ukrainian"
- },
- {
- "id": "vi",
- "name": "Vietnamese"
- }
- ],
- "translated": 1576,
- "target": 1691,
- "approved": 1463,
- "voted": 0,
- "positiveVotes": 189,
- "negativeVotes": 21,
- "winning": 1215
- },
- {
- "user": {
- "id": "16021342",
- "username": "Ronner231",
- "fullName": "Ronner (Ronner231)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16021342/medium/7734d550df2de5a2fec2ffff33e7024c.jpeg",
- "joined": "2023-09-24 16:06:42"
- },
- "languages": [
- {
- "id": "ru",
- "name": "Russian"
- }
- ],
- "translated": 901,
- "target": 807,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 351
- },
- {
- "user": {
- "id": "15420178",
- "username": "Manicraft1001",
- "fullName": "Manicraft1001",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15420178/medium/50ec94563a06a9f74f33bd09f01eed4d.jpg",
- "joined": "2022-08-25 11:13:34"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- },
- {
- "id": "en",
- "name": "English"
- },
- {
- "id": "fr",
- "name": "French"
- },
- {
- "id": "de",
- "name": "German"
- }
- ],
- "translated": 830,
- "target": 838,
- "approved": 3075,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 753
- },
- {
- "user": {
- "id": "15818233",
- "username": "MoeToo",
- "fullName": "MoeToo",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15818233/medium/d35cd6953717706eaf20f6c143c62947.png",
- "joined": "2023-07-27 03:50:11"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 676,
- "target": 1029,
- "approved": 0,
- "voted": 26,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 224
- },
- {
- "user": {
- "id": "15419916",
- "username": "pacjo",
- "fullName": "pacjo",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15419916/medium/3cbeddbd7bc01faafb5a3bf47bba915b_default.png",
- "joined": "2022-08-25 07:49:08"
- },
- "languages": [
- {
- "id": "pl",
- "name": "Polish"
- }
- ],
- "translated": 651,
- "target": 603,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 2,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15434162",
- "username": "bfkadan",
- "fullName": "이병주 (bfkadan)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15434162/medium/4f9a0b43cfe3acaea60124c14ba7f44a.png",
- "joined": "2022-09-05 01:53:23"
- },
- "languages": [
- {
- "id": "ko",
- "name": "Korean"
- }
- ],
- "translated": 627,
- "target": 527,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 7,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "13185230",
- "username": "BeersTeddy",
- "fullName": "BeersTeddy",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/13185230/medium/2f1f4e1effe74a23422b195cbefb2a95_default.png",
- "joined": "2023-03-09 09:40:50"
- },
- "languages": [
- {
- "id": "pl",
- "name": "Polish"
- }
- ],
- "translated": 624,
- "target": 570,
- "approved": 0,
- "voted": 12,
- "positiveVotes": 0,
- "negativeVotes": 1,
- "winning": 0
- },
- {
- "user": {
- "id": "15977271",
- "username": "tagaishi",
- "fullName": "tagaishi",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15977271/medium/eade504c83a5a1ff831c80a538fbdb44_default.png",
- "joined": "2023-08-22 07:09:16"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- },
- {
- "id": "fr",
- "name": "French"
- }
- ],
- "translated": 588,
- "target": 693,
- "approved": 0,
- "voted": 2,
- "positiveVotes": 2,
- "negativeVotes": 0,
- "winning": 95
- },
- {
- "user": {
- "id": "15925879",
- "username": "kennit",
- "fullName": "kennit",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15925879/medium/6b0733ad3c5949b91c55e4d8b03db8a5_default.png",
- "joined": "2023-07-19 04:46:11"
- },
- "languages": [
- {
- "id": "fr",
- "name": "French"
- }
- ],
- "translated": 577,
- "target": 711,
- "approved": 0,
- "voted": 1,
- "positiveVotes": 16,
- "negativeVotes": 0,
- "winning": 153
- },
- {
- "user": {
- "id": "15426890",
- "username": "JokeOfDead",
- "fullName": "Alejandro Grande (JokeOfDead)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15426890/medium/a383eac9365e9de64fd4ab1d6fd0cb95.jpeg",
- "joined": "2022-08-30 09:37:25"
- },
- "languages": [
- {
- "id": "es-ES",
- "name": "Spanish"
- }
- ],
- "translated": 552,
- "target": 649,
- "approved": 658,
- "voted": 19,
- "positiveVotes": 87,
- "negativeVotes": 0,
- "winning": 355
- },
- {
- "user": {
- "id": "15057621",
- "username": "jeffersonraimon",
- "fullName": "Jefferson J. Raimon (jeffersonraimon)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15057621/medium/4fbff4945fa3b7c2ab219a726b23778b.jpeg",
- "joined": "2023-02-21 13:25:50"
- },
- "languages": [
- {
- "id": "pt-BR",
- "name": "Portuguese, Brazilian"
- }
- ],
- "translated": 544,
- "target": 606,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "14722148",
- "username": "antoine2tt",
- "fullName": "antoine2tt",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14722148/medium/f88d926900862dd59007ea4b3419cb9d.png",
- "joined": "2023-01-17 10:18:16"
- },
- "languages": [
- {
- "id": "fr",
- "name": "French"
- }
- ],
- "translated": 507,
- "target": 594,
- "approved": 0,
- "voted": 2,
- "positiveVotes": 1,
- "negativeVotes": 0,
- "winning": 480
- },
- {
- "user": {
- "id": "15690777",
- "username": "y.gybson",
- "fullName": "Константин Золотарев (y.gybson)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15690777/medium/b5cb5d2d5768904ff6586c727e3a6c77.jpeg",
- "joined": "2023-02-15 07:43:18"
- },
- "languages": [
- {
- "id": "ru",
- "name": "Russian"
- }
- ],
- "translated": 435,
- "target": 382,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 250
- },
- {
- "user": {
- "id": "15713937",
- "username": "binge203",
- "fullName": "Binge Noah (binge203)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15713937/medium/77c1cfa4314673db80e6881fd9f64668.gif",
- "joined": "2023-02-27 15:57:17"
- },
- "languages": [
- {
- "id": "uk",
- "name": "Ukrainian"
- }
- ],
- "translated": 411,
- "target": 368,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 2,
- "negativeVotes": 0,
- "winning": 44
- },
- {
- "user": {
- "id": "15425808",
- "username": "fabricionaweb",
- "fullName": "Fabricio Silva (fabricionaweb)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15425808/medium/a9354142e7ae5152c144409d55fda551_default.png",
- "joined": "2022-08-29 14:45:47"
- },
- "languages": [
- {
- "id": "pt-BR",
- "name": "Portuguese, Brazilian"
- }
- ],
- "translated": 408,
- "target": 444,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15454038",
- "username": "sebekmartin",
- "fullName": "Martin Sebek (sebekmartin)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15454038/medium/bcfb44598cdfd1d7cd4eb35812538962.jpeg",
- "joined": "2023-10-08 09:26:03"
- },
- "languages": [
- {
- "id": "cs",
- "name": "Czech"
- }
- ],
- "translated": 393,
- "target": 355,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "13330448",
- "username": "vannCN",
- "fullName": "vannCN",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/13330448/medium/9b8a9ee3611e51e951e22d5fd4eb7d8d.jpg",
- "joined": "2023-01-16 01:38:13"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 365,
- "target": 566,
- "approved": 0,
- "voted": 5,
- "positiveVotes": 6,
- "negativeVotes": 0,
- "winning": 79
- },
- {
- "user": {
- "id": "15405614",
- "username": "irithys",
- "fullName": "irithys",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15405614/medium/3086461c47cce0a0c031925e5f943412.png",
- "joined": "2022-09-18 21:10:51"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 344,
- "target": 599,
- "approved": 0,
- "voted": 3,
- "positiveVotes": 15,
- "negativeVotes": 3,
- "winning": 119
- },
- {
- "user": {
- "id": "15685239",
- "username": "petitmewen",
- "fullName": "mobby45 (petitmewen)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15685239/medium/15de9b62d2e0bc25013435f1784bbcc1.jpeg",
- "joined": "2023-08-21 13:42:29"
- },
- "languages": [
- {
- "id": "fr",
- "name": "French"
- }
- ],
- "translated": 320,
- "target": 379,
- "approved": 0,
- "voted": 5,
- "positiveVotes": 0,
- "negativeVotes": 1,
- "winning": 106
- },
- {
- "user": {
- "id": "15427174",
- "username": "hkz",
- "fullName": "hkz",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15427174/medium/c88acefb0d7306e1f7470e872029fb39_default.png",
- "joined": "2022-08-30 13:15:07"
- },
- "languages": [
- {
- "id": "fr",
- "name": "French"
- }
- ],
- "translated": 318,
- "target": 355,
- "approved": 964,
- "voted": 2,
- "positiveVotes": 1,
- "negativeVotes": 0,
- "winning": 316
- },
- {
- "user": {
- "id": "15687709",
- "username": "NoProsNoNoobs",
- "fullName": "NoProsNoNoobs",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15687709/medium/ae8f65fafeb8bcf74dcb8871bbe46461.png",
- "joined": "2023-02-13 14:58:17"
- },
- "languages": [
- {
- "id": "nl",
- "name": "Dutch"
- }
- ],
- "translated": 267,
- "target": 259,
- "approved": 0,
- "voted": 8,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 63
- },
- {
- "user": {
- "id": "7795",
- "username": "zielmann",
- "fullName": "Luke (zielmann)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/7795/medium/ad22b8b8d5eb33e4154d53a454c862fd_default.png",
- "joined": "2023-10-12 09:50:59"
- },
- "languages": [
- {
- "id": "pl",
- "name": "Polish"
- }
- ],
- "translated": 266,
- "target": 258,
- "approved": 0,
- "voted": 7,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "6697",
- "username": "carlchina",
- "fullName": "carl wong (carlchina)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/6697/medium/d22bbe7797bbeb30dbdc73a5648d329a_default.png",
- "joined": "2023-06-30 11:23:45"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 264,
- "target": 429,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 4,
- "negativeVotes": 0,
- "winning": 126
- },
- {
- "user": {
- "id": "16084674",
- "username": "ai5d02sb",
- "fullName": "ai5d02sb",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16084674/medium/7c8119fe2a5ca71bb15f636916a42b95_default.png",
- "joined": "2023-11-02 15:47:09"
- },
- "languages": [
- {
- "id": "fr",
- "name": "French"
- }
- ],
- "translated": 264,
- "target": 275,
- "approved": 0,
- "voted": 12,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "14949159",
- "username": "f1refa11",
- "fullName": "FireFall (f1refa11)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14949159/medium/fd2ae63b8eb4462200ba96abf943c1b9.png",
- "joined": "2023-09-06 14:55:13"
- },
- "languages": [
- {
- "id": "ru",
- "name": "Russian"
- }
- ],
- "translated": 228,
- "target": 203,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 134
- },
- {
- "user": {
- "id": "13641407",
- "username": "wolong98",
- "fullName": "QI wolong (wolong98)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/13641407/medium/f4634edc58c7857a357e5293543c15cf.jpg",
- "joined": "2023-02-17 22:03:21"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 185,
- "target": 289,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 43
- },
- {
- "user": {
- "id": "15420118",
- "username": "WowMurdock",
- "fullName": "Liok haah (WowMurdock)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15420118/medium/de19576fccb495b6dfe4c4c04a56b834.png",
- "joined": "2022-08-25 10:34:26"
- },
- "languages": [
- {
- "id": "ru",
- "name": "Russian"
- }
- ],
- "translated": 183,
- "target": 161,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 54,
- "negativeVotes": 3,
- "winning": 17
- },
- {
- "user": {
- "id": "15304568",
- "username": "Bulgus",
- "fullName": "Pour Les Tests (Bulgus)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15304568/medium/0e8787e5ceb02ed5c96a514d0068ae87.jpg",
- "joined": "2023-02-05 11:48:40"
- },
- "languages": [
- {
- "id": "fr",
- "name": "French"
- }
- ],
- "translated": 168,
- "target": 209,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 18,
- "negativeVotes": 3,
- "winning": 75
- },
- {
- "user": {
- "id": "14934947",
- "username": "djismgaming",
- "fullName": "Ismael (djismgaming)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14934947/medium/f5a8570713c34ab0f7d5405d105e2a9a.jpeg",
- "joined": "2023-11-12 08:36:15"
- },
- "languages": [
- {
- "id": "es-ES",
- "name": "Spanish"
- }
- ],
- "translated": 164,
- "target": 181,
- "approved": 0,
- "voted": 6,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "12580457",
- "username": "almontegil",
- "fullName": "Gil Almonte (almontegil)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/12580457/medium/f4136cacbdfdb4c28ae7f85dc5f840db_default.png",
- "joined": "2022-08-28 20:04:49"
- },
- "languages": [
- {
- "id": "es-ES",
- "name": "Spanish"
- }
- ],
- "translated": 162,
- "target": 179,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 99,
- "negativeVotes": 1,
- "winning": 142
- },
- {
- "user": {
- "id": "15057987",
- "username": "giop98",
- "fullName": "Giovanni Pollo (giop98)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15057987/medium/b8a4825d3fc39fc662f35ee258db4b2d.jpeg",
- "joined": "2022-09-07 15:54:27"
- },
- "languages": [
- {
- "id": "it",
- "name": "Italian"
- }
- ],
- "translated": 134,
- "target": 141,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 3,
- "negativeVotes": 0,
- "winning": 86
- },
- {
- "user": {
- "id": "15419912",
- "username": "JannesV",
- "fullName": "Jannes Vandepitte (JannesV)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15419912/medium/a7809eb4b817d7c49b62cf10ae86b950.png",
- "joined": "2022-08-25 07:47:26"
- },
- "languages": [
- {
- "id": "nl",
- "name": "Dutch"
- }
- ],
- "translated": 133,
- "target": 130,
- "approved": 0,
- "voted": 2,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 114
- },
- {
- "user": {
- "id": "15547289",
- "username": "_vytdv",
- "fullName": "_vytdv",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15547289/medium/60d8644cc8ad6f11a92ccea4a14cd098_default.png",
- "joined": "2022-11-23 06:10:51"
- },
- "languages": [
- {
- "id": "fr",
- "name": "French"
- }
- ],
- "translated": 96,
- "target": 123,
- "approved": 0,
- "voted": 1,
- "positiveVotes": 4,
- "negativeVotes": 2,
- "winning": 85
- },
- {
- "user": {
- "id": "15573823",
- "username": "edxo",
- "fullName": "phui-chen (edxo)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15573823/medium/8a565b732a75a77f840dd123cdb30bf4.png",
- "joined": "2023-04-17 10:47:03"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 96,
- "target": 172,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 7,
- "negativeVotes": 0,
- "winning": 28
- },
- {
- "user": {
- "id": "15792897",
- "username": "HooinKyoma",
- "fullName": "Hooin Kyoma (HooinKyoma)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15792897/medium/9489f0a9b368e0e827ae758b740a2eed.jpeg",
- "joined": "2023-04-19 06:15:34"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 93,
- "target": 135,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 14
- },
- {
- "user": {
- "id": "15422606",
- "username": "R4cc",
- "fullName": "R4cc",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15422606/medium/a390979662b84694f59de30bdb732141.jpeg",
- "joined": "2022-08-27 08:48:59"
- },
- "languages": [
- {
- "id": "de",
- "name": "German"
- }
- ],
- "translated": 92,
- "target": 87,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 69
- },
- {
- "user": {
- "id": "15674577",
- "username": "tee_noodle",
- "fullName": "tee_noodle",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15674577/medium/804d9cf06f5196026acb6436b809d0da_default.png",
- "joined": "2023-02-06 15:57:25"
- },
- "languages": [
- {
- "id": "fr",
- "name": "French"
- }
- ],
- "translated": 91,
- "target": 111,
- "approved": 0,
- "voted": 33,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 9
- },
- {
- "user": {
- "id": "13343482",
- "username": "binswm",
- "fullName": "binswm",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/13343482/medium/45dde0e6097b9b72705d2eba9dbbc276_default.png",
- "joined": "2023-03-06 03:58:56"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 85,
- "target": 136,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15836233",
- "username": "itodouble",
- "fullName": "还有一天就放假了 (itodouble)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15836233/medium/e984caea18fb0673bf319fcf28cef649.png",
- "joined": "2023-05-18 04:20:01"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 79,
- "target": 127,
- "approved": 0,
- "voted": 19,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 31
- },
- {
- "user": {
- "id": "14444264",
- "username": "droidenko",
- "fullName": "Сергій Богданов (droidenko)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14444264/medium/4aa3a8e824f72bc9e5ec0d8de307095e.jpeg",
- "joined": "2023-04-13 16:17:25"
- },
- "languages": [
- {
- "id": "uk",
- "name": "Ukrainian"
- }
- ],
- "translated": 67,
- "target": 64,
- "approved": 0,
- "voted": 2,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15436168",
- "username": "HRKings",
- "fullName": "Helton Reis (HRKings)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15436168/medium/69a31e461d38549f01864e7ef10d642e.png",
- "joined": "2022-09-06 08:46:32"
- },
- "languages": [
- {
- "id": "pt-BR",
- "name": "Portuguese, Brazilian"
- }
- ],
- "translated": 59,
- "target": 64,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15620663",
- "username": "realitymolder",
- "fullName": "Daniel Toubul (realitymolder)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15620663/medium/567349e86d1b57a006a347142f7e11ee.jpeg",
- "joined": "2023-01-18 07:20:56"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- },
- {
- "id": "he",
- "name": "Hebrew"
- }
- ],
- "translated": 51,
- "target": 51,
- "approved": 0,
- "voted": 12,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 33
- },
- {
- "user": {
- "id": "15440860",
- "username": "qaz0911",
- "fullName": "qaz0911",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15440860/medium/3d3e50ee388c72dc4bf7a771761f2d89_default.png",
- "joined": "2022-09-09 16:01:15"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 44,
- "target": 83,
- "approved": 0,
- "voted": 26,
- "positiveVotes": 5,
- "negativeVotes": 0,
- "winning": 7
- },
- {
- "user": {
- "id": "15518710",
- "username": "HeroSizy",
- "fullName": "SiZY (HeroSizy)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15518710/medium/4e79c0e98cbeb536dd961e656331b509.png",
- "joined": "2022-11-03 03:34:17"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 39,
- "target": 63,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 13
- },
- {
- "user": {
- "id": "15470768",
- "username": "DooYoo",
- "fullName": "DooYoo",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15470768/medium/2a18cf4be67094724b508c9e1e698a21_default.png",
- "joined": "2022-09-30 06:15:13"
- },
- "languages": [
- {
- "id": "fr",
- "name": "French"
- }
- ],
- "translated": 31,
- "target": 31,
- "approved": 0,
- "voted": 12,
- "positiveVotes": 1,
- "negativeVotes": 0,
- "winning": 5
- },
- {
- "user": {
- "id": "14670666",
- "username": "gm.cinalli",
- "fullName": "Gian Marco Cinalli (gm.cinalli)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14670666/medium/2d466a0fdbda40764526be86c97c0ab4.jpeg",
- "joined": "2022-11-08 12:01:21"
- },
- "languages": [
- {
- "id": "it",
- "name": "Italian"
- }
- ],
- "translated": 31,
- "target": 40,
- "approved": 0,
- "voted": 11,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 23
- },
- {
- "user": {
- "id": "13547726",
- "username": "raphcatarino",
- "fullName": "Zareix (raphcatarino)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/13547726/medium/b003511e67df13a4b4b5689488fa8099.jpg",
- "joined": "2022-09-09 03:30:43"
- },
- "languages": [
- {
- "id": "fr",
- "name": "French"
- }
- ],
- "translated": 26,
- "target": 39,
- "approved": 0,
- "voted": 2,
- "positiveVotes": 7,
- "negativeVotes": 0,
- "winning": 26
- },
- {
- "user": {
- "id": "15459882",
- "username": "RagnarGraves",
- "fullName": "NONE NAME (RagnarGraves)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15459882/medium/143d5af850c1154070a218bea124e9cb_default.png",
- "joined": "2023-03-15 09:57:36"
- },
- "languages": [
- {
- "id": "es-ES",
- "name": "Spanish"
- }
- ],
- "translated": 24,
- "target": 23,
- "approved": 0,
- "voted": 3,
- "positiveVotes": 1,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15419934",
- "username": "Payou6994",
- "fullName": "Payou (Payou6994)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15419934/medium/37c9b9b17dfb578404c1c1ddb73ba7a8.png",
- "joined": "2022-08-25 10:30:56"
- },
- "languages": [
- {
- "id": "fr",
- "name": "French"
- }
- ],
- "translated": 22,
- "target": 24,
- "approved": 0,
- "voted": 7,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 17
- },
- {
- "user": {
- "id": "15588979",
- "username": "Chengnan",
- "fullName": "Chengnan",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15588979/medium/a3c6f4db39ae6c966190e1a2b3aea3d7.png",
- "joined": "2023-06-27 01:48:52"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 21,
- "target": 49,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 12
- },
- {
- "user": {
- "id": "15439078",
- "username": "wiston81",
- "fullName": "Riky Bahia (wiston81)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15439078/medium/10f292c8d12a7c21a44b54495fa4a3d8.jpeg",
- "joined": "2022-09-08 10:28:34"
- },
- "languages": [
- {
- "id": "it",
- "name": "Italian"
- }
- ],
- "translated": 16,
- "target": 14,
- "approved": 0,
- "voted": 4,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 3
- },
- {
- "user": {
- "id": "15486922",
- "username": "frisco82",
- "fullName": "Ramiro Aparicio (frisco82)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15486922/medium/4e1c5d4189b42508e660daa3c1c25b2a.jpeg",
- "joined": "2022-10-11 13:43:27"
- },
- "languages": [
- {
- "id": "en",
- "name": "English"
- },
- {
- "id": "es-ES",
- "name": "Spanish"
- }
- ],
- "translated": 14,
- "target": 14,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 7
- },
- {
- "user": {
- "id": "15501072",
- "username": "MarcOrfilaCarreras",
- "fullName": "Marc Orfila Carreras (MarcOrfilaCarreras)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15501072/medium/ea52b26c3c6f21e4931e38e3ce3f3d6e.png",
- "joined": "2022-10-21 03:59:58"
- },
- "languages": [
- {
- "id": "es-ES",
- "name": "Spanish"
- }
- ],
- "translated": 14,
- "target": 19,
- "approved": 0,
- "voted": 1,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 6
- },
- {
- "user": {
- "id": "15520022",
- "username": "dwt136",
- "fullName": "dwt136",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15520022/medium/554422503f2baea43ace85facb4546fb_default.png",
- "joined": "2022-11-04 01:35:50"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 14,
- "target": 18,
- "approved": 0,
- "voted": 5,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 6
- },
- {
- "user": {
- "id": "14012333",
- "username": "spair0039",
- "fullName": "spair0039",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14012333/medium/72430e96027c09c19141cac38eae4617.png",
- "joined": "2022-10-14 03:19:17"
- },
- "languages": [
- {
- "id": "ko",
- "name": "Korean"
- }
- ],
- "translated": 13,
- "target": 14,
- "approved": 0,
- "voted": 7,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15953187",
- "username": "Meierschlumpf",
- "fullName": "Meier Lukas (Meierschlumpf)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15953187/medium/23c744faef1ab84fbdc9351a7850aab6.jpeg",
- "joined": "2023-08-06 04:07:46"
- },
- "languages": [
- {
- "id": "de",
- "name": "German"
- }
- ],
- "translated": 9,
- "target": 10,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 9
- },
- {
- "user": {
- "id": "12664938",
- "username": "andibing",
- "fullName": "Andi Chandler (andibing)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/12664938/medium/b8be63e4dcb2e791ced1ffc9e3a049a5.jpg",
- "joined": "2023-08-18 18:10:00"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 7,
- "target": 7,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15507822",
- "username": "robertbridda",
- "fullName": "Robert Bridda (robertbridda)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15507822/medium/a368c2e30411bb2da9b49290084191f3.png",
- "joined": "2022-10-26 04:38:00"
- },
- "languages": [
- {
- "id": "it",
- "name": "Italian"
- }
- ],
- "translated": 6,
- "target": 8,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15434662",
- "username": "BunnySweety",
- "fullName": "BunnySweety",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15434662/medium/f0ef200a6a0dcf0e1d0e9ecd4148f560_default.png",
- "joined": "2022-09-05 07:51:46"
- },
- "languages": [
- {
- "id": "fr",
- "name": "French"
- }
- ],
- "translated": 5,
- "target": 6,
- "approved": 0,
- "voted": 1,
- "positiveVotes": 1,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15462414",
- "username": "PrtmPhlp",
- "fullName": "PrtmPhlp",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15462414/medium/b80db55e9de301432dcd1f8c8b24fd49_default.png",
- "joined": "2022-09-24 09:01:16"
- },
- "languages": [
- {
- "id": "de",
- "name": "German"
- }
- ],
- "translated": 4,
- "target": 4,
- "approved": 0,
- "voted": 1,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15643771",
- "username": "kid1412621",
- "fullName": "kid1412621",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15643771/medium/dd455e32de652fa88e6fd97598bdffa7.png",
- "joined": "2023-08-08 11:09:51"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 3,
- "target": 7,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 3
- },
- {
- "user": {
- "id": "15545537",
- "username": "eiloogs",
- "fullName": "沐川 (eiloogs)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15545537/medium/f290a2f1190983530a9b76b2e858a609.gif",
- "joined": "2022-11-22 01:52:53"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 2,
- "target": 6,
- "approved": 0,
- "voted": 1,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15650315",
- "username": "DataCat",
- "fullName": "DataCat",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15650315/medium/ce7c1365adf35c5d490d77500a4607fb_default.png",
- "joined": "2023-01-23 06:55:50"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 2,
- "target": 5,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15658375",
- "username": "dizo89",
- "fullName": "jbr1989 (dizo89)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15658375/medium/0ca745e5017d491fe1b22b0239904de8.jpeg",
- "joined": "2023-01-28 06:54:20"
- },
- "languages": [
- {
- "id": "es-ES",
- "name": "Spanish"
- }
- ],
- "translated": 2,
- "target": 3,
- "approved": 0,
- "voted": 3,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15847901",
- "username": "loslocitos",
- "fullName": "Daren Austin (loslocitos)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15847901/medium/fe30d12fd2cf38212f929e13b169f9ec.jpeg",
- "joined": "2023-05-26 02:06:40"
- },
- "languages": [
- {
- "id": "es-ES",
- "name": "Spanish"
- }
- ],
- "translated": 2,
- "target": 2,
- "approved": 0,
- "voted": 28,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 2
- },
- {
- "user": {
- "id": "15950309",
- "username": "kuunpire",
- "fullName": "kuunpi re (kuunpire)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15950309/medium/8192a4f08f07086828ac9f74ed29a169.jpeg",
- "joined": "2023-08-04 13:43:57"
- },
- "languages": [
- {
- "id": "uk",
- "name": "Ukrainian"
- }
- ],
- "translated": 2,
- "target": 2,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15677311",
- "username": "REMOVED_USER",
- "fullName": "REMOVED_USER",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15677311/medium/8ffed0dd4eb21b71ee0be60fa7c80720_default.png",
- "joined": "2023-02-20 07:28:13"
- },
- "languages": [
- {
- "id": "no",
- "name": "Norwegian"
- }
- ],
- "translated": 1,
- "target": 1,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15419976",
- "username": "fzibi21",
- "fullName": "Fred Zibulski (fzibi21)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15419976/medium/0da688450358e0290a7b7359cc1f7328.png",
- "joined": "2022-08-25 08:37:20"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15420120",
- "username": "hbooo",
- "fullName": "hbo (hbooo)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15420120/medium/6c7c7f8db785061356ebb03d044d3329.jpeg",
- "joined": "2022-08-25 10:34:01"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15420354",
- "username": "Void123",
- "fullName": "Void123",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15420354/medium/86929d44df92a00f9fe900a985c196df_default.png",
- "joined": "2022-08-25 13:50:08"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "13496556",
- "username": "SkewRam",
- "fullName": "Noan (SkewRam)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/13496556/medium/188f5c2deb7938eda51eb786cc4539ca.jpeg",
- "joined": "2022-08-26 19:12:25"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15246318",
- "username": "andrea.rosso",
- "fullName": "Andrea Rosso (andrea.rosso)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15246318/medium/11f3f5ef44ec7f55b6f143090e208704_default.png",
- "joined": "2022-08-31 08:29:06"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15429052",
- "username": "BerkeleyBlue",
- "fullName": "BerkeleyBlue",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15429052/medium/734cacdf45b7cedf4d56072cb0bce210_default.png",
- "joined": "2022-08-31 19:48:48"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15433542",
- "username": "Bon",
- "fullName": "Bon",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15433542/medium/5397da4dfc821f20b6ac14fe0c514e9a.jpeg",
- "joined": "2022-09-04 11:30:12"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "13245578",
- "username": "jamesmcmahon0",
- "fullName": "James McMahon (jamesmcmahon0)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/13245578/medium/586aa873b4abddbd9abc6f3de99ab70e.jpeg",
- "joined": "2022-09-06 17:40:30"
- },
- "languages": [
- {
- "id": "fr",
- "name": "French"
- }
- ],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 8,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15441462",
- "username": "qqyule",
- "fullName": "qqyule",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15441462/medium/7a3cdf82710ffb5d8f388bc0bd010665.png",
- "joined": "2022-09-10 04:34:50"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 6,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15446228",
- "username": "TariqDaCoder",
- "fullName": "TariqDaCoder",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15446228/medium/50b0f4040112bbd67690b769477398e5_default.png",
- "joined": "2022-09-13 13:28:16"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15449644",
- "username": "Anarchon",
- "fullName": "Chri S. (Anarchon) (Anarchon)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15449644/medium/e925e1f3e3ffbf0f982391ce263a1a28.jpeg",
- "joined": "2022-09-15 11:23:00"
- },
- "languages": [
- {
- "id": "de",
- "name": "German"
- }
- ],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 1,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15453020",
- "username": "Ashun",
- "fullName": "Ashun",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15453020/medium/ccdcf51c73d6aae40751bb30beee1915_default.png",
- "joined": "2022-09-17 21:03:53"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "14118689",
- "username": "Soochaehwa",
- "fullName": "Soochaehwa",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14118689/medium/496a1ce63111547bf455a1e0a7ac75f1_default.png",
- "joined": "2022-09-22 09:30:24"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15486092",
- "username": "espentruls",
- "fullName": "Espen Skarsten (espentruls)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15486092/medium/8e38afc3a4ff669226a0cfd3e420ff3a.jpeg",
- "joined": "2022-10-11 02:43:38"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15518090",
- "username": "MKoniuszko",
- "fullName": "Przemek (MKoniuszko)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15518090/medium/87605434fcc839f6763ab07c50f6d232.jpeg",
- "joined": "2022-11-02 14:48:04"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15526719",
- "username": "asifthewebguy",
- "fullName": "asifthewebguy",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15526719/medium/b18931dd0c800d725048bd440646198b_default.png",
- "joined": "2022-11-08 17:35:53"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15662563",
- "username": "bowlr-support",
- "fullName": "bowlr-support",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15662563/medium/6d242a9fc7dcf98fd4f528fbad02e767_default.png",
- "joined": "2023-02-01 20:05:36"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15704947",
- "username": "inside90",
- "fullName": "inside90",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15704947/medium/c1355fcb30dd76f8e39d98d1d49f1c52.png",
- "joined": "2023-02-23 05:18:04"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15719805",
- "username": "tim-wiegers",
- "fullName": "tim-wiegers",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15719805/medium/08a96f064813350661cd6b20bf3d7d99.png",
- "joined": "2023-03-02 15:53:50"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15359236",
- "username": "jonathan.berglin.work",
- "fullName": "Jonathan Berglin (jonathan.berglin.work)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15359236/medium/95930b2093db13b76179782f7322c5d5.png",
- "joined": "2023-03-22 14:28:48"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15714337",
- "username": "Mailootje",
- "fullName": "Mailootje",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15714337/medium/743c3bc4ab1989966a375eeeec83d8b8.jpeg",
- "joined": "2023-04-24 03:34:25"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15292058",
- "username": "guineuu",
- "fullName": "guineu (guineuu)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15292058/medium/22fbb758bda3b7805d50bf21d38f2c20.jpeg",
- "joined": "2023-04-27 13:51:15"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15849065",
- "username": "Oversleep",
- "fullName": "Oversleep",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15849065/medium/06141f13a6d541d753f3c2f2947b8068_default.png",
- "joined": "2023-05-26 16:51:56"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15315986",
- "username": "BySempron",
- "fullName": "Sergio (BySempron)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15315986/medium/e3d22d7b1423c6823a9f36d595ed4bdb.png",
- "joined": "2023-05-27 11:09:06"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15250690",
- "username": "M1n-4d316e",
- "fullName": "David (M1n-4d316e)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15250690/medium/f719940f4843d092ae8370cb014e4a04.png",
- "joined": "2023-06-29 09:53:46"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15913763",
- "username": "dolphin738",
- "fullName": "行素 (dolphin738)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15913763/medium/ee6fede7b8528ca642329ada80d1cc18.png",
- "joined": "2023-07-11 08:18:20"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 1,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15916719",
- "username": "brunotco",
- "fullName": "brunotco",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15916719/medium/09db45880fc05abc18adb8d932a5ecf9_default.png",
- "joined": "2023-07-13 02:34:44"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "14817246",
- "username": "ktKongTong",
- "fullName": "ktKongTong",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14817246/medium/97cfc3c028dbdaf85ebd1102da71e58c.jpeg",
- "joined": "2023-07-13 21:49:21"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "14861042",
- "username": "marinkaberg",
- "fullName": "marinkaberg",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14861042/medium/2d5c4e62613f03082f3e645fa92efd59.jpeg",
- "joined": "2023-07-28 00:44:23"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15970733",
- "username": "harmlesscat",
- "fullName": "harmlesscat",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15970733/medium/dd8d0214a0250c932bb518b1b55e45a2_default.png",
- "joined": "2023-08-17 11:14:25"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 2,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15976121",
- "username": "OrzWTF",
- "fullName": "__Gio__ (OrzWTF)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15976121/medium/4c4557cbff7ff7b0503455bc59c020e0.jpeg",
- "joined": "2023-08-21 12:05:12"
- },
- "languages": [
- {
- "id": "zh-CN",
- "name": "Chinese Simplified"
- }
- ],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 1,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "16034148",
- "username": "ugyes",
- "fullName": "ugyes",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16034148/medium/ed001e3f470a2dea9a8ce955b18e7bd5.png",
- "joined": "2023-10-01 13:41:09"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "16051620",
- "username": "flambyisyou",
- "fullName": "Flamby Isyou (flambyisyou)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16051620/medium/3a3bc0c90f6b95ab4ef74396a0a17beb.png",
- "joined": "2023-10-13 05:07:02"
- },
- "languages": [
- {
- "id": "fr",
- "name": "French"
- }
- ],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 18,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "15760967",
- "username": "Zoen-Millo",
- "fullName": "Zoen Millo (Zoen-Millo)",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15760967/medium/9e956f11adc5b34f5636268b5c485dbf.jpg",
- "joined": "2023-10-16 23:29:05"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- },
- {
- "user": {
- "id": "16097722",
- "username": "explosiveparrot",
- "fullName": "explosiveparrot",
- "avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16097722/medium/7762f80fc1da63f5b2eb87de9d640324_default.png",
- "joined": "2023-11-10 21:23:11"
- },
- "languages": [],
- "translated": 0,
- "target": 0,
- "approved": 0,
- "voted": 0,
- "positiveVotes": 0,
- "negativeVotes": 0,
- "winning": 0
- }
- ]
-}
\ No newline at end of file
diff --git a/data/default.json b/data/default.json
deleted file mode 100644
index 498653196..000000000
--- a/data/default.json
+++ /dev/null
@@ -1,513 +0,0 @@
-{
- "schemaVersion": 2,
- "configProperties": {
- "name": "default"
- },
- "categories": [],
- "wrappers": [
- {
- "id": "default",
- "position": 0
- }
- ],
- "apps": [
- {
- "id": "5df743d9-5cb1-457c-85d2-64ff86855652",
- "name": "Documentation",
- "url": "https://homarr.dev",
- "behaviour": {
- "onClickUrl": "https://homarr.dev",
- "externalUrl": "https://homarr.dev",
- "isOpeningNewTab": true
- },
- "network": {
- "enabledStatusChecker": false,
- "statusCodes": [
- "200"
- ]
- },
- "appearance": {
- "iconUrl": "/imgs/logo/logo.png",
- "appNameStatus": "normal",
- "positionAppName": "column",
- "lineClampAppName": 1
- },
- "integration": {
- "type": null,
- "properties": []
- },
- "area": {
- "type": "wrapper",
- "properties": {
- "id": "default"
- }
- },
- "shape": {
- "md": {
- "location": {
- "x": 5,
- "y": 1
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "sm": {
- "location": {
- "x": 0,
- "y": 1
- },
- "size": {
- "width": 1,
- "height": 2
- }
- },
- "lg": {
- "location": {
- "x": 6,
- "y": 1
- },
- "size": {
- "width": 2,
- "height": 2
- }
- }
- }
- },
- {
- "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a337",
- "name": "Discord",
- "url": "https://discord.com/invite/aCsmEV5RgA",
- "behaviour": {
- "onClickUrl": "https://discord.com/invite/aCsmEV5RgA",
- "isOpeningNewTab": true,
- "externalUrl": "https://discord.com/invite/aCsmEV5RgA",
- "tooltipDescription": "Join our Discord server! We're waiting for your ideas and feedback. "
- },
- "network": {
- "enabledStatusChecker": false,
- "statusCodes": [
- "200"
- ]
- },
- "appearance": {
- "iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/discord.png",
- "appNameStatus": "normal",
- "positionAppName": "row-reverse",
- "lineClampAppName": 1
- },
- "integration": {
- "type": null,
- "properties": []
- },
- "area": {
- "type": "wrapper",
- "properties": {
- "id": "default"
- }
- },
- "shape": {
- "md": {
- "location": {
- "x": 3,
- "y": 1
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "sm": {
- "location": {
- "x": 1,
- "y": 4
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "lg": {
- "location": {
- "x": 4,
- "y": 0
- },
- "size": {
- "width": 2,
- "height": 1
- }
- }
- }
- },
- {
- "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a330",
- "name": "Contribute",
- "url": "https://github.com/ajnart/homarr",
- "behaviour": {
- "onClickUrl": "https://github.com/ajnart/homarr",
- "externalUrl": "https://github.com/ajnart/homarr",
- "isOpeningNewTab": true,
- "tooltipDescription": ""
- },
- "network": {
- "enabledStatusChecker": false,
- "statusCodes": []
- },
- "appearance": {
- "iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/github.png",
- "appNameStatus": "normal",
- "positionAppName": "row-reverse",
- "lineClampAppName": 2
- },
- "integration": {
- "type": null,
- "properties": []
- },
- "area": {
- "type": "wrapper",
- "properties": {
- "id": "default"
- }
- },
- "shape": {
- "md": {
- "location": {
- "x": 3,
- "y": 2
- },
- "size": {
- "width": 2,
- "height": 1
- }
- },
- "sm": {
- "location": {
- "x": 1,
- "y": 3
- },
- "size": {
- "width": 2,
- "height": 1
- }
- },
- "lg": {
- "location": {
- "x": 2,
- "y": 0
- },
- "size": {
- "width": 2,
- "height": 1
- }
- }
- }
- },
- {
- "id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a990",
- "name": "Donate",
- "url": "https://ko-fi.com/ajnart",
- "behaviour": {
- "onClickUrl": "https://ko-fi.com/ajnart",
- "externalUrl": "https://ko-fi.com/ajnart",
- "isOpeningNewTab": true,
- "tooltipDescription": "Please consider making a donation"
- },
- "network": {
- "enabledStatusChecker": false,
- "statusCodes": [
- "200"
- ]
- },
- "appearance": {
- "iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/ko-fi.png",
- "appNameStatus": "normal",
- "positionAppName": "row-reverse",
- "lineClampAppName": 1
- },
- "integration": {
- "type": null,
- "properties": []
- },
- "area": {
- "type": "wrapper",
- "properties": {
- "id": "default"
- }
- },
- "shape": {
- "md": {
- "location": {
- "x": 4,
- "y": 1
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "sm": {
- "location": {
- "x": 2,
- "y": 4
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "lg": {
- "location": {
- "x": 6,
- "y": 0
- },
- "size": {
- "width": 2,
- "height": 1
- }
- }
- }
- }
- ],
- "widgets": [
- {
- "id": "e3004052-6b83-480e-b458-56e8ccdca5f0",
- "type": "weather",
- "properties": {
- "displayInFahrenheit": false,
- "location": {
- "name": "Paris",
- "latitude": 48.85341,
- "longitude": 2.3488
- },
- "displayCityName": true
- },
- "area": {
- "type": "wrapper",
- "properties": {
- "id": "default"
- }
- },
- "shape": {
- "md": {
- "location": {
- "x": 5,
- "y": 0
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "sm": {
- "location": {
- "x": 2,
- "y": 0
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "lg": {
- "location": {
- "x": 0,
- "y": 0
- },
- "size": {
- "width": 2,
- "height": 1
- }
- }
- }
- },
- {
- "id": "971aa859-8570-49a1-8d34-dd5c7b3638d1",
- "type": "date",
- "properties": {
- "display24HourFormat": true,
- "dateFormat": "hide",
- "enableTimezone": false,
- "timezoneLocation": {
- "name": "Paris",
- "latitude": 48.85341,
- "longitude": 2.3488
- },
- "titleState": "city"
- },
- "area": {
- "type": "wrapper",
- "properties": {
- "id": "default"
- }
- },
- "shape": {
- "sm": {
- "location": {
- "x": 1,
- "y": 0
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "md": {
- "location": {
- "x": 4,
- "y": 0
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "lg": {
- "location": {
- "x": 8,
- "y": 0
- },
- "size": {
- "width": 2,
- "height": 1
- }
- }
- }
- },
- {
- "id": "f252768d-9e69-491b-b6b4-8cad04fa30e8",
- "type": "date",
- "properties": {
- "display24HourFormat": true,
- "dateFormat": "hide",
- "enableTimezone": true,
- "timezoneLocation": {
- "name": "Tokyo",
- "latitude": 35.6895,
- "longitude": 139.69171
- },
- "titleState": "city"
- },
- "area": {
- "type": "wrapper",
- "properties": {
- "id": "default"
- }
- },
- "shape": {
- "sm": {
- "location": {
- "x": 0,
- "y": 0
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "md": {
- "location": {
- "x": 3,
- "y": 0
- },
- "size": {
- "width": 1,
- "height": 1
- }
- },
- "lg": {
- "location": {
- "x": 8,
- "y": 1
- },
- "size": {
- "width": 2,
- "height": 1
- }
- }
- }
- },
- {
- "id": "86b1921f-efa7-410f-92dd-79553bf3264d",
- "type": "notebook",
- "properties": {
- "showToolbar": true,
- "content": "Welcome to Homarr 🚀👋
We're glad that you're here! Homarr is a modern and easy to use dashboard that helps you to organize and manage your home network from one place. Control is at your fingertips.
We recommend you to read the getting started guide first. To edit this board you must enter the edit mode - only administrators can do this. Adding an app is the first step you should take. You can do this by clicking the Add tile button at the top right and select App. After you provided an internal URL, external URL and selected an icon you can drag it around when holding down the left mouse button. Make it bigger or smaller using the drag icon at the bottom right. When you're happy with it's position, you must exit edit mode to save your board. Adding widgets works the same way but may require additional configuration - read the documentation for more information.
To remove this widget, you must log in to your administrator account and click on the menu to delete it.
Your TODO list:
"
- },
- "area": {
- "type": "wrapper",
- "properties": {
- "id": "default"
- }
- },
- "shape": {
- "sm": {
- "location": {
- "x": 0,
- "y": 0
- },
- "size": {
- "width": 3,
- "height": 2
- }
- },
- "md": {
- "location": {
- "x": 0,
- "y": 0
- },
- "size": {
- "width": 3,
- "height": 4
- }
- },
- "lg": {
- "location": {
- "x": 0,
- "y": 1
- },
- "size": {
- "width": 6,
- "height": 3
- }
- }
- }
- }
- ],
- "settings": {
- "common": {
- "searchEngine": {
- "type": "google",
- "properties": {}
- }
- },
- "customization": {
- "layout": {
- "enabledLeftSidebar": false,
- "enabledRightSidebar": false,
- "enabledDocker": false,
- "enabledPing": false,
- "enabledSearchbar": true
- },
- "pageTitle": "Homarr ⭐️",
- "logoImageUrl": "/imgs/logo/logo.png",
- "faviconUrl": "/imgs/favicon/favicon-squared.png",
- "backgroundImageUrl": "",
- "customCss": "",
- "colors": {
- "primary": "red",
- "secondary": "yellow",
- "shade": 7
- },
- "appOpacity": 100,
- "gridstack": {
- "columnCountSmall": 3,
- "columnCountMedium": 6,
- "columnCountLarge": 10
- }
- },
- "access": {
- "allowGuests": false
- }
- }
-}
\ No newline at end of file
diff --git a/database/.gitkeep b/database/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/deployments/prebuilt-debian/Dockerfile b/deployments/prebuilt-debian/Dockerfile
new file mode 100644
index 000000000..414af691c
--- /dev/null
+++ b/deployments/prebuilt-debian/Dockerfile
@@ -0,0 +1,6 @@
+FROM node:24.12.0-trixie AS base
+WORKDIR /app
+COPY package.json .
+COPY pnpm-lock.yaml .
+RUN corepack enable pnpm && pnpm install --frozen-lockfile
+CMD ["sleep", "60s"]
\ No newline at end of file
diff --git a/deployments/prebuilt-debian/package.json b/deployments/prebuilt-debian/package.json
new file mode 100644
index 000000000..b9e0930b8
--- /dev/null
+++ b/deployments/prebuilt-debian/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "homarr-prebuilt-debian",
+ "private": true,
+ "dependencies": {
+ "better-sqlite3": "^12.5.0"
+ },
+ "packageManager": "pnpm@10.27.0",
+ "engines": {
+ "node": ">=24.12.0",
+ "pnpm": ">=10.27.0"
+ },
+ "pnpm": {
+ "onlyBuiltDependencies": [
+ "better-sqlite3"
+ ]
+ }
+}
diff --git a/deployments/prebuilt-debian/pnpm-lock.yaml b/deployments/prebuilt-debian/pnpm-lock.yaml
new file mode 100644
index 000000000..1868baaa2
--- /dev/null
+++ b/deployments/prebuilt-debian/pnpm-lock.yaml
@@ -0,0 +1,286 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ better-sqlite3:
+ specifier: ^12.5.0
+ version: 12.5.0
+
+packages:
+
+ base64-js@1.5.1:
+ resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+
+ better-sqlite3@12.5.0:
+ resolution: {integrity: sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==}
+ engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
+
+ bindings@1.5.0:
+ resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
+
+ bl@4.1.0:
+ resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
+
+ buffer@5.7.1:
+ resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
+
+ chownr@1.1.4:
+ resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
+
+ decompress-response@6.0.0:
+ resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
+ engines: {node: '>=10'}
+
+ deep-extend@0.6.0:
+ resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
+ engines: {node: '>=4.0.0'}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ end-of-stream@1.4.5:
+ resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+
+ expand-template@2.0.3:
+ resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
+ engines: {node: '>=6'}
+
+ file-uri-to-path@1.0.0:
+ resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
+
+ fs-constants@1.0.0:
+ resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
+
+ github-from-package@0.0.0:
+ resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
+
+ ieee754@1.2.1:
+ resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
+
+ inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+ ini@1.3.8:
+ resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
+
+ mimic-response@3.1.0:
+ resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
+ engines: {node: '>=10'}
+
+ minimist@1.2.8:
+ resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+
+ mkdirp-classic@0.5.3:
+ resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
+
+ napi-build-utils@2.0.0:
+ resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
+
+ node-abi@3.85.0:
+ resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==}
+ engines: {node: '>=10'}
+
+ once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
+ prebuild-install@7.1.3:
+ resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ pump@3.0.3:
+ resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
+
+ rc@1.2.8:
+ resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
+ hasBin: true
+
+ readable-stream@3.6.2:
+ resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
+ engines: {node: '>= 6'}
+
+ safe-buffer@5.2.1:
+ resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
+ semver@7.7.3:
+ resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ simple-concat@1.0.1:
+ resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
+
+ simple-get@4.0.1:
+ resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
+
+ string_decoder@1.3.0:
+ resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+
+ strip-json-comments@2.0.1:
+ resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
+ engines: {node: '>=0.10.0'}
+
+ tar-fs@2.1.4:
+ resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
+
+ tar-stream@2.2.0:
+ resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
+ engines: {node: '>=6'}
+
+ tunnel-agent@0.6.0:
+ resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
+
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+ wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
+snapshots:
+
+ base64-js@1.5.1: {}
+
+ better-sqlite3@12.5.0:
+ dependencies:
+ bindings: 1.5.0
+ prebuild-install: 7.1.3
+
+ bindings@1.5.0:
+ dependencies:
+ file-uri-to-path: 1.0.0
+
+ bl@4.1.0:
+ dependencies:
+ buffer: 5.7.1
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+
+ buffer@5.7.1:
+ dependencies:
+ base64-js: 1.5.1
+ ieee754: 1.2.1
+
+ chownr@1.1.4: {}
+
+ decompress-response@6.0.0:
+ dependencies:
+ mimic-response: 3.1.0
+
+ deep-extend@0.6.0: {}
+
+ detect-libc@2.1.2: {}
+
+ end-of-stream@1.4.5:
+ dependencies:
+ once: 1.4.0
+
+ expand-template@2.0.3: {}
+
+ file-uri-to-path@1.0.0: {}
+
+ fs-constants@1.0.0: {}
+
+ github-from-package@0.0.0: {}
+
+ ieee754@1.2.1: {}
+
+ inherits@2.0.4: {}
+
+ ini@1.3.8: {}
+
+ mimic-response@3.1.0: {}
+
+ minimist@1.2.8: {}
+
+ mkdirp-classic@0.5.3: {}
+
+ napi-build-utils@2.0.0: {}
+
+ node-abi@3.85.0:
+ dependencies:
+ semver: 7.7.3
+
+ once@1.4.0:
+ dependencies:
+ wrappy: 1.0.2
+
+ prebuild-install@7.1.3:
+ dependencies:
+ detect-libc: 2.1.2
+ expand-template: 2.0.3
+ github-from-package: 0.0.0
+ minimist: 1.2.8
+ mkdirp-classic: 0.5.3
+ napi-build-utils: 2.0.0
+ node-abi: 3.85.0
+ pump: 3.0.3
+ rc: 1.2.8
+ simple-get: 4.0.1
+ tar-fs: 2.1.4
+ tunnel-agent: 0.6.0
+
+ pump@3.0.3:
+ dependencies:
+ end-of-stream: 1.4.5
+ once: 1.4.0
+
+ rc@1.2.8:
+ dependencies:
+ deep-extend: 0.6.0
+ ini: 1.3.8
+ minimist: 1.2.8
+ strip-json-comments: 2.0.1
+
+ readable-stream@3.6.2:
+ dependencies:
+ inherits: 2.0.4
+ string_decoder: 1.3.0
+ util-deprecate: 1.0.2
+
+ safe-buffer@5.2.1: {}
+
+ semver@7.7.3: {}
+
+ simple-concat@1.0.1: {}
+
+ simple-get@4.0.1:
+ dependencies:
+ decompress-response: 6.0.0
+ once: 1.4.0
+ simple-concat: 1.0.1
+
+ string_decoder@1.3.0:
+ dependencies:
+ safe-buffer: 5.2.1
+
+ strip-json-comments@2.0.1: {}
+
+ tar-fs@2.1.4:
+ dependencies:
+ chownr: 1.1.4
+ mkdirp-classic: 0.5.3
+ pump: 3.0.3
+ tar-stream: 2.2.0
+
+ tar-stream@2.2.0:
+ dependencies:
+ bl: 4.1.0
+ end-of-stream: 1.4.5
+ fs-constants: 1.0.0
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+
+ tunnel-agent@0.6.0:
+ dependencies:
+ safe-buffer: 5.2.1
+
+ util-deprecate@1.0.2: {}
+
+ wrappy@1.0.2: {}
diff --git a/development/build.cmd b/development/build.cmd
new file mode 100644
index 000000000..88679ba0c
--- /dev/null
+++ b/development/build.cmd
@@ -0,0 +1 @@
+docker build -t homarr .
\ No newline at end of file
diff --git a/development/development.docker-compose.yml b/development/development.docker-compose.yml
new file mode 100644
index 000000000..f19ed787d
--- /dev/null
+++ b/development/development.docker-compose.yml
@@ -0,0 +1,54 @@
+############################
+#
+# This compose file is only for development.
+# Do not use this in production.
+#
+############################
+
+name: development-docker-compose
+services:
+ redis:
+ container_name: redis
+ image: redis
+ ports:
+ - "6379:6379"
+
+ mysql:
+ container_name: mysql
+ image: mysql:8.0
+ ports:
+ - "3306:3306"
+ environment:
+ MYSQL_ROOT_PASSWORD: homarr
+ MYSQL_DATABASE: homarrdb
+ MYSQL_USER: homarr
+ MYSQL_PASSWORD: homarr
+ volumes:
+ - mysql_data:/var/lib/mysql
+
+ postgresql:
+ image: postgres
+ restart: always
+ # set shared memory limit when using docker compose
+ shm_size: 128mb
+ # or set shared memory limit when deploy via swarm stack
+ #volumes:
+ # - type: tmpfs
+ # target: /dev/shm
+ # tmpfs:
+ # size: 134217728 # 128*2^20 bytes = 128Mb
+ environment:
+ POSTGRES_PASSWORD: homarr
+ POSTGRES_USER: homarr
+ POSTGRES_DB: homarrdb
+ PGDATA: /var/lib/postgresql/data/pgdata
+ volumes:
+ - postgresql_data:/var/lib/postgresql/data
+ ports:
+ - 5432:5432
+ # if already run PostgreSQL, change port number to use container's service
+ # - 2345:5432
+
+volumes:
+ mysql_data:
+ postgresql_data:
\ No newline at end of file
diff --git a/development/docker-run.cmd b/development/docker-run.cmd
new file mode 100644
index 000000000..2ef22223b
--- /dev/null
+++ b/development/docker-run.cmd
@@ -0,0 +1,2 @@
+:: Please do not run this command in production. It is only for local testing.
+docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 homarr:latest
\ No newline at end of file
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 000000000..127ef95f8
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,369 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Install 💻
+ •
+
+ Translations 🈺
+ •
+
+ Discord 👋
+
+
+
+
+
+
+
+- 🖌️ Highly customizable with an extensive drag and drop grid system
+- ✨ Integrates seamlessly with your favorite self-hosted applications
+- 📌 Easy and fast app management - no YAML involved
+- 👤 Detailed and easy to use user management with permissions and groups
+- 👥 Support for single sign on via OIDC / LDAP
+- 🙊 Safe encryption using BCrypt and AES-256-CBC for your valuable data
+- 🕔 Realtime widget updates using WebSockets, tRPC and Redis
+- 🔍 Search through thousands of data points in supported integrations or your data in Homarr using the fast built-in search
+- 🦞 Icon picker with over 11K icons
+- 🚀 Compatible with any major consumer hardware (x86, Raspberry Pi, old laptops, ...) and most OS (Windows, Linux, TrueNAS, Unraid)
+- 🖥️ Extensive Kubernetes support with Helm for efficient scaling & high reliability
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Homarr is a free-to-use open source project maintained by volunteers and developers from all over the world.
+We publish under the `Apache License 2.0` license which allows commercial usage.
+We invest multiple hours daily in providing support, developing Homarr, integrating to third party software and more.
+We also pay for licensing and server hosting fees.
+Please consider helping us cover these costs to enable the future development of Homarr. Thank you!
+
+
+
+You can also support us by helping with [translating the entire project](https://homarr.dev/docs/community/translations) to as many languages as possible or contributing directly to the code or documentation. Please read our [Contribution Guidelines](/CONTRIBUTING.md). All contributions, regardless of their size or scope, are welcome and highly appreciated! Thank you ❤️
+
+## Sponsors
+
+Thanks to your generous sponsors, we can continue to build Homarr. Check them out for high-quality and easy-to-use development tools.
+Feel free to contact us at homarr-labs@proton.me if you wish to become a sponsor.
+
+[](https://argos-ci.com?utm_source=%5Bhomarr%5D&utm_campaign=oss) \
+[](https://www.pikapods.com/pods?run=homarr-v1)
diff --git a/docs/banner.png b/docs/banner.png
deleted file mode 100644
index 823bb50b1..000000000
Binary files a/docs/banner.png and /dev/null differ
diff --git a/docs/banner.xcf b/docs/banner.xcf
deleted file mode 100644
index f58de314c..000000000
Binary files a/docs/banner.xcf and /dev/null differ
diff --git a/docs/img/headers/contribute.png b/docs/img/headers/contribute.png
new file mode 100644
index 000000000..aedb22540
Binary files /dev/null and b/docs/img/headers/contribute.png differ
diff --git a/docs/img/headers/features.png b/docs/img/headers/features.png
new file mode 100644
index 000000000..a275ce946
Binary files /dev/null and b/docs/img/headers/features.png differ
diff --git a/docs/img/headers/header.xcf b/docs/img/headers/header.xcf
new file mode 100644
index 000000000..22a96ee2e
Binary files /dev/null and b/docs/img/headers/header.xcf differ
diff --git a/docs/img/headers/installation.png b/docs/img/headers/installation.png
new file mode 100644
index 000000000..359cc3bca
Binary files /dev/null and b/docs/img/headers/installation.png differ
diff --git a/docs/img/headers/integrations.png b/docs/img/headers/integrations.png
new file mode 100644
index 000000000..41f0173b2
Binary files /dev/null and b/docs/img/headers/integrations.png differ
diff --git a/docs/img/logo/2340450-2-title.png b/docs/img/logo/2340450-2-title.png
new file mode 100644
index 000000000..b53b409f4
Binary files /dev/null and b/docs/img/logo/2340450-2-title.png differ
diff --git a/docs/img/logo/2340450-2-title.xcf b/docs/img/logo/2340450-2-title.xcf
new file mode 100644
index 000000000..e2a413f13
Binary files /dev/null and b/docs/img/logo/2340450-2-title.xcf differ
diff --git a/docs/img/screenshot.png b/docs/img/screenshot.png
new file mode 100644
index 000000000..963f6d8fb
Binary files /dev/null and b/docs/img/screenshot.png differ
diff --git a/docs/installation-button.png b/docs/installation-button.png
deleted file mode 100644
index 991cb9b27..000000000
Binary files a/docs/installation-button.png and /dev/null differ
diff --git a/docs/section-contribute.png b/docs/section-contribute.png
deleted file mode 100644
index 3700fee95..000000000
Binary files a/docs/section-contribute.png and /dev/null differ
diff --git a/docs/section-features.png b/docs/section-features.png
deleted file mode 100644
index b1854bf3a..000000000
Binary files a/docs/section-features.png and /dev/null differ
diff --git a/docs/section-installation.png b/docs/section-installation.png
deleted file mode 100644
index 92fa4474f..000000000
Binary files a/docs/section-installation.png and /dev/null differ
diff --git a/docs/section-preview.png b/docs/section-preview.png
deleted file mode 100644
index 6e43e043d..000000000
Binary files a/docs/section-preview.png and /dev/null differ
diff --git a/docs/section-template.xcf b/docs/section-template.xcf
deleted file mode 100644
index 7c2094bd4..000000000
Binary files a/docs/section-template.xcf and /dev/null differ
diff --git a/docs/section-widgets-and-integrations.png b/docs/section-widgets-and-integrations.png
deleted file mode 100644
index 67fcbc9ea..000000000
Binary files a/docs/section-widgets-and-integrations.png and /dev/null differ
diff --git a/drizzle.config.ts b/drizzle.config.ts
deleted file mode 100644
index e6aa45846..000000000
--- a/drizzle.config.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import 'dotenv';
-import { type Config } from 'drizzle-kit';
-
-export default {
- schema: './src/server/db/schema.ts',
- driver: 'better-sqlite',
- out: './drizzle',
- dbCredentials: {
- url: process.env.DATABASE_URL!,
- },
-} satisfies Config;
diff --git a/drizzle/0000_supreme_the_captain.sql b/drizzle/0000_supreme_the_captain.sql
deleted file mode 100644
index d814bc5ed..000000000
--- a/drizzle/0000_supreme_the_captain.sql
+++ /dev/null
@@ -1,69 +0,0 @@
-CREATE TABLE `account` (
- `userId` text NOT NULL,
- `type` text NOT NULL,
- `provider` text NOT NULL,
- `providerAccountId` text NOT NULL,
- `refresh_token` text,
- `access_token` text,
- `expires_at` integer,
- `token_type` text,
- `scope` text,
- `id_token` text,
- `session_state` text,
- PRIMARY KEY(`provider`, `providerAccountId`),
- FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
-);
---> statement-breakpoint
-CREATE TABLE `invite` (
- `id` text PRIMARY KEY NOT NULL,
- `token` text NOT NULL,
- `expires` integer NOT NULL,
- `created_by_id` text NOT NULL,
- FOREIGN KEY (`created_by_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
-);
---> statement-breakpoint
-CREATE TABLE `session` (
- `sessionToken` text PRIMARY KEY NOT NULL,
- `userId` text NOT NULL,
- `expires` integer NOT NULL,
- FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
-);
---> statement-breakpoint
-CREATE TABLE `user_setting` (
- `id` text PRIMARY KEY NOT NULL,
- `user_id` text NOT NULL,
- `color_scheme` text DEFAULT 'environment' NOT NULL,
- `language` text DEFAULT 'en' NOT NULL,
- `default_board` text DEFAULT 'default' NOT NULL,
- `first_day_of_week` text DEFAULT 'monday' NOT NULL,
- `search_template` text DEFAULT 'https://google.com/search?q=%s' NOT NULL,
- `open_search_in_new_tab` integer DEFAULT true NOT NULL,
- `disable_ping_pulse` integer DEFAULT false NOT NULL,
- `replace_ping_with_icons` integer DEFAULT false NOT NULL,
- `use_debug_language` integer DEFAULT false NOT NULL,
- `auto_focus_search` integer DEFAULT false NOT NULL,
- FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
-);
---> statement-breakpoint
-CREATE TABLE `user` (
- `id` text PRIMARY KEY NOT NULL,
- `name` text,
- `email` text,
- `emailVerified` integer,
- `image` text,
- `password` text,
- `salt` text,
- `is_admin` integer DEFAULT false NOT NULL,
- `is_owner` integer DEFAULT false NOT NULL
-);
---> statement-breakpoint
-CREATE TABLE `verificationToken` (
- `identifier` text NOT NULL,
- `token` text NOT NULL,
- `expires` integer NOT NULL,
- PRIMARY KEY(`identifier`, `token`)
-);
---> statement-breakpoint
-CREATE INDEX `userId_idx` ON `account` (`userId`);--> statement-breakpoint
-CREATE UNIQUE INDEX `invite_token_unique` ON `invite` (`token`);--> statement-breakpoint
-CREATE INDEX `user_id_idx` ON `session` (`userId`);
\ No newline at end of file
diff --git a/drizzle/0001_brave_mimic.sql b/drizzle/0001_brave_mimic.sql
deleted file mode 100644
index e32518ada..000000000
--- a/drizzle/0001_brave_mimic.sql
+++ /dev/null
@@ -1,10 +0,0 @@
-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`);
\ No newline at end of file
diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json
deleted file mode 100644
index 87169d793..000000000
--- a/drizzle/meta/0000_snapshot.json
+++ /dev/null
@@ -1,468 +0,0 @@
-{
- "version": "5",
- "dialect": "sqlite",
- "id": "32c1bc91-e69f-4e1d-b53c-9c43f2e6c9d3",
- "prevId": "00000000-0000-0000-0000-000000000000",
- "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": {}
- },
- "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": {}
- }
-}
\ No newline at end of file
diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json
deleted file mode 100644
index e47b5af15..000000000
--- a/drizzle/meta/0001_snapshot.json
+++ /dev/null
@@ -1,527 +0,0 @@
-{
- "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": {}
- }
-}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
deleted file mode 100644
index a45618382..000000000
--- a/drizzle/meta/_journal.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "version": "5",
- "dialect": "sqlite",
- "entries": [
- {
- "idx": 0,
- "version": "5",
- "when": 1695874816934,
- "tag": "0000_supreme_the_captain",
- "breakpoints": true
- },
- {
- "idx": 1,
- "version": "5",
- "when": 1730643218521,
- "tag": "0001_brave_mimic",
- "breakpoints": true
- }
- ]
-}
\ No newline at end of file
diff --git a/drizzle/migrate/migrate.ts b/drizzle/migrate/migrate.ts
deleted file mode 100644
index 469cdb119..000000000
--- a/drizzle/migrate/migrate.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-// This file is used to migrate the database to the current version
-// It is run when the docker container starts
-const Database = require('better-sqlite3');
-const path = require('path');
-const dotenv = require('dotenv');
-const { drizzle } = require('drizzle-orm/better-sqlite3');
-const { migrate } = require('drizzle-orm/better-sqlite3/migrator');
-
-const migrationsFolder = process.argv[2] ?? '../drizzle';
-
-dotenv.config({ path: path.join(__dirname, '/../.env') });
-const sqlite = new Database(process.env.DATABASE_URL!.replace('file:', ''));
-
-const db = drizzle(sqlite);
-
-const migrateDatabase = async () => {
- await migrate(db, { migrationsFolder });
-};
-
-migrateDatabase();
diff --git a/drizzle/migrate/package.json b/drizzle/migrate/package.json
deleted file mode 100644
index 8e8275111..000000000
--- a/drizzle/migrate/package.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "license": "MIT",
- "description": "This package.json is used for the migration script the dependencies are only installed within the Dockerfile.",
- "scripts": {
- "db:migrate": "tsx ./migrate.ts"
- },
- "dependencies": {
- "@types/better-sqlite3": "^7.6.7",
- "better-sqlite3": "8.6.0",
- "drizzle-orm": "^0.28.6",
- "dotenv": "^16.3.1",
- "tsx": "4.19.1",
- "typescript": "^5.2.2"
- }
-}
\ No newline at end of file
diff --git a/e2e/health-checks.spec.ts b/e2e/health-checks.spec.ts
new file mode 100644
index 000000000..03d175525
--- /dev/null
+++ b/e2e/health-checks.spec.ts
@@ -0,0 +1,46 @@
+import { describe, expect, test } from "vitest";
+
+import { createHomarrContainer } from "./shared/create-homarr-container";
+import { createRedisContainer } from "./shared/redis-container";
+
+describe("Health checks", () => {
+ test("ready and live should return 200 OK with normal image and no extra configuration", async () => {
+ // Arrange
+ const homarrContainer = await createHomarrContainer().start();
+
+ // Act
+ const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
+ const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
+
+ // Assert
+ expect(readyResponse.status).toBe(200);
+ expect(liveResponse.status).toBe(200);
+ }, 20_000);
+
+ test("ready and live should return 200 OK with external redis", async () => {
+ // Arrange
+ const redisContainer = await createRedisContainer().start();
+ const homarrContainer = await createHomarrContainer({
+ environment: {
+ REDIS_IS_EXTERNAL: "true",
+ REDIS_HOST: "host.docker.internal",
+ REDIS_PORT: redisContainer.getMappedPort(6379).toString(),
+ REDIS_PASSWORD: redisContainer.getPassword(),
+ },
+ }).start();
+
+ // Act
+ const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
+ const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
+
+ // Assert
+ expect(
+ readyResponse.status,
+ `Expected ready to return OK statusCode=${readyResponse.status} content=${await readyResponse.text()}`,
+ ).toBe(200);
+ expect(
+ liveResponse.status,
+ `Expected live to return OK statusCode=${liveResponse.status} content=${await liveResponse.text()}`,
+ ).toBe(200);
+ }, 20_000);
+});
diff --git a/e2e/home.spec.ts b/e2e/home.spec.ts
new file mode 100644
index 000000000..e8550a3eb
--- /dev/null
+++ b/e2e/home.spec.ts
@@ -0,0 +1,16 @@
+import { describe, expect, test } from "vitest";
+
+import { createHomarrContainer } from "./shared/create-homarr-container";
+
+describe("Home", () => {
+ test("should open with status code 200", async () => {
+ // Arrange
+ const homarrContainer = await createHomarrContainer().start();
+
+ // Act
+ const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`);
+
+ // Assert
+ expect(homeResponse.status).toBe(200);
+ }, 20_000);
+});
diff --git a/e2e/lldap.spec.ts b/e2e/lldap.spec.ts
new file mode 100644
index 000000000..f9015af55
--- /dev/null
+++ b/e2e/lldap.spec.ts
@@ -0,0 +1,92 @@
+import { chromium } from "playwright";
+import { GenericContainer } from "testcontainers";
+import { describe, expect, test } from "vitest";
+
+import { OnboardingActions } from "./shared/actions/onboarding-actions";
+import { createHomarrContainer, withLogs } from "./shared/create-homarr-container";
+import { createSqliteDbFileAsync } from "./shared/e2e-db";
+
+const defaultCredentials = {
+ username: "admin",
+ password: "password",
+ email: "admin@homarr.dev",
+ group: "lldap_admin",
+};
+
+const ldapBase = "dc=example,dc=com";
+
+describe("LLDAP authorization", () => {
+ test("Authorize with LLDAP successfully", async () => {
+ // Arrange
+ const lldapContainer = await createLldapContainer().start();
+ const { db, localMountPath } = await createSqliteDbFileAsync();
+ const homarrContainer = await createHomarrContainer({
+ environment: {
+ AUTH_PROVIDERS: "ldap",
+ AUTH_LDAP_URI: `ldap://host.docker.internal:${lldapContainer.getMappedPort(3890)}`,
+ AUTH_LDAP_BASE: ldapBase,
+ AUTH_LDAP_BIND_DN: `uid=${defaultCredentials.username},ou=People,${ldapBase}`,
+ AUTH_LDAP_BIND_PASSWORD: defaultCredentials.password,
+ },
+ mounts: {
+ "/appdata": localMountPath,
+ },
+ }).start();
+
+ const browser = await chromium.launch();
+ const context = await browser.newContext();
+ const page = await context.newPage();
+
+ const onboardingActions = new OnboardingActions(page, db);
+ await onboardingActions.skipOnboardingAsync({
+ group: defaultCredentials.group,
+ });
+
+ // Act
+ await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/auth/login`);
+ await page.getByLabel("Username").fill(defaultCredentials.username);
+ await page.getByLabel("Password").fill(defaultCredentials.password);
+ await page.locator("css=button[type='submit']").click();
+
+ // Assert
+ await page.waitForURL(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
+ const users = await db.query.users.findMany({
+ with: {
+ groups: {
+ with: {
+ group: true,
+ },
+ },
+ },
+ });
+ expect(users).toHaveLength(1);
+ const user = users[0]!;
+ expect(user).toEqual(
+ expect.objectContaining({
+ name: defaultCredentials.username,
+ email: defaultCredentials.email,
+ provider: "ldap",
+ }),
+ );
+
+ const groups = user.groups.map((g) => g.group.name);
+ expect(groups).toContain(defaultCredentials.group);
+
+ // Cleanup
+ await browser.close();
+ await homarrContainer.stop();
+ await lldapContainer.stop();
+ }, 120_000);
+});
+
+const createLldapContainer = () => {
+ return withLogs(
+ new GenericContainer("lldap/lldap:stable").withExposedPorts(3890).withEnvironment({
+ LLDAP_JWT_SECRET: "REPLACE_WITH_RANDOM",
+ LLDAP_KEY_SEED: "REPLACE_WITH_RANDOM",
+ LLDAP_LDAP_BASE_DN: ldapBase,
+ LLDAP_LDAP_USER_PASS: defaultCredentials.password,
+ LLDAP_LDAP_USER_EMAIL: defaultCredentials.email,
+ }),
+ );
+};
diff --git a/e2e/onboarding.spec.ts b/e2e/onboarding.spec.ts
new file mode 100644
index 000000000..d2731853a
--- /dev/null
+++ b/e2e/onboarding.spec.ts
@@ -0,0 +1,85 @@
+import { chromium } from "playwright";
+import { describe, test } from "vitest";
+
+import { OnboardingActions } from "./shared/actions/onboarding-actions";
+import { OnboardingAssertions } from "./shared/assertions/onboarding-assertions";
+import { createHomarrContainer } from "./shared/create-homarr-container";
+import { createSqliteDbFileAsync } from "./shared/e2e-db";
+
+describe("Onboarding", () => {
+ test("Credentials onboarding should be successful", async () => {
+ // Arrange
+ const { db, localMountPath } = await createSqliteDbFileAsync();
+ const homarrContainer = await createHomarrContainer({
+ mounts: {
+ "/appdata": localMountPath,
+ },
+ }).start();
+
+ const browser = await chromium.launch();
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ const actions = new OnboardingActions(page, db);
+ const assertions = new OnboardingAssertions(page, db);
+
+ // Act
+ await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
+ await actions.startOnboardingAsync("scratch");
+ await actions.processUserStepAsync({
+ username: "admin",
+ password: "Comp(exP4sswOrd",
+ confirmPassword: "Comp(exP4sswOrd",
+ });
+ await actions.processSettingsStepAsync();
+
+ // Assert
+ await assertions.assertFinishStepVisibleAsync();
+ await assertions.assertUserAndAdminGroupInsertedAsync("admin");
+ await assertions.assertDbOnboardingStepAsync("finish");
+
+ // Cleanup
+ await browser.close();
+ await homarrContainer.stop();
+ }, 60_000);
+
+ test("External provider onboarding setup should be successful", async () => {
+ // Arrange
+ const { db, localMountPath } = await createSqliteDbFileAsync();
+ const homarrContainer = await createHomarrContainer({
+ environment: {
+ AUTH_PROVIDERS: "ldap",
+ AUTH_LDAP_URI: "ldap://host.docker.internal:3890",
+ AUTH_LDAP_BASE: "not-used",
+ AUTH_LDAP_BIND_DN: "not-used",
+ AUTH_LDAP_BIND_PASSWORD: "not-used",
+ },
+ mounts: {
+ "/appdata": localMountPath,
+ },
+ }).start();
+ const externalGroupName = "oidc-admins";
+
+ const browser = await chromium.launch();
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ const actions = new OnboardingActions(page, db);
+ const assertions = new OnboardingAssertions(page, db);
+
+ // Act
+ await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
+ await actions.startOnboardingAsync("scratch");
+ await actions.processExternalGroupStepAsync({
+ name: externalGroupName,
+ });
+ await actions.processSettingsStepAsync();
+
+ // Assert
+ await assertions.assertFinishStepVisibleAsync();
+ await assertions.assertExternalGroupInsertedAsync(externalGroupName);
+ await assertions.assertDbOnboardingStepAsync("finish");
+
+ // Cleanup
+ await browser.close();
+ await homarrContainer.stop();
+ }, 60_000);
+});
diff --git a/e2e/shared/actions/onboarding-actions.ts b/e2e/shared/actions/onboarding-actions.ts
new file mode 100644
index 000000000..182203fcf
--- /dev/null
+++ b/e2e/shared/actions/onboarding-actions.ts
@@ -0,0 +1,54 @@
+import { createId } from "@paralleldrive/cuid2";
+import type { Page } from "playwright";
+
+import * as sqliteSchema from "../../../packages/db/schema/sqlite";
+import type { SqliteDatabase } from "../e2e-db";
+
+export class OnboardingActions {
+ private readonly page: Page;
+ private readonly db: SqliteDatabase;
+
+ constructor(page: Page, db: SqliteDatabase) {
+ this.page = page;
+ this.db = db;
+ }
+
+ public async skipOnboardingAsync(input?: { group?: string }) {
+ await this.db.update(sqliteSchema.onboarding).set({
+ step: "finish",
+ });
+
+ if (input?.group) {
+ await this.db.insert(sqliteSchema.groups).values({
+ id: createId(),
+ name: input.group,
+ position: 1,
+ });
+ }
+ }
+
+ public async startOnboardingAsync(type: "scratch" | "before 1.0") {
+ await this.page.locator("button", { hasText: type }).click();
+ }
+
+ public async processUserStepAsync(input: { username: string; password: string; confirmPassword: string }) {
+ await this.page.waitForSelector("text=administrator user");
+
+ await this.page.getByLabel("Username").fill(input.username);
+ await this.page.getByLabel("Password", { exact: true }).fill(input.password);
+ await this.page.getByLabel("Confirm password").fill(input.confirmPassword);
+
+ await this.page.locator("css=button[type='submit']").click();
+ }
+
+ public async processExternalGroupStepAsync(input: { name: string }) {
+ await this.page.waitForSelector("text=external provider");
+ await this.page.locator("input").fill(input.name);
+ await this.page.locator("css=button[type='submit']").click();
+ }
+
+ public async processSettingsStepAsync() {
+ await this.page.waitForSelector("text=Analytics");
+ await this.page.locator("css=button[type='submit']").click();
+ }
+}
diff --git a/e2e/shared/assertions/onboarding-assertions.ts b/e2e/shared/assertions/onboarding-assertions.ts
new file mode 100644
index 000000000..a2f9ccb2f
--- /dev/null
+++ b/e2e/shared/assertions/onboarding-assertions.ts
@@ -0,0 +1,62 @@
+import { eq } from "drizzle-orm";
+import type { Page } from "playwright";
+import { expect } from "vitest";
+
+import * as sqliteSchema from "../../../packages/db/schema/sqlite";
+import { OnboardingStep } from "../../../packages/definitions/src";
+import { credentialsAdminGroup } from "../../../packages/definitions/src/group";
+import type { SqliteDatabase } from "../e2e-db";
+
+export class OnboardingAssertions {
+ private readonly page: Page;
+ private readonly db: SqliteDatabase;
+
+ constructor(page: Page, db: SqliteDatabase) {
+ this.page = page;
+ this.db = db;
+ }
+
+ public async assertDbOnboardingStepAsync(expectedStep: OnboardingStep) {
+ const onboarding = await this.db.query.onboarding.findFirst();
+ expect(onboarding?.step).toEqual(expectedStep);
+ }
+
+ public async assertUserAndAdminGroupInsertedAsync(expectedUsername: string) {
+ const users = await this.db.query.users.findMany({
+ with: {
+ groups: {
+ with: {
+ group: {
+ with: {
+ permissions: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ const user = users.find((u) => u.name === expectedUsername);
+ expect(user).toBeDefined();
+
+ const adminGroup = user!.groups.find((g) => g.group.name === credentialsAdminGroup);
+ expect(adminGroup).toBeDefined();
+ expect(adminGroup!.group.permissions).toEqual([expect.objectContaining({ permission: "admin" })]);
+ }
+
+ public async assertExternalGroupInsertedAsync(expectedGroupName: string) {
+ const group = await this.db.query.groups.findFirst({
+ where: eq(sqliteSchema.groups.name, expectedGroupName),
+ with: {
+ permissions: true,
+ },
+ });
+
+ expect(group).toBeDefined();
+ expect(group!.permissions).toEqual([expect.objectContaining({ permission: "admin" })]);
+ }
+
+ public async assertFinishStepVisibleAsync() {
+ await this.page.waitForSelector("text=completed the setup", { timeout: 5000 });
+ }
+}
diff --git a/e2e/shared/create-homarr-container.ts b/e2e/shared/create-homarr-container.ts
new file mode 100644
index 000000000..7e5bcd5c4
--- /dev/null
+++ b/e2e/shared/create-homarr-container.ts
@@ -0,0 +1,51 @@
+import { GenericContainer, Wait } from "testcontainers";
+import { Environment } from "testcontainers/build/types";
+
+export const createHomarrContainer = (
+ options: {
+ environment?: Environment;
+ mounts?: {
+ "/appdata"?: string;
+ "/var/run/docker.sock"?: string;
+ };
+ } = {},
+) => {
+ if (!process.env.CI) {
+ throw new Error("This test should only be run in CI or with a homarr image named 'homarr-e2e'");
+ }
+
+ const container = new GenericContainer("homarr-e2e")
+ .withExposedPorts(7575)
+ .withEnvironment({
+ ...options.environment,
+ SECRET_ENCRYPTION_KEY: "0".repeat(64),
+ })
+ .withBindMounts(
+ Object.entries(options.mounts ?? {})
+ .filter((item) => item?.[0] !== undefined)
+ .map(([container, local]) => ({
+ source: local,
+ target: container,
+ })),
+ )
+ .withWaitStrategy(Wait.forHttp("/api/health/ready", 7575))
+ .withExtraHosts([
+ {
+ // This enabled the usage of host.docker.internal as hostname in the container
+ host: "host.docker.internal",
+ ipAddress: "host-gateway",
+ },
+ ]);
+
+ return withLogs(container);
+};
+
+export const withLogs = (container: GenericContainer) => {
+ container.withLogConsumer((stream) =>
+ stream
+ .on("data", (line) => console.log(line))
+ .on("err", (line) => console.error(line))
+ .on("end", () => console.log("Stream closed")),
+ );
+ return container;
+};
diff --git a/e2e/shared/e2e-db.ts b/e2e/shared/e2e-db.ts
new file mode 100644
index 000000000..ea59efe6f
--- /dev/null
+++ b/e2e/shared/e2e-db.ts
@@ -0,0 +1,33 @@
+import { mkdir } from "fs/promises";
+import path from "path";
+import { createId } from "@paralleldrive/cuid2";
+import Database from "better-sqlite3";
+import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
+import { migrate } from "drizzle-orm/better-sqlite3/migrator";
+
+import { DB_CASING } from "../../packages/core/src/infrastructure/db/constants";
+import * as sqliteSchema from "../../packages/db/schema/sqlite";
+
+export const createSqliteDbFileAsync = async () => {
+ const localMountPath = path.join(__dirname, "tmp", createId());
+ await mkdir(path.join(localMountPath, "db"), { recursive: true });
+
+ const localDbUrl = path.join(localMountPath, "db", "db.sqlite");
+
+ const connection = new Database(localDbUrl);
+ const db = drizzle(connection, {
+ schema: sqliteSchema,
+ casing: DB_CASING,
+ });
+
+ await migrate(db, {
+ migrationsFolder: path.join(__dirname, "..", "..", "packages", "db", "migrations", "sqlite"),
+ });
+
+ return {
+ db,
+ localMountPath,
+ };
+};
+
+export type SqliteDatabase = BetterSQLite3Database;
diff --git a/e2e/shared/redis-container.ts b/e2e/shared/redis-container.ts
new file mode 100644
index 000000000..cfd8ad4fb
--- /dev/null
+++ b/e2e/shared/redis-container.ts
@@ -0,0 +1,5 @@
+import { RedisContainer } from "@testcontainers/redis";
+
+export const createRedisContainer = () => {
+ return new RedisContainer("redis:latest").withPassword("homarr");
+};
diff --git a/next-env.d.ts b/next-env.d.ts
deleted file mode 100644
index 4f11a03dc..000000000
--- a/next-env.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-///
-///
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/next-i18next.config.js b/next-i18next.config.js
deleted file mode 100644
index 8ee9c6e11..000000000
--- a/next-i18next.config.js
+++ /dev/null
@@ -1,49 +0,0 @@
-const path = require('path');
-
-module.exports = {
- // https://www.i18next.com/overview/configuration-options#logging
- i18n: {
- defaultLocale: 'en',
- locales: [
- 'ar',
- 'cn',
- 'cr',
- 'cs',
- 'da',
- 'de',
- 'el',
- 'en',
- 'es',
- 'fr',
- 'he',
- 'hr',
- 'hu',
- 'it',
- 'ja',
- 'ko',
- 'lv',
- 'nl',
- 'no',
- 'pl',
- 'pt',
- 'ru',
- 'sk',
- 'sl',
- 'sv',
- 'tr',
- 'tw',
- 'uk',
- 'vi',
- 'et',
- 'lt',
- 'ro'
- ],
-
- localeDetection: false,
- },
- returnEmptyString: false,
- appendNamespaceToCIMode: true,
- reloadOnPrerender: process.env.NODE_ENV === 'development',
- fallbackLng: 'en',
- localePath: path.resolve('./public/locales'),
-};
diff --git a/next.config.js b/next.config.js
deleted file mode 100644
index 191310ecf..000000000
--- a/next.config.js
+++ /dev/null
@@ -1,31 +0,0 @@
-require('./src/env');
-const { i18n } = require('./next-i18next.config');
-
-const withBundleAnalyzer = require('@next/bundle-analyzer')({
- enabled: process.env.ANALYZE === 'true',
-});
-
-module.exports = withBundleAnalyzer({
- webpack: (config) => {
- // for dynamic loading of auth providers
- config.experiments = { ...config.experiments, topLevelAwait: true };
- return config;
- },
- images: {
- domains: ['cdn.jsdelivr.net'],
- },
- reactStrictMode: true,
- output: 'standalone',
- i18n,
- transpilePackages: ['@jellyfin/sdk'],
- redirects: async () => [
- {
- source: '/',
- destination: '/board',
- permanent: false,
- },
- ],
- env: {
- NEXTAUTH_URL_INTERNAL: process.env.NEXTAUTH_URL_INTERNAL || process.env.HOSTNAME || 'http://localhost:3000'
- },
-});
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 000000000..4b82c8ca5
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,29 @@
+events {
+ worker_connections 1024;
+}
+
+http {
+ server {
+ listen 7575;
+ listen [::]:7575;
+
+ # Route websockets traffic to port 3001
+ location /websockets {
+ proxy_pass http://${HOSTNAME}:3001;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "Upgrade";
+ proxy_set_header Host $http_host;
+ }
+
+ # Route all other traffic to port 3000
+ location / {
+ proxy_pass http://${HOSTNAME}:3000;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
+ client_max_body_size 32M;
+ }
+ }
+}
diff --git a/package.json b/package.json
index 1d72a865f..afc0d5b98 100644
--- a/package.json
+++ b/package.json
@@ -1,248 +1,111 @@
{
"name": "homarr",
- "version": "0.16.0",
- "description": "Homarr - A homepage for your server.",
- "license": "MIT",
- "repository": {
- "type": "git",
- "url": "https://github.com/ajnart/homarr"
- },
+ "version": "1.50.0",
+ "private": true,
"scripts": {
- "dev": "next dev",
- "build": "NEXTAUTH_SECRET=WILL_BE_OVERWRITTEN next build",
- "analyze": "ANALYZE=true next build",
- "turbo": "DATABASE_URL=file:WILL_BE_OVERWRITTEN.sqlite turbo build",
- "start": "next start",
- "typecheck": "tsc --noEmit",
- "export": "next build && next export",
- "lint": "next lint",
- "prettier:check": "prettier --check \"**/*.{ts,tsx}\"",
- "prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
- "test": "SKIP_ENV_VALIDATION=1 vitest",
- "test:docker": "yarn run turbo build && yarn test:run && docker build . -t homarr:local-dev && docker run -p 7575:7575 --name homarr-dev homarr:local-dev",
- "test:ui": "SKIP_ENV_VALIDATION=1 vitest --ui",
- "test:run": "SKIP_ENV_VALIDATION=1 vitest run",
- "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:add": "drizzle-kit generate:sqlite --config ./drizzle.config.ts"
- },
- "dependencies": {
- "@ctrl/deluge": "^4.1.0",
- "@ctrl/qbittorrent": "^6.0.0",
- "@ctrl/shared-torrent": "^4.1.1",
- "@ctrl/transmission": "^4.1.1",
- "@emotion/react": "^11.10.6",
- "@emotion/server": "^11.10.0",
- "@jellyfin/sdk": "^0.8.0",
- "@mantine/core": "^6.0.0",
- "@mantine/dates": "^6.0.0",
- "@mantine/form": "^6.0.0",
- "@mantine/hooks": "^6.0.0",
- "@mantine/modals": "^6.0.0",
- "@mantine/next": "^6.0.0",
- "@mantine/notifications": "^6.0.0",
- "@mantine/prism": "^6.0.19",
- "@mantine/tiptap": "^6.0.17",
- "@nivo/core": "^0.83.0",
- "@nivo/line": "^0.83.0",
- "@t3-oss/env-nextjs": "^0.7.1",
- "@tabler/icons-react": "^2.20.0",
- "@tanstack/react-query": "^4.2.1",
- "@tanstack/react-query-devtools": "^4.24.4",
- "@tiptap/extension-color": "^2.1.12",
- "@tiptap/extension-highlight": "^2.1.12",
- "@tiptap/extension-image": "^2.1.12",
- "@tiptap/extension-link": "^2.1.12",
- "@tiptap/extension-table": "^2.1.12",
- "@tiptap/extension-table-cell": "^2.1.12",
- "@tiptap/extension-table-header": "^2.1.12",
- "@tiptap/extension-table-row": "^2.1.12",
- "@tiptap/extension-task-item": "^2.1.12",
- "@tiptap/extension-task-list": "^2.1.12",
- "@tiptap/extension-text-align": "^2.1.12",
- "@tiptap/extension-text-style": "^2.1.12",
- "@tiptap/extension-underline": "^2.1.12",
- "@tiptap/pm": "^2.1.12",
- "@tiptap/react": "^2.1.12",
- "@tiptap/starter-kit": "^2.1.12",
- "@trpc/client": "^10.37.1",
- "@trpc/next": "^10.37.1",
- "@trpc/react-query": "^10.37.1",
- "@trpc/server": "^10.37.1",
- "@types/bcryptjs": "^2.4.2",
- "@vitejs/plugin-react": "^4.0.0",
- "adm-zip": "^0.5.15",
- "axios": "^1.0.0",
- "bcryptjs": "^2.4.3",
- "better-sqlite3": "^8.6.0",
- "consola": "^3.0.0",
- "cookies": "^0.8.0",
- "cookies-next": "^2.1.1",
- "dayjs": "^1.11.7",
- "dockerode": "^3.3.2",
- "dotenv": "^16.3.1",
- "drizzle-kit": "^0.19.13",
- "drizzle-orm": "^0.28.6",
- "drizzle-zod": "^0.5.1",
- "fily-publish-gridstack": "^0.0.13",
- "flag-icons": "^6.9.2",
- "framer-motion": "^10.0.0",
- "generate-password": "^1.7.0",
- "html-entities": "^2.3.3",
- "i18next": "^22.5.1",
- "immer": "^10.0.2",
- "js-file-download": "^0.4.12",
- "ldapjs": "^3.0.5",
- "mantine-react-table": "^1.3.4",
- "next": "13.4.12",
- "next-auth": "^4.23.0",
- "next-i18next": "^14.0.0",
- "nextjs-cors": "^2.2.0",
- "nzbget-api": "^0.0.3",
- "prismjs": "^1.29.0",
- "react": "^18.2.0",
- "react-dom": "^18.2.0",
- "react-i18next": "^12.3.1",
- "react-simple-code-editor": "^0.13.1",
- "rss-parser": "^3.12.0",
- "sabnzbd-api": "^1.5.0",
- "swagger-ui-react": "^5.11.0",
- "tldts": "^6.1.18",
- "trpc-openapi": "^1.2.0",
- "uuid": "^9.0.0",
- "xml-js": "^1.6.11",
- "xss": "^1.0.14",
- "zod": "^3.21.4",
- "zustand": "^4.3.7"
+ "build": "cross-env CI=true turbo build",
+ "clean": "git clean -xdf node_modules",
+ "clean:workspaces": "turbo clean",
+ "cli": "pnpm with-env tsx packages/cli/index.ts",
+ "db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
+ "db:migration:mysql:run": "pnpm -F db migration:mysql:run",
+ "db:migration:postgresql:generate": "pnpm -F db migration:postgresql:generate",
+ "db:migration:postgresql:run": "pnpm -F db migration:postgresql:run",
+ "db:migration:sqlite:generate": "pnpm -F db migration:sqlite:generate",
+ "db:migration:sqlite:run": "pnpm -F db migration:sqlite:run",
+ "db:push": "pnpm -F db push:sqlite",
+ "db:studio": "pnpm -F db studio",
+ "dev": "turbo dev --parallel",
+ "docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
+ "format": "turbo format --continue -- --cache --cache-location node_modules/.cache/.prettiercache",
+ "format:fix": "turbo format --continue -- --write --cache --cache-location node_modules/.cache/.prettiercache",
+ "lint": "turbo lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache",
+ "lint:fix": "turbo lint --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache",
+ "lint:ws": "pnpm dlx sherif@latest",
+ "package:new": "turbo gen init",
+ "release": "semantic-release",
+ "scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
+ "scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
+ "start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
+ "test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
+ "test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
+ "test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
+ "typecheck": "turbo typecheck",
+ "with-env": "dotenv -e .env --"
},
+ "prettier": "@homarr/prettier-config",
"devDependencies": {
- "@next/bundle-analyzer": "^13.0.0",
- "@next/eslint-plugin-next": "^13.4.5",
- "@testing-library/react": "^14.0.0",
- "@trivago/prettier-plugin-sort-imports": "^4.2.0",
- "@types/adm-zip": "^0.5.5",
- "@types/better-sqlite3": "^7.6.5",
- "@types/cookies": "^0.7.7",
- "@types/dockerode": "^3.3.9",
- "@types/ldapjs": "^3.0.2",
- "@types/node": "^20.6.0",
- "@types/prismjs": "^1.26.0",
- "@types/react": "^18.2.11",
- "@types/swagger-ui-react": "^4.18.3",
- "@types/umami": "^0.1.4",
- "@types/uuid": "^9.0.0",
- "@types/video.js": "^7.3.51",
- "@typescript-eslint/eslint-plugin": "^6.0.0",
- "@typescript-eslint/parser": "^6.0.0",
- "@vitest/coverage-c8": "^0.33.0",
- "@vitest/coverage-v8": "^0.34.5",
- "@vitest/ui": "^0.34.4",
- "dotenv-cli": "^7.3.0",
- "eslint": "^8.0.1",
- "eslint-config-next": "^13.4.5",
- "eslint-plugin-promise": "^6.0.0",
- "eslint-plugin-react": "latest",
- "eslint-plugin-react-hooks": "^4.6.0",
- "eslint-plugin-testing-library": "^5.5.1",
- "eslint-plugin-unused-imports": "^3.0.0",
- "eslint-plugin-vitest": "^0.2.0",
- "happy-dom": "^10.0.0",
- "node-mocks-http": "^1.12.2",
- "prettier": "^3.0.0",
- "sass": "^1.56.1",
- "tsx": "4.19.1",
- "turbo": "^1.10.12",
- "typescript": "5.1.6",
- "video.js": "^8.0.3",
- "vite-tsconfig-paths": "^4.2.0",
- "vitest": "^0.33.0",
- "vitest-fetch-mock": "^0.2.2"
+ "@homarr/prettier-config": "workspace:^0.1.0",
+ "@semantic-release/changelog": "^6.0.3",
+ "@semantic-release/commit-analyzer": "^13.0.1",
+ "@semantic-release/git": "^10.0.1",
+ "@semantic-release/github": "^12.0.2",
+ "@semantic-release/npm": "^13.1.3",
+ "@semantic-release/release-notes-generator": "^14.1.0",
+ "@testcontainers/redis": "^11.11.0",
+ "@turbo/gen": "^2.7.2",
+ "@vitejs/plugin-react": "^5.1.2",
+ "@vitest/coverage-v8": "^4.0.16",
+ "@vitest/ui": "^4.0.16",
+ "conventional-changelog-conventionalcommits": "^9.1.0",
+ "cross-env": "^10.1.0",
+ "jsdom": "^27.4.0",
+ "json5": "^2.2.3",
+ "prettier": "^3.7.4",
+ "semantic-release": "^25.0.2",
+ "testcontainers": "^11.11.0",
+ "turbo": "^2.7.2",
+ "typescript": "^5.9.3",
+ "vite-tsconfig-paths": "^6.0.3",
+ "vitest": "^4.0.16"
},
- "nextBundleAnalysis": {
- "budget": null,
- "budgetPercentIncreaseRed": 20,
- "minimumChangeThreshold": 0,
- "showDetails": true
+ "packageManager": "pnpm@10.27.0",
+ "engines": {
+ "node": ">=24.12.0",
+ "pnpm": ">=10.27.0"
},
- "prettier": {
- "printWidth": 100,
- "tabWidth": 2,
- "parser": "typescript",
- "singleQuote": true,
- "trailingComma": "es5",
- "useTabs": false,
- "endOfLine": "lf",
- "importOrder": [
- "^@core/(.*)$",
- "^@server/(.*)$",
- "^@ui/(.*)$",
- "^[./]"
+ "pnpm": {
+ "onlyBuiltDependencies": [
+ "@tree-sitter-grammars/tree-sitter-yaml",
+ "bcrypt",
+ "better-sqlite3",
+ "cpu-features",
+ "esbuild",
+ "sharp",
+ "ssh2",
+ "tree-sitter",
+ "tree-sitter-json"
],
- "importOrderSeparation": true,
- "plugins": [
- "@trivago/prettier-plugin-sort-imports"
- ],
- "importOrderSortSpecifiers": true
- },
- "eslintConfig": {
- "ignoreDuringBuilds": true,
- "extends": [
- "next",
- "eslint:recommended",
- "plugin:@next/next/recommended",
- "plugin:react-hooks/recommended",
- "plugin:react/recommended",
- "plugin:@typescript-eslint/recommended",
- "plugin:vitest/recommended"
- ],
- "plugins": [
- "testing-library",
- "unused-imports",
- "react",
- "vitest"
- ],
- "overrides": [
- {
- "files": [
- "**/?(*.)+(spec|test).[jt]s?(x)"
- ],
- "extends": [
- "plugin:testing-library/react"
- ]
- }
- ],
- "parser": "@typescript-eslint/parser",
- "parserOptions": {
- "project": "./tsconfig.json"
+ "overrides": {
+ "@babel/helpers@<7.26.10": ">=7.28.4",
+ "@babel/runtime@<7.26.10": ">=7.28.4",
+ "axios@>=1.0.0 <1.8.2": ">=1.13.2",
+ "brace-expansion@>=2.0.0 <=2.0.1": ">=4.0.1",
+ "brace-expansion@>=1.0.0 <=1.1.11": ">=4.0.1",
+ "esbuild@<=0.24.2": ">=0.27.2",
+ "form-data@>=4.0.0 <4.0.4": ">=4.0.5",
+ "hono@<4.6.5": ">=4.11.3",
+ "linkifyjs@<4.3.2": ">=4.3.2",
+ "nanoid@>=4.0.0 <5.0.9": ">=5.1.6",
+ "prismjs@<1.30.0": ">=1.30.0",
+ "proxmox-api>undici": "7.16.0",
+ "react-is": "^19.2.3",
+ "rollup@>=4.0.0 <4.22.4": ">=4.54.0",
+ "sha.js@<=2.4.11": ">=2.4.12",
+ "tar-fs@>=3.0.0 <3.0.9": ">=3.1.1",
+ "tar-fs@>=2.0.0 <2.1.3": ">=3.1.1",
+ "tmp@<=0.2.3": ">=0.2.5",
+ "vite@>=5.0.0 <=5.4.18": ">=7.3.0"
},
- "rules": {
- "import/no-cycle": "off",
- "react/react-in-jsx-scope": "off",
- "react/no-children-prop": "off",
- "@typescript-eslint/no-unused-vars": "off",
- "@typescript-eslint/no-unused-imports": "off",
- "@typescript-eslint/no-unused-expressions": "off",
- "@typescript-eslint/no-explicit-any": "off",
- "@typescript-eslint/no-shadow": "off",
- "@typescript-eslint/no-use-before-define": "off",
- "@typescript-eslint/no-non-null-assertion": "off",
- "no-continue": "off",
- "linebreak-style": 0,
- "import/extensions": "off",
- "vitest/max-nested-describe": [
- "error",
- {
- "max": 3
- }
- ],
- "testing-library/no-node-access": [
- "error",
- {
- "allowContainerFirstChild": true
- }
- ]
- }
+ "patchedDependencies": {
+ "@types/node-unifi": "patches/@types__node-unifi.patch",
+ "trpc-to-openapi": "patches/trpc-to-openapi.patch"
+ },
+ "allowUnusedPatches": true,
+ "ignoredBuiltDependencies": [
+ "@scarf/scarf",
+ "core-js-pure",
+ "protobufjs"
+ ]
}
}
diff --git a/packages/analytics/eslint.config.js b/packages/analytics/eslint.config.js
new file mode 100644
index 000000000..f7a5a7d36
--- /dev/null
+++ b/packages/analytics/eslint.config.js
@@ -0,0 +1,4 @@
+import baseConfig from "@homarr/eslint-config/base";
+
+/** @type {import('typescript-eslint').Config} */
+export default [...baseConfig];
diff --git a/packages/analytics/index.ts b/packages/analytics/index.ts
new file mode 100644
index 000000000..3bd16e178
--- /dev/null
+++ b/packages/analytics/index.ts
@@ -0,0 +1 @@
+export * from "./src";
diff --git a/packages/analytics/package.json b/packages/analytics/package.json
new file mode 100644
index 000000000..18324242c
--- /dev/null
+++ b/packages/analytics/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "@homarr/analytics",
+ "version": "0.1.0",
+ "private": true,
+ "license": "Apache-2.0",
+ "type": "module",
+ "exports": {
+ ".": "./index.ts"
+ },
+ "typesVersions": {
+ "*": {
+ "*": [
+ "src/*"
+ ]
+ }
+ },
+ "scripts": {
+ "clean": "rm -rf .turbo node_modules",
+ "format": "prettier --check . --ignore-path ../../.gitignore",
+ "lint": "eslint",
+ "typecheck": "tsc --noEmit"
+ },
+ "prettier": "@homarr/prettier-config",
+ "dependencies": {
+ "@homarr/core": "workspace:^0.1.0",
+ "@homarr/db": "workspace:^0.1.0",
+ "@homarr/server-settings": "workspace:^0.1.0",
+ "@umami/node": "^0.4.0",
+ "superjson": "2.2.6"
+ },
+ "devDependencies": {
+ "@homarr/eslint-config": "workspace:^0.2.0",
+ "@homarr/prettier-config": "workspace:^0.1.0",
+ "@homarr/tsconfig": "workspace:^0.1.0",
+ "eslint": "^9.39.2",
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/packages/analytics/src/constants.ts b/packages/analytics/src/constants.ts
new file mode 100644
index 000000000..6316180ad
--- /dev/null
+++ b/packages/analytics/src/constants.ts
@@ -0,0 +1,2 @@
+export const UMAMI_HOST_URL = "https://umami.homarr.dev";
+export const UMAMI_WEBSITE_ID = "ff7dc470-a84f-4779-b1ab-66a5bb16a94b";
diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts
new file mode 100644
index 000000000..c3c0fd550
--- /dev/null
+++ b/packages/analytics/src/index.ts
@@ -0,0 +1,2 @@
+export * from "./constants";
+export * from "./send-server-analytics";
diff --git a/packages/analytics/src/send-server-analytics.ts b/packages/analytics/src/send-server-analytics.ts
new file mode 100644
index 000000000..27f1ae3d8
--- /dev/null
+++ b/packages/analytics/src/send-server-analytics.ts
@@ -0,0 +1,93 @@
+import type { UmamiEventData } from "@umami/node";
+import { Umami } from "@umami/node";
+
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import { count, db } from "@homarr/db";
+import { getServerSettingByKeyAsync } from "@homarr/db/queries";
+import { integrations, items, users } from "@homarr/db/schema";
+import type { defaultServerSettings } from "@homarr/server-settings";
+
+import { Stopwatch } from "../../common/src";
+import { UMAMI_HOST_URL, UMAMI_WEBSITE_ID } from "./constants";
+
+const logger = createLogger({ module: "analytics" });
+
+export const sendServerAnalyticsAsync = async () => {
+ const stopWatch = new Stopwatch();
+ const analyticsSettings = await getServerSettingByKeyAsync(db, "analytics");
+
+ if (!analyticsSettings.enableGeneral) {
+ logger.info("Analytics are disabled. No data will be sent. Enable analytics in the settings");
+ return;
+ }
+
+ const umamiInstance = new Umami();
+ umamiInstance.init({
+ hostUrl: UMAMI_HOST_URL,
+ websiteId: UMAMI_WEBSITE_ID,
+ });
+
+ await sendIntegrationDataAsync(umamiInstance, analyticsSettings);
+ await sendWidgetDataAsync(umamiInstance, analyticsSettings);
+ await sendUserDataAsync(umamiInstance, analyticsSettings);
+
+ logger.info(`Sent all analytics in ${stopWatch.getElapsedInHumanWords()}`);
+};
+
+const sendWidgetDataAsync = async (umamiInstance: Umami, analyticsSettings: typeof defaultServerSettings.analytics) => {
+ if (!analyticsSettings.enableWidgetData) {
+ return;
+ }
+ const widgetCount = await db.$count(items);
+
+ const response = await umamiInstance.track("server-widget-data", {
+ countWidgets: widgetCount,
+ });
+ if (response.ok) {
+ return;
+ }
+
+ logger.warn("Unable to send track event data to Umami instance");
+};
+
+const sendUserDataAsync = async (umamiInstance: Umami, analyticsSettings: typeof defaultServerSettings.analytics) => {
+ if (!analyticsSettings.enableUserData) {
+ return;
+ }
+ const userCount = await db.$count(users);
+
+ const response = await umamiInstance.track("server-user-data", {
+ countUsers: userCount,
+ });
+ if (response.ok) {
+ return;
+ }
+
+ logger.warn("Unable to send track event data to Umami instance");
+};
+
+const sendIntegrationDataAsync = async (
+ umamiInstance: Umami,
+ analyticsSettings: typeof defaultServerSettings.analytics,
+) => {
+ if (!analyticsSettings.enableIntegrationData) {
+ return;
+ }
+ const integrationKinds = await db
+ .select({ kind: integrations.kind, count: count(integrations.id) })
+ .from(integrations)
+ .groupBy(integrations.kind);
+
+ const map: UmamiEventData = {};
+
+ integrationKinds.forEach((integrationKind) => {
+ map[integrationKind.kind] = integrationKind.count;
+ });
+
+ const response = await umamiInstance.track("server-integration-data-kind", map);
+ if (response.ok) {
+ return;
+ }
+
+ logger.warn("Unable to send track event data to Umami instance");
+};
diff --git a/packages/analytics/tsconfig.json b/packages/analytics/tsconfig.json
new file mode 100644
index 000000000..cbe8483d9
--- /dev/null
+++ b/packages/analytics/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@homarr/tsconfig/base.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
+ },
+ "include": ["*.ts", "src"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/api/eslint.config.js b/packages/api/eslint.config.js
new file mode 100644
index 000000000..f7a5a7d36
--- /dev/null
+++ b/packages/api/eslint.config.js
@@ -0,0 +1,4 @@
+import baseConfig from "@homarr/eslint-config/base";
+
+/** @type {import('typescript-eslint').Config} */
+export default [...baseConfig];
diff --git a/packages/api/package.json b/packages/api/package.json
new file mode 100644
index 000000000..a1543085c
--- /dev/null
+++ b/packages/api/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "@homarr/api",
+ "version": "0.1.0",
+ "private": true,
+ "license": "Apache-2.0",
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts",
+ "./client": "./src/client.ts",
+ "./server": "./src/server.ts",
+ "./websocket": "./src/websocket.ts",
+ "./shared": "./src/shared.ts"
+ },
+ "main": "./index.ts",
+ "types": "./index.ts",
+ "scripts": {
+ "clean": "rm -rf .turbo node_modules",
+ "format": "prettier --check . --ignore-path ../../.gitignore",
+ "lint": "eslint",
+ "typecheck": "tsc --noEmit"
+ },
+ "prettier": "@homarr/prettier-config",
+ "dependencies": {
+ "@homarr/auth": "workspace:^0.1.0",
+ "@homarr/common": "workspace:^0.1.0",
+ "@homarr/core": "workspace:^0.1.0",
+ "@homarr/cron-job-api": "workspace:^0.1.0",
+ "@homarr/cron-job-status": "workspace:^0.1.0",
+ "@homarr/cron-jobs": "workspace:^0.1.0",
+ "@homarr/db": "workspace:^0.1.0",
+ "@homarr/definitions": "workspace:^0.1.0",
+ "@homarr/docker": "workspace:^0.1.0",
+ "@homarr/icons": "workspace:^0.1.0",
+ "@homarr/integrations": "workspace:^0.1.0",
+ "@homarr/old-import": "workspace:^0.1.0",
+ "@homarr/old-schema": "workspace:^0.1.0",
+ "@homarr/ping": "workspace:^0.1.0",
+ "@homarr/redis": "workspace:^0.1.0",
+ "@homarr/request-handler": "workspace:^0.1.0",
+ "@homarr/server-settings": "workspace:^0.1.0",
+ "@homarr/translation": "workspace:^0.1.0",
+ "@homarr/validation": "workspace:^0.1.0",
+ "@kubernetes/client-node": "^1.4.0",
+ "@tanstack/react-query": "^5.90.16",
+ "@trpc/client": "^11.8.1",
+ "@trpc/react-query": "^11.8.1",
+ "@trpc/server": "^11.8.1",
+ "@trpc/tanstack-react-query": "^11.8.1",
+ "lodash.clonedeep": "^4.5.0",
+ "next": "16.1.1",
+ "react": "19.2.3",
+ "react-dom": "19.2.3",
+ "superjson": "2.2.6",
+ "trpc-to-openapi": "^3.1.0",
+ "zod": "^4.2.1"
+ },
+ "devDependencies": {
+ "@homarr/eslint-config": "workspace:^0.2.0",
+ "@homarr/prettier-config": "workspace:^0.1.0",
+ "@homarr/tsconfig": "workspace:^0.1.0",
+ "eslint": "^9.39.2",
+ "prettier": "^3.7.4",
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/packages/api/src/client.ts b/packages/api/src/client.ts
new file mode 100644
index 000000000..5fe104810
--- /dev/null
+++ b/packages/api/src/client.ts
@@ -0,0 +1,19 @@
+"use client";
+
+import { createTRPCClient, httpLink } from "@trpc/client";
+import { createTRPCReact } from "@trpc/react-query";
+import SuperJSON from "superjson";
+
+import type { AppRouter } from ".";
+import { createHeadersCallbackForSource, getTrpcUrl } from "./shared";
+
+export const clientApi = createTRPCReact();
+export const fetchApi = createTRPCClient({
+ links: [
+ httpLink({
+ url: getTrpcUrl(),
+ transformer: SuperJSON,
+ headers: createHeadersCallbackForSource("fetch"),
+ }),
+ ],
+});
diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts
new file mode 100644
index 000000000..7cc81cc57
--- /dev/null
+++ b/packages/api/src/env.ts
@@ -0,0 +1,12 @@
+import { z } from "zod/v4";
+
+import { createEnv } from "@homarr/core/infrastructure/env";
+
+export const env = createEnv({
+ server: {
+ KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
+ },
+ runtimeEnv: {
+ KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
+ },
+});
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
new file mode 100644
index 000000000..d339d177f
--- /dev/null
+++ b/packages/api/src/index.ts
@@ -0,0 +1,34 @@
+import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
+
+import { openApiDocument } from "./open-api";
+import type { AppRouter } from "./root";
+import { appRouter } from "./root";
+import { createCallerFactory, createTRPCContext } from "./trpc";
+
+/**
+ * Create a server-side caller for the tRPC API
+ * @example
+ * const trpc = createCaller(createContext);
+ * const res = await trpc.post.all();
+ * ^? Post[]
+ */
+const createCaller = createCallerFactory(appRouter);
+
+/**
+ * Inference helpers for input types
+ * @example
+ * type PostByIdInput = RouterInputs['post']['byId']
+ * ^? { id: number }
+ **/
+type RouterInputs = inferRouterInputs;
+
+/**
+ * Inference helpers for output types
+ * @example
+ * type AllPostsOutput = RouterOutputs['post']['all']
+ * ^? Post[]
+ **/
+type RouterOutputs = inferRouterOutputs;
+
+export { createTRPCContext, appRouter, createCaller, openApiDocument };
+export type { AppRouter, RouterInputs, RouterOutputs };
diff --git a/packages/api/src/middlewares/docker.ts b/packages/api/src/middlewares/docker.ts
new file mode 100644
index 000000000..5a317b95a
--- /dev/null
+++ b/packages/api/src/middlewares/docker.ts
@@ -0,0 +1,17 @@
+import { TRPCError } from "@trpc/server";
+
+import { env } from "@homarr/docker/env";
+
+import { publicProcedure } from "../trpc";
+
+export const dockerMiddleware = () => {
+ return publicProcedure.use(async ({ next }) => {
+ if (env.ENABLE_DOCKER) {
+ return await next();
+ }
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Docker route is not available",
+ });
+ });
+};
diff --git a/packages/api/src/middlewares/integration.ts b/packages/api/src/middlewares/integration.ts
new file mode 100644
index 000000000..320cc7f66
--- /dev/null
+++ b/packages/api/src/middlewares/integration.ts
@@ -0,0 +1,178 @@
+import { TRPCError } from "@trpc/server";
+import { z } from "zod/v4";
+
+import type { Session } from "@homarr/auth";
+import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
+import { constructIntegrationPermissions } from "@homarr/auth/shared";
+import { decryptSecret } from "@homarr/common/server";
+import type { AtLeastOneOf } from "@homarr/common/types";
+import type { Database } from "@homarr/db";
+import { and, eq, inArray } from "@homarr/db";
+import { integrations } from "@homarr/db/schema";
+import type { IntegrationKind } from "@homarr/definitions";
+
+import { publicProcedure } from "../trpc";
+
+export type IntegrationAction = "query" | "interact";
+
+/**
+ * Creates a middleware that provides the integration in the context that is of the specified kinds
+ * @param action query for showing data or interact for mutating data
+ * @param kinds kinds of integrations that are supported
+ * @returns middleware that can be used with trpc
+ * @example publicProcedure.concat(createOneIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...)
+ * @throws TRPCError NOT_FOUND if the integration was not found
+ * @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on the specified integration
+ */
+export const createOneIntegrationMiddleware = (
+ action: IntegrationAction,
+ ...kinds: AtLeastOneOf // Ensure at least one kind is provided
+) => {
+ return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => {
+ const integration = await ctx.db.query.integrations.findFirst({
+ where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)),
+ with: {
+ app: true,
+ secrets: true,
+ groupPermissions: true,
+ userPermissions: true,
+ items: {
+ with: {
+ item: true,
+ },
+ },
+ },
+ });
+
+ if (!integration) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Integration with id ${input.integrationId} not found or not of kinds ${kinds.join(",")}`,
+ });
+ }
+
+ await throwIfActionIsNotAllowedAsync(action, ctx.db, [integration], ctx.session);
+
+ const {
+ secrets,
+ kind,
+ items: _ignore1,
+ groupPermissions: _ignore2,
+ userPermissions: _ignore3,
+ ...rest
+ } = integration;
+
+ return next({
+ ctx: {
+ integration: {
+ ...rest,
+ externalUrl: rest.app?.href ?? null,
+ kind: kind as TKind,
+ decryptedSecrets: secrets.map((secret) => ({
+ ...secret,
+ value: decryptSecret(secret.value),
+ })),
+ },
+ },
+ });
+ });
+};
+
+/**
+ * Creates a middleware that provides the integrations in the context that are of the specified kinds and have the specified item
+ * It also ensures that the user has permission to perform the specified action on the integrations
+ * @param action query for showing data or interact for mutating data
+ * @param kinds kinds of integrations that are supported
+ * @returns middleware that can be used with trpc
+ * @example publicProcedure.concat(createManyIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...)
+ * @throws TRPCError NOT_FOUND if the integration was not found
+ * @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
+ */
+export const createManyIntegrationMiddleware = (
+ action: IntegrationAction,
+ ...kinds: AtLeastOneOf // Ensure at least one kind is provided
+) => {
+ return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => {
+ const dbIntegrations =
+ input.integrationIds.length >= 1
+ ? await ctx.db.query.integrations.findMany({
+ where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
+ with: {
+ app: true,
+ secrets: true,
+ items: {
+ with: {
+ item: true,
+ },
+ },
+ userPermissions: true,
+ groupPermissions: true,
+ },
+ })
+ : [];
+
+ const offset = input.integrationIds.length - dbIntegrations.length;
+ if (offset !== 0) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
+ });
+ }
+
+ if (dbIntegrations.length >= 1) {
+ await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
+ }
+
+ return next({
+ ctx: {
+ integrations: dbIntegrations.map(
+ ({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
+ ...rest,
+ externalUrl: rest.app?.href ?? null,
+ kind: kind as TKind,
+ decryptedSecrets: secrets.map((secret) => ({
+ ...secret,
+ value: decryptSecret(secret.value),
+ })),
+ }),
+ ),
+ },
+ });
+ });
+};
+
+/**
+ * Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
+ * @param action action to perform
+ * @param db db instance
+ * @param integrations integrations to check permissions for
+ * @param session session of the user
+ * @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
+ */
+const throwIfActionIsNotAllowedAsync = async (
+ action: IntegrationAction,
+ db: Database,
+ integrations: Parameters[1],
+ session: Session | null,
+) => {
+ if (action === "interact") {
+ const haveAllInteractAccess = integrations
+ .map((integration) => constructIntegrationPermissions(integration, session))
+ .every(({ hasInteractAccess }) => hasInteractAccess);
+ if (haveAllInteractAccess) return;
+
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User does not have permission to interact with at least one of the specified integrations",
+ });
+ }
+
+ const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
+
+ if (hasQueryAccess) return;
+
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User does not have permission to query at least one of the specified integration",
+ });
+};
diff --git a/packages/api/src/middlewares/item.ts b/packages/api/src/middlewares/item.ts
new file mode 100644
index 000000000..375e21607
--- /dev/null
+++ b/packages/api/src/middlewares/item.ts
@@ -0,0 +1,29 @@
+import { TRPCError } from "@trpc/server";
+import { z } from "zod/v4";
+
+import { and, eq } from "@homarr/db";
+import { items } from "@homarr/db/schema";
+import type { WidgetKind } from "@homarr/definitions";
+
+import { publicProcedure } from "../trpc";
+
+export const createOneItemMiddleware = (kind: WidgetKind) => {
+ return publicProcedure.input(z.object({ itemId: z.string() })).use(async ({ input, ctx, next }) => {
+ const item = await ctx.db.query.items.findFirst({
+ where: and(eq(items.id, input.itemId), eq(items.kind, kind)),
+ });
+
+ if (!item) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Item with id ${input.itemId} not found`,
+ });
+ }
+
+ return next({
+ ctx: {
+ item,
+ },
+ });
+ });
+};
diff --git a/packages/api/src/middlewares/kubernetes.ts b/packages/api/src/middlewares/kubernetes.ts
new file mode 100644
index 000000000..8cefc0c35
--- /dev/null
+++ b/packages/api/src/middlewares/kubernetes.ts
@@ -0,0 +1,17 @@
+import { TRPCError } from "@trpc/server";
+
+import { env } from "@homarr/docker/env";
+
+import { publicProcedure } from "../trpc";
+
+export const kubernetesMiddleware = () => {
+ return publicProcedure.use(async ({ next }) => {
+ if (env.ENABLE_KUBERNETES) {
+ return await next();
+ }
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Kubernetes route is not available",
+ });
+ });
+};
diff --git a/packages/api/src/open-api.ts b/packages/api/src/open-api.ts
new file mode 100644
index 000000000..b4827871c
--- /dev/null
+++ b/packages/api/src/open-api.ts
@@ -0,0 +1,21 @@
+import { generateOpenApiDocument } from "trpc-to-openapi";
+
+import { API_KEY_HEADER_NAME } from "@homarr/auth/api-key";
+
+import { appRouter } from "./root";
+
+export const openApiDocument = (base: string) =>
+ generateOpenApiDocument(appRouter, {
+ title: "Homarr API documentation",
+ version: "1.0.0",
+ baseUrl: base,
+ docsUrl: "https://homarr.dev",
+ securitySchemes: {
+ apikey: {
+ type: "apiKey",
+ name: API_KEY_HEADER_NAME,
+ description: "API key which can be obtained in the Homarr administration dashboard",
+ in: "header",
+ },
+ },
+ });
diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts
new file mode 100644
index 000000000..6efc10ca5
--- /dev/null
+++ b/packages/api/src/root.ts
@@ -0,0 +1,55 @@
+import { apiKeysRouter } from "./router/apiKeys";
+import { appRouter as innerAppRouter } from "./router/app";
+import { boardRouter } from "./router/board";
+import { certificateRouter } from "./router/certificates/certificate-router";
+import { cronJobsRouter } from "./router/cron-jobs";
+import { dockerRouter } from "./router/docker/docker-router";
+import { groupRouter } from "./router/group";
+import { homeRouter } from "./router/home";
+import { iconsRouter } from "./router/icons";
+import { importRouter } from "./router/import/import-router";
+import { infoRouter } from "./router/info";
+import { integrationRouter } from "./router/integration/integration-router";
+import { inviteRouter } from "./router/invite";
+import { kubernetesRouter } from "./router/kubernetes/router/kubernetes-router";
+import { locationRouter } from "./router/location";
+import { logRouter } from "./router/log";
+import { mediaRouter } from "./router/medias/media-router";
+import { onboardRouter } from "./router/onboard/onboard-router";
+import { searchEngineRouter } from "./router/search-engine/search-engine-router";
+import { sectionRouter } from "./router/section/section-router";
+import { serverSettingsRouter } from "./router/serverSettings";
+import { updateCheckerRouter } from "./router/update-checker";
+import { userRouter } from "./router/user";
+import { widgetRouter } from "./router/widgets";
+import { createTRPCRouter } from "./trpc";
+
+export const appRouter = createTRPCRouter({
+ user: userRouter,
+ group: groupRouter,
+ invite: inviteRouter,
+ integration: integrationRouter,
+ board: boardRouter,
+ section: sectionRouter,
+ app: innerAppRouter,
+ searchEngine: searchEngineRouter,
+ widget: widgetRouter,
+ location: locationRouter,
+ log: logRouter,
+ icon: iconsRouter,
+ import: importRouter,
+ onboard: onboardRouter,
+ home: homeRouter,
+ docker: dockerRouter,
+ kubernetes: kubernetesRouter,
+ serverSettings: serverSettingsRouter,
+ cronJobs: cronJobsRouter,
+ apiKeys: apiKeysRouter,
+ media: mediaRouter,
+ updateChecker: updateCheckerRouter,
+ certificates: certificateRouter,
+ info: infoRouter,
+});
+
+// export type definition of API
+export type AppRouter = typeof appRouter;
diff --git a/packages/api/src/router/apiKeys.ts b/packages/api/src/router/apiKeys.ts
new file mode 100644
index 000000000..9c0117085
--- /dev/null
+++ b/packages/api/src/router/apiKeys.ts
@@ -0,0 +1,52 @@
+import { z } from "zod/v4";
+
+import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
+import { createId } from "@homarr/common";
+import { generateSecureRandomToken } from "@homarr/common/server";
+import { db, eq } from "@homarr/db";
+import { apiKeys } from "@homarr/db/schema";
+
+import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
+
+export const apiKeysRouter = createTRPCRouter({
+ getAll: permissionRequiredProcedure.requiresPermission("admin").query(() => {
+ return db.query.apiKeys.findMany({
+ columns: {
+ id: true,
+ apiKey: false,
+ salt: false,
+ },
+ with: {
+ user: {
+ columns: {
+ id: true,
+ name: true,
+ image: true,
+ email: true,
+ },
+ },
+ },
+ });
+ }),
+ create: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => {
+ const salt = await createSaltAsync();
+ const randomToken = generateSecureRandomToken(64);
+ const hashedRandomToken = await hashPasswordAsync(randomToken, salt);
+ const id = createId();
+ await db.insert(apiKeys).values({
+ id,
+ apiKey: hashedRandomToken,
+ salt,
+ userId: ctx.session.user.id,
+ });
+ return {
+ apiKey: `${id}.${randomToken}`,
+ };
+ }),
+ delete: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(z.object({ apiKeyId: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ await ctx.db.delete(apiKeys).where(eq(apiKeys.id, input.apiKeyId)).limit(1);
+ }),
+});
diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts
new file mode 100644
index 000000000..32c607951
--- /dev/null
+++ b/packages/api/src/router/app.ts
@@ -0,0 +1,189 @@
+import { TRPCError } from "@trpc/server";
+import { z } from "zod/v4";
+
+import { createId } from "@homarr/common";
+import { asc, eq, inArray, like } from "@homarr/db";
+import { apps } from "@homarr/db/schema";
+import { selectAppSchema } from "@homarr/db/validationSchemas";
+import { getIconForName } from "@homarr/icons";
+import { appCreateManySchema, appEditSchema, appManageSchema } from "@homarr/validation/app";
+import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
+
+import { convertIntersectionToZodObject } from "../schema-merger";
+import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
+import { canUserSeeAppAsync } from "./app/app-access-control";
+
+const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
+
+export const appRouter = createTRPCRouter({
+ getPaginated: protectedProcedure
+ .input(paginatedSchema)
+ .output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
+ .meta({ openapi: { method: "GET", path: "/api/apps/paginated", tags: ["apps"], protect: true } })
+ .query(async ({ input, ctx }) => {
+ const whereQuery = input.search ? like(apps.name, `%${input.search.trim()}%`) : undefined;
+ const totalCount = await ctx.db.$count(apps, whereQuery);
+
+ const dbApps = await ctx.db.query.apps.findMany({
+ limit: input.pageSize,
+ offset: (input.page - 1) * input.pageSize,
+ where: whereQuery,
+ orderBy: asc(apps.name),
+ });
+
+ return {
+ items: dbApps,
+ totalCount,
+ };
+ }),
+ all: protectedProcedure
+ .input(z.void())
+ .output(z.array(selectAppSchema))
+ .meta({ openapi: { method: "GET", path: "/api/apps", tags: ["apps"], protect: true } })
+ .query(({ ctx }) => {
+ return ctx.db.query.apps.findMany({
+ orderBy: asc(apps.name),
+ });
+ }),
+ search: protectedProcedure
+ .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
+ .output(z.array(selectAppSchema))
+ .meta({ openapi: { method: "GET", path: "/api/apps/search", tags: ["apps"], protect: true } })
+ .query(({ ctx, input }) => {
+ return ctx.db.query.apps.findMany({
+ where: like(apps.name, `%${input.query}%`),
+ orderBy: asc(apps.name),
+ limit: input.limit,
+ });
+ }),
+ selectable: protectedProcedure
+ .input(z.void())
+ .output(
+ z.array(
+ selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, pingUrl: true, description: true }),
+ ),
+ )
+ .meta({
+ openapi: {
+ method: "GET",
+ path: "/api/apps/selectable",
+ tags: ["apps"],
+ protect: true,
+ },
+ })
+ .query(({ ctx }) => {
+ return ctx.db.query.apps.findMany({
+ columns: {
+ id: true,
+ name: true,
+ iconUrl: true,
+ description: true,
+ href: true,
+ pingUrl: true,
+ },
+ orderBy: asc(apps.name),
+ });
+ }),
+ byId: publicProcedure
+ .input(byIdSchema)
+ .output(selectAppSchema)
+ .meta({ openapi: { method: "GET", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
+ .query(async ({ ctx, input }) => {
+ const app = await ctx.db.query.apps.findFirst({
+ where: eq(apps.id, input.id),
+ });
+
+ if (!app) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "App not found",
+ });
+ }
+
+ const canUserSeeApp = await canUserSeeAppAsync(ctx.session?.user ?? null, app.id);
+ if (!canUserSeeApp) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "App not found",
+ });
+ }
+
+ return app;
+ }),
+ byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
+ return await ctx.db.query.apps.findMany({
+ where: inArray(apps.id, input),
+ });
+ }),
+ create: permissionRequiredProcedure
+ .requiresPermission("app-create")
+ .input(appManageSchema)
+ .output(z.object({ appId: z.string() }).and(selectAppSchema))
+ .meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
+ .mutation(async ({ ctx, input }) => {
+ const id = createId();
+ const insertValues = {
+ id,
+ name: input.name,
+ description: input.description,
+ iconUrl: input.iconUrl,
+ href: input.href,
+ pingUrl: input.pingUrl === "" ? null : input.pingUrl,
+ };
+ await ctx.db.insert(apps).values(insertValues);
+
+ // TODO: breaking change necessary for removing appId property
+ return { appId: id, ...insertValues };
+ }),
+ createMany: permissionRequiredProcedure
+ .requiresPermission("app-create")
+ .input(appCreateManySchema)
+ .output(z.void())
+ .mutation(async ({ ctx, input }) => {
+ await ctx.db.insert(apps).values(
+ input.map((app) => ({
+ id: createId(),
+ name: app.name,
+ description: app.description,
+ iconUrl: app.iconUrl ?? getIconForName(ctx.db, app.name).sync()?.url ?? defaultIcon,
+ href: app.href,
+ })),
+ );
+ }),
+ update: permissionRequiredProcedure
+ .requiresPermission("app-modify-all")
+ .input(convertIntersectionToZodObject(appEditSchema))
+ .output(z.void())
+ .meta({ openapi: { method: "PATCH", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
+ .mutation(async ({ ctx, input }) => {
+ const app = await ctx.db.query.apps.findFirst({
+ where: eq(apps.id, input.id),
+ });
+
+ if (!app) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "App not found",
+ });
+ }
+
+ await ctx.db
+ .update(apps)
+ .set({
+ name: input.name,
+ description: input.description,
+ iconUrl: input.iconUrl,
+ href: input.href,
+ pingUrl: input.pingUrl === "" ? null : input.pingUrl,
+ })
+ .where(eq(apps.id, input.id));
+ }),
+ delete: permissionRequiredProcedure
+ .requiresPermission("app-full-all")
+ .output(z.void())
+ .meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
+ .input(byIdSchema)
+ .mutation(async ({ ctx, input }) => {
+ await ctx.db.delete(apps).where(eq(apps.id, input.id));
+ }),
+});
diff --git a/packages/api/src/router/app/app-access-control.ts b/packages/api/src/router/app/app-access-control.ts
new file mode 100644
index 000000000..0f5a0a478
--- /dev/null
+++ b/packages/api/src/router/app/app-access-control.ts
@@ -0,0 +1,45 @@
+import SuperJSON from "superjson";
+
+import type { Session } from "@homarr/auth";
+import { db, eq, or } from "@homarr/db";
+import { items } from "@homarr/db/schema";
+
+import type { WidgetComponentProps } from "../../../../widgets/src";
+
+export const canUserSeeAppAsync = async (user: Session["user"] | null, appId: string) => {
+ return await canUserSeeAppsAsync(user, [appId]);
+};
+
+export const canUserSeeAppsAsync = async (user: Session["user"] | null, appIds: string[]) => {
+ if (user) return true;
+
+ const appIdsOnPublicBoards = await getAllAppIdsOnPublicBoardsAsync();
+ return appIds.every((appId) => appIdsOnPublicBoards.includes(appId));
+};
+
+const getAllAppIdsOnPublicBoardsAsync = async () => {
+ const itemsWithApps = await db.query.items.findMany({
+ where: or(eq(items.kind, "app"), eq(items.kind, "bookmarks")),
+ with: {
+ board: {
+ columns: {
+ isPublic: true,
+ },
+ },
+ },
+ });
+
+ return itemsWithApps
+ .filter((item) => item.board.isPublic)
+ .flatMap((item) => {
+ if (item.kind === "app") {
+ const parsedOptions = SuperJSON.parse["options"]>(item.options);
+ return [parsedOptions.appId];
+ } else if (item.kind === "bookmarks") {
+ const parsedOptions = SuperJSON.parse["options"]>(item.options);
+ return parsedOptions.items;
+ }
+
+ throw new Error("Failed to get app ids from board. Invalid item kind: 'test'");
+ });
+};
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
new file mode 100644
index 000000000..bbb7c0c66
--- /dev/null
+++ b/packages/api/src/router/board.ts
@@ -0,0 +1,1659 @@
+import { TRPCError } from "@trpc/server";
+import superjson from "superjson";
+import { z } from "zod/v4";
+
+import { constructBoardPermissions } from "@homarr/auth/shared";
+import { createId } from "@homarr/common";
+import type { DeviceType } from "@homarr/common/server";
+import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
+import { and, asc, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db";
+import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection";
+import { getServerSettingByKeyAsync } from "@homarr/db/queries";
+import {
+ boardGroupPermissions,
+ boards,
+ boardUserPermissions,
+ groupMembers,
+ groupPermissions,
+ groups,
+ integrationGroupPermissions,
+ integrationItems,
+ integrationUserPermissions,
+ itemLayouts,
+ items,
+ layouts,
+ sectionCollapseStates,
+ sectionLayouts,
+ sections,
+ users,
+} from "@homarr/db/schema";
+import type { WidgetKind } from "@homarr/definitions";
+import {
+ emptySuperJSON,
+ everyoneGroup,
+ getPermissionsWithChildren,
+ getPermissionsWithParents,
+ widgetKinds,
+} from "@homarr/definitions";
+import { importOldmarrAsync } from "@homarr/old-import";
+import { importJsonFileSchema } from "@homarr/old-import/shared";
+import { oldmarrConfigSchema } from "@homarr/old-schema";
+import {
+ boardByNameSchema,
+ boardChangeVisibilitySchema,
+ boardCreateSchema,
+ boardDuplicateSchema,
+ boardRenameSchema,
+ boardSaveLayoutsSchema,
+ boardSavePartialSettingsSchema,
+ boardSavePermissionsSchema,
+ boardSaveSchema,
+} from "@homarr/validation/board";
+import { byIdSchema } from "@homarr/validation/common";
+import { zodUnionFromArray } from "@homarr/validation/enums";
+import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
+import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
+
+import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
+import { throwIfActionForbiddenAsync } from "./board/board-access";
+import { generateResponsiveGridFor } from "./board/grid-algorithm";
+
+export const boardRouter = createTRPCRouter({
+ exists: permissionRequiredProcedure
+ .requiresPermission("board-create")
+ .input(z.string())
+ .query(async ({ ctx, input: name }) => {
+ try {
+ await noBoardWithSimilarNameAsync(ctx.db, name);
+ return false;
+ } catch (error) {
+ if (error instanceof TRPCError && error.code === "CONFLICT") {
+ return true;
+ }
+ throw error;
+ }
+ }),
+ getPublicBoards: publicProcedure.query(async ({ ctx }) => {
+ return await ctx.db.query.boards.findMany({
+ columns: {
+ id: true,
+ name: true,
+ logoImageUrl: true,
+ },
+ where: eq(boards.isPublic, true),
+ });
+ }),
+ getBoardsForGroup: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(z.object({ groupId: z.string() }))
+ .query(async ({ ctx, input }) => {
+ const dbEveryoneAndCurrentGroup = await ctx.db.query.groups.findMany({
+ where: or(eq(groups.name, everyoneGroup), eq(groups.id, input.groupId)),
+ with: {
+ boardPermissions: true,
+ permissions: true,
+ },
+ });
+
+ const distinctPermissions = new Set(
+ dbEveryoneAndCurrentGroup.flatMap((group) => group.permissions.map(({ permission }) => permission)),
+ );
+ const canViewAllBoards = getPermissionsWithChildren([...distinctPermissions]).includes("board-view-all");
+
+ const boardIds = dbEveryoneAndCurrentGroup.flatMap((group) =>
+ group.boardPermissions.map(({ boardId }) => boardId),
+ );
+ const boardWhere = canViewAllBoards ? undefined : or(eq(boards.isPublic, true), inArray(boards.id, boardIds));
+
+ return await ctx.db.query.boards.findMany({
+ columns: {
+ id: true,
+ name: true,
+ logoImageUrl: true,
+ },
+ where: boardWhere,
+ });
+ }),
+ getAllBoards: publicProcedure.query(async ({ ctx }) => {
+ const userId = ctx.session?.user.id;
+ const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
+ where: eq(boardUserPermissions.userId, userId ?? ""),
+ });
+
+ const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
+ where: eq(groupMembers.userId, userId ?? ""),
+ with: {
+ group: {
+ with: {
+ boardPermissions: {},
+ },
+ },
+ },
+ });
+ const boardIds = permissionsOfCurrentUserWhenPresent
+ .map((permission) => permission.boardId)
+ .concat(
+ permissionsOfCurrentUserGroupsWhenPresent
+ .map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
+ .flat(),
+ );
+
+ const currentUserWhenPresent = await ctx.db.query.users.findFirst({
+ where: eq(users.id, userId ?? ""),
+ });
+
+ const dbBoards = await ctx.db.query.boards.findMany({
+ columns: {
+ id: true,
+ name: true,
+ logoImageUrl: true,
+ isPublic: true,
+ },
+ with: {
+ creator: {
+ columns: {
+ id: true,
+ name: true,
+ image: true,
+ email: true,
+ },
+ },
+ userPermissions: {
+ where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
+ },
+ groupPermissions: {
+ where:
+ permissionsOfCurrentUserGroupsWhenPresent.length >= 1
+ ? inArray(
+ boardGroupPermissions.groupId,
+ permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
+ )
+ : undefined,
+ },
+ },
+ // Allow viewing all boards if the user has the permission
+ where: ctx.session?.user.permissions.includes("board-view-all")
+ ? undefined
+ : or(
+ eq(boards.isPublic, true),
+ eq(boards.creatorId, ctx.session?.user.id ?? ""),
+ boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
+ ),
+ });
+ return dbBoards.map((board) => ({
+ ...board,
+ isHome: currentUserWhenPresent?.homeBoardId === board.id,
+ isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
+ }));
+ }),
+ search: publicProcedure
+ .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
+ .query(async ({ ctx, input }) => {
+ const userId = ctx.session?.user.id;
+ const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
+ where: eq(boardUserPermissions.userId, userId ?? ""),
+ });
+
+ const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
+ where: eq(groupMembers.userId, userId ?? ""),
+ with: {
+ group: {
+ with: {
+ boardPermissions: {},
+ },
+ },
+ },
+ });
+ const boardIds = permissionsOfCurrentUserWhenPresent
+ .map((permission) => permission.boardId)
+ .concat(
+ permissionsOfCurrentUserGroupsWhenPresent
+ .map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
+ .flat(),
+ );
+
+ const currentUserWhenPresent = await ctx.db.query.users.findFirst({
+ where: eq(users.id, userId ?? ""),
+ });
+
+ const foundBoards = await ctx.db.query.boards.findMany({
+ where: and(
+ like(boards.name, `%${input.query}%`),
+ ctx.session?.user.permissions.includes("board-view-all")
+ ? undefined
+ : or(
+ eq(boards.isPublic, true),
+ eq(boards.creatorId, ctx.session?.user.id ?? ""),
+ inArray(boards.id, boardIds),
+ ),
+ ),
+ limit: input.limit,
+ columns: {
+ id: true,
+ name: true,
+ creatorId: true,
+ isPublic: true,
+ logoImageUrl: true,
+ },
+ with: {
+ userPermissions: {
+ where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
+ },
+ groupPermissions: {
+ where:
+ permissionsOfCurrentUserGroupsWhenPresent.length >= 1
+ ? inArray(
+ boardGroupPermissions.groupId,
+ permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
+ )
+ : undefined,
+ },
+ },
+ });
+
+ return foundBoards.map((board) => ({
+ id: board.id,
+ name: board.name,
+ logoImageUrl: board.logoImageUrl,
+ permissions: constructBoardPermissions(board, ctx.session),
+ isHome: currentUserWhenPresent?.homeBoardId === board.id,
+ isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
+ }));
+ }),
+ createBoard: permissionRequiredProcedure
+ .requiresPermission("board-create")
+ .input(boardCreateSchema)
+ .mutation(async ({ ctx, input }) => {
+ const boardId = createId();
+
+ const user = await ctx.db.query.users.findFirst({
+ where: eq(users.id, ctx.session.user.id),
+ columns: {
+ homeBoardId: true,
+ },
+ });
+
+ const createBoardCollection = createDbInsertCollectionWithoutTransaction(["boards", "sections", "layouts"]);
+
+ createBoardCollection.boards.push({
+ id: boardId,
+ name: input.name,
+ isPublic: input.isPublic,
+ creatorId: ctx.session.user.id,
+ });
+ createBoardCollection.sections.push({
+ id: createId(),
+ kind: "empty",
+ xOffset: 0,
+ yOffset: 0,
+ boardId,
+ });
+ createBoardCollection.layouts.push({
+ id: createId(),
+ name: "Base",
+ columnCount: input.columnCount,
+ breakpoint: 0,
+ boardId,
+ });
+
+ await createBoardCollection.insertAllAsync(ctx.db);
+
+ if (!user?.homeBoardId) {
+ await ctx.db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, ctx.session.user.id));
+ }
+
+ return { boardId };
+ }),
+ duplicateBoard: permissionRequiredProcedure
+ .requiresPermission("board-create")
+ .input(boardDuplicateSchema)
+ .mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
+ await noBoardWithSimilarNameAsync(ctx.db, input.name);
+
+ const board = await ctx.db.query.boards.findFirst({
+ where: eq(boards.id, input.id),
+ with: {
+ layouts: true,
+ sections: {
+ with: {
+ collapseStates: true,
+ layouts: true,
+ },
+ },
+ items: {
+ with: {
+ layouts: true,
+ integrations: true,
+ },
+ },
+ },
+ });
+
+ if (!board) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Board not found",
+ });
+ }
+
+ const { sections: boardSections, items: boardItems, layouts: boardLayouts, ...boardProps } = board;
+
+ const newBoardId = createId();
+
+ const layoutsMap = new Map(boardLayouts.map((layout) => [layout.id, createId()]));
+ const layoutsToInsert = boardLayouts.map((layout) => ({
+ ...layout,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ id: layoutsMap.get(layout.id)!,
+ boardId: newBoardId,
+ }));
+
+ const sectionMap = new Map(boardSections.map((section) => [section.id, createId()]));
+ const sectionsToInsert: InferInsertModel[] = boardSections.map(
+ ({ collapseStates: _, layouts: _layouts, ...section }) => ({
+ ...section,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ id: sectionMap.get(section.id)!,
+ boardId: newBoardId,
+ }),
+ );
+
+ const sectionLayoutsToInsert: InferInsertModel[] = boardSections.flatMap((section) =>
+ section.layouts.map(
+ (layoutSection): InferInsertModel => ({
+ ...layoutSection,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ layoutId: layoutsMap.get(layoutSection.layoutId)!,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ sectionId: sectionMap.get(layoutSection.sectionId)!,
+ parentSectionId: layoutSection.parentSectionId
+ ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ sectionMap.get(layoutSection.parentSectionId)!
+ : layoutSection.parentSectionId,
+ }),
+ ),
+ );
+ const sectionCollapseStatesToInsert: InferInsertModel[] = boardSections.flatMap(
+ (section) =>
+ section.collapseStates.map(
+ (collapseState): InferInsertModel => ({
+ ...collapseState,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ sectionId: sectionMap.get(collapseState.sectionId)!,
+ }),
+ ),
+ );
+
+ const itemMap = new Map(boardItems.map((item) => [item.id, createId()]));
+ const itemsToInsert: InferInsertModel[] = boardItems.map(
+ ({ integrations: _, layouts: _layouts, ...item }) => ({
+ ...item,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ id: itemMap.get(item.id)!,
+ boardId: newBoardId,
+ }),
+ );
+
+ const itemLayoutsToInsert: InferInsertModel[] = boardItems.flatMap((item) =>
+ item.layouts.map(
+ (layoutSection): InferInsertModel => ({
+ ...layoutSection,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ sectionId: sectionMap.get(layoutSection.sectionId)!,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ itemId: itemMap.get(layoutSection.itemId)!,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ layoutId: layoutsMap.get(layoutSection.layoutId)!,
+ }),
+ ),
+ );
+
+ // Creates a list with all integration ids the user has access to
+ const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
+ const integrationIdsWithAccess = hasAccessForAll
+ ? []
+ : await ctx.db
+ .selectDistinct({
+ id: integrationGroupPermissions.integrationId,
+ })
+ .from(integrationGroupPermissions)
+ .leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
+ .where(eq(groupMembers.userId, ctx.session.user.id))
+ .union(
+ ctx.db
+ .selectDistinct({ id: integrationUserPermissions.integrationId })
+ .from(integrationUserPermissions)
+ .where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
+ )
+ .then((result) => result.map((row) => row.id));
+
+ const itemIntegrationsToInsert = boardItems.flatMap((item) =>
+ item.integrations
+ // Restrict integrations to only those the user has access to
+ .filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll)
+ .map((integration) => ({
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ itemId: itemMap.get(item.id)!,
+ integrationId: integration.integrationId,
+ })),
+ );
+
+ await handleTransactionsAsync(ctx.db, {
+ async handleAsync(db, schema) {
+ await db.transaction(async (transaction) => {
+ transaction.insert(schema.boards).values({
+ ...boardProps,
+ id: newBoardId,
+ name: input.name,
+ creatorId: ctx.session.user.id,
+ });
+
+ if (layoutsToInsert.length > 0) {
+ await transaction.insert(schema.layouts).values(layoutsToInsert);
+ }
+
+ if (sectionsToInsert.length > 0) {
+ await transaction.insert(schema.sections).values(sectionsToInsert);
+ }
+
+ if (sectionLayoutsToInsert.length > 0) {
+ await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert);
+ }
+
+ if (sectionCollapseStatesToInsert.length > 0) {
+ await transaction.insert(schema.sectionCollapseStates).values(sectionCollapseStatesToInsert);
+ }
+
+ if (itemsToInsert.length > 0) {
+ await transaction.insert(schema.items).values(itemsToInsert);
+ }
+
+ if (itemLayoutsToInsert.length > 0) {
+ await transaction.insert(schema.itemLayouts).values(itemLayoutsToInsert);
+ }
+
+ if (itemIntegrationsToInsert.length > 0) {
+ await transaction.insert(schema.integrationItems).values(itemIntegrationsToInsert);
+ }
+ });
+ },
+ handleSync(db) {
+ db.transaction((transaction) => {
+ transaction
+ .insert(boards)
+ .values({
+ ...boardProps,
+ id: newBoardId,
+ name: input.name,
+ creatorId: ctx.session.user.id,
+ })
+ .run();
+
+ if (layoutsToInsert.length > 0) {
+ transaction.insert(layouts).values(layoutsToInsert).run();
+ }
+
+ if (sectionsToInsert.length > 0) {
+ transaction.insert(sections).values(sectionsToInsert).run();
+ }
+
+ if (sectionLayoutsToInsert.length > 0) {
+ transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
+ }
+
+ if (sectionCollapseStatesToInsert.length > 0) {
+ transaction.insert(sectionCollapseStates).values(sectionCollapseStatesToInsert).run();
+ }
+
+ if (itemsToInsert.length > 0) {
+ transaction.insert(items).values(itemsToInsert).run();
+ }
+
+ if (itemLayoutsToInsert.length > 0) {
+ transaction.insert(itemLayouts).values(itemLayoutsToInsert).run();
+ }
+
+ if (itemIntegrationsToInsert.length > 0) {
+ transaction.insert(integrationItems).values(itemIntegrationsToInsert).run();
+ }
+ });
+ },
+ });
+ }),
+ renameBoard: protectedProcedure.input(boardRenameSchema).mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
+
+ await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
+
+ await ctx.db.update(boards).set({ name: input.name }).where(eq(boards.id, input.id));
+ }),
+ changeBoardVisibility: protectedProcedure.input(boardChangeVisibilitySchema).mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
+ const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
+
+ if (
+ input.visibility !== "public" &&
+ (boardSettings.homeBoardId === input.id || boardSettings.mobileHomeBoardId === input.id)
+ ) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Cannot make home board private",
+ });
+ }
+
+ await ctx.db
+ .update(boards)
+ .set({ isPublic: input.visibility === "public" })
+ .where(eq(boards.id, input.id));
+ }),
+ deleteBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
+
+ await ctx.db.delete(boards).where(eq(boards.id, input.id));
+ }),
+ setHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
+
+ await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
+ }),
+ setMobileHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
+
+ await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
+ }),
+ getHomeBoard: publicProcedure.query(async ({ ctx }) => {
+ const userId = ctx.session?.user.id;
+ const user = userId
+ ? ((await ctx.db.query.users.findFirst({
+ where: eq(users.id, userId),
+ })) ?? null)
+ : null;
+
+ const homeBoardId = await getHomeIdBoardAsync(ctx.db, user, ctx.deviceType);
+
+ if (!homeBoardId) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "No home board found",
+ });
+ }
+
+ const boardWhere = eq(boards.id, homeBoardId);
+
+ await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
+
+ return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
+ }),
+ getBoardByName: publicProcedure.input(boardByNameSchema).query(async ({ input, ctx }) => {
+ const boardWhere = eq(sql`UPPER(${boards.name})`, input.name.toUpperCase());
+ await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
+
+ return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
+ }),
+ saveLayouts: protectedProcedure.input(boardSaveLayoutsSchema).mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
+
+ const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
+
+ const addedLayouts = filterAddedItems(input.layouts, board.layouts);
+
+ const layoutsToInsert: InferInsertModel[] = [];
+ const itemSectionLayoutsToInsert: InferInsertModel[] = [];
+ const sectionLayoutsToInsert: InferInsertModel[] = [];
+
+ for (const addedLayout of addedLayouts) {
+ const layoutId = createId();
+
+ layoutsToInsert.push({
+ id: layoutId,
+ name: addedLayout.name,
+ columnCount: addedLayout.columnCount,
+ breakpoint: addedLayout.breakpoint,
+ boardId: board.id,
+ });
+
+ const sortedLayouts = board.layouts.sort((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
+ // Fallback to biggest if none exists with columnCount bigger than addedLayout.columnCount
+ const layoutToClone =
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ sortedLayouts.find((layout) => layout.columnCount >= addedLayout.columnCount) ?? sortedLayouts.at(-1)!;
+
+ const updatedBoardLayout = getUpdatedBoardLayout(board, {
+ previous: {
+ layoutId: layoutToClone.id,
+ columnCount: layoutToClone.columnCount,
+ },
+ current: {
+ layoutId,
+ columnCount: addedLayout.columnCount,
+ },
+ });
+
+ itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts);
+ sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts);
+ }
+
+ if (layoutsToInsert.length > 0) {
+ await ctx.db.insert(layouts).values(layoutsToInsert);
+ }
+
+ if (itemSectionLayoutsToInsert.length > 0) {
+ await ctx.db.insert(itemLayouts).values(itemSectionLayoutsToInsert);
+ }
+
+ if (sectionLayoutsToInsert.length > 0) {
+ await ctx.db.insert(sectionLayouts).values(sectionLayoutsToInsert);
+ }
+
+ const updatedLayouts = filterUpdatedItems(input.layouts, board.layouts);
+ for (const updatedLayout of updatedLayouts) {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const dbLayout = board.layouts.find((layout) => layout.id === updatedLayout.id)!;
+
+ if (dbLayout.columnCount !== updatedLayout.columnCount) {
+ const updatedBoardLayout = getUpdatedBoardLayout(board, {
+ previous: {
+ layoutId: dbLayout.id,
+ columnCount: dbLayout.columnCount,
+ },
+ current: {
+ layoutId: dbLayout.id,
+ columnCount: updatedLayout.columnCount,
+ },
+ });
+
+ for (const itemSectionLayout of updatedBoardLayout.itemSectionLayouts) {
+ await ctx.db
+ .update(itemLayouts)
+ .set({
+ height: itemSectionLayout.height,
+ width: itemSectionLayout.width,
+ xOffset: itemSectionLayout.xOffset,
+ yOffset: itemSectionLayout.yOffset,
+ sectionId: itemSectionLayout.sectionId,
+ })
+ .where(
+ and(
+ eq(itemLayouts.itemId, itemSectionLayout.itemId),
+ eq(itemLayouts.layoutId, itemSectionLayout.layoutId),
+ ),
+ );
+ }
+
+ for (const sectionLayout of updatedBoardLayout.sectionLayouts) {
+ await ctx.db
+ .update(sectionLayouts)
+ .set({
+ height: sectionLayout.height,
+ width: sectionLayout.width,
+ xOffset: sectionLayout.xOffset,
+ yOffset: sectionLayout.yOffset,
+ parentSectionId: sectionLayout.parentSectionId,
+ })
+ .where(
+ and(
+ eq(sectionLayouts.sectionId, sectionLayout.sectionId),
+ eq(sectionLayouts.layoutId, sectionLayout.layoutId),
+ ),
+ );
+ }
+ }
+
+ await ctx.db
+ .update(layouts)
+ .set({
+ name: updatedLayout.name,
+ columnCount: updatedLayout.columnCount,
+ breakpoint: updatedLayout.breakpoint,
+ })
+ .where(eq(layouts.id, updatedLayout.id));
+ }
+
+ const removedLayouts = filterRemovedItems(input.layouts, board.layouts);
+ const removedLayoutIds = removedLayouts.map((layout) => layout.id);
+ if (removedLayoutIds.length > 0) {
+ await ctx.db.delete(layouts).where(inArray(layouts.id, removedLayoutIds));
+ }
+ }),
+ savePartialBoardSettings: protectedProcedure
+ .input(boardSavePartialSettingsSchema.and(z.object({ id: z.string() })))
+ .mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
+
+ await ctx.db
+ .update(boards)
+ .set({
+ // general settings
+ pageTitle: input.pageTitle,
+ metaTitle: input.metaTitle,
+ logoImageUrl: input.logoImageUrl,
+ faviconImageUrl: input.faviconImageUrl,
+
+ // background settings
+ backgroundImageUrl: input.backgroundImageUrl,
+ backgroundImageAttachment: input.backgroundImageAttachment,
+ backgroundImageRepeat: input.backgroundImageRepeat,
+ backgroundImageSize: input.backgroundImageSize,
+
+ // appearance settings
+ primaryColor: input.primaryColor,
+ secondaryColor: input.secondaryColor,
+ opacity: input.opacity,
+ iconColor: input.iconColor,
+ itemRadius: input.itemRadius,
+
+ // custom css
+ customCss: input.customCss,
+
+ // Behavior settings
+ disableStatus: input.disableStatus,
+ })
+ .where(eq(boards.id, input.id));
+ }),
+ saveBoard: protectedProcedure.input(boardSaveSchema).mutation(async ({ input, ctx }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
+
+ const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
+
+ await handleTransactionsAsync(ctx.db, {
+ async handleAsync(db, schema) {
+ await db.transaction(async (transaction) => {
+ const addedSections = filterAddedItems(input.sections, dbBoard.sections);
+
+ if (addedSections.length > 0) {
+ await transaction.insert(schema.sections).values(
+ addedSections.map((section) => ({
+ id: section.id,
+ kind: section.kind,
+ yOffset: section.kind !== "dynamic" ? section.yOffset : null,
+ xOffset: section.kind === "dynamic" ? null : 0,
+ options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
+ name: "name" in section ? section.name : null,
+ boardId: dbBoard.id,
+ })),
+ );
+
+ if (addedSections.some((section) => section.kind === "dynamic")) {
+ await transaction.insert(schema.sectionLayouts).values(
+ addedSections
+ .filter((section) => section.kind === "dynamic")
+ .flatMap((section) =>
+ section.layouts.map(
+ (sectionLayout): InferInsertModel => ({
+ layoutId: sectionLayout.layoutId,
+ sectionId: section.id,
+ parentSectionId: sectionLayout.parentSectionId,
+ height: sectionLayout.height,
+ width: sectionLayout.width,
+ xOffset: sectionLayout.xOffset,
+ yOffset: sectionLayout.yOffset,
+ }),
+ ),
+ ),
+ );
+ }
+ }
+
+ const addedItems = filterAddedItems(input.items, dbBoard.items);
+
+ if (addedItems.length > 0) {
+ await transaction.insert(schema.items).values(
+ addedItems.map((item) => ({
+ id: item.id,
+ kind: item.kind,
+ options: superjson.stringify(item.options),
+ advancedOptions: superjson.stringify(item.advancedOptions),
+ boardId: dbBoard.id,
+ })),
+ );
+ await transaction.insert(schema.itemLayouts).values(
+ addedItems.flatMap((item) =>
+ item.layouts.map(
+ (layoutSection): InferInsertModel => ({
+ layoutId: layoutSection.layoutId,
+ sectionId: layoutSection.sectionId,
+ itemId: item.id,
+ height: layoutSection.height,
+ width: layoutSection.width,
+ xOffset: layoutSection.xOffset,
+ yOffset: layoutSection.yOffset,
+ }),
+ ),
+ ),
+ );
+ }
+
+ const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
+ integrationIds.map((integrationId) => ({
+ integrationId,
+ itemId,
+ })),
+ );
+ const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
+ integrationIds.map((integrationId) => ({
+ integrationId,
+ itemId,
+ })),
+ );
+ const addedIntegrationRelations = inputIntegrationRelations.filter(
+ (inputRelation) =>
+ !dbIntegrationRelations.some(
+ (dbRelation) =>
+ dbRelation.itemId === inputRelation.itemId &&
+ dbRelation.integrationId === inputRelation.integrationId,
+ ),
+ );
+
+ if (addedIntegrationRelations.length > 0) {
+ await transaction.insert(schema.integrationItems).values(
+ addedIntegrationRelations.map((relation) => ({
+ itemId: relation.itemId,
+ integrationId: relation.integrationId,
+ })),
+ );
+ }
+
+ const updatedItems = filterUpdatedItems(input.items, dbBoard.items);
+
+ for (const item of updatedItems) {
+ await transaction
+ .update(schema.items)
+ .set({
+ kind: item.kind,
+ options: superjson.stringify(item.options),
+ advancedOptions: superjson.stringify(item.advancedOptions),
+ })
+ .where(eq(schema.items.id, item.id));
+
+ for (const itemSectionLayout of item.layouts) {
+ await transaction
+ .update(schema.itemLayouts)
+ .set({
+ height: itemSectionLayout.height,
+ width: itemSectionLayout.width,
+ xOffset: itemSectionLayout.xOffset,
+ yOffset: itemSectionLayout.yOffset,
+ sectionId: itemSectionLayout.sectionId,
+ })
+ .where(
+ and(
+ eq(schema.itemLayouts.itemId, item.id),
+ eq(schema.itemLayouts.layoutId, itemSectionLayout.layoutId),
+ ),
+ );
+ }
+ }
+
+ const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
+
+ for (const section of updatedSections) {
+ const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
+ await transaction
+ .update(schema.sections)
+ .set({
+ yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
+ xOffset: prev?.kind !== "dynamic" && "yOffset" in section ? 0 : null,
+ options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
+ name: prev?.kind === "category" && "name" in section ? section.name : null,
+ })
+ .where(eq(schema.sections.id, section.id));
+
+ if (section.kind !== "dynamic") continue;
+
+ for (const sectionLayout of section.layouts) {
+ await transaction
+ .update(schema.sectionLayouts)
+ .set({
+ height: sectionLayout.height,
+ width: sectionLayout.width,
+ xOffset: sectionLayout.xOffset,
+ yOffset: sectionLayout.yOffset,
+ parentSectionId: sectionLayout.parentSectionId,
+ })
+ .where(
+ and(
+ eq(schema.sectionLayouts.sectionId, section.id),
+ eq(schema.sectionLayouts.layoutId, sectionLayout.layoutId),
+ ),
+ );
+ }
+ }
+
+ const removedIntegrationRelations = dbIntegrationRelations.filter(
+ (dbRelation) =>
+ !inputIntegrationRelations.some(
+ (inputRelation) =>
+ dbRelation.itemId === inputRelation.itemId &&
+ dbRelation.integrationId === inputRelation.integrationId,
+ ),
+ );
+
+ for (const relation of removedIntegrationRelations) {
+ await transaction
+ .delete(schema.integrationItems)
+ .where(
+ and(
+ eq(integrationItems.itemId, relation.itemId),
+ eq(integrationItems.integrationId, relation.integrationId),
+ ),
+ );
+ }
+
+ const removedItems = filterRemovedItems(input.items, dbBoard.items);
+
+ const itemIds = removedItems.map((item) => item.id);
+ if (itemIds.length > 0) {
+ await transaction.delete(schema.items).where(inArray(schema.items.id, itemIds));
+ }
+
+ const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
+ const sectionIds = removedSections.map((section) => section.id);
+
+ if (sectionIds.length > 0) {
+ await transaction.delete(schema.sections).where(inArray(schema.sections.id, sectionIds));
+ }
+ });
+ },
+ handleSync(db) {
+ db.transaction((transaction) => {
+ const addedSections = filterAddedItems(input.sections, dbBoard.sections);
+
+ if (addedSections.length > 0) {
+ transaction
+ .insert(sections)
+ .values(
+ addedSections.map((section) => ({
+ id: section.id,
+ kind: section.kind,
+ yOffset: section.kind !== "dynamic" ? section.yOffset : null,
+ xOffset: section.kind === "dynamic" ? null : 0,
+ options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
+ name: "name" in section ? section.name : null,
+ boardId: dbBoard.id,
+ })),
+ )
+ .run();
+
+ if (addedSections.some((section) => section.kind === "dynamic")) {
+ transaction
+ .insert(sectionLayouts)
+ .values(
+ addedSections
+ .filter((section) => section.kind === "dynamic")
+ .flatMap((section) =>
+ section.layouts.map(
+ (sectionLayout): InferInsertModel => ({
+ layoutId: sectionLayout.layoutId,
+ sectionId: section.id,
+ parentSectionId: sectionLayout.parentSectionId,
+ height: sectionLayout.height,
+ width: sectionLayout.width,
+ xOffset: sectionLayout.xOffset,
+ yOffset: sectionLayout.yOffset,
+ }),
+ ),
+ ),
+ )
+ .run();
+ }
+ }
+
+ const addedItems = filterAddedItems(input.items, dbBoard.items);
+
+ if (addedItems.length > 0) {
+ transaction
+ .insert(items)
+ .values(
+ addedItems.map((item) => ({
+ id: item.id,
+ kind: item.kind,
+ options: superjson.stringify(item.options),
+ advancedOptions: superjson.stringify(item.advancedOptions),
+ boardId: dbBoard.id,
+ })),
+ )
+ .run();
+ transaction
+ .insert(itemLayouts)
+ .values(
+ addedItems.flatMap((item) =>
+ item.layouts.map(
+ (layoutSection): InferInsertModel => ({
+ layoutId: layoutSection.layoutId,
+ sectionId: layoutSection.sectionId,
+ itemId: item.id,
+ height: layoutSection.height,
+ width: layoutSection.width,
+ xOffset: layoutSection.xOffset,
+ yOffset: layoutSection.yOffset,
+ }),
+ ),
+ ),
+ )
+ .run();
+ }
+
+ const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
+ integrationIds.map((integrationId) => ({
+ integrationId,
+ itemId,
+ })),
+ );
+ const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
+ integrationIds.map((integrationId) => ({
+ integrationId,
+ itemId,
+ })),
+ );
+ const addedIntegrationRelations = inputIntegrationRelations.filter(
+ (inputRelation) =>
+ !dbIntegrationRelations.some(
+ (dbRelation) =>
+ dbRelation.itemId === inputRelation.itemId &&
+ dbRelation.integrationId === inputRelation.integrationId,
+ ),
+ );
+
+ if (addedIntegrationRelations.length > 0) {
+ transaction
+ .insert(integrationItems)
+ .values(
+ addedIntegrationRelations.map((relation) => ({
+ itemId: relation.itemId,
+ integrationId: relation.integrationId,
+ })),
+ )
+ .run();
+ }
+
+ const updatedItems = filterUpdatedItems(input.items, dbBoard.items);
+
+ for (const item of updatedItems) {
+ transaction
+ .update(items)
+ .set({
+ kind: item.kind,
+ options: superjson.stringify(item.options),
+ advancedOptions: superjson.stringify(item.advancedOptions),
+ })
+ .where(eq(items.id, item.id))
+ .run();
+
+ for (const itemSectionLayout of item.layouts) {
+ transaction
+ .update(itemLayouts)
+ .set({
+ height: itemSectionLayout.height,
+ width: itemSectionLayout.width,
+ xOffset: itemSectionLayout.xOffset,
+ yOffset: itemSectionLayout.yOffset,
+ sectionId: itemSectionLayout.sectionId,
+ })
+ .where(and(eq(itemLayouts.itemId, item.id), eq(itemLayouts.layoutId, itemSectionLayout.layoutId)))
+ .run();
+ }
+ }
+
+ const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
+
+ for (const section of updatedSections) {
+ const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
+ transaction
+ .update(sections)
+ .set({
+ yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
+ xOffset: prev?.kind !== "dynamic" && "yOffset" in section ? 0 : null,
+ options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
+ name: prev?.kind === "category" && "name" in section ? section.name : null,
+ })
+ .where(eq(sections.id, section.id))
+ .run();
+
+ if (section.kind !== "dynamic") continue;
+
+ for (const sectionLayout of section.layouts) {
+ transaction
+ .update(sectionLayouts)
+ .set({
+ height: sectionLayout.height,
+ width: sectionLayout.width,
+ xOffset: sectionLayout.xOffset,
+ yOffset: sectionLayout.yOffset,
+ parentSectionId: sectionLayout.parentSectionId,
+ })
+ .where(
+ and(eq(sectionLayouts.sectionId, section.id), eq(sectionLayouts.layoutId, sectionLayout.layoutId)),
+ )
+ .run();
+ }
+ }
+
+ const removedIntegrationRelations = dbIntegrationRelations.filter(
+ (dbRelation) =>
+ !inputIntegrationRelations.some(
+ (inputRelation) =>
+ dbRelation.itemId === inputRelation.itemId &&
+ dbRelation.integrationId === inputRelation.integrationId,
+ ),
+ );
+
+ for (const relation of removedIntegrationRelations) {
+ transaction
+ .delete(integrationItems)
+ .where(
+ and(
+ eq(integrationItems.itemId, relation.itemId),
+ eq(integrationItems.integrationId, relation.integrationId),
+ ),
+ )
+ .run();
+ }
+
+ const removedItems = filterRemovedItems(input.items, dbBoard.items);
+
+ const itemIds = removedItems.map((item) => item.id);
+ if (itemIds.length > 0) {
+ transaction.delete(items).where(inArray(items.id, itemIds)).run();
+ }
+
+ const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
+ const sectionIds = removedSections.map((section) => section.id);
+
+ if (sectionIds.length > 0) {
+ transaction.delete(sections).where(inArray(sections.id, sectionIds)).run();
+ }
+ });
+ },
+ });
+ }),
+ getBoardPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
+
+ const dbGroupPermissions = await ctx.db.query.groupPermissions.findMany({
+ where: inArray(
+ groupPermissions.permission,
+ getPermissionsWithParents(["board-view-all", "board-modify-all", "board-full-all"]),
+ ),
+ columns: {
+ groupId: false,
+ },
+ with: {
+ group: {
+ columns: {
+ id: true,
+ name: true,
+ },
+ },
+ },
+ });
+
+ const userPermissions = await ctx.db.query.boardUserPermissions.findMany({
+ where: eq(boardUserPermissions.boardId, input.id),
+ with: {
+ user: {
+ columns: {
+ id: true,
+ name: true,
+ image: true,
+ email: true,
+ },
+ },
+ },
+ });
+
+ const dbGroupBoardPermission = await ctx.db.query.boardGroupPermissions.findMany({
+ where: eq(boardGroupPermissions.boardId, input.id),
+ with: {
+ group: {
+ columns: {
+ id: true,
+ name: true,
+ },
+ },
+ },
+ });
+
+ return {
+ inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
+ return permissionA.group.name.localeCompare(permissionB.group.name);
+ }),
+ users: userPermissions
+ .map(({ user, permission }) => ({
+ user,
+ permission,
+ }))
+ .sort((permissionA, permissionB) => {
+ return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
+ }),
+ groups: dbGroupBoardPermission
+ .map(({ group, permission }) => ({
+ group: {
+ id: group.id,
+ name: group.name,
+ },
+ permission,
+ }))
+ .sort((permissionA, permissionB) => {
+ return permissionA.group.name.localeCompare(permissionB.group.name);
+ }),
+ };
+ }),
+ saveUserBoardPermissions: protectedProcedure.input(boardSavePermissionsSchema).mutation(async ({ input, ctx }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full");
+
+ await handleTransactionsAsync(ctx.db, {
+ async handleAsync(db, schema) {
+ await db.transaction(async (transaction) => {
+ await transaction.delete(schema.boardUserPermissions).where(eq(boardUserPermissions.boardId, input.entityId));
+ if (input.permissions.length === 0) {
+ return;
+ }
+ await transaction.insert(schema.boardUserPermissions).values(
+ input.permissions.map((permission) => ({
+ userId: permission.principalId,
+ permission: permission.permission,
+ boardId: input.entityId,
+ })),
+ );
+ });
+ },
+ handleSync(db) {
+ db.transaction((transaction) => {
+ transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.entityId)).run();
+ if (input.permissions.length === 0) {
+ return;
+ }
+ transaction
+ .insert(boardUserPermissions)
+ .values(
+ input.permissions.map((permission) => ({
+ userId: permission.principalId,
+ permission: permission.permission,
+ boardId: input.entityId,
+ })),
+ )
+ .run();
+ });
+ },
+ });
+ }),
+ saveGroupBoardPermissions: protectedProcedure.input(boardSavePermissionsSchema).mutation(async ({ input, ctx }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full");
+
+ await handleTransactionsAsync(ctx.db, {
+ async handleAsync(db, schema) {
+ await db.transaction(async (transaction) => {
+ await transaction
+ .delete(schema.boardGroupPermissions)
+ .where(eq(boardGroupPermissions.boardId, input.entityId));
+ if (input.permissions.length === 0) {
+ return;
+ }
+ await transaction.insert(schema.boardGroupPermissions).values(
+ input.permissions.map((permission) => ({
+ groupId: permission.principalId,
+ permission: permission.permission,
+ boardId: input.entityId,
+ })),
+ );
+ });
+ },
+ handleSync(db) {
+ db.transaction((transaction) => {
+ transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.entityId)).run();
+ if (input.permissions.length === 0) {
+ return;
+ }
+ transaction
+ .insert(boardGroupPermissions)
+ .values(
+ input.permissions.map((permission) => ({
+ groupId: permission.principalId,
+ permission: permission.permission,
+ boardId: input.entityId,
+ })),
+ )
+ .run();
+ });
+ },
+ });
+ }),
+ importOldmarrConfig: permissionRequiredProcedure
+ .requiresPermission("board-create")
+ .input(importJsonFileSchema)
+ .mutation(async ({ input, ctx }) => {
+ const content = await input.file.text();
+ const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
+ await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
+ }),
+});
+
+/**
+ * Get the home board id of the user with the given device type
+ * For an example of a user with deviceType = 'mobile' it would go through the following order:
+ * 1. user.mobileHomeBoardId
+ * 2. user.homeBoardId
+ * 3. group.mobileHomeBoardId of the lowest positions group
+ * 4. group.homeBoardId of the lowest positions group
+ * 5. everyoneGroup.mobileHomeBoardId
+ * 6. everyoneGroup.homeBoardId
+ * 7. serverSettings.mobileHomeBoardId
+ * 8. serverSettings.homeBoardId
+ * 9. show NOT_FOUND error
+ */
+const getHomeIdBoardAsync = async (
+ db: Database,
+ user: InferSelectModel | null,
+ deviceType: DeviceType,
+) => {
+ const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
+
+ if (!user) {
+ const boardSettings = await getServerSettingByKeyAsync(db, "board");
+ return boardSettings[settingKey] ?? boardSettings.homeBoardId;
+ }
+
+ if (user[settingKey]) return user[settingKey];
+ if (user.homeBoardId) return user.homeBoardId;
+
+ const lowestGroupExceptEveryone = await db
+ .select({
+ homeBoardId: groups.homeBoardId,
+ mobileHomeBoardId: groups.mobileHomeBoardId,
+ })
+ .from(groups)
+ .leftJoin(groupMembers, eq(groups.id, groupMembers.groupId))
+ .where(
+ and(
+ eq(groupMembers.userId, user.id),
+ not(eq(groups.name, everyoneGroup)),
+ not(isNull(groups[settingKey])),
+ not(isNull(groups.homeBoardId)),
+ ),
+ )
+ .orderBy(asc(groups.position))
+ .limit(1)
+ .then((result) => result[0]);
+
+ if (lowestGroupExceptEveryone?.[settingKey]) return lowestGroupExceptEveryone[settingKey];
+ if (lowestGroupExceptEveryone?.homeBoardId) return lowestGroupExceptEveryone.homeBoardId;
+
+ const dbEveryoneGroup = await db.query.groups.findFirst({
+ where: eq(groups.name, everyoneGroup),
+ });
+
+ if (dbEveryoneGroup?.[settingKey]) return dbEveryoneGroup[settingKey];
+ if (dbEveryoneGroup?.homeBoardId) return dbEveryoneGroup.homeBoardId;
+
+ const boardSettings = await getServerSettingByKeyAsync(db, "board");
+ return boardSettings[settingKey] ?? boardSettings.homeBoardId;
+};
+
+const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {
+ const boards = await db.query.boards.findMany({
+ columns: {
+ id: true,
+ name: true,
+ },
+ });
+
+ const board = boards.find(
+ (board) => board.name.toLowerCase() === name.toLowerCase() && !ignoredIds.includes(board.id),
+ );
+
+ if (board) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "Board with similar name already exists",
+ });
+ }
+};
+
+const getUpdatedBoardLayout = (
+ board: Awaited>,
+ options: {
+ previous: {
+ layoutId: string;
+ columnCount: number;
+ };
+ current: {
+ layoutId: string;
+ columnCount: number;
+ };
+ },
+) => {
+ const itemSectionLayoutsCollection: InferInsertModel[] = [];
+ const sectionLayoutsCollection: InferInsertModel[] = [];
+
+ const elements = getElementsForLayout(board, options.previous.layoutId);
+ const rootSections = board.sections.filter((section) => section.kind !== "dynamic");
+
+ for (const rootSection of rootSections) {
+ const result = generateResponsiveGridFor({
+ items: elements,
+ previousWidth: options.previous.columnCount,
+ width: options.current.columnCount,
+ sectionId: rootSection.id,
+ });
+
+ itemSectionLayoutsCollection.push(
+ ...board.items
+ .map((item): InferInsertModel | null => {
+ const currentElement = result.items.find((element) => element.type === "item" && element.id === item.id);
+
+ if (!currentElement) {
+ return null;
+ }
+
+ return {
+ itemId: item.id,
+ layoutId: options.current.layoutId,
+ sectionId: currentElement.sectionId,
+ height: currentElement.height,
+ width: currentElement.width,
+ xOffset: currentElement.xOffset,
+ yOffset: currentElement.yOffset,
+ };
+ })
+ .filter((item) => item !== null),
+ );
+
+ sectionLayoutsCollection.push(
+ ...board.sections
+ .filter((section) => section.kind === "dynamic")
+ .map((section): InferInsertModel | null => {
+ const currentElement = result.items.find(
+ (element) => element.type === "section" && element.id === section.id,
+ );
+
+ if (!currentElement) {
+ return null;
+ }
+
+ return {
+ layoutId: options.current.layoutId,
+ sectionId: section.id,
+ parentSectionId: currentElement.sectionId,
+ height: currentElement.height,
+ width: currentElement.width,
+ xOffset: currentElement.xOffset,
+ yOffset: currentElement.yOffset,
+ };
+ })
+ .filter((section) => section !== null),
+ );
+ }
+
+ return {
+ itemSectionLayouts: itemSectionLayoutsCollection,
+ sectionLayouts: sectionLayoutsCollection,
+ };
+};
+
+const getElementsForLayout = (board: Awaited>, layoutId: string) => {
+ const sectionElements = board.sections
+ .filter((section) => section.kind === "dynamic")
+ .map((section) => {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const clonedLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layoutId)!;
+
+ return {
+ id: section.id,
+ type: "section" as const,
+ height: clonedLayout.height,
+ width: clonedLayout.width,
+ xOffset: clonedLayout.xOffset,
+ yOffset: clonedLayout.yOffset,
+ sectionId: clonedLayout.parentSectionId,
+ };
+ });
+
+ const itemElements = board.items.map((item) => {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const clonedLayout = item.layouts.find((itemLayout) => itemLayout.layoutId === layoutId)!;
+
+ return {
+ id: item.id,
+ type: "item" as const,
+ height: clonedLayout.height,
+ width: clonedLayout.width,
+ xOffset: clonedLayout.xOffset,
+ yOffset: clonedLayout.yOffset,
+ sectionId: clonedLayout.sectionId,
+ };
+ });
+
+ return [...itemElements, ...sectionElements];
+};
+
+const getFullBoardWithWhereAsync = async (db: Database, where: SQL, userId: string | null) => {
+ const groupsOfCurrentUser = await db.query.groupMembers.findMany({
+ where: eq(groupMembers.userId, userId ?? ""),
+ });
+ const board = await db.query.boards.findFirst({
+ where,
+ with: {
+ creator: {
+ columns: {
+ id: true,
+ name: true,
+ image: true,
+ email: true,
+ },
+ },
+ sections: {
+ with: {
+ collapseStates: {
+ where: eq(sectionCollapseStates.userId, userId ?? ""),
+ },
+ layouts: true,
+ },
+ },
+ items: {
+ with: {
+ integrations: {
+ with: {
+ integration: true,
+ },
+ },
+ layouts: true,
+ },
+ },
+ layouts: true,
+ userPermissions: {
+ where: eq(boardUserPermissions.userId, userId ?? ""),
+ columns: {
+ permission: true,
+ },
+ },
+ groupPermissions: {
+ where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
+ },
+ },
+ });
+
+ if (!board) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Board not found",
+ });
+ }
+
+ const { sections, items, layouts, ...otherBoardProperties } = board;
+
+ return {
+ ...otherBoardProperties,
+ layouts: layouts
+ .map(({ boardId: _, ...layout }) => layout)
+ .sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
+ sections: sections.map(({ collapseStates, ...section }) =>
+ parseSection({
+ ...section,
+ xOffset: section.xOffset,
+ yOffset: section.yOffset,
+ options: superjson.parse(section.options ?? emptySuperJSON),
+ layouts: section.layouts.map((layout) => ({
+ xOffset: layout.xOffset,
+ yOffset: layout.yOffset,
+ width: layout.width,
+ height: layout.height,
+ parentSectionId: layout.parentSectionId,
+ layoutId: layout.layoutId,
+ })),
+ collapsed: collapseStates.at(0)?.collapsed ?? false,
+ }),
+ ),
+ items: items.map(({ integrations: itemIntegrations, ...item }) =>
+ parseItem({
+ ...item,
+ layouts: item.layouts.map((layout) => ({
+ xOffset: layout.xOffset,
+ yOffset: layout.yOffset,
+ width: layout.width,
+ height: layout.height,
+ layoutId: layout.layoutId,
+ sectionId: layout.sectionId,
+ })),
+ integrationIds: itemIntegrations.map((item) => item.integration.id),
+ advancedOptions: superjson.parse(item.advancedOptions),
+ options: superjson.parse>(item.options),
+ }),
+ ),
+ };
+};
+
+const forKind = (kind: T) =>
+ z.object({
+ kind: z.literal(kind),
+ options: z.record(z.string(), z.unknown()),
+ });
+
+const outputItemSchema = zodUnionFromArray(widgetKinds.map((kind) => forKind(kind))).and(sharedItemSchema);
+
+const parseItem = (item: unknown) => {
+ const result = outputItemSchema.safeParse(item);
+
+ if (!result.success) {
+ throw new Error(result.error.message);
+ }
+ return result.data;
+};
+
+const parseSection = (section: unknown) => {
+ const result = sectionSchema.safeParse(section);
+
+ if (!result.success) {
+ throw new Error(result.error.message);
+ }
+ return result.data;
+};
+
+const filterAddedItems = (inputArray: TInput[], dbArray: TInput[]) =>
+ inputArray.filter((inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id));
+
+const filterRemovedItems = (inputArray: TInput[], dbArray: TInput[]) =>
+ dbArray.filter((dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id));
+
+const filterUpdatedItems = (inputArray: TInput[], dbArray: TInput[]) =>
+ inputArray.filter((inputItem) => dbArray.some((dbItem) => dbItem.id === inputItem.id));
diff --git a/packages/api/src/router/board/board-access.ts b/packages/api/src/router/board/board-access.ts
new file mode 100644
index 000000000..1e4351cba
--- /dev/null
+++ b/packages/api/src/router/board/board-access.ts
@@ -0,0 +1,72 @@
+import { TRPCError } from "@trpc/server";
+
+import type { Session } from "@homarr/auth";
+import { constructBoardPermissions } from "@homarr/auth/shared";
+import type { Database, SQL } from "@homarr/db";
+import { eq, inArray } from "@homarr/db";
+import { boardGroupPermissions, boardUserPermissions, groupMembers } from "@homarr/db/schema";
+import type { BoardPermission } from "@homarr/definitions";
+
+/**
+ * Throws NOT_FOUND if user is not allowed to perform action on board
+ * @param ctx trpc router context
+ * @param boardWhere where clause for the board
+ * @param permission permission required to perform action on board
+ */
+export const throwIfActionForbiddenAsync = async (
+ ctx: { db: Database; session: Session | null },
+ boardWhere: SQL,
+ permission: BoardPermission,
+) => {
+ const { db, session } = ctx;
+ const groupsOfCurrentUser = await db.query.groupMembers.findMany({
+ where: eq(groupMembers.userId, session?.user.id ?? ""),
+ });
+ const board = await db.query.boards.findFirst({
+ where: boardWhere,
+ columns: {
+ id: true,
+ creatorId: true,
+ isPublic: true,
+ },
+ with: {
+ userPermissions: {
+ where: eq(boardUserPermissions.userId, session?.user.id ?? ""),
+ },
+ groupPermissions: {
+ where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
+ },
+ },
+ });
+
+ if (!board) {
+ notAllowed();
+ }
+
+ const { hasViewAccess, hasChangeAccess, hasFullAccess } = constructBoardPermissions(board, session);
+
+ if (hasFullAccess) {
+ return; // As full access is required and user has full access, allow
+ }
+
+ if (["modify", "view"].includes(permission) && hasChangeAccess) {
+ return; // As change access is required and user has change access, allow
+ }
+
+ if (permission === "view" && hasViewAccess) {
+ return; // As view access is required and user has view access, allow
+ }
+
+ notAllowed();
+};
+
+/**
+ * This method returns NOT_FOUND to prevent snooping on board existence
+ * A function is used to use the method without return statement
+ */
+function notAllowed(): never {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Board not found",
+ });
+}
diff --git a/packages/api/src/router/board/grid-algorithm.ts b/packages/api/src/router/board/grid-algorithm.ts
new file mode 100644
index 000000000..cccc61056
--- /dev/null
+++ b/packages/api/src/router/board/grid-algorithm.ts
@@ -0,0 +1,186 @@
+export interface GridAlgorithmItem {
+ id: string;
+ type: "item" | "section";
+ width: number;
+ height: number;
+ xOffset: number;
+ yOffset: number;
+ sectionId: string;
+}
+
+interface GridAlgorithmInput {
+ items: GridAlgorithmItem[];
+ width: number;
+ previousWidth: number;
+ sectionId: string;
+}
+
+interface GridAlgorithmOutput {
+ height: number;
+ items: GridAlgorithmItem[];
+}
+
+export const generateResponsiveGridFor = ({
+ items,
+ previousWidth,
+ width,
+ sectionId,
+}: GridAlgorithmInput): GridAlgorithmOutput => {
+ const itemsOfCurrentSection = items
+ .filter((item) => item.sectionId === sectionId)
+ .sort((itemA, itemB) =>
+ itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
+ );
+ const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
+
+ if (itemsOfCurrentSection.length === 0) {
+ return {
+ height: 0,
+ items: [],
+ };
+ }
+
+ const newItems: GridAlgorithmItem[] = [];
+
+ // Fix height of dynamic sections
+ const dynamicSectionHeightMap = new Map();
+ const dynamicSectionsOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
+ for (const dynamicSection of dynamicSectionsOfCurrentSection) {
+ const result = generateResponsiveGridFor({
+ items,
+ previousWidth: dynamicSection.previousWidth,
+ width: dynamicSection.width,
+ sectionId: dynamicSection.id,
+ });
+ newItems.push(...result.items);
+ dynamicSectionHeightMap.set(dynamicSection.id, result.height);
+ }
+
+ // Return same positions for items in the current section
+ if (width >= previousWidth) {
+ return {
+ height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
+ items: newItems.concat(normalizedItems),
+ };
+ }
+
+ const occupied2d: boolean[][] = [];
+
+ for (const item of normalizedItems) {
+ const itemWithHeight = {
+ ...item,
+ height: item.type === "section" ? Math.max(dynamicSectionHeightMap.get(item.id) ?? 1, item.height) : item.height,
+ };
+ const position = nextFreeSpot(occupied2d, itemWithHeight, width);
+ if (!position) throw new Error("No free spot available");
+
+ addItemToOccupied(occupied2d, itemWithHeight, position, width);
+ newItems.push({
+ ...itemWithHeight,
+ xOffset: position.x,
+ yOffset: position.y,
+ });
+ }
+
+ return {
+ height: occupied2d.length,
+ items: newItems,
+ };
+};
+
+/**
+ * Reduces the width of the items to fit the new column count.
+ * @param items items to normalize
+ * @param columnCount new column count
+ */
+const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) => {
+ return items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) }));
+};
+
+/**
+ * Adds the item to the occupied spots.
+ * @param occupied2d array of occupied spots
+ * @param item item to place
+ * @param position position to place the item
+ */
+const addItemToOccupied = (
+ occupied2d: boolean[][],
+ item: GridAlgorithmItem,
+ position: { x: number; y: number },
+ columnCount: number,
+) => {
+ for (let yOffset = 0; yOffset < item.height; yOffset++) {
+ let row = occupied2d[position.y + yOffset];
+ if (!row) {
+ addRow(occupied2d, columnCount);
+ // After adding it, it must exist
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ row = occupied2d[position.y + yOffset]!;
+ }
+
+ for (let xOffset = 0; xOffset < item.width; xOffset++) {
+ row[position.x + xOffset] = true;
+ }
+ }
+};
+
+/**
+ * Adds a new row to the grid.
+ * @param occupied2d array of occupied spots
+ * @param columnCount column count of section
+ */
+const addRow = (occupied2d: boolean[][], columnCount: number) => {
+ occupied2d.push(new Array(columnCount).fill(false));
+};
+
+/**
+ * Searches for the next free spot in the grid.
+ * @param occupied2d array of occupied spots
+ * @param item item to place
+ * @param columnCount column count of section
+ * @returns the position of the next free spot or null if no spot is available
+ */
+const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => {
+ for (let offsetY = 0; offsetY < 99999; offsetY++) {
+ for (let offsetX = 0; offsetX < columnCount; offsetX++) {
+ if (hasHorizontalSpace(columnCount, item, offsetX) && isFree(occupied2d, item, { x: offsetX, y: offsetY })) {
+ return { x: offsetX, y: offsetY };
+ }
+ }
+ }
+
+ return null;
+};
+
+/**
+ * Check if the item fits into the grid horizontally.
+ * @param columnCount available width
+ * @param item item to place
+ * @param offsetX current x position
+ * @returns true if the item fits horizontally
+ */
+const hasHorizontalSpace = (columnCount: number, item: GridAlgorithmItem, offsetX: number) => {
+ return offsetX + item.width <= columnCount;
+};
+
+/**
+ * Check if the spot is free.
+ * @param occupied2d array of occupied spots
+ * @param item item to place
+ * @param position position to check
+ * @returns true if the spot is free
+ */
+const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => {
+ for (let yOffset = 0; yOffset < item.height; yOffset++) {
+ const row = occupied2d[position.y + yOffset];
+ if (!row) return true; // Empty row is free
+
+ for (let xOffset = 0; xOffset < item.width; xOffset++) {
+ if (row[position.x + xOffset]) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+};
diff --git a/packages/api/src/router/board/test/grid-algorithm.spec.ts b/packages/api/src/router/board/test/grid-algorithm.spec.ts
new file mode 100644
index 000000000..6d73a4386
--- /dev/null
+++ b/packages/api/src/router/board/test/grid-algorithm.spec.ts
@@ -0,0 +1,378 @@
+import { createId } from "@paralleldrive/cuid2";
+import { describe, expect, test } from "vitest";
+
+import type { GridAlgorithmItem } from "../grid-algorithm";
+import { generateResponsiveGridFor } from "../grid-algorithm";
+
+const ROOT_SECTION_ID = "section";
+
+/**
+ * If you want to see how the layouts progress between the different layouts, you can find images here:
+ * https://github.com/homarr-labs/architecture-documentation/tree/main/grid-algorithm#graphical-representation-of-the-algorithm
+ */
+describe("Grid Algorithm", () => {
+ test.each(itemTests)("should convert a grid with %i columns to a grid with %i columns", (_, _ignored, item) => {
+ const input = generateInputFromText(item.input);
+
+ const result = generateResponsiveGridFor({
+ items: input,
+ width: item.outputColumnCount,
+ previousWidth: item.inputColumnCount,
+ sectionId: ROOT_SECTION_ID,
+ });
+
+ const output = generateOutputText(result.items, item.outputColumnCount);
+
+ expect(output).toBe(item.output);
+ });
+ test.each(dynamicSectionTests)(
+ "should convert a grid with dynamic sections from 16 columns to %i columns",
+ (_, testInput) => {
+ const outerDynamicSectionId = "b";
+ const innerDynamicSectionId = "f";
+ const items = [
+ algoItem({ id: "a", width: 2, height: 2 }),
+ algoItem({ id: outerDynamicSectionId, type: "section", width: 12, height: 3, yOffset: 2 }),
+ algoItem({ id: "a", width: 2, sectionId: outerDynamicSectionId }),
+ algoItem({ id: "b", width: 4, sectionId: outerDynamicSectionId, xOffset: 2 }),
+ algoItem({ id: "c", width: 2, sectionId: outerDynamicSectionId, xOffset: 6 }),
+ algoItem({ id: "d", width: 1, sectionId: outerDynamicSectionId, xOffset: 8 }),
+ algoItem({ id: "e", width: 3, sectionId: outerDynamicSectionId, xOffset: 9 }),
+ algoItem({
+ id: innerDynamicSectionId,
+ type: "section",
+ width: 8,
+ height: 2,
+ yOffset: 1,
+ sectionId: outerDynamicSectionId,
+ }),
+ algoItem({ id: "a", width: 2, sectionId: innerDynamicSectionId }),
+ algoItem({ id: "b", width: 5, xOffset: 2, sectionId: innerDynamicSectionId }),
+ algoItem({ id: "c", width: 1, height: 2, xOffset: 7, sectionId: innerDynamicSectionId }),
+ algoItem({ id: "d", width: 7, yOffset: 1, sectionId: innerDynamicSectionId }),
+ algoItem({ id: "g", width: 4, yOffset: 1, sectionId: outerDynamicSectionId, xOffset: 8 }),
+ algoItem({ id: "h", width: 3, yOffset: 2, sectionId: outerDynamicSectionId, xOffset: 8 }),
+ algoItem({ id: "i", width: 1, yOffset: 2, sectionId: outerDynamicSectionId, xOffset: 11 }),
+ algoItem({ id: "c", width: 5, yOffset: 5 }),
+ ];
+
+ const newItems = generateResponsiveGridFor({
+ items,
+ width: testInput.outputColumns,
+ previousWidth: 16,
+ sectionId: ROOT_SECTION_ID,
+ });
+
+ const rootItems = newItems.items.filter((item) => item.sectionId === ROOT_SECTION_ID);
+ const outerSection = items.find((item) => item.id === outerDynamicSectionId);
+ const outerItems = newItems.items.filter((item) => item.sectionId === outerDynamicSectionId);
+ const innerSection = items.find((item) => item.id === innerDynamicSectionId);
+ const innerItems = newItems.items.filter((item) => item.sectionId === innerDynamicSectionId);
+
+ expect(generateOutputText(rootItems, testInput.outputColumns)).toBe(testInput.root);
+ expect(generateOutputText(outerItems, Math.min(testInput.outputColumns, outerSection?.width ?? 999))).toBe(
+ testInput.outer,
+ );
+ expect(generateOutputText(innerItems, Math.min(testInput.outputColumns, innerSection?.width ?? 999))).toBe(
+ testInput.inner,
+ );
+ },
+ );
+});
+
+const algoItem = (item: Partial): GridAlgorithmItem => ({
+ id: createId(),
+ type: "item",
+ width: 1,
+ height: 1,
+ xOffset: 0,
+ yOffset: 0,
+ sectionId: ROOT_SECTION_ID,
+ ...item,
+});
+
+const sixteenColumns = `
+abbccccddddeeefg
+hbbccccddddeeeij
+klllmmmmmnneeeop
+qlllmmmmmnnrrrst
+ulllmmmmmnnrrrvw
+xyz äö`;
+
+// Just add two empty columns to the right
+const eighteenColumns = sixteenColumns
+ .split("\n")
+ .map((line, index) => (index === 0 ? line : `${line} `))
+ .join("\n");
+
+const tenColumns = `
+abbcccceee
+fbbcccceee
+ddddghieee
+ddddjklllo
+mmmmmplllq
+mmmmmslllt
+mmmmmnnrrr
+uvwxynnrrr
+zäö nn `;
+
+const sixColumns = `
+abbfgh
+ibbjko
+ccccnn
+ccccnn
+ddddnn
+ddddpq
+eeelll
+eeelll
+eeelll
+mmmmms
+mmmmmt
+mmmmmu
+rrrvwx
+rrryzä
+ö `;
+const threeColumns = `
+abb
+fbb
+ccc
+ccc
+ddd
+ddd
+eee
+eee
+eee
+ghi
+jko
+lll
+lll
+lll
+mmm
+mmm
+mmm
+nnp
+nnq
+nns
+rrr
+rrr
+tuv
+wxy
+zäö`;
+
+const itemTests = [
+ {
+ input: sixteenColumns,
+ inputColumnCount: 16,
+ output: sixteenColumns,
+ outputColumnCount: 16,
+ },
+ {
+ input: sixteenColumns,
+ inputColumnCount: 16,
+ output: eighteenColumns,
+ outputColumnCount: 18,
+ },
+ {
+ input: sixteenColumns,
+ inputColumnCount: 16,
+ output: tenColumns,
+ outputColumnCount: 10,
+ },
+ {
+ input: sixteenColumns,
+ inputColumnCount: 16,
+ output: sixColumns,
+ outputColumnCount: 6,
+ },
+ {
+ input: sixteenColumns,
+ inputColumnCount: 16,
+ output: threeColumns,
+ outputColumnCount: 3,
+ },
+].map((item) => [item.inputColumnCount, item.outputColumnCount, item] as const);
+
+const dynamicSectionTests = [
+ {
+ outputColumns: 16,
+ root: `
+aa
+aa
+bbbbbbbbbbbb
+bbbbbbbbbbbb
+bbbbbbbbbbbb
+ccccc `,
+ outer: `
+aabbbbccdeee
+ffffffffgggg
+ffffffffhhhi`,
+ inner: `
+aabbbbbc
+dddddddc`,
+ },
+ {
+ outputColumns: 10,
+ root: `
+aaccccc
+aa
+bbbbbbbbbb
+bbbbbbbbbb
+bbbbbbbbbb
+bbbbbbbbbb`,
+ outer: `
+aabbbbccdi
+eeegggghhh
+ffffffff
+ffffffff `,
+ inner: `
+aabbbbbc
+dddddddc`,
+ },
+ {
+ outputColumns: 6,
+ root: `
+aa
+aa
+bbbbbb
+bbbbbb
+bbbbbb
+bbbbbb
+bbbbbb
+bbbbbb
+bbbbbb
+ccccc `,
+ outer: `
+aabbbb
+ccdeee
+ffffff
+ffffff
+ffffff
+ggggi
+hhh `,
+ inner: `
+aa c
+bbbbbc
+dddddd`,
+ },
+ {
+ outputColumns: 3,
+ root: `
+aa
+aa
+bbb
+bbb
+bbb
+bbb
+bbb
+bbb
+bbb
+bbb
+bbb
+bbb
+bbb
+ccc`,
+ outer: `
+aad
+bbb
+cci
+eee
+fff
+fff
+fff
+fff
+fff
+ggg
+hhh`,
+ inner: `
+aa
+bbb
+c
+c
+ddd`,
+ },
+].map((item) => [item.outputColumns, item] as const);
+
+const generateInputFromText = (text: string) => {
+ const lines = text.split("\n").slice(1); // Remove first empty row
+ const items: GridAlgorithmItem[] = [];
+ for (let yOffset = 0; yOffset < lines.length; yOffset++) {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const line = lines[yOffset]!;
+ for (let xOffset = 0; xOffset < line.length; xOffset++) {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const char = line[xOffset]!;
+ if (char === " ") continue;
+ if (items.some((item) => item.id === char)) continue;
+ items.push({
+ id: char,
+ type: "item",
+ width: getWidth(line, xOffset, char),
+ height: getHeight(lines, { x: xOffset, y: yOffset }, char),
+ xOffset,
+ yOffset,
+ sectionId: ROOT_SECTION_ID,
+ });
+ }
+ }
+
+ return items;
+};
+
+const generateOutputText = (items: GridAlgorithmItem[], columnCount: number) => {
+ const occupied2d: string[][] = [];
+ for (const item of items) {
+ addItemToOccupied(occupied2d, item, { x: item.xOffset, y: item.yOffset }, columnCount);
+ }
+
+ return `\n${occupied2d.map((row) => row.join("")).join("\n")}`;
+};
+
+const getWidth = (line: string, offset: number, char: string) => {
+ const row = line.split("");
+ let width = 1;
+ for (let xOffset = offset + 1; xOffset < row.length; xOffset++) {
+ if (row[xOffset] === char) {
+ width++;
+ } else {
+ break;
+ }
+ }
+ return width;
+};
+
+const getHeight = (lines: string[], position: { x: number; y: number }, char: string) => {
+ let height = 1;
+ for (let yOffset = position.y + 1; yOffset < lines.length; yOffset++) {
+ if (lines[yOffset]?.[position.x] === char) {
+ height++;
+ } else {
+ break;
+ }
+ }
+ return height;
+};
+
+const addItemToOccupied = (
+ occupied2d: string[][],
+ item: GridAlgorithmItem,
+ position: { x: number; y: number },
+ columnCount: number,
+) => {
+ for (let yOffset = 0; yOffset < item.height; yOffset++) {
+ let row = occupied2d[position.y + yOffset];
+ if (!row) {
+ addRow(occupied2d, columnCount);
+ // After adding it, it must exist
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ row = occupied2d[position.y + yOffset]!;
+ }
+
+ for (let xOffset = 0; xOffset < item.width; xOffset++) {
+ row[position.x + xOffset] = item.id;
+ }
+ }
+};
+
+const addRow = (occupied2d: string[][], columnCount: number) => {
+ occupied2d.push(new Array(columnCount).fill(" "));
+};
diff --git a/packages/api/src/router/certificates/certificate-router.ts b/packages/api/src/router/certificates/certificate-router.ts
new file mode 100644
index 000000000..cab6633f0
--- /dev/null
+++ b/packages/api/src/router/certificates/certificate-router.ts
@@ -0,0 +1,131 @@
+import { X509Certificate } from "node:crypto";
+import { TRPCError } from "@trpc/server";
+import { zfd } from "zod-form-data";
+import { z } from "zod/v4";
+
+import {
+ addCustomRootCertificateAsync,
+ removeCustomRootCertificateAsync,
+} from "@homarr/core/infrastructure/certificates";
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import { and, eq } from "@homarr/db";
+import { trustedCertificateHostnames } from "@homarr/db/schema";
+import { certificateValidFileNameSchema, checkCertificateFile } from "@homarr/validation/certificates";
+
+import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
+
+const logger = createLogger({ module: "certificateRouter" });
+
+export const certificateRouter = createTRPCRouter({
+ addCertificate: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(
+ zfd.formData({
+ file: zfd.file().check(checkCertificateFile),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const content = await input.file.text();
+
+ // Validate the certificate
+ let x509Certificate: X509Certificate;
+ try {
+ x509Certificate = new X509Certificate(content);
+ logger.info("Adding trusted certificate", {
+ subject: x509Certificate.subject,
+ issuer: x509Certificate.issuer,
+ });
+ } catch {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid certificate",
+ });
+ }
+
+ await addCustomRootCertificateAsync(input.file.name, content);
+
+ logger.info("Added trusted certificate", {
+ subject: x509Certificate.subject,
+ issuer: x509Certificate.issuer,
+ });
+ }),
+ trustHostnameMismatch: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(z.object({ hostname: z.string(), certificate: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ // Validate the certificate
+ let x509Certificate: X509Certificate;
+ try {
+ x509Certificate = new X509Certificate(input.certificate);
+ logger.info("Adding trusted hostname", {
+ subject: x509Certificate.subject,
+ issuer: x509Certificate.issuer,
+ thumbprint: x509Certificate.fingerprint256,
+ hostname: input.hostname,
+ });
+ } catch {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid certificate",
+ });
+ }
+
+ await ctx.db.insert(trustedCertificateHostnames).values({
+ hostname: input.hostname,
+ thumbprint: x509Certificate.fingerprint256,
+ certificate: input.certificate,
+ });
+
+ logger.info("Added trusted hostname", {
+ subject: x509Certificate.subject,
+ issuer: x509Certificate.issuer,
+ thumbprint: x509Certificate.fingerprint256,
+ hostname: input.hostname,
+ });
+ }),
+ removeTrustedHostname: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(z.object({ hostname: z.string(), thumbprint: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ logger.info("Removing trusted hostname", {
+ hostname: input.hostname,
+ thumbprint: input.thumbprint,
+ });
+ const dbResult = await ctx.db
+ .delete(trustedCertificateHostnames)
+ .where(
+ and(
+ eq(trustedCertificateHostnames.hostname, input.hostname),
+ eq(trustedCertificateHostnames.thumbprint, input.thumbprint),
+ ),
+ );
+
+ logger.info("Removed trusted hostname", {
+ hostname: input.hostname,
+ thumbprint: input.thumbprint,
+ count: dbResult.changes,
+ });
+ }),
+ removeCertificate: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(z.object({ fileName: certificateValidFileNameSchema }))
+ .mutation(async ({ input, ctx }) => {
+ logger.info("Removing trusted certificate", {
+ fileName: input.fileName,
+ });
+
+ const certificate = await removeCustomRootCertificateAsync(input.fileName);
+ if (!certificate) return;
+
+ // Delete all trusted hostnames for this certificate
+ await ctx.db
+ .delete(trustedCertificateHostnames)
+ .where(eq(trustedCertificateHostnames.thumbprint, certificate.fingerprint256));
+
+ logger.info("Removed trusted certificate", {
+ fileName: input.fileName,
+ subject: certificate.subject,
+ issuer: certificate.issuer,
+ });
+ }),
+});
diff --git a/packages/api/src/router/cron-jobs.ts b/packages/api/src/router/cron-jobs.ts
new file mode 100644
index 000000000..5428ac374
--- /dev/null
+++ b/packages/api/src/router/cron-jobs.ts
@@ -0,0 +1,80 @@
+import { observable } from "@trpc/server/observable";
+import z from "zod/v4";
+
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import { cronExpressionSchema, jobGroupKeys, jobNameSchema } from "@homarr/cron-job-api";
+import { cronJobApi } from "@homarr/cron-job-api/client";
+import type { TaskStatus } from "@homarr/cron-job-status";
+import { createCronJobStatusChannel } from "@homarr/cron-job-status";
+
+import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
+
+const logger = createLogger({ module: "cronJobsRouter" });
+
+export const cronJobsRouter = createTRPCRouter({
+ triggerJob: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(jobNameSchema)
+ .mutation(async ({ input }) => {
+ await cronJobApi.trigger.mutate(input);
+ }),
+ startJob: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(jobNameSchema)
+ .mutation(async ({ input }) => {
+ await cronJobApi.start.mutate(input);
+ }),
+ stopJob: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(jobNameSchema)
+ .mutation(async ({ input }) => {
+ await cronJobApi.stop.mutate(input);
+ }),
+ updateJobInterval: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(
+ z.object({
+ name: jobNameSchema,
+ cron: cronExpressionSchema,
+ }),
+ )
+ .mutation(async ({ input }) => {
+ await cronJobApi.updateInterval.mutate(input);
+ }),
+ disableJob: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(jobNameSchema)
+ .mutation(async ({ input }) => {
+ await cronJobApi.disable.mutate(input);
+ }),
+ enableJob: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(jobNameSchema)
+ .mutation(async ({ input }) => {
+ await cronJobApi.enable.mutate(input);
+ }),
+ getJobs: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
+ return await cronJobApi.getAll.query();
+ }),
+ subscribeToStatusUpdates: permissionRequiredProcedure.requiresPermission("admin").subscription(() => {
+ return observable((emit) => {
+ const unsubscribes: (() => void)[] = [];
+
+ for (const name of jobGroupKeys) {
+ const channel = createCronJobStatusChannel(name);
+ const unsubscribe = channel.subscribe((data) => {
+ emit.next(data);
+ });
+ unsubscribes.push(unsubscribe);
+ }
+
+ logger.info("A tRPC client has connected to the cron job status updates procedure");
+
+ return () => {
+ unsubscribes.forEach((unsubscribe) => {
+ unsubscribe();
+ });
+ };
+ });
+ }),
+});
diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts
new file mode 100644
index 000000000..6a0ea49a0
--- /dev/null
+++ b/packages/api/src/router/docker/docker-router.ts
@@ -0,0 +1,147 @@
+import { TRPCError } from "@trpc/server";
+import { observable } from "@trpc/server/observable";
+import { z } from "zod/v4";
+
+import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
+import { DockerSingleton } from "@homarr/docker";
+import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
+
+import { dockerMiddleware } from "../../middlewares/docker";
+import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
+
+export const dockerRouter = createTRPCRouter({
+ getContainers: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .concat(dockerMiddleware())
+ .query(async () => {
+ const innerHandler = dockerContainersRequestHandler.handler({});
+ const result = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+
+ const { data, timestamp } = result;
+
+ return {
+ containers: data satisfies DockerContainer[],
+ timestamp,
+ };
+ }),
+ subscribeContainers: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .concat(dockerMiddleware())
+ .subscription(() => {
+ return observable((emit) => {
+ const innerHandler = dockerContainersRequestHandler.handler({});
+ const unsubscribe = innerHandler.subscribe((data) => {
+ emit.next(data);
+ });
+
+ return unsubscribe;
+ });
+ }),
+ invalidate: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .concat(dockerMiddleware())
+ .mutation(async () => {
+ const innerHandler = dockerContainersRequestHandler.handler({});
+ await innerHandler.invalidateAsync();
+ }),
+ startAll: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .concat(dockerMiddleware())
+ .input(z.object({ ids: z.array(z.string()) }))
+ .mutation(async ({ input }) => {
+ await Promise.allSettled(
+ input.ids.map(async (id) => {
+ const container = await getContainerOrThrowAsync(id);
+ await container.start();
+ }),
+ );
+
+ const innerHandler = dockerContainersRequestHandler.handler({});
+ await innerHandler.invalidateAsync();
+ }),
+ stopAll: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .concat(dockerMiddleware())
+ .input(z.object({ ids: z.array(z.string()) }))
+ .mutation(async ({ input }) => {
+ await Promise.allSettled(
+ input.ids.map(async (id) => {
+ const container = await getContainerOrThrowAsync(id);
+ await container.stop();
+ }),
+ );
+
+ const innerHandler = dockerContainersRequestHandler.handler({});
+ await innerHandler.invalidateAsync();
+ }),
+ restartAll: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .concat(dockerMiddleware())
+ .input(z.object({ ids: z.array(z.string()) }))
+ .mutation(async ({ input }) => {
+ await Promise.allSettled(
+ input.ids.map(async (id) => {
+ const container = await getContainerOrThrowAsync(id);
+ await container.restart();
+ }),
+ );
+
+ const innerHandler = dockerContainersRequestHandler.handler({});
+ await innerHandler.invalidateAsync();
+ }),
+ removeAll: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .concat(dockerMiddleware())
+ .input(z.object({ ids: z.array(z.string()) }))
+ .mutation(async ({ input }) => {
+ await Promise.allSettled(
+ input.ids.map(async (id) => {
+ const container = await getContainerOrThrowAsync(id);
+ await container.remove();
+ }),
+ );
+
+ const innerHandler = dockerContainersRequestHandler.handler({});
+ await innerHandler.invalidateAsync();
+ }),
+});
+
+const getContainerOrDefaultAsync = async (instance: Docker, id: string) => {
+ const container = instance.getContainer(id);
+
+ return await new Promise((resolve) => {
+ container.inspect((err, data) => {
+ if (err || !data) {
+ resolve(null);
+ } else {
+ resolve(container);
+ }
+ });
+ });
+};
+
+const getContainerOrThrowAsync = async (id: string) => {
+ const dockerInstances = DockerSingleton.getInstances();
+ const containers = await Promise.all(dockerInstances.map(({ instance }) => getContainerOrDefaultAsync(instance, id)));
+ const foundContainer = containers.find((container) => container) ?? null;
+
+ if (!foundContainer) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Container not found",
+ });
+ }
+
+ return foundContainer;
+};
+
+interface DockerContainer {
+ name: string;
+ id: string;
+ state: ContainerState;
+ image: string;
+ ports: Port[];
+ iconUrl: string | null;
+ cpuUsage: number;
+ memoryUsage: number;
+}
diff --git a/packages/api/src/router/group.ts b/packages/api/src/router/group.ts
new file mode 100644
index 000000000..13fb4fdd3
--- /dev/null
+++ b/packages/api/src/router/group.ts
@@ -0,0 +1,385 @@
+import { TRPCError } from "@trpc/server";
+import { z } from "zod/v4";
+
+import { createId } from "@homarr/common";
+import type { Database } from "@homarr/db";
+import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db";
+import { getMaxGroupPositionAsync } from "@homarr/db/queries";
+import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
+import { everyoneGroup } from "@homarr/definitions";
+import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
+import {
+ groupCreateSchema,
+ groupSavePartialSettingsSchema,
+ groupSavePermissionsSchema,
+ groupSavePositionsSchema,
+ groupUpdateSchema,
+ groupUserSchema,
+} from "@homarr/validation/group";
+
+import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc";
+import { throwIfCredentialsDisabled } from "./invite/checks";
+import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
+
+export const groupRouter = createTRPCRouter({
+ getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
+ const dbGroups = await ctx.db.query.groups.findMany({
+ with: {
+ members: {
+ with: {
+ user: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ return dbGroups.map((group) => ({
+ ...group,
+ members: group.members.map((member) => member.user),
+ }));
+ }),
+
+ getPaginated: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(paginatedSchema)
+ .query(async ({ input, ctx }) => {
+ const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined;
+ const groupCount = await ctx.db.$count(groups, whereQuery);
+
+ const dbGroups = await ctx.db.query.groups.findMany({
+ with: {
+ members: {
+ with: {
+ user: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ },
+ },
+ },
+ },
+ },
+ limit: input.pageSize,
+ offset: (input.page - 1) * input.pageSize,
+ where: whereQuery,
+ });
+
+ return {
+ items: dbGroups.map((group) => ({
+ ...group,
+ members: group.members.map((member) => member.user),
+ })),
+ totalCount: groupCount,
+ };
+ }),
+ getById: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(byIdSchema)
+ .query(async ({ input, ctx }) => {
+ const group = await ctx.db.query.groups.findFirst({
+ where: eq(groups.id, input.id),
+ with: {
+ members: {
+ with: {
+ user: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ provider: true,
+ },
+ },
+ },
+ },
+ permissions: {
+ columns: {
+ permission: true,
+ },
+ },
+ owner: {
+ columns: {
+ id: true,
+ name: true,
+ image: true,
+ email: true,
+ },
+ },
+ },
+ });
+
+ if (!group) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Group not found",
+ });
+ }
+
+ return {
+ ...group,
+ members: group.members.map((member) => member.user),
+ permissions: group.permissions.map((permission) => permission.permission),
+ };
+ }),
+ // Is protected because also used in board access / integration access forms
+ selectable: protectedProcedure
+ .input(z.object({ withPermissions: z.boolean().default(false) }).optional())
+ .query(async ({ ctx, input }) => {
+ const withPermissions = input?.withPermissions && ctx.session.user.permissions.includes("admin");
+
+ if (!withPermissions) {
+ return await ctx.db.query.groups.findMany({
+ columns: {
+ id: true,
+ name: true,
+ },
+ });
+ }
+
+ const groups = await ctx.db.query.groups.findMany({
+ columns: {
+ id: true,
+ name: true,
+ },
+ with: { permissions: { columns: { permission: true } } },
+ });
+
+ return groups.map((group) => ({
+ ...group,
+ permissions: group.permissions.map((permission) => permission.permission),
+ }));
+ }),
+ search: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(
+ z.object({
+ query: z.string(),
+ limit: z.number().min(1).max(100).default(10),
+ }),
+ )
+ .query(async ({ input, ctx }) => {
+ return await ctx.db.query.groups.findMany({
+ where: like(groups.name, `%${input.query}%`),
+ columns: {
+ id: true,
+ name: true,
+ },
+ limit: input.limit,
+ });
+ }),
+ createInitialExternalGroup: onboardingProcedure
+ .requiresStep("group")
+ .input(groupCreateSchema)
+ .mutation(async ({ input, ctx }) => {
+ await checkSimilarNameAndThrowAsync(ctx.db, input.name);
+
+ const maxPosition = await getMaxGroupPositionAsync(ctx.db);
+
+ const groupId = createId();
+ await ctx.db.insert(groups).values({
+ id: groupId,
+ name: input.name,
+ position: maxPosition + 1,
+ });
+
+ await ctx.db.insert(groupPermissions).values({
+ groupId,
+ permission: "admin",
+ });
+
+ await nextOnboardingStepAsync(ctx.db, undefined);
+ }),
+ createGroup: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(groupCreateSchema)
+ .mutation(async ({ input, ctx }) => {
+ await checkSimilarNameAndThrowAsync(ctx.db, input.name);
+
+ const maxPosition = await getMaxGroupPositionAsync(ctx.db);
+
+ const id = createId();
+ await ctx.db.insert(groups).values({
+ id,
+ name: input.name,
+ position: maxPosition + 1,
+ ownerId: ctx.session.user.id,
+ });
+
+ return id;
+ }),
+ updateGroup: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(groupUpdateSchema)
+ .mutation(async ({ input, ctx }) => {
+ await throwIfGroupNotFoundAsync(ctx.db, input.id);
+ await throwIfGroupNameIsReservedAsync(ctx.db, input.id);
+
+ await checkSimilarNameAndThrowAsync(ctx.db, input.name, input.id);
+
+ await ctx.db
+ .update(groups)
+ .set({
+ name: input.name,
+ })
+ .where(eq(groups.id, input.id));
+ }),
+ savePartialSettings: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(groupSavePartialSettingsSchema)
+ .mutation(async ({ input, ctx }) => {
+ await throwIfGroupNotFoundAsync(ctx.db, input.id);
+
+ await ctx.db
+ .update(groups)
+ .set({
+ homeBoardId: input.settings.homeBoardId,
+ mobileHomeBoardId: input.settings.mobileHomeBoardId,
+ })
+ .where(eq(groups.id, input.id));
+ }),
+ savePositions: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(groupSavePositionsSchema)
+ .mutation(async ({ input, ctx }) => {
+ const positions = input.positions.map((id, index) => ({ id, position: index + 1 }));
+
+ await handleTransactionsAsync(ctx.db, {
+ handleAsync: async (db, schema) => {
+ await db.transaction(async (trx) => {
+ for (const { id, position } of positions) {
+ await trx.update(schema.groups).set({ position }).where(eq(groups.id, id));
+ }
+ });
+ },
+ handleSync: (db) => {
+ db.transaction((trx) => {
+ for (const { id, position } of positions) {
+ trx.update(groups).set({ position }).where(eq(groups.id, id)).run();
+ }
+ });
+ },
+ });
+ }),
+ savePermissions: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(groupSavePermissionsSchema)
+ .mutation(async ({ input, ctx }) => {
+ await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
+
+ await ctx.db.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId));
+
+ if (input.permissions.length > 0) {
+ await ctx.db.insert(groupPermissions).values(
+ input.permissions.map((permission) => ({
+ groupId: input.groupId,
+ permission,
+ })),
+ );
+ }
+ }),
+ transferOwnership: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(groupUserSchema)
+ .mutation(async ({ input, ctx }) => {
+ await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
+ await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
+
+ await ctx.db
+ .update(groups)
+ .set({
+ ownerId: input.userId,
+ })
+ .where(eq(groups.id, input.groupId));
+ }),
+ deleteGroup: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(byIdSchema)
+ .mutation(async ({ input, ctx }) => {
+ await throwIfGroupNotFoundAsync(ctx.db, input.id);
+ await throwIfGroupNameIsReservedAsync(ctx.db, input.id);
+
+ await ctx.db.delete(groups).where(eq(groups.id, input.id));
+ }),
+ addMember: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(groupUserSchema)
+ .mutation(async ({ input, ctx }) => {
+ await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
+ await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
+ throwIfCredentialsDisabled();
+
+ const user = await ctx.db.query.users.findFirst({
+ where: eq(groups.id, input.userId),
+ });
+
+ if (!user) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ await ctx.db.insert(groupMembers).values({
+ groupId: input.groupId,
+ userId: input.userId,
+ });
+ }),
+ removeMember: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(groupUserSchema)
+ .mutation(async ({ input, ctx }) => {
+ await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
+ await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
+ throwIfCredentialsDisabled();
+
+ await ctx.db
+ .delete(groupMembers)
+ .where(and(eq(groupMembers.groupId, input.groupId), eq(groupMembers.userId, input.userId)));
+ }),
+});
+
+const checkSimilarNameAndThrowAsync = async (db: Database, name: string, ignoreId?: string) => {
+ const similar = await db.query.groups.findFirst({
+ where: and(like(groups.name, `${name}`), not(eq(groups.id, ignoreId ?? ""))),
+ });
+
+ if (similar) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "Found group with similar name",
+ });
+ }
+};
+
+const throwIfGroupNameIsReservedAsync = async (db: Database, id: string) => {
+ const count = await db.$count(groups, and(eq(groups.id, id), eq(groups.name, everyoneGroup)));
+
+ if (count > 0) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Action is forbidden for reserved group names",
+ });
+ }
+};
+
+const throwIfGroupNotFoundAsync = async (db: Database, id: string) => {
+ const group = await db.query.groups.findFirst({
+ where: eq(groups.id, id),
+ });
+
+ if (!group) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Group not found",
+ });
+ }
+};
diff --git a/packages/api/src/router/home.ts b/packages/api/src/router/home.ts
new file mode 100644
index 000000000..fac941fa8
--- /dev/null
+++ b/packages/api/src/router/home.ts
@@ -0,0 +1,147 @@
+import { isProviderEnabled } from "@homarr/auth/server";
+import { db, eq, inArray, or } from "@homarr/db";
+import {
+ apps,
+ boards,
+ boardUserPermissions,
+ groupMembers,
+ groups,
+ integrations,
+ invites,
+ medias,
+ searchEngines,
+ users,
+} from "@homarr/db/schema";
+import type { TranslationObject } from "@homarr/translation";
+
+import { createTRPCRouter, publicProcedure } from "../trpc";
+
+interface HomeStatistic {
+ titleKey: keyof TranslationObject["management"]["page"]["home"]["statistic"];
+ subtitleKey: keyof TranslationObject["management"]["page"]["home"]["statisticLabel"];
+ count: number;
+ path: string;
+}
+
+export const homeRouter = createTRPCRouter({
+ getStats: publicProcedure.query(async ({ ctx }) => {
+ const isAdmin = ctx.session?.user.permissions.includes("admin") ?? false;
+ const isCredentialsEnabled = isProviderEnabled("credentials");
+
+ const statistics: HomeStatistic[] = [];
+
+ const boardIds: string[] = [];
+ if (ctx.session?.user && !ctx.session.user.permissions.includes("board-view-all")) {
+ const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
+ where: eq(boardUserPermissions.userId, ctx.session.user.id),
+ });
+
+ const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
+ where: eq(groupMembers.userId, ctx.session.user.id),
+ with: {
+ group: {
+ with: {
+ boardPermissions: {},
+ },
+ },
+ },
+ });
+
+ boardIds.push(
+ ...permissionsOfCurrentUserWhenPresent
+ .map((permission) => permission.boardId)
+ .concat(
+ permissionsOfCurrentUserGroupsWhenPresent
+ .map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
+ .flat(),
+ ),
+ );
+ }
+
+ statistics.push({
+ titleKey: "board",
+ subtitleKey: "boards",
+ count: await db.$count(
+ boards,
+ ctx.session?.user.permissions.includes("board-view-all")
+ ? undefined
+ : or(
+ eq(boards.isPublic, true),
+ eq(boards.creatorId, ctx.session?.user.id ?? ""),
+ boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
+ ),
+ ),
+ path: "/manage/boards",
+ });
+
+ if (isAdmin) {
+ statistics.push({
+ titleKey: "user",
+ subtitleKey: "authentication",
+ count: await db.$count(users),
+ path: "/manage/users",
+ });
+ }
+
+ if (isAdmin && isCredentialsEnabled) {
+ statistics.push({
+ titleKey: "invite",
+ subtitleKey: "authentication",
+ count: await db.$count(invites),
+ path: "/manage/users/invites",
+ });
+ }
+
+ if (ctx.session?.user.permissions.includes("integration-create")) {
+ statistics.push({
+ titleKey: "integration",
+ subtitleKey: "resources",
+ count: await db.$count(integrations),
+ path: "/manage/integrations",
+ });
+ }
+
+ if (ctx.session?.user) {
+ statistics.push({
+ titleKey: "app",
+ subtitleKey: "resources",
+ count: await db.$count(apps),
+ path: "/manage/apps",
+ });
+ }
+
+ if (isAdmin) {
+ statistics.push({
+ titleKey: "group",
+ subtitleKey: "authorization",
+ count: await db.$count(groups),
+ path: "/manage/users/groups",
+ });
+ }
+
+ if (ctx.session?.user.permissions.includes("search-engine-create")) {
+ statistics.push({
+ titleKey: "searchEngine",
+ subtitleKey: "resources",
+ count: await db.$count(searchEngines),
+ path: "/manage/search-engines",
+ });
+ }
+
+ if (ctx.session?.user.permissions.includes("media-upload")) {
+ statistics.push({
+ titleKey: "media",
+ subtitleKey: "resources",
+ count: await db.$count(
+ medias,
+ ctx.session.user.permissions.includes("media-view-all")
+ ? undefined
+ : eq(medias.creatorId, ctx.session.user.id),
+ ),
+ path: "/manage/medias",
+ });
+ }
+
+ return statistics;
+ }),
+});
diff --git a/packages/api/src/router/icons.ts b/packages/api/src/router/icons.ts
new file mode 100644
index 000000000..04f15fc2d
--- /dev/null
+++ b/packages/api/src/router/icons.ts
@@ -0,0 +1,30 @@
+import { and, like } from "@homarr/db";
+import { icons } from "@homarr/db/schema";
+import { iconsFindSchema } from "@homarr/validation/icons";
+
+import { createTRPCRouter, publicProcedure } from "../trpc";
+
+export const iconsRouter = createTRPCRouter({
+ findIcons: publicProcedure.input(iconsFindSchema).query(async ({ ctx, input }) => {
+ return {
+ icons: await ctx.db.query.iconRepositories.findMany({
+ with: {
+ icons: {
+ columns: {
+ id: true,
+ name: true,
+ url: true,
+ },
+ where:
+ (input.searchText?.length ?? 0) > 0
+ ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
+ : undefined,
+ limit: input.limitPerGroup,
+ },
+ },
+ }),
+ countIcons: await ctx.db.$count(icons),
+ };
+ }),
+});
diff --git a/packages/api/src/router/import/import-router.ts b/packages/api/src/router/import/import-router.ts
new file mode 100644
index 000000000..96c5fd039
--- /dev/null
+++ b/packages/api/src/router/import/import-router.ts
@@ -0,0 +1,43 @@
+import { z } from "zod/v4";
+
+import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse";
+import {
+ ensureValidTokenOrThrow,
+ importInitialOldmarrAsync,
+ importInitialOldmarrInputSchema,
+} from "@homarr/old-import/import";
+
+import { createTRPCRouter, onboardingProcedure } from "../../trpc";
+import { nextOnboardingStepAsync } from "../onboard/onboard-queries";
+
+export const importRouter = createTRPCRouter({
+ analyseInitialOldmarrImport: onboardingProcedure
+ .requiresStep("import")
+ .input(analyseOldmarrImportInputSchema)
+ .mutation(async ({ input }) => {
+ return await analyseOldmarrImportForRouterAsync(input);
+ }),
+ validateToken: onboardingProcedure
+ .requiresStep("import")
+ .input(
+ z.object({
+ checksum: z.string(),
+ token: z.string(),
+ }),
+ )
+ .mutation(({ input }) => {
+ try {
+ ensureValidTokenOrThrow(input.checksum, input.token);
+ return true;
+ } catch {
+ return false;
+ }
+ }),
+ importInitialOldmarrImport: onboardingProcedure
+ .requiresStep("import")
+ .input(importInitialOldmarrInputSchema)
+ .mutation(async ({ ctx, input }) => {
+ await importInitialOldmarrAsync(ctx.db, input);
+ await nextOnboardingStepAsync(ctx.db, undefined);
+ }),
+});
diff --git a/packages/api/src/router/info.ts b/packages/api/src/router/info.ts
new file mode 100644
index 000000000..3c205963c
--- /dev/null
+++ b/packages/api/src/router/info.ts
@@ -0,0 +1,16 @@
+import z from "zod/v4";
+
+import packageJson from "../../../../package.json";
+import { createTRPCRouter, protectedProcedure } from "../trpc";
+
+export const infoRouter = createTRPCRouter({
+ getInfo: protectedProcedure
+ .input(z.void())
+ .output(z.object({ version: z.string() }))
+ .meta({ openapi: { method: "GET", path: "/api/info", tags: ["info"] } })
+ .query(() => {
+ return {
+ version: packageJson.version,
+ };
+ }),
+});
diff --git a/packages/api/src/router/integration/integration-access.ts b/packages/api/src/router/integration/integration-access.ts
new file mode 100644
index 000000000..78f8961e4
--- /dev/null
+++ b/packages/api/src/router/integration/integration-access.ts
@@ -0,0 +1,73 @@
+import { TRPCError } from "@trpc/server";
+
+import type { Session } from "@homarr/auth";
+import { constructIntegrationPermissions } from "@homarr/auth/shared";
+import type { Database, SQL } from "@homarr/db";
+import { eq, inArray } from "@homarr/db";
+import { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
+import type { IntegrationPermission } from "@homarr/definitions";
+
+/**
+ * Throws NOT_FOUND if user is not allowed to perform action on integration
+ * @param ctx trpc router context
+ * @param integrationWhere where clause for the integration
+ * @param permission permission required to perform action on integration
+ */
+export const throwIfActionForbiddenAsync = async (
+ ctx: { db: Database; session: Session | null },
+ integrationWhere: SQL,
+ permission: IntegrationPermission,
+) => {
+ const { db, session } = ctx;
+ const groupsOfCurrentUser = await db.query.groupMembers.findMany({
+ where: eq(groupMembers.userId, session?.user.id ?? ""),
+ });
+ const integration = await db.query.integrations.findFirst({
+ where: integrationWhere,
+ columns: {
+ id: true,
+ },
+ with: {
+ userPermissions: {
+ where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
+ },
+ groupPermissions: {
+ where: inArray(
+ integrationGroupPermissions.groupId,
+ groupsOfCurrentUser.map((group) => group.groupId).concat(""),
+ ),
+ },
+ },
+ });
+
+ if (!integration) {
+ notAllowed();
+ }
+
+ const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session);
+
+ if (hasFullAccess) {
+ return; // As full access is required and user has full access, allow
+ }
+
+ if (["interact", "use"].includes(permission) && hasInteractAccess) {
+ return; // As interact access is required and user has interact access, allow
+ }
+
+ if (permission === "use" && hasUseAccess) {
+ return; // As use access is required and user has use access, allow
+ }
+
+ notAllowed();
+};
+
+/**
+ * This method returns NOT_FOUND to prevent snooping on board existence
+ * A function is used to use the method without return statement
+ */
+function notAllowed(): never {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Integration not found",
+ });
+}
diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts
new file mode 100644
index 000000000..77a98515b
--- /dev/null
+++ b/packages/api/src/router/integration/integration-router.ts
@@ -0,0 +1,705 @@
+import { TRPCError } from "@trpc/server";
+import { z } from "zod/v4";
+
+import { createId, objectEntries } from "@homarr/common";
+import { decryptSecret, encryptSecret } from "@homarr/common/server";
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import type { Database } from "@homarr/db";
+import { and, asc, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
+import {
+ apps,
+ groupMembers,
+ groupPermissions,
+ integrationGroupPermissions,
+ integrations,
+ integrationSecrets,
+ integrationUserPermissions,
+ searchEngines,
+} from "@homarr/db/schema";
+import type { IntegrationSecretKind } from "@homarr/definitions";
+import {
+ getIconUrl,
+ getIntegrationKindsByCategory,
+ getPermissionsWithParents,
+ integrationCategories,
+ integrationDefs,
+ integrationKinds,
+ integrationSecretKindObject,
+} from "@homarr/definitions";
+import { createIntegrationAsync } from "@homarr/integrations";
+import { byIdSchema } from "@homarr/validation/common";
+import {
+ integrationCreateSchema,
+ integrationSavePermissionsSchema,
+ integrationUpdateSchema,
+} from "@homarr/validation/integration";
+
+import { createOneIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
+import { throwIfActionForbiddenAsync } from "./integration-access";
+import { MissingSecretError, testConnectionAsync } from "./integration-test-connection";
+import { mapTestConnectionError } from "./map-test-connection-error";
+
+const logger = createLogger({ module: "integrationRouter" });
+
+export const integrationRouter = createTRPCRouter({
+ all: publicProcedure.query(async ({ ctx }) => {
+ const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
+ where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
+ });
+
+ const integrations = await ctx.db.query.integrations.findMany({
+ with: {
+ userPermissions: {
+ where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
+ },
+ groupPermissions: {
+ where: inArray(
+ integrationGroupPermissions.groupId,
+ groupsOfCurrentUser.map((group) => group.groupId),
+ ),
+ },
+ },
+ });
+ return integrations
+ .map((integration) => {
+ const permissions = integration.userPermissions
+ .map(({ permission }) => permission)
+ .concat(integration.groupPermissions.map(({ permission }) => permission));
+
+ return {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ url: integration.url,
+ permissions: {
+ hasUseAccess:
+ permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
+ hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
+ hasFullAccess: permissions.includes("full"),
+ },
+ };
+ })
+ .sort(
+ (integrationA, integrationB) =>
+ integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
+ );
+ }),
+ allThatSupportSearch: publicProcedure.query(async ({ ctx }) => {
+ const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
+ where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
+ });
+
+ const integrationsFromDb = await ctx.db.query.integrations.findMany({
+ with: {
+ userPermissions: {
+ where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
+ },
+ groupPermissions: {
+ where: inArray(
+ integrationGroupPermissions.groupId,
+ groupsOfCurrentUser.map((group) => group.groupId),
+ ),
+ },
+ },
+ where: inArray(
+ integrations.kind,
+ objectEntries(integrationDefs)
+ .filter(([_, integration]) => [...integration.category].includes("search"))
+ .map(([kind, _]) => kind),
+ ),
+ });
+ return integrationsFromDb
+ .map((integration) => {
+ const permissions = integration.userPermissions
+ .map(({ permission }) => permission)
+ .concat(integration.groupPermissions.map(({ permission }) => permission));
+
+ return {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ url: integration.url,
+ permissions: {
+ hasUseAccess:
+ permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
+ hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
+ hasFullAccess: permissions.includes("full"),
+ },
+ };
+ })
+ .sort(
+ (integrationA, integrationB) =>
+ integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
+ );
+ }),
+ allOfGivenCategory: publicProcedure
+ .input(
+ z.object({
+ category: z.enum(integrationCategories),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
+ where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
+ });
+
+ const intergrationKinds = getIntegrationKindsByCategory(input.category);
+
+ const integrationsFromDb = await ctx.db.query.integrations.findMany({
+ with: {
+ userPermissions: {
+ where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
+ },
+ groupPermissions: {
+ where: inArray(
+ integrationGroupPermissions.groupId,
+ groupsOfCurrentUser.map((group) => group.groupId),
+ ),
+ },
+ },
+ where: inArray(integrations.kind, intergrationKinds),
+ });
+ return integrationsFromDb
+ .map((integration) => {
+ const permissions = integration.userPermissions
+ .map(({ permission }) => permission)
+ .concat(integration.groupPermissions.map(({ permission }) => permission));
+
+ return {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ url: integration.url,
+ permissions: {
+ hasUseAccess:
+ permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
+ hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
+ hasFullAccess: permissions.includes("full"),
+ },
+ };
+ })
+ .sort(
+ (integrationA, integrationB) =>
+ integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
+ );
+ }),
+ search: protectedProcedure
+ .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
+ .query(async ({ ctx, input }) => {
+ return await ctx.db.query.integrations.findMany({
+ where: like(integrations.name, `%${input.query}%`),
+ orderBy: asc(integrations.name),
+ limit: input.limit,
+ });
+ }),
+ // This is used to get the integrations by their ids it's public because it's needed to get integrations data in the boards
+ byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
+ return await ctx.db.query.integrations.findMany({
+ where: inArray(integrations.id, input),
+ columns: {
+ id: true,
+ kind: true,
+ },
+ });
+ }),
+ byId: protectedProcedure.input(byIdSchema).query(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
+ const integration = await ctx.db.query.integrations.findFirst({
+ where: eq(integrations.id, input.id),
+ with: {
+ secrets: {
+ columns: {
+ kind: true,
+ value: true,
+ updatedAt: true,
+ },
+ },
+ app: {
+ columns: {
+ id: true,
+ name: true,
+ iconUrl: true,
+ href: true,
+ },
+ },
+ },
+ });
+
+ if (!integration) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Integration not found",
+ });
+ }
+
+ return {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ url: integration.url,
+ secrets: integration.secrets.map((secret) => ({
+ kind: secret.kind,
+ // Only return the value if the secret is public, so for example the username
+ value: integrationSecretKindObject[secret.kind].isPublic ? decryptSecret(secret.value) : null,
+ updatedAt: secret.updatedAt,
+ })),
+ app: integration.app,
+ };
+ }),
+ create: permissionRequiredProcedure
+ .requiresPermission("integration-create")
+ .input(integrationCreateSchema)
+ .mutation(async ({ ctx, input }) => {
+ logger.info("Creating integration", {
+ name: input.name,
+ kind: input.kind,
+ url: input.url,
+ });
+
+ if (input.app && "name" in input.app && !ctx.session.user.permissions.includes("app-create")) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Permission denied",
+ });
+ }
+
+ const result = await testConnectionAsync({
+ id: "new",
+ name: input.name,
+ url: input.url,
+ kind: input.kind,
+ secrets: input.secrets,
+ }).catch((error) => {
+ if (!(error instanceof MissingSecretError)) throw error;
+
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: error.message,
+ });
+ });
+
+ if (!result.success) {
+ logger.error(result.error);
+ return {
+ error: mapTestConnectionError(result.error),
+ };
+ }
+
+ const appId = await createAppIfNecessaryAsync(ctx.db, input.app);
+
+ const integrationId = createId();
+ await ctx.db.insert(integrations).values({
+ id: integrationId,
+ name: input.name,
+ url: input.url,
+ kind: input.kind,
+ appId,
+ });
+
+ if (input.secrets.length >= 1) {
+ await ctx.db.insert(integrationSecrets).values(
+ input.secrets.map((secret) => ({
+ kind: secret.kind,
+ value: encryptSecret(secret.value),
+ integrationId,
+ })),
+ );
+ }
+
+ logger.info("Created integration", {
+ id: integrationId,
+ name: input.name,
+ kind: input.kind,
+ url: input.url,
+ });
+
+ if (
+ input.attemptSearchEngineCreation &&
+ integrationDefs[input.kind].category.flatMap((category) => category).includes("search")
+ ) {
+ const icon = getIconUrl(input.kind);
+ await ctx.db.insert(searchEngines).values({
+ id: createId(),
+ name: input.name,
+ integrationId,
+ type: "fromIntegration",
+ iconUrl: icon,
+ short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name),
+ });
+ }
+ }),
+ update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
+
+ logger.info("Updating integration", {
+ id: input.id,
+ });
+
+ const integration = await ctx.db.query.integrations.findFirst({
+ where: eq(integrations.id, input.id),
+ with: {
+ secrets: true,
+ },
+ });
+
+ if (!integration) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Integration not found",
+ });
+ }
+
+ const testResult = await testConnectionAsync(
+ {
+ id: input.id,
+ name: input.name,
+ url: input.url,
+ kind: integration.kind,
+ secrets: input.secrets,
+ },
+ integration.secrets,
+ ).catch((error) => {
+ if (!(error instanceof MissingSecretError)) throw error;
+
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: error.message,
+ });
+ });
+
+ if (!testResult.success) {
+ logger.error(testResult.error);
+ return {
+ error: mapTestConnectionError(testResult.error),
+ };
+ }
+
+ await ctx.db
+ .update(integrations)
+ .set({
+ name: input.name,
+ url: input.url,
+ appId: input.appId,
+ })
+ .where(eq(integrations.id, input.id));
+
+ const changedSecrets = input.secrets.filter(
+ (secret): secret is { kind: IntegrationSecretKind; value: string } =>
+ secret.value !== null && // only update secrets that have a value
+ !integration.secrets.find(
+ // Checked above
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ (dbSecret) => dbSecret.kind === secret.kind && dbSecret.value === encryptSecret(secret.value!),
+ ),
+ );
+
+ if (changedSecrets.length > 0) {
+ for (const changedSecret of changedSecrets) {
+ const secretInput = {
+ integrationId: input.id,
+ value: changedSecret.value,
+ kind: changedSecret.kind,
+ };
+ if (!integration.secrets.some((secret) => secret.kind === changedSecret.kind)) {
+ await addSecretAsync(ctx.db, secretInput);
+ } else {
+ await updateSecretAsync(ctx.db, secretInput);
+ }
+ }
+ }
+
+ const removedSecrets = integration.secrets.filter(
+ (dbSecret) => !input.secrets.some((secret) => dbSecret.kind === secret.kind),
+ );
+ if (removedSecrets.length >= 1) {
+ await ctx.db
+ .delete(integrationSecrets)
+ .where(
+ or(
+ ...removedSecrets.map((secret) =>
+ and(eq(integrationSecrets.integrationId, input.id), eq(integrationSecrets.kind, secret.kind)),
+ ),
+ ),
+ );
+ }
+
+ logger.info("Updated integration", {
+ id: input.id,
+ name: input.name,
+ kind: integration.kind,
+ url: input.url,
+ });
+ }),
+ delete: protectedProcedure.input(byIdSchema).mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
+
+ const integration = await ctx.db.query.integrations.findFirst({
+ where: eq(integrations.id, input.id),
+ });
+
+ if (!integration) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Integration not found",
+ });
+ }
+
+ await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
+ }),
+ getIntegrationPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
+
+ const dbGroupPermissions = await ctx.db.query.groupPermissions.findMany({
+ where: inArray(
+ groupPermissions.permission,
+ getPermissionsWithParents(["integration-use-all", "integration-interact-all", "integration-full-all"]),
+ ),
+ columns: {
+ groupId: false,
+ },
+ with: {
+ group: {
+ columns: {
+ id: true,
+ name: true,
+ },
+ },
+ },
+ });
+
+ const userPermissions = await ctx.db.query.integrationUserPermissions.findMany({
+ where: eq(integrationUserPermissions.integrationId, input.id),
+ with: {
+ user: {
+ columns: {
+ id: true,
+ name: true,
+ image: true,
+ email: true,
+ },
+ },
+ },
+ });
+
+ const dbGroupIntegrationPermission = await ctx.db.query.integrationGroupPermissions.findMany({
+ where: eq(integrationGroupPermissions.integrationId, input.id),
+ with: {
+ group: {
+ columns: {
+ id: true,
+ name: true,
+ },
+ },
+ },
+ });
+
+ return {
+ inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
+ return permissionA.group.name.localeCompare(permissionB.group.name);
+ }),
+ users: userPermissions
+ .map(({ user, permission }) => ({
+ user,
+ permission,
+ }))
+ .sort((permissionA, permissionB) => {
+ return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
+ }),
+ groups: dbGroupIntegrationPermission
+ .map(({ group, permission }) => ({
+ group: {
+ id: group.id,
+ name: group.name,
+ },
+ permission,
+ }))
+ .sort((permissionA, permissionB) => {
+ return permissionA.group.name.localeCompare(permissionB.group.name);
+ }),
+ };
+ }),
+ saveUserIntegrationPermissions: protectedProcedure
+ .input(integrationSavePermissionsSchema)
+ .mutation(async ({ input, ctx }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full");
+
+ await handleTransactionsAsync(ctx.db, {
+ async handleAsync(db, schema) {
+ await ctx.db.transaction(async (transaction) => {
+ await transaction
+ .delete(schema.integrationUserPermissions)
+ .where(eq(schema.integrationUserPermissions.integrationId, input.entityId));
+ if (input.permissions.length === 0) {
+ return;
+ }
+ await transaction.insert(schema.integrationUserPermissions).values(
+ input.permissions.map((permission) => ({
+ userId: permission.principalId,
+ permission: permission.permission,
+ integrationId: input.entityId,
+ })),
+ );
+ });
+ },
+ handleSync(db) {
+ db.transaction((transaction) => {
+ transaction
+ .delete(integrationUserPermissions)
+ .where(eq(integrationUserPermissions.integrationId, input.entityId))
+ .run();
+ if (input.permissions.length === 0) {
+ return;
+ }
+ transaction
+ .insert(integrationUserPermissions)
+ .values(
+ input.permissions.map((permission) => ({
+ userId: permission.principalId,
+ permission: permission.permission,
+ integrationId: input.entityId,
+ })),
+ )
+ .run();
+ });
+ },
+ });
+ }),
+ saveGroupIntegrationPermissions: protectedProcedure
+ .input(integrationSavePermissionsSchema)
+ .mutation(async ({ input, ctx }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full");
+
+ await handleTransactionsAsync(ctx.db, {
+ async handleAsync(db, schema) {
+ await db.transaction(async (transaction) => {
+ await transaction
+ .delete(schema.integrationGroupPermissions)
+ .where(eq(schema.integrationGroupPermissions.integrationId, input.entityId));
+ if (input.permissions.length === 0) {
+ return;
+ }
+ await transaction.insert(schema.integrationGroupPermissions).values(
+ input.permissions.map((permission) => ({
+ groupId: permission.principalId,
+ permission: permission.permission,
+ integrationId: input.entityId,
+ })),
+ );
+ });
+ },
+ handleSync(db) {
+ db.transaction((transaction) => {
+ transaction
+ .delete(integrationGroupPermissions)
+ .where(eq(integrationGroupPermissions.integrationId, input.entityId))
+ .run();
+ if (input.permissions.length === 0) {
+ return;
+ }
+ transaction
+ .insert(integrationGroupPermissions)
+ .values(
+ input.permissions.map((permission) => ({
+ groupId: permission.principalId,
+ permission: permission.permission,
+ integrationId: input.entityId,
+ })),
+ )
+ .run();
+ });
+ },
+ });
+ }),
+ searchInIntegration: protectedProcedure
+ .concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("search")))
+ .input(z.object({ integrationId: z.string(), query: z.string() }))
+ .query(async ({ ctx, input }) => {
+ const integrationInstance = await createIntegrationAsync(ctx.integration);
+ return await integrationInstance.searchAsync(encodeURI(input.query));
+ }),
+});
+
+interface UpdateSecretInput {
+ integrationId: string;
+ value: string;
+ kind: IntegrationSecretKind;
+}
+const updateSecretAsync = async (db: Database, input: UpdateSecretInput) => {
+ await db
+ .update(integrationSecrets)
+ .set({
+ value: encryptSecret(input.value),
+ })
+ .where(and(eq(integrationSecrets.integrationId, input.integrationId), eq(integrationSecrets.kind, input.kind)));
+};
+
+interface AddSecretInput {
+ integrationId: string;
+ value: string;
+ kind: IntegrationSecretKind;
+}
+
+const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => {
+ const searchEngines = await db.query.searchEngines.findMany({
+ columns: {
+ short: true,
+ },
+ });
+
+ const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase());
+ const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase();
+
+ if (!usedShortNames.includes(nameByIntegrationName)) {
+ return nameByIntegrationName;
+ }
+
+ // 8 is max length constraint
+ for (let i = 2; i < 9999999; i++) {
+ const generatedName = `${nameByIntegrationName}${i}`;
+ if (usedShortNames.includes(generatedName)) {
+ continue;
+ }
+
+ return generatedName;
+ }
+
+ throw new Error(
+ "Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.",
+ );
+};
+
+const addSecretAsync = async (db: Database, input: AddSecretInput) => {
+ await db.insert(integrationSecrets).values({
+ kind: input.kind,
+ value: encryptSecret(input.value),
+ integrationId: input.integrationId,
+ });
+};
+
+const createAppIfNecessaryAsync = async (db: Database, app: z.infer["app"]) => {
+ if (!app) return null;
+ if ("id" in app) return app.id;
+
+ logger.info("Creating app", {
+ name: app.name,
+ url: app.href,
+ });
+ const appId = createId();
+ await db.insert(apps).values({
+ id: appId,
+ name: app.name,
+ description: app.description,
+ iconUrl: app.iconUrl,
+ href: app.href,
+ pingUrl: app.pingUrl,
+ });
+
+ logger.info("Created app", {
+ id: appId,
+ name: app.name,
+ url: app.href,
+ });
+
+ return appId;
+};
diff --git a/packages/api/src/router/integration/integration-test-connection.ts b/packages/api/src/router/integration/integration-test-connection.ts
new file mode 100644
index 000000000..a1c735f25
--- /dev/null
+++ b/packages/api/src/router/integration/integration-test-connection.ts
@@ -0,0 +1,154 @@
+import { decryptSecret } from "@homarr/common/server";
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
+import type { Integration } from "@homarr/db/schema";
+import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
+import { getAllSecretKindOptions } from "@homarr/definitions";
+import { createIntegrationAsync } from "@homarr/integrations";
+
+const logger = createLogger({ module: "integrationTestConnection" });
+
+type FormIntegration = Omit & {
+ secrets: {
+ kind: IntegrationSecretKind;
+ value: string | null;
+ }[];
+};
+
+export const testConnectionAsync = async (
+ integration: FormIntegration,
+ dbSecrets: {
+ kind: IntegrationSecretKind;
+ value: `${string}.${string}`;
+ }[] = [],
+) => {
+ logger.info("Testing connection", {
+ integrationName: integration.name,
+ integrationKind: integration.kind,
+ integrationUrl: integration.url,
+ });
+
+ const decryptedDbSecrets = dbSecrets
+ .map((secret) => {
+ try {
+ return {
+ ...secret,
+ value: decryptSecret(secret.value),
+ source: "db" as const,
+ };
+ } catch (error) {
+ logger.warn(
+ new ErrorWithMetadata(
+ "Failed to decrypt secret from database",
+ {
+ integrationName: integration.name,
+ integrationKind: integration.kind,
+ secretKind: secret.kind,
+ },
+ { cause: error },
+ ),
+ );
+ return null;
+ }
+ })
+ .filter((secret) => secret !== null);
+
+ const formSecrets = integration.secrets
+ .map((secret) => ({
+ ...secret,
+ // If the value is not defined in the form (because we only changed other values) we use the existing value from the db if it exists
+ value: secret.value ?? decryptedDbSecrets.find((dbSecret) => dbSecret.kind === secret.kind)?.value ?? null,
+ source: "form" as const,
+ }))
+ .filter((secret): secret is SourcedIntegrationSecret<"form"> => secret.value !== null);
+
+ const sourcedSecrets = [...formSecrets, ...decryptedDbSecrets];
+ const secretKinds = getSecretKindOption(integration.kind, sourcedSecrets);
+
+ const decryptedSecrets = secretKinds
+ .map((kind) => {
+ const secrets = sourcedSecrets.filter((secret) => secret.kind === kind);
+ // Will never be undefined because of the check before
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ if (secrets.length === 1) return secrets[0]!;
+
+ // There will always be a matching secret because of the getSecretKindOption function
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ return secrets.find((secret) => secret.source === "form") ?? secrets[0]!;
+ })
+ .map(({ source: _, ...secret }) => secret);
+
+ const { secrets: _, ...baseIntegration } = integration;
+
+ const integrationInstance = await createIntegrationAsync({
+ ...baseIntegration,
+ decryptedSecrets,
+ externalUrl: null,
+ });
+
+ const result = await integrationInstance.testConnectionAsync();
+ if (result.success) {
+ logger.info("Tested connection successfully", {
+ integrationName: integration.name,
+ integrationKind: integration.kind,
+ integrationUrl: integration.url,
+ });
+ }
+ return result;
+};
+
+interface SourcedIntegrationSecret {
+ kind: IntegrationSecretKind;
+ value: string;
+ source: TSource;
+}
+
+const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedIntegrationSecret[]) => {
+ const matchingSecretKindOptions = getAllSecretKindOptions(kind).filter((secretKinds) =>
+ secretKinds.every((kind) => sourcedSecrets.some((secret) => secret.kind === kind)),
+ );
+
+ if (matchingSecretKindOptions.length === 0) {
+ throw new MissingSecretError();
+ }
+
+ if (matchingSecretKindOptions.length === 1) {
+ // Will never be undefined because of the check above
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ return matchingSecretKindOptions[0]!;
+ }
+
+ const onlyFormSecretsKindOptions = matchingSecretKindOptions.filter((secretKinds) =>
+ secretKinds.every((secretKind) =>
+ sourcedSecrets.find((secret) => secret.kind === secretKind && secret.source === "form"),
+ ),
+ );
+
+ if (onlyFormSecretsKindOptions.length >= 1) {
+ // If the first option is no secret it would always be selected even if we want to have a secret
+ if (
+ onlyFormSecretsKindOptions.length >= 2 &&
+ onlyFormSecretsKindOptions.some((secretKinds) => secretKinds.length === 0)
+ ) {
+ return (
+ // Will never be undefined because of the check above
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ onlyFormSecretsKindOptions.find((secretKinds) => secretKinds.length >= 1) ?? onlyFormSecretsKindOptions[0]!
+ );
+ }
+
+ // Will never be undefined because of the check above
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ return onlyFormSecretsKindOptions[0]!;
+ }
+
+ // Will never be undefined because of the check above
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ return matchingSecretKindOptions[0]!;
+};
+
+export class MissingSecretError extends Error {
+ constructor() {
+ super("No secret defined for this integration");
+ }
+}
diff --git a/packages/api/src/router/integration/map-test-connection-error.ts b/packages/api/src/router/integration/map-test-connection-error.ts
new file mode 100644
index 000000000..d0805b6ec
--- /dev/null
+++ b/packages/api/src/router/integration/map-test-connection-error.ts
@@ -0,0 +1,143 @@
+import type { X509Certificate } from "node:crypto";
+
+import type { RequestErrorCode } from "@homarr/common/server";
+import type {
+ AnyTestConnectionError,
+ TestConnectionErrorDataOfType,
+ TestConnectionErrorType,
+} from "@homarr/integrations/test-connection";
+
+export interface MappedError {
+ name: string;
+ message: string;
+ metadata: { key: string; value: string | number | boolean }[];
+ cause?: MappedError;
+}
+
+const ignoredErrorProperties = ["name", "message", "cause", "stack"];
+const mapError = (error: Error): MappedError => {
+ const metadata = Object.entries(error)
+ .filter(([key]) => !ignoredErrorProperties.includes(key))
+ .map(([key, value]) => {
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
+ return { key, value };
+ }
+ return null;
+ })
+ .filter((value) => value !== null);
+ return {
+ name: error.name,
+ message: error.message,
+ metadata,
+ cause: error.cause && error.cause instanceof Error ? mapError(error.cause) : undefined,
+ };
+};
+
+export interface MappedCertificate {
+ isSelfSigned: boolean;
+ issuer: string;
+ issuerCertificate?: MappedCertificate;
+ subject: string;
+ serialNumber: string;
+ validFrom: Date;
+ validTo: Date;
+ fingerprint: string;
+ pem: string;
+}
+
+const mapCertificate = (certificate: X509Certificate, code: RequestErrorCode): MappedCertificate => ({
+ isSelfSigned: certificate.ca || code === "DEPTH_ZERO_SELF_SIGNED_CERT",
+ issuer: certificate.issuer,
+ issuerCertificate: certificate.issuerCertificate ? mapCertificate(certificate.issuerCertificate, code) : undefined,
+ subject: certificate.subject,
+ serialNumber: certificate.serialNumber,
+ validFrom: certificate.validFromDate,
+ validTo: certificate.validToDate,
+ fingerprint: certificate.fingerprint256,
+ pem: certificate.toString(),
+});
+
+type MappedData = TType extends "unknown" | "parse"
+ ? undefined
+ : TType extends "certificate"
+ ? {
+ type: TestConnectionErrorDataOfType["requestError"]["type"];
+ reason: TestConnectionErrorDataOfType["requestError"]["reason"];
+ certificate: MappedCertificate;
+ }
+ : TType extends "request"
+ ? {
+ type: TestConnectionErrorDataOfType["requestError"]["type"];
+ reason: TestConnectionErrorDataOfType["requestError"]["reason"];
+ }
+ : TType extends "authorization"
+ ? {
+ statusCode: TestConnectionErrorDataOfType["statusCode"];
+ reason: TestConnectionErrorDataOfType["reason"];
+ }
+ : TType extends "statusCode"
+ ? {
+ statusCode: TestConnectionErrorDataOfType["statusCode"];
+ reason: TestConnectionErrorDataOfType["reason"];
+ url: TestConnectionErrorDataOfType["url"];
+ }
+ : never;
+
+type AnyMappedData = {
+ [TType in TestConnectionErrorType]: MappedData;
+}[TestConnectionErrorType];
+
+const mapData = (error: AnyTestConnectionError): AnyMappedData => {
+ if (error.type === "unknown") return undefined;
+ if (error.type === "parse") return undefined;
+ if (error.type === "certificate") {
+ return {
+ type: error.data.requestError.type,
+ reason: error.data.requestError.reason,
+ certificate: mapCertificate(error.data.certificate, error.data.requestError.code),
+ };
+ }
+ if (error.type === "request") {
+ return {
+ type: error.data.requestError.type,
+ reason: error.data.requestError.reason,
+ };
+ }
+ if (error.type === "authorization") {
+ return {
+ statusCode: error.data.statusCode,
+ reason: error.data.reason,
+ };
+ }
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (error.type === "statusCode") {
+ return {
+ statusCode: error.data.statusCode,
+ reason: error.data.reason,
+ url: error.data.url,
+ };
+ }
+
+ throw new Error(`Unsupported error type: ${(error as AnyTestConnectionError).type}`);
+};
+
+interface MappedTestConnectionError {
+ type: TType;
+ name: string;
+ message: string;
+ data: MappedData;
+ cause?: MappedError;
+}
+export type AnyMappedTestConnectionError = {
+ [TType in TestConnectionErrorType]: MappedTestConnectionError;
+}[TestConnectionErrorType];
+
+export const mapTestConnectionError = (error: AnyTestConnectionError) => {
+ return {
+ type: error.type,
+ name: error.name,
+ message: error.message,
+ data: mapData(error),
+ cause: error.cause ? mapError(error.cause) : undefined,
+ } as AnyMappedTestConnectionError;
+};
diff --git a/packages/api/src/router/invite.ts b/packages/api/src/router/invite.ts
new file mode 100644
index 000000000..1b767748d
--- /dev/null
+++ b/packages/api/src/router/invite.ts
@@ -0,0 +1,95 @@
+import { randomBytes } from "crypto";
+import { TRPCError } from "@trpc/server";
+import { z } from "zod/v4";
+
+import { createId } from "@homarr/common";
+import { asc, eq } from "@homarr/db";
+import { invites } from "@homarr/db/schema";
+import { selectInviteSchema } from "@homarr/db/validationSchemas";
+
+import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
+import { throwIfCredentialsDisabled } from "./invite/checks";
+
+export const inviteRouter = createTRPCRouter({
+ getAll: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .output(
+ z.array(
+ selectInviteSchema
+ .pick({
+ id: true,
+ expirationDate: true,
+ })
+ .extend({ creator: z.object({ name: z.string().nullable(), id: z.string() }) }),
+ ),
+ )
+ .input(z.undefined())
+ .meta({ openapi: { method: "GET", path: "/api/invites", tags: ["invites"], protect: true } })
+ .query(async ({ ctx }) => {
+ throwIfCredentialsDisabled();
+ return await ctx.db.query.invites.findMany({
+ orderBy: asc(invites.expirationDate),
+ columns: {
+ token: false,
+ },
+ with: {
+ creator: {
+ columns: {
+ id: true,
+ name: true,
+ },
+ },
+ },
+ });
+ }),
+ createInvite: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(
+ z.object({
+ expirationDate: z.date(),
+ }),
+ )
+ .output(z.object({ id: z.string(), token: z.string() }))
+ .meta({ openapi: { method: "POST", path: "/api/invites", tags: ["invites"], protect: true } })
+ .mutation(async ({ ctx, input }) => {
+ throwIfCredentialsDisabled();
+ const id = createId();
+ const token = randomBytes(20).toString("hex");
+
+ await ctx.db.insert(invites).values({
+ id,
+ expirationDate: input.expirationDate,
+ creatorId: ctx.session.user.id,
+ token,
+ });
+
+ return {
+ id,
+ token,
+ };
+ }),
+ deleteInvite: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(
+ z.object({
+ id: z.string(),
+ }),
+ )
+ .output(z.undefined())
+ .meta({ openapi: { method: "DELETE", path: "/api/invites/{id}", tags: ["invites"], protect: true } })
+ .mutation(async ({ ctx, input }) => {
+ throwIfCredentialsDisabled();
+ const dbInvite = await ctx.db.query.invites.findFirst({
+ where: eq(invites.id, input.id),
+ });
+
+ if (!dbInvite) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Invite not found",
+ });
+ }
+
+ await ctx.db.delete(invites).where(eq(invites.id, input.id));
+ }),
+});
diff --git a/packages/api/src/router/invite/checks.ts b/packages/api/src/router/invite/checks.ts
new file mode 100644
index 000000000..10585d8b2
--- /dev/null
+++ b/packages/api/src/router/invite/checks.ts
@@ -0,0 +1,12 @@
+import { TRPCError } from "@trpc/server";
+
+import { env } from "@homarr/auth/env";
+
+export const throwIfCredentialsDisabled = () => {
+ if (!env.AUTH_PROVIDERS.includes("credentials")) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Credentials provider is disabled",
+ });
+ }
+};
diff --git a/packages/api/src/router/kubernetes/kubernetes-client.ts b/packages/api/src/router/kubernetes/kubernetes-client.ts
new file mode 100644
index 000000000..29714b358
--- /dev/null
+++ b/packages/api/src/router/kubernetes/kubernetes-client.ts
@@ -0,0 +1,71 @@
+import * as fs from "fs";
+import { CoreV1Api, KubeConfig, Metrics, NetworkingV1Api, VersionApi } from "@kubernetes/client-node";
+
+import { env } from "../../env";
+
+export class KubernetesClient {
+ private static instance: KubernetesClient | null = null;
+ public kubeConfig: KubeConfig;
+ public coreApi: CoreV1Api;
+ public networkingApi: NetworkingV1Api;
+ public metricsApi: Metrics;
+ public versionApi: VersionApi;
+
+ private constructor() {
+ this.kubeConfig = new KubeConfig();
+
+ if (process.env.NODE_ENV === "development") {
+ this.kubeConfig.loadFromDefault();
+ } else {
+ this.kubeConfig.loadFromCluster();
+
+ const currentCluster = this.kubeConfig.getCurrentCluster();
+ if (!currentCluster) throw new Error("No cluster configuration found");
+
+ const token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8");
+ const caData = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", "utf8");
+
+ const clusterWithCA = {
+ ...currentCluster,
+ name: `${currentCluster.name}-service-account`,
+ caData,
+ };
+
+ const serviceAccountUser = {
+ name: env.KUBERNETES_SERVICE_ACCOUNT_NAME ?? "default-sa",
+ token,
+ };
+
+ this.kubeConfig.clusters = [];
+ this.kubeConfig.users = [];
+
+ this.kubeConfig.addCluster(clusterWithCA);
+ this.kubeConfig.addUser(serviceAccountUser);
+
+ const currentContext = this.kubeConfig.getCurrentContext();
+ const originalContext = this.kubeConfig.getContextObject(currentContext);
+ if (!originalContext) throw new Error("No context found");
+
+ const updatedContext = {
+ ...originalContext,
+ name: `${originalContext.name}-service-account`,
+ cluster: clusterWithCA.name,
+ user: serviceAccountUser.name,
+ };
+
+ this.kubeConfig.contexts = [];
+ this.kubeConfig.addContext(updatedContext);
+ this.kubeConfig.setCurrentContext(updatedContext.name);
+ }
+
+ this.coreApi = this.kubeConfig.makeApiClient(CoreV1Api);
+ this.networkingApi = this.kubeConfig.makeApiClient(NetworkingV1Api);
+ this.metricsApi = new Metrics(this.kubeConfig);
+ this.versionApi = this.kubeConfig.makeApiClient(VersionApi);
+ }
+
+ public static getInstance(): KubernetesClient {
+ KubernetesClient.instance ??= new KubernetesClient();
+ return KubernetesClient.instance;
+ }
+}
diff --git a/packages/api/src/router/kubernetes/resource-parser/cpu-resource-parser.ts b/packages/api/src/router/kubernetes/resource-parser/cpu-resource-parser.ts
new file mode 100644
index 000000000..d36017864
--- /dev/null
+++ b/packages/api/src/router/kubernetes/resource-parser/cpu-resource-parser.ts
@@ -0,0 +1,41 @@
+import type { ResourceParser } from "./resource-parser";
+
+export class CpuResourceParser implements ResourceParser {
+ private readonly billionthsCore = 1_000_000_000;
+ private readonly millionthsCore = 1_000_000;
+ private readonly MiliCore = 1_000;
+ private readonly ThousandCore = 1_000;
+
+ parse(value: string): number {
+ if (!value.length) {
+ return NaN;
+ }
+
+ value = value.replace(/,/g, "").trim();
+
+ const [, numericValue, unit = ""] = /^([0-9.]+)\s*([a-zA-Z]*)$/.exec(value) ?? [];
+
+ if (numericValue === undefined) {
+ return NaN;
+ }
+
+ const parsedValue = parseFloat(numericValue);
+
+ if (isNaN(parsedValue)) {
+ return NaN;
+ }
+
+ switch (unit.toLowerCase()) {
+ case "n": // nano-cores (billionths of a core)
+ return parsedValue / this.billionthsCore; // 1 NanoCPU = 1/1,000,000,000 cores
+ case "u": // micro-cores (millionths of a core)
+ return parsedValue / this.millionthsCore; // 1 MicroCPU = 1/1,000,000 cores
+ case "m": // milli-cores
+ return parsedValue / this.MiliCore; // 1 milli-core = 1/1000 cores
+ case "k": // thousands of cores
+ return parsedValue * this.ThousandCore; // 1 thousand-core = 1000 cores
+ default: // cores (no unit)
+ return parsedValue;
+ }
+ }
+}
diff --git a/packages/api/src/router/kubernetes/resource-parser/memory-resource-parser.ts b/packages/api/src/router/kubernetes/resource-parser/memory-resource-parser.ts
new file mode 100644
index 000000000..50a98b8ef
--- /dev/null
+++ b/packages/api/src/router/kubernetes/resource-parser/memory-resource-parser.ts
@@ -0,0 +1,69 @@
+import type { ResourceParser } from "./resource-parser";
+
+export class MemoryResourceParser implements ResourceParser {
+ private readonly binaryMultipliers: Record = {
+ ki: 1024,
+ mi: 1024 ** 2,
+ gi: 1024 ** 3,
+ ti: 1024 ** 4,
+ pi: 1024 ** 5,
+ } as const;
+
+ private readonly decimalMultipliers: Record = {
+ k: 1000,
+ m: 1000 ** 2,
+ g: 1000 ** 3,
+ t: 1000 ** 4,
+ p: 1000 ** 5,
+ } as const;
+
+ parse(value: string): number {
+ if (!value.length) {
+ return NaN;
+ }
+
+ value = value.replace(/,/g, "").trim();
+
+ const [, numericValue, unit = ""] = /^([0-9.]+)\s*([a-zA-Z]*)$/.exec(value) ?? [];
+
+ if (!numericValue) {
+ return NaN;
+ }
+
+ const parsedValue = parseFloat(numericValue);
+
+ if (isNaN(parsedValue)) {
+ return NaN;
+ }
+
+ const unitLower = unit.toLowerCase();
+
+ // Handle binary units (Ki, Mi, Gi, etc.)
+ if (unitLower in this.binaryMultipliers) {
+ const multiplier = this.binaryMultipliers[unitLower];
+ const giMultiplier = this.binaryMultipliers.gi;
+
+ if (multiplier !== undefined && giMultiplier !== undefined) {
+ return (parsedValue * multiplier) / giMultiplier;
+ }
+ }
+
+ // Handle decimal units (K, M, G, etc.)
+ if (unitLower in this.decimalMultipliers) {
+ const multiplier = this.decimalMultipliers[unitLower];
+ const giMultiplier = this.binaryMultipliers.gi;
+
+ if (multiplier !== undefined && giMultiplier !== undefined) {
+ return (parsedValue * multiplier) / giMultiplier;
+ }
+ }
+
+ // No unit or unrecognized unit, assume bytes and convert to GiB
+ const giMultiplier = this.binaryMultipliers.gi;
+ if (giMultiplier !== undefined) {
+ return parsedValue / giMultiplier;
+ }
+
+ return NaN; // Return NaN if giMultiplier is undefined
+ }
+}
diff --git a/packages/api/src/router/kubernetes/resource-parser/resource-parser.ts b/packages/api/src/router/kubernetes/resource-parser/resource-parser.ts
new file mode 100644
index 000000000..23b652af8
--- /dev/null
+++ b/packages/api/src/router/kubernetes/resource-parser/resource-parser.ts
@@ -0,0 +1,3 @@
+export interface ResourceParser {
+ parse(value: string): number;
+}
diff --git a/packages/api/src/router/kubernetes/router/cluster.ts b/packages/api/src/router/kubernetes/router/cluster.ts
new file mode 100644
index 000000000..adcb72389
--- /dev/null
+++ b/packages/api/src/router/kubernetes/router/cluster.ts
@@ -0,0 +1,204 @@
+import type { V1NodeList, VersionInfo } from "@kubernetes/client-node";
+import { TRPCError } from "@trpc/server";
+
+import type { ClusterResourceCount, KubernetesCluster } from "@homarr/definitions";
+
+import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
+import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
+import { KubernetesClient } from "../kubernetes-client";
+import { CpuResourceParser } from "../resource-parser/cpu-resource-parser";
+import { MemoryResourceParser } from "../resource-parser/memory-resource-parser";
+
+export const clusterRouter = createTRPCRouter({
+ getCluster: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .concat(kubernetesMiddleware())
+ .query(async (): Promise => {
+ const { coreApi, metricsApi, versionApi, kubeConfig } = KubernetesClient.getInstance();
+
+ try {
+ const versionInfo = await versionApi.getCode();
+ const nodes = await coreApi.listNode();
+ const nodeMetricsClient = await metricsApi.getNodeMetrics();
+ const listPodForAllNamespaces = await coreApi.listPodForAllNamespaces();
+
+ let totalCPUCapacity = 0;
+ let totalCPUAllocatable = 0;
+ let totalCPUUsage = 0;
+
+ let totalMemoryCapacity = 0;
+ let totalMemoryAllocatable = 0;
+ let totalMemoryUsage = 0;
+
+ let totalCapacityPods = 0;
+ const cpuResourceParser = new CpuResourceParser();
+ const memoryResourceParser = new MemoryResourceParser();
+
+ nodes.items.forEach((node) => {
+ totalCapacityPods += Number(node.status?.capacity?.pods);
+
+ const cpuCapacity = cpuResourceParser.parse(node.status?.capacity?.cpu ?? "0");
+ const cpuAllocatable = cpuResourceParser.parse(node.status?.allocatable?.cpu ?? "0");
+ totalCPUCapacity += cpuCapacity;
+ totalCPUAllocatable += cpuAllocatable;
+
+ const memoryCapacity = memoryResourceParser.parse(node.status?.capacity?.memory ?? "0");
+ const memoryAllocatable = memoryResourceParser.parse(node.status?.allocatable?.memory ?? "0");
+ totalMemoryCapacity += memoryCapacity;
+ totalMemoryAllocatable += memoryAllocatable;
+
+ const nodeName = node.metadata?.name;
+ const nodeMetric = nodeMetricsClient.items.find((metric) => metric.metadata.name === nodeName);
+ if (nodeMetric) {
+ const cpuUsage = cpuResourceParser.parse(nodeMetric.usage.cpu);
+ totalCPUUsage += cpuUsage;
+
+ const memoryUsage = memoryResourceParser.parse(nodeMetric.usage.memory);
+ totalMemoryUsage += memoryUsage;
+ }
+ });
+
+ const reservedCPU = totalCPUCapacity - totalCPUAllocatable;
+ const reservedMemory = totalMemoryCapacity - totalMemoryAllocatable;
+
+ const reservedCPUPercentage = (reservedCPU / totalCPUCapacity) * 100;
+ const reservedMemoryPercentage = (reservedMemory / totalMemoryCapacity) * 100;
+
+ const usagePercentageAllocatable = (totalCPUUsage / totalCPUAllocatable) * 100;
+ const usagePercentageMemoryAllocatable = (totalMemoryUsage / totalMemoryAllocatable) * 100;
+
+ const usedPodsPercentage = (listPodForAllNamespaces.items.length / totalCapacityPods) * 100;
+
+ return {
+ name: kubeConfig.getCurrentContext(),
+ providers: getProviders(versionInfo, nodes),
+ kubernetesVersion: versionInfo.gitVersion,
+ architecture: versionInfo.platform,
+ nodeCount: nodes.items.length,
+ capacity: [
+ {
+ type: "CPU",
+ resourcesStats: [
+ {
+ percentageValue: Number(reservedCPUPercentage.toFixed(2)),
+ type: "Reserved",
+ capacityUnit: "Cores",
+ usedValue: Number(reservedCPU.toFixed(2)),
+ maxUsedValue: Number(totalCPUCapacity.toFixed(2)),
+ },
+ {
+ percentageValue: Number(usagePercentageAllocatable.toFixed(2)),
+ type: "Used",
+ capacityUnit: "Cores",
+ usedValue: Number(totalCPUUsage.toFixed(2)),
+ maxUsedValue: Number(totalCPUAllocatable.toFixed(2)),
+ },
+ ],
+ },
+ {
+ type: "Memory",
+ resourcesStats: [
+ {
+ percentageValue: Number(reservedMemoryPercentage.toFixed(2)),
+ type: "Reserved",
+ capacityUnit: "GiB",
+ usedValue: Number(reservedMemory.toFixed(2)),
+ maxUsedValue: Number(totalMemoryCapacity.toFixed(2)),
+ },
+ {
+ percentageValue: Number(usagePercentageMemoryAllocatable.toFixed(2)),
+ type: "Used",
+ capacityUnit: "GiB",
+ usedValue: Number(totalMemoryUsage.toFixed(2)),
+ maxUsedValue: Number(totalMemoryAllocatable.toFixed(2)),
+ },
+ ],
+ },
+ {
+ type: "Pods",
+ resourcesStats: [
+ {
+ percentageValue: Number(usedPodsPercentage.toFixed(2)),
+ type: "Used",
+ usedValue: listPodForAllNamespaces.items.length,
+ maxUsedValue: totalCapacityPods,
+ },
+ ],
+ },
+ ],
+ };
+ } catch (error) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "An error occurred while fetching Kubernetes cluster",
+ cause: error,
+ });
+ }
+ }),
+ getClusterResourceCounts: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .query(async (): Promise => {
+ const { coreApi, networkingApi } = KubernetesClient.getInstance();
+
+ try {
+ const [pods, ingresses, services, configMaps, namespaces, nodes, secrets, volumes] = await Promise.all([
+ coreApi.listPodForAllNamespaces(),
+ networkingApi.listIngressForAllNamespaces(),
+ coreApi.listServiceForAllNamespaces(),
+ coreApi.listConfigMapForAllNamespaces(),
+ coreApi.listNamespace(),
+ coreApi.listNode(),
+ coreApi.listSecretForAllNamespaces(),
+ coreApi.listPersistentVolumeClaimForAllNamespaces(),
+ ]);
+
+ return [
+ { label: "nodes", count: nodes.items.length },
+ { label: "namespaces", count: namespaces.items.length },
+ { label: "ingresses", count: ingresses.items.length },
+ { label: "services", count: services.items.length },
+ { label: "pods", count: pods.items.length },
+ { label: "secrets", count: secrets.items.length },
+ { label: "configmaps", count: configMaps.items.length },
+ { label: "volumes", count: volumes.items.length },
+ ];
+ } catch (error) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "An error occurred while fetching Kubernetes resources count",
+ cause: error,
+ });
+ }
+ }),
+});
+
+function getProviders(versionInfo: VersionInfo, nodes: V1NodeList) {
+ const providers = new Set();
+
+ if (versionInfo.gitVersion.includes("k3s")) providers.add("k3s");
+ if (versionInfo.gitVersion.includes("gke")) providers.add("GKE");
+ if (versionInfo.gitVersion.includes("eks")) providers.add("EKS");
+ if (versionInfo.gitVersion.includes("aks")) providers.add("AKS");
+
+ nodes.items.forEach((node) => {
+ const labels = node.metadata?.labels ?? {};
+ const nodeProviderLabel = labels["node.kubernetes.io/instance-type"] ?? labels.provider ?? "";
+
+ if (nodeProviderLabel.includes("aws")) providers.add("EKS");
+ if (nodeProviderLabel.includes("azure")) providers.add("AKS");
+ if (nodeProviderLabel.includes("gce")) providers.add("GKE");
+ if (nodeProviderLabel.includes("k3s")) providers.add("k3s");
+
+ const nodeInfo = node.status?.nodeInfo;
+ if (nodeInfo) {
+ const osImage = nodeInfo.osImage.toLowerCase();
+ const kernelVersion = nodeInfo.kernelVersion.toLowerCase();
+
+ if (osImage.includes("talos") || kernelVersion.includes("talos")) {
+ providers.add("Talos");
+ }
+ }
+ });
+
+ return Array.from(providers).join(", ");
+}
diff --git a/packages/api/src/router/kubernetes/router/configMaps.ts b/packages/api/src/router/kubernetes/router/configMaps.ts
new file mode 100644
index 000000000..ac7edeeae
--- /dev/null
+++ b/packages/api/src/router/kubernetes/router/configMaps.ts
@@ -0,0 +1,34 @@
+import { TRPCError } from "@trpc/server";
+
+import type { KubernetesBaseResource } from "@homarr/definitions";
+
+import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
+import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
+import { KubernetesClient } from "../kubernetes-client";
+
+export const configMapsRouter = createTRPCRouter({
+ getConfigMaps: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .concat(kubernetesMiddleware())
+ .query(async (): Promise => {
+ const { coreApi } = KubernetesClient.getInstance();
+
+ try {
+ const configMaps = await coreApi.listConfigMapForAllNamespaces();
+
+ return configMaps.items.map((configMap) => {
+ return {
+ name: configMap.metadata?.name ?? "unknown",
+ namespace: configMap.metadata?.namespace ?? "unknown",
+ creationTimestamp: configMap.metadata?.creationTimestamp,
+ };
+ });
+ } catch (error) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "An error occurred while fetching Kubernetes ConfigMaps",
+ cause: error,
+ });
+ }
+ }),
+});
diff --git a/packages/api/src/router/kubernetes/router/ingresses.ts b/packages/api/src/router/kubernetes/router/ingresses.ts
new file mode 100644
index 000000000..a0ec41ce5
--- /dev/null
+++ b/packages/api/src/router/kubernetes/router/ingresses.ts
@@ -0,0 +1,52 @@
+import type { V1HTTPIngressPath, V1Ingress, V1IngressRule } from "@kubernetes/client-node";
+import { TRPCError } from "@trpc/server";
+
+import type { KubernetesIngress, KubernetesIngressPath, KubernetesIngressRuleAndPath } from "@homarr/definitions";
+
+import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
+import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
+import { KubernetesClient } from "../kubernetes-client";
+
+export const ingressesRouter = createTRPCRouter({
+ getIngresses: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .concat(kubernetesMiddleware())
+ .query(async (): Promise => {
+ const { networkingApi } = KubernetesClient.getInstance();
+ try {
+ const ingresses = await networkingApi.listIngressForAllNamespaces();
+
+ const mapIngress = (ingress: V1Ingress): KubernetesIngress => {
+ return {
+ name: ingress.metadata?.name ?? "",
+ namespace: ingress.metadata?.namespace ?? "",
+ className: ingress.spec?.ingressClassName ?? "",
+ rulesAndPaths: getIngressRulesAndPaths(ingress.spec?.rules ?? []),
+ creationTimestamp: ingress.metadata?.creationTimestamp,
+ };
+ };
+
+ const getIngressRulesAndPaths = (rules: V1IngressRule[] = []): KubernetesIngressRuleAndPath[] => {
+ return rules.map((rule) => ({
+ host: rule.host ?? "",
+ paths: getPaths(rule.http?.paths ?? []),
+ }));
+ };
+
+ const getPaths = (paths: V1HTTPIngressPath[] = []): KubernetesIngressPath[] => {
+ return paths.map((path) => ({
+ serviceName: path.backend.service?.name ?? "",
+ port: path.backend.service?.port?.number ?? 0,
+ }));
+ };
+
+ return ingresses.items.map(mapIngress);
+ } catch (error) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "An error occurred while fetching Kubernetes ingresses",
+ cause: error,
+ });
+ }
+ }),
+});
diff --git a/packages/api/src/router/kubernetes/router/kubernetes-router.ts b/packages/api/src/router/kubernetes/router/kubernetes-router.ts
new file mode 100644
index 000000000..ad9667c69
--- /dev/null
+++ b/packages/api/src/router/kubernetes/router/kubernetes-router.ts
@@ -0,0 +1,22 @@
+import { createTRPCRouter } from "../../../trpc";
+import { clusterRouter } from "./cluster";
+import { configMapsRouter } from "./configMaps";
+import { ingressesRouter } from "./ingresses";
+import { namespacesRouter } from "./namespaces";
+import { nodesRouter } from "./nodes";
+import { podsRouter } from "./pods";
+import { secretsRouter } from "./secrets";
+import { servicesRouter } from "./services";
+import { volumesRouter } from "./volumes";
+
+export const kubernetesRouter = createTRPCRouter({
+ nodes: nodesRouter,
+ cluster: clusterRouter,
+ namespaces: namespacesRouter,
+ ingresses: ingressesRouter,
+ services: servicesRouter,
+ pods: podsRouter,
+ secrets: secretsRouter,
+ configMaps: configMapsRouter,
+ volumes: volumesRouter,
+});
diff --git a/packages/api/src/router/kubernetes/router/namespaces.ts b/packages/api/src/router/kubernetes/router/namespaces.ts
new file mode 100644
index 000000000..f515af890
--- /dev/null
+++ b/packages/api/src/router/kubernetes/router/namespaces.ts
@@ -0,0 +1,34 @@
+import { TRPCError } from "@trpc/server";
+
+import type { KubernetesNamespace, KubernetesNamespaceState } from "@homarr/definitions";
+
+import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
+import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
+import { KubernetesClient } from "../kubernetes-client";
+
+export const namespacesRouter = createTRPCRouter({
+ getNamespaces: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .concat(kubernetesMiddleware())
+ .query(async (): Promise => {
+ const { coreApi } = KubernetesClient.getInstance();
+
+ try {
+ const namespaces = await coreApi.listNamespace();
+
+ return namespaces.items.map((namespace) => {
+ return {
+ status: namespace.status?.phase as KubernetesNamespaceState,
+ name: namespace.metadata?.name ?? "unknown",
+ creationTimestamp: namespace.metadata?.creationTimestamp,
+ } satisfies KubernetesNamespace;
+ });
+ } catch (error) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "An error occurred while fetching Kubernetes namespaces",
+ cause: error,
+ });
+ }
+ }),
+});
diff --git a/packages/api/src/router/kubernetes/router/nodes.ts b/packages/api/src/router/kubernetes/router/nodes.ts
new file mode 100644
index 000000000..c7afe2a8b
--- /dev/null
+++ b/packages/api/src/router/kubernetes/router/nodes.ts
@@ -0,0 +1,66 @@
+import { TRPCError } from "@trpc/server";
+
+import type { KubernetesNode, KubernetesNodeState } from "@homarr/definitions";
+
+import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
+import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
+import { KubernetesClient } from "../kubernetes-client";
+import { CpuResourceParser } from "../resource-parser/cpu-resource-parser";
+import { MemoryResourceParser } from "../resource-parser/memory-resource-parser";
+
+export const nodesRouter = createTRPCRouter({
+ getNodes: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .concat(kubernetesMiddleware())
+ .query(async (): Promise => {
+ const { coreApi, metricsApi } = KubernetesClient.getInstance();
+
+ try {
+ const nodes = await coreApi.listNode();
+ const nodeMetricsClient = await metricsApi.getNodeMetrics();
+ const cpuResourceParser = new CpuResourceParser();
+ const memoryResourceParser = new MemoryResourceParser();
+
+ return nodes.items.map((node) => {
+ const name = node.metadata?.name ?? "unknown";
+
+ const readyCondition = node.status?.conditions?.find((condition) => condition.type === "Ready");
+ const status: KubernetesNodeState = readyCondition?.status === "True" ? "Ready" : "NotReady";
+
+ const cpuAllocatable = cpuResourceParser.parse(node.status?.allocatable?.cpu ?? "0");
+
+ const memoryAllocatable = memoryResourceParser.parse(node.status?.allocatable?.memory ?? "0");
+
+ let cpuUsage = 0;
+ let memoryUsage = 0;
+
+ const nodeMetric = nodeMetricsClient.items.find((metric) => metric.metadata.name === name);
+ if (nodeMetric) {
+ cpuUsage += cpuResourceParser.parse(nodeMetric.usage.cpu);
+ memoryUsage += memoryResourceParser.parse(nodeMetric.usage.memory);
+ }
+
+ const usagePercentageCPUAllocatable = (cpuUsage / cpuAllocatable) * 100;
+ const usagePercentageMemoryAllocatable = (memoryUsage / memoryAllocatable) * 100;
+
+ return {
+ name,
+ status,
+ allocatableCpuPercentage: Number(usagePercentageCPUAllocatable.toFixed(0)),
+ allocatableRamPercentage: Number(usagePercentageMemoryAllocatable.toFixed(0)),
+ podsCount: Number(node.status?.capacity?.pods),
+ operatingSystem: node.status?.nodeInfo?.operatingSystem,
+ architecture: node.status?.nodeInfo?.architecture,
+ kubernetesVersion: node.status?.nodeInfo?.kubeletVersion,
+ creationTimestamp: node.metadata?.creationTimestamp,
+ };
+ });
+ } catch (error) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "An error occurred while fetching Kubernetes nodes",
+ cause: error,
+ });
+ }
+ }),
+});
diff --git a/packages/api/src/router/kubernetes/router/pods.ts b/packages/api/src/router/kubernetes/router/pods.ts
new file mode 100644
index 000000000..707e6ff58
--- /dev/null
+++ b/packages/api/src/router/kubernetes/router/pods.ts
@@ -0,0 +1,105 @@
+import type { KubeConfig, V1OwnerReference } from "@kubernetes/client-node";
+import { AppsV1Api } from "@kubernetes/client-node";
+import { TRPCError } from "@trpc/server";
+
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import type { KubernetesPod } from "@homarr/definitions";
+
+import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
+import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
+import { KubernetesClient } from "../kubernetes-client";
+
+const logger = createLogger({ module: "podsRouter" });
+
+export const podsRouter = createTRPCRouter({
+ getPods: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .concat(kubernetesMiddleware())
+ .query(async (): Promise => {
+ const { coreApi, kubeConfig } = KubernetesClient.getInstance();
+ try {
+ const podsResp = await coreApi.listPodForAllNamespaces();
+
+ const pods: KubernetesPod[] = [];
+
+ for (const pod of podsResp.items) {
+ const labels = pod.metadata?.labels ?? {};
+ const ownerRefs = pod.metadata?.ownerReferences ?? [];
+
+ let applicationType = "Pod";
+
+ if (labels["app.kubernetes.io/managed-by"] === "Helm") {
+ applicationType = "Helm";
+ } else {
+ for (const owner of ownerRefs) {
+ if (["Deployment", "StatefulSet", "DaemonSet"].includes(owner.kind)) {
+ applicationType = owner.kind;
+ break;
+ } else if (owner.kind === "ReplicaSet") {
+ const ownerType = await getOwnerKind(kubeConfig, owner, pod.metadata?.namespace ?? "");
+ if (ownerType) {
+ applicationType = ownerType;
+ break;
+ }
+ }
+ }
+ }
+
+ pods.push({
+ name: pod.metadata?.name ?? "",
+ namespace: pod.metadata?.namespace ?? "",
+ image: pod.spec?.containers.map((container) => container.image).join(", "),
+ applicationType,
+ status: pod.status?.phase ?? "unknown",
+ creationTimestamp: pod.metadata?.creationTimestamp,
+ });
+ }
+
+ return pods;
+ } catch (error) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "An error occurred while fetching Kubernetes pods",
+ cause: error,
+ });
+ }
+ }),
+});
+
+async function getOwnerKind(
+ kubeConfig: KubeConfig,
+ ownerRef: V1OwnerReference,
+ namespace: string,
+): Promise {
+ const { kind, name } = ownerRef;
+
+ if (kind === "ReplicaSet") {
+ const appsApi = kubeConfig.makeApiClient(AppsV1Api);
+ try {
+ const rsResp = await appsApi.readNamespacedReplicaSet({
+ name,
+ namespace,
+ });
+
+ if (rsResp.metadata?.ownerReferences) {
+ for (const rsOwner of rsResp.metadata.ownerReferences) {
+ if (rsOwner.kind === "Deployment") {
+ return "Deployment";
+ }
+ const parentKind = await getOwnerKind(kubeConfig, rsOwner, namespace);
+ if (parentKind) return parentKind;
+ }
+ }
+ return "ReplicaSet";
+ } catch (error) {
+ logger.error("Error reading ReplicaSet:", error);
+ return null;
+ }
+ }
+
+ if (["Deployment", "StatefulSet", "DaemonSet"].includes(kind)) {
+ return kind;
+ }
+
+ return null;
+}
diff --git a/packages/api/src/router/kubernetes/router/secrets.ts b/packages/api/src/router/kubernetes/router/secrets.ts
new file mode 100644
index 000000000..2bf9b5665
--- /dev/null
+++ b/packages/api/src/router/kubernetes/router/secrets.ts
@@ -0,0 +1,34 @@
+import { TRPCError } from "@trpc/server";
+
+import type { KubernetesSecret } from "@homarr/definitions";
+
+import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
+import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
+import { KubernetesClient } from "../kubernetes-client";
+
+export const secretsRouter = createTRPCRouter({
+ getSecrets: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .concat(kubernetesMiddleware())
+ .query(async (): Promise => {
+ const { coreApi } = KubernetesClient.getInstance();
+ try {
+ const secrets = await coreApi.listSecretForAllNamespaces();
+
+ return secrets.items.map((secret) => {
+ return {
+ name: secret.metadata?.name ?? "unknown",
+ namespace: secret.metadata?.namespace ?? "unknown",
+ type: secret.type ?? "unknown",
+ creationTimestamp: secret.metadata?.creationTimestamp,
+ };
+ });
+ } catch (error) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "An error occurred while fetching Kubernetes secrets",
+ cause: error,
+ });
+ }
+ }),
+});
diff --git a/packages/api/src/router/kubernetes/router/services.ts b/packages/api/src/router/kubernetes/router/services.ts
new file mode 100644
index 000000000..2d42cecfb
--- /dev/null
+++ b/packages/api/src/router/kubernetes/router/services.ts
@@ -0,0 +1,38 @@
+import { TRPCError } from "@trpc/server";
+
+import type { KubernetesService } from "@homarr/definitions";
+
+import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
+import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
+import { KubernetesClient } from "../kubernetes-client";
+
+export const servicesRouter = createTRPCRouter({
+ getServices: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .concat(kubernetesMiddleware())
+ .query(async (): Promise => {
+ const { coreApi } = KubernetesClient.getInstance();
+
+ try {
+ const services = await coreApi.listServiceForAllNamespaces();
+
+ return services.items.map((service) => {
+ return {
+ name: service.metadata?.name ?? "unknown",
+ namespace: service.metadata?.namespace ?? "",
+ type: service.spec?.type ?? "",
+ ports: service.spec?.ports?.map(({ port, protocol }) => `${port}/${protocol}`),
+ targetPorts: service.spec?.ports?.map(({ targetPort }) => `${targetPort}`),
+ clusterIP: service.spec?.clusterIP ?? "",
+ creationTimestamp: service.metadata?.creationTimestamp,
+ };
+ });
+ } catch (error) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "An error occurred while fetching Kubernetes services",
+ cause: error,
+ });
+ }
+ }),
+});
diff --git a/packages/api/src/router/kubernetes/router/volumes.ts b/packages/api/src/router/kubernetes/router/volumes.ts
new file mode 100644
index 000000000..d9c824a68
--- /dev/null
+++ b/packages/api/src/router/kubernetes/router/volumes.ts
@@ -0,0 +1,40 @@
+import { TRPCError } from "@trpc/server";
+
+import type { KubernetesVolume } from "@homarr/definitions";
+
+import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
+import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
+import { KubernetesClient } from "../kubernetes-client";
+
+export const volumesRouter = createTRPCRouter({
+ getVolumes: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .concat(kubernetesMiddleware())
+ .query(async (): Promise => {
+ const { coreApi } = KubernetesClient.getInstance();
+
+ try {
+ const volumes = await coreApi.listPersistentVolumeClaimForAllNamespaces();
+
+ return volumes.items.map((volume) => {
+ return {
+ name: volume.metadata?.name ?? "unknown",
+ namespace: volume.metadata?.namespace ?? "unknown",
+ accessModes: volume.status?.accessModes?.map((accessMode) => accessMode) ?? [],
+ storage: volume.status?.capacity?.storage ?? "",
+ storageClassName: volume.spec?.storageClassName ?? "",
+ volumeMode: volume.spec?.volumeMode ?? "",
+ volumeName: volume.spec?.volumeName ?? "",
+ status: volume.status?.phase ?? "",
+ creationTimestamp: volume.metadata?.creationTimestamp,
+ };
+ });
+ } catch (error) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "An error occurred while fetching Kubernetes Volumes",
+ cause: error,
+ });
+ }
+ }),
+});
diff --git a/packages/api/src/router/location.ts b/packages/api/src/router/location.ts
new file mode 100644
index 000000000..99bb9c3af
--- /dev/null
+++ b/packages/api/src/router/location.ts
@@ -0,0 +1,48 @@
+import { z } from "zod/v4";
+
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
+
+import { createTRPCRouter, publicProcedure } from "../trpc";
+
+const citySchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ country: z.string().optional(),
+ country_code: z.string().optional(),
+ latitude: z.number(),
+ longitude: z.number(),
+ population: z.number().optional(),
+});
+
+export const locationSearchCityInput = z.object({
+ query: z.string(),
+});
+
+export const locationSearchCityOutput = z
+ .object({
+ results: z.array(citySchema),
+ })
+ .or(
+ z
+ .object({
+ generationtime_ms: z.number(),
+ })
+ .refine((data) => Object.keys(data).length === 1, { message: "Invalid response" })
+ .transform(() => ({ results: [] })), // We fallback to empty array if no results
+ );
+
+export const locationRouter = createTRPCRouter({
+ searchCity: publicProcedure
+ .input(locationSearchCityInput)
+ .output(locationSearchCityOutput)
+ .query(async ({ input }) => {
+ const res = await withTimeoutAsync(async (signal) => {
+ return await fetchWithTrustedCertificatesAsync(
+ `https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`,
+ { signal },
+ );
+ });
+ return (await res.json()) as z.infer;
+ }),
+});
diff --git a/packages/api/src/router/log.ts b/packages/api/src/router/log.ts
new file mode 100644
index 000000000..10d44d0ab
--- /dev/null
+++ b/packages/api/src/router/log.ts
@@ -0,0 +1,35 @@
+import { observable } from "@trpc/server/observable";
+import z from "zod/v4";
+
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import { logLevels } from "@homarr/core/infrastructure/logs/constants";
+import type { LoggerMessage } from "@homarr/redis";
+import { loggingChannel } from "@homarr/redis";
+import { zodEnumFromArray } from "@homarr/validation/enums";
+
+import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
+
+const logger = createLogger({ module: "logRouter" });
+
+export const logRouter = createTRPCRouter({
+ subscribe: permissionRequiredProcedure
+ .requiresPermission("other-view-logs")
+ .input(
+ z.object({
+ levels: z.array(zodEnumFromArray(logLevels)).default(["info"]),
+ }),
+ )
+ .subscription(({ input }) => {
+ return observable((emit) => {
+ const unsubscribe = loggingChannel.subscribe((data) => {
+ if (!input.levels.includes(data.level)) return;
+ emit.next(data);
+ });
+ logger.info("A tRPC client has connected to the logging procedure");
+
+ return () => {
+ unsubscribe();
+ };
+ });
+ }),
+});
diff --git a/packages/api/src/router/medias/media-router.ts b/packages/api/src/router/medias/media-router.ts
new file mode 100644
index 000000000..7f12bb042
--- /dev/null
+++ b/packages/api/src/router/medias/media-router.ts
@@ -0,0 +1,128 @@
+import { TRPCError } from "@trpc/server";
+import { z } from "zod/v4";
+
+import { createId } from "@homarr/common";
+import type { InferInsertModel } from "@homarr/db";
+import { and, desc, eq, like } from "@homarr/db";
+import { iconRepositories, icons, medias } from "@homarr/db/schema";
+import { createLocalImageUrl, LOCAL_ICON_REPOSITORY_SLUG, mapMediaToIcon } from "@homarr/icons/local";
+import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
+import { mediaUploadSchema } from "@homarr/validation/media";
+
+import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
+
+export const mediaRouter = createTRPCRouter({
+ getPaginated: protectedProcedure
+ .input(
+ paginatedSchema.and(
+ z.object({ includeFromAllUsers: z.boolean().default(false), search: z.string().trim().default("") }),
+ ),
+ )
+ .query(async ({ ctx, input }) => {
+ const includeFromAllUsers = ctx.session.user.permissions.includes("media-view-all") && input.includeFromAllUsers;
+
+ const where = and(
+ input.search.length >= 1 ? like(medias.name, `%${input.search}%`) : undefined,
+ includeFromAllUsers ? undefined : eq(medias.creatorId, ctx.session.user.id),
+ );
+ const dbMedias = await ctx.db.query.medias.findMany({
+ where,
+ orderBy: desc(medias.createdAt),
+ limit: input.pageSize,
+ offset: (input.page - 1) * input.pageSize,
+ columns: {
+ content: false,
+ },
+ with: {
+ creator: {
+ columns: {
+ id: true,
+ name: true,
+ image: true,
+ email: true,
+ },
+ },
+ },
+ });
+
+ const totalCount = await ctx.db.$count(medias, where);
+
+ return {
+ items: dbMedias,
+ totalCount,
+ };
+ }),
+ uploadMedia: permissionRequiredProcedure
+ .requiresPermission("media-upload")
+ .input(mediaUploadSchema)
+ .mutation(async ({ ctx, input }) => {
+ const files = await Promise.all(
+ input.files.map(async (file) => ({
+ id: createId(),
+ meta: file,
+ content: Buffer.from(await file.arrayBuffer()),
+ })),
+ );
+ const insertMedias = files.map(
+ (file): InferInsertModel => ({
+ id: file.id,
+ creatorId: ctx.session.user.id,
+ content: file.content,
+ size: file.meta.size,
+ contentType: file.meta.type,
+ name: file.meta.name,
+ }),
+ );
+ await ctx.db.insert(medias).values(insertMedias);
+
+ const localIconRepository = await ctx.db.query.iconRepositories.findFirst({
+ where: eq(iconRepositories.slug, LOCAL_ICON_REPOSITORY_SLUG),
+ });
+
+ const ids = files.map((file) => file.id);
+ if (!localIconRepository) return ids;
+
+ await ctx.db.insert(icons).values(
+ insertMedias.map((media) => {
+ const icon = mapMediaToIcon(media);
+
+ return {
+ id: createId(),
+ checksum: icon.checksum,
+ name: icon.fileNameWithExtension,
+ url: icon.imageUrl,
+ iconRepositoryId: localIconRepository.id,
+ };
+ }),
+ );
+
+ return ids;
+ }),
+ deleteMedia: protectedProcedure.input(byIdSchema).mutation(async ({ ctx, input }) => {
+ const dbMedia = await ctx.db.query.medias.findFirst({
+ where: eq(medias.id, input.id),
+ columns: {
+ id: true,
+ creatorId: true,
+ },
+ });
+
+ if (!dbMedia) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Media not found",
+ });
+ }
+
+ // Only allow users with media-full-all permission and the creator of the media to delete it
+ if (!ctx.session.user.permissions.includes("media-full-all") && ctx.session.user.id !== dbMedia.creatorId) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "You don't have permission to delete this media",
+ });
+ }
+
+ await ctx.db.delete(medias).where(eq(medias.id, input.id));
+ await ctx.db.delete(icons).where(eq(icons.url, createLocalImageUrl(input.id)));
+ }),
+});
diff --git a/packages/api/src/router/onboard/onboard-queries.ts b/packages/api/src/router/onboard/onboard-queries.ts
new file mode 100644
index 000000000..15f205d8b
--- /dev/null
+++ b/packages/api/src/router/onboard/onboard-queries.ts
@@ -0,0 +1,81 @@
+import { isProviderEnabled } from "@homarr/auth/server";
+import { objectEntries } from "@homarr/common";
+import type { MaybePromise } from "@homarr/common/types";
+import type { Database } from "@homarr/db";
+import { eq } from "@homarr/db";
+import { groups, onboarding } from "@homarr/db/schema";
+import type { OnboardingStep } from "@homarr/definitions";
+import { credentialsAdminGroup } from "@homarr/definitions";
+
+export const nextOnboardingStepAsync = async (db: Database, preferredStep: OnboardingStep | undefined) => {
+ const { current } = await getOnboardingOrFallbackAsync(db);
+ const nextStepConfiguration = nextSteps[current];
+ if (!nextStepConfiguration) return;
+
+ for (const conditionalStep of objectEntries(nextStepConfiguration)) {
+ if (!conditionalStep) continue;
+ const [nextStep, condition] = conditionalStep;
+ if (condition === "preferred" && nextStep !== preferredStep) continue;
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (typeof condition === "boolean" && !condition) continue;
+ if (typeof condition === "function" && !(await condition(db))) continue;
+
+ await db.update(onboarding).set({
+ previousStep: current,
+ step: nextStep,
+ });
+ return;
+ }
+};
+
+export const getOnboardingOrFallbackAsync = async (db: Database) => {
+ const value = await db.query.onboarding.findFirst();
+ if (!value) return { current: "start" as const, previous: null };
+
+ return { current: value.step, previous: value.previousStep };
+};
+
+type NextStepCondition = true | "preferred" | ((db: Database) => MaybePromise);
+
+/**
+ * The below object is a definition of which can be the next step of the current one.
+ * If the value is `true`, it means the step can always be the next one.
+ * If the value is `preferred`, it means that the step can only be reached if the input `preferredStep` is set to the step.
+ * If the value is a function, it will be called with the database instance and should return a boolean.
+ * If the value or result is `false`, the step has to be skipped and the next value or callback should be checked.
+ */
+const nextSteps: Partial>>> = {
+ start: {
+ import: "preferred" as const,
+ user: () => isProviderEnabled("credentials"),
+ group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
+ settings: true,
+ },
+ import: {
+ // eslint-disable-next-line no-restricted-syntax
+ user: async (db: Database) => {
+ if (!isProviderEnabled("credentials")) return false;
+
+ const adminGroup = await db.query.groups.findFirst({
+ where: eq(groups.name, credentialsAdminGroup),
+ with: {
+ members: true,
+ },
+ });
+
+ return !adminGroup || adminGroup.members.length === 0;
+ },
+ group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
+ settings: true,
+ },
+ user: {
+ group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
+ settings: true,
+ },
+ group: {
+ settings: true,
+ },
+ settings: {
+ finish: true,
+ },
+};
diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts
new file mode 100644
index 000000000..14909b5b3
--- /dev/null
+++ b/packages/api/src/router/onboard/onboard-router.ts
@@ -0,0 +1,36 @@
+import { z } from "zod/v4";
+
+import { onboarding } from "@homarr/db/schema";
+import { onboardingSteps } from "@homarr/definitions";
+import { zodEnumFromArray } from "@homarr/validation/enums";
+
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries";
+
+export const onboardRouter = createTRPCRouter({
+ currentStep: publicProcedure.query(async ({ ctx }) => {
+ return await getOnboardingOrFallbackAsync(ctx.db);
+ }),
+ nextStep: publicProcedure
+ .input(
+ z.object({
+ // Preferred step is only needed for 'preferred' conditions
+ preferredStep: zodEnumFromArray(onboardingSteps).optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ await nextOnboardingStepAsync(ctx.db, input.preferredStep);
+ }),
+ previousStep: publicProcedure.mutation(async ({ ctx }) => {
+ const { previous } = await getOnboardingOrFallbackAsync(ctx.db);
+
+ if (previous !== "start") {
+ return;
+ }
+
+ await ctx.db.update(onboarding).set({
+ previousStep: null,
+ step: "start",
+ });
+ }),
+});
diff --git a/packages/api/src/router/search-engine/search-engine-router.ts b/packages/api/src/router/search-engine/search-engine-router.ts
new file mode 100644
index 000000000..ddfc892a9
--- /dev/null
+++ b/packages/api/src/router/search-engine/search-engine-router.ts
@@ -0,0 +1,219 @@
+import { TRPCError } from "@trpc/server";
+import { z } from "zod/v4";
+
+import { createId } from "@homarr/common";
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import { asc, eq, like } from "@homarr/db";
+import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
+import { searchEngines, users } from "@homarr/db/schema";
+import { createIntegrationAsync } from "@homarr/integrations";
+import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
+import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine";
+import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
+
+import { createOneIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
+
+const logger = createLogger({ module: "searchEngineRouter" });
+
+export const searchEngineRouter = createTRPCRouter({
+ getPaginated: protectedProcedure.input(paginatedSchema).query(async ({ input, ctx }) => {
+ const whereQuery = input.search ? like(searchEngines.name, `%${input.search.trim()}%`) : undefined;
+ const searchEngineCount = await ctx.db.$count(searchEngines, whereQuery);
+
+ const dbSearachEngines = await ctx.db.query.searchEngines.findMany({
+ limit: input.pageSize,
+ offset: (input.page - 1) * input.pageSize,
+ where: whereQuery,
+ });
+
+ return {
+ items: dbSearachEngines,
+ totalCount: searchEngineCount,
+ };
+ }),
+ getSelectable: protectedProcedure
+ .input(z.object({ withIntegrations: z.boolean() }).default({ withIntegrations: true }))
+ .query(async ({ ctx, input }) => {
+ return await ctx.db.query.searchEngines
+ .findMany({
+ orderBy: asc(searchEngines.name),
+ where: input.withIntegrations ? undefined : eq(searchEngines.type, "generic"),
+ columns: {
+ id: true,
+ name: true,
+ },
+ })
+ .then((engines) => engines.map((engine) => ({ value: engine.id, label: engine.name })));
+ }),
+
+ byId: protectedProcedure.input(byIdSchema).query(async ({ ctx, input }) => {
+ const searchEngine = await ctx.db.query.searchEngines.findFirst({
+ where: eq(searchEngines.id, input.id),
+ });
+
+ if (!searchEngine) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Search engine not found",
+ });
+ }
+
+ return searchEngine.type === "fromIntegration"
+ ? {
+ ...searchEngine,
+ type: "fromIntegration" as const,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ integrationId: searchEngine.integrationId!,
+ }
+ : {
+ ...searchEngine,
+ type: "generic" as const,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ urlTemplate: searchEngine.urlTemplate!,
+ };
+ }),
+ getDefaultSearchEngine: publicProcedure.query(async ({ ctx }) => {
+ const userDefaultId = ctx.session?.user.id
+ ? ((await ctx.db.query.users
+ .findFirst({
+ where: eq(users.id, ctx.session.user.id),
+ columns: {
+ defaultSearchEngineId: true,
+ },
+ })
+ .then((user) => user?.defaultSearchEngineId)) ?? null)
+ : null;
+
+ if (userDefaultId) {
+ return await ctx.db.query.searchEngines.findFirst({
+ where: eq(searchEngines.id, userDefaultId),
+ with: {
+ integration: {
+ columns: {
+ kind: true,
+ url: true,
+ id: true,
+ },
+ },
+ },
+ });
+ }
+
+ const searchSettings = await getServerSettingByKeyAsync(ctx.db, "search");
+
+ if (!searchSettings.defaultSearchEngineId) return null;
+
+ const serverDefault = await ctx.db.query.searchEngines.findFirst({
+ where: eq(searchEngines.id, searchSettings.defaultSearchEngineId),
+ with: {
+ integration: {
+ columns: {
+ kind: true,
+ url: true,
+ id: true,
+ },
+ },
+ },
+ });
+
+ if (serverDefault) return serverDefault;
+
+ // Remove the default search engine ID from settings if it does not longer exist
+ try {
+ await updateServerSettingByKeyAsync(ctx.db, "search", {
+ ...searchSettings,
+ defaultSearchEngineId: null,
+ });
+ } catch (error) {
+ logger.warn(
+ new Error("Failed to update search settings after default search engine not found", { cause: error }),
+ );
+ }
+
+ return null;
+ }),
+ search: protectedProcedure.input(searchSchema).query(async ({ ctx, input }) => {
+ return await ctx.db.query.searchEngines.findMany({
+ where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
+ with: {
+ integration: {
+ columns: {
+ kind: true,
+ url: true,
+ id: true,
+ },
+ },
+ },
+ limit: input.limit,
+ });
+ }),
+ getMediaRequestOptions: protectedProcedure
+ .concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr"))
+ .input(mediaRequestOptionsSchema)
+ .query(async ({ ctx, input }) => {
+ const integration = await createIntegrationAsync(ctx.integration);
+ return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
+ }),
+ requestMedia: protectedProcedure
+ .concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr"))
+ .input(mediaRequestRequestSchema)
+ .mutation(async ({ ctx, input }) => {
+ const integration = await createIntegrationAsync(ctx.integration);
+ return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
+ }),
+ create: permissionRequiredProcedure
+ .requiresPermission("search-engine-create")
+ .input(searchEngineManageSchema)
+ .mutation(async ({ ctx, input }) => {
+ await ctx.db.insert(searchEngines).values({
+ id: createId(),
+ name: input.name,
+ short: input.short.toLowerCase(),
+ iconUrl: input.iconUrl,
+ urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
+ description: input.description,
+ type: input.type,
+ integrationId: "integrationId" in input ? input.integrationId : null,
+ });
+ }),
+ update: permissionRequiredProcedure
+ .requiresPermission("search-engine-modify-all")
+ .input(searchEngineEditSchema)
+ .mutation(async ({ ctx, input }) => {
+ const searchEngine = await ctx.db.query.searchEngines.findFirst({
+ where: eq(searchEngines.id, input.id),
+ });
+
+ if (!searchEngine) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Search engine not found",
+ });
+ }
+
+ await ctx.db
+ .update(searchEngines)
+ .set({
+ name: input.name,
+ iconUrl: input.iconUrl,
+ urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
+ description: input.description,
+ integrationId: "integrationId" in input ? input.integrationId : null,
+ type: input.type,
+ })
+ .where(eq(searchEngines.id, input.id));
+ }),
+ delete: permissionRequiredProcedure
+ .requiresPermission("search-engine-full-all")
+ .input(byIdSchema)
+ .mutation(async ({ ctx, input }) => {
+ await ctx.db
+ .update(users)
+ .set({
+ defaultSearchEngineId: null,
+ })
+ .where(eq(users.defaultSearchEngineId, input.id));
+ await ctx.db.delete(searchEngines).where(eq(searchEngines.id, input.id));
+ }),
+});
diff --git a/packages/api/src/router/section/section-router.ts b/packages/api/src/router/section/section-router.ts
new file mode 100644
index 000000000..81613f581
--- /dev/null
+++ b/packages/api/src/router/section/section-router.ts
@@ -0,0 +1,52 @@
+import { TRPCError } from "@trpc/server";
+import { z } from "zod/v4";
+
+import { and, eq } from "@homarr/db";
+import { sectionCollapseStates, sections } from "@homarr/db/schema";
+
+import { createTRPCRouter, protectedProcedure } from "../../trpc";
+
+export const sectionRouter = createTRPCRouter({
+ changeCollapsed: protectedProcedure
+ .input(
+ z.object({
+ sectionId: z.string(),
+ collapsed: z.boolean(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const section = await ctx.db.query.sections.findFirst({
+ where: and(eq(sections.id, input.sectionId), eq(sections.kind, "category")),
+ with: {
+ collapseStates: {
+ where: eq(sectionCollapseStates.userId, ctx.session.user.id),
+ },
+ },
+ });
+
+ if (!section) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Section not found id=${input.sectionId}`,
+ });
+ }
+
+ if (section.collapseStates.length === 0) {
+ await ctx.db.insert(sectionCollapseStates).values({
+ sectionId: section.id,
+ userId: ctx.session.user.id,
+ collapsed: input.collapsed,
+ });
+ return;
+ }
+
+ await ctx.db
+ .update(sectionCollapseStates)
+ .set({
+ collapsed: input.collapsed,
+ })
+ .where(
+ and(eq(sectionCollapseStates.sectionId, section.id), eq(sectionCollapseStates.userId, ctx.session.user.id)),
+ );
+ }),
+});
diff --git a/packages/api/src/router/serverSettings.ts b/packages/api/src/router/serverSettings.ts
new file mode 100644
index 000000000..3a31a51b0
--- /dev/null
+++ b/packages/api/src/router/serverSettings.ts
@@ -0,0 +1,41 @@
+import { z } from "zod/v4";
+
+import { getServerSettingByKeyAsync, getServerSettingsAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
+import type { ServerSettings } from "@homarr/server-settings";
+import { defaultServerSettingsKeys } from "@homarr/server-settings";
+import { settingsInitSchema } from "@homarr/validation/settings";
+
+import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, publicProcedure } from "../trpc";
+import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
+
+export const serverSettingsRouter = createTRPCRouter({
+ getCulture: publicProcedure.query(async ({ ctx }) => {
+ return await getServerSettingByKeyAsync(ctx.db, "culture");
+ }),
+ getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
+ return await getServerSettingsAsync(ctx.db);
+ }),
+ saveSettings: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(
+ z.object({
+ settingsKey: z.enum(defaultServerSettingsKeys),
+ value: z.record(z.string(), z.unknown()),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ await updateServerSettingByKeyAsync(
+ ctx.db,
+ input.settingsKey,
+ input.value as ServerSettings[keyof ServerSettings],
+ );
+ }),
+ initSettings: onboardingProcedure
+ .requiresStep("settings")
+ .input(settingsInitSchema)
+ .mutation(async ({ ctx, input }) => {
+ await updateServerSettingByKeyAsync(ctx.db, "analytics", input.analytics);
+ await updateServerSettingByKeyAsync(ctx.db, "crawlingAndIndexing", input.crawlingAndIndexing);
+ await nextOnboardingStepAsync(ctx.db, undefined);
+ }),
+});
diff --git a/packages/api/src/router/test/app.spec.ts b/packages/api/src/router/test/app.spec.ts
new file mode 100644
index 000000000..f8877a403
--- /dev/null
+++ b/packages/api/src/router/test/app.spec.ts
@@ -0,0 +1,297 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+import { describe, expect, test, vi } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import { createId } from "@homarr/common";
+import { apps } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+import type { GroupPermissionKey } from "@homarr/definitions";
+
+import { appRouter } from "../app";
+import * as appAccessControl from "../app/app-access-control";
+
+// Mock the auth module to return an empty session
+vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
+
+const createDefaultSession = (permissions: GroupPermissionKey[] = []): Session => ({
+ user: { id: createId(), permissions, colorScheme: "light" },
+ expires: new Date().toISOString(),
+});
+
+describe("all should return all apps", () => {
+ test("should return all apps with session", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: createDefaultSession(),
+ });
+
+ await db.insert(apps).values([
+ {
+ id: "2",
+ name: "Mantine",
+ description: "React components and hooks library",
+ iconUrl: "https://mantine.dev/favicon.svg",
+ href: "https://mantine.dev",
+ },
+ {
+ id: "1",
+ name: "Tabler Icons",
+ iconUrl: "https://tabler.io/favicon.ico",
+ },
+ ]);
+
+ const result = await caller.all();
+ expect(result.length).toBe(2);
+ expect(result[0]!.id).toBe("2");
+ expect(result[1]!.id).toBe("1");
+ expect(result[0]!.href).toBeDefined();
+ expect(result[0]!.description).toBeDefined();
+ expect(result[1]!.href).toBeNull();
+ expect(result[1]!.description).toBeNull();
+ });
+ test("should throw UNAUTHORIZED if the user is not authenticated", async () => {
+ // Arrange
+ const caller = appRouter.createCaller({
+ db: createDb(),
+ deviceType: undefined,
+ session: null,
+ });
+
+ // Act
+ const actAsync = async () => await caller.all();
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("UNAUTHORIZED");
+ });
+});
+
+describe("byId should return an app by id", () => {
+ test("should return an app by id when canUserSeeAppAsync returns true", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: null,
+ });
+ vi.spyOn(appAccessControl, "canUserSeeAppAsync").mockReturnValue(Promise.resolve(true));
+
+ await db.insert(apps).values([
+ {
+ id: "2",
+ name: "Mantine",
+ description: "React components and hooks library",
+ iconUrl: "https://mantine.dev/favicon.svg",
+ href: "https://mantine.dev",
+ },
+ {
+ id: "1",
+ name: "Tabler Icons",
+ iconUrl: "https://tabler.io/favicon.ico",
+ },
+ ]);
+
+ // Act
+ const result = await caller.byId({ id: "2" });
+
+ // Assert
+ expect(result.name).toBe("Mantine");
+ });
+
+ test("should throw NOT_FOUND error when canUserSeeAppAsync returns false", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: null,
+ });
+ await db.insert(apps).values([
+ {
+ id: "2",
+ name: "Mantine",
+ description: "React components and hooks library",
+ iconUrl: "https://mantine.dev/favicon.svg",
+ href: "https://mantine.dev",
+ },
+ ]);
+ vi.spyOn(appAccessControl, "canUserSeeAppAsync").mockReturnValue(Promise.resolve(false));
+
+ // Act
+ const actAsync = async () => await caller.byId({ id: "2" });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("App not found");
+ });
+
+ test("should throw an error if the app does not exist", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: null,
+ });
+
+ // Act
+ const actAsync = async () => await caller.byId({ id: "2" });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("App not found");
+ });
+});
+
+describe("create should create a new app with all arguments", () => {
+ test("should create a new app", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: createDefaultSession(["app-create"]),
+ });
+ const input = {
+ name: "Mantine",
+ description: "React components and hooks library",
+ iconUrl: "https://mantine.dev/favicon.svg",
+ href: "https://mantine.dev",
+ pingUrl: "https://mantine.dev/a",
+ };
+
+ // Act
+ await caller.create(input);
+
+ // Assert
+ const dbApp = await db.query.apps.findFirst();
+ expect(dbApp).toBeDefined();
+ expect(dbApp!.name).toBe(input.name);
+ expect(dbApp!.description).toBe(input.description);
+ expect(dbApp!.iconUrl).toBe(input.iconUrl);
+ expect(dbApp!.href).toBe(input.href);
+ expect(dbApp!.pingUrl).toBe(input.pingUrl);
+ });
+
+ test("should create a new app only with required arguments", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: createDefaultSession(["app-create"]),
+ });
+ const input = {
+ name: "Mantine",
+ description: null,
+ iconUrl: "https://mantine.dev/favicon.svg",
+ href: null,
+ pingUrl: "",
+ };
+
+ // Act
+ await caller.create(input);
+
+ // Assert
+ const dbApp = await db.query.apps.findFirst();
+ expect(dbApp).toBeDefined();
+ expect(dbApp!.name).toBe(input.name);
+ expect(dbApp!.description).toBe(input.description);
+ expect(dbApp!.iconUrl).toBe(input.iconUrl);
+ expect(dbApp!.href).toBe(input.href);
+ expect(dbApp!.pingUrl).toBe(null);
+ });
+});
+
+describe("update should update an app", () => {
+ test("should update an app", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: createDefaultSession(["app-modify-all"]),
+ });
+
+ const appId = createId();
+ const toInsert = {
+ id: appId,
+ name: "Mantine",
+ iconUrl: "https://mantine.dev/favicon.svg",
+ };
+
+ await db.insert(apps).values(toInsert);
+
+ const input = {
+ id: appId,
+ name: "Mantine2",
+ description: "React components and hooks library",
+ iconUrl: "https://mantine.dev/favicon.svg2",
+ href: "https://mantine.dev",
+ pingUrl: "https://mantine.dev/a",
+ };
+
+ // Act
+ await caller.update(input);
+
+ // Assert
+ const dbApp = await db.query.apps.findFirst();
+
+ expect(dbApp).toBeDefined();
+ expect(dbApp!.name).toBe(input.name);
+ expect(dbApp!.description).toBe(input.description);
+ expect(dbApp!.iconUrl).toBe(input.iconUrl);
+ expect(dbApp!.href).toBe(input.href);
+ });
+
+ test("should throw an error if the app does not exist", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: createDefaultSession(["app-modify-all"]),
+ });
+
+ // Act
+ const actAsync = async () =>
+ await caller.update({
+ id: createId(),
+ name: "Mantine",
+ iconUrl: "https://mantine.dev/favicon.svg",
+ description: null,
+ href: null,
+ pingUrl: "",
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("App not found");
+ });
+});
+
+describe("delete should delete an app", () => {
+ test("should delete an app", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: createDefaultSession(["app-full-all"]),
+ });
+
+ const appId = createId();
+ await db.insert(apps).values({
+ id: appId,
+ name: "Mantine",
+ iconUrl: "https://mantine.dev/favicon.svg",
+ });
+
+ // Act
+ await caller.delete({ id: appId });
+
+ // Assert
+ const dbApp = await db.query.apps.findFirst();
+ expect(dbApp).toBeUndefined();
+ });
+});
diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts
new file mode 100644
index 000000000..44cc97adc
--- /dev/null
+++ b/packages/api/src/router/test/board.spec.ts
@@ -0,0 +1,1793 @@
+import SuperJSON from "superjson";
+import { describe, expect, it, test, vi } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import { createId } from "@homarr/common";
+import type { Database, InferInsertModel } from "@homarr/db";
+import { and, eq, not } from "@homarr/db";
+import {
+ boardGroupPermissions,
+ boards,
+ boardUserPermissions,
+ groupMembers,
+ groupPermissions,
+ groups,
+ integrationItems,
+ integrations,
+ itemLayouts,
+ items,
+ layouts,
+ sectionLayouts,
+ sections,
+ serverSettings,
+ users,
+} from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+import type { BoardPermission, GroupPermissionKey } from "@homarr/definitions";
+
+import type { RouterOutputs } from "../..";
+import { boardRouter } from "../board";
+import * as boardAccess from "../board/board-access";
+import { expectToBeDefined } from "./helper";
+
+const defaultCreatorId = createId();
+const defaultSession = {
+ user: {
+ id: defaultCreatorId,
+ permissions: [],
+ colorScheme: "light",
+ },
+ expires: new Date().toISOString(),
+} satisfies Session;
+
+// Mock the auth module to return an empty session
+vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
+
+const createRandomUserAsync = async (db: Database) => {
+ const userId = createId();
+ await db.insert(users).values({
+ id: userId,
+ homeBoardId: null,
+ });
+ return userId;
+};
+
+describe("getAllBoards should return all boards accessable to the current user", () => {
+ test("without session it should return only public boards", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: null });
+
+ const user1 = await createRandomUserAsync(db);
+ const user2 = await createRandomUserAsync(db);
+
+ await db.insert(boards).values([
+ {
+ id: createId(),
+ name: "public",
+ creatorId: user1,
+ isPublic: true,
+ },
+ {
+ id: createId(),
+ name: "private",
+ creatorId: user2,
+ isPublic: false,
+ },
+ ]);
+
+ // Act
+ const result = await caller.getAllBoards();
+
+ // Assert
+ expect(result.length).toBe(1);
+ expect(result[0]?.name).toBe("public");
+ });
+
+ test("with session containing board-view-all permission it should return all boards", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: {
+ user: {
+ id: defaultCreatorId,
+ permissions: ["board-view-all"],
+ colorScheme: "light",
+ },
+ expires: new Date().toISOString(),
+ },
+ });
+
+ const user1 = await createRandomUserAsync(db);
+ const user2 = await createRandomUserAsync(db);
+
+ await db.insert(boards).values([
+ {
+ id: createId(),
+ name: "public",
+ creatorId: user1,
+ isPublic: true,
+ },
+ {
+ id: createId(),
+ name: "private",
+ creatorId: user2,
+ isPublic: false,
+ },
+ ]);
+
+ // Act
+ const result = await caller.getAllBoards();
+
+ // Assert
+ expect(result.length).toBe(2);
+ expect(result.map((board) => board.name)).toEqual(["public", "private"]);
+ });
+
+ test("with session user beeing creator it should return all private boards of them", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const user1 = await createRandomUserAsync(db);
+ const user2 = await createRandomUserAsync(db);
+ await db.insert(users).values({
+ id: defaultCreatorId,
+ });
+
+ await db.insert(boards).values([
+ {
+ id: createId(),
+ name: "public",
+ creatorId: user1,
+ isPublic: true,
+ },
+ {
+ id: createId(),
+ name: "private",
+ creatorId: user2,
+ isPublic: false,
+ },
+ {
+ id: createId(),
+ name: "private2",
+ creatorId: defaultCreatorId,
+ isPublic: false,
+ },
+ ]);
+
+ // Act
+ const result = await caller.getAllBoards();
+
+ // Assert
+ expect(result.length).toBe(2);
+ expect(result.map(({ name }) => name)).toStrictEqual(["public", "private2"]);
+ });
+
+ test.each([["view"], ["modify"]] satisfies [BoardPermission][])(
+ "with %s group board permission it should show board",
+ async (permission) => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSession,
+ });
+
+ const user1 = await createRandomUserAsync(db);
+ const user2 = await createRandomUserAsync(db);
+ await db.insert(users).values({
+ id: defaultCreatorId,
+ });
+ const boardId = createId();
+
+ await db.insert(boards).values([
+ {
+ id: createId(),
+ name: "public",
+ creatorId: user1,
+ isPublic: true,
+ },
+ {
+ id: boardId,
+ name: "private1",
+ creatorId: user2,
+ isPublic: false,
+ },
+ {
+ id: createId(),
+ name: "private2",
+ creatorId: user2,
+ isPublic: false,
+ },
+ ]);
+
+ const groupId = createId();
+ await db.insert(groups).values({
+ id: groupId,
+ name: "group1",
+ position: 1,
+ });
+
+ await db.insert(groupMembers).values({
+ userId: defaultSession.user.id,
+ groupId,
+ });
+
+ await db.insert(boardGroupPermissions).values({
+ groupId,
+ permission,
+ boardId,
+ });
+
+ // Act
+ const result = await caller.getAllBoards();
+
+ // Assert
+ expect(result.length).toBe(2);
+ expect(result.map(({ name }) => name)).toStrictEqual(["public", "private1"]);
+ },
+ );
+
+ test.each([["view"], ["modify"]] satisfies [BoardPermission][])(
+ "with %s user board permission it should show board",
+ async (permission) => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSession,
+ });
+
+ const user1 = await createRandomUserAsync(db);
+ const user2 = await createRandomUserAsync(db);
+ await db.insert(users).values({
+ id: defaultCreatorId,
+ });
+ const boardId = createId();
+
+ await db.insert(boards).values([
+ {
+ id: createId(),
+ name: "public",
+ creatorId: user1,
+ isPublic: true,
+ },
+ {
+ id: boardId,
+ name: "private1",
+ creatorId: user2,
+ isPublic: false,
+ },
+ {
+ id: createId(),
+ name: "private2",
+ creatorId: user2,
+ isPublic: false,
+ },
+ ]);
+
+ await db.insert(boardUserPermissions).values({
+ userId: defaultSession.user.id,
+ permission,
+ boardId,
+ });
+
+ // Act
+ const result = await caller.getAllBoards();
+
+ // Assert
+ expect(result.length).toBe(2);
+ expect(result.map(({ name }) => name)).toStrictEqual(["public", "private1"]);
+ },
+ );
+});
+
+describe("createBoard should create a new board", () => {
+ test("should create a new board with permission board-create", async () => {
+ // Arrange
+ const db = createDb();
+ const session = {
+ ...defaultSession,
+ user: {
+ ...defaultSession.user,
+ permissions: ["board-create"] satisfies GroupPermissionKey[],
+ },
+ };
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
+
+ await db.insert(users).values({
+ id: defaultCreatorId,
+ });
+
+ // Act
+ await caller.createBoard({ name: "newBoard", columnCount: 24, isPublic: true });
+
+ // Assert
+ const dbBoard = await db.query.boards.findFirst({
+ with: {
+ sections: true,
+ layouts: true,
+ },
+ });
+ expect(dbBoard).toBeDefined();
+ expect(dbBoard?.name).toBe("newBoard");
+ expect(dbBoard?.isPublic).toBe(true);
+ expect(dbBoard?.creatorId).toBe(defaultCreatorId);
+
+ expect(dbBoard?.sections.length).toBe(1);
+ const firstSection = dbBoard?.sections.at(0);
+ expect(firstSection?.kind).toBe("empty");
+ expect(firstSection?.xOffset).toBe(0);
+ expect(firstSection?.yOffset).toBe(0);
+
+ expect(dbBoard?.layouts.length).toBe(1);
+ const firstLayout = dbBoard?.layouts.at(0);
+ expect(firstLayout?.columnCount).toBe(24);
+ expect(firstLayout?.breakpoint).toBe(0);
+ });
+
+ test("should throw error when user has no board-create permission", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ // Act
+ const actAsync = async () => await caller.createBoard({ name: "newBoard", columnCount: 12, isPublic: true });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrowError("Permission denied");
+ });
+});
+
+describe("rename board should rename board", () => {
+ test("should rename board", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+
+ await db.insert(users).values({
+ id: defaultCreatorId,
+ });
+ const boardId = createId();
+ await db.insert(boards).values({
+ id: boardId,
+ name: "oldName",
+ creatorId: defaultCreatorId,
+ });
+
+ // Act
+ await caller.renameBoard({ id: boardId, name: "newName" });
+
+ // Assert
+ const dbBoard = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ });
+ expect(dbBoard).toBeDefined();
+ expect(dbBoard?.name).toBe("newName");
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
+ });
+
+ test("should throw error when similar board name exists", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ await db.insert(users).values({
+ id: defaultCreatorId,
+ });
+ const boardId = createId();
+ await db.insert(boards).values({
+ id: boardId,
+ name: "oldName",
+ creatorId: defaultCreatorId,
+ });
+ await db.insert(boards).values({
+ id: createId(),
+ name: "newName",
+ creatorId: defaultCreatorId,
+ });
+
+ // Act
+ const actAsync = async () => await caller.renameBoard({ id: boardId, name: "Newname" });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrowError("Board with similar name already exists");
+ });
+
+ test("should throw error when board not found", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ // Act
+ const actAsync = async () => await caller.renameBoard({ id: "nonExistentBoardId", name: "newName" });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrowError("Board not found");
+ });
+});
+
+describe("changeBoardVisibility should change board visibility", () => {
+ test.each([["public"], ["private"]] satisfies ["private" | "public"][])(
+ "should change board visibility to %s",
+ async (visibility) => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+
+ await db.insert(users).values({
+ id: defaultCreatorId,
+ });
+ const boardId = createId();
+ await db.insert(boards).values({
+ id: boardId,
+ name: "board",
+ creatorId: defaultCreatorId,
+ isPublic: visibility === "public",
+ });
+
+ // Act
+ await caller.changeBoardVisibility({
+ id: boardId,
+ visibility,
+ });
+
+ // Assert
+ const dbBoard = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ });
+ expect(dbBoard).toBeDefined();
+ expect(dbBoard?.isPublic).toBe(visibility === "public");
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
+ },
+ );
+});
+
+describe("deleteBoard should delete board", () => {
+ test("should delete board", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+
+ await db.insert(users).values({
+ id: defaultCreatorId,
+ });
+ const boardId = createId();
+ await db.insert(boards).values({
+ id: boardId,
+ name: "board",
+ creatorId: defaultCreatorId,
+ });
+
+ // Act
+ await caller.deleteBoard({ id: boardId });
+
+ // Assert
+ const dbBoard = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ });
+ expect(dbBoard).toBeUndefined();
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
+ });
+
+ test("should throw error when board not found", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ // Act
+ const actAsync = async () => await caller.deleteBoard({ id: "nonExistentBoardId" });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrowError("Board not found");
+ });
+});
+
+describe("getHomeBoard should return home board", () => {
+ test("should return user home board when user has one", async () => {
+ // Arrange
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const fullBoardProps = await createFullBoardAsync(db, "home");
+ await db
+ .update(users)
+ .set({
+ homeBoardId: fullBoardProps.boardId,
+ })
+ .where(eq(users.id, defaultCreatorId));
+
+ // Act
+ const result = await caller.getHomeBoard();
+
+ // Assert
+ expectInputToBeFullBoardWithName(result, {
+ name: "home",
+ ...fullBoardProps,
+ });
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
+ });
+ test("should return global home board when user doesn't have one", async () => {
+ // Arrange
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const fullBoardProps = await createFullBoardAsync(db, "home");
+ await db.insert(serverSettings).values({
+ settingKey: "board",
+ value: SuperJSON.stringify({ homeBoardId: fullBoardProps.boardId }),
+ });
+
+ // Act
+ const result = await caller.getHomeBoard();
+
+ // Assert
+ expectInputToBeFullBoardWithName(result, {
+ name: "home",
+ ...fullBoardProps,
+ });
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
+ });
+ test("should throw error when home board not configured in serverSettings", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ await createFullBoardAsync(db, "home");
+
+ // Act
+ const actAsync = async () => await caller.getHomeBoard();
+
+ // Assert
+ await expect(actAsync()).rejects.toThrowError("No home board found");
+ });
+});
+
+describe("getBoardByName should return board by name", () => {
+ it.each([["default"], ["something"]])("should return board by name %s when present", async (name) => {
+ // Arrange
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const fullBoardProps = await createFullBoardAsync(db, name);
+
+ // Act
+ const result = await caller.getBoardByName({ name });
+
+ // Assert
+ expectInputToBeFullBoardWithName(result, {
+ name,
+ ...fullBoardProps,
+ });
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
+ });
+
+ it("should throw error when not present", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ await createFullBoardAsync(db, "default");
+
+ // Act
+ const actAsync = async () => await caller.getBoardByName({ name: "nonExistentBoard" });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrowError("Board not found");
+ });
+});
+
+describe("savePartialBoardSettings should save general settings", () => {
+ it("should save general settings", async () => {
+ // Arrange
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const newPageTitle = "newPageTitle";
+ const newMetaTitle = "newMetaTitle";
+ const newLogoImageUrl = "http://logo.image/url.png";
+ const newFaviconImageUrl = "http://favicon.image/url.png";
+ const newBackgroundImageAttachment = "scroll";
+ const newBackgroundImageSize = "cover";
+ const newBackgroundImageRepeat = "repeat";
+ const newBackgroundImageUrl = "http://background.image/url.png";
+ const newCustomCss = "body { background-color: blue; }";
+ const newOpacity = 0.8;
+ const newPrimaryColor = "#0000ff";
+ const newSecondaryColor = "#ff00ff";
+
+ const { boardId } = await createFullBoardAsync(db, "default");
+
+ // Act
+ await caller.savePartialBoardSettings({
+ pageTitle: newPageTitle,
+ metaTitle: newMetaTitle,
+ logoImageUrl: newLogoImageUrl,
+ faviconImageUrl: newFaviconImageUrl,
+ backgroundImageAttachment: newBackgroundImageAttachment,
+ backgroundImageRepeat: newBackgroundImageRepeat,
+ backgroundImageSize: newBackgroundImageSize,
+ backgroundImageUrl: newBackgroundImageUrl,
+ customCss: newCustomCss,
+ opacity: newOpacity,
+ primaryColor: newPrimaryColor,
+ secondaryColor: newSecondaryColor,
+ id: boardId,
+ });
+
+ // Assert
+ const dbBoard = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ });
+ expect(dbBoard).toBeDefined();
+ expect(dbBoard?.pageTitle).toBe(newPageTitle);
+ expect(dbBoard?.metaTitle).toBe(newMetaTitle);
+ expect(dbBoard?.logoImageUrl).toBe(newLogoImageUrl);
+ expect(dbBoard?.faviconImageUrl).toBe(newFaviconImageUrl);
+ expect(dbBoard?.backgroundImageAttachment).toBe(newBackgroundImageAttachment);
+ expect(dbBoard?.backgroundImageRepeat).toBe(newBackgroundImageRepeat);
+ expect(dbBoard?.backgroundImageSize).toBe(newBackgroundImageSize);
+ expect(dbBoard?.backgroundImageUrl).toBe(newBackgroundImageUrl);
+ expect(dbBoard?.customCss).toBe(newCustomCss);
+ expect(dbBoard?.opacity).toBe(newOpacity);
+ expect(dbBoard?.primaryColor).toBe(newPrimaryColor);
+ expect(dbBoard?.secondaryColor).toBe(newSecondaryColor);
+
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
+ });
+
+ it("should throw error when board not found", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const actAsync = async () =>
+ await caller.savePartialBoardSettings({
+ pageTitle: "newPageTitle",
+ metaTitle: "newMetaTitle",
+ logoImageUrl: "http://logo.image/url.png",
+ faviconImageUrl: "http://favicon.image/url.png",
+ id: "nonExistentBoardId",
+ });
+
+ await expect(actAsync()).rejects.toThrowError("Board not found");
+ });
+});
+
+describe("saveBoard should save full board", () => {
+ it("should remove section when not present in input", async () => {
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const { boardId, sectionId } = await createFullBoardAsync(db, "default");
+
+ await caller.saveBoard({
+ id: boardId,
+ sections: [
+ {
+ id: createId(),
+ kind: "empty",
+ yOffset: 0,
+ xOffset: 0,
+ },
+ ],
+ items: [],
+ });
+
+ const board = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ with: {
+ sections: true,
+ },
+ });
+
+ const section = await db.query.boards.findFirst({
+ where: eq(sections.id, sectionId),
+ });
+
+ const definedBoard = expectToBeDefined(board);
+ expect(definedBoard.sections.length).toBe(1);
+ expect(definedBoard.sections[0]?.id).not.toBe(sectionId);
+ expect(section).toBeUndefined();
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
+ });
+ it("should remove item when not present in input", async () => {
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const { boardId, itemId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
+
+ await caller.saveBoard({
+ id: boardId,
+ sections: [
+ {
+ id: sectionId,
+ kind: "empty",
+ yOffset: 0,
+ xOffset: 0,
+ },
+ ],
+ items: [
+ {
+ id: createId(),
+ kind: "clock",
+ options: { is24HourFormat: true },
+ integrationIds: [],
+ layouts: [
+ {
+ layoutId,
+ sectionId,
+ height: 1,
+ width: 1,
+ xOffset: 0,
+ yOffset: 0,
+ },
+ ],
+ advancedOptions: {},
+ },
+ ],
+ });
+
+ const board = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ with: {
+ sections: true,
+ items: true,
+ },
+ });
+
+ const item = await db.query.items.findFirst({
+ where: eq(items.id, itemId),
+ });
+
+ const definedBoard = expectToBeDefined(board);
+ expect(definedBoard.sections.length).toBe(1);
+ expect(definedBoard.items.length).toBe(1);
+ expect(definedBoard.items[0]?.id).not.toBe(itemId);
+ expect(item).toBeUndefined();
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
+ });
+ it("should remove integration reference when not present in input", async () => {
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const anotherIntegration = {
+ id: createId(),
+ kind: "adGuardHome",
+ name: "AdGuard Home",
+ url: "http://localhost:3000",
+ } as const;
+
+ const { boardId, itemId, integrationId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
+ await db.insert(integrations).values(anotherIntegration);
+
+ await caller.saveBoard({
+ id: boardId,
+ sections: [
+ {
+ id: sectionId,
+ kind: "empty",
+ xOffset: 0,
+ yOffset: 0,
+ },
+ ],
+ items: [
+ {
+ id: itemId,
+ kind: "clock",
+ options: { is24HourFormat: true },
+ integrationIds: [anotherIntegration.id],
+ layouts: [
+ {
+ layoutId,
+ sectionId,
+ height: 1,
+ width: 1,
+ xOffset: 0,
+ yOffset: 0,
+ },
+ ],
+ advancedOptions: {},
+ },
+ ],
+ });
+
+ const board = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ with: {
+ sections: true,
+ items: {
+ with: {
+ integrations: true,
+ },
+ },
+ },
+ });
+
+ const integration = await db.query.integrationItems.findFirst({
+ where: eq(integrationItems.integrationId, integrationId),
+ });
+
+ const definedBoard = expectToBeDefined(board);
+ expect(definedBoard.sections.length).toBe(1);
+ expect(definedBoard.items.length).toBe(1);
+ const firstItem = expectToBeDefined(definedBoard.items[0]);
+ expect(firstItem.integrations.length).toBe(1);
+ expect(firstItem.integrations[0]?.integrationId).not.toBe(integrationId);
+ expect(integration).toBeUndefined();
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
+ });
+ it.each([[{ kind: "empty" as const }], [{ kind: "category" as const, collapsed: false, name: "My first category" }]])(
+ "should add section when present in input",
+ async (partialSection) => {
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const { boardId, sectionId } = await createFullBoardAsync(db, "default");
+
+ const newSectionId = createId();
+ await caller.saveBoard({
+ id: boardId,
+ sections: [
+ {
+ id: newSectionId,
+ xOffset: 0,
+ yOffset: 1,
+ ...partialSection,
+ },
+ {
+ id: sectionId,
+ kind: "empty",
+ xOffset: 0,
+ yOffset: 0,
+ },
+ ],
+ items: [],
+ });
+
+ const board = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ with: {
+ sections: true,
+ },
+ });
+
+ const section = await db.query.sections.findFirst({
+ where: eq(sections.id, newSectionId),
+ });
+
+ const definedBoard = expectToBeDefined(board);
+ expect(definedBoard.sections.length).toBe(2);
+ const addedSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
+ expect(addedSection).toBeDefined();
+ expect(addedSection.id).toBe(newSectionId);
+ expect(addedSection.kind).toBe(partialSection.kind);
+ expect(addedSection.yOffset).toBe(1);
+ if ("name" in partialSection) {
+ expect(addedSection.name).toBe(partialSection.name);
+ }
+ expect(section).toBeDefined();
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
+ },
+ );
+ it("should add item when present in input", async () => {
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const { boardId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
+
+ const newItemId = createId();
+ await caller.saveBoard({
+ id: boardId,
+ sections: [
+ {
+ id: sectionId,
+ kind: "empty",
+ yOffset: 0,
+ xOffset: 0,
+ },
+ ],
+ items: [
+ {
+ id: newItemId,
+ kind: "clock",
+ options: { is24HourFormat: true },
+ integrationIds: [],
+ layouts: [
+ {
+ layoutId,
+ sectionId,
+ height: 1,
+ width: 1,
+ xOffset: 3,
+ yOffset: 2,
+ },
+ ],
+ advancedOptions: {},
+ },
+ ],
+ });
+
+ const board = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ with: {
+ sections: true,
+ items: {
+ with: {
+ layouts: true,
+ },
+ },
+ },
+ });
+
+ const item = await db.query.items.findFirst({
+ where: eq(items.id, newItemId),
+ });
+
+ const definedBoard = expectToBeDefined(board);
+ expect(definedBoard.sections.length).toBe(1);
+ expect(definedBoard.items.length).toBe(1);
+ const addedItem = expectToBeDefined(definedBoard.items.find((item) => item.id === newItemId));
+ expect(addedItem).toBeDefined();
+ expect(addedItem.id).toBe(newItemId);
+ expect(addedItem.kind).toBe("clock");
+ expect(addedItem.options).toBe(SuperJSON.stringify({ is24HourFormat: true }));
+ const firstLayout = expectToBeDefined(addedItem.layouts[0]);
+ expect(firstLayout.sectionId).toBe(sectionId);
+ expect(firstLayout.height).toBe(1);
+ expect(firstLayout.width).toBe(1);
+ expect(firstLayout.xOffset).toBe(3);
+ expect(firstLayout.yOffset).toBe(2);
+ expect(item).toBeDefined();
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
+ });
+ it("should add integration reference when present in input", async () => {
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const integration = {
+ id: createId(),
+ kind: "plex",
+ name: "Plex",
+ url: "http://plex.local",
+ } as const;
+
+ const { boardId, itemId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
+ await db.insert(integrations).values(integration);
+
+ await caller.saveBoard({
+ id: boardId,
+ sections: [
+ {
+ id: sectionId,
+ kind: "empty",
+ xOffset: 0,
+ yOffset: 0,
+ },
+ ],
+ items: [
+ {
+ id: itemId,
+ kind: "clock",
+ options: { is24HourFormat: true },
+ integrationIds: [integration.id],
+ layouts: [
+ {
+ sectionId,
+ layoutId,
+ height: 1,
+ width: 1,
+ xOffset: 0,
+ yOffset: 0,
+ },
+ ],
+ advancedOptions: {},
+ },
+ ],
+ });
+
+ const board = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ with: {
+ sections: true,
+ items: {
+ with: {
+ integrations: true,
+ },
+ },
+ },
+ });
+
+ const integrationItem = await db.query.integrationItems.findFirst({
+ where: eq(integrationItems.integrationId, integration.id),
+ });
+
+ const definedBoard = expectToBeDefined(board);
+ expect(definedBoard.sections.length).toBe(1);
+ const firstItem = expectToBeDefined(definedBoard.items.find((item) => item.id === itemId));
+ expect(firstItem.integrations.length).toBe(1);
+ expect(firstItem.integrations[0]?.integrationId).toBe(integration.id);
+ expect(integrationItem).toBeDefined();
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
+ });
+ it("should update section when present in input", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const { boardId, sectionId } = await createFullBoardAsync(db, "default");
+ const newSectionId = createId();
+ await db.insert(sections).values({
+ id: newSectionId,
+ kind: "category",
+ name: "Before",
+ yOffset: 1,
+ xOffset: 0,
+ boardId,
+ });
+
+ await caller.saveBoard({
+ id: boardId,
+ sections: [
+ {
+ id: sectionId,
+ kind: "category",
+ yOffset: 1,
+ xOffset: 0,
+ name: "Test",
+ collapsed: true,
+ },
+ {
+ id: newSectionId,
+ kind: "category",
+ name: "After",
+ yOffset: 0,
+ xOffset: 0,
+ collapsed: false,
+ },
+ ],
+ items: [],
+ });
+
+ const board = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ with: {
+ sections: true,
+ },
+ });
+
+ const definedBoard = expectToBeDefined(board);
+ expect(definedBoard.sections.length).toBe(2);
+ const firstSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === sectionId));
+ expect(firstSection.id).toBe(sectionId);
+ expect(firstSection.kind).toBe("empty");
+ expect(firstSection.yOffset).toBe(1);
+ expect(firstSection.name).toBe(null);
+ const secondSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
+ expect(secondSection.id).toBe(newSectionId);
+ expect(secondSection.kind).toBe("category");
+ expect(secondSection.yOffset).toBe(0);
+ expect(secondSection.name).toBe("After");
+ });
+ it("should update item when present in input", async () => {
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const { boardId, itemId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
+
+ await caller.saveBoard({
+ id: boardId,
+ sections: [
+ {
+ id: sectionId,
+ kind: "empty",
+ yOffset: 0,
+ xOffset: 0,
+ },
+ ],
+ items: [
+ {
+ id: itemId,
+ kind: "clock",
+ options: { is24HourFormat: false },
+ integrationIds: [],
+ layouts: [
+ {
+ layoutId,
+ sectionId,
+ height: 3,
+ width: 2,
+ xOffset: 7,
+ yOffset: 5,
+ },
+ ],
+ advancedOptions: {},
+ },
+ ],
+ });
+
+ const board = await db.query.boards.findFirst({
+ where: eq(boards.id, boardId),
+ with: {
+ sections: true,
+ items: {
+ with: {
+ layouts: true,
+ },
+ },
+ },
+ });
+
+ const definedBoard = expectToBeDefined(board);
+ expect(definedBoard.sections.length).toBe(1);
+ expect(definedBoard.items.length).toBe(1);
+ const firstItem = expectToBeDefined(definedBoard.items.find((item) => item.id === itemId));
+ expect(firstItem.id).toBe(itemId);
+ expect(firstItem.kind).toBe("clock");
+ expect(SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options).is24HourFormat).toBe(false);
+ const firstLayout = expectToBeDefined(firstItem.layouts[0]);
+ expect(firstLayout.sectionId).toBe(sectionId);
+ expect(firstLayout.height).toBe(3);
+ expect(firstLayout.width).toBe(2);
+ expect(firstLayout.xOffset).toBe(7);
+ expect(firstLayout.yOffset).toBe(5);
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
+ });
+ it("should fail when board not found", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const actAsync = async () =>
+ await caller.saveBoard({
+ id: "nonExistentBoardId",
+ sections: [],
+ items: [],
+ });
+
+ await expect(actAsync()).rejects.toThrowError("Board not found");
+ });
+});
+
+describe("getBoardPermissions should return board permissions", () => {
+ test("should return board permissions", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+
+ const user1 = await createRandomUserAsync(db);
+ const user2 = await createRandomUserAsync(db);
+ await db.insert(users).values({
+ id: defaultCreatorId,
+ });
+
+ const boardId = createId();
+ await db.insert(boards).values({
+ id: boardId,
+ name: "board",
+ creatorId: defaultCreatorId,
+ });
+
+ await db.insert(boardUserPermissions).values([
+ {
+ userId: user1,
+ permission: "view",
+ boardId,
+ },
+ {
+ userId: user2,
+ permission: "modify",
+ boardId,
+ },
+ ]);
+
+ const groupId = createId();
+ await db.insert(groups).values({
+ id: groupId,
+ name: "group1",
+ position: 1,
+ });
+
+ await db.insert(boardGroupPermissions).values({
+ groupId,
+ permission: "view",
+ boardId,
+ });
+
+ await db.insert(groupPermissions).values({
+ groupId,
+ permission: "admin",
+ });
+
+ // Act
+ const result = await caller.getBoardPermissions({ id: boardId });
+
+ // Assert
+ expect(result.groups).toEqual([{ group: { id: groupId, name: "group1" }, permission: "view" }]);
+ expect(result.users).toEqual(
+ expect.arrayContaining([
+ {
+ user: { id: user1, name: null, image: null, email: null },
+ permission: "view",
+ },
+ {
+ user: { id: user2, name: null, image: null, email: null },
+ permission: "modify",
+ },
+ ]),
+ );
+ expect(result.inherited).toEqual([{ group: { id: groupId, name: "group1" }, permission: "admin" }]);
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
+ });
+});
+
+describe("saveUserBoardPermissions should save user board permissions", () => {
+ test.each([["view"], ["modify"]] satisfies [BoardPermission][])(
+ "should save user board permissions",
+ async (permission) => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+
+ const user1 = await createRandomUserAsync(db);
+ await db.insert(users).values({
+ id: defaultCreatorId,
+ });
+
+ const boardId = createId();
+ await db.insert(boards).values({
+ id: boardId,
+ name: "board",
+ creatorId: defaultCreatorId,
+ });
+
+ // Act
+ await caller.saveUserBoardPermissions({
+ entityId: boardId,
+ permissions: [
+ {
+ principalId: user1,
+ permission,
+ },
+ ],
+ });
+
+ // Assert
+ const dbUserPermission = await db.query.boardUserPermissions.findFirst({
+ where: eq(boardUserPermissions.userId, user1),
+ });
+ expect(dbUserPermission).toBeDefined();
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
+ },
+ );
+});
+
+describe("saveGroupBoardPermissions should save group board permissions", () => {
+ test.each([["view"], ["modify"]] satisfies [BoardPermission][])(
+ "should save group board permissions",
+ async (permission) => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+
+ await db.insert(users).values({
+ id: defaultCreatorId,
+ });
+
+ const groupId = createId();
+ await db.insert(groups).values({
+ id: groupId,
+ name: "group1",
+ position: 1,
+ });
+
+ const boardId = createId();
+ await db.insert(boards).values({
+ id: boardId,
+ name: "board",
+ creatorId: defaultCreatorId,
+ });
+
+ // Act
+ await caller.saveGroupBoardPermissions({
+ entityId: boardId,
+ permissions: [
+ {
+ principalId: groupId,
+ permission,
+ },
+ ],
+ });
+
+ // Assert
+ const dbGroupPermission = await db.query.boardGroupPermissions.findFirst({
+ where: eq(boardGroupPermissions.groupId, groupId),
+ });
+ expect(dbGroupPermission).toBeDefined();
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full");
+ },
+ );
+});
+
+const createExistingLayout = (id: string) => ({
+ id,
+ name: "Base",
+ columnCount: 10,
+ breakpoint: 0,
+});
+const createNewLayout = (columnCount: number) => ({
+ id: createId(),
+ name: "New layout",
+ columnCount,
+ breakpoint: 1400,
+});
+describe("saveLayouts should save layout changes", () => {
+ test("should add layout when not present in database", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+ const newLayout = createNewLayout(12);
+
+ // Act
+ await caller.saveLayouts({
+ id: boardId,
+ layouts: [createExistingLayout(layoutId), newLayout],
+ });
+
+ // Assert
+ const layout = await db.query.layouts.findFirst({
+ where: not(eq(layouts.id, layoutId)),
+ });
+
+ const definedLayout = expectToBeDefined(layout);
+ expect(definedLayout.name).toBe(newLayout.name);
+ expect(definedLayout.columnCount).toBe(newLayout.columnCount);
+ expect(definedLayout.breakpoint).toBe(newLayout.breakpoint);
+ });
+ test("should add items and dynamic sections generated from grid-algorithm when new layout is added", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
+ const assignments = await createItemsAndSectionsAsync(db, {
+ boardId,
+ layoutId,
+ sectionId,
+ });
+ const newLayout = createNewLayout(3);
+
+ // Act
+ await caller.saveLayouts({
+ id: boardId,
+ layouts: [createExistingLayout(layoutId), newLayout],
+ });
+
+ // Assert
+ const layout = await db.query.layouts.findFirst({
+ where: not(eq(layouts.id, layoutId)),
+ });
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ await expectLayoutForRootLayoutAsync(db, sectionId, layout!.id, {
+ ...assignments.inRoot,
+ a: itemId,
+ });
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layout!.id, assignments.inDynamicSection);
+ });
+ test("should update layout when present in input", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+ const updatedLayout = createExistingLayout(layoutId);
+ updatedLayout.breakpoint = 1400;
+ updatedLayout.name = "Updated layout";
+
+ // Act
+ await caller.saveLayouts({
+ id: boardId,
+ layouts: [updatedLayout],
+ });
+
+ // Assert
+ const layout = await db.query.layouts.findFirst({
+ where: eq(layouts.id, layoutId),
+ });
+
+ const definedLayout = expectToBeDefined(layout);
+ expect(definedLayout.name).toBe(updatedLayout.name);
+ expect(definedLayout.columnCount).toBe(updatedLayout.columnCount);
+ expect(definedLayout.breakpoint).toBe(updatedLayout.breakpoint);
+ });
+ test("should update position of items when column count changes", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
+ const assignments = await createItemsAndSectionsAsync(db, {
+ boardId,
+ layoutId,
+ sectionId,
+ });
+ const updatedLayout = createExistingLayout(layoutId);
+ updatedLayout.columnCount = 3;
+
+ // Act
+ await caller.saveLayouts({
+ id: boardId,
+ layouts: [updatedLayout],
+ });
+
+ // Assert
+ await expectLayoutForRootLayoutAsync(db, sectionId, layoutId, {
+ ...assignments.inRoot,
+ a: itemId,
+ });
+ await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
+ });
+ test("should remove layout when not present in input", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+
+ // Act
+ await caller.saveLayouts({
+ id: boardId,
+ layouts: [createNewLayout(12)],
+ });
+
+ // Assert
+ const layout = await db.query.layouts.findFirst({
+ where: eq(layouts.id, layoutId),
+ });
+ expect(layout).toBeUndefined();
+ });
+ test("should fail when board not found", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ const { layoutId } = await createFullBoardAsync(db, "default");
+
+ // Act
+ const actAsync = async () =>
+ await caller.saveLayouts({
+ id: createId(),
+ layouts: [createExistingLayout(layoutId)],
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrowError("Board not found");
+ });
+});
+
+const expectInputToBeFullBoardWithName = (
+ input: RouterOutputs["board"]["getHomeBoard"],
+ props: { name: string } & Awaited>,
+) => {
+ expect(input.id).toBe(props.boardId);
+ expect(input.name).toBe(props.name);
+ expect(input.sections.length).toBe(1);
+ const firstSection = expectToBeDefined(input.sections[0]);
+ expect(firstSection.id).toBe(props.sectionId);
+ expect(input.items.length).toBe(1);
+ const firstItem = expectToBeDefined(input.items[0]);
+ expect(firstItem.id).toBe(props.itemId);
+ expect(firstItem.kind).toBe("clock");
+ if (firstItem.kind === "clock") {
+ expect(firstItem.options.is24HourFormat).toBe(true);
+ }
+ expect(firstItem.integrationIds.length).toBe(1);
+ const firstIntegration = expectToBeDefined(firstItem.integrationIds[0]);
+ expect(firstIntegration).toBe(props.integrationId);
+};
+
+const createFullBoardAsync = async (db: Database, name: string) => {
+ await db.insert(users).values({
+ id: defaultCreatorId,
+ });
+
+ const boardId = createId();
+ await db.insert(boards).values({
+ id: boardId,
+ name,
+ creatorId: defaultCreatorId,
+ });
+
+ const layoutId = createId();
+ await db.insert(layouts).values({
+ id: layoutId,
+ name: "Base",
+ columnCount: 10,
+ breakpoint: 0,
+ boardId,
+ });
+
+ const sectionId = createId();
+ await db.insert(sections).values({
+ id: sectionId,
+ kind: "empty",
+ yOffset: 0,
+ xOffset: 0,
+ boardId,
+ });
+
+ const itemId = createId();
+ await db.insert(items).values({
+ id: itemId,
+ kind: "clock",
+ boardId,
+ options: SuperJSON.stringify({ is24HourFormat: true }),
+ });
+
+ await db.insert(itemLayouts).values({
+ height: 1,
+ width: 1,
+ xOffset: 0,
+ yOffset: 0,
+ sectionId,
+ itemId,
+ layoutId,
+ });
+
+ const integrationId = createId();
+ await db.insert(integrations).values({
+ id: integrationId,
+ kind: "adGuardHome",
+ name: "AdGuard Home",
+ url: "http://localhost:3000",
+ });
+
+ await db.insert(integrationItems).values({
+ integrationId,
+ itemId,
+ });
+
+ return {
+ boardId,
+ sectionId,
+ layoutId,
+ itemId,
+ integrationId,
+ };
+};
+
+const addItemAsync = async (
+ db: Database,
+ item: Partial, "height" | "width" | "xOffset" | "yOffset">> & {
+ sectionId: string;
+ layoutId: string;
+ boardId: string;
+ },
+) => {
+ const itemId = createId();
+ await db.insert(items).values({
+ id: itemId,
+ kind: "clock",
+ boardId: item.boardId,
+ options: SuperJSON.stringify({ is24HourFormat: true }),
+ });
+ await db.insert(itemLayouts).values({
+ itemId,
+ layoutId: item.layoutId,
+ sectionId: item.sectionId,
+ height: item.height ?? 1,
+ width: item.width ?? 1,
+ xOffset: item.xOffset ?? 0,
+ yOffset: item.yOffset ?? 0,
+ });
+ return itemId;
+};
+
+const addDynamicSectionAsync = async (
+ db: Database,
+ section: Partial, "xOffset" | "yOffset" | "width" | "height">> & {
+ parentSectionId: string;
+ boardId: string;
+ layoutId: string;
+ },
+) => {
+ const sectionId = createId();
+ await db.insert(sections).values({
+ id: sectionId,
+ kind: "dynamic",
+ boardId: section.boardId,
+ });
+ await db.insert(sectionLayouts).values({
+ parentSectionId: section.parentSectionId,
+ layoutId: section.layoutId,
+ sectionId,
+ xOffset: section.xOffset ?? 0,
+ yOffset: section.yOffset ?? 0,
+ width: section.width ?? 1,
+ height: section.height ?? 1,
+ });
+ return sectionId;
+};
+
+const createItemsAndSectionsAsync = async (
+ db: Database,
+ options: { boardId: string; sectionId: string; layoutId: string },
+) => {
+ const { boardId, layoutId, sectionId } = options;
+ // From:
+ // abbbbbccdd
+ // efffffccdd
+ // efffffggdd
+ // efffffgg
+ // To:
+ // a
+ // bbb
+ // cce
+ // cce
+ // dde
+ // dd
+ // dd
+ // fff
+ // fff
+ // fff
+ // fff
+ // gg
+ // gg
+ const itemB = await addItemAsync(db, { boardId, layoutId, sectionId, xOffset: 1, width: 5 });
+ const itemC = await addItemAsync(db, { boardId, layoutId, sectionId, xOffset: 6, width: 2, height: 2 });
+ const itemD = await addItemAsync(db, { boardId, layoutId, sectionId, xOffset: 8, width: 2, height: 3 });
+ const itemE = await addItemAsync(db, { boardId, layoutId, sectionId, yOffset: 1, height: 3 });
+ const sectionF = await addDynamicSectionAsync(db, {
+ yOffset: 1,
+ xOffset: 1,
+ width: 5,
+ height: 3,
+ parentSectionId: sectionId,
+ boardId,
+ layoutId,
+ });
+ const sectionG = await addDynamicSectionAsync(db, {
+ yOffset: 2,
+ xOffset: 6,
+ width: 2,
+ height: 2,
+ parentSectionId: sectionId,
+ boardId,
+ layoutId,
+ });
+ // From:
+ // hhhhh
+ // iiijj
+ // iii
+ // To:
+ // hhh
+ // iii
+ // iii
+ // jj
+ const itemH = await addItemAsync(db, { boardId, layoutId, sectionId: sectionF, width: 5 });
+ const itemI = await addItemAsync(db, { boardId, layoutId, sectionId: sectionF, width: 3, height: 2, yOffset: 1 });
+ const itemJ = await addItemAsync(db, { boardId, layoutId, sectionId: sectionF, width: 2, yOffset: 1, xOffset: 2 });
+
+ return {
+ inRoot: {
+ b: itemB,
+ c: itemC,
+ d: itemD,
+ e: itemE,
+ f: sectionF,
+ g: sectionG,
+ },
+ inDynamicSection: {
+ h: itemH,
+ i: itemI,
+ j: itemJ,
+ },
+ };
+};
+
+const expectLayoutForRootLayoutAsync = async (
+ db: Database,
+ sectionId: string,
+ layoutId: string,
+ assignments: Record,
+) => {
+ await expectLayoutInSectionAsync(
+ db,
+ sectionId,
+ layoutId,
+ `
+a
+bbb
+cce
+cce
+dde
+dd
+dd
+fff
+fff
+fff
+fff
+gg
+gg`,
+ assignments,
+ );
+};
+
+const expectLayoutForDynamicSectionAsync = async (
+ db: Database,
+ sectionId: string,
+ layoutId: string,
+ assignments: Record,
+) => {
+ await expectLayoutInSectionAsync(
+ db,
+ sectionId,
+ layoutId,
+ `
+hhh
+iii
+iii
+jj`,
+ assignments,
+ );
+};
+
+const expectLayoutInSectionAsync = async (
+ db: Database,
+ sectionId: string,
+ layoutId: string,
+ layout: string,
+ assignments: Record,
+) => {
+ const itemsInSection = await db.query.itemLayouts.findMany({
+ where: and(eq(itemLayouts.sectionId, sectionId), eq(itemLayouts.layoutId, layoutId)),
+ });
+ const sectionsInSection = await db.query.sectionLayouts.findMany({
+ where: and(eq(sectionLayouts.parentSectionId, sectionId), eq(sectionLayouts.layoutId, layoutId)),
+ });
+ const entries = [...itemsInSection, ...sectionsInSection];
+
+ const lines = layout.split("\n").slice(1);
+ const keys = Object.keys(assignments);
+ const positions: Record = {};
+ for (let yOffset = 0; yOffset < lines.length; yOffset++) {
+ const line = lines[yOffset];
+ if (!line) continue;
+ for (let xOffset = 0; xOffset < line.length; xOffset++) {
+ const char = line[xOffset];
+ if (!char) continue;
+ if (!keys.includes(char)) continue;
+ if (char in positions) continue;
+ const width = line.split("").filter((lineChar) => lineChar === char).length;
+ const height = lines.slice(yOffset).filter((line) => line.substring(xOffset).startsWith(char)).length;
+ positions[char] = { x: xOffset, y: yOffset, w: width, h: height };
+ }
+ }
+
+ for (const [key, { x, y, w, h }] of Object.entries(positions)) {
+ const entry = entries.find((entry) => ("itemId" in entry ? entry.itemId : entry.sectionId) === assignments[key]);
+ expect(entry, `Expect entry for ${key} to be defined in assignments=${JSON.stringify(assignments)}`).toBeDefined();
+ expect(entry?.xOffset, `Expect xOffset of entry for ${key} to be ${x} for entry=${JSON.stringify(entry)}`).toBe(x);
+ expect(entry?.yOffset, `Expect yOffset of entry for ${key} to be ${y} for entry=${JSON.stringify(entry)}`).toBe(y);
+ expect(entry?.width, `Expect width of entry for ${key} to be ${w} for entry=${JSON.stringify(entry)}`).toBe(w);
+ expect(entry?.height, `Expect height of entry for ${key} to be ${h} for entry=${JSON.stringify(entry)}`).toBe(h);
+ }
+};
diff --git a/packages/api/src/router/test/board/board-access.spec.ts b/packages/api/src/router/test/board/board-access.spec.ts
new file mode 100644
index 000000000..9ba978381
--- /dev/null
+++ b/packages/api/src/router/test/board/board-access.spec.ts
@@ -0,0 +1,149 @@
+import { describe, expect, test, vi } from "vitest";
+
+import * as authShared from "@homarr/auth/shared";
+import { createId } from "@homarr/common";
+import { eq } from "@homarr/db";
+import { boards, users } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+
+import { throwIfActionForbiddenAsync } from "../../board/board-access";
+
+const defaultCreatorId = createId();
+
+const expectActToBeAsync = async (act: () => Promise, success: boolean) => {
+ if (!success) {
+ await expect(act()).rejects.toThrow("Board not found");
+ return;
+ }
+
+ await expect(act()).resolves.toBeUndefined();
+};
+
+describe("throwIfActionForbiddenAsync should check access to board and return boolean", () => {
+ test.each([
+ ["full" as const, true],
+ ["modify" as const, true],
+ ["view" as const, true],
+ ])("with permission %s should return %s when hasFullAccess is true", async (permission, expectedResult) => {
+ // Arrange
+ const db = createDb();
+ const spy = vi.spyOn(authShared, "constructBoardPermissions");
+ spy.mockReturnValue({
+ hasFullAccess: true,
+ hasChangeAccess: false,
+ hasViewAccess: false,
+ });
+
+ await db.insert(users).values({ id: defaultCreatorId });
+ const boardId = createId();
+ await db.insert(boards).values({
+ id: boardId,
+ name: "test",
+ creatorId: defaultCreatorId,
+ });
+
+ // Act
+ const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, boardId), permission);
+
+ // Assert
+ await expectActToBeAsync(act, expectedResult);
+ });
+
+ test.each([
+ ["full" as const, false],
+ ["modify" as const, true],
+ ["view" as const, true],
+ ])("with permission %s should return %s when hasChangeAccess is true", async (permission, expectedResult) => {
+ // Arrange
+ const db = createDb();
+ const spy = vi.spyOn(authShared, "constructBoardPermissions");
+ spy.mockReturnValue({
+ hasFullAccess: false,
+ hasChangeAccess: true,
+ hasViewAccess: false,
+ });
+
+ await db.insert(users).values({ id: defaultCreatorId });
+ const boardId = createId();
+ await db.insert(boards).values({
+ id: boardId,
+ name: "test",
+ creatorId: defaultCreatorId,
+ });
+
+ // Act
+ const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, boardId), permission);
+
+ // Assert
+ await expectActToBeAsync(act, expectedResult);
+ });
+
+ test.each([
+ ["full" as const, false],
+ ["modify" as const, false],
+ ["view" as const, true],
+ ])("with permission %s should return %s when hasViewAccess is true", async (permission, expectedResult) => {
+ // Arrange
+ const db = createDb();
+ const spy = vi.spyOn(authShared, "constructBoardPermissions");
+ spy.mockReturnValue({
+ hasFullAccess: false,
+ hasChangeAccess: false,
+ hasViewAccess: true,
+ });
+
+ await db.insert(users).values({ id: defaultCreatorId });
+ const boardId = createId();
+ await db.insert(boards).values({
+ id: boardId,
+ name: "test",
+ creatorId: defaultCreatorId,
+ });
+
+ // Act
+ const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, boardId), permission);
+
+ // Assert
+ await expectActToBeAsync(act, expectedResult);
+ });
+
+ test.each([
+ ["full" as const, false],
+ ["modify" as const, false],
+ ["view" as const, false],
+ ])("with permission %s should return %s when hasViewAccess is false", async (permission, expectedResult) => {
+ // Arrange
+ const db = createDb();
+ const spy = vi.spyOn(authShared, "constructBoardPermissions");
+ spy.mockReturnValue({
+ hasFullAccess: false,
+ hasChangeAccess: false,
+ hasViewAccess: false,
+ });
+
+ await db.insert(users).values({ id: defaultCreatorId });
+ const boardId = createId();
+ await db.insert(boards).values({
+ id: boardId,
+ name: "test",
+ creatorId: defaultCreatorId,
+ });
+
+ // Act
+ const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, boardId), permission);
+
+ // Assert
+ await expectActToBeAsync(act, expectedResult);
+ });
+
+ test("should throw when board is not found", async () => {
+ // Arrange
+ const db = createDb();
+
+ // Act
+ const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, createId()), "full");
+
+ // Assert
+ await expect(act()).rejects.toThrow("Board not found");
+ });
+});
diff --git a/packages/api/src/router/test/docker/docker-router.spec.ts b/packages/api/src/router/test/docker/docker-router.spec.ts
new file mode 100644
index 000000000..941e696e6
--- /dev/null
+++ b/packages/api/src/router/test/docker/docker-router.spec.ts
@@ -0,0 +1,115 @@
+import { TRPCError } from "@trpc/server";
+import { describe, expect, test, vi } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import { objectKeys } from "@homarr/common";
+import type { Database } from "@homarr/db";
+import type { GroupPermissionKey } from "@homarr/definitions";
+import { getPermissionsWithChildren } from "@homarr/definitions";
+
+import type { RouterInputs } from "../../..";
+import { dockerRouter } from "../../docker/docker-router";
+
+// Mock the auth module to return an empty session
+vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
+vi.mock("@homarr/request-handler/docker", () => ({
+ dockerContainersRequestHandler: {
+ handler: () => ({
+ getCachedOrUpdatedDataAsync: async () => {
+ return await Promise.resolve({ containers: [] });
+ },
+ invalidateAsync: async () => {
+ return await Promise.resolve();
+ },
+ }),
+ },
+}));
+vi.mock("@homarr/redis", () => ({
+ createCacheChannel: () => ({
+ // eslint-disable-next-line @typescript-eslint/require-await
+ consumeAsync: async () => ({
+ timestamp: new Date().toISOString(),
+ data: { containers: [] },
+ }),
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ invalidateAsync: async () => {},
+ }),
+ createWidgetOptionsChannel: () => ({}),
+}));
+
+vi.mock("@homarr/docker/env", () => ({
+ env: {
+ ENABLE_DOCKER: true,
+ },
+}));
+
+const createSessionWithPermissions = (...permissions: GroupPermissionKey[]) =>
+ ({
+ user: {
+ id: "1",
+ permissions,
+ colorScheme: "light",
+ },
+ expires: new Date().toISOString(),
+ }) satisfies Session;
+
+const procedureKeys = objectKeys(dockerRouter._def.procedures);
+
+const validInputs: {
+ [key in (typeof procedureKeys)[number]]: RouterInputs["docker"][key];
+} = {
+ getContainers: undefined,
+ subscribeContainers: undefined,
+ startAll: { ids: ["1"] },
+ stopAll: { ids: ["1"] },
+ restartAll: { ids: ["1"] },
+ removeAll: { ids: ["1"] },
+ invalidate: undefined,
+};
+
+describe("All procedures should only be accessible for users with admin permission", () => {
+ test.each(procedureKeys)("Procedure %s should be accessible for users with admin permission", async (procedure) => {
+ // Arrange
+ const caller = dockerRouter.createCaller({
+ db: null as unknown as Database,
+ deviceType: undefined,
+ session: createSessionWithPermissions("admin"),
+ });
+
+ // Act
+ const act = () => caller[procedure](validInputs[procedure] as never);
+
+ await expect(act()).resolves.not.toThrow();
+ });
+
+ test.each(procedureKeys)("Procedure %s should not be accessible with other permissions", async (procedure) => {
+ // Arrange
+ const groupPermissionsWithoutAdmin = getPermissionsWithChildren(["admin"]).filter(
+ (permission) => permission !== "admin",
+ );
+ const caller = dockerRouter.createCaller({
+ db: null as unknown as Database,
+ deviceType: undefined,
+ session: createSessionWithPermissions(...groupPermissionsWithoutAdmin),
+ });
+
+ // Act
+ const act = () => caller[procedure](validInputs[procedure] as never);
+
+ await expect(act()).rejects.toThrow(new TRPCError({ code: "FORBIDDEN", message: "Permission denied" }));
+ });
+
+ test.each(procedureKeys)("Procedure %s should not be accessible without session", async (procedure) => {
+ // Arrange
+ const caller = dockerRouter.createCaller({
+ db: null as unknown as Database,
+ deviceType: undefined,
+ session: null,
+ });
+
+ // Act
+ const act = () => caller[procedure](validInputs[procedure] as never);
+
+ await expect(act()).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" }));
+ });
+});
diff --git a/packages/api/src/router/test/group.spec.ts b/packages/api/src/router/test/group.spec.ts
new file mode 100644
index 000000000..1f5b461cc
--- /dev/null
+++ b/packages/api/src/router/test/group.spec.ts
@@ -0,0 +1,886 @@
+import { describe, expect, test, vi } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import * as env from "@homarr/auth/env";
+import { createId } from "@homarr/common";
+import { eq } from "@homarr/db";
+import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+import type { GroupPermissionKey } from "@homarr/definitions";
+
+import { groupRouter } from "../group";
+
+const defaultOwnerId = createId();
+const createSession = (permissions: GroupPermissionKey[]) =>
+ ({
+ user: {
+ id: defaultOwnerId,
+ permissions,
+ colorScheme: "light",
+ },
+ expires: new Date().toISOString(),
+ }) satisfies Session;
+const defaultSession = createSession([]);
+const adminSession = createSession(["admin"]);
+
+// Mock the auth module to return an empty session
+vi.mock("@homarr/auth", async () => {
+ const mod = await import("@homarr/auth/security");
+ return { ...mod, auth: () => ({}) as Session };
+});
+
+describe("paginated should return a list of groups with pagination", () => {
+ test.each([
+ [1, 3],
+ [2, 2],
+ ])(
+ "with 5 groups in database and pageSize set to 3 on page %s it should return %s groups",
+ async (page, expectedCount) => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ await db.insert(groups).values(
+ [1, 2, 3, 4, 5].map((number) => ({
+ id: number.toString(),
+ name: `Group ${number}`,
+ position: number,
+ })),
+ );
+
+ // Act
+ const result = await caller.getPaginated({
+ page,
+ pageSize: 3,
+ });
+
+ // Assert
+ expect(result.items.length).toBe(expectedCount);
+ },
+ );
+
+ test("with 5 groups in database and pagesize set to 3 it should return total count 5", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ await db.insert(groups).values(
+ [1, 2, 3, 4, 5].map((number) => ({
+ id: number.toString(),
+ name: `Group ${number}`,
+ position: number,
+ })),
+ );
+
+ // Act
+ const result = await caller.getPaginated({
+ pageSize: 3,
+ });
+
+ // Assert
+ expect(result.totalCount).toBe(5);
+ });
+
+ test("groups should contain id, name, email and image of members", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ const user = createDummyUser();
+ await db.insert(users).values(user);
+ const groupId = createId();
+ await db.insert(groups).values({
+ id: groupId,
+ name: "Group",
+ position: 1,
+ });
+ await db.insert(groupMembers).values({
+ groupId,
+ userId: user.id,
+ });
+
+ // Act
+ const result = await caller.getPaginated({});
+
+ // Assert
+ const item = result.items[0];
+ expect(item).toBeDefined();
+ expect(item?.members.length).toBe(1);
+ const userKeys = Object.keys(item?.members[0] ?? {});
+ expect(userKeys.length).toBe(4);
+ expect(["id", "name", "email", "image"].some((key) => userKeys.includes(key)));
+ });
+
+ test.each([
+ [undefined, 5, "first"],
+ ["d", 2, "second"],
+ ["th", 3, "third"],
+ ["fi", 2, "first"],
+ ])(
+ "groups should be searchable by name with contains pattern, query %s should result in %s results",
+ async (query, expectedCount, firstKey) => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ await db.insert(groups).values(
+ ["first", "second", "third", "forth", "fifth"].map((key, index) => ({
+ id: index.toString(),
+ name: key,
+ position: index + 1,
+ })),
+ );
+
+ // Act
+ const result = await caller.getPaginated({
+ search: query,
+ });
+
+ // Assert
+ expect(result.totalCount).toBe(expectedCount);
+ expect(result.items.at(0)?.name).toBe(firstKey);
+ },
+ );
+
+ test("without admin permissions it should throw unauthorized error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ // Act
+ const actAsync = async () => await caller.getPaginated({});
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Permission denied");
+ });
+});
+
+describe("byId should return group by id including members and permissions", () => {
+ test('should return group with id "1" with members and permissions', async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ const user = createDummyUser();
+ const groupId = "1";
+ await db.insert(users).values(user);
+ await db.insert(groups).values([
+ {
+ id: groupId,
+ name: "Group",
+ position: 1,
+ },
+ {
+ id: createId(),
+ name: "Another group",
+ position: 2,
+ },
+ ]);
+ await db.insert(groupMembers).values({
+ userId: user.id,
+ groupId,
+ });
+ await db.insert(groupPermissions).values({
+ groupId,
+ permission: "admin",
+ });
+
+ // Act
+ const result = await caller.getById({
+ id: groupId,
+ });
+
+ // Assert
+ expect(result.id).toBe(groupId);
+ expect(result.members.length).toBe(1);
+
+ const userKeys = Object.keys(result.members[0] ?? {});
+ expect(userKeys.length).toBe(5);
+ expect(["id", "name", "email", "image", "provider"].some((key) => userKeys.includes(key)));
+ expect(result.permissions.length).toBe(1);
+ expect(result.permissions[0]).toBe("admin");
+ });
+
+ test("with group id 1 and group 2 in database it should throw NOT_FOUND error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ await db.insert(groups).values({
+ id: "2",
+ name: "Group",
+ position: 1,
+ });
+
+ // Act
+ const actAsync = async () => await caller.getById({ id: "1" });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Group not found");
+ });
+
+ test("without admin permissions it should throw unauthorized error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ // Act
+ const actAsync = async () => await caller.getById({ id: "1" });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Permission denied");
+ });
+});
+
+describe("create should create group in database", () => {
+ test("with valid input (64 character name) and non existing name it should be successful", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ const name = "a".repeat(64);
+ await db.insert(users).values(defaultSession.user);
+
+ // Act
+ const result = await caller.createGroup({
+ name,
+ });
+
+ // Assert
+ const item = await db.query.groups.findFirst({
+ where: eq(groups.id, result),
+ });
+
+ expect(item).toBeDefined();
+ expect(item?.id).toBe(result);
+ expect(item?.ownerId).toBe(defaultOwnerId);
+ expect(item?.name).toBe(name);
+ });
+
+ test("with more than 64 characters name it should fail while validation", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+ const longName = "a".repeat(65);
+
+ // Act
+ const actAsync = async () =>
+ await caller.createGroup({
+ name: longName,
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("too_big");
+ });
+
+ test.each([
+ ["test", "Test"],
+ ["test", "Test "],
+ ["test", "test"],
+ ["test", " TeSt"],
+ ])("with similar name %s it should fail to create %s", async (similarName, nameToCreate) => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ await db.insert(groups).values({
+ id: createId(),
+ name: similarName,
+ position: 1,
+ });
+
+ // Act
+ const actAsync = async () => await caller.createGroup({ name: nameToCreate });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("similar name");
+ });
+
+ test("without admin permissions it should throw unauthorized error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ // Act
+ const actAsync = async () => await caller.createGroup({ name: "test" });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Permission denied");
+ });
+});
+
+describe("update should update name with value that is no duplicate", () => {
+ test.each([
+ ["first", "second ", "second"],
+ ["first", " first", "first"],
+ ])("update should update name from %s to %s normalized", async (initialValue, updateValue, expectedValue) => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ const groupId = createId();
+ await db.insert(groups).values([
+ {
+ id: groupId,
+ name: initialValue,
+ position: 1,
+ },
+ {
+ id: createId(),
+ name: "Third",
+ position: 2,
+ },
+ ]);
+
+ // Act
+ await caller.updateGroup({
+ id: groupId,
+ name: updateValue,
+ });
+
+ // Assert
+ const value = await db.query.groups.findFirst({
+ where: eq(groups.id, groupId),
+ });
+ expect(value?.name).toBe(expectedValue);
+ });
+
+ test.each([
+ ["Second ", "second"],
+ [" seCond", "second"],
+ ])("with similar name %s it should fail to update %s", async (updateValue, initialDuplicate) => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ const groupId = createId();
+ await db.insert(groups).values([
+ {
+ id: groupId,
+ name: "Something",
+ position: 1,
+ },
+ {
+ id: createId(),
+ name: initialDuplicate,
+ position: 2,
+ },
+ ]);
+
+ // Act
+ const actAsync = async () =>
+ await caller.updateGroup({
+ id: groupId,
+ name: updateValue,
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("similar name");
+ });
+
+ test("with non existing id it should throw not found error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ await db.insert(groups).values({
+ id: createId(),
+ name: "something",
+ position: 1,
+ });
+
+ // Act
+ const act = () =>
+ caller.updateGroup({
+ id: createId(),
+ name: "something else",
+ });
+
+ // Assert
+ await expect(act()).rejects.toThrow("Group not found");
+ });
+
+ test("without admin permissions it should throw unauthorized error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ // Act
+ const actAsync = async () =>
+ await caller.updateGroup({
+ id: createId(),
+ name: "test",
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Permission denied");
+ });
+});
+
+describe("savePermissions should save permissions for group", () => {
+ test("with existing group and permissions it should save permissions", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ const groupId = createId();
+ await db.insert(groups).values({
+ id: groupId,
+ name: "Group",
+ position: 1,
+ });
+ await db.insert(groupPermissions).values({
+ groupId,
+ permission: "admin",
+ });
+
+ // Act
+ await caller.savePermissions({
+ groupId,
+ permissions: ["integration-use-all", "board-full-all"],
+ });
+
+ // Assert
+ const permissions = await db.query.groupPermissions.findMany({
+ where: eq(groupPermissions.groupId, groupId),
+ });
+
+ expect(permissions.length).toBe(2);
+ expect(permissions.map(({ permission }) => permission)).toEqual(["integration-use-all", "board-full-all"]);
+ });
+
+ test("with non existing group it should throw not found error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ await db.insert(groups).values({
+ id: createId(),
+ name: "Group",
+ position: 1,
+ });
+
+ // Act
+ const actAsync = async () =>
+ await caller.savePermissions({
+ groupId: createId(),
+ permissions: ["integration-create", "board-full-all"],
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Group not found");
+ });
+
+ test("without admin permissions it should throw unauthorized error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ // Act
+ const actAsync = async () =>
+ await caller.savePermissions({
+ groupId: createId(),
+ permissions: ["integration-create", "board-full-all"],
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Permission denied");
+ });
+});
+
+describe("transferOwnership should transfer ownership of group", () => {
+ test("with existing group and user it should transfer ownership", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ const groupId = createId();
+ const newUserId = createId();
+ await db.insert(users).values([
+ {
+ id: newUserId,
+ name: "New user",
+ },
+ {
+ id: defaultOwnerId,
+ name: "Old user",
+ },
+ ]);
+ await db.insert(groups).values({
+ id: groupId,
+ name: "Group",
+ ownerId: defaultOwnerId,
+ position: 1,
+ });
+
+ // Act
+ await caller.transferOwnership({
+ groupId,
+ userId: newUserId,
+ });
+
+ // Assert
+ const group = await db.query.groups.findFirst({
+ where: eq(groups.id, groupId),
+ });
+
+ expect(group?.ownerId).toBe(newUserId);
+ });
+
+ test("with non existing group it should throw not found error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ await db.insert(groups).values({
+ id: createId(),
+ name: "Group",
+ position: 1,
+ });
+
+ // Act
+ const actAsync = async () =>
+ await caller.transferOwnership({
+ groupId: createId(),
+ userId: createId(),
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Group not found");
+ });
+
+ test("without admin permissions it should throw unauthorized error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ // Act
+ const actAsync = async () =>
+ await caller.transferOwnership({
+ groupId: createId(),
+ userId: createId(),
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Permission denied");
+ });
+});
+
+describe("deleteGroup should delete group", () => {
+ test("with existing group it should delete group", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ const groupId = createId();
+ await db.insert(groups).values([
+ {
+ id: groupId,
+ name: "Group",
+ position: 1,
+ },
+ {
+ id: createId(),
+ name: "Another group",
+ position: 2,
+ },
+ ]);
+
+ // Act
+ await caller.deleteGroup({
+ id: groupId,
+ });
+
+ // Assert
+ const dbGroups = await db.query.groups.findMany();
+
+ expect(dbGroups.length).toBe(1);
+ expect(dbGroups[0]?.id).not.toBe(groupId);
+ });
+
+ test("with non existing group it should throw not found error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ await db.insert(groups).values({
+ id: createId(),
+ name: "Group",
+ position: 1,
+ });
+
+ // Act
+ const actAsync = async () =>
+ await caller.deleteGroup({
+ id: createId(),
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Group not found");
+ });
+
+ test("without admin permissions it should throw unauthorized error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ // Act
+ const actAsync = async () =>
+ await caller.deleteGroup({
+ id: createId(),
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Permission denied");
+ });
+});
+
+describe("addMember should add member to group", () => {
+ test("with existing group and user it should add member", async () => {
+ // Arrange
+ const db = createDb();
+ const spy = vi.spyOn(env, "env", "get");
+ spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never);
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ const groupId = createId();
+ const userId = createId();
+ await db.insert(users).values([
+ {
+ id: userId,
+ name: "User",
+ },
+ {
+ id: defaultOwnerId,
+ name: "Creator",
+ },
+ ]);
+ await db.insert(groups).values({
+ id: groupId,
+ name: "Group",
+ ownerId: defaultOwnerId,
+ position: 1,
+ });
+
+ // Act
+ await caller.addMember({
+ groupId,
+ userId,
+ });
+
+ // Assert
+ const members = await db.query.groupMembers.findMany({
+ where: eq(groupMembers.groupId, groupId),
+ });
+
+ expect(members.length).toBe(1);
+ expect(members[0]?.userId).toBe(userId);
+ });
+
+ test("with non existing group it should throw not found error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ await db.insert(users).values({
+ id: createId(),
+ name: "User",
+ });
+
+ // Act
+ const actAsync = async () =>
+ await caller.addMember({
+ groupId: createId(),
+ userId: createId(),
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Group not found");
+ });
+
+ test("without admin permissions it should throw unauthorized error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ // Act
+ const actAsync = async () =>
+ await caller.addMember({
+ groupId: createId(),
+ userId: createId(),
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Permission denied");
+ });
+
+ test("without credentials provider it should throw FORBIDDEN error", async () => {
+ // Arrange
+ const db = createDb();
+ const spy = vi.spyOn(env, "env", "get");
+ spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never);
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ const groupId = createId();
+ const userId = createId();
+ await db.insert(users).values([
+ {
+ id: userId,
+ name: "User",
+ },
+ {
+ id: defaultOwnerId,
+ name: "Creator",
+ },
+ ]);
+ await db.insert(groups).values({
+ id: groupId,
+ name: "Group",
+ ownerId: defaultOwnerId,
+ position: 1,
+ });
+
+ // Act
+ const actAsync = async () =>
+ await caller.addMember({
+ groupId,
+ userId,
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
+ });
+});
+
+describe("removeMember should remove member from group", () => {
+ test("with existing group and user it should remove member", async () => {
+ // Arrange
+ const db = createDb();
+ const spy = vi.spyOn(env, "env", "get");
+ spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never);
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ const groupId = createId();
+ const userId = createId();
+ await db.insert(users).values([
+ {
+ id: userId,
+ name: "User",
+ },
+ {
+ id: defaultOwnerId,
+ name: "Creator",
+ },
+ ]);
+ await db.insert(groups).values({
+ id: groupId,
+ name: "Group",
+ ownerId: defaultOwnerId,
+ position: 1,
+ });
+ await db.insert(groupMembers).values({
+ groupId,
+ userId,
+ });
+
+ // Act
+ await caller.removeMember({
+ groupId,
+ userId,
+ });
+
+ // Assert
+ const members = await db.query.groupMembers.findMany({
+ where: eq(groupMembers.groupId, groupId),
+ });
+
+ expect(members.length).toBe(0);
+ });
+
+ test("with non existing group it should throw not found error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ await db.insert(users).values({
+ id: createId(),
+ name: "User",
+ });
+
+ // Act
+ const actAsync = async () =>
+ await caller.removeMember({
+ groupId: createId(),
+ userId: createId(),
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Group not found");
+ });
+
+ test("without admin permissions it should throw unauthorized error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+
+ // Act
+ const actAsync = async () =>
+ await caller.removeMember({
+ groupId: createId(),
+ userId: createId(),
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Permission denied");
+ });
+
+ test("without credentials provider it should throw FORBIDDEN error", async () => {
+ // Arrange
+ const db = createDb();
+ const spy = vi.spyOn(env, "env", "get");
+ spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never);
+ const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ const groupId = createId();
+ const userId = createId();
+ await db.insert(users).values([
+ {
+ id: userId,
+ name: "User",
+ },
+ {
+ id: defaultOwnerId,
+ name: "Creator",
+ },
+ ]);
+ await db.insert(groups).values({
+ id: groupId,
+ name: "Group",
+ ownerId: defaultOwnerId,
+ position: 1,
+ });
+ await db.insert(groupMembers).values({
+ groupId,
+ userId,
+ });
+
+ // Act
+ const actAsync = async () =>
+ await caller.removeMember({
+ groupId,
+ userId,
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
+ });
+});
+
+const createDummyUser = () => ({
+ id: createId(),
+ name: "username",
+ email: "user@gmail.com",
+ image: "example",
+ password: "secret",
+ salt: "secret",
+});
diff --git a/packages/api/src/router/test/helper.ts b/packages/api/src/router/test/helper.ts
new file mode 100644
index 000000000..f979bac5e
--- /dev/null
+++ b/packages/api/src/router/test/helper.ts
@@ -0,0 +1,11 @@
+import { expect } from "vitest";
+
+export const expectToBeDefined = (value: T) => {
+ if (value === undefined) {
+ expect(value).toBeDefined();
+ }
+ if (value === null) {
+ expect(value).not.toBeNull();
+ }
+ return value as Exclude;
+};
diff --git a/packages/api/src/router/test/integration/integration-access.spec.ts b/packages/api/src/router/test/integration/integration-access.spec.ts
new file mode 100644
index 000000000..b5349558a
--- /dev/null
+++ b/packages/api/src/router/test/integration/integration-access.spec.ts
@@ -0,0 +1,156 @@
+import { describe, expect, test, vi } from "vitest";
+
+import * as authShared from "@homarr/auth/shared";
+import { createId } from "@homarr/common";
+import { eq } from "@homarr/db";
+import { integrations, users } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+
+import { throwIfActionForbiddenAsync } from "../../integration/integration-access";
+
+const defaultCreatorId = createId();
+
+const expectActToBeAsync = async (act: () => Promise, success: boolean) => {
+ if (!success) {
+ await expect(act()).rejects.toThrow("Integration not found");
+ return;
+ }
+
+ await expect(act()).resolves.toBeUndefined();
+};
+
+describe("throwIfActionForbiddenAsync should check access to integration and return boolean", () => {
+ test.each([
+ ["full" as const, true],
+ ["interact" as const, true],
+ ["use" as const, true],
+ ])("with permission %s should return %s when hasFullAccess is true", async (permission, expectedResult) => {
+ // Arrange
+ const db = createDb();
+ const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
+ spy.mockReturnValue({
+ hasFullAccess: true,
+ hasInteractAccess: false,
+ hasUseAccess: false,
+ });
+
+ const integrationId = createId();
+ await db.insert(integrations).values({
+ id: integrationId,
+ name: "test",
+ kind: "adGuardHome",
+ url: "http://localhost:3000",
+ });
+
+ // Act
+ const act = () =>
+ throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
+
+ // Assert
+ await expectActToBeAsync(act, expectedResult);
+ });
+
+ test.each([
+ ["full" as const, false],
+ ["interact" as const, true],
+ ["use" as const, true],
+ ])("with permission %s should return %s when hasInteractAccess is true", async (permission, expectedResult) => {
+ // Arrange
+ const db = createDb();
+ const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
+ spy.mockReturnValue({
+ hasFullAccess: false,
+ hasInteractAccess: true,
+ hasUseAccess: false,
+ });
+
+ await db.insert(users).values({ id: defaultCreatorId });
+ const integrationId = createId();
+ await db.insert(integrations).values({
+ id: integrationId,
+ name: "test",
+ kind: "adGuardHome",
+ url: "http://localhost:3000",
+ });
+
+ // Act
+ const act = () =>
+ throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
+
+ // Assert
+ await expectActToBeAsync(act, expectedResult);
+ });
+
+ test.each([
+ ["full" as const, false],
+ ["interact" as const, false],
+ ["use" as const, true],
+ ])("with permission %s should return %s when hasUseAccess is true", async (permission, expectedResult) => {
+ // Arrange
+ const db = createDb();
+ const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
+ spy.mockReturnValue({
+ hasFullAccess: false,
+ hasInteractAccess: false,
+ hasUseAccess: true,
+ });
+
+ await db.insert(users).values({ id: defaultCreatorId });
+ const integrationId = createId();
+ await db.insert(integrations).values({
+ id: integrationId,
+ name: "test",
+ kind: "adGuardHome",
+ url: "http://localhost:3000",
+ });
+
+ // Act
+ const act = () =>
+ throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
+
+ // Assert
+ await expectActToBeAsync(act, expectedResult);
+ });
+
+ test.each([
+ ["full" as const, false],
+ ["interact" as const, false],
+ ["use" as const, false],
+ ])("with permission %s should return %s when hasUseAccess is false", async (permission, expectedResult) => {
+ // Arrange
+ const db = createDb();
+ const spy = vi.spyOn(authShared, "constructIntegrationPermissions");
+ spy.mockReturnValue({
+ hasFullAccess: false,
+ hasInteractAccess: false,
+ hasUseAccess: false,
+ });
+
+ await db.insert(users).values({ id: defaultCreatorId });
+ const integrationId = createId();
+ await db.insert(integrations).values({
+ id: integrationId,
+ name: "test",
+ kind: "adGuardHome",
+ url: "http://localhost:3000",
+ });
+
+ // Act
+ const act = () =>
+ throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, integrationId), permission);
+
+ // Assert
+ await expectActToBeAsync(act, expectedResult);
+ });
+
+ test("should throw when integration is not found", async () => {
+ // Arrange
+ const db = createDb();
+
+ // Act
+ const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(integrations.id, createId()), "full");
+
+ // Assert
+ await expect(act()).rejects.toThrow("Integration not found");
+ });
+});
diff --git a/packages/api/src/router/test/integration/integration-router.spec.ts b/packages/api/src/router/test/integration/integration-router.spec.ts
new file mode 100644
index 000000000..17927b9c4
--- /dev/null
+++ b/packages/api/src/router/test/integration/integration-router.spec.ts
@@ -0,0 +1,580 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+import { describe, expect, test, vi } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import { createId } from "@homarr/common";
+import { encryptSecret } from "@homarr/common/server";
+import { apps, integrations, integrationSecrets } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+import type { GroupPermissionKey } from "@homarr/definitions";
+
+import { integrationRouter } from "../../integration/integration-router";
+import { expectToBeDefined } from "../helper";
+
+const defaultUserId = createId();
+const defaultSessionWithPermissions = (permissions: GroupPermissionKey[] = []) =>
+ ({
+ user: {
+ id: defaultUserId,
+ permissions,
+ colorScheme: "light",
+ },
+ expires: new Date().toISOString(),
+ }) satisfies Session;
+
+// Mock the auth module to return an empty session
+vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
+vi.mock("../../integration/integration-test-connection", () => ({
+ testConnectionAsync: async () => await Promise.resolve({ success: true }),
+}));
+
+describe("all should return all integrations", () => {
+ test("with any session should return all integrations", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(),
+ });
+
+ await db.insert(integrations).values([
+ {
+ id: "1",
+ name: "Home assistant",
+ kind: "homeAssistant",
+ url: "http://homeassist.local",
+ },
+ {
+ id: "2",
+ name: "Home plex server",
+ kind: "plex",
+ url: "http://plex.local",
+ },
+ ]);
+
+ const result = await caller.all();
+ expect(result.length).toBe(2);
+ expect(result[0]!.kind).toBe("plex");
+ expect(result[1]!.kind).toBe("homeAssistant");
+ });
+});
+
+describe("byId should return an integration by id", () => {
+ test("with full access should return an integration by id", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(["integration-full-all"]),
+ });
+
+ await db.insert(integrations).values([
+ {
+ id: "1",
+ name: "Home assistant",
+ kind: "homeAssistant",
+ url: "http://homeassist.local",
+ },
+ {
+ id: "2",
+ name: "Home plex server",
+ kind: "plex",
+ url: "http://plex.local",
+ },
+ ]);
+
+ const result = await caller.byId({ id: "2" });
+ expect(result.kind).toBe("plex");
+ });
+
+ test("with full access should throw an error if the integration does not exist", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(["integration-full-all"]),
+ });
+
+ const actAsync = async () => await caller.byId({ id: "2" });
+ await expect(actAsync()).rejects.toThrow("Integration not found");
+ });
+
+ test("with full access should only return the public secret values", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(["integration-full-all"]),
+ });
+
+ await db.insert(integrations).values([
+ {
+ id: "1",
+ name: "Home assistant",
+ kind: "homeAssistant",
+ url: "http://homeassist.local",
+ },
+ ]);
+ await db.insert(integrationSecrets).values([
+ {
+ kind: "username",
+ value: encryptSecret("musterUser"),
+ integrationId: "1",
+ updatedAt: new Date(),
+ },
+ {
+ kind: "password",
+ value: encryptSecret("Password123!"),
+ integrationId: "1",
+ updatedAt: new Date(),
+ },
+ {
+ kind: "apiKey",
+ value: encryptSecret("1234567890"),
+ integrationId: "1",
+ updatedAt: new Date(),
+ },
+ ]);
+
+ const result = await caller.byId({ id: "1" });
+ expect(result.secrets.length).toBe(3);
+ const username = expectToBeDefined(result.secrets.find((secret) => secret.kind === "username"));
+ expect(username.value).not.toBeNull();
+ const password = expectToBeDefined(result.secrets.find((secret) => secret.kind === "password"));
+ expect(password.value).toBeNull();
+ const apiKey = expectToBeDefined(result.secrets.find((secret) => secret.kind === "apiKey"));
+ expect(apiKey.value).toBeNull();
+ });
+
+ test("without full access should throw integration not found error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(["integration-interact-all"]),
+ });
+
+ await db.insert(integrations).values([
+ {
+ id: "1",
+ name: "Home assistant",
+ kind: "homeAssistant",
+ url: "http://homeassist.local",
+ },
+ ]);
+
+ // Act
+ const actAsync = async () => await caller.byId({ id: "1" });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Integration not found");
+ });
+});
+
+describe("create should create a new integration", () => {
+ test("with create integration access should create a new integration", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(["integration-create"]),
+ });
+ const input = {
+ name: "Jellyfin",
+ kind: "jellyfin" as const,
+ url: "http://jellyfin.local",
+ secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
+ attemptSearchEngineCreation: false,
+ };
+
+ const fakeNow = new Date("2023-07-01T00:00:00Z");
+ vi.useFakeTimers();
+ vi.setSystemTime(fakeNow);
+ await caller.create(input);
+ vi.useRealTimers();
+
+ const dbIntegration = await db.query.integrations.findFirst();
+ const dbSecret = await db.query.integrationSecrets.findFirst();
+ expect(dbIntegration).toBeDefined();
+ expect(dbIntegration!.name).toBe(input.name);
+ expect(dbIntegration!.kind).toBe(input.kind);
+ expect(dbIntegration!.url).toBe(input.url);
+
+ expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
+ expect(dbSecret).toBeDefined();
+ expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
+ expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
+ expect(dbSecret!.updatedAt).toEqual(fakeNow);
+ });
+
+ test("with create integration access should create a new integration when creating search engine", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(["integration-create"]),
+ });
+ const input = {
+ name: "Jellyseerr",
+ kind: "jellyseerr" as const,
+ url: "http://jellyseerr.local",
+ secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
+ attemptSearchEngineCreation: true,
+ };
+
+ const fakeNow = new Date("2023-07-01T00:00:00Z");
+ vi.useFakeTimers();
+ vi.setSystemTime(fakeNow);
+ await caller.create(input);
+ vi.useRealTimers();
+
+ const dbIntegration = await db.query.integrations.findFirst();
+ const dbSecret = await db.query.integrationSecrets.findFirst();
+ const dbSearchEngine = await db.query.searchEngines.findFirst();
+ expect(dbIntegration).toBeDefined();
+ expect(dbIntegration!.name).toBe(input.name);
+ expect(dbIntegration!.kind).toBe(input.kind);
+ expect(dbIntegration!.url).toBe(input.url);
+
+ expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
+ expect(dbSecret).toBeDefined();
+ expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
+ expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
+ expect(dbSecret!.updatedAt).toEqual(fakeNow);
+
+ expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id);
+ expect(dbSearchEngine!.short).toBe("j");
+ expect(dbSearchEngine!.name).toBe(input.name);
+ expect(dbSearchEngine!.iconUrl).toBe(
+ "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
+ );
+ });
+
+ test("with create integration access should create a new integration with new linked app", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(["integration-create", "app-create"]),
+ });
+ const input = {
+ name: "Jellyfin",
+ kind: "jellyfin" as const,
+ url: "http://jellyfin.local",
+ secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
+ attemptSearchEngineCreation: false,
+ app: {
+ name: "Jellyfin",
+ description: null,
+ pingUrl: "http://jellyfin.local",
+ href: "https://jellyfin.home",
+ iconUrl: "logo.png",
+ },
+ };
+
+ const fakeNow = new Date("2023-07-01T00:00:00Z");
+ vi.useFakeTimers();
+ vi.setSystemTime(fakeNow);
+ await caller.create(input);
+ vi.useRealTimers();
+
+ const dbIntegration = await db.query.integrations.findFirst({
+ with: {
+ app: true,
+ },
+ });
+ const dbSecret = await db.query.integrationSecrets.findFirst();
+ expect(dbIntegration).toBeDefined();
+ expect(dbIntegration!.name).toBe(input.name);
+ expect(dbIntegration!.kind).toBe(input.kind);
+ expect(dbIntegration!.url).toBe(input.url);
+ expect(dbIntegration!.app!.name).toBe(input.app.name);
+ expect(dbIntegration!.app!.pingUrl).toBe(input.app.pingUrl);
+ expect(dbIntegration!.app!.href).toBe(input.app.href);
+ expect(dbIntegration!.app!.iconUrl).toBe(input.app.iconUrl);
+
+ expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
+ expect(dbSecret).toBeDefined();
+ expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
+ expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
+ expect(dbSecret!.updatedAt).toEqual(fakeNow);
+ });
+
+ test("with create integration access should create a new integration with existing linked app", async () => {
+ const db = createDb();
+ const appId = createId();
+ await db.insert(apps).values({
+ id: appId,
+ name: "Existing Jellyfin",
+ iconUrl: "logo.png",
+ });
+
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(["integration-create"]),
+ });
+ const input = {
+ name: "Jellyfin",
+ kind: "jellyfin" as const,
+ url: "http://jellyfin.local",
+ secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
+ attemptSearchEngineCreation: false,
+ app: {
+ id: appId,
+ },
+ };
+
+ const fakeNow = new Date("2023-07-01T00:00:00Z");
+ vi.useFakeTimers();
+ vi.setSystemTime(fakeNow);
+ await caller.create(input);
+ vi.useRealTimers();
+
+ const dbIntegration = await db.query.integrations.findFirst();
+ const dbSecret = await db.query.integrationSecrets.findFirst();
+ expect(dbIntegration).toBeDefined();
+ expect(dbIntegration!.name).toBe(input.name);
+ expect(dbIntegration!.kind).toBe(input.kind);
+ expect(dbIntegration!.url).toBe(input.url);
+ expect(dbIntegration!.appId).toBe(appId);
+
+ expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
+ expect(dbSecret).toBeDefined();
+ expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
+ expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
+ expect(dbSecret!.updatedAt).toEqual(fakeNow);
+ });
+
+ test("without create integration access should throw permission error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(["integration-interact-all"]),
+ });
+ const input = {
+ name: "Jellyfin",
+ kind: "jellyfin" as const,
+ url: "http://jellyfin.local",
+ secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
+ attemptSearchEngineCreation: false,
+ };
+
+ // Act
+ const actAsync = async () => await caller.create(input);
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Permission denied");
+ });
+
+ test("without create app access should throw permission error with new linked app", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(["integration-create"]),
+ });
+ const input = {
+ name: "Jellyfin",
+ kind: "jellyfin" as const,
+ url: "http://jellyfin.local",
+ secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
+ attemptSearchEngineCreation: false,
+ app: {
+ name: "Jellyfin",
+ description: null,
+ href: "https://jellyfin.home",
+ iconUrl: "logo.png",
+ pingUrl: null,
+ },
+ };
+
+ // Act
+ const actAsync = async () => await caller.create(input);
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Permission denied");
+ });
+});
+
+describe("update should update an integration", () => {
+ test("with full access should update an integration", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(["integration-full-all"]),
+ });
+
+ const lastWeek = new Date("2023-06-24T00:00:00Z");
+ const appId = createId();
+ const integrationId = createId();
+ const toInsert = {
+ id: integrationId,
+ name: "Pi Hole",
+ kind: "piHole" as const,
+ url: "http://hole.local",
+ };
+
+ await db.insert(apps).values({
+ id: appId,
+ name: "Previous",
+ iconUrl: "logo.png",
+ });
+ await db.insert(integrations).values(toInsert);
+
+ const usernameToInsert = {
+ kind: "username" as const,
+ value: encryptSecret("musterUser"),
+ integrationId,
+ updatedAt: lastWeek,
+ };
+
+ const passwordToInsert = {
+ kind: "password" as const,
+ value: encryptSecret("Password123!"),
+ integrationId,
+ updatedAt: lastWeek,
+ };
+ await db.insert(integrationSecrets).values([usernameToInsert, passwordToInsert]);
+
+ const input = {
+ id: integrationId,
+ name: "Milky Way Pi Hole",
+ kind: "piHole" as const,
+ url: "http://milkyway.local",
+ secrets: [
+ { kind: "username" as const, value: "newUser" },
+ { kind: "password" as const, value: null },
+ { kind: "apiKey" as const, value: "1234567890" },
+ ],
+ appId,
+ };
+
+ const fakeNow = new Date("2023-07-01T00:00:00Z");
+ vi.useFakeTimers();
+ vi.setSystemTime(fakeNow);
+ await caller.update(input);
+ vi.useRealTimers();
+
+ const dbIntegration = await db.query.integrations.findFirst();
+ const dbSecrets = await db.query.integrationSecrets.findMany();
+
+ expect(dbIntegration).toBeDefined();
+ expect(dbIntegration!.name).toBe(input.name);
+ expect(dbIntegration!.kind).toBe(input.kind);
+ expect(dbIntegration!.url).toBe(input.url);
+ expect(dbIntegration!.appId).toBe(appId);
+
+ expect(dbSecrets.length).toBe(3);
+ const username = expectToBeDefined(dbSecrets.find((secret) => secret.kind === "username"));
+ const password = expectToBeDefined(dbSecrets.find((secret) => secret.kind === "password"));
+ const apiKey = expectToBeDefined(dbSecrets.find((secret) => secret.kind === "apiKey"));
+ expect(username.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
+ expect(password.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
+ expect(apiKey.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
+ expect(username.updatedAt).toEqual(fakeNow);
+ expect(password.updatedAt).toEqual(lastWeek);
+ expect(apiKey.updatedAt).toEqual(fakeNow);
+ expect(username.value).not.toEqual(usernameToInsert.value);
+ expect(password.value).toEqual(passwordToInsert.value);
+ expect(apiKey.value).not.toEqual(input.secrets[2]!.value);
+ });
+
+ test("with full access should throw an error if the integration does not exist", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(["integration-full-all"]),
+ });
+
+ const actAsync = async () =>
+ await caller.update({
+ id: createId(),
+ name: "Pi Hole",
+ url: "http://hole.local",
+ secrets: [],
+ appId: null,
+ });
+ await expect(actAsync()).rejects.toThrow("Integration not found");
+ });
+
+ test("without full access should throw permission error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(["integration-interact-all"]),
+ });
+
+ // Act
+ const actAsync = async () =>
+ await caller.update({
+ id: createId(),
+ name: "Pi Hole",
+ url: "http://hole.local",
+ secrets: [],
+ appId: null,
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Integration not found");
+ });
+});
+
+describe("delete should delete an integration", () => {
+ test("with full access should delete an integration", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(["integration-full-all"]),
+ });
+
+ const integrationId = createId();
+ await db.insert(integrations).values({
+ id: integrationId,
+ name: "Home assistant",
+ kind: "homeAssistant",
+ url: "http://homeassist.local",
+ });
+
+ await db.insert(integrationSecrets).values([
+ {
+ kind: "username",
+ value: encryptSecret("example"),
+ integrationId,
+ updatedAt: new Date(),
+ },
+ ]);
+
+ await caller.delete({ id: integrationId });
+
+ const dbIntegration = await db.query.integrations.findFirst();
+ expect(dbIntegration).toBeUndefined();
+ const dbSecrets = await db.query.integrationSecrets.findMany();
+ expect(dbSecrets.length).toBe(0);
+ });
+
+ test("without full access should throw permission error", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(["integration-interact-all"]),
+ });
+
+ // Act
+ const actAsync = async () => await caller.delete({ id: createId() });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Integration not found");
+ });
+});
diff --git a/packages/api/src/router/test/integration/integration-test-connection.spec.ts b/packages/api/src/router/test/integration/integration-test-connection.spec.ts
new file mode 100644
index 000000000..f2519c533
--- /dev/null
+++ b/packages/api/src/router/test/integration/integration-test-connection.spec.ts
@@ -0,0 +1,347 @@
+import { describe, expect, test, vi } from "vitest";
+
+import * as homarrDefinitions from "@homarr/definitions";
+import * as homarrIntegrations from "@homarr/integrations";
+
+import { testConnectionAsync } from "../../integration/integration-test-connection";
+
+vi.mock("@homarr/common/server", async (importActual) => {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
+ const actual = await importActual();
+
+ return {
+ ...actual,
+ decryptSecret: (value: string) => value.split(".")[0],
+ };
+});
+
+describe("testConnectionAsync should run test connection of integration", () => {
+ test("with input of only form secrets matching api key kind it should use form apiKey", async () => {
+ // Arrange
+ const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
+ const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
+ factorySpy.mockReturnValue(
+ Promise.resolve({
+ testConnectionAsync: async () => await Promise.resolve({ success: true }),
+ } as homarrIntegrations.PiHoleIntegrationV6),
+ );
+ optionsSpy.mockReturnValue([["apiKey"]]);
+
+ const integration = {
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ kind: "piHole" as const,
+ secrets: [
+ {
+ kind: "apiKey" as const,
+ value: "secret",
+ },
+ ],
+ };
+
+ // Act
+ await testConnectionAsync(integration);
+
+ // Assert
+ expect(factorySpy).toHaveBeenCalledWith({
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ kind: "piHole",
+ decryptedSecrets: [
+ expect.objectContaining({
+ kind: "apiKey",
+ value: "secret",
+ }),
+ ],
+ externalUrl: null,
+ });
+ });
+
+ test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => {
+ // Arrange
+ const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
+ const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
+ factorySpy.mockReturnValue(
+ Promise.resolve({
+ testConnectionAsync: async () => await Promise.resolve({ success: true }),
+ } as homarrIntegrations.PiHoleIntegrationV6),
+ );
+ optionsSpy.mockReturnValue([["apiKey"]]);
+
+ const integration = {
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ kind: "piHole" as const,
+ secrets: [
+ {
+ kind: "apiKey" as const,
+ value: null,
+ },
+ ],
+ };
+
+ const dbSecrets = [
+ {
+ kind: "apiKey" as const,
+ value: "dbSecret.encrypted" as const,
+ },
+ ];
+
+ // Act
+ await testConnectionAsync(integration, dbSecrets);
+
+ // Assert
+ expect(factorySpy).toHaveBeenCalledWith({
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ kind: "piHole",
+ decryptedSecrets: [
+ expect.objectContaining({
+ kind: "apiKey",
+ value: "dbSecret",
+ }),
+ ],
+ externalUrl: null,
+ });
+ });
+
+ test("with input of form and db secrets matching api key kind it should use form apiKey", async () => {
+ // Arrange
+ const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
+ const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
+ factorySpy.mockReturnValue(
+ Promise.resolve({
+ testConnectionAsync: async () => await Promise.resolve({ success: true }),
+ } as homarrIntegrations.PiHoleIntegrationV6),
+ );
+ optionsSpy.mockReturnValue([["apiKey"]]);
+
+ const integration = {
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ kind: "piHole" as const,
+ secrets: [
+ {
+ kind: "apiKey" as const,
+ value: "secret",
+ },
+ ],
+ };
+
+ const dbSecrets = [
+ {
+ kind: "apiKey" as const,
+ value: "dbSecret.encrypted" as const,
+ },
+ ];
+
+ // Act
+ await testConnectionAsync(integration, dbSecrets);
+
+ // Assert
+ expect(factorySpy).toHaveBeenCalledWith({
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ kind: "piHole",
+ decryptedSecrets: [
+ expect.objectContaining({
+ kind: "apiKey",
+ value: "secret",
+ }),
+ ],
+ externalUrl: null,
+ });
+ });
+
+ test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => {
+ // Arrange
+ const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
+ const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
+ factorySpy.mockReturnValue(
+ Promise.resolve({
+ testConnectionAsync: async () => await Promise.resolve({ success: true }),
+ } as homarrIntegrations.PiHoleIntegrationV6),
+ );
+ optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
+
+ const integration = {
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ kind: "piHole" as const,
+ secrets: [
+ {
+ kind: "apiKey" as const,
+ value: "secret",
+ },
+ ],
+ };
+
+ const dbSecrets = [
+ {
+ kind: "username" as const,
+ value: "dbUsername.encrypted" as const,
+ },
+ {
+ kind: "password" as const,
+ value: "dbPassword.encrypted" as const,
+ },
+ ];
+
+ // Act
+ await testConnectionAsync(integration, dbSecrets);
+
+ // Assert
+ expect(factorySpy).toHaveBeenCalledWith({
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ kind: "piHole",
+ decryptedSecrets: [
+ expect.objectContaining({
+ kind: "apiKey",
+ value: "secret",
+ }),
+ ],
+ externalUrl: null,
+ });
+ });
+
+ test("with input of null form apiKey and db secrets for username and password it should use db username and password when both is allowed", async () => {
+ // Arrange
+ const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
+ const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
+ factorySpy.mockReturnValue(
+ Promise.resolve({
+ testConnectionAsync: async () => await Promise.resolve({ success: true }),
+ } as homarrIntegrations.PiHoleIntegrationV6),
+ );
+ optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
+
+ const integration = {
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ kind: "piHole" as const,
+ secrets: [
+ {
+ kind: "apiKey" as const,
+ value: null,
+ },
+ ],
+ };
+
+ const dbSecrets = [
+ {
+ kind: "username" as const,
+ value: "dbUsername.encrypted" as const,
+ },
+ {
+ kind: "password" as const,
+ value: "dbPassword.encrypted" as const,
+ },
+ ];
+
+ // Act
+ await testConnectionAsync(integration, dbSecrets);
+
+ // Assert
+ expect(factorySpy).toHaveBeenCalledWith({
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ kind: "piHole",
+ decryptedSecrets: [
+ expect.objectContaining({
+ kind: "username",
+ value: "dbUsername",
+ }),
+ expect.objectContaining({
+ kind: "password",
+ value: "dbPassword",
+ }),
+ ],
+ externalUrl: null,
+ });
+ });
+
+ test("with input of existing github app", async () => {
+ // Arrange
+ const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
+ const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
+ factorySpy.mockReturnValue(
+ Promise.resolve({
+ testConnectionAsync: async () => await Promise.resolve({ success: true }),
+ } as homarrIntegrations.PiHoleIntegrationV6),
+ );
+ optionsSpy.mockReturnValue([[], ["githubAppId", "githubInstallationId", "privateKey"]]);
+
+ const integration = {
+ id: "new",
+ name: "GitHub",
+ url: "https://api.github.com",
+ kind: "github" as const,
+ secrets: [
+ {
+ kind: "githubAppId" as const,
+ value: "345",
+ },
+ {
+ kind: "githubInstallationId" as const,
+ value: "456",
+ },
+ {
+ kind: "privateKey" as const,
+ value: null,
+ },
+ ],
+ };
+
+ const dbSecrets = [
+ {
+ kind: "githubAppId" as const,
+ value: "123.encrypted" as const,
+ },
+ {
+ kind: "githubInstallationId" as const,
+ value: "234.encrypted" as const,
+ },
+ {
+ kind: "privateKey" as const,
+ value: "privateKey.encrypted" as const,
+ },
+ ];
+
+ // Act
+ await testConnectionAsync(integration, dbSecrets);
+
+ // Assert
+ expect(factorySpy).toHaveBeenCalledWith({
+ id: "new",
+ name: "GitHub",
+ url: "https://api.github.com",
+ kind: "github" as const,
+ decryptedSecrets: [
+ expect.objectContaining({
+ kind: "githubAppId",
+ value: "345",
+ }),
+ expect.objectContaining({
+ kind: "githubInstallationId",
+ value: "456",
+ }),
+ expect.objectContaining({
+ kind: "privateKey",
+ value: "privateKey",
+ }),
+ ],
+ externalUrl: null,
+ });
+ });
+});
diff --git a/packages/api/src/router/test/invite.spec.ts b/packages/api/src/router/test/invite.spec.ts
new file mode 100644
index 000000000..57a157de3
--- /dev/null
+++ b/packages/api/src/router/test/invite.spec.ts
@@ -0,0 +1,207 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+import { describe, expect, test, vi } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import { createId } from "@homarr/common";
+import { invites, users } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+
+import { inviteRouter } from "../invite";
+
+const defaultSession = {
+ user: {
+ id: createId(),
+ permissions: ["admin"],
+ colorScheme: "light",
+ },
+ expires: new Date().toISOString(),
+} satisfies Session;
+
+// Mock the auth module to return an empty session
+vi.mock("@homarr/auth", async () => {
+ const mod = await import("@homarr/auth/security");
+ return { ...mod, auth: () => ({}) as Session };
+});
+
+// Mock the env module to return the credentials provider
+vi.mock("@homarr/auth/env", () => {
+ return {
+ env: {
+ AUTH_PROVIDERS: ["credentials"],
+ },
+ };
+});
+
+describe("all should return all existing invites without sensitive informations", () => {
+ test("invites should not contain sensitive informations", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = inviteRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSession,
+ });
+
+ const userId = createId();
+ await db.insert(users).values({
+ id: userId,
+ name: "someone",
+ });
+
+ const inviteId = createId();
+ await db.insert(invites).values({
+ id: inviteId,
+ creatorId: userId,
+ expirationDate: new Date(2022, 5, 1),
+ token: "token",
+ });
+
+ // Act
+ const result = await caller.getAll();
+
+ // Assert
+ expect(result.length).toBe(1);
+ expect(result[0]?.id).toBe(inviteId);
+ expect(result[0]?.expirationDate).toEqual(new Date(2022, 5, 1));
+ expect(result[0]?.creator.id).toBe(userId);
+ expect(result[0]?.creator.name).toBe("someone");
+ expect("token" in result[0]!).toBe(false);
+ });
+
+ test("invites should be sorted ascending by expiration date", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = inviteRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSession,
+ });
+
+ const userId = createId();
+ await db.insert(users).values({
+ id: userId,
+ name: "someone",
+ });
+
+ const inviteId = createId();
+ await db.insert(invites).values({
+ id: inviteId,
+ creatorId: userId,
+ expirationDate: new Date(2022, 5, 1),
+ token: "token",
+ });
+ await db.insert(invites).values({
+ id: createId(),
+ creatorId: userId,
+ expirationDate: new Date(2022, 5, 2),
+ token: "token2",
+ });
+
+ // Act
+ const result = await caller.getAll();
+
+ // Assert
+ expect(result.length).toBe(2);
+ expect(result[0]?.expirationDate.getDate()).toBe(1);
+ expect(result[1]?.expirationDate.getDate()).toBe(2);
+ });
+});
+
+describe("create should create a new invite expiring on the specified date with a token and id returned to generate url", () => {
+ test("creation should work with a date in the future, but less than 6 months.", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = inviteRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSession,
+ });
+ await db.insert(users).values({
+ id: defaultSession.user.id,
+ });
+ const expirationDate = new Date(2024, 5, 1); // TODO: add mock date
+
+ // Act
+ const result = await caller.createInvite({
+ expirationDate,
+ });
+
+ // Assert
+ expect(result.id.length).toBeGreaterThan(10);
+ expect(result.token.length).toBeGreaterThan(20);
+
+ const createdInvite = await db.query.invites.findFirst();
+ expect(createdInvite).toBeDefined();
+ expect(createdInvite?.id).toBe(result.id);
+ expect(createdInvite?.token).toBe(result.token);
+ expect(createdInvite?.expirationDate).toEqual(expirationDate);
+ expect(createdInvite?.creatorId).toBe(defaultSession.user.id);
+ });
+});
+
+describe("delete should remove invite by id", () => {
+ test("deletion should remove present invite", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = inviteRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSession,
+ });
+
+ const userId = createId();
+ await db.insert(users).values({
+ id: userId,
+ });
+ const inviteId = createId();
+ await db.insert(invites).values([
+ {
+ id: createId(),
+ creatorId: userId,
+ expirationDate: new Date(2023, 1, 1),
+ token: "first-token",
+ },
+ {
+ id: inviteId,
+ creatorId: userId,
+ expirationDate: new Date(2023, 1, 1),
+ token: "second-token",
+ },
+ ]);
+
+ // Act
+ await caller.deleteInvite({ id: inviteId });
+
+ // Assert
+ const dbInvites = await db.query.invites.findMany();
+ expect(dbInvites.length).toBe(1);
+ expect(dbInvites[0]?.id).not.toBe(inviteId);
+ });
+
+ test("deletion should throw with NOT_FOUND code when specified invite not present", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = inviteRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSession,
+ });
+
+ const userId = createId();
+ await db.insert(users).values({
+ id: userId,
+ });
+ await db.insert(invites).values({
+ id: createId(),
+ creatorId: userId,
+ expirationDate: new Date(2023, 1, 1),
+ token: "first-token",
+ });
+
+ // Act
+ const actAsync = async () => await caller.deleteInvite({ id: createId() });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("not found");
+ });
+});
diff --git a/packages/api/src/router/test/kubernetes/resource-parser/cpu-resource-parser.spec.ts b/packages/api/src/router/test/kubernetes/resource-parser/cpu-resource-parser.spec.ts
new file mode 100644
index 000000000..819dce1ff
--- /dev/null
+++ b/packages/api/src/router/test/kubernetes/resource-parser/cpu-resource-parser.spec.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from "vitest";
+
+import { CpuResourceParser } from "../../../kubernetes/resource-parser/cpu-resource-parser";
+
+describe("CpuResourceParser", () => {
+ const parser = new CpuResourceParser();
+
+ it("should return NaN for empty or invalid input", () => {
+ expect(parser.parse("")).toBeNaN();
+ expect(parser.parse(" ")).toBeNaN();
+ expect(parser.parse("abc")).toBeNaN();
+ });
+
+ it("should parse CPU values without a unit (cores)", () => {
+ expect(parser.parse("1")).toBe(1);
+ expect(parser.parse("2.5")).toBe(2.5);
+ expect(parser.parse("10")).toBe(10);
+ });
+
+ it("should parse CPU values with milli-core unit ('m')", () => {
+ expect(parser.parse("500m")).toBe(0.5); // 500 milli-cores = 0.5 cores
+ expect(parser.parse("250m")).toBe(0.25);
+ expect(parser.parse("1000m")).toBe(1);
+ });
+
+ it("should parse CPU values with kilo-core unit ('k')", () => {
+ expect(parser.parse("1k")).toBe(1000); // 1 kilo-core = 1000 cores
+ expect(parser.parse("2k")).toBe(2000);
+ expect(parser.parse("0.5k")).toBe(500);
+ });
+
+ it("should parse CPU values with nano-core unit ('n')", () => {
+ // Adjust the expected values for nano-cores to account for floating-point precision
+ expect(parser.parse("1000000000n")).toBe(1); // 1 NanoCPU = 1/1,000,000,000 cores
+ expect(parser.parse("500000000n")).toBe(0.5);
+ expect(parser.parse("0.000000001n")).toBe(0.000000000000000001); // Tiny value
+ });
+
+ it("should parse CPU values with micro-core unit ('u')", () => {
+ // Adjust the expected values for micro-cores to account for floating-point precision
+ expect(parser.parse("1000000u")).toBe(1); // 1 MicroCPU = 1/1,000,000 cores
+ expect(parser.parse("500000u")).toBe(0.5);
+ expect(parser.parse("0.000001u")).toBe(0.000000000001); // Tiny value
+ });
+
+ it("should handle input with commas", () => {
+ expect(parser.parse("1,000")).toBe(1000); // 1,000 cores
+ expect(parser.parse("1,500m")).toBe(1.5); // 1,500 milli-cores = 1.5 cores
+ });
+
+ it("should ignore leading and trailing whitespace", () => {
+ expect(parser.parse(" 1 ")).toBe(1);
+ expect(parser.parse(" 500m ")).toBe(0.5);
+ expect(parser.parse(" 2k ")).toBe(2000);
+ });
+});
diff --git a/packages/api/src/router/test/kubernetes/resource-parser/memory-resource-parser.spec.ts b/packages/api/src/router/test/kubernetes/resource-parser/memory-resource-parser.spec.ts
new file mode 100644
index 000000000..f56f78237
--- /dev/null
+++ b/packages/api/src/router/test/kubernetes/resource-parser/memory-resource-parser.spec.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+
+import { MemoryResourceParser } from "../../../kubernetes/resource-parser/memory-resource-parser";
+
+const BYTES_IN_GIB = 1024 ** 3; // 1 GiB in bytes
+const BYTES_IN_MIB = 1024 ** 2; // 1 MiB in bytes
+const BYTES_IN_KIB = 1024; // 1 KiB in bytes
+const KI = "Ki";
+const MI = "Mi";
+const GI = "Gi";
+const TI = "Ti";
+const PI = "Pi";
+
+describe("MemoryResourceParser", () => {
+ const parser = new MemoryResourceParser();
+
+ it("should parse values without units as bytes and convert to GiB", () => {
+ expect(parser.parse("1073741824")).toBe(1); // 1 GiB
+ expect(parser.parse("2147483648")).toBe(2); // 2 GiB
+ });
+
+ it("should parse binary units (Ki, Mi, Gi, Ti, Pi) into GiB", () => {
+ expect(parser.parse(`1024${KI}`)).toBeCloseTo(1 / 1024); // 1 MiB = 1/1024 GiB
+ expect(parser.parse(`1${MI}`)).toBeCloseTo(1 / 1024); // 1 MiB = 1/1024 GiB
+ expect(parser.parse(`1${GI}`)).toBe(1); // 1 GiB
+ expect(parser.parse(`1${TI}`)).toBe(BYTES_IN_KIB); // 1 TiB = 1024 GiB
+ expect(parser.parse(`1${PI}`)).toBe(BYTES_IN_MIB); // 1 PiB = 1024^2 GiB
+ });
+
+ it("should parse decimal units (K, M, G, T, P) into GiB", () => {
+ expect(parser.parse("1000K")).toBeCloseTo(1000 / BYTES_IN_GIB); // 1000 KB
+ expect(parser.parse("1M")).toBeCloseTo(1 / BYTES_IN_KIB); // 1 MB = 1/1024 GiB
+ expect(parser.parse("1G")).toBeCloseTo(0.9313225746154785); // 1 GB ≈ 0.931 GiB
+ expect(parser.parse("1T")).toBeCloseTo(931.3225746154785); // 1 TB ≈ 931.32 GiB
+ expect(parser.parse("1P")).toBeCloseTo(931322.5746154785); // 1 PB ≈ 931,322.57 GiB
+ });
+
+ it("should handle invalid input and return NaN", () => {
+ expect(parser.parse("")).toBeNaN();
+ expect(parser.parse(" ")).toBeNaN();
+ expect(parser.parse("abc")).toBeNaN();
+ });
+
+ it("should handle commas in input and convert to GiB", () => {
+ expect(parser.parse("1,073,741,824")).toBe(1); // 1 GiB
+ expect(parser.parse("1,024Ki")).toBeCloseTo(1 / BYTES_IN_KIB); // 1 MiB
+ });
+
+ it("should handle lowercase and uppercase units", () => {
+ expect(parser.parse("1ki")).toBeCloseTo(1 / BYTES_IN_KIB); // 1 MiB
+ expect(parser.parse("1KI")).toBeCloseTo(1 / BYTES_IN_KIB);
+ expect(parser.parse("1Mi")).toBeCloseTo(1 / BYTES_IN_KIB);
+ expect(parser.parse("1m")).toBeCloseTo(1 / BYTES_IN_KIB);
+ });
+
+ it("should assume bytes for unrecognized or no units and convert to GiB", () => {
+ expect(parser.parse("1073741824")).toBe(1); // 1 GiB
+ expect(parser.parse("42")).toBeCloseTo(42 / BYTES_IN_GIB); // 42 bytes in GiB
+ expect(parser.parse("42unknown")).toBeCloseTo(42 / BYTES_IN_GIB); // Invalid unit = bytes
+ });
+});
diff --git a/packages/api/src/router/test/serverSettings.spec.ts b/packages/api/src/router/test/serverSettings.spec.ts
new file mode 100644
index 000000000..6a0e458c2
--- /dev/null
+++ b/packages/api/src/router/test/serverSettings.spec.ts
@@ -0,0 +1,97 @@
+import SuperJSON from "superjson";
+import { describe, expect, test, vi } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import { createId } from "@homarr/common";
+import { serverSettings } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
+
+import { serverSettingsRouter } from "../serverSettings";
+
+// Mock the auth module to return an empty session
+vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
+
+const defaultSession = {
+ user: {
+ id: createId(),
+ permissions: ["admin"],
+ colorScheme: "light",
+ },
+ expires: new Date().toISOString(),
+} satisfies Session;
+
+describe("getAll server settings", () => {
+ test("getAll should throw error when unauthorized", async () => {
+ const db = createDb();
+ const caller = serverSettingsRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: null,
+ });
+
+ await db.insert(serverSettings).values([
+ {
+ settingKey: defaultServerSettingsKeys[0],
+ value: SuperJSON.stringify(defaultServerSettings.analytics),
+ },
+ ]);
+
+ const actAsync = async () => await caller.getAll();
+
+ await expect(actAsync()).rejects.toThrow();
+ });
+ test("getAll should return default server settings when nothing in database", async () => {
+ const db = createDb();
+ const caller = serverSettingsRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSession,
+ });
+
+ const result = await caller.getAll();
+
+ expect(result).toStrictEqual(defaultServerSettings);
+ });
+});
+
+describe("saveSettings", () => {
+ test("saveSettings should update settings and return true when it updated only one", async () => {
+ const db = createDb();
+ const caller = serverSettingsRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSession,
+ });
+
+ await db.insert(serverSettings).values([
+ {
+ settingKey: defaultServerSettingsKeys[0],
+ value: SuperJSON.stringify(defaultServerSettings.analytics),
+ },
+ ]);
+
+ await caller.saveSettings({
+ settingsKey: "analytics",
+ value: {
+ enableGeneral: true,
+ enableWidgetData: true,
+ enableIntegrationData: true,
+ enableUserData: true,
+ },
+ });
+
+ const dbSettings = await db.select().from(serverSettings);
+ expect(dbSettings).toStrictEqual([
+ {
+ settingKey: "analytics",
+ value: SuperJSON.stringify({
+ enableGeneral: true,
+ enableWidgetData: true,
+ enableIntegrationData: true,
+ enableUserData: true,
+ }),
+ },
+ ]);
+ });
+});
diff --git a/packages/api/src/router/test/user.spec.ts b/packages/api/src/router/test/user.spec.ts
new file mode 100644
index 000000000..b3b262846
--- /dev/null
+++ b/packages/api/src/router/test/user.spec.ts
@@ -0,0 +1,323 @@
+import { describe, expect, it, test, vi } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import { createId } from "@homarr/common";
+import type { Database } from "@homarr/db";
+import { eq } from "@homarr/db";
+import { invites, onboarding, users } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
+
+import { userRouter } from "../user";
+
+const defaultOwnerId = createId();
+const createSession = (permissions: GroupPermissionKey[]) =>
+ ({
+ user: {
+ id: defaultOwnerId,
+ permissions,
+ colorScheme: "light",
+ },
+ expires: new Date().toISOString(),
+ }) satisfies Session;
+const defaultSession = createSession([]);
+
+// Mock the auth module to return an empty session
+vi.mock("@homarr/auth", async () => {
+ const mod = await import("@homarr/auth/security");
+ return { ...mod, auth: () => ({}) as Session };
+});
+
+// Mock the env module to return the credentials provider
+vi.mock("@homarr/auth/env", () => {
+ return {
+ env: {
+ AUTH_PROVIDERS: ["credentials"],
+ },
+ };
+});
+
+describe("initUser should initialize the first user", () => {
+ it("should create a user if none exists", async () => {
+ const db = createDb();
+ await createOnboardingStepAsync(db, "user");
+ const caller = userRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: null,
+ });
+
+ await caller.initUser({
+ username: "test",
+ password: "123ABCdef+/-",
+ confirmPassword: "123ABCdef+/-",
+ });
+
+ const user = await db.query.users.findFirst({
+ columns: {
+ id: true,
+ },
+ });
+
+ expect(user).toBeDefined();
+ });
+
+ it("should not create a user if the password and confirmPassword do not match", async () => {
+ const db = createDb();
+ await createOnboardingStepAsync(db, "user");
+ const caller = userRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: null,
+ });
+
+ const actAsync = async () =>
+ await caller.initUser({
+ username: "test",
+ password: "123ABCdef+/-",
+ confirmPassword: "456ABCdef+/-",
+ });
+
+ await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch");
+ });
+
+ it.each([
+ ["aB2%"], // too short
+ ["abc123DEF"], // does not contain special characters
+ ["abcDEFghi+"], // does not contain numbers
+ ["ABC123+/-"], // does not contain lowercase
+ ["abc123+/-"], // does not contain uppercase
+ ])("should throw error that password requirements do not match for '%s' as password", async (password) => {
+ const db = createDb();
+ await createOnboardingStepAsync(db, "user");
+ const caller = userRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: null,
+ });
+
+ const actAsync = async () =>
+ await caller.initUser({
+ username: "test",
+ password,
+ confirmPassword: password,
+ });
+
+ await expect(actAsync()).rejects.toThrow("passwordRequirements");
+ });
+});
+
+describe("register should create a user with valid invitation", () => {
+ test("register should create a user with valid invitation", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = userRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: null,
+ });
+
+ const userId = createId();
+ const inviteId = createId();
+ const inviteToken = "123";
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(2024, 0, 3));
+
+ await db.insert(users).values({
+ id: userId,
+ });
+ await db.insert(invites).values({
+ id: inviteId,
+ token: inviteToken,
+ creatorId: userId,
+ expirationDate: new Date(2024, 0, 5),
+ });
+
+ // Act
+ await caller.register({
+ inviteId,
+ token: inviteToken,
+ username: "test",
+ password: "123ABCdef+/-",
+ confirmPassword: "123ABCdef+/-",
+ });
+
+ // Assert
+ const user = await db.query.users.findMany({
+ columns: {
+ name: true,
+ },
+ });
+ const invite = await db.query.invites.findMany({
+ columns: {
+ id: true,
+ },
+ });
+
+ expect(user).toHaveLength(2);
+ expect(invite).toHaveLength(0);
+ });
+
+ test.each([
+ [{ token: "fakeToken" }, new Date(2024, 0, 3)],
+ [{ inviteId: "fakeInviteId" }, new Date(2024, 0, 3)],
+ [{}, new Date(2024, 0, 5, 0, 0, 1)],
+ ])(
+ "register should throw an error with input %s and date %s if the invitation is invalid",
+ async (partialInput, systemTime) => {
+ // Arrange
+ const db = createDb();
+ const caller = userRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: null,
+ });
+
+ const userId = createId();
+ const inviteId = createId();
+ const inviteToken = "123";
+ vi.useFakeTimers();
+ vi.setSystemTime(systemTime);
+
+ await db.insert(users).values({
+ id: userId,
+ });
+ await db.insert(invites).values({
+ id: inviteId,
+ token: inviteToken,
+ creatorId: userId,
+ expirationDate: new Date(2024, 0, 5),
+ });
+
+ // Act
+ const actAsync = async () =>
+ await caller.register({
+ inviteId,
+ token: inviteToken,
+ username: "test",
+ password: "123ABCdef+/-",
+ confirmPassword: "123ABCdef+/-",
+ ...partialInput,
+ });
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow("Invalid invite");
+ },
+ );
+});
+
+describe("editProfile shoud update user", () => {
+ test("editProfile should update users and not update emailVerified when email not dirty", async () => {
+ // arrange
+ const db = createDb();
+ const caller = userRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSession,
+ });
+
+ const emailVerified = new Date(2024, 0, 5);
+
+ await db.insert(users).values({
+ id: defaultOwnerId,
+ name: "TEST 1",
+ email: "abc@gmail.com",
+ emailVerified,
+ });
+
+ // act
+ await caller.editProfile({
+ id: defaultOwnerId,
+ name: "ABC",
+ email: "",
+ });
+
+ // assert
+ const user = await db.select().from(users).where(eq(users.id, defaultOwnerId));
+
+ expect(user).toHaveLength(1);
+ expect(user[0]).containSubset({
+ id: defaultOwnerId,
+ name: "abc",
+ email: "abc@gmail.com",
+ emailVerified,
+ });
+ });
+
+ test("editProfile should update users and update emailVerified when email dirty", async () => {
+ // arrange
+ const db = createDb();
+ const caller = userRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSession,
+ });
+
+ await db.insert(users).values({
+ id: defaultOwnerId,
+ name: "TEST 1",
+ email: "abc@gmail.com",
+ emailVerified: new Date(2024, 0, 5),
+ });
+
+ // act
+ await caller.editProfile({
+ id: defaultOwnerId,
+ name: "ABC",
+ email: "myNewEmail@gmail.com",
+ });
+
+ // assert
+ const user = await db.select().from(users).where(eq(users.id, defaultOwnerId));
+
+ expect(user).toHaveLength(1);
+ expect(user[0]).containSubset({
+ id: defaultOwnerId,
+ name: "abc",
+ email: "myNewEmail@gmail.com",
+ emailVerified: null,
+ });
+ });
+});
+
+describe("delete should delete user", () => {
+ test("delete should delete user", async () => {
+ const db = createDb();
+ const caller = userRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSession,
+ });
+
+ const initialUsers = [
+ {
+ id: createId(),
+ name: "User 1",
+ },
+ {
+ id: defaultOwnerId,
+ name: "User 2",
+ },
+ {
+ id: createId(),
+ name: "User 3",
+ },
+ ];
+
+ await db.insert(users).values(initialUsers);
+
+ await caller.delete({ userId: defaultOwnerId });
+
+ const usersInDb = await db.select().from(users);
+ expect(usersInDb).toHaveLength(2);
+ expect(usersInDb[0]).containSubset(initialUsers[0]);
+ expect(usersInDb[1]).containSubset(initialUsers[2]);
+ });
+});
+
+const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) => {
+ await db.insert(onboarding).values({
+ id: createId(),
+ step,
+ });
+};
diff --git a/packages/api/src/router/test/widgets/app.spec.ts b/packages/api/src/router/test/widgets/app.spec.ts
new file mode 100644
index 000000000..bd5265ff6
--- /dev/null
+++ b/packages/api/src/router/test/widgets/app.spec.ts
@@ -0,0 +1,53 @@
+import { describe, expect, test, vi } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import { createDb } from "@homarr/db/test";
+import * as ping from "@homarr/ping";
+
+import { appRouter } from "../../widgets/app";
+
+// Mock the auth module to return an empty session
+vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
+vi.mock("@homarr/ping", () => ({ sendPingRequestAsync: async () => await Promise.resolve(null) }));
+
+describe("ping should call sendPingRequestAsync with url and return result", () => {
+ test("ping with error response should return error and url", async () => {
+ // Arrange
+ const spy = vi.spyOn(ping, "sendPingRequestAsync");
+ const url = "http://localhost";
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: null,
+ });
+ spy.mockImplementation(() => Promise.resolve({ error: "error" }));
+
+ // Act
+ const result = await caller.ping({ url });
+
+ // Assert
+ expect(result.url).toBe(url);
+ expect("error" in result).toBe(true);
+ });
+
+ test("ping with success response should return statusCode and url", async () => {
+ // Arrange
+ const spy = vi.spyOn(ping, "sendPingRequestAsync");
+ const url = "http://localhost";
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: null,
+ });
+ spy.mockImplementation(() => Promise.resolve({ statusCode: 200, durationMs: 123 }));
+
+ // Act
+ const result = await caller.ping({ url });
+
+ // Assert
+ expect(result.url).toBe(url);
+ expect("statusCode" in result).toBe(true);
+ });
+});
diff --git a/packages/api/src/router/update-checker.ts b/packages/api/src/router/update-checker.ts
new file mode 100644
index 000000000..8957be5f2
--- /dev/null
+++ b/packages/api/src/router/update-checker.ts
@@ -0,0 +1,19 @@
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
+
+import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
+
+const logger = createLogger({ module: "updateCheckerRouter" });
+
+export const updateCheckerRouter = createTRPCRouter({
+ getAvailableUpdates: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
+ try {
+ const handler = updateCheckerRequestHandler.handler({});
+ const data = await handler.getCachedOrUpdatedDataAsync({});
+ return data.data.availableUpdates;
+ } catch (error) {
+ logger.error(new Error("Failed to get available updates", { cause: error }));
+ return undefined; // We return undefined to not show the indicator in the UI
+ }
+ }),
+});
diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts
new file mode 100644
index 000000000..f7ed6deb6
--- /dev/null
+++ b/packages/api/src/router/user.ts
@@ -0,0 +1,570 @@
+import { TRPCError } from "@trpc/server";
+import { z } from "zod/v4";
+
+import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
+import { createId } from "@homarr/common";
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import type { Database } from "@homarr/db";
+import { and, eq, like } from "@homarr/db";
+import { getMaxGroupPositionAsync } from "@homarr/db/queries";
+import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
+import { selectUserSchema } from "@homarr/db/validationSchemas";
+import { credentialsAdminGroup } from "@homarr/definitions";
+import type { SupportedAuthProvider } from "@homarr/definitions";
+import { byIdSchema } from "@homarr/validation/common";
+import type { userBaseCreateSchema } from "@homarr/validation/user";
+import {
+ userChangeColorSchemeSchema,
+ userChangeHomeBoardsSchema,
+ userChangePasswordApiSchema,
+ userChangeSearchPreferencesSchema,
+ userCreateSchema,
+ userEditProfileSchema,
+ userFirstDayOfWeekSchema,
+ userInitSchema,
+ userPingIconsEnabledSchema,
+ userRegistrationApiSchema,
+} from "@homarr/validation/user";
+
+import { convertIntersectionToZodObject } from "../schema-merger";
+import {
+ createTRPCRouter,
+ onboardingProcedure,
+ permissionRequiredProcedure,
+ protectedProcedure,
+ publicProcedure,
+} from "../trpc";
+import { throwIfActionForbiddenAsync } from "./board/board-access";
+import { throwIfCredentialsDisabled } from "./invite/checks";
+import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
+import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences";
+
+const logger = createLogger({ module: "userRouter" });
+
+export const userRouter = createTRPCRouter({
+ initUser: onboardingProcedure
+ .requiresStep("user")
+ .input(userInitSchema)
+ .mutation(async ({ ctx, input }) => {
+ throwIfCredentialsDisabled();
+
+ const maxPosition = await getMaxGroupPositionAsync(ctx.db);
+ const userId = await createUserAsync(ctx.db, input);
+ const groupId = createId();
+ await ctx.db.insert(groups).values({
+ id: groupId,
+ name: credentialsAdminGroup,
+ ownerId: userId,
+ position: maxPosition + 1,
+ });
+ await ctx.db.insert(groupPermissions).values({
+ groupId,
+ permission: "admin",
+ });
+ await ctx.db.insert(groupMembers).values({
+ groupId,
+ userId,
+ });
+ await nextOnboardingStepAsync(ctx.db, undefined);
+ }),
+ register: publicProcedure
+ .input(userRegistrationApiSchema)
+ .output(z.void())
+ .mutation(async ({ ctx, input }) => {
+ throwIfCredentialsDisabled();
+ const inviteWhere = and(eq(invites.id, input.inviteId), eq(invites.token, input.token));
+ const dbInvite = await ctx.db.query.invites.findFirst({
+ columns: {
+ id: true,
+ expirationDate: true,
+ },
+ where: inviteWhere,
+ });
+
+ if (!dbInvite || dbInvite.expirationDate < new Date()) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Invalid invite",
+ });
+ }
+
+ await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
+
+ await createUserAsync(ctx.db, input);
+
+ // Delete invite as it's used
+ await ctx.db.delete(invites).where(inviteWhere);
+ }),
+ create: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"], protect: true } })
+ .input(userCreateSchema)
+ .output(z.void())
+ .mutation(async ({ ctx, input }) => {
+ throwIfCredentialsDisabled();
+ await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
+
+ const userId = await createUserAsync(ctx.db, input);
+
+ if (input.groupIds.length >= 1) {
+ await ctx.db.insert(groupMembers).values(input.groupIds.map((groupId) => ({ groupId, userId })));
+ }
+ }),
+ setProfileImage: protectedProcedure
+ .output(z.void())
+ .meta({ openapi: { method: "PUT", path: "/api/users/profileImage", tags: ["users"], protect: true } })
+ .input(
+ z.object({
+ userId: z.string(),
+ image: z
+ .string()
+ .regex(/^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9/+]+=*$/g)
+ .max(350000) // approximately 256KB in base64 (256 * 1024 * 4 / 3 + prefixes)
+ .nullable(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ // Only admins can change other users profile images
+ if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "You are not allowed to change other users profile images",
+ });
+ }
+
+ const user = await ctx.db.query.users.findFirst({
+ columns: {
+ id: true,
+ image: true,
+ provider: true,
+ },
+ where: eq(users.id, input.userId),
+ });
+
+ if (!user) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ await ctx.db
+ .update(users)
+ .set({
+ image: input.image,
+ })
+ .where(eq(users.id, input.userId));
+ }),
+ getAll: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(z.void())
+ .output(z.array(selectUserSchema.pick({ id: true, name: true, email: true, emailVerified: true, image: true })))
+ .meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"], protect: true } })
+ .query(({ ctx }) => {
+ return ctx.db.query.users.findMany({
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ emailVerified: true,
+ image: true,
+ },
+ });
+ }),
+ // Is protected because also used in board access / integration access forms
+ selectable: protectedProcedure
+ .input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional())
+ .output(z.array(selectUserSchema.pick({ id: true, name: true, image: true, email: true })))
+ .meta({ openapi: { method: "GET", path: "/api/users/selectable", tags: ["users"], protect: true } })
+ .query(({ ctx, input }) => {
+ return ctx.db.query.users.findMany({
+ columns: {
+ id: true,
+ name: true,
+ image: true,
+ email: true,
+ },
+ where: input?.excludeExternalProviders ? eq(users.provider, "credentials") : undefined,
+ });
+ }),
+ search: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(
+ z.object({
+ query: z.string(),
+ limit: z.number().min(1).max(100).default(10),
+ }),
+ )
+ .output(z.array(selectUserSchema.pick({ id: true, name: true, image: true, email: true })))
+ .meta({ openapi: { method: "POST", path: "/api/users/search", tags: ["users"], protect: true } })
+ .query(async ({ input, ctx }) => {
+ const dbUsers = await ctx.db.query.users.findMany({
+ columns: {
+ id: true,
+ name: true,
+ image: true,
+ email: true,
+ },
+ where: like(users.name, `%${input.query}%`),
+ limit: input.limit,
+ });
+ return dbUsers.map((user) => ({
+ id: user.id,
+ name: user.name ?? "",
+ image: user.image,
+ email: user.email,
+ }));
+ }),
+ getById: protectedProcedure
+ .input(z.object({ userId: z.string() }))
+ .output(
+ selectUserSchema.pick({
+ id: true,
+ name: true,
+ email: true,
+ emailVerified: true,
+ image: true,
+ provider: true,
+ homeBoardId: true,
+ mobileHomeBoardId: true,
+ firstDayOfWeek: true,
+ pingIconsEnabled: true,
+ defaultSearchEngineId: true,
+ openSearchInNewTab: true,
+ }),
+ )
+ .meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
+ .query(async ({ input, ctx }) => {
+ // Only admins can view other users details
+ if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "You are not allowed to view other users details",
+ });
+ }
+ const user = await ctx.db.query.users.findFirst({
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ emailVerified: true,
+ image: true,
+ provider: true,
+ homeBoardId: true,
+ mobileHomeBoardId: true,
+ firstDayOfWeek: true,
+ pingIconsEnabled: true,
+ defaultSearchEngineId: true,
+ openSearchInNewTab: true,
+ },
+ where: eq(users.id, input.userId),
+ });
+
+ if (!user) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ return user;
+ }),
+ editProfile: protectedProcedure
+ .input(userEditProfileSchema)
+ .output(z.void())
+ .meta({ openapi: { method: "PUT", path: "/api/users/profile", tags: ["users"], protect: true } })
+ .mutation(async ({ input, ctx }) => {
+ // Only admins can view other users details
+ if (ctx.session.user.id !== input.id && !ctx.session.user.permissions.includes("admin")) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "You are not allowed to edit other users details",
+ });
+ }
+
+ const user = await ctx.db.query.users.findFirst({
+ columns: { email: true, provider: true },
+ where: eq(users.id, input.id),
+ });
+
+ if (!user) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ if (user.provider !== "credentials") {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Username and email can not be changed for users with external providers",
+ });
+ }
+
+ await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.name, input.id);
+
+ const emailDirty = input.email && user.email !== input.email;
+ await ctx.db
+ .update(users)
+ .set({
+ name: input.name,
+ email: emailDirty === true ? input.email : undefined,
+ emailVerified: emailDirty === true ? null : undefined,
+ })
+ .where(eq(users.id, input.id));
+ }),
+ delete: protectedProcedure
+ .input(z.object({ userId: z.string() }))
+ .output(z.void())
+ .meta({ openapi: { method: "DELETE", path: "/api/users/{userId}", tags: ["users"], protect: true } })
+ .mutation(async ({ input, ctx }) => {
+ // Only admins and user itself can delete a user
+ if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "You are not allowed to delete other users",
+ });
+ }
+
+ await ctx.db.delete(users).where(eq(users.id, input.userId));
+ }),
+ changePassword: protectedProcedure
+ .input(userChangePasswordApiSchema)
+ .output(z.void())
+ .meta({ openapi: { method: "PATCH", path: "/api/users/{userId}/changePassword", tags: ["users"], protect: true } })
+ .mutation(async ({ ctx, input }) => {
+ const user = ctx.session.user;
+ // Only admins can change other users' passwords
+ if (!user.permissions.includes("admin") && user.id !== input.userId) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ const dbUser = await ctx.db.query.users.findFirst({
+ columns: {
+ id: true,
+ password: true,
+ salt: true,
+ provider: true,
+ },
+ where: eq(users.id, input.userId),
+ });
+
+ if (!dbUser) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ if (dbUser.provider !== "credentials") {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Password can not be changed for users with external providers",
+ });
+ }
+
+ // Admins can change the password of other users without providing the previous password
+ const isPreviousPasswordRequired = ctx.session.user.id === input.userId;
+
+ logger.info("Changing user password", {
+ actorId: ctx.session.user.id,
+ targetUserId: input.userId,
+ previousPasswordRequired: isPreviousPasswordRequired,
+ });
+
+ if (isPreviousPasswordRequired) {
+ const previousPasswordHash = await hashPasswordAsync(input.previousPassword, dbUser.salt ?? "");
+ const isValid = previousPasswordHash === dbUser.password;
+
+ if (!isValid) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Invalid password",
+ });
+ }
+ }
+
+ const salt = await createSaltAsync();
+ const hashedPassword = await hashPasswordAsync(input.password, salt);
+ await ctx.db
+ .update(users)
+ .set({
+ password: hashedPassword,
+ })
+ .where(eq(users.id, input.userId));
+ }),
+ changeHomeBoards: protectedProcedure
+ .input(convertIntersectionToZodObject(userChangeHomeBoardsSchema.and(z.object({ userId: z.string() }))))
+ .output(z.void())
+ .meta({ openapi: { method: "PATCH", path: "/api/users/changeHome", tags: ["users"], protect: true } })
+ .mutation(async ({ input, ctx }) => {
+ const user = ctx.session.user;
+ // Only admins can change other users passwords
+ if (!user.permissions.includes("admin") && user.id !== input.userId) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ const dbUser = await ctx.db.query.users.findFirst({
+ columns: {
+ id: true,
+ },
+ where: eq(users.id, input.userId),
+ });
+
+ if (!dbUser) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ // Only allow user to select boards they have access to
+ if (input.homeBoardId) {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.homeBoardId), "view");
+ }
+ if (input.mobileHomeBoardId) {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.mobileHomeBoardId), "view");
+ }
+
+ await ctx.db
+ .update(users)
+ .set({
+ homeBoardId: input.homeBoardId,
+ mobileHomeBoardId: input.mobileHomeBoardId,
+ })
+ .where(eq(users.id, input.userId));
+ }),
+ changeDefaultSearchEngine: protectedProcedure
+ .input(
+ convertIntersectionToZodObject(
+ userChangeSearchPreferencesSchema.omit({ openInNewTab: true }).and(z.object({ userId: z.string() })),
+ ),
+ )
+ .output(z.void())
+ .meta({
+ openapi: {
+ method: "PATCH",
+ path: "/api/users/changeSearchEngine",
+ tags: ["users"],
+ protect: true,
+ deprecated: true,
+ },
+ })
+ .mutation(async ({ input, ctx }) => {
+ await changeSearchPreferencesAsync(ctx.db, ctx.session, {
+ ...input,
+ openInNewTab: undefined,
+ });
+ }),
+ changeSearchPreferences: protectedProcedure
+ .input(convertIntersectionToZodObject(changeSearchPreferencesInputSchema))
+ .output(z.void())
+ .meta({ openapi: { method: "PATCH", path: "/api/users/search-preferences", tags: ["users"], protect: true } })
+ .mutation(async ({ input, ctx }) => {
+ await changeSearchPreferencesAsync(ctx.db, ctx.session, input);
+ }),
+ changeColorScheme: protectedProcedure
+ .input(userChangeColorSchemeSchema)
+ .output(z.void())
+ .meta({ openapi: { method: "PATCH", path: "/api/users/changeScheme", tags: ["users"], protect: true } })
+ .mutation(async ({ input, ctx }) => {
+ await ctx.db
+ .update(users)
+ .set({
+ colorScheme: input.colorScheme,
+ })
+ .where(eq(users.id, ctx.session.user.id));
+ }),
+ changePingIconsEnabled: protectedProcedure
+ .input(userPingIconsEnabledSchema.and(byIdSchema))
+ .mutation(async ({ input, ctx }) => {
+ // Only admins can change other users ping icons enabled
+ if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ await ctx.db
+ .update(users)
+ .set({
+ pingIconsEnabled: input.pingIconsEnabled,
+ })
+ .where(eq(users.id, ctx.session.user.id));
+ }),
+ changeFirstDayOfWeek: protectedProcedure
+ .input(convertIntersectionToZodObject(userFirstDayOfWeekSchema.and(byIdSchema)))
+ .output(z.void())
+ .meta({ openapi: { method: "PATCH", path: "/api/users/firstDayOfWeek", tags: ["users"], protect: true } })
+ .mutation(async ({ input, ctx }) => {
+ // Only admins can change other users first day of week
+ if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ const dbUser = await ctx.db.query.users.findFirst({
+ columns: {
+ id: true,
+ },
+ where: eq(users.id, input.id),
+ });
+
+ if (!dbUser) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ await ctx.db
+ .update(users)
+ .set({
+ firstDayOfWeek: input.firstDayOfWeek,
+ })
+ .where(eq(users.id, ctx.session.user.id));
+ }),
+});
+
+const createUserAsync = async (db: Database, input: Omit, "groupIds">) => {
+ const salt = await createSaltAsync();
+ const hashedPassword = await hashPasswordAsync(input.password, salt);
+
+ const userId = createId();
+ await db.insert(users).values({
+ id: userId,
+ name: input.username,
+ email: input.email,
+ password: hashedPassword,
+ salt,
+ });
+ return userId;
+};
+
+const checkUsernameAlreadyTakenAndThrowAsync = async (
+ db: Database,
+ provider: SupportedAuthProvider,
+ username: string,
+ ignoreId?: string,
+) => {
+ const user = await db.query.users.findFirst({
+ where: and(eq(users.name, username), eq(users.provider, provider)),
+ });
+
+ if (!user) return;
+ if (ignoreId && user.id === ignoreId) return;
+
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "Username already taken",
+ });
+};
diff --git a/packages/api/src/router/user/change-search-preferences.ts b/packages/api/src/router/user/change-search-preferences.ts
new file mode 100644
index 000000000..7f2793b9e
--- /dev/null
+++ b/packages/api/src/router/user/change-search-preferences.ts
@@ -0,0 +1,50 @@
+import { TRPCError } from "@trpc/server";
+import { z } from "zod/v4";
+
+import type { Session } from "@homarr/auth";
+import type { Modify } from "@homarr/common/types";
+import { eq } from "@homarr/db";
+import type { Database } from "@homarr/db";
+import { users } from "@homarr/db/schema";
+import { userChangeSearchPreferencesSchema } from "@homarr/validation/user";
+
+export const changeSearchPreferencesInputSchema = userChangeSearchPreferencesSchema.and(
+ z.object({ userId: z.string() }),
+);
+
+export const changeSearchPreferencesAsync = async (
+ db: Database,
+ session: Session,
+ input: Modify, { openInNewTab: boolean | undefined }>,
+) => {
+ const user = session.user;
+ // Only admins can change other users passwords
+ if (!user.permissions.includes("admin") && user.id !== input.userId) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ const dbUser = await db.query.users.findFirst({
+ columns: {
+ id: true,
+ },
+ where: eq(users.id, input.userId),
+ });
+
+ if (!dbUser) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ await db
+ .update(users)
+ .set({
+ defaultSearchEngineId: input.defaultSearchEngineId,
+ openSearchInNewTab: input.openInNewTab,
+ })
+ .where(eq(users.id, input.userId));
+};
diff --git a/packages/api/src/router/widgets/app.ts b/packages/api/src/router/widgets/app.ts
new file mode 100644
index 000000000..7a65225a6
--- /dev/null
+++ b/packages/api/src/router/widgets/app.ts
@@ -0,0 +1,45 @@
+import { observable } from "@trpc/server/observable";
+import { z } from "zod/v4";
+
+import { sendPingRequestAsync } from "@homarr/ping";
+import { pingChannel, pingUrlChannel } from "@homarr/redis";
+
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+export const appRouter = createTRPCRouter({
+ ping: publicProcedure.input(z.object({ url: z.string() })).query(async ({ input }) => {
+ const pingResult = await sendPingRequestAsync(input.url);
+
+ return {
+ url: input.url,
+ ...pingResult,
+ };
+ }),
+ updatedPing: publicProcedure
+ .input(
+ z.object({
+ url: z.string(),
+ }),
+ )
+ .subscription(async ({ input }) => {
+ await pingUrlChannel.addAsync(input.url);
+
+ const pingResult = await sendPingRequestAsync(input.url);
+
+ return observable<{ url: string; statusCode: number; durationMs: number } | { url: string; error: string }>(
+ (emit) => {
+ emit.next({ url: input.url, ...pingResult });
+ const unsubscribe = pingChannel.subscribe((message) => {
+ // Only emit if same url
+ if (message.url !== input.url) return;
+ emit.next(message);
+ });
+
+ return () => {
+ unsubscribe();
+ void pingUrlChannel.removeAsync(input.url);
+ };
+ },
+ );
+ }),
+});
diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts
new file mode 100644
index 000000000..5c8322299
--- /dev/null
+++ b/packages/api/src/router/widgets/calendar.ts
@@ -0,0 +1,79 @@
+import { observable } from "@trpc/server/observable";
+import { z } from "zod/v4";
+
+import type { Modify } from "@homarr/common/types";
+import type { Integration } from "@homarr/db/schema";
+import type { IntegrationKindByCategory } from "@homarr/definitions";
+import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import type { CalendarEvent } from "@homarr/integrations/types";
+import { radarrReleaseTypes } from "@homarr/integrations/types";
+import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
+
+import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+export const calendarRouter = createTRPCRouter({
+ findAllEvents: publicProcedure
+ .input(
+ z.object({
+ year: z.number(),
+ month: z.number(),
+ releaseType: z.array(z.enum(radarrReleaseTypes)),
+ showUnmonitored: z.boolean(),
+ }),
+ )
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
+ .query(async ({ ctx, input }) => {
+ return await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const { integrationIds: _integrationIds, ...handlerInput } = input;
+ const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
+ const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+
+ return {
+ events: data,
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ },
+ };
+ }),
+ );
+ }),
+ subscribeToEvents: publicProcedure
+ .input(
+ z.object({
+ year: z.number(),
+ month: z.number(),
+ releaseType: z.array(z.enum(radarrReleaseTypes)),
+ showUnmonitored: z.boolean(),
+ }),
+ )
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
+ .subscription(({ ctx, input }) => {
+ return observable<{
+ integration: Modify }>;
+ events: CalendarEvent[];
+ }>((emit) => {
+ const unsubscribes: (() => void)[] = [];
+ for (const integrationWithSecrets of ctx.integrations) {
+ const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
+ const { integrationIds: _integrationIds, ...handlerInput } = input;
+ const innerHandler = calendarMonthRequestHandler.handler(integrationWithSecrets, handlerInput);
+ const unsubscribe = innerHandler.subscribe((events) => {
+ emit.next({
+ integration,
+ events,
+ });
+ });
+ unsubscribes.push(unsubscribe);
+ }
+ return () => {
+ unsubscribes.forEach((unsubscribe) => {
+ unsubscribe();
+ });
+ };
+ });
+ }),
+});
diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts
new file mode 100644
index 000000000..46b868435
--- /dev/null
+++ b/packages/api/src/router/widgets/dns-hole.ts
@@ -0,0 +1,95 @@
+import { observable } from "@trpc/server/observable";
+import { z } from "zod/v4";
+
+import type { Modify } from "@homarr/common/types";
+import type { Integration } from "@homarr/db/schema";
+import type { IntegrationKindByCategory } from "@homarr/definitions";
+import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import { createIntegrationAsync } from "@homarr/integrations";
+import type { DnsHoleSummary } from "@homarr/integrations/types";
+import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
+
+import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
+
+export const dnsHoleRouter = createTRPCRouter({
+ summary: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
+ .query(async ({ ctx }) => {
+ const results = await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = dnsHoleRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: timestamp,
+ },
+ summary: data,
+ };
+ }),
+ );
+ return results;
+ }),
+
+ subscribeToSummary: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
+ .subscription(({ ctx }) => {
+ return observable<{
+ integration: Modify }>;
+ summary: DnsHoleSummary;
+ }>((emit) => {
+ const unsubscribes: (() => void)[] = [];
+ for (const integrationWithSecrets of ctx.integrations) {
+ const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
+ const innerHandler = dnsHoleRequestHandler.handler(integrationWithSecrets, {});
+ const unsubscribe = innerHandler.subscribe((summary) => {
+ emit.next({
+ integration,
+ summary,
+ });
+ });
+ unsubscribes.push(unsubscribe);
+ }
+ return () => {
+ unsubscribes.forEach((unsubscribe) => {
+ unsubscribe();
+ });
+ };
+ });
+ }),
+
+ enable: protectedProcedure
+ .concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
+ .mutation(async ({ ctx: { integration } }) => {
+ const client = await createIntegrationAsync(integration);
+ await client.enableAsync();
+
+ const innerHandler = dnsHoleRequestHandler.handler(integration, {});
+ // We need to wait for the integration to be enabled before invalidating the cache
+ await new Promise((resolve) => {
+ setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000);
+ });
+ }),
+
+ disable: protectedProcedure
+ .input(
+ z.object({
+ duration: z.number().optional(),
+ }),
+ )
+ .concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
+ .mutation(async ({ ctx: { integration }, input }) => {
+ const client = await createIntegrationAsync(integration);
+ await client.disableAsync(input.duration);
+
+ const innerHandler = dnsHoleRequestHandler.handler(integration, {});
+ // We need to wait for the integration to be disabled before invalidating the cache
+ await new Promise((resolve) => {
+ setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000);
+ });
+ }),
+});
diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts
new file mode 100644
index 000000000..f5ed6fdb9
--- /dev/null
+++ b/packages/api/src/router/widgets/downloads.ts
@@ -0,0 +1,120 @@
+import { observable } from "@trpc/server/observable";
+import { z } from "zod/v4";
+
+import type { Modify } from "@homarr/common/types";
+import type { Integration } from "@homarr/db/schema";
+import type { IntegrationKindByCategory } from "@homarr/definitions";
+import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
+import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
+import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
+
+import type { IntegrationAction } from "../../middlewares/integration";
+import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
+
+const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
+ createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("downloadClient"));
+
+export const downloadsRouter = createTRPCRouter({
+ getJobsAndStatuses: publicProcedure
+ .concat(createDownloadClientIntegrationMiddleware("query"))
+ .input(z.object({ limitPerIntegration: z.number().default(50) }))
+ .query(async ({ ctx, input }) => {
+ return await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
+
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: timestamp,
+ },
+ data,
+ };
+ }),
+ );
+ }),
+ subscribeToJobsAndStatuses: publicProcedure
+ .concat(createDownloadClientIntegrationMiddleware("query"))
+ .input(z.object({ limitPerIntegration: z.number().default(50) }))
+ .subscription(({ ctx, input }) => {
+ return observable<{
+ integration: Modify }>;
+ data: DownloadClientJobsAndStatus;
+ }>((emit) => {
+ const unsubscribes: (() => void)[] = [];
+ for (const integrationWithSecrets of ctx.integrations) {
+ const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
+ const innerHandler = downloadClientRequestHandler.handler(integrationWithSecrets, {
+ limit: input.limitPerIntegration,
+ });
+ const unsubscribe = innerHandler.subscribe((data) => {
+ emit.next({
+ integration,
+ data,
+ });
+ });
+ unsubscribes.push(unsubscribe);
+ }
+ return () => {
+ unsubscribes.forEach((unsubscribe) => {
+ unsubscribe();
+ });
+ };
+ });
+ }),
+ pause: protectedProcedure.concat(createDownloadClientIntegrationMiddleware("interact")).mutation(async ({ ctx }) => {
+ await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const integrationInstance = await createIntegrationAsync(integration);
+ await integrationInstance.pauseQueueAsync();
+ }),
+ );
+ }),
+ pauseItem: protectedProcedure
+ .concat(createDownloadClientIntegrationMiddleware("interact"))
+ .input(z.object({ item: downloadClientItemSchema }))
+ .mutation(async ({ ctx, input }) => {
+ await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const integrationInstance = await createIntegrationAsync(integration);
+ await integrationInstance.pauseItemAsync(input.item);
+ }),
+ );
+ }),
+ resume: protectedProcedure.concat(createDownloadClientIntegrationMiddleware("interact")).mutation(async ({ ctx }) => {
+ await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const integrationInstance = await createIntegrationAsync(integration);
+ await integrationInstance.resumeQueueAsync();
+ }),
+ );
+ }),
+ resumeItem: protectedProcedure
+ .concat(createDownloadClientIntegrationMiddleware("interact"))
+ .input(z.object({ item: downloadClientItemSchema }))
+ .mutation(async ({ ctx, input }) => {
+ await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const integrationInstance = await createIntegrationAsync(integration);
+ await integrationInstance.resumeItemAsync(input.item);
+ }),
+ );
+ }),
+ deleteItem: protectedProcedure
+ .concat(createDownloadClientIntegrationMiddleware("interact"))
+ .input(z.object({ item: downloadClientItemSchema, fromDisk: z.boolean() }))
+ .mutation(async ({ ctx, input }) => {
+ await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const integrationInstance = await createIntegrationAsync(integration);
+ await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
+ }),
+ );
+ }),
+});
diff --git a/packages/api/src/router/widgets/firewall.ts b/packages/api/src/router/widgets/firewall.ts
new file mode 100644
index 000000000..2d582cb1f
--- /dev/null
+++ b/packages/api/src/router/widgets/firewall.ts
@@ -0,0 +1,215 @@
+import { observable } from "@trpc/server/observable";
+
+import type { Modify } from "@homarr/common/types";
+import type { Integration } from "@homarr/db/schema";
+import type { IntegrationKindByCategory } from "@homarr/definitions";
+import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import type {
+ FirewallCpuSummary,
+ FirewallInterfacesSummary,
+ FirewallMemorySummary,
+ FirewallVersionSummary,
+} from "@homarr/integrations";
+import {
+ firewallCpuRequestHandler,
+ firewallInterfacesRequestHandler,
+ firewallMemoryRequestHandler,
+ firewallVersionRequestHandler,
+} from "@homarr/request-handler/firewall";
+
+import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+export const firewallRouter = createTRPCRouter({
+ getFirewallCpuStatus: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
+ .query(async ({ ctx }) => {
+ const results = await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = firewallCpuRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: timestamp,
+ },
+ summary: data,
+ };
+ }),
+ );
+ return results;
+ }),
+ subscribeFirewallCpuStatus: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
+ .subscription(({ ctx }) => {
+ return observable<{
+ integration: Modify }>;
+ summary: FirewallCpuSummary;
+ }>((emit) => {
+ const unsubscribes: (() => void)[] = [];
+ for (const integrationWithSecrets of ctx.integrations) {
+ const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
+ const innerHandler = firewallCpuRequestHandler.handler(integrationWithSecrets, {});
+ const unsubscribe = innerHandler.subscribe((summary) => {
+ emit.next({
+ integration,
+ summary,
+ });
+ });
+ unsubscribes.push(unsubscribe);
+ }
+ return () => {
+ unsubscribes.forEach((unsubscribe) => {
+ unsubscribe();
+ });
+ };
+ });
+ }),
+
+ getFirewallInterfacesStatus: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
+ .query(async ({ ctx }) => {
+ const results = await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = firewallInterfacesRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: timestamp,
+ },
+ summary: data,
+ };
+ }),
+ );
+ return results;
+ }),
+ subscribeFirewallInterfacesStatus: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
+ .subscription(({ ctx }) => {
+ return observable<{
+ integration: Modify }>;
+ summary: FirewallInterfacesSummary[];
+ }>((emit) => {
+ const unsubscribes: (() => void)[] = [];
+ for (const integrationWithSecrets of ctx.integrations) {
+ const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
+ const innerHandler = firewallInterfacesRequestHandler.handler(integrationWithSecrets, {});
+ const unsubscribe = innerHandler.subscribe((summary) => {
+ emit.next({
+ integration,
+ summary,
+ });
+ });
+ unsubscribes.push(unsubscribe);
+ }
+ return () => {
+ unsubscribes.forEach((unsubscribe) => {
+ unsubscribe();
+ });
+ };
+ });
+ }),
+
+ getFirewallVersionStatus: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
+ .query(async ({ ctx }) => {
+ const results = await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = firewallVersionRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: timestamp,
+ },
+ summary: data,
+ };
+ }),
+ );
+ return results;
+ }),
+ subscribeFirewallVersionStatus: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
+ .subscription(({ ctx }) => {
+ return observable<{
+ integration: Modify }>;
+ summary: FirewallVersionSummary;
+ }>((emit) => {
+ const unsubscribes: (() => void)[] = [];
+ for (const integrationWithSecrets of ctx.integrations) {
+ const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
+ const innerHandler = firewallVersionRequestHandler.handler(integrationWithSecrets, {});
+ const unsubscribe = innerHandler.subscribe((summary) => {
+ emit.next({
+ integration,
+ summary,
+ });
+ });
+ unsubscribes.push(unsubscribe);
+ }
+ return () => {
+ unsubscribes.forEach((unsubscribe) => {
+ unsubscribe();
+ });
+ };
+ });
+ }),
+
+ getFirewallMemoryStatus: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
+ .query(async ({ ctx }) => {
+ const results = await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = firewallMemoryRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: timestamp,
+ },
+ summary: data,
+ };
+ }),
+ );
+ return results;
+ }),
+ subscribeFirewallMemoryStatus: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
+ .subscription(({ ctx }) => {
+ return observable<{
+ integration: Modify }>;
+ summary: FirewallMemorySummary;
+ }>((emit) => {
+ const unsubscribes: (() => void)[] = [];
+ for (const integrationWithSecrets of ctx.integrations) {
+ const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
+ const innerHandler = firewallMemoryRequestHandler.handler(integrationWithSecrets, {});
+ const unsubscribe = innerHandler.subscribe((summary) => {
+ emit.next({
+ integration,
+ summary,
+ });
+ });
+ unsubscribes.push(unsubscribe);
+ }
+ return () => {
+ unsubscribes.forEach((unsubscribe) => {
+ unsubscribe();
+ });
+ };
+ });
+ }),
+});
diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts
new file mode 100644
index 000000000..14ab160f7
--- /dev/null
+++ b/packages/api/src/router/widgets/health-monitoring.ts
@@ -0,0 +1,73 @@
+import { observable } from "@trpc/server/observable";
+
+import type { SystemHealthMonitoring } from "@homarr/integrations";
+import type { ProxmoxClusterInfo } from "@homarr/integrations/types";
+import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
+
+import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+export const healthMonitoringRouter = createTRPCRouter({
+ getSystemHealthStatus: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "mock"))
+ .query(async ({ ctx }) => {
+ return await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = systemInfoRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ healthInfo: data,
+ updatedAt: timestamp,
+ };
+ }),
+ );
+ }),
+ subscribeSystemHealthStatus: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "mock"))
+ .subscription(({ ctx }) => {
+ return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => {
+ const unsubscribes: (() => void)[] = [];
+ for (const integration of ctx.integrations) {
+ const innerHandler = systemInfoRequestHandler.handler(integration, {});
+ const unsubscribe = innerHandler.subscribe((healthInfo) => {
+ emit.next({
+ integrationId: integration.id,
+ healthInfo,
+ timestamp: new Date(),
+ });
+ });
+ unsubscribes.push(unsubscribe);
+ }
+ return () => {
+ unsubscribes.forEach((unsubscribe) => {
+ unsubscribe();
+ });
+ };
+ });
+ }),
+ getClusterHealthStatus: publicProcedure
+ .concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
+ .query(async ({ ctx }) => {
+ const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
+ const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return data;
+ }),
+ subscribeClusterHealthStatus: publicProcedure
+ .concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
+ .subscription(({ ctx }) => {
+ return observable((emit) => {
+ const unsubscribes: (() => void)[] = [];
+ const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
+ const unsubscribe = innerHandler.subscribe((healthInfo) => {
+ emit.next(healthInfo);
+ });
+ unsubscribes.push(unsubscribe);
+ return () => {
+ unsubscribe();
+ };
+ });
+ }),
+});
diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts
new file mode 100644
index 000000000..8175aaab3
--- /dev/null
+++ b/packages/api/src/router/widgets/index.ts
@@ -0,0 +1,46 @@
+import { createTRPCRouter } from "../../trpc";
+import { appRouter } from "./app";
+import { calendarRouter } from "./calendar";
+import { dnsHoleRouter } from "./dns-hole";
+import { downloadsRouter } from "./downloads";
+import { firewallRouter } from "./firewall";
+import { healthMonitoringRouter } from "./health-monitoring";
+import { indexerManagerRouter } from "./indexer-manager";
+import { mediaReleaseRouter } from "./media-release";
+import { mediaRequestsRouter } from "./media-requests";
+import { mediaServerRouter } from "./media-server";
+import { mediaTranscodingRouter } from "./media-transcoding";
+import { minecraftRouter } from "./minecraft";
+import { networkControllerRouter } from "./network-controller";
+import { notebookRouter } from "./notebook";
+import { notificationsRouter } from "./notifications";
+import { optionsRouter } from "./options";
+import { releasesRouter } from "./releases";
+import { rssFeedRouter } from "./rssFeed";
+import { smartHomeRouter } from "./smart-home";
+import { stockPriceRouter } from "./stocks";
+import { weatherRouter } from "./weather";
+
+export const widgetRouter = createTRPCRouter({
+ notebook: notebookRouter,
+ weather: weatherRouter,
+ app: appRouter,
+ dnsHole: dnsHoleRouter,
+ smartHome: smartHomeRouter,
+ stockPrice: stockPriceRouter,
+ mediaServer: mediaServerRouter,
+ mediaRelease: mediaReleaseRouter,
+ calendar: calendarRouter,
+ downloads: downloadsRouter,
+ mediaRequests: mediaRequestsRouter,
+ rssFeed: rssFeedRouter,
+ indexerManager: indexerManagerRouter,
+ healthMonitoring: healthMonitoringRouter,
+ mediaTranscoding: mediaTranscodingRouter,
+ minecraft: minecraftRouter,
+ options: optionsRouter,
+ releases: releasesRouter,
+ networkController: networkControllerRouter,
+ firewall: firewallRouter,
+ notifications: notificationsRouter,
+});
diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts
new file mode 100644
index 000000000..cb8b88262
--- /dev/null
+++ b/packages/api/src/router/widgets/indexer-manager.ts
@@ -0,0 +1,72 @@
+import { TRPCError } from "@trpc/server";
+import { observable } from "@trpc/server/observable";
+
+import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import { createIntegrationAsync } from "@homarr/integrations";
+import type { Indexer } from "@homarr/integrations/types";
+import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
+
+import type { IntegrationAction } from "../../middlewares/integration";
+import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
+
+const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
+ createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("indexerManager"));
+
+export const indexerManagerRouter = createTRPCRouter({
+ getIndexersStatus: publicProcedure
+ .concat(createIndexerManagerIntegrationMiddleware("query"))
+ .query(async ({ ctx }) => {
+ const results = await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = indexerManagerRequestHandler.handler(integration, {});
+ const { data: indexers } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+
+ return {
+ integrationId: integration.id,
+ indexers,
+ };
+ }),
+ );
+ return results;
+ }),
+
+ subscribeIndexersStatus: publicProcedure
+ .concat(createIndexerManagerIntegrationMiddleware("query"))
+ .subscription(({ ctx }) => {
+ return observable<{ integrationId: string; indexers: Indexer[] }>((emit) => {
+ const unsubscribes: (() => void)[] = [];
+ for (const integrationWithSecrets of ctx.integrations) {
+ const innerHandler = indexerManagerRequestHandler.handler(integrationWithSecrets, {});
+ const unsubscribe = innerHandler.subscribe((indexers) => {
+ emit.next({
+ integrationId: integrationWithSecrets.id,
+ indexers,
+ });
+ });
+ unsubscribes.push(unsubscribe);
+ }
+ return () => {
+ unsubscribes.forEach((unsubscribe) => {
+ unsubscribe();
+ });
+ };
+ });
+ }),
+ testAllIndexers: protectedProcedure
+ .concat(createIndexerManagerIntegrationMiddleware("interact"))
+ .mutation(async ({ ctx }) => {
+ await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const client = await createIntegrationAsync(integration);
+ await client.testAllAsync().catch((err) => {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Failed to test all indexers for ${integration.name} (${integration.id})`,
+ cause: err,
+ });
+ });
+ }),
+ );
+ }),
+});
diff --git a/packages/api/src/router/widgets/media-release.ts b/packages/api/src/router/widgets/media-release.ts
new file mode 100644
index 000000000..fddc174cc
--- /dev/null
+++ b/packages/api/src/router/widgets/media-release.ts
@@ -0,0 +1,67 @@
+import { observable } from "@trpc/server/observable";
+
+import type { Modify } from "@homarr/common/types";
+import type { Integration } from "@homarr/db/schema";
+import type { IntegrationKindByCategory } from "@homarr/definitions";
+import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import type { MediaRelease } from "@homarr/integrations/types";
+import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
+
+import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+export const mediaReleaseRouter = createTRPCRouter({
+ getMediaReleases: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
+ .query(async ({ ctx }) => {
+ const results = await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: timestamp,
+ },
+ releases: data,
+ };
+ }),
+ );
+ return results.flatMap((result) =>
+ result.releases.map((release) => ({
+ ...release,
+ integration: result.integration,
+ })),
+ );
+ }),
+
+ subscribeToReleases: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
+ .subscription(({ ctx }) => {
+ return observable<{
+ integration: Modify }>;
+ releases: MediaRelease[];
+ }>((emit) => {
+ const unsubscribes: (() => void)[] = [];
+ for (const integrationWithSecrets of ctx.integrations) {
+ const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
+ const innerHandler = mediaReleaseRequestHandler.handler(integrationWithSecrets, {});
+ const unsubscribe = innerHandler.subscribe((releases) => {
+ emit.next({
+ integration,
+ releases,
+ });
+ });
+ unsubscribes.push(unsubscribe);
+ }
+ return () => {
+ unsubscribes.forEach((unsubscribe) => {
+ unsubscribe();
+ });
+ };
+ });
+ }),
+});
diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts
new file mode 100644
index 000000000..a77c89b10
--- /dev/null
+++ b/packages/api/src/router/widgets/media-requests.ts
@@ -0,0 +1,112 @@
+import { observable } from "@trpc/server/observable";
+import { z } from "zod/v4";
+
+import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import { createIntegrationAsync } from "@homarr/integrations";
+import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
+import type { MediaRequest } from "@homarr/integrations/types";
+import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
+import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
+
+import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
+
+export const mediaRequestsRouter = createTRPCRouter({
+ getLatestRequests: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
+ .query(async ({ ctx }) => {
+ const results = await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
+ const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ },
+ data,
+ };
+ }),
+ );
+ return results
+ .flatMap(({ data, integration }) => data.map((request) => ({ ...request, integrationId: integration.id })))
+ .sort((dataA, dataB) => {
+ if (dataA.status === dataB.status) {
+ return dataB.createdAt.getTime() - dataA.createdAt.getTime();
+ }
+
+ return (
+ mediaRequestStatusConfiguration[dataA.status].position -
+ mediaRequestStatusConfiguration[dataB.status].position
+ );
+ });
+ }),
+ subscribeToLatestRequests: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
+ .subscription(({ ctx }) => {
+ return observable<{
+ integrationId: string;
+ requests: MediaRequest[];
+ }>((emit) => {
+ const unsubscribes: (() => void)[] = [];
+ for (const integrationWithSecrets of ctx.integrations) {
+ const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
+ const innerHandler = mediaRequestListRequestHandler.handler(integrationWithSecrets, {});
+ const unsubscribe = innerHandler.subscribe((requests) => {
+ emit.next({
+ integrationId: integration.id,
+ requests,
+ });
+ });
+ unsubscribes.push(unsubscribe);
+ }
+ return () => {
+ unsubscribes.forEach((unsubscribe) => {
+ unsubscribe();
+ });
+ };
+ });
+ }),
+ getStats: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
+ .query(async ({ ctx }) => {
+ const results = await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = mediaRequestStatsRequestHandler.handler(integration, {});
+ const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ },
+ data,
+ };
+ }),
+ );
+ return {
+ stats: results.flatMap((result) => result.data.stats),
+ users: results
+ .map((result) => result.data.users.map((user) => ({ ...user, integration: result.integration })))
+ .flat()
+ .sort(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
+ integrations: results.map((result) => result.integration),
+ };
+ }),
+ answerRequest: protectedProcedure
+ .concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
+ .input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) }))
+ .mutation(async ({ ctx: { integration }, input }) => {
+ const integrationInstance = await createIntegrationAsync(integration);
+ const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
+
+ if (input.answer === "approve") {
+ await integrationInstance.approveRequestAsync(input.requestId);
+ await innerHandler.invalidateAsync();
+ return;
+ }
+ await integrationInstance.declineRequestAsync(input.requestId);
+ await innerHandler.invalidateAsync();
+ }),
+});
diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts
new file mode 100644
index 000000000..0e497c8ff
--- /dev/null
+++ b/packages/api/src/router/widgets/media-server.ts
@@ -0,0 +1,60 @@
+import { observable } from "@trpc/server/observable";
+import { z } from "zod/v4";
+
+import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import type { StreamSession } from "@homarr/integrations";
+import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
+
+import type { IntegrationAction } from "../../middlewares/integration";
+import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
+ createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaService"));
+
+export const mediaServerRouter = createTRPCRouter({
+ getCurrentStreams: publicProcedure
+ .concat(createMediaServerIntegrationMiddleware("query"))
+ .input(z.object({ showOnlyPlaying: z.boolean() }))
+ .query(async ({ ctx, input }) => {
+ return await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = mediaServerRequestHandler.handler(integration, {
+ showOnlyPlaying: input.showOnlyPlaying,
+ });
+ const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return {
+ integrationId: integration.id,
+ integrationKind: integration.kind,
+ sessions: data,
+ };
+ }),
+ );
+ }),
+ subscribeToCurrentStreams: publicProcedure
+ .concat(createMediaServerIntegrationMiddleware("query"))
+ .input(z.object({ showOnlyPlaying: z.boolean() }))
+ .subscription(({ ctx, input }) => {
+ return observable<{ integrationId: string; data: StreamSession[] }>((emit) => {
+ const unsubscribes: (() => void)[] = [];
+ for (const integration of ctx.integrations) {
+ const innerHandler = mediaServerRequestHandler.handler(integration, {
+ showOnlyPlaying: input.showOnlyPlaying,
+ });
+
+ const unsubscribe = innerHandler.subscribe((sessions) => {
+ emit.next({
+ integrationId: integration.id,
+ data: sessions,
+ });
+ });
+ unsubscribes.push(unsubscribe);
+ }
+ return () => {
+ unsubscribes.forEach((unsubscribe) => {
+ unsubscribe();
+ });
+ };
+ });
+ }),
+});
diff --git a/packages/api/src/router/widgets/media-transcoding.ts b/packages/api/src/router/widgets/media-transcoding.ts
new file mode 100644
index 000000000..b02f0a714
--- /dev/null
+++ b/packages/api/src/router/widgets/media-transcoding.ts
@@ -0,0 +1,46 @@
+import { observable } from "@trpc/server/observable";
+
+import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import type { MediaTranscoding } from "@homarr/request-handler/media-transcoding";
+import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
+import { paginatedSchema } from "@homarr/validation/common";
+
+import type { IntegrationAction } from "../../middlewares/integration";
+import { createOneIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
+ createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaTranscoding"));
+
+export const mediaTranscodingRouter = createTRPCRouter({
+ getDataAsync: publicProcedure
+ .concat(createIndexerManagerIntegrationMiddleware("query"))
+ .input(paginatedSchema.pick({ page: true, pageSize: true }))
+ .query(async ({ ctx, input }) => {
+ const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, {
+ pageOffset: (input.page - 1) * input.pageSize,
+ pageSize: input.pageSize,
+ });
+ const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+
+ return {
+ integrationId: ctx.integration.id,
+ data,
+ };
+ }),
+ subscribeData: publicProcedure
+ .concat(createIndexerManagerIntegrationMiddleware("query"))
+ .input(paginatedSchema.pick({ page: true, pageSize: true }))
+ .subscription(({ ctx, input }) => {
+ return observable<{ integrationId: string; data: MediaTranscoding }>((emit) => {
+ const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, {
+ pageOffset: (input.page - 1) * input.pageSize,
+ pageSize: input.pageSize,
+ });
+ const unsubscribe = innerHandler.subscribe((data) => {
+ emit.next({ integrationId: input.integrationId, data });
+ });
+ return unsubscribe;
+ });
+ }),
+});
diff --git a/packages/api/src/router/widgets/minecraft.ts b/packages/api/src/router/widgets/minecraft.ts
new file mode 100644
index 000000000..7f646c3bc
--- /dev/null
+++ b/packages/api/src/router/widgets/minecraft.ts
@@ -0,0 +1,36 @@
+import { observable } from "@trpc/server/observable";
+import { z } from "zod/v4";
+
+import type { MinecraftServerStatus } from "@homarr/request-handler/minecraft-server-status";
+import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status";
+
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+const serverStatusInputSchema = z.object({
+ domain: z.string().nonempty(),
+ isBedrockServer: z.boolean(),
+});
+export const minecraftRouter = createTRPCRouter({
+ getServerStatus: publicProcedure.input(serverStatusInputSchema).query(async ({ input }) => {
+ const innerHandler = minecraftServerStatusRequestHandler.handler({
+ isBedrockServer: input.isBedrockServer,
+ domain: input.domain,
+ });
+ return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
+ }),
+ subscribeServerStatus: publicProcedure.input(serverStatusInputSchema).subscription(({ input }) => {
+ return observable((emit) => {
+ const innerHandler = minecraftServerStatusRequestHandler.handler({
+ isBedrockServer: input.isBedrockServer,
+ domain: input.domain,
+ });
+ const unsubscribe = innerHandler.subscribe((data) => {
+ emit.next(data);
+ });
+
+ return () => {
+ unsubscribe();
+ };
+ });
+ }),
+});
diff --git a/packages/api/src/router/widgets/network-controller.ts b/packages/api/src/router/widgets/network-controller.ts
new file mode 100644
index 000000000..053bd0dcb
--- /dev/null
+++ b/packages/api/src/router/widgets/network-controller.ts
@@ -0,0 +1,62 @@
+import { observable } from "@trpc/server/observable";
+
+import type { Modify } from "@homarr/common/types";
+import type { Integration } from "@homarr/db/schema";
+import type { IntegrationKindByCategory } from "@homarr/definitions";
+import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import type { NetworkControllerSummary } from "@homarr/integrations/types";
+import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
+
+import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+export const networkControllerRouter = createTRPCRouter({
+ summary: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
+ .query(async ({ ctx }) => {
+ const results = await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = networkControllerRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ },
+ summary: data,
+ updatedAt: timestamp,
+ };
+ }),
+ );
+ return results;
+ }),
+
+ subscribeToSummary: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
+ .subscription(({ ctx }) => {
+ return observable<{
+ integration: Modify }>;
+ summary: NetworkControllerSummary;
+ }>((emit) => {
+ const unsubscribes: (() => void)[] = [];
+ for (const integrationWithSecrets of ctx.integrations) {
+ const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
+ const innerHandler = networkControllerRequestHandler.handler(integrationWithSecrets, {});
+ const unsubscribe = innerHandler.subscribe((summary) => {
+ emit.next({
+ integration,
+ summary,
+ });
+ });
+ unsubscribes.push(unsubscribe);
+ }
+ return () => {
+ unsubscribes.forEach((unsubscribe) => {
+ unsubscribe();
+ });
+ };
+ });
+ }),
+});
diff --git a/packages/api/src/router/widgets/notebook.ts b/packages/api/src/router/widgets/notebook.ts
new file mode 100644
index 000000000..de89059d4
--- /dev/null
+++ b/packages/api/src/router/widgets/notebook.ts
@@ -0,0 +1,41 @@
+import { TRPCError } from "@trpc/server";
+import SuperJSON from "superjson";
+import { z } from "zod/v4";
+
+import { eq } from "@homarr/db";
+import { boards, items } from "@homarr/db/schema";
+
+import { createTRPCRouter, protectedProcedure } from "../../trpc";
+import { throwIfActionForbiddenAsync } from "../board/board-access";
+
+export const notebookRouter = createTRPCRouter({
+ updateContent: protectedProcedure
+ .input(
+ z.object({
+ itemId: z.string(),
+ content: z.string(),
+ boardId: z.string(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
+
+ const item = await ctx.db.query.items.findFirst({
+ where: eq(items.id, input.itemId),
+ });
+
+ if (item?.boardId !== input.boardId) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Specified item was not found",
+ });
+ }
+
+ const options = SuperJSON.parse<{ content: string }>(item.options);
+ options.content = input.content;
+ await ctx.db
+ .update(items)
+ .set({ options: SuperJSON.stringify(options) })
+ .where(eq(items.id, input.itemId));
+ }),
+});
diff --git a/packages/api/src/router/widgets/notifications.ts b/packages/api/src/router/widgets/notifications.ts
new file mode 100644
index 000000000..89ec95165
--- /dev/null
+++ b/packages/api/src/router/widgets/notifications.ts
@@ -0,0 +1,64 @@
+import { observable } from "@trpc/server/observable";
+
+import type { Modify } from "@homarr/common/types";
+import type { Integration } from "@homarr/db/schema";
+import type { IntegrationKindByCategory } from "@homarr/definitions";
+import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import type { Notification } from "@homarr/integrations";
+import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
+
+import type { IntegrationAction } from "../../middlewares/integration";
+import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
+ createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("notifications"));
+
+export const notificationsRouter = createTRPCRouter({
+ getNotifications: publicProcedure
+ .unstable_concat(createNotificationsIntegrationMiddleware("query"))
+ .query(async ({ ctx }) => {
+ return await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = notificationsRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: timestamp,
+ },
+ data,
+ };
+ }),
+ );
+ }),
+ subscribeNotifications: publicProcedure
+ .unstable_concat(createNotificationsIntegrationMiddleware("query"))
+ .subscription(({ ctx }) => {
+ return observable<{
+ integration: Modify }>;
+ data: Notification[];
+ }>((emit) => {
+ const unsubscribes: (() => void)[] = [];
+ for (const integrationWithSecrets of ctx.integrations) {
+ const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
+ const innerHandler = notificationsRequestHandler.handler(integrationWithSecrets, {});
+ const unsubscribe = innerHandler.subscribe((data) => {
+ emit.next({
+ integration,
+ data,
+ });
+ });
+ unsubscribes.push(unsubscribe);
+ }
+ return () => {
+ unsubscribes.forEach((unsubscribe) => {
+ unsubscribe();
+ });
+ };
+ });
+ }),
+});
diff --git a/packages/api/src/router/widgets/options.ts b/packages/api/src/router/widgets/options.ts
new file mode 100644
index 000000000..aa7c5e5c6
--- /dev/null
+++ b/packages/api/src/router/widgets/options.ts
@@ -0,0 +1,19 @@
+import { getServerSettingsAsync } from "@homarr/db/queries";
+
+import type { WidgetOptionsSettings } from "../../../../widgets/src";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+export const optionsRouter = createTRPCRouter({
+ getWidgetOptionSettings: publicProcedure.query(async ({ ctx }): Promise => {
+ const serverSettings = await getServerSettingsAsync(ctx.db);
+
+ return {
+ server: {
+ board: {
+ enableStatusByDefault: serverSettings.board.enableStatusByDefault,
+ forceDisableStatus: serverSettings.board.forceDisableStatus,
+ },
+ },
+ };
+ }),
+});
diff --git a/packages/api/src/router/widgets/releases.ts b/packages/api/src/router/widgets/releases.ts
new file mode 100644
index 000000000..3d355b26c
--- /dev/null
+++ b/packages/api/src/router/widgets/releases.ts
@@ -0,0 +1,62 @@
+import { escapeForRegEx } from "@tiptap/react";
+import { z } from "zod/v4";
+
+import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import { releasesRequestHandler } from "@homarr/request-handler/releases";
+
+import { createOneIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+const formatVersionFilterRegex = (versionFilter: z.infer | undefined) => {
+ if (!versionFilter) return undefined;
+
+ const escapedPrefix = versionFilter.prefix ? escapeForRegEx(versionFilter.prefix) : "";
+ const precision = "[0-9]+\\.".repeat(versionFilter.precision).slice(0, -2);
+ const escapedSuffix = versionFilter.suffix ? escapeForRegEx(versionFilter.suffix) : "";
+
+ return `^${escapedPrefix}${precision}${escapedSuffix}$`;
+};
+
+const releaseVersionFilterSchema = z.object({
+ prefix: z.string().optional(),
+ precision: z.number(),
+ suffix: z.string().optional(),
+});
+
+export const releasesRouter = createTRPCRouter({
+ getLatest: publicProcedure
+ .concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("releasesProvider")))
+ .input(
+ z.object({
+ repositories: z.array(
+ z.object({
+ id: z.string(),
+ identifier: z.string(),
+ versionFilter: releaseVersionFilterSchema.optional(),
+ }),
+ ),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ return await Promise.all(
+ input.repositories.map(async (repository) => {
+ const response = await releasesRequestHandler
+ .handler(ctx.integration, {
+ id: repository.id,
+ identifier: repository.identifier,
+ versionRegex: formatVersionFilterRegex(repository.versionFilter),
+ })
+ .getCachedOrUpdatedDataAsync({
+ forceUpdate: false,
+ });
+
+ return {
+ id: repository.id,
+ integration: { name: ctx.integration.name, kind: ctx.integration.kind },
+ timestamp: response.timestamp,
+ ...response.data,
+ };
+ }),
+ );
+ }),
+});
diff --git a/packages/api/src/router/widgets/rssFeed.ts b/packages/api/src/router/widgets/rssFeed.ts
new file mode 100644
index 000000000..b66662c2e
--- /dev/null
+++ b/packages/api/src/router/widgets/rssFeed.ts
@@ -0,0 +1,37 @@
+import { z } from "zod/v4";
+
+import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
+
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+export const rssFeedRouter = createTRPCRouter({
+ getFeeds: publicProcedure
+ .input(
+ z.object({
+ urls: z.array(z.string()),
+ maximumAmountPosts: z.number(),
+ }),
+ )
+ .query(async ({ input }) => {
+ const rssFeeds = await Promise.all(
+ input.urls.map(async (url) => {
+ const innerHandler = rssFeedsRequestHandler.handler({
+ url,
+ count: input.maximumAmountPosts,
+ });
+ return await innerHandler.getCachedOrUpdatedDataAsync({
+ forceUpdate: false,
+ });
+ }),
+ );
+
+ return rssFeeds
+ .flatMap((rssFeed) => rssFeed.data.entries)
+ .slice(0, input.maximumAmountPosts)
+ .sort((entryA, entryB) => {
+ return entryA.published && entryB.published
+ ? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
+ : 0;
+ });
+ }),
+});
diff --git a/packages/api/src/router/widgets/smart-home.ts b/packages/api/src/router/widgets/smart-home.ts
new file mode 100644
index 000000000..2dc9add89
--- /dev/null
+++ b/packages/api/src/router/widgets/smart-home.ts
@@ -0,0 +1,63 @@
+import { observable } from "@trpc/server/observable";
+import { z } from "zod/v4";
+
+import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import { createIntegrationAsync } from "@homarr/integrations";
+import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
+
+import type { IntegrationAction } from "../../middlewares/integration";
+import { createOneIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
+
+const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) =>
+ createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer"));
+
+export const smartHomeRouter = createTRPCRouter({
+ entityState: publicProcedure
+ .input(z.object({ entityId: z.string() }))
+ .concat(createSmartHomeIntegrationMiddleware("query"))
+ .query(async ({ ctx: { integration }, input }) => {
+ const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
+ const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return data;
+ }),
+ subscribeEntityState: publicProcedure
+ .concat(createSmartHomeIntegrationMiddleware("query"))
+ .input(z.object({ entityId: z.string() }))
+ .subscription(({ input, ctx }) => {
+ return observable<{
+ entityId: string;
+ state: string;
+ }>((emit) => {
+ const innerHandler = smartHomeEntityStateRequestHandler.handler(ctx.integration, {
+ entityId: input.entityId,
+ });
+ const unsubscribe = innerHandler.subscribe((state) => {
+ emit.next({ state, entityId: input.entityId });
+ });
+
+ return () => {
+ unsubscribe();
+ };
+ });
+ }),
+ switchEntity: protectedProcedure
+ .concat(createSmartHomeIntegrationMiddleware("interact"))
+ .input(z.object({ entityId: z.string() }))
+ .mutation(async ({ ctx: { integration }, input }) => {
+ const client = await createIntegrationAsync(integration);
+ const success = await client.triggerToggleAsync(input.entityId);
+
+ const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
+ await innerHandler.invalidateAsync();
+
+ return success;
+ }),
+ executeAutomation: protectedProcedure
+ .concat(createSmartHomeIntegrationMiddleware("interact"))
+ .input(z.object({ automationId: z.string() }))
+ .mutation(async ({ ctx: { integration }, input }) => {
+ const client = await createIntegrationAsync(integration);
+ await client.triggerAutomationAsync(input.automationId);
+ }),
+});
diff --git a/packages/api/src/router/widgets/stocks.ts b/packages/api/src/router/widgets/stocks.ts
new file mode 100644
index 000000000..d7b4ed13a
--- /dev/null
+++ b/packages/api/src/router/widgets/stocks.ts
@@ -0,0 +1,23 @@
+import { z } from "zod/v4";
+
+import { fetchStockPriceHandler } from "@homarr/request-handler/stock-price";
+
+import { stockPriceTimeFrames } from "../../../../widgets/src/stocks";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+const stockPriceInputSchema = z.object({
+ stock: z.string().nonempty(),
+ timeRange: z.enum(stockPriceTimeFrames.range),
+ timeInterval: z.enum(stockPriceTimeFrames.interval),
+});
+
+export const stockPriceRouter = createTRPCRouter({
+ getPriceHistory: publicProcedure.input(stockPriceInputSchema).query(async ({ input }) => {
+ const innerHandler = fetchStockPriceHandler.handler({
+ stock: input.stock,
+ timeRange: input.timeRange,
+ timeInterval: input.timeInterval,
+ });
+ return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ }),
+});
diff --git a/packages/api/src/router/widgets/weather.ts b/packages/api/src/router/widgets/weather.ts
new file mode 100644
index 000000000..bb1077569
--- /dev/null
+++ b/packages/api/src/router/widgets/weather.ts
@@ -0,0 +1,29 @@
+import { observable } from "@trpc/server/observable";
+import { z } from "zod/v4";
+
+import type { Weather } from "@homarr/request-handler/weather";
+import { weatherRequestHandler } from "@homarr/request-handler/weather";
+
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+const atLocationInput = z.object({
+ longitude: z.number(),
+ latitude: z.number(),
+});
+
+export const weatherRouter = createTRPCRouter({
+ atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => {
+ const handler = weatherRequestHandler.handler(input);
+ return await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }).then((result) => result.data);
+ }),
+ subscribeAtLocation: publicProcedure.input(atLocationInput).subscription(({ input }) => {
+ return observable((emit) => {
+ const handler = weatherRequestHandler.handler(input);
+ const unsubscribe = handler.subscribe((data) => {
+ emit.next(data);
+ });
+
+ return unsubscribe;
+ });
+ }),
+});
diff --git a/packages/api/src/schema-merger.ts b/packages/api/src/schema-merger.ts
new file mode 100644
index 000000000..1385bf656
--- /dev/null
+++ b/packages/api/src/schema-merger.ts
@@ -0,0 +1,21 @@
+import { z } from "zod/v4";
+import type { ZodIntersection, ZodObject } from "zod/v4";
+
+export function convertIntersectionToZodObject>(
+ intersection: TIntersection,
+) {
+ const left = intersection.def.left;
+ const right = intersection.def.right;
+
+ // Merge the shapes
+ const mergedShape = { ...left.def.shape, ...right.def.shape };
+
+ // Return a new ZodObject
+ return z.object(mergedShape) as unknown as TIntersection extends ZodIntersection
+ ? TLeft extends ZodObject
+ ? TRight extends ZodObject
+ ? ZodObject