diff --git a/web/store/updateOs.ts b/web/store/updateOs.ts index 2782e4ed9..5892574c7 100644 --- a/web/store/updateOs.ts +++ b/web/store/updateOs.ts @@ -2,91 +2,9 @@ import dayjs, { extend } from 'dayjs'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import relativeTime from 'dayjs/plugin/relativeTime'; import { defineStore, createPinia, setActivePinia } from 'pinia'; -import gt from 'semver/functions/gt'; -import prerelease from 'semver/functions/prerelease'; -import type { SemVer } from 'semver'; -import { computed, ref } from 'vue'; -import wretch from 'wretch'; - -import { - ACCOUNT, - OS_RELEASES, - OS_RELEASES_NEXT, - OS_RELEASES_PREVIEW, - OS_RELEASES_TEST, -} from '@/helpers/urls'; - -export type OsVersionBranch = 'stable' | 'next' | 'preview' | 'test'; - -export interface RequestReleasesPayload { - cache?: boolean; // saves response to localStorage - guid: string; - keyfile: string; - osVersion: SemVer | string; - osVersionBranch: OsVersionBranch; - skipCache?: boolean; // forces a refetch from the api - isLoggedIn?: boolean; - authUserGroups?: string[]; -} - -export interface Release { - version: string; // "6.12.4" - name: string; // "Unraid 6.12.4" - basefile: string; // "unRAIDServer-6.12.4-x86_64.zip" - date: string; // "2023-08-31" - url: string; // "https://stable.dl.unraid.net/unRAIDServer-6.12.4-x86_64.zip" - changelog: string; // "https://raw.githubusercontent.com/unraid/docs/main/docs/unraid-os/release-notes/6.12.4.md" - changelog_pretty: string; // "https://docs.unraid.net/unraid-os/release-notes/6.12.4/" - md5: string; // "df6e5859d28c14617efde36d59458206" - size: string; // "439999418" - sha256: string; // "5ad2d22e8c124e3b925c3bd05f1d782d8521965aabcbedd7dd782db76afd9ace" - plugin_url: string; // "https://stable.dl.unraid.net/unRAIDServer-6.12.4.plg" - plugin_sha256: string; // "57d2ab6036e663208b3f72298ceb478b937b17e333986e68dcae2696c88ed152" - announce_url: string; // "https://unraid.net/blog/6-12-4" - branch: OsVersionBranch; // "stable" -} -export interface ReleasesResponse { - stable: Release[]; - next?: Release[]; - preview?: Release[]; - test?: Release[]; -} -export interface CachedReleasesResponse { - timestamp: number; - response: ReleasesResponse; -} - -export interface UserInfo { - email?: string; - email_verifed?: 'true' | 'false'; - preferred_username?: string; - sub?: string; - username?: string; - /** - * @param identities {string} JSON string containing @type Identity[] - */ - identities?: string; -} - -export interface UpdateOsActionStore { - osVersion: SemVer | string; - osVersionBranch: OsVersionBranch; - regExp: number; - regUpdatesExpired: boolean; -} - -interface UpdateOsStorePayload { - useUpdateOsActions?: () => UpdateOsActionStore; - /** - * If values are added below they need to exported by useUpdateOsActions so that they can be used in the computed properties - * @note Values below are used in both account.unraid.net and the webgui web components - */ - currentOsVersion?: SemVer | string; - currentOsVersionBranch?: OsVersionBranch; - currentRegExp?: number; - currentRegUpdatesExpired?: boolean; -} +import { computed } from 'vue'; +import { useServerStore } from '~/store/server'; /** * @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components * @see https://github.com/vuejs/pinia/discussions/1085 @@ -97,339 +15,38 @@ setActivePinia(createPinia()); extend(customParseFormat); extend(relativeTime); -export const RELEASES_LOCAL_STORAGE_KEY = 'unraidReleasesResponse'; +export const useUpdateOsStore = defineStore('updateOs', () => { + const serverStore = useServerStore(); -export const useUpdateOsStoreGeneric = (payload?: UpdateOsStorePayload) => - defineStore('updateOs', () => { - console.debug('[updateOs] payload', payload); - // Since this file is shared between account.unraid.net and the web components, we need to handle the state differently - // If useUpdateOsActions is passed in, we're in the webgui web components - const updateOsActions = payload?.useUpdateOsActions !== undefined ? payload?.useUpdateOsActions() : undefined; - console.debug('[updateOs] updateOsActions', updateOsActions); - // If useUpdateOsActions is not passed in, we're in account.unraid.net - // creating refs from the passed in values so that we can use them in the computed properties - const paramCurrentOsVersion = ref(payload?.currentOsVersion ?? ''); - const paramCurrentOsVersionBranch = ref(payload?.currentOsVersionBranch ?? ''); - const paramCurrentRegExp = ref(payload?.currentRegExp ?? 0); - const paramCurrentRegUpdatesExpired = ref(payload?.currentRegUpdatesExpired ?? false); - // getters – when set from updateOsActions we're in the webgui web components otherwise we're in account.unraid.net - const osVersion = computed(() => updateOsActions?.osVersion ?? paramCurrentOsVersion.value ?? ''); - const osVersionBranch = computed(() => updateOsActions?.osVersionBranch ?? paramCurrentOsVersionBranch.value ?? ''); - const regExp = computed(() => updateOsActions?.regExp ?? paramCurrentRegExp.value ?? 0); - const regUpdatesExpired = computed(() => updateOsActions?.regUpdatesExpired ?? paramCurrentRegUpdatesExpired.value ?? false); - // will only ever be used by account.unraid.net - const authUserGroups = ref([]); - const isLoggedIn = ref(false); + const regExp = computed(() => serverStore.regExp); + const regUpdatesExpired = computed(() => serverStore.regUpdatesExpired); + const updateOsResponse = computed(() => serverStore.updateOsResponse); - // state - const available = ref(''); - const availableWithRenewal = ref(''); - const isOnAccountApp = ref(window.location.origin === ACCOUNT.origin); - const releases = ref(localStorage.getItem(RELEASES_LOCAL_STORAGE_KEY) ? JSON.parse(localStorage.getItem(RELEASES_LOCAL_STORAGE_KEY) ?? '') : undefined); - const releasesError = ref(''); + const releaseDateGtRegExpDate = (releaseDate: number | string, regExpDate: number): boolean => { + const parsedReleaseDate = dayjs(releaseDate, 'YYYY-MM-DD'); + const parsedUpdateExpirationDate = dayjs(regExpDate ?? undefined); - // getters - const parsedRegExp = computed(() => dayjs(regExp.value).format('YYYY-MM-DD')); - const parsedReleaseTimestamp = computed(() => { - if (!releases.value?.timestamp) { return undefined; } - return { - formatted: dayjs(releases.value?.timestamp).format('YYYY-MM-DD HH:mm:ss'), - relative: dayjs().to(dayjs(releases.value?.timestamp)), - }; - }); + return parsedReleaseDate.isAfter(parsedUpdateExpirationDate, 'day'); + }; - const isOsVersionStable = computed(() => isVersionStable(osVersion.value)); - const isAvailableStable = computed(() => available.value ? isVersionStable(available.value) : false); - - const filteredNextReleases = computed(() => { - if (!osVersion.value) { return undefined; } - - if (releases.value?.response?.next) { - return releases.value?.response?.next.filter( - release => gt(release.version, osVersion.value as string) - ); - } + const available = computed(() => { + if (!updateOsResponse.value) { return undefined; - }); - - const filteredPreviewReleases = computed(() => { - // if we're on account.unraid.net and the user is not in the download_preview group, don't show preview releases - const userNotInGroup = isOnAccountApp.value && isLoggedIn.value && authUserGroups.value && !authUserGroups.value.includes('download_preview'); - if (!osVersion.value || userNotInGroup) { return undefined; } - // if not authed but the current osVersionBranch is test then return an empty array to prevent showing test releases but still showing the test branch in the tabs - if (isOnAccountApp.value && !isLoggedIn.value && osVersionBranch.value === 'preview') { return []; } - - if (releases.value?.response?.preview) { - return releases.value?.response?.preview.filter( - release => gt(release.version, osVersion.value as string) - ); - } - return undefined; - }); - - const filteredStableReleases = computed(() => { - if (!osVersion.value) { return undefined; } - - let filteredReleases: Release[] | undefined; - - if (releases.value?.response?.stable) { - filteredReleases = releases.value?.response?.stable.filter( - release => gt(release.version, osVersion.value as string) - ); - } - // if current osBranch is next, preview, or test we should return the latest stable release regardless if the current version is ahead - if ((!filteredReleases || filteredReleases.length === 0) && osVersionBranch.value !== 'stable' && releases.value?.response?.stable[0]) { - filteredReleases = releases.value?.response?.stable.filter( // using filter so we get an array back - (_release, index) => index === 0 - ); - } - - return filteredReleases; - }); - - const filteredTestReleases = computed(() => { - // if we're on account.unraid.net and the user is not in the download_test group, don't show test releases - const userNotInGroup = isOnAccountApp.value && isLoggedIn.value && authUserGroups.value && !authUserGroups.value.includes('download_test'); - if (!osVersion.value || userNotInGroup) { return undefined; } - // if not authed but the current osVersionBranch is test then return an empty array to prevent showing test releases but still showing the test branch in the tabs - if (isOnAccountApp.value && !isLoggedIn.value && osVersionBranch.value === 'test') { return []; } - - if (releases.value?.response?.test) { - return releases.value?.response?.test.filter( - release => gt(release.version, osVersion.value as string) - ); - } - return undefined; - }); - - const allFilteredReleases = computed(() => { - if (!releases.value || (!filteredNextReleases.value && !filteredPreviewReleases.value && !filteredStableReleases.value && !filteredTestReleases.value)) { - return undefined; - } - - return { - ...(filteredStableReleases.value && { stable: [...filteredStableReleases.value] }), - ...(filteredNextReleases.value && { next: [...filteredNextReleases.value] }), - ...(filteredPreviewReleases.value && { preview: [...filteredPreviewReleases.value] }), - ...(filteredTestReleases.value && { test: [...filteredTestReleases.value] }), - }; - }); - - /** - * We need two ways of determining which branch to use: - * 1. On the server webgui use the osVersionBranch param - * 2. On account.unraid.net we can use the user's auth to determine which branch to use - */ - const releasesUrl = computed((): typeof OS_RELEASES => { - /** - * @note The webgui should only use stable and next URLs. - * Users with test or preview would need to manually check for updates. - * - * Alternatively, what could be done is the webgui URLs slightly differ in that the JSON only contains the sha256s of the releases. - * Rather than revealing the entire release object with the download URLs. - * This may require download URLs to be generated on the fly or the URLs are randomized and don't follow a specific pattern. - * Because https://stable.dl.unraid.net/unRAIDServer-6.12.4-x86_64.zip is a pretty obvious pattern. - * Even if it means just adding a randomized hash to the end of the URL. - * */ - const webguiNextBranch = !isOnAccountApp.value && osVersionBranch.value === 'next'; - - const accountAppLoggedIn = isOnAccountApp.value && isLoggedIn.value; - - const accountAppPreviewBranch = accountAppLoggedIn && authUserGroups.value && authUserGroups.value.includes('download_preview'); - const accountAppTestBranch = accountAppLoggedIn && authUserGroups.value && authUserGroups.value.includes('download_test'); - - const useNextBranch = webguiNextBranch || accountAppLoggedIn; - const usePreviewBranch = accountAppPreviewBranch; - const useTestBranch = accountAppTestBranch; - - let releasesUrl = OS_RELEASES; - if (useNextBranch) { releasesUrl = OS_RELEASES_NEXT; } - // @note we don't want PREVIEW / TEST used in the webgui hence additional checks to ensure the URLs exist - if (usePreviewBranch && OS_RELEASES_PREVIEW) { releasesUrl = OS_RELEASES_PREVIEW; } - if (useTestBranch && OS_RELEASES_TEST) { releasesUrl = OS_RELEASES_TEST; } - - return releasesUrl; - }); - // actions - const setReleasesState = (response: ReleasesResponse) => { - releases.value = { - timestamp: Date.now(), - response, - }; - }; - - const cacheReleasesResponse = () => { - localStorage.setItem(RELEASES_LOCAL_STORAGE_KEY, JSON.stringify(releases.value)); - }; - - const purgeReleasesCache = async () => { - releases.value = undefined; - await localStorage.removeItem(RELEASES_LOCAL_STORAGE_KEY); - }; - - const requestReleases = async (payload: RequestReleasesPayload): Promise => { - console.debug('[requestReleases]', payload); - - if (!payload || !payload.osVersion || !payload.osVersionBranch || !payload.guid || !payload.keyfile) { - throw new Error('Invalid Payload for updateOs.requestReleases'); - } - - // if we're on account.unraid.net, set these values - if (payload.isLoggedIn) { - isLoggedIn.value = payload.isLoggedIn; - if (payload.authUserGroups) { - console.debug('[requestReleases] setting authUserGroups', payload.authUserGroups); - authUserGroups.value = payload.authUserGroups; - } - } - - if (payload.skipCache) { - await purgeReleasesCache(); - } else if (!payload.skipCache && releases.value) { - /** - * Compare the timestamp of the cached releases data to the current time, - * if it's older than 7 days, reset releases. - * Which will trigger a new API call to get the releases. - * Otherwise skip the API call and use the cached data. - */ - const currentTime = new Date().getTime(); - const cacheDuration = import.meta.env.DEV ? 30000 : 604800000; // 30 seconds for testing, 7 days for prod - if (currentTime - releases.value.timestamp > cacheDuration) { - // cache is expired, purge it - console.debug('[requestReleases] cache EXPIRED'); - await purgeReleasesCache(); - } else { - // if the cache is valid return the existing response - console.debug('[requestReleases] cache VALID', releases.value.response); - return releases.value.response; - } - } - - // If here we're needing to fetch a new releases…whether it's the first time or b/c the cache was expired - try { - console.debug('[requestReleases] fetching new releases from', releasesUrl.value.toString()); - const response: ReleasesResponse = await wretch(releasesUrl.value.toString()).get().json(); - console.debug('[requestReleases] response', response); - - // save it to local state - setReleasesState(response); - if (payload.cache) { - cacheReleasesResponse(); - } - - return response; - } catch (error) { - let errorMessage = 'Unknown error'; - if (typeof error === 'string') { - errorMessage = error.toUpperCase(); - } else if (error instanceof Error) { - errorMessage = error.message; - } - releasesError.value = errorMessage; - console.error('[requestReleases]', error); - } - }; - - const checkForUpdate = async (payload: RequestReleasesPayload) => { - console.debug('[checkForUpdate]', payload); - - if (!payload || !payload.osVersion || !payload.osVersionBranch || !payload.guid || !payload.keyfile) { - console.error('[checkForUpdate] invalid payload'); - throw new Error('Invalid Payload for updateOs.checkForUpdate'); - } - - // reset any available - available.value = ''; - availableWithRenewal.value = ''; - - // gets releases from cache or fetches from api - await requestReleases(payload); - - if (!releases.value) { - return console.error('[checkForUpdate] no releases found'); - } - - Object.keys(releases.value.response ?? {}).forEach((key) => { - // this is just to make TS happy (it's already checked above…thanks github copilot for knowing what I needed) - if (!releases.value) { - return; - } - // if we've already found an available update, skip the rest - if (available.value) { - return; - } - - const branchReleases = releases.value.response[key as keyof ReleasesResponse]; - - if (!branchReleases || branchReleases.length === 0) { - return; - } - - branchReleases.find((release) => { - if (gt(release.version, osVersion.value)) { - // before we set the available version, check if the license key updates have expired to ensure we don't show an update that the user can't install - if (regUpdatesExpired.value && releaseDateGtRegExpDate(release.date, regExp.value)) { - // then save the value to use throughout messaging - if (!availableWithRenewal.value) { // so we don't overwrite a newer version - availableWithRenewal.value = release.version; - } - return false; - } - available.value = release.version; - return true; - } - return false; - }); - }); - }; - - const findRelease = (searchKey: keyof Release, searchValue: string): Release | null => { - const response = releases?.value?.response; - if (!response) { return null; } - - for (const key of Object.keys(response)) { - const branchReleases = response[key as keyof ReleasesResponse]; - if (!branchReleases || branchReleases.length === 0) { continue; } - - const foundRelease = branchReleases.find(release => release[searchKey] === searchValue); - if (foundRelease) { return foundRelease; } - } - - return null; - }; - - const isVersionStable = (version: SemVer | string): boolean => prerelease(version) === null; - /** - * @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'); - const parsedUpdateExpirationDate = dayjs(regExpDate ?? undefined); - - return parsedReleaseDate.isAfter(parsedUpdateExpirationDate, 'day'); - }; - - return { - // state - available, - availableWithRenewal, - releases, - releasesError, - // getters - parsedRegExp, - parsedReleaseTimestamp, - isOsVersionStable, - isAvailableStable, - filteredNextReleases, - filteredPreviewReleases, - filteredStableReleases, - filteredTestReleases, - allFilteredReleases, - // actions - checkForUpdate, - findRelease, - requestReleases, - isVersionStable, - releaseDateGtRegExpDate, - }; + } + return updateOsResponse.value.isNewer ? updateOsResponse.value.version : undefined; }); + const availableWithRenewal = computed(() => { + if (!available.value || !updateOsResponse.value || !regExp.value || !regUpdatesExpired.value) { + return undefined; + } + + return releaseDateGtRegExpDate(updateOsResponse.value.date, regExp.value) + ? updateOsResponse.value.version + : undefined; + }); + + return { + available, + availableWithRenewal, + }; +});