Browse Source

对接第三方

tuon 1 year ago
parent
commit
ffa0eb39e8

+ 45 - 0
.env.development

@@ -0,0 +1,45 @@
+
+# Your openai api key. (required)
+OPENAI_API_KEY=sk-xxxx
+
+# Access passsword, separated by comma. (optional)
+CODE=qwer@1234
+
+# You can start service behind a proxy
+PROXY_URL=http://10.10.0.6:13123
+
+# Override openai api request base url. (optional)
+# Default: https://api.openai.com
+# Examples: http://your-openai-proxy.com
+#BASE_URL=http://10.10.0.1:20003/
+BASE_URL=http://10.10.0.2:20003/
+
+# Specify OpenAI organization ID.(optional)
+# Default: Empty
+OPENAI_ORG_ID=
+
+# (optional)
+# Default: Empty
+# If you do not want users to use GPT-4, set this value to 1.
+DISABLE_GPT4=
+
+# (optional)
+# Default: Empty
+# If you do not want users to input their own API key, set this value to 1.
+HIDE_USER_API_KEY=1
+
+# (optional)
+# Default: Empty
+# If you do want users to query balance, set this value to 1.
+ENABLE_BALANCE_QUERY=
+
+# (optional)
+# Default: Empty
+# If you want to disable parse settings from url, set this value to 1.
+DISABLE_FAST_LINK=
+
+OAUTH_CLIENT_ID=52065645520a434ab8d2170a3db6e09f
+OAUTH_CLIENT_SECRET=e63a36c1024f41afb80004e49c5564cf
+OAUTH_REDIRECT_URI=http://localhost:3000/
+OAUTH_AUTHORIZE_ENDPOINT=https://www.tonyandmoney.cn/oauth/authorize
+OAUTH_USERINFO=https://api.tonyandmoney.cn/oauth/user

+ 6 - 0
app/api/auth.ts

@@ -33,6 +33,12 @@ export function auth(req: NextRequest) {
   const hashedCode = md5.hash(accessCode ?? "").trim();
 
   const serverConfig = getServerSideConfig();
+  if (serverConfig.OAUTH_AUTHORIZE_ENDPOINT && serverConfig.OAUTH_CLIENT_ID) {
+    return {
+      error: false,
+    };
+  }
+
   console.log("[Auth] allowed hashed codes: ", [...serverConfig.codes]);
   console.log("[Auth] got access code:", accessCode);
   console.log("[Auth] hashed access code:", hashedCode);

+ 3 - 0
app/api/common.ts

@@ -100,6 +100,9 @@ export async function requestOpenai(req: NextRequest) {
     }
   }
 
+  // console.log('[Proxy] ',fetchUrl, fetchOptions)
+  console.log("[Proxy] ", fetchUrl);
+
   try {
     const res = await fetch(fetchUrl, fetchOptions);
 

+ 68 - 0
app/api/user/route.ts

@@ -0,0 +1,68 @@
+import { NextRequest, NextResponse } from "next/server";
+
+import { getServerSideConfig } from "../../config/server";
+const serverConfig = getServerSideConfig();
+
+async function handle(
+  req: NextRequest,
+  { params }: { params: { path: string[] } },
+) {
+  const controller = new AbortController();
+
+  const authValue = req.headers.get("Authorization") ?? "";
+  const authHeaderName = serverConfig.isAzure ? "api-key" : "Authorization";
+  const fetchUrl = serverConfig.OAUTH_USERINFO;
+  console.log("[Proxy] ", fetchUrl);
+  // this fix [Org ID] undefined in server side if not using custom point
+  const timeoutId = setTimeout(
+    () => {
+      controller.abort();
+    },
+    10 * 60 * 1000,
+  );
+  if (!fetchUrl) {
+    const err = {
+      code: 404,
+      msg: "未配置",
+    };
+    return new Response(JSON.stringify(err), {
+      status: 404,
+      statusText: "not found",
+    });
+  }
+
+  const fetchOptions: RequestInit = {
+    headers: {
+      "Content-Type": "application/json",
+      "Cache-Control": "no-store",
+      [authHeaderName]: authValue,
+    },
+    method: req.method,
+    body: req.body,
+    // to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
+    redirect: "manual",
+    // @ts-ignore
+    duplex: "half",
+    signal: controller.signal,
+  };
+
+  try {
+    const res = await fetch(fetchUrl, fetchOptions);
+    const newHeaders = new Headers(res.headers);
+    newHeaders.delete("www-authenticate");
+    newHeaders.set("X-Accel-Buffering", "no");
+
+    return new Response(res.body, {
+      status: res.status,
+      statusText: res.statusText,
+      headers: newHeaders,
+    });
+  } finally {
+    clearTimeout(timeoutId);
+  }
+}
+
+export const GET = handle;
+export const POST = handle;
+
+export const runtime = "edge";

+ 10 - 0
app/client/api.ts

@@ -2,6 +2,7 @@ import { getClientConfig } from "../config/client";
 import { ACCESS_CODE_PREFIX, Azure, ServiceProvider } from "../constant";
 import { ChatMessage, ModelType, useAccessStore } from "../store";
 import { ChatGPTApi } from "./platforms/openai";
+import { OauthUserApi } from "@/app/client/platforms/user";
 
 export const ROLES = ["system", "user", "assistant"] as const;
 export type MessageRole = (typeof ROLES)[number];
@@ -49,6 +50,10 @@ export abstract class LLMApi {
   abstract models(): Promise<LLMModel[]>;
 }
 
+export abstract class UserApi {
+  abstract userinfo(): Promise<void>;
+}
+
 type ProviderName = "openai" | "azure" | "claude" | "palm";
 
 interface Model {
@@ -72,9 +77,11 @@ interface ChatProvider {
 
 export class ClientApi {
   public llm: LLMApi;
+  public user: UserApi;
 
   constructor() {
     this.llm = new ChatGPTApi();
+    this.user = new OauthUserApi();
   }
 
   config() {}
@@ -139,9 +146,12 @@ export function getHeaders() {
   const makeBearer = (s: string) => `${isAzure ? "" : "Bearer "}${s.trim()}`;
   const validString = (x: string) => x && x.length > 0;
 
+  // console.log('ServiceProvider', accessStore.provider)
   // use user's api key first
   if (validString(apiKey)) {
     headers[authHeader] = makeBearer(apiKey);
+  } else if (accessStore.provider === ServiceProvider.Oauth) {
+    headers[authHeader] = makeBearer(accessStore.accessToken);
   } else if (
     accessStore.enabledAccessControl() &&
     validString(accessStore.accessCode)

+ 27 - 0
app/client/platforms/user.ts

@@ -0,0 +1,27 @@
+import { REQUEST_TIMEOUT_MS } from "@/app/constant";
+import { useAccessStore, useAppConfig } from "@/app/store";
+
+import { getHeaders, UserApi } from "../api";
+
+export class OauthUserApi implements UserApi {
+  async userinfo() {
+    const accessStore = useAccessStore.getState();
+    const controller = new AbortController();
+
+    try {
+      const url = "/api/user";
+      const payload = {
+        method: "GET",
+        signal: controller.signal,
+        headers: getHeaders(),
+      };
+      const res = await fetch(url, payload);
+      if (res.status == 401) {
+        accessStore.clearToken();
+      }
+      console.log("res status", res.status, res.statusText);
+    } catch (e) {
+      console.log("[Request] failed to make a chat request", e);
+    }
+  }
+}

+ 8 - 4
app/components/home.tsx

@@ -16,6 +16,7 @@ import { Path, SlotID } from "../constant";
 import { ErrorBoundary } from "./error";
 
 import { getISOLang, getLang } from "../locales";
+import { EnsureToken } from "./oauth/index";
 
 import {
   HashRouter as Router,
@@ -128,7 +129,8 @@ function Screen() {
   const isHome = location.pathname === Path.Home;
   const isAuth = location.pathname === Path.Auth;
   const isMobileScreen = useMobileScreen();
-  const shouldTightBorder = getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen);
+  const shouldTightBorder =
+    getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen);
 
   useEffect(() => {
     loadAsyncGoogleFont();
@@ -194,9 +196,11 @@ export function Home() {
 
   return (
     <ErrorBoundary>
-      <Router>
-        <Screen />
-      </Router>
+      <EnsureToken>
+        <Router>
+          <Screen />
+        </Router>
+      </EnsureToken>
     </ErrorBoundary>
   );
 }

+ 78 - 0
app/components/oauth/index.tsx

@@ -0,0 +1,78 @@
+import { useEffect, useState } from "react";
+import { getClientConfig } from "@/app/config/client";
+import { useAccessStore } from "@/app/store";
+import { api } from "@/app/client/api";
+import dayjs from "dayjs";
+
+export type ITokenData = {
+  access_token: string;
+  token_type: string;
+  expires_in: number;
+  scope?: string[];
+};
+
+/**
+ * 使用oauth2登录站点
+ */
+export const EnsureToken = (props: any) => {
+  const accessStore = useAccessStore();
+  const [loading, setLoading] = useState(true);
+
+  const config = getClientConfig();
+
+  useEffect(() => {
+    const parseToken = () => {
+      const url = new URL(window.location.href);
+      if (url.hash && url.hash.startsWith("#token=")) {
+        try {
+          setLoading(true);
+          const token = JSON.parse(
+            decodeURIComponent(url.hash.substring(7)),
+          ) as ITokenData;
+          accessStore.setAccessToken({
+            accessToken: token.access_token,
+            tokenType: token.token_type,
+            expiresIn: dayjs().add(token.expires_in, "s").unix(),
+          });
+          setTimeout(() => {
+            setLoading(false);
+            url.hash = "";
+            window.location.href = url.toString();
+          }, 1000);
+          return;
+        } catch (e) {
+          setLoading(false);
+          console.log("parse token fail", e);
+        }
+      } else if (accessStore.hasAccessToken()) {
+        api.user.userinfo().finally(() => {
+          setLoading(false);
+        });
+      } else {
+        accessStore.clearToken();
+        setLoading(false);
+      }
+    };
+    parseToken();
+    const handler = setInterval(parseToken, 10000);
+    return () => {
+      clearInterval(handler);
+    };
+  }, []);
+
+  if (loading) {
+    return (
+      <div>
+        <span>Loading...</span>
+      </div>
+    );
+  }
+  if (!accessStore?.hasAccessToken()) {
+    return (
+      <div>
+        <a href={config?.authorizeUrl ?? "/login"}>登录</a>
+      </div>
+    );
+  }
+  return <>{props.children}</>;
+};

+ 15 - 1
app/config/build.ts

@@ -6,7 +6,6 @@ export const getBuildConfig = () => {
       "[Server Config] you are importing a nodejs-only module outside of nodejs",
     );
   }
-
   const buildMode = process.env.BUILD_MODE ?? "standalone";
   const isApp = !!process.env.BUILD_APP;
   const version = "v" + tauriConfig.package.version;
@@ -33,11 +32,26 @@ export const getBuildConfig = () => {
     }
   })();
 
+  const {
+    OAUTH_AUTHORIZE_ENDPOINT,
+    OAUTH_CLIENT_ID,
+    OAUTH_REDIRECT_URI,
+    OAUTH_CLIENT_SECRET,
+    OAUTH_USERINFO,
+  } = process.env;
+  let authorizeUrl = "";
+  if (OAUTH_AUTHORIZE_ENDPOINT && OAUTH_CLIENT_ID && OAUTH_REDIRECT_URI) {
+    authorizeUrl = `${OAUTH_AUTHORIZE_ENDPOINT}?client_id=${OAUTH_CLIENT_ID}&response_type=token&scope=userinfo&redirect_uri=${encodeURIComponent(
+      OAUTH_REDIRECT_URI,
+    )}`;
+  }
+
   return {
     version,
     ...commitInfo,
     buildMode,
     isApp,
+    authorizeUrl,
   };
 };
 

+ 12 - 0
app/config/server.ts

@@ -26,6 +26,12 @@ declare global {
       AZURE_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name}
       AZURE_API_KEY?: string;
       AZURE_API_VERSION?: string;
+      // oauth2
+      OAUTH_CLIENT_ID?: string;
+      OAUTH_CLIENT_SECRET?: string;
+      OAUTH_REDIRECT_URI?: string;
+      OAUTH_AUTHORIZE_ENDPOINT?: string;
+      OAUTH_USERINFO?: string;
     }
   }
 }
@@ -92,5 +98,11 @@ export const getServerSideConfig = () => {
     hideBalanceQuery: !process.env.ENABLE_BALANCE_QUERY,
     disableFastLink: !!process.env.DISABLE_FAST_LINK,
     customModels,
+    // oauth2
+    OAUTH_CLIENT_ID: process.env.OAUTH_CLIENT_ID,
+    OAUTH_CLIENT_SECRET: process.env.OAUTH_CLIENT_SECRET,
+    OAUTH_REDIRECT_URI: process.env.OAUTH_REDIRECT_URI,
+    OAUTH_AUTHORIZE_ENDPOINT: process.env.OAUTH_AUTHORIZE_ENDPOINT,
+    OAUTH_USERINFO: process.env.OAUTH_USERINFO,
   };
 };

+ 8 - 7
app/constant.ts

@@ -1,14 +1,14 @@
-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 OWNER = "tuonq";
+export const REPO = "ChatAI";
+export const REPO_URL = `https://git.tonyandmoney.cn/${OWNER}/${REPO}`;
+export const ISSUE_URL = `https://git.tonyandmoney.cn/${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 FETCH_COMMIT_URL = `https://git.tonyandmoney.cn/repos/${OWNER}/${REPO}/commits?per_page=1`;
+export const FETCH_TAG_URL = `https://git.tonyandmoney.cn/repos/${OWNER}/${REPO}/tags?per_page=1`;
 export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
 
-export const DEFAULT_CORS_HOST = "https://a.nextweb.fun";
+export const DEFAULT_CORS_HOST = "https://www.tonyandmoney.cn";
 export const DEFAULT_API_HOST = `${DEFAULT_CORS_HOST}/api/proxy`;
 export const OPENAI_BASE_URL = "https://api.openai.com";
 
@@ -65,6 +65,7 @@ export const EXPORT_MESSAGE_CLASS_NAME = "export-markdown";
 export enum ServiceProvider {
   OpenAI = "OpenAI",
   Azure = "Azure",
+  Oauth = "Oauth",
 }
 
 export const OpenaiPath = {

+ 27 - 1
app/store/access.ts

@@ -8,6 +8,7 @@ import { getHeaders } from "../client/api";
 import { getClientConfig } from "../config/client";
 import { createPersistStore } from "../utils/store";
 import { ensure } from "../utils/clone";
+import dayjs from "dayjs";
 
 let fetchState = 0; // 0 not fetch, 1 fetching, 2 done
 
@@ -18,12 +19,17 @@ const DEFAULT_ACCESS_STATE = {
   accessCode: "",
   useCustomConfig: false,
 
-  provider: ServiceProvider.OpenAI,
+  provider: ServiceProvider.Oauth,
 
   // openai
   openaiUrl: DEFAULT_OPENAI_URL,
   openaiApiKey: "",
 
+  // oauth2
+  accessToken: "",
+  expiresIn: 0,
+  tokenType: "bearer",
+
   // azure
   azureUrl: "",
   azureApiKey: "",
@@ -48,6 +54,26 @@ export const useAccessStore = createPersistStore(
       return get().needCode;
     },
 
+    clearToken() {
+      set(() => ({
+        accessToken: "",
+        expiresIn: 0,
+        tokenType: "bearer",
+      }));
+    },
+
+    setAccessToken(token: any) {
+      set(() => ({ ...token }));
+    },
+
+    hasAccessToken() {
+      const state = get();
+      if (state.provider == ServiceProvider.Oauth) {
+        return !!state.accessToken?.length && dayjs().unix() < state.expiresIn;
+      }
+      return false;
+    },
+
     isValidOpenAI() {
       return ensure(get(), ["openaiApiKey"]);
     },