mirror of
https://github.com/unraid/api.git
synced 2026-01-02 22:50:02 -06:00
Compare commits
27 Commits
feat/notif
...
feat/deno
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e030c0604c | ||
|
|
c847bbaf26 | ||
|
|
7a5ff92c72 | ||
|
|
056f2abf74 | ||
|
|
b1409684db | ||
|
|
14d9448e4c | ||
|
|
924fa699eb | ||
|
|
999a8e39eb | ||
|
|
5a1c85d739 | ||
|
|
ba77ff4a4c | ||
|
|
05765495c4 | ||
|
|
f7cccc8c37 | ||
|
|
85e0f7993e | ||
|
|
d5a424ebe1 | ||
|
|
01441961c3 | ||
|
|
836f64d28f | ||
|
|
79bb4e585b | ||
|
|
409e88b727 | ||
|
|
5034a8981a | ||
|
|
e61d9f195d | ||
|
|
b3e213ba04 | ||
|
|
a7ea678683 | ||
|
|
791e16ce52 | ||
|
|
173da0e65b | ||
|
|
287aabfda7 | ||
|
|
d8656cc6b3 | ||
|
|
a3500c9bc9 |
@@ -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
10
api/deno.json
Normal 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"
|
||||
}
|
||||
@@ -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
769
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ test('Returns paths', async () => {
|
||||
"machine-id",
|
||||
"log-base",
|
||||
"var-run",
|
||||
"auth-sessions",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
16
api/src/__test__/store/state-parsers/devices.test.ts
Normal file
16
api/src/__test__/store/state-parsers/devices.test.ts
Normal 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('[]');
|
||||
});
|
||||
81
api/src/__test__/store/state-parsers/network.test.ts
Normal file
81
api/src/__test__/store/state-parsers/network.test.ts
Normal 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,
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
203
api/src/__test__/store/state-parsers/nfs.test.ts
Normal file
203
api/src/__test__/store/state-parsers/nfs.test.ts
Normal 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": [],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
306
api/src/__test__/store/state-parsers/smb.test.ts
Normal file
306
api/src/__test__/store/state-parsers/smb.test.ts
Normal 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": [],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
40
api/src/__test__/store/state-parsers/users.test.ts
Normal file
40
api/src/__test__/store/state-parsers/users.test.ts
Normal 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",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -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
71
api/src/am.ts
Normal 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);
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { format } from 'util';
|
||||
import { format } from 'node:util';
|
||||
import { AppError } from '@app/core/errors/app-error';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
|
||||
39
api/src/core/notifiers/unraid-local.ts
Normal file
39
api/src/core/notifiers/unraid-local.ts
Normal 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'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
3
api/src/core/utils/files/get-extension-from-path.ts
Normal file
3
api/src/core/utils/files/get-extension-from-path.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { extname } from 'node:path';
|
||||
|
||||
export const getExtensionFromPath = (filePath: string): string => extname(filePath);
|
||||
23
api/src/core/utils/files/load-file-from-path.ts
Normal file
23
api/src/core/utils/files/load-file-from-path.ts
Normal 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}`);
|
||||
};
|
||||
9
api/src/core/utils/misc/attempt-read-file-sync.ts
Normal file
9
api/src/core/utils/misc/attempt-read-file-sync.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
20
api/src/core/utils/misc/get-key-file.ts
Normal file
20
api/src/core/utils/misc/get-key-file.ts
Normal 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, '');
|
||||
};
|
||||
23
api/src/core/utils/misc/get-machine-id.ts
Normal file
23
api/src/core/utils/misc/get-machine-id.ts
Normal 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;
|
||||
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
15
api/src/core/utils/write-to-boot.ts
Normal file
15
api/src/core/utils/write-to-boot.ts
Normal 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);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]>;
|
||||
|
||||
65
api/src/graphql/resolvers/query/cloud/check-dns.ts
Normal file
65
api/src/graphql/resolvers/query/cloud/check-dns.ts
Normal 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: '' };
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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({
|
||||
|
||||
57
api/src/store/modules/registration.ts
Normal file
57
api/src/store/modules/registration.ts
Normal 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 });
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
21
api/src/store/sync/config-disk-sync.ts
Normal file
21
api/src/store/sync/config-disk-sync.ts
Normal 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);
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
28
api/src/store/watch/config-watch.ts
Normal file
28
api/src/store/watch/config-watch.ts
Normal 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)');
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
|
||||
80
api/src/unraid-api/app/cors.ts
Normal file
80
api/src/unraid-api/app/cors.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -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 {}
|
||||
|
||||
67
api/src/unraid-api/auth/cookie.service.spec.ts
Normal file
67
api/src/unraid-api/auth/cookie.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
69
api/src/unraid-api/auth/cookie.service.ts
Normal file
69
api/src/unraid-api/auth/cookie.service.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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"
|
||||
`;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { execSync } from 'child_process';
|
||||
import 'dotenv/config';
|
||||
import { defineConfig } from 'tsup';
|
||||
import { version } from './package.json';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`, '');
|
||||
@@ -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
9
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user