Compare commits

...

27 Commits

Author SHA1 Message Date
Eli Bosley
e030c0604c feat: more dino changes 2024-10-15 11:45:41 -04:00
Eli Bosley
c847bbaf26 fix: skypack for lodash 2024-10-11 13:29:10 -04:00
Eli Bosley
7a5ff92c72 feat: keep trying deno 2024-10-11 13:03:52 -04:00
Eli Bosley
056f2abf74 feat: begin moving to deno 2024-10-11 12:33:06 -04:00
Zack Spear
b1409684db refactor: conditionally skip removeConsole plugin based on VITE_ALLOW_CONSOLE_LOGS env 2024-10-11 10:58:36 -04:00
Zack Spear
14d9448e4c refactor: build removeConsole conditionally skip via VITE_ALLOW_CONSOLE_LOGS env 2024-10-11 10:58:36 -04:00
Eli Bosley
924fa699eb fix: linter error 2024-10-10 09:42:38 -04:00
Eli Bosley
999a8e39eb fix: remove console logs with vue plugin 2024-10-10 09:41:12 -04:00
Eli Bosley
5a1c85d739 fix: remove unused disableProductionConsoleLogs call 2024-10-09 13:49:57 -04:00
Eli Bosley
ba77ff4a4c feat: remove console log disabler 2024-10-09 13:49:57 -04:00
Pujit Mehrotra
05765495c4 test(NotificationsService): add snapshot test to legacy script execution error 2024-10-09 13:12:15 -04:00
Pujit Mehrotra
f7cccc8c37 test(NotificationsService): add special characters to legacy script test 2024-10-09 13:12:15 -04:00
Pujit Mehrotra
85e0f7993e feat(NotificationsService): use existing notifier script to create notifications when possible 2024-10-09 13:12:15 -04:00
Pujit Mehrotra
d5a424ebe1 refactor(api): directly accept importance level in UnraidLocalNotifier 2024-10-09 13:12:15 -04:00
Pujit Mehrotra
01441961c3 doc(cors): update name of bypass flag 2024-10-08 15:52:43 -04:00
Pujit Mehrotra
836f64d28f test(api): add auth-sessions to paths test snapshot 2024-10-08 15:52:43 -04:00
Pujit Mehrotra
79bb4e585b refactor(CookieService): use paths store to get default sessions directory instead of a literal 2024-10-08 15:52:43 -04:00
Pujit Mehrotra
409e88b727 refactor(cors): use BYPASS_CORS_CHECKS flag to ignore cors failures instead of BYPASS_PERMISSION_CHECKS 2024-10-08 15:52:43 -04:00
Pujit Mehrotra
5034a8981a chore(CookieService): remove unused CookieGuard 2024-10-08 15:52:43 -04:00
Pujit Mehrotra
e61d9f195d fix(CookieService): potential race condition in unit tests 2024-10-08 15:52:43 -04:00
Pujit Mehrotra
b3e213ba04 refactor(CookieService): rename SESSION_COOKIE_OPTIONS to SESSION_COOKIE_CONFIG for clearer semantics 2024-10-08 15:52:43 -04:00
Pujit Mehrotra
a7ea678683 fix(cors): excessive instantiation of CookieService to improve memory overhead 2024-10-08 15:52:43 -04:00
Pujit Mehrotra
791e16ce52 test(CookieService): reading valid & invalid session cookies 2024-10-08 15:52:43 -04:00
Pujit Mehrotra
173da0e65b refactor(CookieService): make cookie prefix & session directory injectable via Nest.js 2024-10-08 15:52:43 -04:00
Pujit Mehrotra
287aabfda7 feat(auth): make cors aware of authenticated sessions 2024-10-08 15:52:43 -04:00
Pujit Mehrotra
d8656cc6b3 fix: replace express cookie parser with fastify's 2024-10-08 15:52:43 -04:00
Pujit Mehrotra
a3500c9bc9 feat(Auth): add cookie guard to check for valid sessions 2024-10-08 15:52:43 -04:00
85 changed files with 1754 additions and 937 deletions

View File

@@ -13,7 +13,11 @@ RUN apt-get update -y && apt-get install -y \
jq \
zstd \
git \
build-essential
build-essential \
zip \
curl
RUN curl -fsSL https://deno.land/install.sh | sh -s -- -y
WORKDIR /app
@@ -28,12 +32,17 @@ COPY tsconfig.json tsup.config.ts .eslintrc.cjs .npmrc .env.production .env.stag
COPY package.json package-lock.json ./
# Install pkg
RUN npm i -g pkg zx
# Install deps
RUN npm i
RUN npm i -g node-gyp
RUN deno install --allow-read --allow-write --allow-env --allow-net --allow-run
# Add zx and pkg to the path
ENV PATH /root/.deno/bin:$PATH
ENV PATH /app/node_modules/.bin:$PATH
EXPOSE 4000
###########################################################

10
api/deno.json Normal file
View File

@@ -0,0 +1,10 @@
{
"imports": {
"lodash": "https://cdn.skypack.dev/lodash",
"xml2js": "npm:xml2js",
"@app/": "./src/",
"@std/collections": "jsr:@std/collections@^1.0.8",
"@vmngr/libvirt": "https://cdn.jsdelivr.net/gh/elibosley/libvirt-deno@HEAD/lib/index.ts"
},
"nodeModulesDir": "auto"
}

View File

@@ -22,6 +22,7 @@ x-volumes: &volumes
- ./.pkg-cache:/app/.pkg-cache
- ./codegen.yml:/app/codegen.yml
- ./fix-array-type.cjs:/app/fix-array-type.cjs
- ./deno.json:/app/deno.json
- /var/run/docker.sock:/var/run/docker.sock
services:

769
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -43,7 +43,7 @@
"install:unraid": "./scripts/install-in-unraid.sh",
"start:plugin": "INTROSPECTION=true LOG_MOTHERSHIP_MESSAGES=true LOG_TYPE=pretty LOG_LEVEL=trace unraid-api start --debug",
"start:plugin-verbose": "LOG_CONTEXT=true LOG_MOTHERSHIP_MESSAGES=true LOG_TYPE=pretty LOG_LEVEL=trace unraid-api start --debug",
"start:dev": "LOG_MOTHERSHIP_MESSAGES=true LOG_TYPE=pretty NODE_ENV=development LOG_LEVEL=trace NODE_ENV=development tsup --config ./tsup.config.ts --watch --onSuccess 'DOTENV_CONFIG_PATH=./.env.development node -r dotenv/config dist/unraid-api.cjs start --debug'",
"start:dev": "LOG_MOTHERSHIP_MESSAGES=true LOG_TYPE=pretty NODE_ENV=development LOG_LEVEL=trace NODE_ENV=development tsup --config ./tsup.config.ts --watch --onSuccess 'DOTENV_CONFIG_PATH=./.env.development deno -A dist/unraid-api.cjs start --debug'",
"restart:dev": "LOG_MOTHERSHIP_MESSAGES=true LOG_TYPE=pretty NODE_ENV=development LOG_LEVEL=trace NODE_ENV=development tsup --config ./tsup.config.ts --watch --onSuccess 'DOTENV_CONFIG_PATH=./.env.development node -r dotenv/config dist/unraid-api.cjs restart --debug'",
"stop:dev": "LOG_MOTHERSHIP_MESSAGES=true LOG_TYPE=pretty NODE_ENV=development LOG_LEVEL=trace NODE_ENV=development tsup --config ./tsup.config.ts --onSuccess 'DOTENV_CONFIG_PATH=./.env.development node -r dotenv/config dist/unraid-api.cjs stop --debug'",
"start:report": "LOG_MOTHERSHIP_MESSAGES=true LOG_TYPE=pretty NODE_ENV=development LOG_LEVEL=trace NODE_ENV=development LOG_CONTEXT=true tsup --config ./tsup.config.ts --watch --onSuccess 'DOTENV_CONFIG_PATH=./.env.development node -r dotenv/config dist/unraid-api.cjs report --debug'",
@@ -60,9 +60,10 @@
"unraid-api"
],
"dependencies": {
"@apollo/client": "^3.10.4",
"@apollo/client": "^3.11.8",
"@apollo/server": "^4.10.4",
"@as-integrations/fastify": "^2.1.1",
"@fastify/cookie": "^9.4.0",
"@graphql-codegen/client-preset": "^4.2.5",
"@graphql-tools/load-files": "^7.0.0",
"@graphql-tools/merge": "^9.0.4",
@@ -78,7 +79,6 @@
"@reflet/cron": "^1.3.1",
"@runonflux/nat-upnp": "^1.0.2",
"accesscontrol": "^2.2.1",
"am": "github:unraid/am",
"async-exit-hook": "^2.0.1",
"btoa": "^1.2.1",
"bycontract": "^2.0.11",
@@ -133,13 +133,14 @@
"reflect-metadata": "^0.1.14",
"request": "^2.88.2",
"semver": "^7.6.2",
"stoppable": "^1.1.0",
"systeminformation": "^5.22.9",
"ts-command-line-args": "^2.5.1",
"uuid": "^10.0.0",
"ws": "^8.17.0",
"wtfnode": "^0.9.2",
"xhr2": "^0.2.1",
"xml2js": "^0.6.2",
"zen-observable-ts": "^1.1.0",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -160,6 +161,7 @@
"@types/bytes": "^3.1.4",
"@types/cli-table": "^0.3.4",
"@types/command-exists": "^1.2.3",
"@types/cors": "^2.8.17",
"@types/dockerode": "^3.3.29",
"@types/express": "^4.17.21",
"@types/graphql-fields": "^1.3.9",
@@ -178,7 +180,6 @@
"@types/wtfnode": "^0.7.3",
"@typescript-eslint/eslint-plugin": "^7.9.0",
"@typescript-eslint/parser": "^7.9.0",
"@unraid/eslint-config": "github:unraid/eslint-config",
"@vitest/coverage-v8": "^2.1.1",
"@vitest/ui": "^2.1.1",
"camelcase-keys": "^8.0.2",
@@ -210,12 +211,9 @@
"vitest": "^2.1.1",
"zx": "^7.2.3"
},
"optionalDependencies": {
"@vmngr/libvirt": "github:unraid/libvirt"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
"overrides": {
"dependencies": {
"graphql-tag": "^2.12.6"
}
}
}

View File

@@ -1,7 +1,7 @@
import { test, expect } from 'vitest';
import { parseConfig } from '@app/core/utils/misc/parse-config';
import { Parser as MultiIniParser } from 'multi-ini';
import { readFile, writeFile } from 'fs/promises';
import { readFile, writeFile } from 'node:fs/promises';
import { parse } from 'ini';
import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer';

View File

@@ -25,6 +25,7 @@ test('Returns paths', async () => {
"machine-id",
"log-base",
"var-run",
"auth-sessions",
]
`);
});

View File

@@ -0,0 +1,16 @@
import { join } from 'node:path';
import { expect, test } from 'vitest';
import { store } from '@app/store';
import type { DevicesIni } from '@app/store/state-parsers/devices';
test('Returns parsed state file', async () => {
const { parse } = await import('@app/store/state-parsers/devices');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'devs.ini');
const stateFile = parseConfig<DevicesIni>({
filePath,
type: 'ini',
});
expect(parse(stateFile)).toMatchInlineSnapshot('[]');
});

View File

@@ -0,0 +1,81 @@
import { join } from 'node:path';
import { expect, test } from 'vitest';
import { store } from '@app/store';
import type { NetworkIni } from '@app/store/state-parsers/network';
test('Returns parsed state file', async () => {
const { parse } = await import('@app/store/state-parsers/network');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'network.ini');
const stateFile = parseConfig<NetworkIni>({
filePath,
type: 'ini',
});
expect(parse(stateFile)).toMatchInlineSnapshot(`
[
{
"bonding": true,
"bondingMiimon": "100",
"bondingMode": "1",
"bondname": "",
"bondnics": [
"eth0",
"eth1",
"eth2",
"eth3",
],
"brfd": "0",
"bridging": true,
"brname": "",
"brnics": "bond0",
"brstp": "0",
"description": [
"",
],
"dhcp6Keepresolv": false,
"dhcpKeepresolv": false,
"dnsServer1": "1.1.1.1",
"dnsServer2": "8.8.8.8",
"gateway": [
"192.168.1.1",
],
"gateway6": [
"",
],
"ipaddr": [
"192.168.1.150",
],
"ipaddr6": [
"",
],
"metric": [
"",
],
"metric6": [
"",
],
"mtu": "",
"netmask": [
"255.255.255.0",
],
"netmask6": [
"",
],
"privacy6": [
"",
],
"protocol": [
"",
],
"type": "access",
"useDhcp": [
true,
],
"useDhcp6": [
false,
],
},
]
`);
});

View File

@@ -0,0 +1,203 @@
import { join } from 'node:path';
import { expect, test } from 'vitest';
import { store } from '@app/store';
import type { NfsSharesIni } from '@app/store/state-parsers/nfs';
test('Returns parsed state file', async () => {
const { parse } = await import('@app/store/state-parsers/nfs');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'sec_nfs.ini');
const stateFile = parseConfig<NfsSharesIni>({
filePath,
type: 'ini',
});
expect(parse(stateFile)).toMatchInlineSnapshot(`
[
{
"enabled": false,
"hostList": "",
"name": "disk1",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk2",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk3",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk4",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk5",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk6",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk7",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk8",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk9",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk10",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk11",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk12",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk13",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk14",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk15",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk16",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk17",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk18",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk19",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk20",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk21",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "disk22",
"readList": [],
"security": "public",
"writeList": [],
},
{
"enabled": false,
"hostList": "",
"name": "abc",
"readList": [],
"security": "public",
"writeList": [],
},
]
`);
});

View File

@@ -1,4 +1,4 @@
import { join } from 'path';
import { join } from 'node:path';
import { expect, test } from 'vitest';
import { store } from '@app/store';
import type { NginxIni } from '@app/store/state-parsers/nginx';

View File

@@ -1,4 +1,4 @@
import { join } from 'path';
import { join } from 'node:path';
import { expect, test } from 'vitest';
import { store } from '@app/store';
import type { SharesIni } from '@app/store/state-parsers/shares';

View File

@@ -1,4 +1,4 @@
import { join } from 'path';
import { join } from 'node:path';
import { expect, test } from 'vitest';
import { store } from '@app/store';
import type { SlotsIni } from '@app/store/state-parsers/slots';

View File

@@ -0,0 +1,306 @@
import { join } from 'node:path';
import { expect, test } from 'vitest';
import { store } from '@app/store';
import type { SmbIni } from '@app/store/state-parsers/smb';
test('Returns parsed state file', async () => {
const { parse } = await import('@app/store/state-parsers/smb');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'sec.ini');
const stateFile = parseConfig<SmbIni>({
filePath,
type: 'ini',
});
expect(parse(stateFile)).toMatchInlineSnapshot(`
[
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk1",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk2",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk3",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk4",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk5",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk6",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk7",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk8",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk9",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk10",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk11",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk12",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk13",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk14",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk15",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk16",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk17",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk18",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk19",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk20",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk21",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "disk22",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"caseSensitive": "auto",
"enabled": true,
"fruit": "no",
"name": "abc",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
{
"enabled": true,
"fruit": "no",
"name": "flash",
"readList": [],
"security": "public",
"timemachine": {
"volsizelimit": NaN,
},
"writeList": [],
},
]
`);
});

View File

@@ -0,0 +1,40 @@
import { join } from 'node:path';
import { expect, test } from 'vitest';
import { store } from '@app/store';
import type { UsersIni } from '@app/store/state-parsers/users';
test('Returns parsed state file', async () => {
const { parse } = await import('@app/store/state-parsers/users');
const { parseConfig } = await import('@app/core/utils/misc/parse-config');
const { paths } = store.getState();
const filePath = join(paths.states, 'users.ini');
const stateFile = parseConfig<UsersIni>({
filePath,
type: 'ini',
});
expect(parse(stateFile)).toMatchInlineSnapshot(`
[
{
"description": "Console and webGui login account",
"id": "0",
"name": "root",
"password": true,
"role": "admin",
},
{
"description": "",
"id": "1",
"name": "xo",
"password": true,
"role": "user",
},
{
"description": "",
"id": "2",
"name": "test_user",
"password": false,
"role": "user",
},
]
`);
});

View File

@@ -1,4 +1,4 @@
import { join } from 'path';
import { join } from 'node:path';
import { expect, test } from 'vitest';
import { store } from '@app/store';
import type { VarIni } from '@app/store/state-parsers/var';

71
api/src/am.ts Normal file
View File

@@ -0,0 +1,71 @@
const defaultErrorHandler = (error: Error) => {
console.error(error);
};
const getScriptArgs = (process: NodeJS.Process) => {
if (!process || typeof process !== 'object' || !Array.isArray(process.argv)) return [];
const [, , ...params] = process.argv;
return params;
};
const setExitCode = (process: NodeJS.Process, code = 1) => {
if (process && typeof process === 'object' && process.exitCode !== code) {
process.exitCode = code;
}
};
const handleError = async (mainError: Error, errorHandler: (error: Error) => Promise<void> | void) => {
setExitCode(process, 1);
if (errorHandler === defaultErrorHandler) return defaultErrorHandler(mainError);
try {
await errorHandler(mainError);
} catch (errorHandlerFailure) {
console.warn(`The custom error handler failed`, errorHandlerFailure);
defaultErrorHandler(mainError);
}
};
type RegisterUnhandledRejectionHandler = {
(process: NodeJS.Process): void;
done?: boolean;
};
const registerUnhandledRejectionHandler: RegisterUnhandledRejectionHandler = (
process: NodeJS.Process
) => {
if (
!process ||
typeof process !== 'object' ||
typeof process.on !== 'function' ||
registerUnhandledRejectionHandler.done
)
return;
const amUnhandledRejectionHandler: NodeJS.UnhandledRejectionListener = (error, failedPromise) => {
setExitCode(process, 2);
console.warn(`Unhandled Promise Rejection ${error}\n\tat: Promise ${failedPromise}`);
};
process.on('unhandledRejection', amUnhandledRejectionHandler);
registerUnhandledRejectionHandler.done = true;
};
const runMain = async (
asyncMain: (...args: string[]) => Promise<void> | void,
errorHandler: (error: Error) => Promise<void> | void,
process: NodeJS.Process
) => {
registerUnhandledRejectionHandler(process);
try {
return await asyncMain(...getScriptArgs(process));
} catch (mainError: unknown) {
await handleError(mainError as Error, errorHandler);
}
};
export const am = (
asyncMain: (...args: string[]) => Promise<void> | void,
errorHandler = defaultErrorHandler
) => runMain(asyncMain, errorHandler, process);

View File

@@ -1,6 +1,7 @@
import 'wtfnode';
import { am } from 'am';
import { am } from '@app/am';
import { main } from '@app/cli/index';
import { internalLogger } from '@app/core/log';

View File

@@ -17,7 +17,7 @@ import {
type ApolloQueryResult,
type ApolloClient,
type NormalizedCacheObject,
} from '@apollo/client/core/core.cjs';
} from '@apollo/client/core/index.js';
import { MinigraphStatus } from '@app/graphql/generated/api/types';
import { API_VERSION } from '@app/environment';
import { loadStateFiles } from '@app/store/modules/emhttp';

View File

@@ -1,5 +1,5 @@
import { copyFile, readFile, writeFile } from 'fs/promises';
import { join } from 'path';
import { copyFile, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { cliLogger } from '@app/core/log';
import { getUnraidApiPid } from '@app/cli/get-unraid-api-pid';
import { setEnv } from '@app/cli/set-env';

View File

@@ -1,5 +1,5 @@
import { getters, type RootState, store } from '@app/store';
import { uniq } from 'lodash';
import {
getServerIps,
getUrlForField,
@@ -19,6 +19,8 @@ const getAllowedSocks = (): string[] => [
'/var/run/unraid-cli.sock',
];
const getLocalAccessUrlsForServer = (
state: RootState = store.getState()
): string[] => {
@@ -102,11 +104,12 @@ const getApolloSandbox = (): string[] => {
export const getAllowedOrigins = (
state: RootState = store.getState()
): string[] =>
uniq([
Array.from(new Set([
...getAllowedSocks(),
...getLocalAccessUrlsForServer(),
...getRemoteAccessUrlsForAllowedOrigins(state),
...getExtraOrigins(),
...getConnectOrigins(),
...getApolloSandbox(),
]).map((url) => (url.endsWith('/') ? url.slice(0, -1) : url));
]))
.map((url) => (url.endsWith('/') ? url.slice(0, -1) : url));

View File

@@ -1,4 +1,4 @@
import { format } from 'util';
import { format } from 'node:util';
import { AppError } from '@app/core/errors/app-error';
/**

View File

@@ -1,4 +1,4 @@
import { writeFile } from 'fs/promises';
import { writeFile } from 'node:fs/promises';
import { fileExists } from '@app/core/utils/files/file-exists';
export const setupLogRotation = async () => {
@@ -9,9 +9,10 @@ export const setupLogRotation = async () => {
'/etc/logrotate.d/unraid-api',
`
/var/log/unraid-api/*.log {
rotate 1
rotate 0
missingok
size 5M
copytruncate
}
`,
{ mode: '644' }

View File

@@ -7,7 +7,9 @@ import {
import { store } from '@app/store/index';
import { FileLoadStatus } from '@app/store/types';
import { GraphQLError } from 'graphql';
import sum from 'lodash/sum';
// Utility function to sum an array of numbers
const sum = (numbers: number[]): number => numbers.reduce((acc, num) => acc + num, 0);
export const getArrayData = (getState = store.getState): ArrayType => {
// Var state isn't loaded
@@ -35,9 +37,9 @@ export const getArrayData = (getState = store.getState): ArrayType => {
const disks = allDisks.filter((disk) => disk.type === ArrayDiskType.DATA);
const caches = allDisks.filter((disk) => disk.type === ArrayDiskType.CACHE);
// Disk sizes
const disksTotalKBytes = sum(disks.map((disk) => disk.fsSize));
const disksFreeKBytes = sum(disks.map((disk) => disk.fsFree));
const disksUsedKBytes = sum(disks.map((disk) => disk.fsUsed));
const disksTotalKBytes = sum(disks.map((disk) => disk.fsSize).filter((size): size is number => size !== undefined));
const disksFreeKBytes = sum(disks.map((disk) => disk.fsFree).filter((free): free is number => free !== undefined));
const disksUsedKBytes = sum(disks.map((disk) => disk.fsUsed).filter((used): used is number => used !== undefined));
// Max
const maxDisks = emhttp.var.maxArraysz ?? disks.length;

View File

@@ -1,4 +1,4 @@
import fs from 'fs';
import fs from 'node:fs';
import camelCaseKeys from 'camelcase-keys';
import { catchHandlers } from '@app/core/utils/misc/catch-handlers';
import { getters, store } from '@app/store';

View File

@@ -4,7 +4,6 @@ import {
blockDevices,
diskLayout,
} from 'systeminformation';
import { map as asyncMap } from 'p-iteration';
import {
type Disk,
DiskInterfaceType,
@@ -13,6 +12,13 @@ import {
} from '@app/graphql/generated/api/types';
import { graphqlLogger } from '@app/core/log';
const asyncMap = async <T, U>(
array: T[],
callback: (item: T, index: number, array: T[]) => Promise<U>
): Promise<U[]> => {
return Promise.all(array.map(callback));
};
const getTemperature = async (
disk: Systeminformation.DiskLayoutData
): Promise<number> => {

View File

@@ -1,4 +1,4 @@
import { promises as fs } from 'fs';
import { promises as fs } from 'node:fs';
import { type CoreResult, type CoreContext } from '@app/core/types';
import { FileMissingError } from '@app/core/errors/file-missing-error';
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';

View File

@@ -1,10 +1,12 @@
import Mustache from 'mustache';
import { type LooseObject } from '@app/core/types';
import { type NotificationIni } from '../types/states/notification';
export type NotifierLevel = 'info' | 'warn' | 'error';
export type NotifierOptions = Partial<{
level: NotifierLevel;
importance?: NotificationIni['importance'];
helpers?: Record<string, unknown>;
template?: string;
}>;

View File

@@ -0,0 +1,39 @@
import { logger } from '@app/core/log';
import { Notifier, type NotifierSendOptions, type NotifierOptions } from '@app/core/notifiers/notifier';
import { execa } from 'execa';
type ValidLocalLevels = 'alert' | 'warning' | 'normal';
export class UnraidLocalNotifier extends Notifier {
private convertNotifierLevel(level: NotifierOptions['level']): ValidLocalLevels {
switch (level) {
case 'error':
return 'alert';
case 'warn':
return 'warning';
case 'info':
return 'normal';
default:
return 'normal';
}
}
constructor(options: NotifierOptions = {}) {
super(options);
this.level = options.importance ?? this.convertNotifierLevel(options.level ?? 'info');
this.template = options.template ?? '{{ message }}';
}
async send(options: NotifierSendOptions) {
const { title, data } = options;
const { level } = this;
const template = this.render(data);
try {
await execa('/usr/local/emhttp/webGui/scripts/notify', ['-i', `${level}`, '-s', 'Unraid API', '-d', `${template}`, '-e', `${title}`]);
} catch (error: unknown) {
logger.warn(`Error sending unraid notification: ${error instanceof Error ? error.message : 'No Error Information'}`);
}
}
}

View File

@@ -1,5 +1,5 @@
import { PubSub } from 'graphql-subscriptions';
import EventEmitter from 'events';
import EventEmitter from 'node:events';
// Allow subscriptions to have 30 connections
const eventEmitter = new EventEmitter();

View File

@@ -4,11 +4,11 @@ import {
type SliceState as ConfigSliceState,
initialState,
} from '@app/store/modules/config';
import { type RecursivePartial } from '@app/types';
import type { RecursivePartial } from '@app/types/index.d.ts';
import type {
MyServersConfig,
MyServersConfigMemory,
} from '@app/types/my-servers-config';
} from '@app/types/my-servers-config.d.ts';
import { isEqual } from 'lodash';
export type ConfigType = 'flash' | 'memory';

View File

@@ -1,5 +1,5 @@
import { access } from 'fs/promises';
import { accessSync } from 'fs';
import { access } from 'node:fs/promises';
import { accessSync } from 'node:fs';
import { F_OK } from 'node:constants';
export const fileExists = async (path: string) => access(path, F_OK).then(() => true).catch(() => false);

View File

@@ -0,0 +1,3 @@
import { extname } from 'node:path';
export const getExtensionFromPath = (filePath: string): string => extname(filePath);

View File

@@ -0,0 +1,23 @@
import { readFile } from 'node:fs/promises';
import { fileExists, fileExistsSync } from './file-exists';
import { extname } from 'node:path';
import { readFileSync } from 'node:fs';
export const loadFileFromPath = async (filePath: string): Promise<{ fileContents: string; extension: string }> => {
if (await fileExists(filePath)) {
const fileContents = await readFile(filePath, 'utf-8');
const extension = extname(filePath);
return { fileContents, extension };
}
throw new Error(`Failed to load file at path: ${filePath}`);
};
export const loadFileFromPathSync = (filePath: string): string => {
if (fileExistsSync(filePath)) {
const fileContents = readFileSync(filePath, 'utf-8').toString();
return fileContents;
}
throw new Error(`Failed to load file at path: ${filePath}`);
};

View File

@@ -0,0 +1,9 @@
import { readFileSync } from 'node:fs';
export const attemptReadFileSync = (path: string, fallback: any = undefined) => {
try {
return readFileSync(path, 'utf-8');
} catch {
return fallback;
}
};

View File

@@ -0,0 +1,20 @@
import { type RootState, store } from '@app/store';
import btoa from 'btoa';
import { basename, join } from 'node:path';
import { readFile } from 'node:fs/promises';
// Get key file
export const getKeyFile = async function (appStore: RootState = store.getState()) {
const { emhttp, paths } = appStore;
// If emhttp's state isn't loaded then return null as we can't load the key yet
if (emhttp.var?.regFile === undefined) return null;
// If the key location is empty return an empty string as there is no key
if (emhttp.var?.regFile.trim() === '') return '';
const keyFileName = basename(emhttp.var?.regFile);
const registrationKeyFilePath = join(paths['keyfile-base'], keyFileName);
const keyFile = await readFile(registrationKeyFilePath, 'binary');
return btoa(keyFile).trim().replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
};

View File

@@ -0,0 +1,23 @@
import { FileMissingError } from '@app/core/errors/file-missing-error';
import { getters } from '@app/store';
import { readFile } from 'node:fs/promises';
let machineId: string | null = null
export const getMachineId = async (): Promise<string> => {
const path = getters.paths()['machine-id'];
if (machineId) {
return machineId;
}
if (!path) {
const error = new FileMissingError('/etc/machine-id');
error.fatal = false;
throw error;
}
machineId = await readFile(path, 'utf8').then(machineId => machineId.split('\n')[0].trim()).catch(() => '');
return machineId;
};

View File

@@ -3,10 +3,10 @@ import camelCaseKeys from 'camelcase-keys';
import { includeKeys } from 'filter-obj';
import mapObject from 'map-obj';
import { AppError } from '@app/core/errors/app-error';
import { accessSync, readFileSync } from 'fs';
import { access } from 'fs/promises';
import { F_OK } from 'constants';
import { extname } from 'path';
import { accessSync, readFileSync } from 'node:fs';
import { access } from 'node:fs/promises';
import { F_OK } from 'node:constants';
import { extname } from 'node:path';
type ConfigType = 'ini' | 'cfg';

View File

@@ -1,4 +1,4 @@
import path from 'path';
import path from 'node:path';
import { execa } from 'execa';
import { FileMissingError } from '@app/core/errors/file-missing-error';
import { type LooseObject, type LooseStringObject } from '@app/core/types';

View File

@@ -1,6 +1,6 @@
import { getters } from '@app/store/index';
import crypto from 'crypto';
import { hostname } from 'os';
import crypto from 'node:crypto';
import { hostname } from 'node:os';
export const getServerIdentifier = (): string => {
const flashGuid = getters.emhttp()?.var?.flashGuid ?? 'FLASH_GUID_NOT_FOUND';
return crypto

View File

@@ -1,7 +1,14 @@
import { execa } from 'execa';
import { map as asyncMap } from 'p-iteration';
import { sync as commandExistsSync } from 'command-exists';
const asyncMap = async <T, U>(
array: T[],
callback: (item: T, index: number, array: T[]) => Promise<U>
): Promise<U[]> => {
return Promise.all(array.map(callback));
};
interface Device {
id: string;
allowed: boolean;

View File

@@ -1,5 +1,5 @@
import { access } from 'fs/promises';
import { constants } from 'fs';
import { access } from 'node:fs/promises';
import { constants } from 'node:fs';
import { Hypervisor } from '@vmngr/libvirt';
import { libvirtLogger } from '@app/core/log';

View File

@@ -0,0 +1,15 @@
import fs from 'node:fs';
import path from 'node:path';
import prettyBytes from 'pretty-bytes';
import { logger } from '@app/core/log';
const writeFile = async (filePath: string, fileContents: string | Buffer) => {
logger.debug(`Writing ${prettyBytes(fileContents.length)} to ${filePath}`);
await fs.promises.writeFile(filePath, fileContents);
};
export const writeToBoot = async (filePath: string, fileContents: string | Buffer) => {
const basePath = '/boot/config/plugins/dynamix/';
const resolvedPath = path.resolve(basePath, filePath);
await writeFile(resolvedPath, fileContents);
};

View File

@@ -14,6 +14,7 @@ export const GRAPHQL_INTROSPECTION = Boolean(
export const PORT = process.env.PORT ?? '/var/run/unraid-api.sock';
export const DRY_RUN = process.env.DRY_RUN === 'true';
export const BYPASS_PERMISSION_CHECKS = process.env.BYPASS_PERMISSION_CHECKS === 'true';
export const BYPASS_CORS_CHECKS = process.env.BYPASS_CORS_CHECKS === 'true';
export const LOG_CORS = process.env.LOG_CORS === 'true';
export const LOG_TYPE = process.env.LOG_TYPE as 'pretty' | 'raw' ?? 'pretty';
export const LOG_LEVEL = process.env.LOG_LEVEL as 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL';

View File

@@ -3,16 +3,16 @@ import {
HttpLink,
InMemoryCache,
split,
} from '@apollo/client/core/core.cjs';
import { onError } from '@apollo/client/link/error';
} from '@apollo/client/core/index.js';
import { onError } from '@apollo/client/link/error/index.js';
import { getInternalApiAddress } from '@app/consts';
import WebSocket from 'ws';
import { fetch } from 'cross-fetch';
import { getMainDefinition } from '@apollo/client/utilities';
import { getMainDefinition } from '@apollo/client/utilities/index.js';
import { graphqlLogger } from '@app/core/log';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { getters } from '@app/store/index';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js';
const getWebsocketWithHeaders = () => {
return class WebsocketWithOriginHeader extends WebSocket {

View File

@@ -3,7 +3,7 @@ import * as Types from '@app/graphql/generated/api/types';
import { z } from 'zod'
import { AccessUrl, AccessUrlInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationData, NotificationFilter, NotificationOverview, NotificationType, Notifications, NotificationslistArgs, Os, Owner, ParityCheck, Partition, Pci, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addApiKeyInput, addUserInput, arrayDiskInput, authenticateInput, deleteUserInput, mdState, registrationType, updateApikeyInput, usersInput } from '@app/graphql/generated/api/types'
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core/typings/index.d.ts';
type Properties<T> = Required<{
[K in keyof T]: z.ZodType<T[K], any, T[K]>;

View File

@@ -0,0 +1,65 @@
/*!
* Copyright 2022 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { MOTHERSHIP_GRAPHQL_LINK } from '@app/consts';
import { store } from '@app/store';
import { getDnsCache } from '@app/store/getters';
import { setDNSCheck } from '@app/store/modules/cache';
import { lookup as lookupDNS, resolve as resolveDNS } from 'dns';
import { isPrivate as isPrivateIP } from 'ip';
import { promisify } from 'node:util';
const msHostname = new URL(MOTHERSHIP_GRAPHQL_LINK).host;
/**
* Check if the local and network resolvers are able to see mothership
*
* See: https://nodejs.org/docs/latest/api/dns.html#dns_implementation_considerations
*/
export const checkDNS = async (hostname = msHostname): Promise<{ cloudIp: string }> => {
const dnsCachedResuslt = getDnsCache();
if (dnsCachedResuslt) {
if (dnsCachedResuslt.cloudIp) {
return { cloudIp: dnsCachedResuslt.cloudIp };
}
if (dnsCachedResuslt.error) {
throw dnsCachedResuslt.error;
}
}
let local: string | null = null;
let network: string | null = null;
try {
// Check the local resolver like "ping" does
// Check the DNS server the server has set - does a DNS query on the network
const [localRes, networkRes] = await Promise.all([
promisify(lookupDNS)(hostname).then(({ address }) => address),
promisify(resolveDNS)(hostname).then(([address]) => address),
]);
local = localRes;
network = networkRes;
// The user's server and the DNS server they're using are returning different results
if (!local.includes(network)) throw new Error(`Local and network resolvers showing different IP for "${hostname}". [local="${local ?? 'NOT FOUND'}"] [network="${network ?? 'NOT FOUND'}"]`);
// The user likely has a PI-hole or something similar running.
if (isPrivateIP(local)) throw new Error(`"${hostname}" is being resolved to a private IP. [IP=${local ?? 'NOT FOUND'}]`);
} catch (error: unknown) {
if (!(error instanceof Error)) {
throw error;
}
store.dispatch(setDNSCheck({ cloudIp: null, error }));
}
if (typeof local === 'string' || typeof network === 'string') {
const validIp: string = local ?? network ?? '';
store.dispatch(setDNSCheck({ cloudIp: validIp, error: null }));
return { cloudIp: validIp };
}
return { cloudIp: '' };
};

View File

@@ -0,0 +1,28 @@
import type { QueryOptions } from "@apollo/client/core";
import { gql } from "graphql-tag";
;
interface ParsedQuery {
query?: string;
variables?: Record<string, string>;
}
export const parseGraphQLQuery = (body: string): QueryOptions => {
try {
const parsedBody: ParsedQuery = JSON.parse(body);
if (
parsedBody.query &&
parsedBody.variables &&
typeof parsedBody.variables === 'object'
) {
return {
query: gql(parsedBody.query),
variables: parsedBody.variables,
};
}
throw new Error('Invalid Body');
} catch (error) {
throw new Error('Invalid Body Provided');
}
};

View File

@@ -1,4 +1,4 @@
import { join } from 'path';
import { join } from 'node:path';
import { loadFilesSync } from '@graphql-tools/load-files';
import { mergeTypeDefs } from '@graphql-tools/merge';

View File

@@ -1,7 +1,7 @@
import 'reflect-metadata';
import 'global-agent/bootstrap';
import { am } from 'am';
import { am } from '@app/am';
import http from 'http';
import https from 'https';
import CacheableLookup from 'cacheable-lookup';
@@ -14,7 +14,7 @@ import { loadStateFiles } from '@app/store/modules/emhttp';
import { StateManager } from '@app/store/watch/state-watch';
import { setupRegistrationKeyWatch } from '@app/store/watch/registration-watch';
import { loadRegistrationKey } from '@app/store/modules/registration';
import { unlinkSync } from 'fs';
import { unlinkSync } from 'node:fs';
import { fileExistsSync } from '@app/core/utils/files/file-exists';
import { PORT, environment } from '@app/environment';
import { shutdownApiEvent } from '@app/store/actions/shutdown-api-event';

View File

@@ -11,8 +11,8 @@ import {
ApolloClient,
InMemoryCache,
type NormalizedCacheObject,
} from '@apollo/client/core/core.cjs';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
} from '@apollo/client/core/index.js';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js';
import { MinigraphStatus } from '@app/graphql/generated/api/types';
import { API_VERSION } from '@app/environment';
import {
@@ -20,8 +20,8 @@ import {
setMothershipTimeout,
} from '@app/store/modules/minigraph';
import { logoutUser } from '@app/store/modules/config';
import { RetryLink } from '@apollo/client/link/retry';
import { ErrorLink } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry/index.js';
import { ErrorLink } from '@apollo/client/link/error/index.js';
import { isApiKeyValid } from '@app/store/getters/index';
import { buildDelayFunction } from '@app/mothership/utils/delay-function';
import { WebSocket } from 'ws';

View File

@@ -1,4 +1,4 @@
import { type DelayFunctionOptions } from '@apollo/client/link/retry/delayFunction';
import { type DelayFunctionOptions } from '@apollo/client/link/retry/delayFunction.js';
export function buildDelayFunction(
delayOptions?: DelayFunctionOptions,

View File

@@ -2,9 +2,9 @@ import { parseConfig } from '@app/core/utils/misc/parse-config';
import {
createAsyncThunk,
} from '@reduxjs/toolkit';
import { access } from 'fs/promises';
import { F_OK } from 'constants';
import { type RecursivePartial, type RecursiveNullable } from '@app/types';
import { access } from 'node:fs/promises';
import { F_OK } from 'node:constants';
import type { RecursivePartial, RecursiveNullable } from '@app/types/index.d.ts';
import { type DynamixConfig } from '@app/core/types/ini';
/**

View File

@@ -5,7 +5,7 @@ import {
WAN_FORWARD_TYPE,
} from '@app/graphql/generated/api/types';
import { type AppDispatch, type RootState } from '@app/store/index';
import { type MyServersConfig } from '@app/types/my-servers-config';
import type { MyServersConfig } from '@app/types/my-servers-config.d.ts';
import { createAsyncThunk } from '@reduxjs/toolkit';
const getDynamicRemoteAccessType = (

View File

@@ -15,7 +15,7 @@ import { FileLoadStatus } from '@app/store/types';
import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer';
import { isFulfilled } from '@reduxjs/toolkit';
import { environment } from '@app/environment';
import { writeFileSync } from 'fs';
import { writeFileSync } from 'node:fs';
const actionIsLoginOrLogout = isFulfilled(logoutUser, loginUser);

View File

@@ -1,34 +1,48 @@
import { startAppListening } from '@app/store/listeners/listener-middleware';
import { subscribeToEvents } from '@app/mothership/subscribe-to-mothership';
import { getMothershipConnectionParams } from '@app/mothership/utils/get-mothership-websocket-headers';
import isEqual from 'lodash/isEqual';
import { GraphQLClient } from '@app/mothership/graphql-client';
import { MinigraphStatus } from '@app/graphql/generated/api/types';
import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status';
import { minigraphLogger } from '@app/core/log';
import { isEqual } from 'lodash';
export const enableMothershipJobsListener = () => startAppListening({
predicate(action, currentState, previousState) {
// This event happens on first app load, or if a user signs out and signs back in, etc
if (!isEqual(getMothershipConnectionParams(currentState), getMothershipConnectionParams(previousState)) && getMothershipConnectionParams(currentState)?.apiKey) {
minigraphLogger.info('Connecting / Reconnecting Mothership Due to Changed Config File or First Load')
return true;
}
export const enableMothershipJobsListener = () =>
startAppListening({
predicate(action, currentState, previousState) {
// This event happens on first app load, or if a user signs out and signs back in, etc
if (
!isEqual(
getMothershipConnectionParams(currentState),
getMothershipConnectionParams(previousState)
) &&
getMothershipConnectionParams(currentState)?.apiKey
) {
minigraphLogger.info(
'Connecting / Reconnecting Mothership Due to Changed Config File or First Load'
);
return true;
}
if (setGraphqlConnectionStatus.match(action) && [MinigraphStatus.PING_FAILURE, MinigraphStatus.PRE_INIT].includes(action.payload.status)) {
minigraphLogger.info('Reconnecting Mothership - PING_FAILURE / PRE_INIT - SetGraphQLConnectionStatus Event')
return true;
}
if (
setGraphqlConnectionStatus.match(action) &&
[MinigraphStatus.PING_FAILURE, MinigraphStatus.PRE_INIT].includes(action.payload.status)
) {
minigraphLogger.info(
'Reconnecting Mothership - PING_FAILURE / PRE_INIT - SetGraphQLConnectionStatus Event'
);
return true;
}
return false;
}, async effect(_, { getState }) {
await GraphQLClient.clearInstance();
if (getMothershipConnectionParams(getState())?.apiKey) {
const client = GraphQLClient.createSingletonInstance();
if (client) {
await subscribeToEvents(getState().config.remote.apikey);
}
}
},
});
return false;
},
async effect(_, { getState }) {
await GraphQLClient.clearInstance();
if (getMothershipConnectionParams(getState())?.apiKey) {
const client = GraphQLClient.createSingletonInstance();
if (client) {
await subscribeToEvents(getState().config.remote.apikey);
}
}
},
});

View File

@@ -4,8 +4,7 @@ import { getServers } from '@app/graphql/schema/utils';
import { isAPIStateDataFullyLoaded } from '@app/mothership/graphql-client';
import { startAppListening } from '@app/store/listeners/listener-middleware';
import { FileLoadStatus } from '@app/store/types';
import isEqual from 'lodash/isEqual';
import { isEqual } from 'lodash';
export const enableServerStateListener = () =>
startAppListening({

View File

@@ -1,28 +1,27 @@
import { parseConfig } from '@app/core/utils/misc/parse-config';
import {
type MyServersConfig,
type MyServersConfigMemory,
} from '@app/types/my-servers-config';
import type {
MyServersConfig,
MyServersConfigMemory,
} from '@app/types/my-servers-config.d.ts';
import {
createAsyncThunk,
createSlice,
type PayloadAction,
} from '@reduxjs/toolkit';
import { access } from 'fs/promises';
import merge from 'lodash/merge';
import { access } from 'node:fs/promises';
import { FileLoadStatus } from '@app/store/types';
import { F_OK } from 'constants';
import { type RecursivePartial } from '@app/types';
import { F_OK } from 'node:constants';
import type { RecursivePartial } from '@app/types/index.d.ts';
import { DynamicRemoteAccessType, MinigraphStatus, type Owner } from '@app/graphql/generated/api/types';
import { type RootState } from '@app/store';
import { randomBytes } from 'crypto';
import { randomBytes } from 'node:crypto';
import { logger } from '@app/core/log';
import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status';
import { getWriteableConfig } from '@app/core/utils/files/config-file-normalizer';
import { writeFileSync } from 'fs';
import { writeFileSync } from 'node:fs';
import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer';
import { PUBSUB_CHANNEL, pubsub } from '@app/core/pubsub';
import { isEqual } from 'lodash';
import { isEqual, merge } from 'lodash';
import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access';
export type SliceState = {

View File

@@ -2,11 +2,11 @@ import {
createSlice,
type PayloadAction,
} from '@reduxjs/toolkit';
import merge from 'lodash/merge';
import { FileLoadStatus } from '@app/store/types';
import { type RecursivePartial } from '@app/types';
import type { RecursivePartial } from '@app/types/index.d.ts';
import { type DynamixConfig } from '@app/core/types/ini';
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file';
import { merge } from 'lodash';
export type SliceState = {
status: FileLoadStatus;

View File

@@ -1,7 +1,7 @@
import { FileLoadStatus, StateFileKey, type StateFileToIniParserMap } from '@app/store/types';
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import merge from 'lodash/merge';
import { join } from 'path';
import { merge } from 'lodash';
import { join } from 'node:path';
import { emhttpLogger } from '@app/core/log';
import { parseConfig } from '@app/core/utils/misc/parse-config';
import { type Devices } from '@app/core/types/states/devices';

View File

@@ -1,28 +1,23 @@
import { createSlice } from '@reduxjs/toolkit';
import { join, resolve as resolvePath } from 'path';
import { join, resolve as resolvePath } from 'node:path';
const initialState = {
core: __dirname,
'unraid-api-base': '/usr/local/bin/unraid-api/' as const,
'unraid-data': resolvePath(
process.env.PATHS_UNRAID_DATA ??
('/boot/config/plugins/dynamix.my.servers/data/' as const)
process.env.PATHS_UNRAID_DATA ?? ('/boot/config/plugins/dynamix.my.servers/data/' as const)
),
'docker-autostart': '/var/lib/docker/unraid-autostart' as const,
'docker-socket': '/var/run/docker.sock' as const,
'parity-checks': '/boot/config/parity-checks.log' as const,
htpasswd: '/etc/nginx/htpasswd' as const,
'emhttpd-socket': '/var/run/emhttpd.socket' as const,
states: resolvePath(
process.env.PATHS_STATES ?? ('/usr/local/emhttp/state/' as const)
),
states: resolvePath(process.env.PATHS_STATES ?? ('/usr/local/emhttp/state/' as const)),
'dynamix-base': resolvePath(
process.env.PATHS_DYNAMIX_BASE ??
('/boot/config/plugins/dynamix/' as const)
process.env.PATHS_DYNAMIX_BASE ?? ('/boot/config/plugins/dynamix/' as const)
),
'dynamix-config': resolvePath(
process.env.PATHS_DYNAMIX_CONFIG ??
('/boot/config/plugins/dynamix/dynamix.cfg' as const)
process.env.PATHS_DYNAMIX_CONFIG ?? ('/boot/config/plugins/dynamix/dynamix.cfg' as const)
),
'myservers-base': '/boot/config/plugins/dynamix.my.servers/' as const,
'myservers-config': resolvePath(
@@ -30,22 +25,19 @@ const initialState = {
('/boot/config/plugins/dynamix.my.servers/myservers.cfg' as const)
),
'myservers-config-states': join(
resolvePath(
process.env.PATHS_STATES ?? ('/usr/local/emhttp/state/' as const)
),
resolvePath(process.env.PATHS_STATES ?? ('/usr/local/emhttp/state/' as const)),
'myservers.cfg' as const
),
'myservers-env': '/boot/config/plugins/dynamix.my.servers/env' as const,
'myservers-keepalive':
process.env.PATHS_MY_SERVERS_FB ?? ('/boot/config/plugins/dynamix.my.servers/fb_keepalive' as const),
'keyfile-base': resolvePath(
process.env.PATHS_KEYFILE_BASE ?? ('/boot/config' as const)
),
'machine-id': resolvePath(
process.env.PATHS_MACHINE_ID ?? ('/var/lib/dbus/machine-id' as const)
),
process.env.PATHS_MY_SERVERS_FB ??
('/boot/config/plugins/dynamix.my.servers/fb_keepalive' as const),
'keyfile-base': resolvePath(process.env.PATHS_KEYFILE_BASE ?? ('/boot/config' as const)),
'machine-id': resolvePath(process.env.PATHS_MACHINE_ID ?? ('/var/lib/dbus/machine-id' as const)),
'log-base': resolvePath('/var/log/unraid-api/' as const),
'var-run': '/var/run' as const,
// contains sess_ files that correspond to authenticated user sessions
'auth-sessions': '/var/lib/php' as const,
};
export const paths = createSlice({

View File

@@ -0,0 +1,57 @@
import { logger } from '@app/core/log';
import { getKeyFile } from '@app/core/utils/misc/get-key-file';
import { FileLoadStatus } from '@app/store/types';
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { format } from 'node:util';
import type { RootState } from '@app/store';
import { merge } from 'lodash';
export type SliceState = {
status: FileLoadStatus;
keyFile: string | null;
};
const initialState = {
status: FileLoadStatus.UNLOADED,
keyFile: null,
};
export const loadRegistrationKey = createAsyncThunk<{ keyFile: string | null }, void, { state: RootState }>('registration/load-registration-key', async (_, { getState }) => {
try {
logger.trace('Loading registration key file');
return {
keyFile: await getKeyFile(getState()),
};
} catch (error: unknown) {
if (!(error instanceof Error)) throw new Error(format('Failed loading registration key with unknown error "%s"', String(error)));
logger.error('Failed loading registration key with "%s"', error.message);
}
return {
keyFile: null,
};
});
export const registration = createSlice({
name: 'registration',
initialState,
reducers: {
updateRegistrationState(state, action: PayloadAction<Partial<{ keyFile: string }>>) {
return merge(state, action.payload);
},
},
extraReducers(builder) {
builder.addCase(loadRegistrationKey.pending, (state) => {
state.status = FileLoadStatus.LOADING;
});
builder.addCase(loadRegistrationKey.fulfilled, (state, action) => {
merge(state, action.payload, { status: FileLoadStatus.LOADED });
});
builder.addCase(loadRegistrationKey.rejected, (state, action) => {
merge(state, action.payload, { status: FileLoadStatus.FAILED_LOADING });
});
},
});

View File

@@ -5,9 +5,9 @@ import { syncRegistration } from '@app/store/sync/registration-sync';
import { syncInfoApps } from '@app/store/sync/info-apps-sync';
import { setupConfigPathWatch } from '@app/store/watch/config-watch';
import { NODE_ENV } from '@app/environment';
import { writeFileSync } from 'fs';
import { writeFileSync } from 'node:fs';
import { isEqual } from 'lodash';
import { join } from 'path';
import { join } from 'node:path';
export const startStoreSync = async () => {
// The last state is stored so we don't end up in a loop of writing -> reading -> writing

View File

@@ -0,0 +1,21 @@
import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer';
import { FileLoadStatus } from '@app/store/types';
import { type ConfigType, getWriteableConfig } from '@app/core/utils/files/config-file-normalizer';
import { store } from '@app/store';
import { writeFileSync } from 'node:fs';
import { logger } from '@app/core/log';
export const writeConfigSync = (mode: ConfigType) => {
const { config, paths } = store.getState();
if (config.status !== FileLoadStatus.LOADED) {
logger.warn('Configs not loaded, unable to write sync');
return;
}
const writeableConfig = getWriteableConfig(config, mode);
const path = mode === 'flash' ? paths['myservers-config'] : paths['myservers-config-states'];
const serializedConfig = safelySerializeObjectToIni(writeableConfig);
writeFileSync(path, serializedConfig);
};

View File

@@ -2,7 +2,7 @@ import { logger } from '@app/core/log';
import { PUBSUB_CHANNEL, pubsub } from '@app/core/pubsub';
import { store } from '@app/store';
import { FileLoadStatus, type StoreSubscriptionHandler } from '@app/store/types';
import isEqual from 'lodash/isEqual';
import { isEqual } from 'lodash';
export type RegistrationEvent = {
registration: {

View File

@@ -0,0 +1,28 @@
import { getters, store } from '@app/store';
import { watch } from 'chokidar';
import { loadConfigFile, logoutUser } from '@app/store/modules/config';
import { logger } from '@app/core/log';
import { existsSync, writeFileSync } from 'node:fs';
export const setupConfigPathWatch = () => {
const myServersConfigPath = getters.paths()?.['myservers-config'];
if (myServersConfigPath) {
logger.info('Watch Setup on Config Path: %s', myServersConfigPath);
if (!existsSync(myServersConfigPath)) {
writeFileSync(myServersConfigPath, '', 'utf-8');
}
const watcher = watch(myServersConfigPath, {
persistent: true,
ignoreInitial: false,
usePolling: process.env.NODE_ENV === 'development',
}).on('change', async () => {
await store.dispatch(loadConfigFile());
}).on('unlink', async () => {
watcher.close();
setupConfigPathWatch();
store.dispatch(logoutUser({ reason: 'Config File was Deleted'}))
});
} else {
logger.error('[FATAL] Failed to setup watch on My Servers Config (Could Not Read Config Path)');
}
};

View File

@@ -3,7 +3,7 @@ import { emhttpLogger } from '@app/core/log';
import { watch, type FSWatcher, type WatchOptions } from 'chokidar';
import { getters, store } from '@app/store';
import { StateFileKey } from '@app/store/types';
import { parse, join } from 'path';
import { parse, join } from 'node:path';
import { loadSingleStateFile } from '@app/store/modules/emhttp';
import { CHOKIDAR_USEPOLLING } from '@app/environment';

View File

@@ -0,0 +1,80 @@
import { type CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
import { apiLogger } from '@app/core/log';
import { getAllowedOrigins } from '@app/common/allowed-origins';
import { BYPASS_CORS_CHECKS } from '@app/environment';
import { GraphQLError } from 'graphql';
import { type CookieService } from '../auth/cookie.service';
/**
* Returns whether the origin is allowed to access the API.
*
* @throws GraphQLError if the origin is not in the list of allowed origins
* and `BYPASS_CORS_CHECKS` flag is not set.
*/
// note: don't make this function synchronous. throwing will then crash the server.
export async function isOriginAllowed(origin: string | undefined) {
const allowedOrigins = getAllowedOrigins();
if (origin && allowedOrigins.includes(origin)) {
return true;
} else {
apiLogger.debug(`Origin not in allowed origins: ${origin}`);
if (BYPASS_CORS_CHECKS) {
return true;
}
throw new GraphQLError(
'The CORS policy for this site does not allow access from the specified Origin.'
);
}
}
/**------------------------------------------------------------------------
* ? Fastify Cors Config
*
* The fastify cors configuration function is very different from express,
* but Nest.js doesn't have clear docs or types describing this so I'm
* documenting it here.
*
* This takes a fastify app instance and returns a cors config function, instead
* of just the cors config function (which is nest's default behavior).
*------------------------------------------------------------------------**/
/**
* A wrapper function for the fastify CORS configuration, which
* takes a CookieService (i.e. a singleton from Nest.js) and returns a
* fastify CORS config function. This function:
*
* Dynamically determines the CORS config for a request.
*
* - Expects any cookies to be parsed & available on the `cookies` property of the request.
*
* If the request contains a valid unraid session cookie, it is allowed to access
* the API from any origin. Otherwise, the origin must be explicitly listed in
* the `allowedOrigins` config option, or the `BYPASS_PERMISSION_CHECKS` flag
* must be set.
*/
export const configureFastifyCors =
(service: CookieService) =>
// this is the function that nestApp.enableCors() needs when configured to use fastify
() =>
/**
* Our CORS handler function. It dynamically determines the CORS config for a request.
*
* @param req the request object
* @param callback the callback to call with the CORS options
*/
(req: any, callback: (error: Error | null, options: CorsOptions) => void) => {
const { cookies } = req;
if (typeof cookies === 'object') {
service.hasValidAuthCookie(cookies).then((isValid) => {
if (isValid) {
callback(null, { origin: true });
} else {
callback(null, { origin: isOriginAllowed });
}
});
} else {
callback(null, { origin: isOriginAllowed });
}
};

View File

@@ -3,9 +3,15 @@ import { AuthService } from './auth.service';
import { UsersModule } from '@app/unraid-api/users/users.module';
import { PassportModule } from '@nestjs/passport';
import { ServerHeaderStrategy } from '@app/unraid-api/auth/header.strategy';
import { CookieService, SESSION_COOKIE_CONFIG } from './cookie.service';
@Module({
imports: [UsersModule, PassportModule],
providers: [AuthService, ServerHeaderStrategy],
providers: [
AuthService,
ServerHeaderStrategy,
CookieService,
{ provide: SESSION_COOKIE_CONFIG, useValue: CookieService.defaultOpts() },
],
})
export class AuthModule {}

View File

@@ -0,0 +1,67 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { CookieService, SESSION_COOKIE_CONFIG } from './cookie.service';
import { describe, it, beforeAll, afterAll } from 'vitest';
import { emptyDir, ensureFile } from 'fs-extra';
describe.concurrent('CookieService', () => {
let service: CookieService;
const sessionDir = '/tmp/php/sessions';
// helper to create a session file
function makeSession(sessionId: string, cookieService: CookieService = service) {
const path = cookieService.getSessionFilePath(sessionId);
return ensureFile(path);
}
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CookieService,
{ provide: SESSION_COOKIE_CONFIG, useValue: { namePrefix: 'unraid_', sessionDir } },
],
}).compile();
service = module.get<CookieService>(CookieService);
await emptyDir(sessionDir);
});
afterAll(async () => {
await emptyDir(sessionDir);
});
it('has completed test setup', ({ expect }) => {
expect(service).toBeDefined();
expect(service.opts.sessionDir).toEqual(sessionDir);
expect(service.opts.namePrefix).toEqual('unraid_');
});
it('handles session names robustly', ({ expect }) => {
const session = (name?: unknown) => service.getSessionFilePath(name as string);
expect(session('foo')).toEqual('/tmp/php/sessions/sess_foo');
expect(session('')).toEqual('/tmp/php/sessions/sess_');
expect(session(null)).toEqual('/tmp/php/sessions/sess_null');
expect(session(undefined)).toEqual('/tmp/php/sessions/sess_undefined');
expect(session(1)).toEqual('/tmp/php/sessions/sess_1');
expect(session(1.0)).toEqual('/tmp/php/sessions/sess_1');
expect(session(1.1)).toEqual('/tmp/php/sessions/sess_1.1');
expect(session({})).toEqual('/tmp/php/sessions/sess_[object Object]');
expect(session(['foo', 'bar'])).toEqual('/tmp/php/sessions/sess_foo,bar');
expect(session('foo/bar')).toEqual('/tmp/php/sessions/sess_foo/bar');
});
it('can read an existing session & reject a non-existent one', async ({ expect }) => {
const sessionId = '123abc';
expect(await service.hasValidAuthCookie({ unraid_session: sessionId })).toBe(false);
await makeSession(sessionId);
expect(await service.hasValidAuthCookie({ unraid_session: sessionId })).toBe(true);
});
it('can recognize session cookies', async ({ expect }) => {
const sessionId = '123abcF00';
await makeSession(sessionId);
expect(await service.hasValidAuthCookie({ unraid: sessionId })).toBe(false);
expect(await service.hasValidAuthCookie({ unraid_: sessionId })).toBe(true);
expect(await service.hasValidAuthCookie({ unraid_0: sessionId })).toBe(true);
expect(await service.hasValidAuthCookie({ unraid_session: sessionId })).toBe(true);
});
});

View File

@@ -0,0 +1,69 @@
import { fileExists } from '@app/core/utils/files/file-exists';
import { getters } from '@app/store';
import { batchProcess } from '@app/utils';
import { Injectable, Inject } from '@nestjs/common';
import { join } from 'node:path';
/** token for dependency injection of a session cookie options object */
export const SESSION_COOKIE_CONFIG = 'SESSION_COOKIE_CONFIG';
type SessionCookieConfig = {
namePrefix: string;
sessionDir: string;
};
@Injectable()
export class CookieService {
constructor(
@Inject(SESSION_COOKIE_CONFIG) readonly opts: SessionCookieConfig = CookieService.defaultOpts()
) {}
/**
* @returns new SessionCookieOptions with `namePrefix: 'unraid_', sessionDir: '/var/lib/php'`
*/
static defaultOpts(): SessionCookieConfig {
return { namePrefix: 'unraid_', sessionDir: getters.paths()['auth-sessions'] };
}
/**
* Given a cookies object, returns true if any of the cookies are a valid unraid session cookie.
* @param cookies an object of cookie name => cookie value
* @param opts optional overrides for the session directory & prefix of the session cookie to look for
* @returns true if any of the cookies are a valid unraid session cookie, false otherwise
*/
async hasValidAuthCookie(cookies: object): Promise<boolean> {
const { data } = await batchProcess(Object.entries(cookies), ([cookieName, cookieValue]) =>
this.isValidAuthCookie(String(cookieName), String(cookieValue))
);
return data.some((valid) => valid);
}
/**
* Checks if a given details point to a valid unraid session cookie.
*
* A valid cookie is one where the name starts with the configured prefix
* and the value corresponds to an existing session file on disk.
*
* @param cookieName the name of the cookie to check
* @param cookieValue the value of the cookie to check
* @returns true if the cookie is valid, false otherwise
*/
private async isValidAuthCookie(cookieName: string, cookieValue: string): Promise<boolean> {
const { namePrefix } = this.opts;
if (!cookieName.startsWith(namePrefix)) {
return false;
}
return fileExists(this.getSessionFilePath(cookieValue));
}
/**
* Given a session id, returns the full path to the session file on disk.
*
* @param sessionId the session id, as read from the session cookie.
* @param basePath path to the directory of session files.
* @returns the full path to the session file on disk.
*/
public getSessionFilePath(sessionId: string): string {
return join(this.opts.sessionDir, `sess_${sessionId}`);
}
}

View File

@@ -4,20 +4,17 @@ import { Injectable, Logger } from '@nestjs/common';
import { AuthService } from './auth.service';
@Injectable()
export class ServerHeaderStrategy extends PassportStrategy(
Strategy,
'server-http-header'
) {
export class ServerHeaderStrategy extends PassportStrategy(Strategy, 'server-http-header') {
static key = 'server-http-header';
private readonly logger = new Logger(ServerHeaderStrategy.name);
constructor(private readonly authService: AuthService) {
constructor(
private readonly authService: AuthService,
) {
super({ header: 'x-api-key', passReqToCallback: false });
}
public validate = async (
apiKey: string
): Promise<any> => {
public validate = async (apiKey: string): Promise<any> => {
this.logger.debug('Validating API key');
const user = await this.authService.validateUser(apiKey);

View File

@@ -4,7 +4,7 @@ import { convertToFuzzyTime } from '@app/mothership/utils/convert-to-fuzzy-time'
import { getters } from '@app/store/index';
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { readFile, writeFile } from 'fs/promises';
import { readFile, writeFile } from 'node:fs/promises';
@Injectable()
export class WriteFlashFileService {

View File

@@ -1,6 +1,6 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { WriteFlashFileService } from './write-flash-file.service';
import { readFileSync, writeFileSync } from 'fs';
import { readFileSync, writeFileSync } from 'node:fs';
import { getters } from '@app/store/index';
describe('WriteFlashFileService', () => {

View File

@@ -0,0 +1,40 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`NotificationsService legacy script compatibility > yields correct cli args for alerts 1`] = `
""/usr/local/emhttp/webGui/scripts/notify" -i alert -e "Test Notification !@#$%^&*()_+={}[]|:;\\"'<>,.?/~\`" -s "Test Subject
🚀💻🛠️" -d "Test Description with special characters
©®™✓✓✓—“”‘’" -l "https://unraid.net/?query=param&special=💡🔥✨""
`;
exports[`NotificationsService legacy script compatibility > yields correct cli args for alerts 2`] = `
"Command failed with ENOENT: /usr/local/emhttp/webGui/scripts/notify -i alert -e Test Notification !@#$%^&*()_+={}[]|:;"'<>,.?/~\` -s Test Subject
🚀💻🛠️ -d Test Description with special characters
©®™✓✓✓—“”‘’ -l https://unraid.net/?query=param&special=💡🔥✨
spawn /usr/local/emhttp/webGui/scripts/notify ENOENT"
`;
exports[`NotificationsService legacy script compatibility > yields correct cli args for normals 1`] = `
""/usr/local/emhttp/webGui/scripts/notify" -i normal -e "Test Notification !@#$%^&*()_+={}[]|:;\\"'<>,.?/~\`" -s "Test Subject
🚀💻🛠️" -d "Test Description with special characters
©®™✓✓✓—“”‘’" -l "https://unraid.net/?query=param&special=💡🔥✨""
`;
exports[`NotificationsService legacy script compatibility > yields correct cli args for normals 2`] = `
"Command failed with ENOENT: /usr/local/emhttp/webGui/scripts/notify -i normal -e Test Notification !@#$%^&*()_+={}[]|:;"'<>,.?/~\` -s Test Subject
🚀💻🛠️ -d Test Description with special characters
©®™✓✓✓—“”‘’ -l https://unraid.net/?query=param&special=💡🔥✨
spawn /usr/local/emhttp/webGui/scripts/notify ENOENT"
`;
exports[`NotificationsService legacy script compatibility > yields correct cli args for warnings 1`] = `
""/usr/local/emhttp/webGui/scripts/notify" -i warning -e "Test Notification !@#$%^&*()_+={}[]|:;\\"'<>,.?/~\`" -s "Test Subject
🚀💻🛠️" -d "Test Description with special characters
©®™✓✓✓—“”‘’" -l "https://unraid.net/?query=param&special=💡🔥✨""
`;
exports[`NotificationsService legacy script compatibility > yields correct cli args for warnings 2`] = `
"Command failed with ENOENT: /usr/local/emhttp/webGui/scripts/notify -i warning -e Test Notification !@#$%^&*()_+={}[]|:;"'<>,.?/~\` -s Test Subject
🚀💻🛠️ -d Test Description with special characters
©®™✓✓✓—“”‘’ -l https://unraid.net/?query=param&special=💡🔥✨
spawn /usr/local/emhttp/webGui/scripts/notify ENOENT"
`;

View File

@@ -1,7 +1,7 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { NotificationsService } from './notifications.service';
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
import { existsSync } from 'fs';
import { existsSync } from 'node:fs';
import {
Importance,
type NotificationData,
@@ -12,7 +12,9 @@ import {
type NotificationFilter,
} from '@app/graphql/generated/api/types';
import { NotificationSchema } from '@app/graphql/generated/api/operations';
import { mkdir } from 'fs/promises';
import { mkdir } from 'node:fs/promises';
import { type NotificationIni } from '@app/core/types/states/notification';
import { execa } from 'execa';
// defined outside `describe` so it's defined inside the `beforeAll`
// needed to mock the dynamix import
@@ -183,8 +185,12 @@ describe.sequential('NotificationsService', () => {
});
it('generates unique ids', async () => {
const notifications = await Promise.all([...new Array(100)].map(() => createNotification()));
const notificationIds = new Set(notifications.map((notification) => notification.id));
const notifications = await Promise.all(
// we break the "rules" here to speed up this test by ~450ms
// @ts-expect-error makeNotificationId is private
[...new Array(100)].map(() => service.makeNotificationId('test event'))
);
const notificationIds = new Set(notifications);
expect(notificationIds.size).toEqual(notifications.length);
});
@@ -363,3 +369,52 @@ describe.sequential('NotificationsService', () => {
expect.soft(overview.archive.total).toEqual(3);
});
});
describe.concurrent('NotificationsService legacy script compatibility', () => {
let service: NotificationsService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [NotificationsService],
}).compile();
service = module.get<NotificationsService>(NotificationsService);
});
it.for([['normal'], ['warning'], ['alert']] as const)(
'yields correct cli args for %ss',
async ([importance], { expect }) => {
const notification: NotificationIni = {
event: 'Test Notification !@#$%^&*()_+={}[]|:;"\'<>,.?/~`',
subject: 'Test Subject \t\n🚀💻🛠',
description: `Test Description with special characters \t\n©®™✓✓✓—“”`,
importance,
link: 'https://unraid.net/?query=param&special=💡🔥✨',
timestamp: new Date().toISOString(),
};
const [cmd, args] = service.getLegacyScriptArgs(notification);
expect(args).toContain('-i');
expect(args).toContain('-e');
expect(args).toContain('-s');
expect(args).toContain('-d');
expect(args).toContain('-l');
expect(args).toContain(notification.event);
expect(args).toContain(notification.subject);
expect(args).toContain(notification.description);
expect(args).toContain(importance);
expect(args).toContain(notification.link);
const result = await execa(cmd, args, { reject: false });
expect.soft(result.escapedCommand).toMatchSnapshot();
if (result.failed) {
// @ts-expect-error this is correct; `execa`'s return type just isn't comprehensive
// see https://github.com/sindresorhus/execa/blob/main/docs/errors.md#error-message
//
//* we use a snapshot because the script should only fail when it doesn't exist (ENOENT)
expect(result.message).toMatchSnapshot();
}
}
);
});

View File

@@ -12,8 +12,8 @@ import {
} from '@app/graphql/generated/api/types';
import { getters } from '@app/store';
import { Injectable } from '@nestjs/common';
import { readdir, rename, unlink, writeFile } from 'fs/promises';
import { basename, join } from 'path';
import { readdir, rename, unlink, writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { Logger } from '@nestjs/common';
import { batchProcess, isFulfilled, isRejected, unraidTimestamp } from '@app/utils';
import { FSWatcher, watch } from 'chokidar';
@@ -23,6 +23,7 @@ import { encode as encodeIni } from 'ini';
import { v7 as uuidv7 } from 'uuid';
import { CHOKIDAR_USEPOLLING } from '@app/environment';
import { emptyDir } from 'fs-extra';
import { execa } from 'execa';
@Injectable()
export class NotificationsService {
@@ -178,19 +179,48 @@ export class NotificationsService {
public async createNotification(data: NotificationData): Promise<Notification> {
const id: string = await this.makeNotificationId(data.title);
const path = join(this.paths().UNREAD, id);
const fileData = this.makeNotificationFileData(data);
this.logger.debug(`[createNotification] FileData: ${JSON.stringify(fileData, null, 4)}`);
const ini = encodeIni(fileData);
// this.logger.debug(`[createNotification] INI: ${ini}`);
await writeFile(path, ini);
// await this.addToOverview(notification);
// make sure both NOTIFICATION_ADDED and NOTIFICATION_OVERVIEW are fired
try {
const [command, args] = this.getLegacyScriptArgs(fileData);
await execa(command, args);
} catch (error) {
// manually write the file if the script fails
this.logger.debug(`[createNotification] legacy notifier failed: ${error}`);
this.logger.verbose(`[createNotification] Writing: ${JSON.stringify(fileData, null, 4)}`);
const path = join(this.paths().UNREAD, id);
const ini = encodeIni(fileData);
// this.logger.debug(`[createNotification] INI: ${ini}`);
await writeFile(path, ini);
}
return this.notificationFileToGqlNotification({ id, type: NotificationType.UNREAD }, fileData);
}
/**
* Given a NotificationIni, returns a tuple containing the command and arguments to be
* passed to the legacy notifier script.
*
* The tuple represents a cli command to create an unraid notification.
*
* @param notification The notification to be converted to command line arguments.
* @returns A 2-element tuple containing the legacy notifier command and arguments.
*/
public getLegacyScriptArgs(notification: NotificationIni): [string, string[]] {
const { event, subject, description, link, importance } = notification;
const args = [
['-i', importance],
['-e', event],
['-s', subject],
['-d', description],
];
if (link) {
args.push(['-l', link]);
}
return ['/usr/local/emhttp/webGui/scripts/notify', args.flat()];
}
private async makeNotificationId(eventTitle: string, replacement = '_'): Promise<string> {
const { default: filenamify } = await import('filenamify');
const allWhitespace = /\s+/g;

View File

@@ -2,50 +2,31 @@ import { NestFactory } from '@nestjs/core';
import { LoggerErrorInterceptor, Logger as PinoLogger } from 'nestjs-pino';
import { AppModule } from './app/app.module';
import Fastify from 'fastify';
import {
FastifyAdapter,
type NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { type CorsOptionsDelegate } from 'cors';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { getAllowedOrigins } from '@app/common/allowed-origins';
import { HttpExceptionFilter } from '@app/unraid-api/exceptions/http-exceptions.filter';
import { GraphQLError } from 'graphql';
import { GraphQLExceptionsFilter } from '@app/unraid-api/exceptions/graphql-exceptions.filter';
import { BYPASS_PERMISSION_CHECKS, PORT } from '@app/environment';
import { PORT } from '@app/environment';
import { type FastifyInstance } from 'fastify';
import { type Server, type IncomingMessage, type ServerResponse } from 'http';
import { apiLogger } from '@app/core/log';
export const corsOptionsDelegate: CorsOptionsDelegate = async (
origin: string | undefined
) => {
const allowedOrigins = getAllowedOrigins();
if (origin && allowedOrigins.includes(origin)) {
return true;
} else {
apiLogger.debug(`Origin not in allowed origins: ${origin}`);
if (BYPASS_PERMISSION_CHECKS) {
return true;
}
throw new GraphQLError(
'The CORS policy for this site does not allow access from the specified Origin.'
);
}
};
import fastifyCookie from '@fastify/cookie';
import { configureFastifyCors } from './app/cors';
import { CookieService } from './auth/cookie.service';
export async function bootstrapNestServer(): Promise<NestFastifyApplication> {
const server: FastifyInstance<Server, IncomingMessage, ServerResponse> =
Fastify({
logger: false,
});
const server: FastifyInstance<Server, IncomingMessage, ServerResponse> = Fastify({
logger: false,
});
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(server),
{ cors: { origin: corsOptionsDelegate }, bufferLogs: true }
);
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(server), {
bufferLogs: true,
});
app.register(fastifyCookie); // parse cookies before cors
const cookieService = app.get(CookieService);
app.enableCors(configureFastifyCors(cookieService));
// Setup Nestjs Pino Logger
app.useLogger(app.get(PinoLogger));
@@ -53,10 +34,7 @@ export async function bootstrapNestServer(): Promise<NestFastifyApplication> {
app.flushLogs();
apiLogger.debug('Starting Nest Server on Port / Path: %s', PORT);
app.useGlobalFilters(
new GraphQLExceptionsFilter(),
new HttpExceptionFilter()
);
app.useGlobalFilters(new GraphQLExceptionsFilter(), new HttpExceptionFilter());
await app.init();
if (Number.isNaN(parseInt(PORT))) {

View File

@@ -51,7 +51,7 @@ export async function batchProcess<Input, T>(items: Input[], action: (id: Input)
const processes = items.map(action);
const results = await Promise.allSettled(processes);
const successes = results.filter(isFulfilled);
const successes = results.filter(isFulfilled).map((result) => result.value);
const errors = results.filter(isRejected).map((result) => result.reason);
return {

View File

@@ -1,4 +1,3 @@
import { execSync } from 'child_process';
import 'dotenv/config';
import { defineConfig } from 'tsup';
import { version } from './package.json';

View File

@@ -2,10 +2,7 @@
import { provide } from 'vue';
import { createI18n, I18nInjectionKey } from 'vue-i18n';
import { disableProductionConsoleLogs } from '~/helpers/functions';
import en_US from '~/locales/en_US.json';
disableProductionConsoleLogs();
import en_US from '~/locales/en_US.json';
// import ja from '~/locales/ja.json';
const defaultLocale = 'en_US'; // ja, en_US

View File

@@ -1,10 +1,2 @@
/** Output key + value as string for each item in the object. Adds new line after each item. */
export const OBJ_TO_STR = (obj: object): string => Object.entries(obj).reduce((str, [p, val]) => `${str}${p}: ${val}\n`, '');
/** Removes our dev logs from prod builds */
export const disableProductionConsoleLogs = () => {
if (import.meta.env.PROD && !import.meta.env.VITE_ALLOW_CONSOLE_LOGS) {
console.log = () => {};
console.debug = () => {};
console.info = () => {};
}
};
export const OBJ_TO_STR = (obj: object): string => Object.entries(obj).reduce((str, [p, val]) => `${str}${p}: ${val}\n`, '');

View File

@@ -1,5 +1,8 @@
import { readFileSync } from 'fs';
import { parse } from 'dotenv';
import removeConsole from "vite-plugin-remove-console";
const envConfig = parse(readFileSync('.env'));
console.log('\n');
console.log('==============================');
@@ -46,26 +49,31 @@ export default defineNuxtConfig({
enabled: true,
},
modules: [
'@vueuse/nuxt',
'@pinia/nuxt',
'@nuxtjs/tailwindcss',
'nuxt-custom-elements',
"@nuxt/eslint"
"@vueuse/nuxt",
"@pinia/nuxt",
"@nuxtjs/tailwindcss",
"nuxt-custom-elements",
"@nuxt/eslint",
],
components: [
{ path: '~/components/Brand', prefix: 'Brand' },
{ path: '~/components/ConnectSettings', prefix: 'ConnectSettings' },
{ path: '~/components/Ui', prefix: 'Ui' },
{ path: '~/components/UserProfile', prefix: 'Upc' },
{ path: '~/components/UpdateOs', prefix: 'UpdateOs' },
'~/components',
{ path: "~/components/Brand", prefix: "Brand" },
{ path: "~/components/ConnectSettings", prefix: "ConnectSettings" },
{ path: "~/components/Ui", prefix: "Ui" },
{ path: "~/components/UserProfile", prefix: "Upc" },
{ path: "~/components/UpdateOs", prefix: "UpdateOs" },
"~/components",
],
// typescript: {
// typeCheck: true
// },
vite: {
plugins: [
!process.env.VITE_ALLOW_CONSOLE_LOGS && removeConsole({
includes: ["log", "warn", "error", "info", "debug"],
}),
],
build: {
minify: 'terser',
minify: "terser",
terserOptions: {
mangle: process.env.VITE_ALLOW_CONSOLE_LOGS
? false
@@ -79,51 +87,51 @@ export default defineNuxtConfig({
customElements: {
entries: [
{
name: 'UnraidComponents',
name: "UnraidComponents",
tags: [
{
name: 'UnraidI18nHost',
path: '@/components/I18nHost.ce',
name: "UnraidI18nHost",
path: "@/components/I18nHost.ce",
},
{
name: 'UnraidAuth',
path: '@/components/Auth.ce',
name: "UnraidAuth",
path: "@/components/Auth.ce",
},
{
name: 'UnraidConnectSettings',
path: '@/components/ConnectSettings/ConnectSettings.ce',
name: "UnraidConnectSettings",
path: "@/components/ConnectSettings/ConnectSettings.ce",
},
{
name: 'UnraidDownloadApiLogs',
path: '@/components/DownloadApiLogs.ce',
name: "UnraidDownloadApiLogs",
path: "@/components/DownloadApiLogs.ce",
},
{
name: 'UnraidHeaderOsVersion',
path: '@/components/HeaderOsVersion.ce',
name: "UnraidHeaderOsVersion",
path: "@/components/HeaderOsVersion.ce",
},
{
name: 'UnraidModals',
path: '@/components/Modals.ce',
name: "UnraidModals",
path: "@/components/Modals.ce",
},
{
name: 'UnraidUserProfile',
path: '@/components/UserProfile.ce',
name: "UnraidUserProfile",
path: "@/components/UserProfile.ce",
},
{
name: 'UnraidUpdateOs',
path: '@/components/UpdateOs.ce',
name: "UnraidUpdateOs",
path: "@/components/UpdateOs.ce",
},
{
name: 'UnraidDowngradeOs',
path: '@/components/DowngradeOs.ce',
name: "UnraidDowngradeOs",
path: "@/components/DowngradeOs.ce",
},
{
name: 'UnraidRegistration',
path: '@/components/Registration.ce',
name: "UnraidRegistration",
path: "@/components/Registration.ce",
},
{
name: 'UnraidWanIpCheck',
path: '@/components/WanIpCheck.ce',
name: "UnraidWanIpCheck",
path: "@/components/WanIpCheck.ce",
},
],
},

9
web/package-lock.json generated
View File

@@ -46,7 +46,8 @@
"lodash-es": "^4.17.21",
"nuxt": "^3.11.2",
"nuxt-custom-elements": "^2.0.0-beta.18",
"terser": "^5.31.0"
"terser": "^5.31.0",
"vite-plugin-remove-console": "^2.2.0"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -18917,6 +18918,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/vite-plugin-remove-console": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/vite-plugin-remove-console/-/vite-plugin-remove-console-2.2.0.tgz",
"integrity": "sha512-qgjh5pz75MdE9Kzs8J0kBwaCfifHV0ezRbB9rpGsIOxam+ilcGV7WOk91vFJXquzRmiKrFh3Hxlh0JJWAmXTbQ==",
"dev": true
},
"node_modules/vite-plugin-vue-inspector": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vite-plugin-vue-inspector/-/vite-plugin-vue-inspector-5.1.0.tgz",

View File

@@ -41,7 +41,8 @@
"lodash-es": "^4.17.21",
"nuxt": "^3.11.2",
"nuxt-custom-elements": "^2.0.0-beta.18",
"terser": "^5.31.0"
"terser": "^5.31.0",
"vite-plugin-remove-console": "^2.2.0"
},
"dependencies": {
"@apollo/client": "^3.10.4",