Browse Source

feat: add upstash redis cloud sync

Yidadaa 1 year ago
parent
commit
83fed42997

+ 37 - 2
app/components/settings.tsx

@@ -50,7 +50,7 @@ import Locale, {
 } from "../locales";
 import { copyToClipboard } from "../utils";
 import Link from "next/link";
-import { Path, RELEASE_URL, UPDATE_URL } from "../constant";
+import { Path, RELEASE_URL, STORAGE_KEY, UPDATE_URL } from "../constant";
 import { Prompt, SearchService, usePromptStore } from "../store/prompt";
 import { ErrorBoundary } from "./error";
 import { InputRange } from "./input-range";
@@ -413,7 +413,42 @@ function SyncConfigModal(props: { onClose?: () => void }) {
 
         {syncStore.provider === ProviderType.UpStash && (
           <List>
-            <ListItem title={Locale.WIP}></ListItem>
+            <ListItem title={Locale.Settings.Sync.Config.UpStash.Endpoint}>
+              <input
+                type="text"
+                value={syncStore.upstash.endpoint}
+                onChange={(e) => {
+                  syncStore.update(
+                    (config) =>
+                      (config.upstash.endpoint = e.currentTarget.value),
+                  );
+                }}
+              ></input>
+            </ListItem>
+
+            <ListItem title={Locale.Settings.Sync.Config.UpStash.UserName}>
+              <input
+                type="text"
+                value={syncStore.upstash.username}
+                placeholder={STORAGE_KEY}
+                onChange={(e) => {
+                  syncStore.update(
+                    (config) =>
+                      (config.upstash.username = e.currentTarget.value),
+                  );
+                }}
+              ></input>
+            </ListItem>
+            <ListItem title={Locale.Settings.Sync.Config.UpStash.Password}>
+              <PasswordInput
+                value={syncStore.upstash.apiKey}
+                onChange={(e) => {
+                  syncStore.update(
+                    (config) => (config.upstash.apiKey = e.currentTarget.value),
+                  );
+                }}
+              ></PasswordInput>
+            </ListItem>
           </List>
         )}
       </Modal>

+ 6 - 0
app/locales/cn.ts

@@ -207,6 +207,12 @@ const cn = {
           UserName: "用户名",
           Password: "密码",
         },
+
+        UpStash: {
+          Endpoint: "UpStash Redis REST Url",
+          UserName: "备份名称",
+          Password: "UpStash Redis REST Token",
+        },
       },
 
       LocalState: "本地数据",

+ 6 - 0
app/locales/en.ts

@@ -210,6 +210,12 @@ const en: LocaleType = {
           UserName: "User Name",
           Password: "Password",
         },
+
+        UpStash: {
+          Endpoint: "UpStash Redis REST Url",
+          UserName: "Backup Name",
+          Password: "UpStash Redis REST Token",
+        },
       },
 
       LocalState: "Local Data",

+ 2 - 2
app/store/sync.ts

@@ -1,5 +1,5 @@
 import { Updater } from "../typing";
-import { ApiPath, StoreKey } from "../constant";
+import { ApiPath, STORAGE_KEY, StoreKey } from "../constant";
 import { createPersistStore } from "../utils/store";
 import {
   AppState,
@@ -36,7 +36,7 @@ export const useSyncStore = createPersistStore(
 
     upstash: {
       endpoint: "",
-      username: "",
+      username: STORAGE_KEY,
       apiKey: "",
     },
 

+ 68 - 6
app/utils/cloud/upstash.ts

@@ -1,25 +1,87 @@
+import { STORAGE_KEY } from "@/app/constant";
 import { SyncStore } from "@/app/store/sync";
+import { corsFetch } from "../cors";
+import { chunks } from "../format";
 
 export type UpstashConfig = SyncStore["upstash"];
 export type UpStashClient = ReturnType<typeof createUpstashClient>;
 
-export function createUpstashClient(config: UpstashConfig) {
+export function createUpstashClient(store: SyncStore) {
+  const config = store.upstash;
+  const storeKey = config.username.length === 0 ? STORAGE_KEY : config.username;
+  const chunkCountKey = `${storeKey}-chunk-count`;
+  const chunkIndexKey = (i: number) => `${storeKey}-chunk-${i}`;
+
+  const proxyUrl =
+    store.useProxy && store.proxyUrl.length > 0 ? store.proxyUrl : undefined;
+
   return {
     async check() {
-      return true;
+      try {
+        const res = await corsFetch(this.path(`get/${storeKey}`), {
+          method: "GET",
+          headers: this.headers(),
+          proxyUrl,
+        });
+        console.log("[Upstash] check", res.status, res.statusText);
+        return [200].includes(res.status);
+      } catch (e) {
+        console.error("[Upstash] failed to check", e);
+      }
+      return false;
+    },
+
+    async redisGet(key: string) {
+      const res = await corsFetch(this.path(`get/${key}`), {
+        method: "GET",
+        headers: this.headers(),
+        proxyUrl,
+      });
+
+      console.log("[Upstash] get key = ", key, res.status, res.statusText);
+      const resJson = (await res.json()) as { result: string };
+
+      return resJson.result;
+    },
+
+    async redisSet(key: string, value: string) {
+      const res = await corsFetch(this.path(`set/${key}`), {
+        method: "POST",
+        headers: this.headers(),
+        body: value,
+        proxyUrl,
+      });
+
+      console.log("[Upstash] set key = ", key, res.status, res.statusText);
     },
 
     async get() {
-      throw Error("[Sync] not implemented");
+      const chunkCount = Number(await this.redisGet(chunkCountKey));
+      if (!Number.isInteger(chunkCount)) return;
+
+      const chunks = await Promise.all(
+        new Array(chunkCount)
+          .fill(0)
+          .map((_, i) => this.redisGet(chunkIndexKey(i))),
+      );
+      console.log("[Upstash] get full chunks", chunks);
+      return chunks.join("");
     },
 
-    async set() {
-      throw Error("[Sync] not implemented");
+    async set(_: string, value: string) {
+      // upstash limit the max request size which is 1Mb for “Free” and “Pay as you go”
+      // so we need to split the data to chunks
+      let index = 0;
+      for await (const chunk of chunks(value)) {
+        await this.redisSet(chunkIndexKey(index), chunk);
+        index += 1;
+      }
+      await this.redisSet(chunkCountKey, index.toString());
     },
 
     headers() {
       return {
-        Authorization: `Basic ${config.apiKey}`,
+        Authorization: `Bearer ${config.apiKey}`,
       };
     },
     path(path: string) {

+ 0 - 2
app/utils/cloud/webdav.ts

@@ -20,9 +20,7 @@ export function createWebDavClient(store: SyncStore) {
           headers: this.headers(),
           proxyUrl,
         });
-
         console.log("[WebDav] check", res.status, res.statusText);
-
         return [201, 200, 404, 401].includes(res.status);
       } catch (e) {
         console.error("[WebDav] failed to check", e);

+ 15 - 0
app/utils/format.ts

@@ -11,3 +11,18 @@ export function prettyObject(msg: any) {
   }
   return ["```json", msg, "```"].join("\n");
 }
+
+export function* chunks(s: string, maxBytes = 1000 * 1000) {
+  const decoder = new TextDecoder("utf-8");
+  let buf = new TextEncoder().encode(s);
+  while (buf.length) {
+    let i = buf.lastIndexOf(32, maxBytes + 1);
+    // If no space found, try forward search
+    if (i < 0) i = buf.indexOf(32, maxBytes);
+    // If there's no space at all, take all
+    if (i < 0) i = buf.length;
+    // This is a safe cut-off point; never half-way a multi-byte
+    yield decoder.decode(buf.slice(0, i));
+    buf = buf.slice(i + 1); // Skip space (if any)
+  }
+}

+ 3 - 0
app/utils/sync.ts

@@ -69,6 +69,9 @@ const MergeStates: StateMerger = {
     localState.sessions.forEach((s) => (localSessions[s.id] = s));
 
     remoteState.sessions.forEach((remoteSession) => {
+      // skip empty chats
+      if (remoteSession.messages.length === 0) return;
+
       const localSession = localSessions[remoteSession.id];
       if (!localSession) {
         // if remote session is new, just merge it