Merge pull request #2819 from Yidadaa/webdav

This commit is contained in:
Yifei Zhang 2023-09-13 02:56:10 +08:00 committed by GitHub
commit 74e32efa7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 626 additions and 104 deletions

View File

@ -0,0 +1,38 @@
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 POST = handle;
export const runtime = "edge";

View File

@ -12,6 +12,12 @@ import EditIcon from "../icons/edit.svg";
import EyeIcon from "../icons/eye.svg"; import EyeIcon from "../icons/eye.svg";
import DownloadIcon from "../icons/download.svg"; import DownloadIcon from "../icons/download.svg";
import UploadIcon from "../icons/upload.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 { import {
Input, Input,
@ -54,6 +60,7 @@ import { getClientConfig } from "../config/client";
import { useSyncStore } from "../store/sync"; import { useSyncStore } from "../store/sync";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { useMaskStore } from "../store/mask"; import { useMaskStore } from "../store/mask";
import { ProviderType } from "../utils/cloud";
function EditPromptModal(props: { id: string; onClose: () => void }) { function EditPromptModal(props: { id: string; onClose: () => void }) {
const promptStore = usePromptStore(); 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() { function SyncItems() {
const syncStore = useSyncStore(); const syncStore = useSyncStore();
const webdav = syncStore.webDavConfig;
const chatStore = useChatStore(); const chatStore = useChatStore();
const promptStore = usePromptStore(); const promptStore = usePromptStore();
const maskStore = useMaskStore(); const maskStore = useMaskStore();
const couldSync = useMemo(() => {
return syncStore.coundSync();
}, [syncStore]);
const [showSyncConfigModal, setShowSyncConfigModal] = useState(false);
const stateOverview = useMemo(() => { const stateOverview = useMemo(() => {
const sessions = chatStore.sessions; const sessions = chatStore.sessions;
@ -267,18 +445,42 @@ function SyncItems() {
}, [chatStore.sessions, maskStore.masks, promptStore.prompts]); }, [chatStore.sessions, maskStore.masks, promptStore.prompts]);
return ( return (
<>
<List> <List>
<ListItem <ListItem
title={Locale.Settings.Sync.LastUpdate} title={Locale.Settings.Sync.CloudState}
subTitle={new Date(syncStore.lastSyncTime).toLocaleString()} 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 <IconButton
icon={<ResetIcon />} icon={<ResetIcon />}
text={Locale.UI.Sync} text={Locale.UI.Sync}
onClick={() => { onClick={async () => {
showToast(Locale.WIP); try {
await syncStore.sync();
showToast(Locale.Settings.Sync.Success);
} catch (e) {
showToast(Locale.Settings.Sync.Fail);
console.error("[Sync]", e);
}
}} }}
/> />
)}
</div>
</ListItem> </ListItem>
<ListItem <ListItem
@ -303,6 +505,11 @@ function SyncItems() {
</div> </div>
</ListItem> </ListItem>
</List> </List>
{showSyncConfigModal && (
<SyncConfigModal onClose={() => setShowSyncConfigModal(false)} />
)}
</>
); );
} }

View File

@ -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_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 FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
export const RUNTIME_CONFIG_DOM = "danger-runtime-config"; 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 { export enum Path {
Home = "/", Home = "/",
@ -18,6 +20,10 @@ export enum Path {
Auth = "/auth", Auth = "/auth",
} }
export enum ApiPath {
Cors = "/api/cors",
}
export enum SlotID { export enum SlotID {
AppBody = "app-body", AppBody = "app-body",
} }
@ -46,6 +52,8 @@ export const ACCESS_CODE_PREFIX = "nk-";
export const LAST_INPUT_KEY = "last-input"; export const LAST_INPUT_KEY = "last-input";
export const UNFINISHED_INPUT = (id: string) => "unfinished-input-" + id; export const UNFINISHED_INPUT = (id: string) => "unfinished-input-" + id;
export const STORAGE_KEY = "chatgpt-next-web";
export const REQUEST_TIMEOUT_MS = 60000; export const REQUEST_TIMEOUT_MS = 60000;
export const EXPORT_MESSAGE_CLASS_NAME = "export-markdown"; export const EXPORT_MESSAGE_CLASS_NAME = "export-markdown";

1
app/icons/cloud-fail.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><g opacity="1" transform="translate(0 0) rotate(0)"><mask id="bg-mask-0" fill="white"><use transform="translate(0 0) rotate(0)" xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="fill:#333333; opacity:1;" d="M4.00337,11.6633M4.00337,11.6633c-0.8391,0 -1.5514,-0.20763 -2.13691,-0.6229c-0.79984,-0.56727 -1.19975,-1.41519 -1.19975,-2.54377c0,-1.2521 0.52073,-2.20348 1.56218,-2.85415c0.68561,-0.42835 1.38711,-0.64252 2.10448,-0.64252v0.66667l-0.64163,-0.18098l0.64163,0.18098l-0.64163,-0.18098c0.65543,-2.32379 2.09264,-3.48569 4.31163,-3.48569c2.30635,0 3.74023,0.99523 4.30163,2.98569l-0.6416,0.18098l0.0729,-0.66267l-0.0729,0.66267l0.0729,-0.66267c2.3958,0.26354 3.5937,1.5411 3.5937,3.83267c0,2.21778 -1.10887,3.32667 -3.3266,3.32667c-0.0438,0 -0.08717,-0.00427 -0.1301,-0.0128c-0.04293,-0.00853 -0.0846,-0.0212 -0.125,-0.038c-0.04047,-0.01673 -0.0789,-0.03727 -0.1153,-0.0616c-0.0364,-0.02427 -0.07007,-0.0519 -0.101,-0.0829c-0.031,-0.03093 -0.05863,-0.0646 -0.0829,-0.101c-0.02433,-0.0364 -0.04487,-0.07483 -0.0616,-0.1153c-0.0168,-0.0404 -0.02947,-0.08207 -0.038,-0.125c-0.00853,-0.04293 -0.0128,-0.0863 -0.0128,-0.1301c0,-0.04373 0.00427,-0.08707 0.0128,-0.13c0.00853,-0.04293 0.0212,-0.08463 0.038,-0.1251c0.01673,-0.04047 0.03727,-0.0789 0.0616,-0.1153c0.02427,-0.0364 0.0519,-0.07007 0.0829,-0.101c0.03093,-0.03093 0.0646,-0.05857 0.101,-0.0829c0.0364,-0.02433 0.07483,-0.04487 0.1153,-0.0616c0.0404,-0.01673 0.08207,-0.02937 0.125,-0.0379c0.04293,-0.00853 0.0863,-0.0128 0.1301,-0.0128c1.32887,0 1.9933,-0.66446 1.9933,-1.99337c0,-1.4951 -0.80207,-2.33088 -2.4062,-2.50733c-0.27,-0.02971 -0.495,-0.22026 0,0c-0.27,-0.02971 -0.495,-0.22026 -0.5688,-0.4817c-0.37873,-1.34287 -1.38484,-2.01431 -3.01833,-2.01431c-1.54613,0 -2.55559,0.8381 -3.02836,2.51431c-0.08103,0.28728 -0.34314,0.48569 0,0c-0.08103,0.28728 -0.34314,0.48569 -0.64164,0.48569c-0.46252,0 -0.92852,0.14666 -1.39801,0.43998c-0.62355,0.38957 -0.93532,0.96402 -0.93532,1.72336c0,0.66927 0.21258,1.15467 0.63775,1.45621c0.35449,0.25144 0.80969,0.37716 1.36558,0.37716c0.04378,0 0.08713,0.00427 0.13006,0.0128c0.04293,0.00853 0.08462,0.02117 0.12507,0.0379c0.04044,0.01673 0.07886,0.03727 0.11525,0.0616c0.0364,0.02433 0.07008,0.05197 0.10103,0.0829c0.03095,0.03093 0.05859,0.0646 0.08291,0.101c0.02432,0.0364 0.04485,0.07483 0.0616,0.1153c0.01675,0.04047 0.0294,0.08217 0.03794,0.1251c0.00854,0.04293 0.01281,0.08627 0.01281,0.13c0,0.0438 -0.00427,0.08717 -0.01281,0.1301c-0.00854,0.04293 -0.02119,0.0846 -0.03794,0.125c-0.01675,0.04047 -0.03728,0.0789 -0.0616,0.1153c-0.02432,0.0364 -0.05196,0.07007 -0.08291,0.101c-0.03095,0.031 -0.06463,0.05863 -0.10103,0.0829c-0.03639,0.02433 -0.07481,0.04487 -0.11525,0.0616c-0.04045,0.0168 -0.08214,0.02947 -0.12507,0.038c-0.04293,0.00853 -0.08628,0.0128 -0.13006,0.0128z"></path><path id="路径 2" style="fill:#333333; opacity:1;" d="M6.42578,10.4903l2,1.66l-0.42578,0.513l-0.52012,-0.4171l2.67002,-3.32998c0.0274,-0.03415 0.05783,-0.06531 0.0913,-0.09346c0.03353,-0.02815 0.0695,-0.05277 0.1079,-0.07384c0.03833,-0.02107 0.07837,-0.0382 0.1201,-0.05138c0.04173,-0.01319 0.08437,-0.02217 0.1279,-0.02696c0.04353,-0.00479 0.0871,-0.00528 0.1307,-0.00149c0.0436,0.00379 0.0864,0.01181 0.1284,0.02404c0.04207,0.01223 0.0825,0.02844 0.1213,0.04863c0.03887,0.0202 0.07537,0.04399 0.1095,0.07137c0.0342,0.02738 0.06537,0.05783 0.0935,0.09135c0.02813,0.03352 0.05273,0.06946 0.0738,0.10783c0.02107,0.03837 0.0382,0.07843 0.0514,0.12017c0.0132,0.04174 0.0222,0.08437 0.027,0.12788c0.00473,0.04351 0.00523,0.08707 0.0015,0.13068c-0.0038,0.04361 -0.01183,0.08643 -0.0241,0.12846c-0.0122,0.04203 -0.0284,0.08247 -0.0486,0.1213c-0.0202,0.03884 -0.044,0.07534 -0.0714,0.10949l-2.66998,3.33001c-0.23308,0.2907 -0.65919,0.3339 -0.9459,0.0959l-2,-1.66c-0.03369,-0.02793 -0.06432,-0.0589 -0.0919,-0.0929c-0.02758,-0.034 -0.05158,-0.07037 -0.072,-0.1091c-0.02042,-0.03867 -0.03687,-0.079 -0.04934,-0.121c-0.01248,-0.04193 -0.02074,-0.0847 -0.02479,-0.1283c-0.00405,-0.0436 -0.0038,-0.08717 0.00073,-0.1307c0.00453,-0.04353 0.01326,-0.0862 0.0262,-0.128c0.01294,-0.0418 0.02983,-0.08197 0.05068,-0.1205c0.02085,-0.03847 0.04526,-0.07453 0.07321,-0.1082c0.02796,-0.03373 0.05893,-0.06437 0.09292,-0.0919c0.03399,-0.0276 0.07035,-0.0516 0.10907,-0.072c0.03872,-0.02047 0.07906,-0.03693 0.12102,-0.0494c0.04196,-0.01247 0.08473,-0.02073 0.12831,-0.0248c0.04359,-0.004 0.08715,-0.00373 0.13069,0.0008c0.04354,0.00453 0.08622,0.01327 0.12804,0.0262c0.04181,0.01293 0.08197,0.02983 0.12046,0.0507c0.03849,0.0208 0.07457,0.0452 0.10826,0.0732z"></path></g></g><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs></svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

1
app/icons/config.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

1
app/icons/connection.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

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

View File

@ -181,7 +181,36 @@ const en: LocaleType = {
SubTitle: "Generate a suitable title based on the conversation content", SubTitle: "Generate a suitable title based on the conversation content",
}, },
Sync: { 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", LocalState: "Local Data",
Overview: (overview: any) => { Overview: (overview: any) => {
return `${overview.chat} chats${overview.message} messages${overview.prompt} prompts${overview.mask} masks`; return `${overview.chat} chats${overview.message} messages${overview.prompt} prompts${overview.mask} masks`;
@ -366,6 +395,7 @@ const en: LocaleType = {
Export: "Export", Export: "Export",
Import: "Import", Import: "Import",
Sync: "Sync", Sync: "Sync",
Config: "Config",
}, },
Exporter: { Exporter: {
Model: "Model", Model: "Model",

View File

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

33
app/utils/cloud/index.ts Normal file
View File

@ -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;
}

View File

@ -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
app/utils/cloud/webdav.ts Normal file
View File

@ -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
app/utils/cors.ts Normal file
View File

@ -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);
}

View File

@ -35,12 +35,7 @@ const nextConfig = {
}, },
}; };
if (mode !== "export") { const CorsHeaders = [
nextConfig.headers = async () => {
return [
{
source: "/api/:path*",
headers: [
{ key: "Access-Control-Allow-Credentials", value: "true" }, { key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Origin", value: "*" }, { key: "Access-Control-Allow-Origin", value: "*" },
{ {
@ -55,7 +50,14 @@ if (mode !== "export") {
key: "Access-Control-Max-Age", key: "Access-Control-Max-Age",
value: "86400", value: "86400",
}, },
], ];
if (mode !== "export") {
nextConfig.headers = async () => {
return [
{
source: "/api/:path*",
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 { return {
beforeFiles: ret, beforeFiles: ret,
}; };