Compare commits

..

14 Commits

Author SHA1 Message Date
JustSong
e7a809b082 fix: allow all origins (close #20) 2023-04-26 15:27:33 +08:00
JustSong
4f8cbd643d fix: prevent common user from specifying channel id (#12) 2023-04-26 14:49:27 +08:00
JustSong
1dd92a3f92 fix: allow all origins (close #20) 2023-04-26 14:26:19 +08:00
JustSong
34a3329f5f docs: update LICENSE 2023-04-26 13:04:41 +08:00
JustSong
4fb07b6d6d chore: update gitignore 2023-04-26 13:04:11 +08:00
JustSong
8be7c9ae80 chore: update Dockerfile 2023-04-26 13:04:01 +08:00
JustSong
4e8dc8d0cf ci: remove useless action 2023-04-26 13:00:33 +08:00
JustSong
1e46b9d135 docs: update README 2023-04-26 12:58:06 +08:00
JustSong
f16a2a5645 feat: redirect to login page if login expired (close #18) 2023-04-26 11:42:56 +08:00
JustSong
03491029f2 feat: limit the ability of common user to set the remaining usage times of token (#9) 2023-04-26 11:10:14 +08:00
JustSong
faf84d833d docs: update Nginx configuration (#3) 2023-04-26 10:50:52 +08:00
JustSong
109736cc05 fix: only reduce remain times when request /v1/chat/completions (close #15)
BREAKING CHANGE: now remain_times is -1 doesn't mean unlimited times anymore!
2023-04-26 10:45:34 +08:00
JustSong
eb8f43acb5 docs: update Nginx config (close #3) 2023-04-26 09:57:46 +08:00
JustSong
05dd7dfd2a feat: able to relay dashboard api now 2023-04-25 22:18:27 +08:00
16 changed files with 159 additions and 89 deletions

View File

@@ -1,29 +0,0 @@
name: Build GitHub Pages
on:
workflow_dispatch:
inputs:
name:
description: 'Reason'
required: false
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v2 # If you're using actions/checkout@v2 you must set persist-credentials to false in most cases for the deployment to work correctly.
with:
persist-credentials: false
- name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
env:
CI: ""
run: |
cd web
npm install
npm run build
- name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@releases/v3
with:
ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
BRANCH: gh-pages # The branch the action should deploy to.
FOLDER: web/build # The folder the action should deploy.

3
.gitignore vendored
View File

@@ -3,4 +3,5 @@
upload
*.exe
*.db
build
build
*.db-journal

View File

@@ -24,7 +24,7 @@ RUN apk update \
&& apk upgrade \
&& apk add --no-cache ca-certificates tzdata \
&& update-ca-certificates 2>/dev/null || true
ENV PORT=3000
COPY --from=builder2 /build/one-api /
EXPOSE 3000
WORKDIR /data

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2022 JustSong
Copyright (c) 2023 JustSong
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -82,10 +82,13 @@ server{
proxy_set_header X-Forwarded-For $remote_addr;
proxy_cache_bypass $http_upgrade;
proxy_set_header Accept-Encoding gzip;
proxy_buffering off; # 重要:关闭代理缓冲
}
}
```
注意,为了 SSE 正常工作,需要关闭 Nginx 的代理缓冲。
之后使用 Let's Encrypt 的 certbot 配置 HTTPS
```bash
# Ubuntu 安装 certbot
@@ -135,6 +138,7 @@ sudo service nginx restart
之后就可以使用你的令牌访问 One API 了,使用方式与 [OpenAI API](https://platform.openai.com/docs/api-reference/introduction) 一致。
可以通过在令牌后面添加渠道 ID 的方式指定使用哪一个渠道处理本次请求,例如:`Authorization: Bearer ONE_API_KEY-CHANNEL_ID`。
注意,需要是管理员用户创建的令牌才能指定渠道 ID。
不加的话将会使用负载均衡的方式使用多个渠道。

View File

@@ -7,16 +7,20 @@ import (
"io"
"net/http"
"one-api/common"
"one-api/model"
"strings"
)
func Relay(c *gin.Context) {
channelType := c.GetInt("channel")
tokenId := c.GetInt("token_id")
isUnlimitedTimes := c.GetBool("unlimited_times")
baseURL := common.ChannelBaseURLs[channelType]
if channelType == common.ChannelTypeCustom {
baseURL = c.GetString("base_url")
}
req, err := http.NewRequest(c.Request.Method, fmt.Sprintf("%s%s", baseURL, c.Request.URL.String()), c.Request.Body)
requestURL := c.Request.URL.String()
req, err := http.NewRequest(c.Request.Method, fmt.Sprintf("%s%s", baseURL, requestURL), c.Request.Body)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"error": gin.H{
@@ -46,7 +50,19 @@ func Relay(c *gin.Context) {
})
return
}
defer resp.Body.Close()
defer func() {
err := req.Body.Close()
if err != nil {
common.SysError("Error closing request body: " + err.Error())
}
if !isUnlimitedTimes && requestURL == "/v1/chat/completions" {
err := model.DecreaseTokenRemainTimesById(tokenId)
if err != nil {
common.SysError("Error decreasing token remain times: " + err.Error())
}
}
}()
isStream := resp.Header.Get("Content-Type") == "text/event-stream"
if isStream {
scanner := bufio.NewScanner(resp.Body)

View File

@@ -76,6 +76,7 @@ func GetToken(c *gin.Context) {
}
func AddToken(c *gin.Context) {
isAdmin := c.GetInt("role") >= common.RoleAdminUser
token := model.Token{}
err := c.ShouldBindJSON(&token)
if err != nil {
@@ -99,7 +100,10 @@ func AddToken(c *gin.Context) {
CreatedTime: common.GetTimestamp(),
AccessedTime: common.GetTimestamp(),
ExpiredTime: token.ExpiredTime,
RemainTimes: token.RemainTimes,
}
if isAdmin {
cleanToken.RemainTimes = token.RemainTimes
cleanToken.UnlimitedTimes = token.UnlimitedTimes
}
err = cleanToken.Insert()
if err != nil {
@@ -135,7 +139,9 @@ func DeleteToken(c *gin.Context) {
}
func UpdateToken(c *gin.Context) {
isAdmin := c.GetInt("role") >= common.RoleAdminUser
userId := c.GetInt("id")
statusOnly := c.Query("status_only")
token := model.Token{}
err := c.ShouldBindJSON(&token)
if err != nil {
@@ -161,19 +167,25 @@ func UpdateToken(c *gin.Context) {
})
return
}
if cleanToken.Status == common.TokenStatusExhausted && cleanToken.RemainTimes == 0 {
if cleanToken.Status == common.TokenStatusExhausted && cleanToken.RemainTimes <= 0 && !cleanToken.UnlimitedTimes {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "令牌可用次数已用尽,无法启用,请先修改令牌剩余次数",
"message": "令牌可用次数已用尽,无法启用,请先修改令牌剩余次数,或者设置为无限次数",
})
return
}
}
cleanToken.Name = token.Name
cleanToken.Status = token.Status
cleanToken.ExpiredTime = token.ExpiredTime
cleanToken.RemainTimes = token.RemainTimes
if statusOnly != "" {
cleanToken.Status = token.Status
} else {
// If you add more fields, please also update token.Update()
cleanToken.Name = token.Name
cleanToken.ExpiredTime = token.ExpiredTime
if isAdmin {
cleanToken.RemainTimes = token.RemainTimes
cleanToken.UnlimitedTimes = token.UnlimitedTimes
}
}
err = cleanToken.Update()
if err != nil {
c.JSON(http.StatusOK, gin.H{

View File

@@ -16,7 +16,7 @@ func authHelper(c *gin.Context, minRole int) {
id := session.Get("id")
status := session.Get("status")
if username == nil {
c.JSON(http.StatusOK, gin.H{
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,未登录",
})
@@ -80,8 +80,21 @@ func TokenAuth() func(c *gin.Context) {
return
}
c.Set("id", token.UserId)
c.Set("token_id", token.Id)
c.Set("unlimited_times", token.UnlimitedTimes)
if len(parts) > 1 {
c.Set("channelId", parts[1])
if model.IsAdmin(token.UserId) {
c.Set("channelId", parts[1])
} else {
c.JSON(http.StatusOK, gin.H{
"error": gin.H{
"message": "普通用户不支持指定渠道",
"type": "one_api_error",
},
})
c.Abort()
return
}
}
c.Next()
}

View File

@@ -7,6 +7,9 @@ import (
func CORS() gin.HandlerFunc {
config := cors.DefaultConfig()
config.AllowOrigins = []string{"https://one-api.vercel.app", "http://localhost:3000/"}
config.AllowAllOrigins = true
config.AllowCredentials = true
config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
config.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization", "Accept", "Connection"}
return cors.New(config)
}

View File

@@ -3,20 +3,22 @@ package model
import (
"errors"
_ "gorm.io/driver/sqlite"
"gorm.io/gorm"
"one-api/common"
"strings"
)
type Token struct {
Id int `json:"id"`
UserId int `json:"user_id"`
Key string `json:"key" gorm:"uniqueIndex"`
Status int `json:"status" gorm:"default:1"`
Name string `json:"name" gorm:"index" `
CreatedTime int64 `json:"created_time" gorm:"bigint"`
AccessedTime int64 `json:"accessed_time" gorm:"bigint"`
ExpiredTime int64 `json:"expired_time" gorm:"bigint;default:-1"` // -1 means never expired
RemainTimes int `json:"remain_times" gorm:"default:-1"` // -1 means infinite times
Id int `json:"id"`
UserId int `json:"user_id"`
Key string `json:"key" gorm:"uniqueIndex"`
Status int `json:"status" gorm:"default:1"`
Name string `json:"name" gorm:"index" `
CreatedTime int64 `json:"created_time" gorm:"bigint"`
AccessedTime int64 `json:"accessed_time" gorm:"bigint"`
ExpiredTime int64 `json:"expired_time" gorm:"bigint;default:-1"` // -1 means never expired
RemainTimes int `json:"remain_times" gorm:"default:0"`
UnlimitedTimes bool `json:"unlimited_times" gorm:"default:false"`
}
func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) {
@@ -50,14 +52,16 @@ func ValidateUserToken(key string) (token *Token, err error) {
}
return nil, errors.New("该 token 已过期")
}
if !token.UnlimitedTimes && token.RemainTimes <= 0 {
token.Status = common.TokenStatusExhausted
err := token.SelectUpdate()
if err != nil {
common.SysError("更新 token 状态失败:" + err.Error())
}
return nil, errors.New("该 token 可用次数已用尽")
}
go func() {
token.AccessedTime = common.GetTimestamp()
if token.RemainTimes > 0 {
token.RemainTimes--
if token.RemainTimes == 0 {
token.Status = common.TokenStatusExhausted
}
}
err := token.SelectUpdate()
if err != nil {
common.SysError("更新 token 失败:" + err.Error())
@@ -84,15 +88,16 @@ func (token *Token) Insert() error {
return err
}
// Update Make sure your token's fields is completed, because this will update non-zero values
func (token *Token) Update() error {
var err error
err = DB.Model(token).Updates(token).Error
err = DB.Model(token).Select("name", "status", "expired_time", "remain_times", "unlimited_times").Updates(token).Error
return err
}
func (token *Token) SelectUpdate() error {
// This can update zero values
return DB.Model(token).Select("accessed_time", "remain_times", "status").Updates(token).Error
return DB.Model(token).Select("accessed_time", "status").Updates(token).Error
}
func (token *Token) Delete() error {
@@ -113,3 +118,8 @@ func DeleteTokenById(id int, userId int) (err error) {
}
return token.Delete()
}
func DecreaseTokenRemainTimesById(id int) (err error) {
err = DB.Model(&Token{}).Where("id = ?", id).Update("remain_times", gorm.Expr("remain_times - ?", 1)).Error
return err
}

View File

@@ -175,3 +175,16 @@ func ResetUserPasswordByEmail(email string, password string) error {
err = DB.Model(&User{}).Where("email = ?", email).Update("password", hashedPassword).Error
return err
}
func IsAdmin(userId int) bool {
if userId == 0 {
return false
}
var user User
err := DB.Where("id = ?", userId).Select("role").Find(&user).Error
if err != nil {
common.SysError("No such user " + err.Error())
return false
}
return user.Role >= common.RoleAdminUser
}

View File

@@ -7,9 +7,14 @@ import (
)
func SetRelayRouter(router *gin.Engine) {
relayRouter := router.Group("/v1")
relayRouter.Use(middleware.GlobalAPIRateLimit(), middleware.TokenAuth(), middleware.Distribute())
relayV1Router := router.Group("/v1")
relayV1Router.Use(middleware.GlobalAPIRateLimit(), middleware.TokenAuth(), middleware.Distribute())
{
relayRouter.Any("/*path", controller.Relay)
relayV1Router.Any("/*path", controller.Relay)
}
relayDashboardRouter := router.Group("/dashboard")
relayDashboardRouter.Use(middleware.GlobalAPIRateLimit(), middleware.TokenAuth(), middleware.Distribute())
{
relayDashboardRouter.Any("/*path", controller.Relay)
}
}

View File

@@ -10,7 +10,7 @@ import {
Modal,
Segment,
} from 'semantic-ui-react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { UserContext } from '../context/User';
import { API, showError, showSuccess } from '../helpers';
@@ -20,6 +20,7 @@ const LoginForm = () => {
password: '',
wechat_verification_code: '',
});
const [searchParams, setSearchParams] = useSearchParams();
const [submitted, setSubmitted] = useState(false);
const { username, password } = inputs;
const [userState, userDispatch] = useContext(UserContext);
@@ -28,6 +29,9 @@ const LoginForm = () => {
const [status, setStatus] = useState({});
useEffect(() => {
if (searchParams.get("expired")) {
showError('未登录或登录已过期,请重新登录!');
}
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);

View File

@@ -79,11 +79,11 @@ const TokensTable = () => {
break;
case 'enable':
data.status = 1;
res = await API.put('/api/token/', data);
res = await API.put('/api/token/?status_only=true', data);
break;
case 'disable':
data.status = 2;
res = await API.put('/api/token/', data);
res = await API.put('/api/token/?status_only=true', data);
break;
}
const { success, message } = res.data;
@@ -230,7 +230,7 @@ const TokensTable = () => {
<Table.Cell>{token.id}</Table.Cell>
<Table.Cell>{token.name ? token.name : '无'}</Table.Cell>
<Table.Cell>{renderStatus(token.status)}</Table.Cell>
<Table.Cell>{token.remain_times === -1 ? "无限制" : token.remain_times}</Table.Cell>
<Table.Cell>{token.unlimited_times ? "无限制" : token.remain_times}</Table.Cell>
<Table.Cell>{renderTimestamp(token.created_time)}</Table.Cell>
<Table.Cell>{renderTimestamp(token.accessed_time)}</Table.Cell>
<Table.Cell>{token.expired_time === -1 ? "永不过期" : renderTimestamp(token.expired_time)}</Table.Cell>

View File

@@ -54,14 +54,18 @@ export function showError(error) {
console.error(error);
if (error.message) {
if (error.name === 'AxiosError') {
switch (error.message) {
case 'Request failed with status code 429':
switch (error.response.status) {
case 401:
// toast.error('错误:未登录或登录已过期,请重新登录!', showErrorOptions);
window.location.href = '/login?expired=true';
break;
case 429:
toast.error('错误:请求次数过多,请稍后再试!', showErrorOptions);
break;
case 'Request failed with status code 500':
case 500:
toast.error('错误:服务器内部错误,请联系管理员!', showErrorOptions);
break;
case 'Request failed with status code 405':
case 405:
toast.info('本站仅作演示之用,无服务端!');
break;
default:

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Segment } from 'semantic-ui-react';
import { useParams } from 'react-router-dom';
import { API, showError, showSuccess, timestamp2string } from '../../helpers';
import { API, isAdmin, showError, showSuccess, timestamp2string } from '../../helpers';
const EditToken = () => {
const params = useParams();
@@ -10,11 +10,13 @@ const EditToken = () => {
const [loading, setLoading] = useState(isEdit);
const originInputs = {
name: '',
remain_times: -1,
expired_time: -1
remain_times: 0,
expired_time: -1,
unlimited_times: false
};
const isAdminUser = isAdmin();
const [inputs, setInputs] = useState(originInputs);
const { name, remain_times, expired_time } = inputs;
const { name, remain_times, expired_time, unlimited_times } = inputs;
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
@@ -35,6 +37,10 @@ const EditToken = () => {
}
};
const setUnlimitedTimes = () => {
setInputs({ ...inputs, unlimited_times: !unlimited_times });
};
const loadToken = async () => {
let res = await API.get(`/api/token/${tokenId}`);
const { success, message, data } = res.data;
@@ -88,7 +94,7 @@ const EditToken = () => {
return (
<>
<Segment loading={loading}>
<Header as='h3'>{isEdit ? "更新令牌信息" : "创建新的令牌"}</Header>
<Header as='h3'>{isEdit ? '更新令牌信息' : '创建新的令牌'}</Header>
<Form autoComplete='off'>
<Form.Field>
<Form.Input
@@ -101,17 +107,25 @@ const EditToken = () => {
required={!isEdit}
/>
</Form.Field>
<Form.Field>
<Form.Input
label='剩余次数'
name='remain_times'
placeholder={'请输入剩余次数,-1 表示无限制'}
onChange={handleInputChange}
value={remain_times}
autoComplete='off'
type='number'
/>
</Form.Field>
{
isAdminUser && <>
<Form.Field>
<Form.Input
label='剩余次数'
name='remain_times'
placeholder={'请输入剩余次数'}
onChange={handleInputChange}
value={remain_times}
autoComplete='off'
type='number'
disabled={unlimited_times}
/>
</Form.Field>
<Button type={'button'} onClick={() => {
setUnlimitedTimes();
}}>{unlimited_times ? '取消无限次' : '设置为无限次'}</Button>
</>
}
<Form.Field>
<Form.Input
label='过期时间'