fix: remove login / logout listener (#728)

This commit is contained in:
Eli Bosley
2023-09-01 15:31:20 -04:00
committed by GitHub
parent 09fb0d6c5a
commit dffc35be74
17 changed files with 735 additions and 86 deletions

View File

@@ -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 \

View 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',
},
})
);
});

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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

View 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,
});
}
};

View File

@@ -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;
};

View 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;
}
}

View File

@@ -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();
};

View File

@@ -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;
};

View 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);
}
};

View File

@@ -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();
}

View 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;

View File

@@ -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;

View 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
View 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;
};
}