/* This file is part of GNU Anastasis (C) 2021-2022 Anastasis SARL GNU Anastasis is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see */ /** * Imports. */ import { TalerErrorCode } from "@gnu-taler/taler-util"; import { AggregatedPolicyMetaInfo, BackupStates, completeProviderStatus, discoverPolicies, DiscoveryCursor, getBackupStartState, getRecoveryStartState, mergeDiscoveryAggregate, RecoveryStates, reduceAction, ReducerState, } from "@gnu-taler/anastasis-core"; import { useState } from "preact/hooks"; const reducerBaseUrl = "http://localhost:5000/"; const remoteReducer = false; interface AnastasisState { reducerState: ReducerState | undefined; currentError: any; discoveryState: DiscoveryUiState; } async function getBackupStartStateRemote(): Promise { let resp: Response; try { resp = await fetch(new URL("start-backup", reducerBaseUrl).href); } catch (e) { return { code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, message: `Network request to remote reducer ${reducerBaseUrl} failed`, } as any; } try { return await resp.json(); } catch (e) { return { code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, message: `Could not parse response from reducer`, } as any; } } async function getRecoveryStartStateRemote(): Promise { let resp: Response; try { resp = await fetch(new URL("start-recovery", reducerBaseUrl).href); } catch (e) { return { code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, message: `Network request to remote reducer ${reducerBaseUrl} failed`, } as any; } try { return await resp.json(); } catch (e) { return { code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, message: `Could not parse response from reducer`, } as any; } } async function reduceStateRemote( state: any, action: string, args: any, ): Promise { let resp: Response; try { resp = await fetch(new URL("action", reducerBaseUrl).href, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ state, action, arguments: args, }), }); } catch (e) { return { code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, message: `Network request to remote reducer ${reducerBaseUrl} failed`, } as any; } try { return await resp.json(); } catch (e) { return { code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, message: `Could not parse response from reducer`, } as any; } } export interface ReducerTransactionHandle { transactionState: ReducerState; transition(action: string, args: any): Promise; } /** * UI-relevant state of the policy discovery process. */ export interface DiscoveryUiState { state: "none" | "active" | "finished"; aggregatedPolicies?: AggregatedPolicyMetaInfo[]; cursor?: DiscoveryCursor; } export interface AnastasisReducerApi { currentReducerState: ReducerState | undefined; // FIXME: Explain better! currentError: any; discoveryState: DiscoveryUiState; dismissError: () => void; startBackup: () => void; startRecover: () => void; reset: () => void; back: () => Promise; transition(action: string, args: any): Promise; exportState: () => string; importState: (s: string) => void; discoverStart(): Promise; discoverMore(): Promise; /** * Run multiple reducer steps in a transaction without * affecting the UI-visible transition state in-between. */ runTransaction( f: (h: ReducerTransactionHandle) => Promise, ): Promise; } function storageGet(key: string): string | null { if (typeof localStorage === "object") { return localStorage.getItem(key); } return null; } function storageSet(key: string, value: any): void { if (typeof localStorage === "object") { return localStorage.setItem(key, value); } } function getStateFromStorage(): any { let state: any; try { const s = storageGet("anastasisReducerState"); if (s === "undefined") { state = undefined; } else if (s) { state = JSON.parse(s); } } catch (e) { console.log("ERROR: getStateFromStorage ", e); } return state ?? undefined; } export function useAnastasisReducer(): AnastasisReducerApi { const [anastasisState, setAnastasisStateInternal] = useState( () => ({ reducerState: getStateFromStorage(), currentError: undefined, discoveryState: { state: "none", }, }), ); const setAnastasisState = (newState: AnastasisState) => { try { storageSet( "anastasisReducerState", JSON.stringify(newState.reducerState), ); } catch (e) { console.log("ERROR setAnastasisState", e); } setAnastasisStateInternal(newState); const tryUpdateProviders = () => { const reducerState = newState.reducerState; if ( reducerState?.reducer_type !== "backup" && reducerState?.reducer_type !== "recovery" ) { return; } const provMap = reducerState.authentication_providers; if (!provMap) { return; } const doUpdate = async () => { const updates = await completeProviderStatus(provMap); if (Object.keys(updates).length === 0) { return; } const rs2 = reducerState; if (rs2.reducer_type !== "backup" && rs2.reducer_type !== "recovery") { return; } setAnastasisState({ ...anastasisState, reducerState: { ...rs2, authentication_providers: { ...rs2.authentication_providers, ...updates, }, }, }); }; doUpdate().catch((e) => console.log("ERROR doUpdate", e)); }; tryUpdateProviders(); }; async function doTransition(action: string, args: any): Promise { let s: ReducerState; if (remoteReducer) { s = await reduceStateRemote(anastasisState.reducerState, action, args); } else { s = await reduceAction(anastasisState.reducerState!, action, args); } if (s.reducer_type === "error") { setAnastasisState({ ...anastasisState, currentError: s }); } else { setAnastasisState({ ...anastasisState, currentError: undefined, reducerState: s, }); } } return { currentReducerState: anastasisState.reducerState, currentError: anastasisState.currentError, discoveryState: anastasisState.discoveryState, async startBackup() { let s: ReducerState; if (remoteReducer) { s = await getBackupStartStateRemote(); } else { s = await getBackupStartState(); } if (s.reducer_type === "error") { setAnastasisState({ ...anastasisState, currentError: s, }); } else { setAnastasisState({ ...anastasisState, currentError: undefined, reducerState: s, }); } }, exportState() { const state = getStateFromStorage(); return JSON.stringify(state); }, importState(s: string) { try { const state = JSON.parse(s); setAnastasisState({ reducerState: state, currentError: undefined, discoveryState: { state: "none", }, }); } catch (e) { throw new Error("could not restore the state"); } }, async discoverStart(): Promise { const res = await discoverPolicies(this.currentReducerState!, undefined); const aggregatedPolicies = mergeDiscoveryAggregate(res.policies, []); setAnastasisState({ ...anastasisState, discoveryState: { state: "finished", aggregatedPolicies, cursor: res.cursor, }, }); }, async discoverMore(): Promise { return; }, async startRecover() { let s: ReducerState; if (remoteReducer) { s = await getRecoveryStartStateRemote(); } else { s = await getRecoveryStartState(); } if (s.reducer_type === "error") { setAnastasisState({ ...anastasisState, currentError: s, }); } else { setAnastasisState({ ...anastasisState, currentError: undefined, reducerState: s, }); } }, transition(action: string, args: any) { return doTransition(action, args); }, async back() { const reducerState = anastasisState.reducerState; if (!reducerState) { return; } if ( (reducerState.reducer_type === "backup" && reducerState.backup_state === BackupStates.ContinentSelecting) || (reducerState.reducer_type === "recovery" && reducerState.recovery_state === RecoveryStates.ContinentSelecting) ) { setAnastasisState({ ...anastasisState, currentError: undefined, reducerState: undefined, }); } else { await doTransition("back", {}); } }, dismissError() { setAnastasisState({ ...anastasisState, currentError: undefined }); }, reset() { setAnastasisState({ ...anastasisState, currentError: undefined, reducerState: undefined, }); }, async runTransaction(f) { const txHandle = new ReducerTxImpl(anastasisState.reducerState!); try { await f(txHandle); } catch (e) { console.log("exception during reducer transaction", e); } const s = txHandle.transactionState; if (s.reducer_type === "error") { setAnastasisState({ ...anastasisState, currentError: txHandle.transactionState, }); } else { setAnastasisState({ ...anastasisState, reducerState: txHandle.transactionState, currentError: undefined, }); } }, }; } class ReducerTxImpl implements ReducerTransactionHandle { constructor(public transactionState: ReducerState) {} async transition(action: string, args: any): Promise { let s: ReducerState; if (remoteReducer) { s = await reduceStateRemote(this.transactionState, action, args); } else { s = await reduceAction(this.transactionState, action, args); } this.transactionState = s; // Abort transaction as soon as we transition into an error state. if (this.transactionState.reducer_type === "error") { throw new Error("transition resulted in error"); } return this.transactionState; } }