mirror of
https://github.com/unraid/api.git
synced 2026-02-18 05:58:28 -06:00
fix: remove login / logout listener (#728)
This commit is contained in:
@@ -6,6 +6,8 @@ FROM node:18.17.1-alpine As development
|
||||
# Install build tools and dependencies
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
# Real PS Command (needed for some dependencies)
|
||||
procps \
|
||||
alpine-sdk \
|
||||
python3 \
|
||||
libvirt-dev \
|
||||
|
||||
154
api/src/__test__/store/modules/config.test.ts
Normal file
154
api/src/__test__/store/modules/config.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { store } from '@app/store';
|
||||
|
||||
test('Before init returns default values for all fields', async () => {
|
||||
const state = store.getState().config;
|
||||
expect(state).toMatchInlineSnapshot(`
|
||||
{
|
||||
"api": {
|
||||
"extraOrigins": "",
|
||||
"version": "",
|
||||
},
|
||||
"connectionStatus": {
|
||||
"minigraph": "PRE_INIT",
|
||||
"upnpStatus": "",
|
||||
},
|
||||
"local": {
|
||||
"2Fa": "",
|
||||
"showT2Fa": "",
|
||||
},
|
||||
"nodeEnv": "test",
|
||||
"notifier": {
|
||||
"apikey": "",
|
||||
},
|
||||
"remote": {
|
||||
"2Fa": "",
|
||||
"accesstoken": "",
|
||||
"allowedOrigins": "",
|
||||
"apikey": "",
|
||||
"avatar": "",
|
||||
"dynamicRemoteAccessType": "DISABLED",
|
||||
"email": "",
|
||||
"idtoken": "",
|
||||
"refreshtoken": "",
|
||||
"regWizTime": "",
|
||||
"upnpEnabled": "",
|
||||
"username": "",
|
||||
"wanaccess": "",
|
||||
"wanport": "",
|
||||
},
|
||||
"status": "UNLOADED",
|
||||
"upc": {
|
||||
"apikey": "",
|
||||
},
|
||||
}
|
||||
`);
|
||||
}, 10_000);
|
||||
|
||||
test('After init returns values from cfg file for all fields', async () => {
|
||||
const { loadConfigFile } = await import('@app/store/modules/config');
|
||||
|
||||
// Load cfg into store
|
||||
await store.dispatch(loadConfigFile());
|
||||
|
||||
// Check if store has cfg contents loaded
|
||||
const state = store.getState().config;
|
||||
expect(state).toMatchObject(
|
||||
expect.objectContaining({
|
||||
api: {
|
||||
extraOrigins: '',
|
||||
version: expect.any(String),
|
||||
},
|
||||
connectionStatus: {
|
||||
minigraph: 'PRE_INIT',
|
||||
upnpStatus: '',
|
||||
},
|
||||
local: {
|
||||
'2Fa': '',
|
||||
showT2Fa: '',
|
||||
},
|
||||
nodeEnv: 'test',
|
||||
notifier: {
|
||||
apikey: 'unnotify_30994bfaccf839c65bae75f7fa12dd5ee16e69389f754c3b98ed7d5',
|
||||
},
|
||||
remote: {
|
||||
'2Fa': '',
|
||||
accesstoken: '',
|
||||
allowedOrigins: '',
|
||||
apikey: '_______________________BIG_API_KEY_HERE_________________________',
|
||||
avatar: 'https://via.placeholder.com/200',
|
||||
dynamicRemoteAccessType: 'DISABLED',
|
||||
email: 'test@example.com',
|
||||
idtoken: '',
|
||||
refreshtoken: '',
|
||||
regWizTime: '1611175408732_0951-1653-3509-FBA155FA23C0',
|
||||
upnpEnabled: 'no',
|
||||
username: 'zspearmint',
|
||||
wanaccess: 'yes',
|
||||
wanport: '8443',
|
||||
},
|
||||
status: 'LOADED',
|
||||
upc: {
|
||||
apikey: 'unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('updateUserConfig merges in changes to current state', async () => {
|
||||
const { loadConfigFile, updateUserConfig } = await import(
|
||||
'@app/store/modules/config'
|
||||
);
|
||||
|
||||
// Load cfg into store
|
||||
await store.dispatch(loadConfigFile());
|
||||
|
||||
// Update store
|
||||
store.dispatch(
|
||||
updateUserConfig({
|
||||
remote: { avatar: 'https://via.placeholder.com/200' },
|
||||
})
|
||||
);
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state).toMatchObject(
|
||||
expect.objectContaining({
|
||||
api: {
|
||||
extraOrigins: '',
|
||||
version: expect.any(String),
|
||||
},
|
||||
connectionStatus: {
|
||||
minigraph: 'PRE_INIT',
|
||||
upnpStatus: '',
|
||||
},
|
||||
local: {
|
||||
'2Fa': '',
|
||||
showT2Fa: '',
|
||||
},
|
||||
nodeEnv: 'test',
|
||||
notifier: {
|
||||
apikey: 'unnotify_30994bfaccf839c65bae75f7fa12dd5ee16e69389f754c3b98ed7d5',
|
||||
},
|
||||
remote: {
|
||||
'2Fa': '',
|
||||
accesstoken: '',
|
||||
allowedOrigins: '',
|
||||
apikey: '_______________________BIG_API_KEY_HERE_________________________',
|
||||
avatar: 'https://via.placeholder.com/200',
|
||||
dynamicRemoteAccessType: 'DISABLED',
|
||||
email: 'test@example.com',
|
||||
idtoken: '',
|
||||
refreshtoken: '',
|
||||
regWizTime: '1611175408732_0951-1653-3509-FBA155FA23C0',
|
||||
upnpEnabled: 'no',
|
||||
username: 'zspearmint',
|
||||
wanaccess: 'yes',
|
||||
wanport: '8443',
|
||||
},
|
||||
status: 'LOADED',
|
||||
upc: {
|
||||
apikey: 'unupc_fab6ff6ffe51040595c6d9ffb63a353ba16cc2ad7d93f813a2e80a5810',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -13,4 +13,5 @@ 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_PERMISSION_CHECKS = process.env.BYPASS_PERMISSION_CHECKS === 'true';
|
||||
export const LOG_CORS = process.env.LOG_CORS === 'true';
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ensurePermission } from '@app/core/utils/index';
|
||||
import { NODE_ENV } from '@app/environment';
|
||||
import { type MutationResolvers } from '@app/graphql/generated/api/types';
|
||||
import { API_KEY_STATUS } from '@app/mothership/api-key/api-key-types';
|
||||
import { validateApiKeyWithKeyServer } from '@app/mothership/api-key/validate-api-key-with-keyserver';
|
||||
import { getters, store } from '@app/store/index';
|
||||
import { setApiKeyState } from '@app/store/modules/apikey';
|
||||
import { loginUser, signIn } from '@app/store/modules/config';
|
||||
import { loginUser } from '@app/store/modules/config';
|
||||
import { FileLoadStatus } from '@app/store/types';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { decodeJwt } from 'jose';
|
||||
@@ -21,7 +21,7 @@ export const connectSignIn: MutationResolvers['connectSignIn'] = async (
|
||||
});
|
||||
|
||||
if (getters.emhttp().status === FileLoadStatus.LOADED) {
|
||||
const result = await validateApiKeyWithKeyServer({
|
||||
const result = NODE_ENV === 'development' ? API_KEY_STATUS.API_KEY_VALID : await validateApiKeyWithKeyServer({
|
||||
apiKey: args.input.apiKey,
|
||||
flashGuid: getters.emhttp().var.flashGuid,
|
||||
});
|
||||
@@ -43,22 +43,14 @@ export const connectSignIn: MutationResolvers['connectSignIn'] = async (
|
||||
) {
|
||||
throw new GraphQLError('Missing User Attributes');
|
||||
}
|
||||
store.dispatch(setApiKeyState(API_KEY_STATUS.API_KEY_VALID));
|
||||
store.dispatch(
|
||||
signIn({
|
||||
apikey: args.input.apiKey,
|
||||
username: userInfo.preferred_username,
|
||||
email: userInfo.email,
|
||||
avatar:
|
||||
typeof userInfo.avatar === 'string' ? userInfo.avatar : '',
|
||||
})
|
||||
);
|
||||
|
||||
// @TODO once we deprecate old sign in method, switch this to do all validation requests
|
||||
await store.dispatch(
|
||||
loginUser({
|
||||
avatar: '',
|
||||
avatar: typeof userInfo.avatar === 'string' ? userInfo.avatar : '',
|
||||
username: userInfo.preferred_username,
|
||||
email: userInfo.email,
|
||||
apikey: args.input.apiKey,
|
||||
})
|
||||
);
|
||||
return true;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission';
|
||||
import { type MutationResolvers } from '@app/graphql/generated/api/types';
|
||||
import { store } from '@app/store/index';
|
||||
import { logoutUser, signOut } from '@app/store/modules/config';
|
||||
import { logoutUser } from '@app/store/modules/config';
|
||||
|
||||
export const connectSignOut: MutationResolvers['connectSignOut'] = async (
|
||||
_,
|
||||
@@ -14,7 +14,6 @@ export const connectSignOut: MutationResolvers['connectSignOut'] = async (
|
||||
action: 'update',
|
||||
});
|
||||
|
||||
store.dispatch(signOut());
|
||||
await store.dispatch(logoutUser({ reason: 'Manual Sign Out With API' }));
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -22,6 +22,8 @@ import { type BaseContext, type ApolloServer } from '@apollo/server';
|
||||
import { setupDynamixConfigWatch } from '@app/store/watch/dynamix-config-watch';
|
||||
import { setupVarRunWatch } from '@app/store/watch/var-run-watch';
|
||||
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file';
|
||||
import { startMiddlewareListeners } from '@app/store/listeners/listener-middleware';
|
||||
import { validateApiKeyIfPresent } from '@app/store/listeners/api-key-listener';
|
||||
|
||||
let server: ApolloServer<BaseContext>;
|
||||
|
||||
@@ -64,14 +66,11 @@ void am(
|
||||
setupRegistrationKeyWatch();
|
||||
|
||||
// Start listening to docker events
|
||||
await setupVarRunWatch();
|
||||
setupVarRunWatch();
|
||||
|
||||
// Start listening to dynamix config file changes
|
||||
setupDynamixConfigWatch();
|
||||
|
||||
// Try and load the HTTP server
|
||||
logger.debug('Starting HTTP server');
|
||||
|
||||
// Disabled until we need the access token to work
|
||||
// TokenRefresh.init();
|
||||
|
||||
@@ -83,6 +82,10 @@ void am(
|
||||
|
||||
PingTimeoutJobs.init();
|
||||
|
||||
startMiddlewareListeners();
|
||||
|
||||
await validateApiKeyIfPresent();
|
||||
|
||||
// On process exit stop HTTP server - this says it supports async but it doesnt seem to
|
||||
exitHook(() => {
|
||||
// If port is unix socket, delete socket before exiting
|
||||
|
||||
25
api/src/mothership/api-key/retry-validate-api-key.ts
Normal file
25
api/src/mothership/api-key/retry-validate-api-key.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { THIRTY_MINUTES_MS } from '@app/consts';
|
||||
import { API_KEY_STATUS } from '@app/mothership/api-key/api-key-types';
|
||||
import { apiKeyCheckJob } from '@app/mothership/jobs/api-key-check-jobs';
|
||||
import { type AppDispatch, type RootState } from '@app/store/index';
|
||||
import { isApiKeyLoading } from '@app/store/getters/index';
|
||||
import pRetry from 'p-retry';
|
||||
import { setApiKeyState } from '@app/store/modules/apikey';
|
||||
import { keyServerLogger } from '@app/core/log';
|
||||
|
||||
export const retryValidateApiKey = async (getState: () => RootState, dispatch: AppDispatch): Promise<void> => {
|
||||
// Start job here
|
||||
if (isApiKeyLoading(getState())) {
|
||||
keyServerLogger.warn('Already running API Key validation, not starting another job');
|
||||
} else {
|
||||
keyServerLogger.info('Starting API Key Validation Job');
|
||||
dispatch(setApiKeyState(API_KEY_STATUS.PENDING_VALIDATION));
|
||||
await pRetry(async count => apiKeyCheckJob(getState, dispatch, count), {
|
||||
retries: 20_000,
|
||||
minTimeout: 2_000,
|
||||
maxTimeout: THIRTY_MINUTES_MS,
|
||||
randomize: true,
|
||||
factor: 2,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { KEYSERVER_VALIDATION_ENDPOINT } from '@app/consts';
|
||||
import { keyServerLogger as ksLog } from '@app/core/log';
|
||||
import { sendFormToKeyServer } from '@app/core/utils/misc/send-form-to-keyserver';
|
||||
import { API_KEY_STATUS } from '@app/mothership/api-key/api-key-types';
|
||||
import { type Response } from 'got';
|
||||
|
||||
/**
|
||||
* Perform a web validation of the API Key
|
||||
* @param state
|
||||
* @returns
|
||||
*/
|
||||
export const validateApiKeyWithKeyServer = async ({ flashGuid, apiKey }: { flashGuid: string; apiKey: string }): Promise<API_KEY_STATUS> => {
|
||||
// If we're still loading config state, just return the config is loading
|
||||
|
||||
ksLog.log('Validating API Key with KeyServer');
|
||||
|
||||
// Send apiKey, etc. to key-server for verification
|
||||
let response: Response<string>;
|
||||
try {
|
||||
response = await sendFormToKeyServer(KEYSERVER_VALIDATION_ENDPOINT, {
|
||||
guid: flashGuid,
|
||||
apikey: apiKey,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
ksLog.addContext('networkError', error);
|
||||
ksLog.error('Caught error reaching Key Server');
|
||||
ksLog.removeContext('networkError');
|
||||
|
||||
return API_KEY_STATUS.NETWORK_ERROR;
|
||||
}
|
||||
|
||||
ksLog.trace('Got response back from key-server while validating API key');
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
ksLog.warn('Error while validating API key with key-server', response);
|
||||
return API_KEY_STATUS.INVALID_KEYSERVER_RESPONSE;
|
||||
}
|
||||
|
||||
// Get response data
|
||||
let data: { valid: boolean };
|
||||
try {
|
||||
data = JSON.parse(response.body);
|
||||
} catch (error: unknown) {
|
||||
ksLog.warn('Failed to parse Keyserver response body', error);
|
||||
return API_KEY_STATUS.INVALID_KEYSERVER_RESPONSE;
|
||||
}
|
||||
|
||||
const { valid } = data;
|
||||
|
||||
if (typeof valid === 'boolean') {
|
||||
if (valid) {
|
||||
return API_KEY_STATUS.API_KEY_VALID;
|
||||
}
|
||||
|
||||
return API_KEY_STATUS.API_KEY_INVALID;
|
||||
}
|
||||
|
||||
ksLog.warn('Returned data from keyserver appears to be invalid', data);
|
||||
return API_KEY_STATUS.INVALID_KEYSERVER_RESPONSE;
|
||||
};
|
||||
211
api/src/mothership/graphql-client.ts
Normal file
211
api/src/mothership/graphql-client.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { FIVE_MINUTES_MS, MOTHERSHIP_GRAPHQL_LINK } from '@app/consts';
|
||||
import { minigraphLogger } from '@app/core/log';
|
||||
import {
|
||||
getMothershipConnectionParams,
|
||||
getMothershipWebsocketHeaders,
|
||||
} from '@app/mothership/utils/get-mothership-websocket-headers';
|
||||
import { getters, store } from '@app/store';
|
||||
import { type Client, createClient } from 'graphql-ws';
|
||||
import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status';
|
||||
import {
|
||||
ApolloClient,
|
||||
InMemoryCache,
|
||||
type NormalizedCacheObject,
|
||||
} from '@apollo/client/core/core.cjs';
|
||||
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
|
||||
import { MinigraphStatus } from '@app/graphql/generated/api/types';
|
||||
import { API_VERSION } from '@app/environment';
|
||||
import {
|
||||
receivedMothershipPing,
|
||||
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 { isApiKeyValid } from '@app/store/getters/index';
|
||||
import { buildDelayFunction } from '@app/mothership/utils/delay-function';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
const getWebsocketWithMothershipHeaders = () => {
|
||||
return class WebsocketWithMothershipHeaders extends WebSocket {
|
||||
constructor(address, protocols) {
|
||||
super(address, protocols, {
|
||||
headers: getMothershipWebsocketHeaders(),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const delayFn = buildDelayFunction({
|
||||
jitter: true,
|
||||
max: FIVE_MINUTES_MS,
|
||||
initial: 10_000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks that API_VERSION, config.remote.apiKey, emhttp.var.flashGuid, and emhttp.var.version are all set before returning true\
|
||||
* Also checks that the API Key has passed Validation from Keyserver
|
||||
* @returns boolean, are variables set
|
||||
*/
|
||||
export const isAPIStateDataFullyLoaded = (state = store.getState()) => {
|
||||
const { config, emhttp } = state;
|
||||
return (
|
||||
Boolean(API_VERSION) &&
|
||||
Boolean(config.remote.apikey) &&
|
||||
Boolean(emhttp.var.flashGuid) &&
|
||||
Boolean(emhttp.var.version)
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
||||
export class GraphQLClient {
|
||||
public static instance: ApolloClient<NormalizedCacheObject> | null = null;
|
||||
public static client: Client | null = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Get a singleton GraphQL instance (if possible given loaded state)
|
||||
* @returns ApolloClient instance or null, if state is not valid
|
||||
*/
|
||||
public static getInstance(): ApolloClient<NormalizedCacheObject> | null {
|
||||
const isStateValid = isAPIStateDataFullyLoaded() && isApiKeyValid();
|
||||
if (!isStateValid) {
|
||||
minigraphLogger.error(
|
||||
'GraphQL Client is not valid. Returning null for instance'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return GraphQLClient.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is used to create a new Apollo instance (if it is possible to do so)
|
||||
* This is used in order to facilitate a single instance existing at a time
|
||||
* @returns Apollo Instance (if creation was possible)
|
||||
*/
|
||||
public static createSingletonInstance = () => {
|
||||
const isStateValid = isAPIStateDataFullyLoaded() && isApiKeyValid();
|
||||
|
||||
if (!GraphQLClient.instance && isStateValid) {
|
||||
minigraphLogger.debug("Creating a new Apollo Client Instance");
|
||||
GraphQLClient.instance = GraphQLClient.createGraphqlClient();
|
||||
}
|
||||
|
||||
return GraphQLClient.instance;
|
||||
};
|
||||
|
||||
public static clearInstance = async () => {
|
||||
if (this.instance) {
|
||||
this.instance?.stop();
|
||||
}
|
||||
|
||||
if (GraphQLClient.client) {
|
||||
await GraphQLClient.client.dispose();
|
||||
GraphQLClient.client = null;
|
||||
}
|
||||
|
||||
GraphQLClient.instance = null;
|
||||
GraphQLClient.client = null;
|
||||
};
|
||||
|
||||
static createGraphqlClient() {
|
||||
GraphQLClient.client = createClient({
|
||||
url: MOTHERSHIP_GRAPHQL_LINK.replace('http', 'ws'),
|
||||
webSocketImpl: getWebsocketWithMothershipHeaders(),
|
||||
connectionParams: () => getMothershipConnectionParams(),
|
||||
});
|
||||
const wsLink = new GraphQLWsLink(GraphQLClient.client);
|
||||
|
||||
const retryLink = new RetryLink({
|
||||
delay(count, operation, error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes('API Key Invalid')
|
||||
) {
|
||||
void store.dispatch(
|
||||
logoutUser({ reason: 'Invalid API Key on Mothership' })
|
||||
);
|
||||
}
|
||||
|
||||
const getDelay = delayFn(count);
|
||||
store.dispatch(setMothershipTimeout(getDelay));
|
||||
minigraphLogger.info('Delay currently is', getDelay);
|
||||
return getDelay;
|
||||
},
|
||||
attempts: { max: Infinity },
|
||||
});
|
||||
const errorLink = new ErrorLink((handler) => {
|
||||
if (handler.graphQLErrors) {
|
||||
// GQL Error Occurred, we should log and move on
|
||||
minigraphLogger.info(
|
||||
'GQL Error Encountered %o',
|
||||
handler.graphQLErrors
|
||||
);
|
||||
} else if (handler.networkError) {
|
||||
minigraphLogger.error(
|
||||
'Network Error Encountered %s',
|
||||
handler.networkError.message
|
||||
);
|
||||
if (
|
||||
getters.minigraph().status !==
|
||||
MinigraphStatus.ERROR_RETRYING
|
||||
) {
|
||||
store.dispatch(
|
||||
setGraphqlConnectionStatus({
|
||||
status: MinigraphStatus.ERROR_RETRYING,
|
||||
error: handler.networkError.message,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
const apolloClient = new ApolloClient({
|
||||
link: retryLink.concat(errorLink).concat(wsLink),
|
||||
cache: new InMemoryCache(),
|
||||
defaultOptions: {
|
||||
watchQuery: {
|
||||
fetchPolicy: 'no-cache',
|
||||
errorPolicy: 'all',
|
||||
},
|
||||
query: {
|
||||
fetchPolicy: 'no-cache',
|
||||
errorPolicy: 'all',
|
||||
},
|
||||
},
|
||||
});
|
||||
// Maybe a listener to initiate this
|
||||
GraphQLClient.client.on('connecting', () => {
|
||||
store.dispatch(
|
||||
setGraphqlConnectionStatus({
|
||||
status: MinigraphStatus.CONNECTING,
|
||||
error: null,
|
||||
})
|
||||
);
|
||||
minigraphLogger.info(
|
||||
'Connecting to %s',
|
||||
MOTHERSHIP_GRAPHQL_LINK.replace('http', 'ws')
|
||||
);
|
||||
});
|
||||
GraphQLClient.client.on('connected', () => {
|
||||
store.dispatch(
|
||||
setGraphqlConnectionStatus({
|
||||
status: MinigraphStatus.CONNECTED,
|
||||
error: null,
|
||||
})
|
||||
);
|
||||
minigraphLogger.info(
|
||||
'Connected to %s',
|
||||
MOTHERSHIP_GRAPHQL_LINK.replace('http', 'ws')
|
||||
);
|
||||
});
|
||||
|
||||
GraphQLClient.client.on('ping', () => {
|
||||
// Received ping from mothership
|
||||
minigraphLogger.trace('ping');
|
||||
store.dispatch(receivedMothershipPing());
|
||||
});
|
||||
return apolloClient;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextFunction, type Request, type Response } from 'express';
|
||||
import { logger } from '@app/core';
|
||||
import { getAllowedOrigins } from '@app/common/allowed-origins';
|
||||
import { LOG_CORS } from '@app/environment';
|
||||
|
||||
const getOriginGraphqlError = () => ({
|
||||
data: null,
|
||||
@@ -39,13 +40,11 @@ export const originMiddleware = (
|
||||
next();
|
||||
return;
|
||||
} else {
|
||||
logger.addContext('origins', allowedOrigins.join(', '))
|
||||
logger.trace(
|
||||
`Current Origin: ${
|
||||
origin ?? 'undefined'
|
||||
}`
|
||||
);
|
||||
logger.removeContext('origins')
|
||||
if (LOG_CORS) {
|
||||
logger.addContext('origins', allowedOrigins.join(', '));
|
||||
logger.trace(`Current Origin: ${origin ?? 'undefined'}`);
|
||||
logger.removeContext('origins');
|
||||
}
|
||||
}
|
||||
|
||||
// Disallow requests with no origin
|
||||
@@ -56,7 +55,9 @@ export const originMiddleware = (
|
||||
return;
|
||||
}
|
||||
|
||||
logger.trace(`📒 Checking "${origin}" for CORS access.`);
|
||||
if (LOG_CORS) {
|
||||
logger.trace(`📒 Checking "${origin}" for CORS access.`);
|
||||
}
|
||||
|
||||
// Only allow known origins
|
||||
if (!allowedOrigins.includes(origin)) {
|
||||
@@ -68,6 +69,8 @@ export const originMiddleware = (
|
||||
return;
|
||||
}
|
||||
|
||||
logger.trace('✔️ Origin check passed, granting CORS!');
|
||||
if (LOG_CORS) {
|
||||
logger.trace('✔️ Origin check passed, granting CORS!');
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
@@ -48,6 +48,8 @@ watch(configFilePath).on('all', updatePubsub);
|
||||
watch(customImageFilePath).on('all', updatePubsub);
|
||||
|
||||
export const createApolloExpressServer = async () => {
|
||||
// Try and load the HTTP server
|
||||
graphqlLogger.debug('Starting HTTP server');
|
||||
const app = express();
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
@@ -207,10 +209,10 @@ export const createApolloExpressServer = async () => {
|
||||
apolloServerPluginOnExit,
|
||||
ApolloServerPluginDrainHttpServer({ httpServer }),
|
||||
],
|
||||
introspection: GRAPHQL_INTROSPECTION
|
||||
introspection: GRAPHQL_INTROSPECTION,
|
||||
});
|
||||
|
||||
await apolloServer.start()
|
||||
await apolloServer.start();
|
||||
|
||||
app.get('/graphql/api/logs', getLogs);
|
||||
|
||||
@@ -347,7 +349,7 @@ export const createApolloExpressServer = async () => {
|
||||
res.status(error.status ?? 500).send(error);
|
||||
}
|
||||
);
|
||||
|
||||
httpServer.listen(PORT);
|
||||
|
||||
httpServer.listen(PORT);
|
||||
return apolloServer;
|
||||
};
|
||||
|
||||
16
api/src/store/listeners/api-key-listener.ts
Normal file
16
api/src/store/listeners/api-key-listener.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { retryValidateApiKey } from '@app/mothership/api-key/retry-validate-api-key';
|
||||
import { isAPIStateDataFullyLoaded } from '@app/mothership/graphql-client';
|
||||
import { isApiKeyLoading } from '@app/store/getters/index';
|
||||
import { store } from '@app/store/index';
|
||||
|
||||
export const validateApiKeyIfPresent = async () => {
|
||||
const currentState = store.getState();
|
||||
if (
|
||||
currentState.config.remote?.apikey &&
|
||||
currentState.emhttp.var.flashGuid &&
|
||||
isAPIStateDataFullyLoaded(currentState) &&
|
||||
!isApiKeyLoading(currentState)
|
||||
) {
|
||||
await retryValidateApiKey(store.getState, store.dispatch);
|
||||
}
|
||||
};
|
||||
@@ -9,11 +9,6 @@ import { enableUpnpListener } from '@app/store/listeners/upnp-listener';
|
||||
import { enableAllowedOriginListener } from '@app/store/listeners/allowed-origin-listener';
|
||||
import { enableConfigFileListener } from '@app/store/listeners/config-listener';
|
||||
import { enableVersionListener } from '@app/store/listeners/version-listener';
|
||||
import { enableApiKeyListener } from '@app/store/listeners/api-key-listener';
|
||||
import {
|
||||
enableLoginListener,
|
||||
enableLogoutListener,
|
||||
} from '@app/store/listeners/login-logout-listener';
|
||||
import { enableMothershipJobsListener } from '@app/store/listeners/mothership-subscription-listener';
|
||||
import { enableDynamicRemoteAccessListener } from '@app/store/listeners/dynamic-remote-access-listener';
|
||||
import { enableArrayEventListener } from '@app/store/listeners/array-event-listener';
|
||||
@@ -37,18 +32,17 @@ export const addAppListener = addListener as TypedAddListener<
|
||||
AppDispatch
|
||||
>;
|
||||
|
||||
// Begin listening for events
|
||||
enableConfigFileListener('flash')();
|
||||
enableConfigFileListener('memory')();
|
||||
enableLoginListener();
|
||||
enableLogoutListener();
|
||||
enableApiKeyListener();
|
||||
enableUpnpListener();
|
||||
enableAllowedOriginListener();
|
||||
enableVersionListener();
|
||||
enableMothershipJobsListener();
|
||||
enableDynamicRemoteAccessListener();
|
||||
enableArrayEventListener();
|
||||
enableWanAccessChangeListener();
|
||||
enableServerStateListener();
|
||||
enableNotificationPathListener();
|
||||
export const startMiddlewareListeners = () => {
|
||||
// Begin listening for events
|
||||
enableConfigFileListener('flash')();
|
||||
enableConfigFileListener('memory')();
|
||||
enableUpnpListener();
|
||||
enableAllowedOriginListener();
|
||||
enableVersionListener();
|
||||
enableMothershipJobsListener();
|
||||
enableDynamicRemoteAccessListener();
|
||||
enableArrayEventListener();
|
||||
enableWanAccessChangeListener();
|
||||
enableServerStateListener();
|
||||
enableNotificationPathListener();
|
||||
}
|
||||
|
||||
34
api/src/store/modules/apikey.ts
Normal file
34
api/src/store/modules/apikey.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { API_KEY_STATUS } from '@app/mothership/api-key/api-key-types';
|
||||
import { loginUser, logoutUser } from '@app/store/modules/config';
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
interface ApiKeyInitialState {
|
||||
status: API_KEY_STATUS;
|
||||
}
|
||||
|
||||
const initialState: ApiKeyInitialState = {
|
||||
status: API_KEY_STATUS.NO_API_KEY,
|
||||
};
|
||||
|
||||
const apiKey = createSlice({
|
||||
name: 'apiKey',
|
||||
initialState,
|
||||
reducers: {
|
||||
setApiKeyState(state, action: PayloadAction<API_KEY_STATUS>) {
|
||||
state.status = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers(builder) {
|
||||
builder.addCase(loginUser.fulfilled, (state) => {
|
||||
state.status = API_KEY_STATUS.API_KEY_VALID;
|
||||
});
|
||||
builder.addCase(logoutUser.fulfilled, (state) => {
|
||||
state.status = API_KEY_STATUS.NO_API_KEY;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { actions, reducer } = apiKey;
|
||||
|
||||
export const { setApiKeyState } = actions;
|
||||
export const apiKeyReducer = reducer;
|
||||
@@ -70,8 +70,8 @@ export const initialState: SliceState = {
|
||||
} as const;
|
||||
|
||||
export const loginUser = createAsyncThunk<
|
||||
Pick<MyServersConfig['remote'], 'email' | 'avatar' | 'username'>,
|
||||
Pick<MyServersConfig['remote'], 'email' | 'avatar' | 'username'>,
|
||||
Pick<MyServersConfig['remote'], 'email' | 'avatar' | 'username' | 'apikey'>,
|
||||
Pick<MyServersConfig['remote'], 'email' | 'avatar' | 'username' | 'apikey'>,
|
||||
{ state: RootState }
|
||||
>('config/login-user', async (userInfo) => {
|
||||
logger.info('Logging in user: %s', userInfo.username);
|
||||
@@ -263,33 +263,6 @@ export const config = createSlice({
|
||||
setWanAccess(state, action: PayloadAction<'yes' | 'no'>) {
|
||||
state.remote.wanaccess = action.payload;
|
||||
},
|
||||
signIn: (
|
||||
state,
|
||||
action: PayloadAction<
|
||||
Pick<
|
||||
MyServersConfig['remote'],
|
||||
'apikey'
|
||||
> & Partial<Pick<MyServersConfig['remote'],
|
||||
'idtoken' | 'accesstoken' | 'refreshtoken' | 'username' | 'avatar' | 'email'>>
|
||||
>
|
||||
) => {
|
||||
state.remote.apikey = action.payload.apikey;
|
||||
state.remote.idtoken = action.payload.idtoken ?? '';
|
||||
state.remote.accesstoken = action.payload.accesstoken ?? ''
|
||||
state.remote.refreshtoken = action.payload.refreshtoken ?? ''
|
||||
state.remote.email = action.payload.email ?? '',
|
||||
state.remote.username = action.payload.username ?? '',
|
||||
state.remote.avatar = action.payload.avatar ?? ''
|
||||
},
|
||||
signOut: (state) => {
|
||||
state.remote.apikey = '';
|
||||
state.remote.idtoken = '';
|
||||
state.remote.accesstoken = '';
|
||||
state.remote.refreshtoken = '';
|
||||
state.remote.email = '';
|
||||
state.remote.username = '';
|
||||
state.remote.avatar = '';
|
||||
}
|
||||
},
|
||||
extraReducers(builder) {
|
||||
builder.addCase(loadConfigFile.pending, (state) => {
|
||||
@@ -323,13 +296,28 @@ export const config = createSlice({
|
||||
}
|
||||
});
|
||||
|
||||
builder.addCase(logoutUser.pending, (state) => {
|
||||
builder.addCase(loginUser.fulfilled, (state, action) => {
|
||||
merge(state, {
|
||||
remote: {
|
||||
apikey: action.payload.apikey,
|
||||
email: action.payload.email,
|
||||
username: action.payload.username,
|
||||
avatar: action.payload.avatar,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
builder.addCase(logoutUser.fulfilled, (state) => {
|
||||
merge(state, {
|
||||
remote: {
|
||||
apikey: '',
|
||||
avatar: '',
|
||||
email: '',
|
||||
username: '',
|
||||
idtoken: '',
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -348,8 +336,6 @@ export const {
|
||||
setUpnpState,
|
||||
setWanPortToValue,
|
||||
setWanAccess,
|
||||
signIn,
|
||||
signOut
|
||||
} = actions;
|
||||
|
||||
export const configReducer = reducer;
|
||||
|
||||
98
api/src/store/modules/minigraph.ts
Normal file
98
api/src/store/modules/minigraph.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import { MinigraphStatus } from '@app/graphql/generated/api/types';
|
||||
import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status';
|
||||
import { loginUser, logoutUser } from '@app/store/modules/config';
|
||||
import { minigraphLogger } from '@app/core/log';
|
||||
import { KEEP_ALIVE_INTERVAL_MS } from '@app/consts';
|
||||
|
||||
export type MinigraphClientState = {
|
||||
status: MinigraphStatus;
|
||||
error: string | null;
|
||||
lastPing: number | null;
|
||||
selfDisconnectedSince: number | null;
|
||||
timeout: number | null;
|
||||
timeoutStart: number | null;
|
||||
};
|
||||
|
||||
const initialState: MinigraphClientState = {
|
||||
status: MinigraphStatus.PRE_INIT,
|
||||
error: null,
|
||||
lastPing: null,
|
||||
selfDisconnectedSince: null,
|
||||
timeout: null,
|
||||
timeoutStart: null,
|
||||
};
|
||||
|
||||
export const mothership = createSlice({
|
||||
name: 'mothership',
|
||||
initialState,
|
||||
reducers: {
|
||||
setMothershipTimeout(state, action: PayloadAction<number>) {
|
||||
state.timeout = action.payload;
|
||||
state.timeoutStart = Date.now();
|
||||
},
|
||||
receivedMothershipPing(state) {
|
||||
state.lastPing = Date.now();
|
||||
},
|
||||
setSelfDisconnected(state) {
|
||||
minigraphLogger.error(
|
||||
`Received disconnect event for own server, waiting for ${
|
||||
KEEP_ALIVE_INTERVAL_MS / 1_000
|
||||
} seconds before setting disconnected`
|
||||
);
|
||||
state.selfDisconnectedSince = Date.now();
|
||||
},
|
||||
setSelfReconnected(state) {
|
||||
minigraphLogger.error(
|
||||
'Received connected event for own server, clearing disconnection timeout'
|
||||
);
|
||||
state.selfDisconnectedSince = null;
|
||||
},
|
||||
},
|
||||
extraReducers(builder) {
|
||||
builder.addCase(setGraphqlConnectionStatus, (state, action) => {
|
||||
minigraphLogger.debug(
|
||||
'GraphQL Connection Status: ',
|
||||
action.payload
|
||||
);
|
||||
state.status = action.payload.status;
|
||||
state.error = action.payload.error;
|
||||
if (
|
||||
[
|
||||
MinigraphStatus.CONNECTED,
|
||||
MinigraphStatus.CONNECTING,
|
||||
].includes(action.payload.status)
|
||||
) {
|
||||
state.error = null;
|
||||
state.timeout = null;
|
||||
state.lastPing = null;
|
||||
state.selfDisconnectedSince = null;
|
||||
state.timeoutStart = null;
|
||||
}
|
||||
});
|
||||
builder.addCase(loginUser.pending, (state) => {
|
||||
state.timeout = null;
|
||||
state.timeoutStart = null;
|
||||
state.lastPing = null;
|
||||
state.selfDisconnectedSince = null;
|
||||
state.status = MinigraphStatus.PRE_INIT;
|
||||
state.error =
|
||||
'Connecting - refresh the page for an updated status.';
|
||||
});
|
||||
builder.addCase(logoutUser.pending, (state) => {
|
||||
state.error = null;
|
||||
state.timeout = null;
|
||||
state.lastPing = null;
|
||||
state.selfDisconnectedSince = null;
|
||||
state.timeoutStart = null;
|
||||
state.status = MinigraphStatus.PRE_INIT;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setMothershipTimeout,
|
||||
receivedMothershipPing,
|
||||
setSelfDisconnected,
|
||||
setSelfReconnected,
|
||||
} = mothership.actions;
|
||||
69
api/src/types/my-servers-config.d.ts
vendored
Normal file
69
api/src/types/my-servers-config.d.ts
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
import { type MinigraphStatus } from '@app/graphql/generated/api/types';
|
||||
import { type DynamicRemoteAccessType } from '@app/remoteAccess/types';
|
||||
|
||||
interface MyServersConfig extends Record<string, unknown> {
|
||||
api: {
|
||||
version: string;
|
||||
extraOrigins?: string;
|
||||
};
|
||||
local: {
|
||||
'2Fa'?: string;
|
||||
'showT2Fa'?: string;
|
||||
};
|
||||
notifier: {
|
||||
apikey: string;
|
||||
};
|
||||
remote: {
|
||||
'2Fa'?: string;
|
||||
wanaccess: string;
|
||||
wanport: string;
|
||||
upnpEnabled?: string;
|
||||
apikey: string;
|
||||
email: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
regWizTime: string;
|
||||
accesstoken: string;
|
||||
idtoken: string;
|
||||
refreshtoken: string;
|
||||
allowedOrigins?: string;
|
||||
dynamicRemoteAccessType?: DynamicRemoteAccessType;
|
||||
};
|
||||
upc: {
|
||||
apikey: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MyServersConfigWithMandatoryHiddenFields extends MyServersConfig {
|
||||
api: {
|
||||
extraOrigins: string;
|
||||
};
|
||||
local: MyServersConfig['local'] & {
|
||||
'2Fa': string;
|
||||
'showT2Fa': string;
|
||||
};
|
||||
remote: MyServersConfig['remote'] & {
|
||||
'2Fa': string;
|
||||
upnpEnabled: string;
|
||||
dynamicRemoteAccessType: DynamicRemoteAccessType;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MyServersConfigMemory extends MyServersConfig {
|
||||
connectionStatus: {
|
||||
minigraph: MinigraphStatus;
|
||||
upnpStatus?: null | string;
|
||||
};
|
||||
remote: MyServersConfig['remote'] & {
|
||||
allowedOrigins: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MyServersConfigMemoryWithMandatoryHiddenFields
|
||||
extends MyServersConfigMemory {
|
||||
connectionStatus: {
|
||||
minigraph: MinigraphStatus;
|
||||
upnpStatus?: null | string;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user