From 9d2bac57ff268aff7a803557a8db4bb32640af34 Mon Sep 17 00:00:00 2001 From: Julian Early Date: Sun, 28 Jul 2024 16:17:14 -0700 Subject: [PATCH] Homebase tabs (#256) Co-authored-by: Jesse Paterson --- .../stores/app/accounts/authenticatorStore.ts | 62 +++-- .../data/stores/app/accounts/prekeyStore.ts | 11 +- .../data/stores/app/homebase/homebaseStore.ts | 56 ++-- .../stores/app/homebase/homebaseTabsStore.ts | 255 ++++++++++++++++++ .../data/stores/app/space/spaceStore.ts | 64 +++-- src/common/lib/signedFiles.ts | 1 + .../space/{homebase.ts => homebase/index.ts} | 0 .../api/space/homebase/tabs/[tabName].ts | 99 +++++++ src/pages/api/space/homebase/tabs/index.ts | 157 +++++++++++ src/pages/homebase/index.tsx | 3 +- 10 files changed, 613 insertions(+), 95 deletions(-) create mode 100644 src/common/data/stores/app/homebase/homebaseTabsStore.ts rename src/pages/api/space/{homebase.ts => homebase/index.ts} (100%) create mode 100644 src/pages/api/space/homebase/tabs/[tabName].ts create mode 100644 src/pages/api/space/homebase/tabs/index.ts diff --git a/src/common/data/stores/app/accounts/authenticatorStore.ts b/src/common/data/stores/app/accounts/authenticatorStore.ts index 60f1aeae..8976f5c1 100644 --- a/src/common/data/stores/app/accounts/authenticatorStore.ts +++ b/src/common/data/stores/app/accounts/authenticatorStore.ts @@ -19,7 +19,7 @@ export interface AuthenticatorState { export interface AuthenticatorActions { loadAuthenitcators: () => Promise; - commitAuthenticatorUpdatesToDatabase: () => Promise; + commitAuthenticatorUpdatesToDatabase: () => Promise | undefined; saveAuthenticatorConfig: (newConfig: AuthenticatorConfig) => Promise; listInstalledAuthenticators: () => string[]; resetAuthenticators: () => void; @@ -87,38 +87,36 @@ export const authenticatorStore = ( console.debug("Could not locate authenticator data"); } }, - commitAuthenticatorUpdatesToDatabase: async () => { - debounce(async () => { - if ( - get().account.authenticatorConfig === - get().account.authenticatorRemoteConfig - ) { - // Only update if changes have been made - return; - } - const configFile = await get().account.createEncryptedSignedFile( - stringify(get().account.authenticatorConfig), - "json", - ); - const postData: AuthenticatorUpdateRequest = { - file: configFile, - identityPublicKey: get().account.currentSpaceIdentityPublicKey!, - }; + commitAuthenticatorUpdatesToDatabase: debounce(async () => { + if ( + get().account.authenticatorConfig === + get().account.authenticatorRemoteConfig + ) { + // Only update if changes have been made + return; + } + const configFile = await get().account.createEncryptedSignedFile( + stringify(get().account.authenticatorConfig), + "json", + ); + const postData: AuthenticatorUpdateRequest = { + file: configFile, + identityPublicKey: get().account.currentSpaceIdentityPublicKey!, + }; - try { - await axiosBackend.post("/api/space/authenticators/", postData, { - headers: { "Content-Type": "application/json" }, - }); - set((draft) => { - draft.account.authenticatorRemoteConfig = - get().account.authenticatorConfig; - }, "commitAuthenticatorUpdatesToDatabase"); - } catch (e) { - console.debug("failed to save authenticator data, trying again"); - get().account.commitAuthenticatorUpdatesToDatabase(); - } - }, 1000)(); - }, + try { + await axiosBackend.post("/api/space/authenticators/", postData, { + headers: { "Content-Type": "application/json" }, + }); + set((draft) => { + draft.account.authenticatorRemoteConfig = + get().account.authenticatorConfig; + }, "commitAuthenticatorUpdatesToDatabase"); + } catch (e) { + console.debug("failed to save authenticator data, trying again"); + get().account.commitAuthenticatorUpdatesToDatabase(); + } + }, 1000), saveAuthenticatorConfig: async (newConfig) => { set((draft) => { draft.account.authenticatorConfig = newConfig; diff --git a/src/common/data/stores/app/accounts/prekeyStore.ts b/src/common/data/stores/app/accounts/prekeyStore.ts index ec105562..b9383df9 100644 --- a/src/common/data/stores/app/accounts/prekeyStore.ts +++ b/src/common/data/stores/app/accounts/prekeyStore.ts @@ -57,7 +57,10 @@ interface PreKeyActions { createEncryptedSignedFile: ( data: string, fileType: string, - useRootKey?: boolean, + options?: { + useRootKey?: boolean; + fileName?: string; + }, ) => Promise; decryptEncryptedSignedFile: (file: SignedFile) => Promise; generatePreKey: () => Promise; @@ -88,7 +91,8 @@ export const prekeyStore = ( currentIdentity.rootKeys.privateKey, ); }, - createEncryptedSignedFile: async (data, fileType, useRootKey = false) => { + createEncryptedSignedFile: async (data, fileType, options) => { + const useRootKey = options?.useRootKey || false; const key = useRootKey ? get().account.getCurrentIdentity()!.rootKeys : get().account.getCurrentPrekey() || @@ -102,6 +106,7 @@ export const prekeyStore = ( publicKey: key.publicKey, isEncrypted: true, timestamp: moment().toISOString(), + fileName: options?.fileName, }; return signSignable(file, key.privateKey); }, @@ -145,7 +150,7 @@ export const prekeyStore = ( const keyFile = await get().account.createEncryptedSignedFile( stringify(prekey), "json", - true, + { useRootKey: true }, ); const postData: PreKeyRequest = { file: keyFile, diff --git a/src/common/data/stores/app/homebase/homebaseStore.ts b/src/common/data/stores/app/homebase/homebaseStore.ts index 7f160d79..3cd127a8 100644 --- a/src/common/data/stores/app/homebase/homebaseStore.ts +++ b/src/common/data/stores/app/homebase/homebaseStore.ts @@ -1,12 +1,12 @@ import { StoreGet, StoreSet } from "../../createStore"; import { AppStore } from ".."; import axios from "axios"; -import { createClient } from "../../../database/supabase/clients/component"; +import { createClient } from "@/common/data/database/supabase/clients/component"; import { homebasePath } from "@/constants/supabase"; import { SignedFile } from "@/common/lib/signedFiles"; import { cloneDeep, debounce, isArray, isUndefined, mergeWith } from "lodash"; import stringify from "fast-json-stable-stringify"; -import axiosBackend from "../../../api/backend"; +import axiosBackend from "@/common/data/api/backend"; import { SpaceConfig, SpaceConfigSaveDetails, @@ -16,6 +16,10 @@ import { analytics, AnalyticsEvent, } from "@/common/providers/AnalyticsProvider"; +import { + HomeBaseTabStore, + createHomeBaseTabStoreFunc, +} from "./homebaseTabsStore"; import moment from "moment"; interface HomeBaseStoreState { @@ -25,13 +29,15 @@ interface HomeBaseStoreState { interface HomeBaseStoreActions { loadHomebase: () => Promise; - commitHomebaseToDatabase: () => Promise; + commitHomebaseToDatabase: () => Promise | undefined; saveHomebaseConfig: (config: SpaceConfigSaveDetails) => Promise; resetHomebaseConfig: () => Promise; clearHomebase: () => void; } -export type HomeBaseStore = HomeBaseStoreState & HomeBaseStoreActions; +export type HomeBaseStore = HomeBaseStoreState & + HomeBaseStoreActions & + HomeBaseTabStore; export const homeBaseStoreDefaults: HomeBaseStoreState = {}; @@ -40,6 +46,7 @@ export const createHomeBaseStoreFunc = ( get: StoreGet, ): HomeBaseStore => ({ ...homeBaseStoreDefaults, + ...createHomeBaseTabStoreFunc(set, get), loadHomebase: async () => { const supabase = createClient(); const { @@ -92,29 +99,26 @@ export const createHomeBaseStoreFunc = ( return cloneDeep(INITIAL_HOMEBASE_CONFIG); } }, - commitHomebaseToDatabase: async () => { - debounce(async () => { - const localCopy = cloneDeep(get().homebase.homebaseConfig); - if (localCopy) { - const file = await get().account.createEncryptedSignedFile( - stringify(localCopy), - "json", - true, - ); - // TO DO: Error handling - try { - await axiosBackend.post(`/api/space/homebase/`, file); - set((draft) => { - draft.homebase.remoteHomebaseConfig = localCopy; - }, "commitHomebaseToDatabase"); - analytics.track(AnalyticsEvent.SAVE_HOMEBASE_THEME); - } catch (e) { - console.error(e); - throw e; - } + commitHomebaseToDatabase: debounce(async () => { + const localCopy = cloneDeep(get().homebase.homebaseConfig); + if (localCopy) { + const file = await get().account.createEncryptedSignedFile( + stringify(localCopy), + "json", + { useRootKey: true }, + ); + try { + await axiosBackend.post(`/api/space/homebase/`, file); + set((draft) => { + draft.homebase.remoteHomebaseConfig = localCopy; + }, "commitHomebaseToDatabase"); + analytics.track(AnalyticsEvent.SAVE_HOMEBASE_THEME); + } catch (e) { + console.error(e); + throw e; } - }, 1000)(); - }, + } + }, 1000), saveHomebaseConfig: async (config) => { const localCopy = cloneDeep(get().homebase.homebaseConfig) as SpaceConfig; mergeWith(localCopy, config, (_, newItem) => { diff --git a/src/common/data/stores/app/homebase/homebaseTabsStore.ts b/src/common/data/stores/app/homebase/homebaseTabsStore.ts new file mode 100644 index 00000000..67620878 --- /dev/null +++ b/src/common/data/stores/app/homebase/homebaseTabsStore.ts @@ -0,0 +1,255 @@ +import { + SpaceConfig, + SpaceConfigSaveDetails, +} from "@/common/components/templates/Space"; +import { StoreGet, StoreSet } from "../../createStore"; +import { AppStore } from ".."; +import { cloneDeep, debounce, forEach, has, isArray, mergeWith } from "lodash"; +import stringify from "fast-json-stable-stringify"; +import axiosBackend from "@/common/data/api/backend"; +import { + ManageHomebaseTabsResponse, + UnsignedManageHomebaseTabsRequest, +} from "@/pages/api/space/homebase/tabs"; +import { createClient } from "@/common/data/database/supabase/clients/component"; +import { homebasePath } from "@/constants/supabase"; +import axios from "axios"; +import { SignedFile, signSignable } from "@/common/lib/signedFiles"; +import INITIAL_HOMEBASE_CONFIG from "@/constants/intialHomebase"; + +interface HomeBaseTabStoreState { + tabs: { + [tabName: string]: { + config?: SpaceConfig; + remoteConfig?: SpaceConfig; + }; + }; +} + +interface HomeBaseTabStoreActions { + loadTabNames: () => Promise; + renameTab: (tabName: string, newName: string) => Promise; + deleteTab: (tabName: string) => Promise; + createTab: (tabName: string) => Promise; + loadHomebaseTab: (tabName: string) => Promise; + commitHomebaseTabToDatabase: (tabName: string) => Promise | undefined; + saveHomebaseTabConfig: ( + tabName: string, + config: SpaceConfigSaveDetails, + ) => Promise; + resetHomebaseTabConfig: (tabName: string) => Promise; + clearHomebaseTabs: () => void; +} + +export type HomeBaseTabStore = HomeBaseTabStoreState & HomeBaseTabStoreActions; + +export const homeBaseStoreDefaults: HomeBaseTabStoreState = { + tabs: {}, +}; + +export const createHomeBaseTabStoreFunc = ( + set: StoreSet, + get: StoreGet, +): HomeBaseTabStore => ({ + ...homeBaseStoreDefaults, + async loadTabNames() { + try { + const { data } = await axiosBackend.get( + "/api/space/homebase/tabs", + { + params: { + identityPublicKey: get().account.currentSpaceIdentityPublicKey, + }, + }, + ); + if (data.result === "error") { + return []; + } else { + const currentTabs = get().homebase.tabs; + set((draft) => { + // Reset all tabs, this removes all ones that no longer exist + draft.homebase.tabs = {}; + forEach(data.value || [], (tabName) => { + // Set the tabs that we have and add the missing ones + draft.homebase.tabs[tabName] = currentTabs[tabName] || {}; + }); + }, "loadTabNames"); + return data.value || []; + } + } catch (e) { + console.debug("failed to load tab names", e); + return []; + } + }, + async createTab(tabName) { + const publicKey = get().account.currentSpaceIdentityPublicKey; + if (!publicKey) return; + const req: UnsignedManageHomebaseTabsRequest = { + publicKey, + type: "create", + tabName, + }; + const sigendReq = await signSignable( + req, + get().account.getCurrentIdentity()!.rootKeys.privateKey, + ); + try { + const { data } = await axiosBackend.post( + "/api/space/homebase/tabs", + sigendReq, + ); + if (data.result === "success") { + set((draft) => { + draft.homebase.tabs[tabName] = { + config: cloneDeep(INITIAL_HOMEBASE_CONFIG), + remoteConfig: cloneDeep(INITIAL_HOMEBASE_CONFIG), + }; + }, "createHomebaseTab"); + } + } catch (e) { + console.debug("failed to create homebase tab", e); + } + }, + async deleteTab(tabName) { + const publicKey = get().account.currentSpaceIdentityPublicKey; + if (!publicKey) return; + const req: UnsignedManageHomebaseTabsRequest = { + publicKey, + type: "delete", + tabName, + }; + const sigendReq = await signSignable( + req, + get().account.getCurrentIdentity()!.rootKeys.privateKey, + ); + try { + const { data } = await axiosBackend.post( + "/api/space/homebase/tabs", + sigendReq, + ); + if (data.result === "success") { + set((draft) => { + delete draft.homebase.tabs[tabName]; + }, "deleteHomebaseTab"); + } + } catch (e) { + console.debug("failed to delete homebase tab", e); + } + }, + async renameTab(tabName, newName) { + const publicKey = get().account.currentSpaceIdentityPublicKey; + if (!publicKey) return; + const req: UnsignedManageHomebaseTabsRequest = { + publicKey, + type: "rename", + tabName, + newName, + }; + const sigendReq = await signSignable( + req, + get().account.getCurrentIdentity()!.rootKeys.privateKey, + ); + try { + const { data } = await axiosBackend.post( + "/api/space/homebase/tabs", + sigendReq, + ); + if (data.result === "success") { + const currentTabData = get().homebase.tabs[tabName]; + set((draft) => { + delete draft.homebase.tabs[tabName]; + draft.homebase.tabs[newName] = currentTabData; + }, "renameHomebaseTab"); + } + } catch (e) { + console.debug("failed to rename homebase tab", e); + } + }, + async loadHomebaseTab(tabName) { + if (!has(get().homebase.tabs, tabName)) return; + const supabase = createClient(); + const { + data: { publicUrl }, + } = supabase.storage + .from("private") + .getPublicUrl( + `${homebasePath(get().account.currentSpaceIdentityPublicKey!)}/tabs/${tabName}`, + ); + try { + const { data } = await axios.get(publicUrl, { + responseType: "blob", + headers: { + "Cache-Control": "no-cache", + Pragma: "no-cache", + Expires: "0", + }, + }); + const fileData = JSON.parse(await data.text()) as SignedFile; + const spaceConfig = JSON.parse( + await get().account.decryptEncryptedSignedFile(fileData), + ) as SpaceConfig; + set((draft) => { + draft.homebase.tabs[tabName].config = cloneDeep(spaceConfig); + draft.homebase.tabs[tabName].remoteConfig = cloneDeep(spaceConfig); + }, `loadHomebase${tabName}-found`); + return spaceConfig; + } catch (e) { + set((draft) => { + draft.homebase.tabs[tabName].config = cloneDeep( + INITIAL_HOMEBASE_CONFIG, + ); + draft.homebase.tabs[tabName].remoteConfig = cloneDeep( + INITIAL_HOMEBASE_CONFIG, + ); + }, "loadHomebase-default"); + return cloneDeep(INITIAL_HOMEBASE_CONFIG); + } + }, + commitHomebaseTabToDatabase: debounce(async (tabname) => { + const localCopy = cloneDeep(get().homebase.tabs[tabname].config); + if (localCopy) { + const file = await get().account.createEncryptedSignedFile( + stringify(localCopy), + "json", + { useRootKey: true, fileName: tabname }, + ); + try { + await axiosBackend.post(`/api/space/homebase/tabs/${tabname}`, file); + set((draft) => { + draft.homebase.tabs[tabname].remoteConfig = localCopy; + }, "commitHomebaseToDatabase"); + } catch (e) { + console.error(e); + throw e; + } + } + }, 1000), + async saveHomebaseTabConfig(tabName, config) { + const localCopy = cloneDeep(get().homebase.tabs[tabName]) as SpaceConfig; + mergeWith(localCopy, config, (_, newItem) => { + if (isArray(newItem)) return newItem; + }); + set( + (draft) => { + draft.homebase.tabs[tabName].config = localCopy; + }, + `saveHomebaseTab${tabName}`, + false, + ); + }, + async resetHomebaseTabConfig(tabName) { + const currentTabInfo = get().homebase.tabs[tabName]; + if (currentTabInfo) { + set((draft) => { + draft.homebase.tabs[tabName].config = cloneDeep( + currentTabInfo.remoteConfig, + ); + }, `resetHomebaseTab${tabName}`); + } + }, + clearHomebaseTabs() { + set((draft) => { + draft.homebase.tabs = {}; + }, "clearHomebaseTabs"); + }, +}); diff --git a/src/common/data/stores/app/space/spaceStore.ts b/src/common/data/stores/app/space/spaceStore.ts index f6a71c00..88220ca2 100644 --- a/src/common/data/stores/app/space/spaceStore.ts +++ b/src/common/data/stores/app/space/spaceStore.ts @@ -89,7 +89,7 @@ interface SpaceActions { registerSpace: (fid: number, name: string) => Promise; renameSpace: (spaceId: string, name: string) => Promise; loadEditableSpaces: () => Promise>; - commitSpaceToDatabase: (spaceId: string) => Promise; + commitSpaceToDatabase: (spaceId: string) => Promise | undefined; saveLocalSpace: ( spaceId: string, config: UpdatableDatabaseWritableSpaceSaveConfig, @@ -249,40 +249,38 @@ export const createSpaceStoreFunc = ( return {}; } }, - commitSpaceToDatabase: async (spaceId) => { - debounce(async () => { - const localCopy = cloneDeep(get().space.localSpaces[spaceId]); - if (localCopy) { - const file = localCopy.isPrivate - ? await get().account.createEncryptedSignedFile( - stringify({ - ...localCopy, - isPrivate: undefined, - }), - "json", - true, - ) - : await get().account.createSignedFile( - stringify({ ...localCopy, isPrivate: undefined }), - "json", - ); - // TO DO: Error handling - await axiosBackend.post(`/api/space/registry/${spaceId}/`, { - spaceConfig: file, - }); + commitSpaceToDatabase: debounce(async (spaceId) => { + const localCopy = cloneDeep(get().space.localSpaces[spaceId]); + if (localCopy) { + const file = localCopy.isPrivate + ? await get().account.createEncryptedSignedFile( + stringify({ + ...localCopy, + isPrivate: undefined, + }), + "json", + { useRootKey: true }, + ) + : await get().account.createSignedFile( + stringify({ ...localCopy, isPrivate: undefined }), + "json", + ); + // TO DO: Error handling + await axiosBackend.post(`/api/space/registry/${spaceId}/`, { + spaceConfig: file, + }); - analytics.track(AnalyticsEvent.SAVE_SPACE_THEME); + analytics.track(AnalyticsEvent.SAVE_SPACE_THEME); - set((draft) => { - draft.space.remoteSpaces[spaceId] = { - id: spaceId, - config: localCopy, - updatedAt: moment().toISOString(), - }; - }, "commitSpaceToDatabase"); - } - }, 1000)(); - }, + set((draft) => { + draft.space.remoteSpaces[spaceId] = { + id: spaceId, + config: localCopy, + updatedAt: moment().toISOString(), + }; + }, "commitSpaceToDatabase"); + } + }, 1000), saveLocalSpace: async (spaceId, changedConfig) => { const localCopy = cloneDeep(get().space.localSpaces[spaceId]); mergeWith(localCopy, changedConfig, (_, newItem) => { diff --git a/src/common/lib/signedFiles.ts b/src/common/lib/signedFiles.ts index 9248ad5f..0f4c7a13 100644 --- a/src/common/lib/signedFiles.ts +++ b/src/common/lib/signedFiles.ts @@ -10,6 +10,7 @@ export interface UnsignedFile { fileType: string; isEncrypted: boolean; timestamp: string; + fileName?: string; } export type SignedFile = UnsignedFile & { diff --git a/src/pages/api/space/homebase.ts b/src/pages/api/space/homebase/index.ts similarity index 100% rename from src/pages/api/space/homebase.ts rename to src/pages/api/space/homebase/index.ts diff --git a/src/pages/api/space/homebase/tabs/[tabName].ts b/src/pages/api/space/homebase/tabs/[tabName].ts new file mode 100644 index 00000000..a4aa5dfe --- /dev/null +++ b/src/pages/api/space/homebase/tabs/[tabName].ts @@ -0,0 +1,99 @@ +import requestHandler, { + NounspaceResponse, +} from "@/common/data/api/requestHandler"; +import { + SignedFile, + isSignedFile, + validateSignable, +} from "@/common/lib/signedFiles"; +import { NextApiRequest, NextApiResponse } from "next/types"; +import supabase from "@/common/data/database/supabase/clients/server"; +import stringify from "fast-json-stable-stringify"; +import { homebasePath } from "@/constants/supabase"; +import { findIndex, isArray, isUndefined } from "lodash"; +import { listTabsForIdentity } from "."; + +export type UpdateHomebaseResponse = NounspaceResponse; +export type UpdateHomebaseRequest = SignedFile; + +async function updateHomebaseTab( + req: NextApiRequest, + res: NextApiResponse, +) { + const file: UpdateHomebaseRequest = req.body; + const tabName = req.query.tabName; + if (isUndefined(tabName) || isArray(tabName)) { + res.status(400).json({ + result: "error", + error: { + message: "Tab Name must be a string", + }, + }); + return; + } + if (!isSignedFile(file)) { + res.status(400).json({ + result: "error", + error: { + message: + "Config must contain publicKey, fileData, fileType, isEncrypted, and timestamp", + }, + }); + return; + } + if (isUndefined(file.fileName) || file.fileName !== tabName) { + res.status(400).json({ + result: "error", + error: { + message: "Filename must be the same as the tab name", + }, + }); + return; + } + if (!validateSignable(file)) { + res.status(400).json({ + result: "error", + error: { + message: "Invalid signature", + }, + }); + return; + } + const tabs = await listTabsForIdentity(file.publicKey); + + if (findIndex(tabs, tabName) === -1) { + res.status(500).json({ + result: "error", + error: { + message: "Tab does not exist", + }, + }); + } + + const { error } = await supabase.storage + .from("private") + .upload( + `${homebasePath(file.publicKey)}Tabs/${tabName}`, + new Blob([stringify(file)], { type: "application/json" }), + { + upsert: true, + }, + ); + if (error) { + res.status(500).json({ + result: "error", + error: { + message: error.message, + }, + }); + return; + } + res.status(200).json({ + result: "success", + value: true, + }); +} + +export default requestHandler({ + post: updateHomebaseTab, +}); diff --git a/src/pages/api/space/homebase/tabs/index.ts b/src/pages/api/space/homebase/tabs/index.ts new file mode 100644 index 00000000..80d2c368 --- /dev/null +++ b/src/pages/api/space/homebase/tabs/index.ts @@ -0,0 +1,157 @@ +import requestHandler, { + NounspaceResponse, +} from "@/common/data/api/requestHandler"; +import { + isSignable, + Signable, + validateSignable, +} from "@/common/lib/signedFiles"; +import { NextApiRequest, NextApiResponse } from "next/types"; +import supabase from "@/common/data/database/supabase/clients/server"; +import { homebasePath } from "@/constants/supabase"; +import { isArray, isNil, isUndefined, map } from "lodash"; +import { StorageError } from "@supabase/storage-js"; + +const homeBaseTabRequestTypes = ["create", "rename", "delete"] as const; +export type HomeBaseTabRequestType = (typeof homeBaseTabRequestTypes)[number]; + +type ListHomebaseTabsResult = string[]; + +export type ManageHomebaseTabsResponse = + NounspaceResponse; + +export type UnsignedManageHomebaseTabsRequest = { + publicKey: string; + type: HomeBaseTabRequestType; + tabName: string; + newName?: string; +}; +export type ManageHomebaseTabsRequest = Signable & + UnsignedManageHomebaseTabsRequest; + +function isUpdateHomebaseRequest( + maybe: unknown, +): maybe is ManageHomebaseTabsRequest { + return ( + isSignable(maybe) && + typeof maybe["publicKey"] === "string" && + typeof maybe["type"] === "string" && + maybe.type in homeBaseTabRequestTypes && + typeof maybe["tabName"] === "string" + ); +} + +async function manageHomebaseTabs( + req: NextApiRequest, + res: NextApiResponse, +) { + const updateReq: ManageHomebaseTabsRequest = req.body; + if (!isUpdateHomebaseRequest) { + res.status(400).json({ + result: "error", + error: { + message: + "Update body must include publicKey, type, tabName, and signature", + }, + }); + return; + } + if (updateReq.type === "rename" && isUndefined(updateReq.newName)) { + res.status(400).json({ + result: "error", + error: { + message: "Update body must include newName if type is rename", + }, + }); + return; + } + if (!validateSignable(updateReq)) { + res.status(400).json({ + result: "error", + error: { + message: "Invalid signature", + }, + }); + return; + } + let errorResult: StorageError | null; + + if (updateReq.type === "create") { + const { error } = await supabase.storage + .from("private") + .upload( + `${homebasePath(updateReq.publicKey)}Tabs/${updateReq.tabName}`, + new Blob(), + ); + errorResult = error; + } else if (updateReq.type === "delete") { + const { error } = await supabase.storage + .from("private") + .remove([ + `${homebasePath(updateReq.publicKey)}Tabs/${updateReq.tabName}`, + ]); + errorResult = error; + } else { + const { error } = await supabase.storage + .from("private") + .move( + `${homebasePath(updateReq.publicKey)}Tabs/${updateReq.tabName}`, + `${homebasePath(updateReq.publicKey)}Tabs/${updateReq.newName!}`, + ); + errorResult = error; + } + + if (errorResult) { + res.status(500).json({ + result: "error", + error: { + message: errorResult.message, + }, + }); + return; + } + res.status(200).json({ + result: "success", + value: await listTabsForIdentity(updateReq.publicKey), + }); +} + +export async function listTabsForIdentity( + identityPublicKey: string, +): Promise { + const { data: listResults, error: listErrors } = await supabase.storage + .from("private") + .list(`${homebasePath(identityPublicKey)}Tabs/`); + if (listErrors) { + return []; + } + if (isNil(listResults) || listResults.length === 0) { + return []; + } + return map(listResults, (f) => f.name); +} + +async function handleListTabsForIdentity( + req: NextApiRequest, + res: NextApiResponse, +) { + const publicKey = req.query.identityPublicKey; + if (!isUndefined(publicKey) && !isArray(publicKey)) { + res.status(200).json({ + result: "success", + value: await listTabsForIdentity(publicKey), + }); + } else { + res.status(400).json({ + result: "error", + error: { + message: "Must provide identityPublicKey as a single string", + }, + }); + } +} + +export default requestHandler({ + post: manageHomebaseTabs, + get: handleListTabsForIdentity, +}); diff --git a/src/pages/homebase/index.tsx b/src/pages/homebase/index.tsx index 5781a319..5b5236c9 100644 --- a/src/pages/homebase/index.tsx +++ b/src/pages/homebase/index.tsx @@ -50,7 +50,8 @@ const Homebase: NextPageWithLayout = () => { : { config: homebaseConfig, saveConfig, - commitConfig, + // To get types to match since store.commitConfig is debounced + commitConfig: async () => await commitConfig(), resetConfig, };