mirror of
https://github.com/unraid/api.git
synced 2026-01-08 09:39:49 -06:00
feat(web): guidValidation if new keyfile auto install
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user