diff --git a/CHANGELOG.md b/CHANGELOG.md index f41169d..c067392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,44 @@ +## v2.10.0 + +`2023-03-07` + +- 老规矩,手动部署的同学需要删除 `node_modules` 安装包重新安装降低出错概率,其他部署不受影响,但是可能会有缓存问题。 +- 虽然说了更新放缓,但是 `issues` 不看, `PR` 不改我睡不着,我的邮箱从每天早上`8`点到凌晨`12`永远在滴滴滴,所以求求各位,超时的`issues`自己关闭下哈,我真的需要缓冲一下。 +- 演示图片请看最后 + +## Feature +- 添加权限功能,用法:`service/.env` 中的 `AUTH_SECRET_KEY` 变量添加密码 +- 感谢 [PeterDaveHello](https://github.com/Chanzhaoyu/chatgpt-web/pull/348) 添加「繁体中文」翻译 +- 感谢 [GermMC](https://github.com/Chanzhaoyu/chatgpt-web/pull/369) 添加聊天记录导入、导出、清空的功能 +- 感谢 [CornerSkyless](https://github.com/Chanzhaoyu/chatgpt-web/pull/374) 添加会话保存为本地图片的功能 + + +## Enhancement +- 感谢 [CornerSkyless](https://github.com/Chanzhaoyu/chatgpt-web/pull/363) 添加 `ctrl+enter` 发送消息 +- 现在新消息只有在结束了之后才滚动到底部,而不是之前的强制性 +- 优化部分代码 + +## BugFix +- 转义状态码前端显示,防止直接暴露 `key`(我可能需要更多的状态码补充) + +## Other +- 更新依赖到最新 + +## 演示 +> 不是界面最新效果,有美化改动 + +权限 + +![权限](https://user-images.githubusercontent.com/24789441/223438518-80d58d42-e344-4e39-b87c-251ff73925ed.png) + +聊天记录导出 + +![聊天记录导出](https://user-images.githubusercontent.com/57023771/223372153-6d8e9ec1-d82c-42af-b4bd-232e50504a25.gif) + +保存图片到本地 + +![保存图片到本地](https://user-images.githubusercontent.com/13901424/223423555-b69b95ef-8bcf-4951-a7c9-98aff2677e18.gif) + ## v2.9.3 `2023-03-06` diff --git a/README.en.md b/README.en.md index 625c2c6..bffae14 100644 --- a/README.en.md +++ b/README.en.md @@ -163,6 +163,7 @@ pnpm dev - `OPENAI_ACCESS_TOKEN` one of two, `OPENAI_API_KEY` takes precedence when both are present - `OPENAI_API_BASE_URL` optional, available when `OPENAI_API_KEY` is set - `API_REVERSE_PROXY` optional, available when `OPENAI_ACCESS_TOKEN` is set [Reference](#introduction) +- `AUTH_SECRET_KEY` Access Password,optional - `TIMEOUT_MS` timeout, in milliseconds, optional - `SOCKS_PROXY_HOST` optional, effective with SOCKS_PROXY_PORT - `SOCKS_PROXY_PORT` optional, effective with SOCKS_PROXY_HOST @@ -205,6 +206,8 @@ services: OPENAI_API_BASE_URL: xxxx # reverse proxy, optional API_REVERSE_PROXY: xxx + # access password,optional + AUTH_SECRET_KEY: xxx # timeout, in milliseconds, optional TIMEOUT_MS: 60000 # socks proxy, optional, effective with SOCKS_PROXY_PORT @@ -223,7 +226,8 @@ The `OPENAI_API_BASE_URL` is optional and only used when setting the `OPENAI_API | Environment Variable | Required | Description | | -------------------- | -------- | ------------------------------------------------------------------------------------------------- | | `PORT` | Required | Default: `3002` | -| `TIMEOUT_MS` | Optional | Timeout in milliseconds. | +| `AUTH_SECRET_KEY` | Optional | access password | +| `TIMEOUT_MS` | Optional | Timeout in milliseconds | | `OPENAI_API_KEY` | Optional | Required for `OpenAI API`. `apiKey` can be obtained from [here](https://platform.openai.com/overview). | | `OPENAI_ACCESS_TOKEN`| Optional | Required for `Web API`. `accessToken` can be obtained from [here](https://chat.openai.com/api/auth/session).| | `OPENAI_API_BASE_URL` | Optional, only for `OpenAI API` | API endpoint. | diff --git a/README.md b/README.md index 39dad6a..32bac58 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ pnpm dev - `OPENAI_ACCESS_TOKEN` 二选一,同时存在时,`OPENAI_API_KEY` 优先 - `OPENAI_API_BASE_URL` 可选,设置 `OPENAI_API_KEY` 时可用 - `API_REVERSE_PROXY` 可选,设置 `OPENAI_ACCESS_TOKEN` 时可用 [参考](#介绍) +- `AUTH_SECRET_KEY` 访问权限密钥,可选 - `TIMEOUT_MS` 超时,单位毫秒,可选 - `SOCKS_PROXY_HOST` 可选,和 SOCKS_PROXY_PORT 一起时生效 - `SOCKS_PROXY_PORT` 可选,和 SOCKS_PROXY_HOST 一起时生效 @@ -203,6 +204,8 @@ services: OPENAI_API_BASE_URL: xxxx # 反向代理,可选 API_REVERSE_PROXY: xxx + # 访问权限密钥,可选 + AUTH_SECRET_KEY: xxx # 超时,单位毫秒,可选 TIMEOUT_MS: 60000 # Socks代理,可选,和 SOCKS_PROXY_PORT 一起时生效 @@ -219,8 +222,9 @@ services: | 环境变量名称 | 必填 | 备注 | | --------------------- | ---------------------- | -------------------------------------------------------------------------------------------------- | -| `PORT` | 必填 | 默认 `3002` | -| `TIMEOUT_MS` | 可选 | 超时时间,单位毫秒, | +| `PORT` | 必填 | 默认 `3002` +| `AUTH_SECRET_KEY` | 可选 | 访问权限密钥 | +| `TIMEOUT_MS` | 可选 | 超时时间,单位毫秒 | | `OPENAI_API_KEY` | `OpenAI API` 二选一 | 使用 `OpenAI API` 所需的 `apiKey` [(获取 apiKey)](https://platform.openai.com/overview) | | `OPENAI_ACCESS_TOKEN` | `Web API` 二选一 | 使用 `Web API` 所需的 `accessToken` [(获取 accessToken)](https://chat.openai.com/api/auth/session) | | `OPENAI_API_BASE_URL` | 可选,`OpenAI API` 时可用 | `API`接口地址 | diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 15f8f52..e2eed1e 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -14,6 +14,8 @@ services: OPENAI_API_BASE_URL: xxxx # 反向代理,可选 API_REVERSE_PROXY: xxx + # 访问权限密钥,可选 + AUTH_SECRET_KEY: xxx # 超时,单位毫秒,可选 TIMEOUT_MS: 60000 # Socks代理,可选,和 SOCKS_PROXY_PORT 一起时生效 diff --git a/package.json b/package.json index af9e920..3cd58a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chatgpt-web", - "version": "2.9.3", + "version": "2.10.0", "private": false, "description": "ChatGPT Web", "author": "ChenZhaoYu ", diff --git a/service/.env b/service/.env index 1df63e9..6a230e1 100644 --- a/service/.env +++ b/service/.env @@ -13,6 +13,9 @@ API_REVERSE_PROXY= # timeout TIMEOUT_MS=100000 +# Secret key +AUTH_SECRET_KEY= + # Socks Proxy Host SOCKS_PROXY_HOST= diff --git a/service/package.json b/service/package.json index 8565bc2..9f051f4 100644 --- a/service/package.json +++ b/service/package.json @@ -24,7 +24,7 @@ "common:cleanup": "rimraf node_modules && rimraf pnpm-lock.yaml" }, "dependencies": { - "chatgpt": "^5.0.7", + "chatgpt": "^5.0.8", "dotenv": "^16.0.3", "esno": "^0.16.3", "express": "^4.18.2", diff --git a/service/pnpm-lock.yaml b/service/pnpm-lock.yaml index 21116c4..5f3eb48 100644 --- a/service/pnpm-lock.yaml +++ b/service/pnpm-lock.yaml @@ -4,7 +4,7 @@ specifiers: '@antfu/eslint-config': ^0.35.3 '@types/express': ^4.17.17 '@types/node': ^18.14.6 - chatgpt: ^5.0.7 + chatgpt: ^5.0.8 dotenv: ^16.0.3 eslint: ^8.35.0 esno: ^0.16.3 @@ -17,7 +17,7 @@ specifiers: typescript: ^4.9.5 dependencies: - chatgpt: 5.0.7 + chatgpt: 5.0.8 dotenv: 16.0.3 esno: 0.16.3 express: 4.18.2 @@ -902,8 +902,8 @@ packages: resolution: {integrity: sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==} dev: true - /chatgpt/5.0.7: - resolution: {integrity: sha512-wy69++JDNS0xKi+6rP+HDOByXBafQIVynHnlQw09apuDntGSKfwBRY902N8Q7/ZFU/XET+8NpJiio2iI69IWYw==} + /chatgpt/5.0.8: + resolution: {integrity: sha512-Bjh7Y15QIsZ+SkQvbbZGymv1PGxkZ7X1vwqAwvyqaMMhbipU4kxht/GL62VCxhoUCXPwxTfScbFeNFtNldgqaw==} engines: {node: '>=14'} hasBin: true dependencies: diff --git a/service/src/chatgpt/index.ts b/service/src/chatgpt/index.ts index 78f7098..b715ca5 100644 --- a/service/src/chatgpt/index.ts +++ b/service/src/chatgpt/index.ts @@ -8,11 +8,14 @@ import { sendResponse } from '../utils' import type { ApiModel, ChatContext, ChatGPTUnofficialProxyAPIOptions, ModelConfig } from '../types' const ErrorCodeMessage: Record = { - 401: '提供错误的API密钥 | Incorrect API key provided', - 429: '服务器限流,请稍后再试 | Server was limited, please try again later', - 503: '服务器繁忙,请稍后再试 | Server is busy, please try again later', - 500: '服务器繁忙,请稍后再试 | Server is busy, please try again later', - 403: '服务器拒绝访问,请稍后再试 | Server refused to access, please try again later', + 400: '[OpenAI] 模型的最大上下文长度是4096个令牌,请减少信息的长度。| This model\'s maximum context length is 4096 tokens.', + 401: '[OpenAI] 提供错误的API密钥 | Incorrect API key provided', + 403: '[OpenAI] 服务器拒绝访问,请稍后再试 | Server refused to access, please try again later', + 429: '[OpenAI] 服务器限流,请稍后再试 | Server was limited, please try again later', + 502: '[OpenAI] 错误的网关 | Bad Gateway', + 503: '[OpenAI] 服务器繁忙,请稍后再试 | Server is busy, please try again later', + 504: '[OpenAI] 网关超时 | Gateway Time-out', + 500: '[OpenAI] 服务器繁忙,请稍后再试 | Internal Server Error', } dotenv.config() @@ -106,10 +109,11 @@ async function chatReplyProcess( return sendResponse({ type: 'Success', data: response }) } catch (error: any) { - const code = error.statusCode || 'unknown' + const code = error.statusCode + global.console.log(error) if (Reflect.has(ErrorCodeMessage, code)) return sendResponse({ type: 'Fail', message: ErrorCodeMessage[code] }) - return sendResponse({ type: 'Fail', message: `${error.statusCode}-${error.statusText}` }) + return sendResponse({ type: 'Fail', message: error.message ?? 'Please check the back-end console' }) } } diff --git a/service/src/index.ts b/service/src/index.ts index 1c001e6..04b5134 100644 --- a/service/src/index.ts +++ b/service/src/index.ts @@ -1,6 +1,7 @@ import express from 'express' import type { ChatContext, ChatMessage } from './chatgpt' import { chatConfig, chatReplyProcess } from './chatgpt' +import { auth } from './middleware/auth' const app = express() const router = express.Router() @@ -15,7 +16,7 @@ app.all('*', (_, res, next) => { next() }) -router.post('/chat-process', async (req, res) => { +router.post('/chat-process', auth, async (req, res) => { res.setHeader('Content-type', 'application/octet-stream') try { @@ -44,6 +45,33 @@ router.post('/config', async (req, res) => { } }) +router.post('/session', async (req, res) => { + try { + const AUTH_SECRET_KEY = process.env.AUTH_SECRET_KEY + const hasAuth = typeof AUTH_SECRET_KEY === 'string' && AUTH_SECRET_KEY.length > 0 + res.send({ status: 'Success', message: '', data: { auth: hasAuth } }) + } + catch (error) { + res.send({ status: 'Fail', message: error.message, data: null }) + } +}) + +router.post('/verify', async (req, res) => { + try { + const { token } = req.body as { token: string } + if (!token) + throw new Error('Secret key is empty') + + if (process.env.AUTH_SECRET_KEY !== token) + throw new Error('密钥无效 | Secret key is invalid') + + res.send({ status: 'Success', message: 'Verify successfully', data: null }) + } + catch (error) { + res.send({ status: 'Fail', message: error.message, data: null }) + } +}) + app.use('', router) app.use('/api', router) diff --git a/service/src/middleware/auth.ts b/service/src/middleware/auth.ts new file mode 100644 index 0000000..50bf02a --- /dev/null +++ b/service/src/middleware/auth.ts @@ -0,0 +1,19 @@ +const auth = async (req, res, next) => { + const AUTH_SECRET_KEY = process.env.AUTH_SECRET_KEY + if (typeof AUTH_SECRET_KEY === 'string' && AUTH_SECRET_KEY.length > 0) { + try { + const Authorization = req.header('Authorization') + if (!Authorization || Authorization.replace('Bearer ', '').trim() !== AUTH_SECRET_KEY.trim()) + throw new Error('Error: 无访问权限 | No access rights') + next() + } + catch (error) { + res.send({ status: 'Unauthorized', message: error.message ?? 'Please authenticate.', data: null }) + } + } + else { + next() + } +} + +export { auth } diff --git a/src/api/index.ts b/src/api/index.ts index 7a5c419..e576ab1 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -33,3 +33,16 @@ export function fetchChatAPIProcess( onDownloadProgress: params.onDownloadProgress, }) } + +export function fetchSession() { + return post({ + url: '/session', + }) +} + +export function fetchVerify(token: string) { + return post({ + url: '/verify', + data: { token }, + }) +} diff --git a/src/icons/403.svg b/src/icons/403.svg deleted file mode 100644 index 3046111..0000000 --- a/src/icons/403.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/icons/403.vue b/src/icons/403.vue new file mode 100644 index 0000000..29fd940 --- /dev/null +++ b/src/icons/403.vue @@ -0,0 +1,5 @@ + diff --git a/src/icons/500.vue b/src/icons/500.vue new file mode 100644 index 0000000..e86e611 --- /dev/null +++ b/src/icons/500.vue @@ -0,0 +1,5 @@ + diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index faddd60..8c3bdd0 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -12,6 +12,8 @@ export default { wrong: 'Something went wrong, please try again later.', success: 'Success', failed: 'Failed', + verify: 'Verify', + unauthorizedTips: 'Unauthorized, please verify first.', }, chat: { placeholder: 'Ask me anything...(Shift + Enter = line break)', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index a498798..5876836 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -12,6 +12,8 @@ export default { wrong: '好像出错了,请稍后再试。', success: '操作成功', failed: '操作失败', + verify: '验证', + unauthorizedTips: '未经授权,请先进行验证。', }, chat: { placeholder: '来说点什么...(Shift + Enter = 换行)', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index ba646ea..6e08a2d 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -12,6 +12,8 @@ export default { wrong: '好像出錯了,請稍後再試。', success: '操作成功', failed: '操作失敗', + verify: '驗證', + unauthorizedTips: '未經授權,請先進行驗證。', }, chat: { placeholder: '來講點什麼...(Shift + Enter = 換行)', diff --git a/src/router/index.ts b/src/router/index.ts index 6ad59af..f5bd1b0 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,6 +1,7 @@ import type { App } from 'vue' import type { RouteRecordRaw } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router' +import { setupPageGuard } from './permission' import { ChatLayout } from '@/views/chat/layout' const routes: RouteRecordRaw[] = [ @@ -18,18 +19,18 @@ const routes: RouteRecordRaw[] = [ ], }, - { - path: '/403', - name: '403', - component: () => import('@/views/exception/403/index.vue'), - }, - { path: '/404', name: '404', component: () => import('@/views/exception/404/index.vue'), }, + { + path: '/500', + name: '500', + component: () => import('@/views/exception/500/index.vue'), + }, + { path: '/:pathMatch(.*)*', name: 'notFound', @@ -43,6 +44,8 @@ export const router = createRouter({ scrollBehavior: () => ({ left: 0, top: 0 }), }) +setupPageGuard(router) + export async function setupRouter(app: App) { app.use(router) await router.isReady() diff --git a/src/router/permission.ts b/src/router/permission.ts new file mode 100644 index 0000000..093f132 --- /dev/null +++ b/src/router/permission.ts @@ -0,0 +1,25 @@ +import type { Router } from 'vue-router' +import { useAuthStoreWithout } from '@/store/modules/auth' + +export function setupPageGuard(router: Router) { + router.beforeEach(async (from, to, next) => { + const authStore = useAuthStoreWithout() + if (!authStore.session) { + try { + const data = await authStore.getSession() + if (String(data.auth) === 'false' && authStore.token) + authStore.removeToken() + next() + } + catch (error) { + if (from.path !== '/500') + next({ name: '500' }) + else + next() + } + } + else { + next() + } + }) +} diff --git a/src/store/modules/auth/helper.ts b/src/store/modules/auth/helper.ts new file mode 100644 index 0000000..c16e0fe --- /dev/null +++ b/src/store/modules/auth/helper.ts @@ -0,0 +1,15 @@ +import { ss } from '@/utils/storage' + +const LOCAL_NAME = 'SECRET_TOKEN' + +export function getToken() { + return ss.get(LOCAL_NAME) +} + +export function setToken(token: string) { + return ss.set(LOCAL_NAME, token) +} + +export function removeToken() { + return ss.remove(LOCAL_NAME) +} diff --git a/src/store/modules/auth/index.ts b/src/store/modules/auth/index.ts new file mode 100644 index 0000000..5b8c8d8 --- /dev/null +++ b/src/store/modules/auth/index.ts @@ -0,0 +1,43 @@ +import { defineStore } from 'pinia' +import { getToken, removeToken, setToken } from './helper' +import { store } from '@/store' +import { fetchSession } from '@/api' + +export interface AuthState { + token: string | undefined + session: { auth: boolean } | null +} + +export const useAuthStore = defineStore('auth-store', { + state: (): AuthState => ({ + token: getToken(), + session: null, + }), + + actions: { + async getSession() { + try { + const { data } = await fetchSession<{ auth: boolean }>() + this.session = { ...data } + return Promise.resolve(data) + } + catch (error) { + return Promise.reject(error) + } + }, + + setToken(token: string) { + this.token = token + setToken(token) + }, + + removeToken() { + this.token = undefined + removeToken() + }, + }, +}) + +export function useAuthStoreWithout() { + return useAuthStore(store) +} diff --git a/src/store/modules/index.ts b/src/store/modules/index.ts index 0980925..b33b51b 100644 --- a/src/store/modules/index.ts +++ b/src/store/modules/index.ts @@ -1,3 +1,4 @@ export * from './app' export * from './chat' export * from './user' +export * from './auth' diff --git a/src/utils/request/axios.ts b/src/utils/request/axios.ts index ac43edf..cdccaa9 100644 --- a/src/utils/request/axios.ts +++ b/src/utils/request/axios.ts @@ -1,4 +1,5 @@ import axios, { type AxiosResponse } from 'axios' +import { useAuthStore } from '@/store' const service = axios.create({ baseURL: import.meta.env.VITE_GLOB_API_URL, @@ -6,6 +7,9 @@ const service = axios.create({ service.interceptors.request.use( (config) => { + const token = useAuthStore().token + if (token) + config.headers.Authorization = `Bearer ${token}` return config }, (error) => { diff --git a/src/utils/request/index.ts b/src/utils/request/index.ts index 4fc36e7..d651bba 100644 --- a/src/utils/request/index.ts +++ b/src/utils/request/index.ts @@ -1,5 +1,6 @@ import type { AxiosProgressEvent, AxiosResponse, GenericAbortSignal } from 'axios' import request from './axios' +import { useAuthStore } from '@/store' export interface HttpOption { url: string @@ -22,9 +23,16 @@ function http( { url, data, method, headers, onDownloadProgress, signal, beforeRequest, afterRequest }: HttpOption, ) { const successHandler = (res: AxiosResponse>) => { + const authStore = useAuthStore() + if (res.data.status === 'Success' || typeof res.data === 'string') return res.data + if (res.data.status === 'Unauthorized') { + authStore.removeToken() + window.location.reload() + } + return Promise.reject(res.data) } diff --git a/src/views/chat/index.vue b/src/views/chat/index.vue index 7c0e4bb..ffc1653 100644 --- a/src/views/chat/index.vue +++ b/src/views/chat/index.vue @@ -113,13 +113,13 @@ async function onConversation() { requestOptions: { prompt: message, options: { ...options } }, }, ) - scrollToBottom() } catch (error) { // } }, }) + scrollToBottom() } catch (error: any) { const errorMessage = error?.message ?? t('common.wrong') diff --git a/src/views/chat/layout/Layout.vue b/src/views/chat/layout/Layout.vue index 3da4654..a822325 100644 --- a/src/views/chat/layout/Layout.vue +++ b/src/views/chat/layout/Layout.vue @@ -4,12 +4,14 @@ import { NLayout, NLayoutContent } from 'naive-ui' import { useRouter } from 'vue-router' import Sider from './sider/index.vue' import Header from './header/index.vue' +import Permission from './Permission.vue' import { useBasicLayout } from '@/hooks/useBasicLayout' -import { useAppStore, useChatStore } from '@/store' +import { useAppStore, useAuthStore, useChatStore } from '@/store' const router = useRouter() const appStore = useAppStore() const chatStore = useChatStore() +const authStore = useAuthStore() router.replace({ name: 'Chat', params: { uuid: chatStore.active } }) @@ -17,6 +19,8 @@ const { isMobile } = useBasicLayout() const collapsed = computed(() => appStore.siderCollapsed) +const needPermission = computed(() => !!authStore.session?.auth && !authStore.token) + const getMobileClass = computed(() => { if (isMobile.value) return ['rounded-none', 'shadow-none'] @@ -44,5 +48,6 @@ const getContainerClass = computed(() => { + diff --git a/src/views/chat/layout/Permission.vue b/src/views/chat/layout/Permission.vue new file mode 100644 index 0000000..7940064 --- /dev/null +++ b/src/views/chat/layout/Permission.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/views/exception/403/index.vue b/src/views/exception/403/index.vue deleted file mode 100644 index 0d874bb..0000000 --- a/src/views/exception/403/index.vue +++ /dev/null @@ -1,34 +0,0 @@ - - - diff --git a/src/views/exception/500/index.vue b/src/views/exception/500/index.vue new file mode 100644 index 0000000..9e2655e --- /dev/null +++ b/src/views/exception/500/index.vue @@ -0,0 +1,32 @@ + + +