소스 검색

feat: add webdav support

Yidadaa 1 년 전
부모
커밋
6f83fbd212

+ 119 - 0
3

@@ -0,0 +1,119 @@
+export const OWNER = "Yidadaa";
+export const REPO = "ChatGPT-Next-Web";
+export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
+export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
+export const UPDATE_URL = `${REPO_URL}#keep-updated`;
+export const RELEASE_URL = `${REPO_URL}/releases`;
+export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/commits?per_page=1`;
+export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
+export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
+export const DEFAULT_API_HOST = "https://chatgpt1.nextweb.fun/api/proxy";
+
+export enum Path {
+  Home = "/",
+  Chat = "/chat",
+  Settings = "/settings",
+  NewChat = "/new-chat",
+  Masks = "/masks",
+  Auth = "/auth",
+}
+
+export enum SlotID {
+  AppBody = "app-body",
+}
+
+export enum FileName {
+  Masks = "masks.json",
+  Prompts = "prompts.json",
+}
+
+export enum StoreKey {
+  Chat = "chat-next-web-store",
+  Access = "access-control",
+  Config = "app-config",
+  Mask = "mask-store",
+  Prompt = "prompt-store",
+  Update = "chat-update",
+  Sync = "sync",
+}
+
+export const MAX_SIDEBAR_WIDTH = 500;
+export const MIN_SIDEBAR_WIDTH = 230;
+export const NARROW_SIDEBAR_WIDTH = 100;
+
+export const ACCESS_CODE_PREFIX = "nk-";
+
+export const LAST_INPUT_KEY = "last-input";
+export const UNFINISHED_INPUT = (id: string) => "unfinished-input-" + id;
+
+export const STORAGE_KEY = "chatgpt-next-web";
+
+export const REQUEST_TIMEOUT_MS = 60000;
+
+export const EXPORT_MESSAGE_CLASS_NAME = "export-markdown";
+
+export const OpenaiPath = {
+  ChatPath: "v1/chat/completions",
+  UsagePath: "dashboard/billing/usage",
+  SubsPath: "dashboard/billing/subscription",
+  ListModelPath: "v1/models",
+};
+
+export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
+export const DEFAULT_SYSTEM_TEMPLATE = `
+You are ChatGPT, a large language model trained by OpenAI.
+Knowledge cutoff: 2021-09
+Current model: {{model}}
+Current time: {{time}}`;
+
+export const SUMMARIZE_MODEL = "gpt-3.5-turbo";
+
+export const DEFAULT_MODELS = [
+  {
+    name: "gpt-4",
+    available: true,
+  },
+  {
+    name: "gpt-4-0314",
+    available: true,
+  },
+  {
+    name: "gpt-4-0613",
+    available: true,
+  },
+  {
+    name: "gpt-4-32k",
+    available: true,
+  },
+  {
+    name: "gpt-4-32k-0314",
+    available: true,
+  },
+  {
+    name: "gpt-4-32k-0613",
+    available: true,
+  },
+  {
+    name: "gpt-3.5-turbo",
+    available: true,
+  },
+  {
+    name: "gpt-3.5-turbo-0301",
+    available: true,
+  },
+  {
+    name: "gpt-3.5-turbo-0613",
+    available: true,
+  },
+  {
+    name: "gpt-3.5-turbo-16k",
+    available: true,
+  },
+  {
+    name: "gpt-3.5-turbo-16k-0613",
+    available: true,
+  },
+] as const;
+
+export const CHAT_PAGE_SIZE = 15;
+export const MAX_RENDER_MSG_COUNT = 45;

+ 44 - 0
app/api/cors/[...path]/route.ts

@@ -0,0 +1,44 @@
+import { NextRequest, NextResponse } from "next/server";
+
+async function handle(
+  req: NextRequest,
+  { params }: { params: { path: string[] } },
+) {
+  if (req.method === "OPTIONS") {
+    return NextResponse.json({ body: "OK" }, { status: 200 });
+  }
+
+  const [protocol, ...subpath] = params.path;
+  const targetUrl = `${protocol}://${subpath.join("/")}`;
+
+  const method = req.headers.get("method") ?? undefined;
+  const shouldNotHaveBody = ["get", "head"].includes(
+    method?.toLowerCase() ?? "",
+  );
+
+  const fetchOptions: RequestInit = {
+    headers: {
+      authorization: req.headers.get("authorization") ?? "",
+    },
+    body: shouldNotHaveBody ? null : req.body,
+    method,
+    // @ts-ignore
+    duplex: "half",
+  };
+
+  console.log("[Any Proxy]", targetUrl);
+
+  const fetchResult = fetch(targetUrl, fetchOptions);
+
+  return fetchResult;
+}
+
+export const GET = handle;
+export const POST = handle;
+export const PUT = handle;
+
+// nextjs dose not support those https methods, sucks
+export const PROFIND = handle;
+export const MKCOL = handle;
+
+export const runtime = "edge";

+ 243 - 36
app/components/settings.tsx

@@ -12,6 +12,12 @@ import EditIcon from "../icons/edit.svg";
 import EyeIcon from "../icons/eye.svg";
 import DownloadIcon from "../icons/download.svg";
 import UploadIcon from "../icons/upload.svg";
+import ConfigIcon from "../icons/config.svg";
+import ConfirmIcon from "../icons/confirm.svg";
+
+import ConnectionIcon from "../icons/connection.svg";
+import CloudSuccessIcon from "../icons/cloud-success.svg";
+import CloudFailIcon from "../icons/cloud-fail.svg";
 
 import {
   Input,
@@ -54,6 +60,7 @@ import { getClientConfig } from "../config/client";
 import { useSyncStore } from "../store/sync";
 import { nanoid } from "nanoid";
 import { useMaskStore } from "../store/mask";
+import { ProviderType } from "../utils/cloud";
 
 function EditPromptModal(props: { id: string; onClose: () => void }) {
   const promptStore = usePromptStore();
@@ -247,12 +254,183 @@ function DangerItems() {
   );
 }
 
+function CheckButton() {
+  const syncStore = useSyncStore();
+
+  const couldCheck = useMemo(() => {
+    return syncStore.coundSync();
+  }, [syncStore]);
+
+  const [checkState, setCheckState] = useState<
+    "none" | "checking" | "success" | "failed"
+  >("none");
+
+  async function check() {
+    setCheckState("checking");
+    const valid = await syncStore.check();
+    setCheckState(valid ? "success" : "failed");
+  }
+
+  if (!couldCheck) return null;
+
+  return (
+    <IconButton
+      text="检查可用性"
+      bordered
+      onClick={check}
+      icon={
+        checkState === "none" ? (
+          <ConnectionIcon />
+        ) : checkState === "checking" ? (
+          <LoadingIcon />
+        ) : checkState === "success" ? (
+          <CloudSuccessIcon />
+        ) : checkState === "failed" ? (
+          <CloudFailIcon />
+        ) : (
+          <ConnectionIcon />
+        )
+      }
+    ></IconButton>
+  );
+}
+
+function SyncConfigModal(props: { onClose?: () => void }) {
+  const syncStore = useSyncStore();
+
+  return (
+    <div className="modal-mask">
+      <Modal
+        title={Locale.Settings.Sync.Config.Modal.Title}
+        onClose={() => props.onClose?.()}
+        actions={[
+          <CheckButton key="check" />,
+          <IconButton
+            key="confirm"
+            onClick={props.onClose}
+            icon={<ConfirmIcon />}
+            bordered
+            text={Locale.UI.Confirm}
+          />,
+        ]}
+      >
+        <List>
+          <ListItem
+            title={Locale.Settings.Sync.Config.SyncType.Title}
+            subTitle={Locale.Settings.Sync.Config.SyncType.SubTitle}
+          >
+            <select
+              value={syncStore.provider}
+              onChange={(e) => {
+                syncStore.update(
+                  (config) =>
+                    (config.provider = e.target.value as ProviderType),
+                );
+              }}
+            >
+              {Object.entries(ProviderType).map(([k, v]) => (
+                <option value={v} key={k}>
+                  {k}
+                </option>
+              ))}
+            </select>
+          </ListItem>
+
+          <ListItem
+            title={Locale.Settings.Sync.Config.Proxy.Title}
+            subTitle={Locale.Settings.Sync.Config.Proxy.SubTitle}
+          >
+            <input
+              type="checkbox"
+              checked={syncStore.useProxy}
+              onChange={(e) => {
+                syncStore.update(
+                  (config) => (config.useProxy = e.currentTarget.checked),
+                );
+              }}
+            ></input>
+          </ListItem>
+          {syncStore.useProxy ? (
+            <ListItem
+              title={Locale.Settings.Sync.Config.ProxyUrl.Title}
+              subTitle={Locale.Settings.Sync.Config.ProxyUrl.SubTitle}
+            >
+              <input
+                type="text"
+                value={syncStore.proxyUrl}
+                onChange={(e) => {
+                  syncStore.update(
+                    (config) => (config.proxyUrl = e.currentTarget.value),
+                  );
+                }}
+              ></input>
+            </ListItem>
+          ) : null}
+        </List>
+
+        {syncStore.provider === ProviderType.WebDAV && (
+          <>
+            <List>
+              <ListItem title={Locale.Settings.Sync.Config.WebDav.Endpoint}>
+                <input
+                  type="text"
+                  value={syncStore.webdav.endpoint}
+                  onChange={(e) => {
+                    syncStore.update(
+                      (config) =>
+                        (config.webdav.endpoint = e.currentTarget.value),
+                    );
+                  }}
+                ></input>
+              </ListItem>
+
+              <ListItem title={Locale.Settings.Sync.Config.WebDav.UserName}>
+                <input
+                  type="text"
+                  value={syncStore.webdav.username}
+                  onChange={(e) => {
+                    syncStore.update(
+                      (config) =>
+                        (config.webdav.username = e.currentTarget.value),
+                    );
+                  }}
+                ></input>
+              </ListItem>
+              <ListItem title={Locale.Settings.Sync.Config.WebDav.Password}>
+                <PasswordInput
+                  value={syncStore.webdav.password}
+                  onChange={(e) => {
+                    syncStore.update(
+                      (config) =>
+                        (config.webdav.password = e.currentTarget.value),
+                    );
+                  }}
+                ></PasswordInput>
+              </ListItem>
+            </List>
+          </>
+        )}
+
+        {syncStore.provider === ProviderType.UpStash && (
+          <List>
+            <ListItem title={Locale.WIP}></ListItem>
+          </List>
+        )}
+      </Modal>
+    </div>
+  );
+}
+
 function SyncItems() {
   const syncStore = useSyncStore();
-  const webdav = syncStore.webDavConfig;
   const chatStore = useChatStore();
   const promptStore = usePromptStore();
   const maskStore = useMaskStore();
+  const couldSync = useMemo(() => {
+    return syncStore.coundSync();
+  }, [syncStore]);
+
+  const [showSyncConfigModal, setShowSyncConfigModal] = useState(false);
 
   const stateOverview = useMemo(() => {
     const sessions = chatStore.sessions;
@@ -267,42 +445,71 @@ function SyncItems() {
   }, [chatStore.sessions, maskStore.masks, promptStore.prompts]);
 
   return (
-    <List>
-      <ListItem
-        title={Locale.Settings.Sync.LastUpdate}
-        subTitle={new Date(syncStore.lastSyncTime).toLocaleString()}
-      >
-        <IconButton
-          icon={<ResetIcon />}
-          text={Locale.UI.Sync}
-          onClick={() => {
-            showToast(Locale.WIP);
-          }}
-        />
-      </ListItem>
+    <>
+      <List>
+        <ListItem
+          title={Locale.Settings.Sync.CloudState}
+          subTitle={
+            syncStore.lastProvider
+              ? `${new Date(syncStore.lastSyncTime).toLocaleString()} [${
+                  syncStore.lastProvider
+                }]`
+              : Locale.Settings.Sync.NotSyncYet
+          }
+        >
+          <div style={{ display: "flex" }}>
+            <IconButton
+              icon={<ConfigIcon />}
+              text={Locale.UI.Config}
+              onClick={() => {
+                setShowSyncConfigModal(true);
+              }}
+            />
+            {couldSync && (
+              <IconButton
+                icon={<ResetIcon />}
+                text={Locale.UI.Sync}
+                onClick={async () => {
+                  try {
+                    await syncStore.sync();
+                    showToast(Locale.Settings.Sync.Success);
+                  } catch (e) {
+                    showToast(Locale.Settings.Sync.Fail);
+                    console.error("[Sync]", e);
+                  }
+                }}
+              />
+            )}
+          </div>
+        </ListItem>
 
-      <ListItem
-        title={Locale.Settings.Sync.LocalState}
-        subTitle={Locale.Settings.Sync.Overview(stateOverview)}
-      >
-        <div style={{ display: "flex" }}>
-          <IconButton
-            icon={<UploadIcon />}
-            text={Locale.UI.Export}
-            onClick={() => {
-              syncStore.export();
-            }}
-          />
-          <IconButton
-            icon={<DownloadIcon />}
-            text={Locale.UI.Import}
-            onClick={() => {
-              syncStore.import();
-            }}
-          />
-        </div>
-      </ListItem>
-    </List>
+        <ListItem
+          title={Locale.Settings.Sync.LocalState}
+          subTitle={Locale.Settings.Sync.Overview(stateOverview)}
+        >
+          <div style={{ display: "flex" }}>
+            <IconButton
+              icon={<UploadIcon />}
+              text={Locale.UI.Export}
+              onClick={() => {
+                syncStore.export();
+              }}
+            />
+            <IconButton
+              icon={<DownloadIcon />}
+              text={Locale.UI.Import}
+              onClick={() => {
+                syncStore.import();
+              }}
+            />
+          </div>
+        </ListItem>
+      </List>
+
+      {showSyncConfigModal && (
+        <SyncConfigModal onClose={() => setShowSyncConfigModal(false)} />
+      )}
+    </>
   );
 }
 

+ 9 - 1
app/constant.ts

@@ -7,7 +7,9 @@ export const RELEASE_URL = `${REPO_URL}/releases`;
 export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/commits?per_page=1`;
 export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
 export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
-export const DEFAULT_API_HOST = "https://chatgpt1.nextweb.fun/api/proxy";
+
+export const DEFAULT_CORS_HOST = "https://chatgpt2.nextweb.fun";
+export const DEFAULT_API_HOST = `${DEFAULT_CORS_HOST}/api/proxy`;
 
 export enum Path {
   Home = "/",
@@ -18,6 +20,10 @@ export enum Path {
   Auth = "/auth",
 }
 
+export enum ApiPath {
+  Cors = "/api/cors",
+}
+
 export enum SlotID {
   AppBody = "app-body",
 }
@@ -46,6 +52,8 @@ export const ACCESS_CODE_PREFIX = "nk-";
 export const LAST_INPUT_KEY = "last-input";
 export const UNFINISHED_INPUT = (id: string) => "unfinished-input-" + id;
 
+export const STORAGE_KEY = "chatgpt-next-web";
+
 export const REQUEST_TIMEOUT_MS = 60000;
 
 export const EXPORT_MESSAGE_CLASS_NAME = "export-markdown";

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
app/icons/cloud-fail.svg


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
app/icons/cloud-success.svg


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
app/icons/config.svg


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
app/icons/connection.svg


+ 30 - 1
app/locales/cn.ts

@@ -179,7 +179,35 @@ const cn = {
       SubTitle: "根据对话内容生成合适的标题",
     },
     Sync: {
-      LastUpdate: "上次同步",
+      CloudState: "云端数据",
+      NotSyncYet: "还没有进行过同步",
+      Success: "同步成功",
+      Fail: "同步失败",
+
+      Config: {
+        Modal: {
+          Title: "配置云同步",
+        },
+        SyncType: {
+          Title: "同步类型",
+          SubTitle: "选择喜爱的同步服务器",
+        },
+        Proxy: {
+          Title: "启用代理",
+          SubTitle: "在浏览器中同步时,必须启用代理以避免跨域限制",
+        },
+        ProxyUrl: {
+          Title: "代理地址",
+          SubTitle: "仅适用于本项目自带的跨域代理",
+        },
+
+        WebDav: {
+          Endpoint: "WebDAV 地址",
+          UserName: "用户名",
+          Password: "密码",
+        },
+      },
+
       LocalState: "本地数据",
       Overview: (overview: any) => {
         return `${overview.chat} 次对话,${overview.message} 条消息,${overview.prompt} 条提示词,${overview.mask} 个面具`;
@@ -366,6 +394,7 @@ const cn = {
     Export: "导出",
     Import: "导入",
     Sync: "同步",
+    Config: "配置",
   },
   Exporter: {
     Model: "模型",

+ 31 - 1
app/locales/en.ts

@@ -181,7 +181,36 @@ const en: LocaleType = {
       SubTitle: "Generate a suitable title based on the conversation content",
     },
     Sync: {
-      LastUpdate: "Last Update",
+      CloudState: "Last Update",
+      NotSyncYet: "Not sync yet",
+      Success: "Sync Success",
+      Fail: "Sync Fail",
+
+      Config: {
+        Modal: {
+          Title: "Config Sync",
+        },
+        SyncType: {
+          Title: "Sync Type",
+          SubTitle: "Choose your favorite sync service",
+        },
+        Proxy: {
+          Title: "Enable CORS Proxy",
+          SubTitle: "Enable a proxy to avoid cross-origin restrictions",
+        },
+        ProxyUrl: {
+          Title: "Proxy Endpoint",
+          SubTitle:
+            "Only applicable to the built-in CORS proxy for this project",
+        },
+
+        WebDav: {
+          Endpoint: "WebDAV Endpoint",
+          UserName: "User Name",
+          Password: "Password",
+        },
+      },
+
       LocalState: "Local Data",
       Overview: (overview: any) => {
         return `${overview.chat} chats,${overview.message} messages,${overview.prompt} prompts,${overview.mask} masks`;
@@ -366,6 +395,7 @@ const en: LocaleType = {
     Export: "Export",
     Import: "Import",
     Sync: "Sync",
+    Config: "Config",
   },
   Exporter: {
     Model: "Model",

+ 50 - 37
app/store/sync.ts

@@ -1,15 +1,18 @@
 import { Updater } from "../typing";
-import { StoreKey } from "../constant";
+import { ApiPath, StoreKey } from "../constant";
 import { createPersistStore } from "../utils/store";
 import {
   AppState,
   getLocalAppState,
+  GetStoreState,
   mergeAppState,
   setLocalAppState,
 } from "../utils/sync";
 import { downloadAs, readFromFile } from "../utils";
 import { showToast } from "../components/ui-lib";
 import Locale from "../locales";
+import { createSyncClient, ProviderType } from "../utils/cloud";
+import { corsPath } from "../utils/cors";
 
 export interface WebDavConfig {
   server: string;
@@ -17,22 +20,43 @@ export interface WebDavConfig {
   password: string;
 }
 
+export type SyncStore = GetStoreState<typeof useSyncStore>;
+
 export const useSyncStore = createPersistStore(
   {
-    webDavConfig: {
-      server: "",
+    provider: ProviderType.WebDAV,
+    useProxy: true,
+    proxyUrl: corsPath(ApiPath.Cors),
+
+    webdav: {
+      endpoint: "",
       username: "",
       password: "",
     },
 
+    upstash: {
+      endpoint: "",
+      username: "",
+      apiKey: "",
+    },
+
     lastSyncTime: 0,
+    lastProvider: "",
   },
   (set, get) => ({
+    coundSync() {
+      const config = get()[get().provider];
+      return Object.values(config).every((c) => c.toString().length > 0);
+    },
+
+    markSyncTime() {
+      set({ lastSyncTime: Date.now(), lastProvider: get().provider });
+    },
+
     export() {
       const state = getLocalAppState();
       const fileName = `Backup-${new Date().toLocaleString()}.json`;
       downloadAs(JSON.stringify(state), fileName);
-      set({ lastSyncTime: Date.now() });
     },
 
     async import() {
@@ -50,47 +74,36 @@ export const useSyncStore = createPersistStore(
       }
     },
 
-    async check() {
-      try {
-        const res = await fetch(this.path(""), {
-          method: "PROFIND",
-          headers: this.headers(),
-        });
-        const sanitizedRes = {
-          status: res.status,
-          statusText: res.statusText,
-          headers: res.headers,
-        };
-        console.log(sanitizedRes);
-        return res.status === 207;
-      } catch (e) {
-        console.error("[Sync] ", e);
-        return false;
-      }
+    getClient() {
+      const provider = get().provider;
+      const client = createSyncClient(provider, get());
+      return client;
     },
 
-    path(path: string) {
-      let url = get().webDavConfig.server;
+    async sync() {
+      const localState = getLocalAppState();
+      const provider = get().provider;
+      const config = get()[provider];
+      const client = this.getClient();
 
-      if (!url.endsWith("/")) {
-        url += "/";
+      try {
+        const remoteState = JSON.parse(
+          await client.get(config.username),
+        ) as AppState;
+        mergeAppState(localState, remoteState);
+        setLocalAppState(localState);
+      } catch (e) {
+        console.log("[Sync] failed to get remoate state", e);
       }
 
-      if (path.startsWith("/")) {
-        path = path.slice(1);
-      }
+      await client.set(config.username, JSON.stringify(localState));
 
-      return url + path;
+      this.markSyncTime();
     },
 
-    headers() {
-      const auth = btoa(
-        [get().webDavConfig.username, get().webDavConfig.password].join(":"),
-      );
-
-      return {
-        Authorization: `Basic ${auth}`,
-      };
+    async check() {
+      const client = this.getClient();
+      return await client.check();
     },
   }),
   {

+ 33 - 0
app/utils/cloud/index.ts

@@ -0,0 +1,33 @@
+import { createWebDavClient } from "./webdav";
+import { createUpstashClient } from "./upstash";
+
+export enum ProviderType {
+  WebDAV = "webdav",
+  UpStash = "upstash",
+}
+
+export const SyncClients = {
+  [ProviderType.UpStash]: createUpstashClient,
+  [ProviderType.WebDAV]: createWebDavClient,
+} as const;
+
+type SyncClientConfig = {
+  [K in keyof typeof SyncClients]: (typeof SyncClients)[K] extends (
+    _: infer C,
+  ) => any
+    ? C
+    : never;
+};
+
+export type SyncClient = {
+  get: (key: string) => Promise<string>;
+  set: (key: string, value: string) => Promise<void>;
+  check: () => Promise<boolean>;
+};
+
+export function createSyncClient<T extends ProviderType>(
+  provider: T,
+  config: SyncClientConfig[T],
+): SyncClient {
+  return SyncClients[provider](config as any) as any;
+}

+ 39 - 0
app/utils/cloud/upstash.ts

@@ -0,0 +1,39 @@
+import { SyncStore } from "@/app/store/sync";
+
+export type UpstashConfig = SyncStore["upstash"];
+export type UpStashClient = ReturnType<typeof createUpstashClient>;
+
+export function createUpstashClient(config: UpstashConfig) {
+  return {
+    async check() {
+      return true;
+    },
+
+    async get() {
+      throw Error("[Sync] not implemented");
+    },
+
+    async set() {
+      throw Error("[Sync] not implemented");
+    },
+
+    headers() {
+      return {
+        Authorization: `Basic ${config.apiKey}`,
+      };
+    },
+    path(path: string) {
+      let url = config.endpoint;
+
+      if (!url.endsWith("/")) {
+        url += "/";
+      }
+
+      if (path.startsWith("/")) {
+        path = path.slice(1);
+      }
+
+      return url + path;
+    },
+  };
+}

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

@@ -0,0 +1,78 @@
+import { STORAGE_KEY } from "@/app/constant";
+import { SyncStore } from "@/app/store/sync";
+import { corsFetch } from "../cors";
+
+export type WebDAVConfig = SyncStore["webdav"];
+export type WebDavClient = ReturnType<typeof createWebDavClient>;
+
+export function createWebDavClient(store: SyncStore) {
+  const folder = STORAGE_KEY;
+  const fileName = `${folder}/backup.json`;
+  const config = store.webdav;
+  const proxyUrl =
+    store.useProxy && store.proxyUrl.length > 0 ? store.proxyUrl : undefined;
+
+  return {
+    async check() {
+      try {
+        const res = await corsFetch(this.path(folder), {
+          method: "MKCOL",
+          headers: this.headers(),
+          proxyUrl,
+        });
+
+        console.log("[WebDav] check", res.status, res.statusText);
+
+        return [201, 200, 404].includes(res.status);
+      } catch (e) {
+        console.error("[WebDav] failed to check", e);
+      }
+
+      return false;
+    },
+
+    async get(key: string) {
+      const res = await corsFetch(this.path(fileName), {
+        method: "GET",
+        headers: this.headers(),
+        proxyUrl,
+      });
+
+      console.log("[WebDav] get key = ", key, res.status, res.statusText);
+
+      return await res.text();
+    },
+
+    async set(key: string, value: string) {
+      const res = await corsFetch(this.path(fileName), {
+        method: "PUT",
+        headers: this.headers(),
+        body: value,
+        proxyUrl,
+      });
+
+      console.log("[WebDav] set key = ", key, res.status, res.statusText);
+    },
+
+    headers() {
+      const auth = btoa(config.username + ":" + config.password);
+
+      return {
+        authorization: `Basic ${auth}`,
+      };
+    },
+    path(path: string) {
+      let url = config.endpoint;
+
+      if (!url.endsWith("/")) {
+        url += "/";
+      }
+
+      if (path.startsWith("/")) {
+        path = path.slice(1);
+      }
+
+      return url + path;
+    },
+  };
+}

+ 50 - 0
app/utils/cors.ts

@@ -0,0 +1,50 @@
+import { getClientConfig } from "../config/client";
+import { ApiPath, DEFAULT_CORS_HOST } from "../constant";
+
+export function corsPath(path: string) {
+  const baseUrl = getClientConfig()?.isApp ? `${DEFAULT_CORS_HOST}` : "";
+
+  if (!path.startsWith("/")) {
+    path = "/" + path;
+  }
+
+  if (!path.endsWith("/")) {
+    path += "/";
+  }
+
+  return `${baseUrl}${path}`;
+}
+
+export function corsFetch(
+  url: string,
+  options: RequestInit & {
+    proxyUrl?: string;
+  },
+) {
+  if (!url.startsWith("http")) {
+    throw Error("[CORS Fetch] url must starts with http/https");
+  }
+
+  let proxyUrl = options.proxyUrl ?? corsPath(ApiPath.Cors);
+  if (!proxyUrl.endsWith("/")) {
+    proxyUrl += "/";
+  }
+
+  url = url.replace("://", "/");
+
+  const corsOptions = {
+    ...options,
+    method: "POST",
+    headers: options.method
+      ? {
+          ...options.headers,
+          method: options.method,
+        }
+      : options.headers,
+  };
+
+  const corsUrl = proxyUrl + url;
+  console.info("[CORS] target = ", corsUrl);
+
+  return fetch(corsUrl, corsOptions);
+}

+ 18 - 25
next.config.mjs

@@ -35,27 +35,29 @@ const nextConfig = {
   },
 };
 
+const CorsHeaders = [
+  { key: "Access-Control-Allow-Credentials", value: "true" },
+  { key: "Access-Control-Allow-Origin", value: "*" },
+  {
+    key: "Access-Control-Allow-Methods",
+    value: "*",
+  },
+  {
+    key: "Access-Control-Allow-Headers",
+    value: "*",
+  },
+  {
+    key: "Access-Control-Max-Age",
+    value: "86400",
+  },
+];
+
 if (mode !== "export") {
   nextConfig.headers = async () => {
     return [
       {
         source: "/api/:path*",
-        headers: [
-          { key: "Access-Control-Allow-Credentials", value: "true" },
-          { key: "Access-Control-Allow-Origin", value: "*" },
-          {
-            key: "Access-Control-Allow-Methods",
-            value: "*",
-          },
-          {
-            key: "Access-Control-Allow-Headers",
-            value: "*",
-          },
-          {
-            key: "Access-Control-Max-Age",
-            value: "86400",
-          },
-        ],
+        headers: CorsHeaders,
       },
     ];
   };
@@ -76,15 +78,6 @@ if (mode !== "export") {
       },
     ];
 
-    const apiUrl = process.env.API_URL;
-    if (apiUrl) {
-      console.log("[Next] using api url ", apiUrl);
-      ret.push({
-        source: "/api/:path*",
-        destination: `${apiUrl}/:path*`,
-      });
-    }
-
     return {
       beforeFiles: ret,
     };

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.