feat(web): guidValidation if new keyfile auto install

This commit is contained in:
Zack Spear
2023-10-04 15:07:03 -07:00
committed by Zack Spear
parent 41879fa27c
commit ed0b41a425
7 changed files with 179 additions and 58 deletions

View File

@@ -6,11 +6,10 @@ import {
import { storeToRefs } from 'pinia';
import { DOCS_REGISTRATION_REPLACE_KEY } from '~/helpers/urls';
import BrandLoadingWhite from '~/components/Brand/LoadingWhite.vue';
import { useReplaceRenewStore } from '~/store/replaceRenew';
const replaceRenewStore = useReplaceRenewStore();
const { status, statusOutput } = storeToRefs(replaceRenewStore);
const { replaceStatusOutput } = storeToRefs(replaceRenewStore);
defineProps<{
t: any;
@@ -20,20 +19,19 @@ defineProps<{
<template>
<div class="flex flex-wrap items-start justify-between gap-8px">
<BrandButton
v-if="status === 'checking' || status === 'ready'"
v-if="!replaceStatusOutput"
@click="replaceRenewStore.check"
:disabled="status !== 'ready'"
:icon="status === 'checking' ? BrandLoadingWhite : KeyIcon"
:text="status === 'checking' ? t('Checking...') : t('Check Eligibility')"
:icon="KeyIcon"
:text="t('Check Eligibility')"
class="flex-grow" />
<UiBadge
v-else-if="statusOutput"
:color="statusOutput.color"
:icon="statusOutput.icon"
v-else
:color="replaceStatusOutput.color"
:icon="replaceStatusOutput.icon"
size="16px"
>
{{ t(statusOutput.text) }}
{{ t(replaceStatusOutput.text) }}
</UiBadge>
<BrandButton

View File

@@ -32,3 +32,14 @@ export const validateGuid = (payload: ValidateGuidPayload) => KeyServer
.url('/validate/guid')
.formUrl(payload)
.post();
export interface KeyLatestPayload {
keyfile: string;
}
export interface KeyLatestResponse {
license: string;
}
export const keyLatest = (payload: KeyLatestPayload) => KeyServer
.url('/key/latest')
.formUrl(payload)
.post();

View File

@@ -1,4 +1,5 @@
import wretch from 'wretch';
import FormDataAddon from 'wretch/addons/formData';
import formUrl from 'wretch/addons/formUrl';
import queryString from 'wretch/addons/queryString';
@@ -7,6 +8,7 @@ import { useErrorsStore } from '~/store/errors';
const errorsStore = useErrorsStore();
export const request = wretch()
.addon(FormDataAddon)
.addon(formUrl)
.addon(queryString)
.errorType('json')

View File

@@ -64,3 +64,31 @@ export const WebguiUnraidApiCommand = async (payload: WebguiUnraidApiCommandPayl
return error;
}
};
export interface NotifyPostParameters {
cmd: 'init' | 'smtp-init' | 'cron-init' | 'add' | 'get' | 'hide' | 'archive';
csrf_token: string;
e?: string; // 'add' command option
s?: string; // 'add' command option
d?: string; // 'add' command option
i?: string; // 'add' command option
m?: string; // 'add' command option
x?: string; // 'add' command option
t?: string; // 'add' command option
file?: string; // 'hide' and 'archive' command option
}
export const WebguiNotify = async (payload: NotifyPostParameters) => {
console.debug('[WebguiNotify] payload', payload);
if (!payload) { return console.error('[WebguiNotify] payload is required'); }
try {
const response = await request
.url('/webGui/include/Notify.php')
.formData(payload)
.post();
return response;
} catch (error) {
console.error('[WebguiNotify] catch failed to execute Notify', error, payload);
return error;
}
}

View File

@@ -26,11 +26,12 @@ export type TrialExtend = 'trialExtend';
export type TrialStart = 'trialStart';
export type Purchase = 'purchase';
export type Redeem = 'redeem';
export type Renew = 'renew'
export type Upgrade = 'upgrade';
export type UpdateOs = 'updateOs';
export type AccountActionTypes = Troubleshoot | SignIn | SignOut | OemSignOut;
export type AccountKeyActionTypes = Recover | Replace | TrialExtend | TrialStart | UpdateOs;
export type PurchaseActionTypes = Purchase | Redeem | Upgrade;
export type PurchaseActionTypes = Purchase | Redeem | Renew | Upgrade;
export type ServerActionTypes = AccountActionTypes | AccountKeyActionTypes | PurchaseActionTypes;

View File

@@ -6,9 +6,17 @@ import {
import { defineStore, createPinia, setActivePinia } from 'pinia';
import type { WretchError } from 'wretch';
import { validateGuid, type ValidateGuidResponse } from '~/composables/services/keyServer';
import {
keyLatest,
validateGuid,
type KeyLatestResponse,
type ValidateGuidResponse,
} from '~/composables/services/keyServer';
import { WebguiNotify } from '~/composables/services/webgui';
import { useInstallKeyStore } from '~/store/installKey';
import { useServerStore } from '~/store/server';
import type { UiBadgeProps } from '~/types/ui/badge';
import BrandLoadingWhite from '~/components/Brand/LoadingWhite.vue';
/**
* @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components
* @see https://github.com/vuejs/pinia/discussions/1085
@@ -22,6 +30,7 @@ export interface UiBadgePropsExtended extends UiBadgeProps {
export const REPLACE_CHECK_LOCAL_STORAGE_KEY = 'unraidReplaceCheck';
export const useReplaceRenewStore = defineStore('replaceRenewCheck', () => {
const installKeyStore = useInstallKeyStore();
const serverStore = useServerStore();
const guid = computed(() => serverStore.guid);
@@ -33,95 +42,173 @@ export const useReplaceRenewStore = defineStore('replaceRenewCheck', () => {
stack?: string | undefined;
cause?: unknown;
} | null>(null);
const status = ref<'checking' | 'eligible' | 'error' | 'ineligible' | 'ready'>(guid.value ? 'ready' : 'error');
const statusOutput = computed((): UiBadgePropsExtended | undefined => {
const renewStatus = ref<'checking' | 'error' | 'installing' | 'installed' | 'ready'>('ready');
const setRenewStatus = (status: typeof renewStatus.value) => renewStatus.value = status;
const replaceStatus = ref<'checking' | 'eligible' | 'error' | 'ineligible' | 'ready'>(guid.value ? 'ready' : 'error');
const setReplaceStatus = (status: typeof replaceStatus.value) => replaceStatus.value = status;
const replaceStatusOutput = computed((): UiBadgePropsExtended | undefined => {
// text values are translated in the component
switch (status.value) {
switch (replaceStatus.value) {
case 'checking':
return {
color: 'gamma',
icon: BrandLoadingWhite,
text: 'Checking...',
};
case 'eligible':
return {
color: 'green',
icon: CheckCircleIcon,
text: 'Eligible',
};
case 'ineligible':
return {
color: 'red',
icon: XCircleIcon,
text: 'Ineligible',
};
case 'error':
return {
color: 'red',
icon: ShieldExclamationIcon,
text: error.value?.message || 'Unknown error',
};
default: return undefined;
case 'ineligible':
return {
color: 'red',
icon: XCircleIcon,
text: 'Ineligible',
};
case 'ready':
default:
return undefined;
}
});
const validationResponse = ref<ValidateGuidResponse | undefined>(
/**
* onBeforeMount checks the timestamp of the validation response and purges it if it's too old
*/
interface validationResponseWithTimestamp extends ValidateGuidResponse {
timestamp: number;
}
const validationResponse = ref<validationResponseWithTimestamp | undefined>(
sessionStorage.getItem(REPLACE_CHECK_LOCAL_STORAGE_KEY)
? JSON.parse(sessionStorage.getItem(REPLACE_CHECK_LOCAL_STORAGE_KEY) as string)
: undefined
);
const purgeValidationResponse = () => {
validationResponse.value = undefined;
sessionStorage.removeItem(REPLACE_CHECK_LOCAL_STORAGE_KEY);
}
const check = async () => {
if (!guid.value) {
status.value = 'error';
setReplaceStatus('error');
error.value = { name: 'Error', message: 'Flash GUID required to check replacement status' };
}
if (!keyfile.value) {
status.value = 'error';
setReplaceStatus('error');
error.value = { name: 'Error', message: 'Keyfile required to check replacement status' };
}
try {
status.value = 'checking';
setReplaceStatus('checking');
error.value = null;
/**
* @todo will eventually take a keyfile and provide renewal details. If this says there's a reneal key available then we'll make a separate request to replace / swap the new key. We'll also use this to update the keyfile to the new key type for legacy users.
* endpoint will be through key server
* this should happen automatically when the web components are mounted…
* account.unraid.net will do a similar thing`
* If the session already has a validation response, use that instead of making a new request
*/
const response: ValidateGuidResponse = await validateGuid({
guid: guid.value,
keyfile: keyfile.value,
}).json();
console.log('[ReplaceCheck.check] response', response);
let response: ValidateGuidResponse | undefined;
if (validationResponse.value) {
console.debug('[ReplaceCheck.check] validationResponse FOUND');
response = validationResponse.value;
} else {
console.debug('[ReplaceCheck.check] validationResponse NOT FOUND');
response = await validateGuid({
guid: guid.value,
keyfile: keyfile.value,
}).json();
}
console.debug('[ReplaceCheck.check] response', response);
status.value = response?.replaceable ? 'eligible' : 'ineligible';
setReplaceStatus(response?.replaceable ? 'eligible' : 'ineligible');
if (status.value === 'eligible' || status.value === 'ineligible') {
sessionStorage.setItem(REPLACE_CHECK_LOCAL_STORAGE_KEY, JSON.stringify(response));
/** cache the response to prevent repeated POSTs in the session */
if (replaceStatus.value === 'eligible' || replaceStatus.value === 'ineligible' && !validationResponse.value) {
console.debug('[ReplaceCheck.check] cache response');
sessionStorage.setItem(REPLACE_CHECK_LOCAL_STORAGE_KEY, JSON.stringify({
timestamp: Date.now(),
...response,
}));
}
/**
* @todo if response?.hasNewerKeyfile then we need to prompt the user to replace the keyfile. This will be a separate request to the key server.
* @todo we don't want to automatically make this request for the new keyfile.
*/
if (response?.hasNewerKeyfile) {
console.log('[ReplaceCheck.check] hasNewerKeyfile');
console.debug('[ReplaceCheck.check] hasNewerKeyfile');
setRenewStatus('checking');
const keyLatestResponse: KeyLatestResponse = await keyLatest({
keyfile: keyfile.value,
}).json();
console.debug('[ReplaceCheck.check] keyLatestResponse', keyLatestResponse);
if (keyLatestResponse?.license) {
console.debug('[ReplaceCheck.check] keyLatestResponse.license', keyLatestResponse.license);
setRenewStatus('installing');
await installKeyStore.install({
keyUrl: keyLatestResponse.license,
type: 'renew',
}).then(() => {
console.debug('[ReplaceCheck.check] installKeyStore.install success');
setRenewStatus('installed');
// reset the validation response so we can check again on the subsequent page load. Will also prevent the keyfile from being installed again on page refresh.
purgeValidationResponse();
/** @todo this doesn't work */
WebguiNotify({
cmd: 'add',
csrf_token: serverStore.csrf,
e: 'Keyfile Renewed and Installed (event)',
s: 'Keyfile Renewed and Installed (subject)',
d: 'While license keys are perpetual, certain keyfiles are not. Your keyfile has automatically been renewed and installed in the background. Thanks for your support!',
m: 'Your keyfile has automatically been renewed and installed in the background. Thanks for your support!',
})
});
}
}
} catch (err) {
const catchError = err as WretchError;
status.value = 'error';
setReplaceStatus('error');
error.value = catchError?.message ? catchError : { name: 'Error', message: 'Unknown error' };
console.error('[ReplaceCheck.check]', catchError);
}
};
/**
* @todo not sure if this is working…guid endpoint still firing
* If we already have a validation response, set the status to eligible or ineligible
*/
onBeforeMount(() => {
onBeforeMount(async () => {
if (validationResponse.value) {
status.value = validationResponse.value?.replaceable ? 'eligible' : 'ineligible';
console.debug('[validationResponse] cache FOUND');
// ensure the response is still valid and not old due to someone keeping their browser open
const currentTime = new Date().getTime();
const cacheDuration = import.meta.env.DEV ? 30000 : 604800000; // 30 seconds for testing, 7 days for prod
if (currentTime - validationResponse.value.timestamp > cacheDuration) {
// cache is expired, purge it
console.debug('[validationResponse] cache EXPIRED');
purgeValidationResponse();
} else {
// if the cache is valid return the existing response
console.debug('[validationResponse] cache VALID');
setReplaceStatus(validationResponse.value?.replaceable ? 'eligible' : 'ineligible');
}
}
});
return {
status,
statusOutput,
// state
renewStatus,
replaceStatus,
replaceStatusOutput,
// actions
check,
setReplaceStatus,
setRenewStatus,
};
});

View File

@@ -273,16 +273,10 @@ export const useUpdateOsStoreGeneric = (
* @returns boolean true should block the update and require key renewal, false should allow the update without key renewal
*/
const releaseDateGtRegExpDate = (releaseDate: number | string, regExpDate: number): boolean => {
const parsedReleaseDate = dayjs(releaseDate, 'YYYY-MM-DD').valueOf();
const parsedRegExpDate = dayjs(regExpDate ?? undefined).valueOf();
console.log('releaseDateGtRegExpDate', {
releaseDate,
regExpDate,
parsedReleaseDate: parsedReleaseDate,
parsedRegExpDate: parsedRegExpDate,
isAfter: parsedReleaseDate > parsedRegExpDate,
});
return parsedReleaseDate > parsedRegExpDate;
const parsedReleaseDate = dayjs(releaseDate, 'YYYY-MM-DD');
const parsedUpdateExpirationDate = dayjs(regExpDate ?? undefined);
return parsedReleaseDate.isAfter(parsedUpdateExpirationDate, 'day');
};
return {