This commit is contained in:
史悦
2025-08-13 19:03:20 +08:00
commit d62a2e9ed9
73 changed files with 7296 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.env
docs
nginx
out
cert

101
.env.example Normal file
View File

@@ -0,0 +1,101 @@
# 通用配置
ENV=production
# HTTP请求的端口号 ,非必要请勿更改
PORT=1188
# HTTPS请求的端口号, 如果与本地冲突可更改, 然后自行反代 http 端口实现 https 访问
HTTPS_PORT=443
# 主机地址, 一般不需要更改
HOST=0.0.0.0
# JWT秘钥, 建议立即修改 (每次修改后都需要重新登录插件后才可正常使用)
TOKEN_SALT=TiXrNaj4avvMuD4w
# 登录页面访问密码, 用于部署在公共服务器上防止他人盗用服务, 默认空:表示不设置
LOGIN_PASSWORD=
# VS2022登录GitHub Copilot插件所需的客户端ID (请勿更改)
VS_COPILOT_CLIENT_ID=a200baed193bb2088a6e
VS_COPILOT_CLIENT_SECRET=
# 语言环境, 默认中文(zh-CN)
CHAT_LOCALE=zh-CN
# 全局 http 请求超时,单位秒
HTTP_CLIENT_TIMEOUT=60
# 代码补全服务配置
CODEX_API_BASE=https://api.deepseek.com/beta/v1/completions
# 支持多个轮询APIKEY用英文逗号分隔
CODEX_API_KEY=sk-
# 代码补全服务的模型名称
CODEX_API_MODEL_NAME=deepseek-chat
# 代码补全模型的最大响应tokens, 如果是Ollama建议设置小一点, 避免直接补全一长串代码
CODEX_MAX_TOKENS=500
# 代码补全模型温度超参数, 数值越大对补全质量影响越大. 如果要跟随插件动态设置,请设置为-1 (默认值为 `1`, 可以调整为 `0.1-1.0` 之间的值.)
CODEX_TEMPERATURE=1
# 代码补全模型类型, 用于兼容本地模型, 可选值: default/ollama
CODEX_SERVICE_TYPE=default
# 限制代码补全 `prompt` 和 `suffix` 的行数, 可减少代码补全时消耗的tokens, 这可能会略微影响代码补全质量. (默认: 0, 表示不限制; 大于 0 表示限制 xx 行)
CODEX_LIMIT_PROMPT=0
# 对话服务请求地址, 理论支持任何符合OpenAI接口规范的模型
CHAT_API_BASE=https://api.deepseek.com/v1/chat/completions
# 对话服务请求的API KEY, 不支持多个API KEY
CHAT_API_KEY=sk-
# 对话服务的模型名称
CHAT_API_MODEL_NAME=deepseek-chat
# 对话服务模型的最大响应tokens
CHAT_MAX_TOKENS=4096
# 是否允许使用工具, 默认开启 (根据自己的模型支持来设置)
CHAT_USE_TOOLS=true
# 默认的服务请求地址, 必须开启https. 可以替换任何二级域名, 但后续的服务域名必须与此域名有关
DEFAULT_BASE_URL=https://copilot.supercopilot.top
# 补全防抖时间, 单位:毫秒
COPILOT_DEBOUNCE=200
# 默认的API服务请求地址, 必须开启https. 域名 `api` 前缀必须固定
API_BASE_URL=https://api.copilot.supercopilot.top
# 默认的代理服务请求地址, 必须开启https. 域名 `copilot-proxy` 前缀必须固定
PROXY_BASE_URL=https://copilot-proxy.copilot.supercopilot.top
# 默认的心跳服务请求地址, 必须开启https. 域名 `copilot-telemetry-service` 前缀必须固定
TELEMETRY_BASE_URL=https://copilot-telemetry-service.copilot.supercopilot.top
# copilot的客户端类型, 用于区分是否使用官方copilot服务 (可选值: default/github)
COPILOT_CLIENT_TYPE=default
# 支持多个轮询token用英文逗号分隔
COPILOT_GHU_TOKEN=ghu_xxxx
# 在使用官方Copilot服务的时候是否全代理请求
COPILOT_PROXY_ALL=false
# github copilot 官方账号类型, 企业版账号需要调整此参数, 否则在全代理模式下无法正常使用 (可选值: individual/business)
COPILOT_ACCOUNT_TYPE=individual
# Copilot伪装token下发的有效期,单位秒 (如果是共享给他人的服务建议使用默认值, 自用的话可以设置很大来避免github copilot插件偶尔断连的问题)
DISGUISE_COPILOT_TOKEN_EXPIRES_AT=1800
# Embedding模型配置
EMBEDDING_API_BASE=http://127.0.0.1:5012/v1/embeddings
EMBEDDING_API_KEY=
EMBEDDING_API_MODEL_NAME=m3e
EMBEDDING_DIMENSION_SIZE=1536
LIGHTWEIGHT_MODEL=gpt-4o-mini

View File

@@ -0,0 +1,77 @@
name: Bug 反馈
description: 当你在代码中发现了一个 Bug导致应用崩溃或抛出异常或者某些地方看起来不对劲。
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
感谢对项目的支持与关注。在提出问题之前,请确保你已查看相关开发或使用文档,以及已搜索现有的问题列表,以避免重复问题。
- type: checkboxes
attributes:
label: 这个问题是否已经存在?
options:
- label: 我已经搜索过现有的 [Issues列表](https://gitee.com/ripperTs/github-copilot-proxies/issues)
required: true
- type: checkboxes
attributes:
label: 是否关闭了代理工具?
options:
- label: 我确定已经关闭了所有代理工具 (无论是系统代理还是其他代理方式) 后依旧无法解决问题
required: true
- type: textarea
attributes:
label: 遇到的问题
description: 请详细告诉我们如何复现你遇到的问题,如涉及代码,可提供一个最小代码示例,并使用反引号```附上它
validations:
required: true
- type: textarea
attributes:
label: 完整的环境变量配置
description: 请提供你的环境变量配置,如果内容敏感不想让其他人看见, 勾选最下方 **添加内容风险标识** 即可。
value: |
```
# 请在这里粘贴你的环境变量配置
```
validations:
required: true
- type: dropdown
attributes:
label: 部署方式
description: 你当前是如何部署项目的?
options:
- Docker 部署(默认)
- 下载的可执行文件
validations:
required: true
- type: dropdown
attributes:
label: IDE 类型
description: 你使用的 ide 是什么?
options:
- 'Jetbrains系列 (如: IntelliJ IDEA, PyCharm等)'
- VSCode
- Visual Studio 2022
- HBuilderX
validations:
required: true
- type: input
attributes:
label: Github Copilot 插件版本
description: 填写你当前使用的 Github Copilot 插件版本
placeholder: 例如v1.5.29 (已是最新版本)
validations:
required: true
- type: textarea
attributes:
label: 截图或视频
description: 如果可以的话,上传任何关于 bug 的截图。
value: |
[在这里上传图片]
- type: input
attributes:
label: 软件版本
description: 你当前正在使用我们软件的哪个版本/分支?
placeholder: 例如v1.0.0
validations:
required: true

View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 环境变量参数说明
url: https://gitee.com/ripperTs/github-copilot-proxies/blob/master/PARAM.md
about: 提供了所有用到的环境变量参数、可选值、默认值等说明
- name: 免费的公共服务端点
url: https://mycopilot.noteo.cn
about: 提供了免费的公共服务端点,代码会与此仓库版本保持一致,仅用于测试连通性

View File

@@ -0,0 +1,20 @@
name: 功能建议
description: 对本项目提出一个功能建议
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
感谢提出功能建议,我们将仔细考虑!
- type: textarea
attributes:
label: 你的建议是什么?
description: 清晰并简洁地描述哦~
validations:
required: true
- type: checkboxes
attributes:
label: 意向参与贡献
options:
- label: 我有意向参与具体功能的开发实现并将代码贡献回到上游社区
required: false

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
.idea
.vscode
go.sum
configs/*_config.yml
bin/*
test/*
*_config.yml
apiclient_key.pem
.env
docker-compose-local.yml
out
logs
cert

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# 使用官方 Golang 镜像作为构建环境
FROM golang:1.21-alpine AS builder
# 设置工作目录
WORKDIR /app
# 复制源代码
COPY . .
# 检查 go.mod 是否存在,如果不存在则初始化一个新的模块
RUN if [ ! -f go.mod ]; then \
go mod init myapp; \
fi
# 下载依赖
RUN go mod tidy
# 构建应用
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# 使用轻量级的 alpine 镜像
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# 从 builder 阶段复制构建的二进制文件
COPY --from=builder /app/main .
COPY .env.example .env
COPY models.json models.json
# 暴露端口
EXPOSE 1188
EXPOSE 443
# 运行应用
CMD ["./main"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Ripper
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

20
Makefile Normal file
View File

@@ -0,0 +1,20 @@
BINARY_NAME=copilot-proxies
OUT_DIR=out
all: build
build: prepare
GOOS=linux GOARCH=amd64 go build -o ${OUT_DIR}/${BINARY_NAME}-linux-amd64 main.go
GOOS=windows GOARCH=amd64 go build -o ${OUT_DIR}/${BINARY_NAME}-windows-amd64.exe main.go
GOOS=windows GOARCH=amd64 go build -ldflags="-H windowsgui" -o ${OUT_DIR}/${BINARY_NAME}-windows-amd64-gui.exe main.go
GOOS=darwin GOARCH=amd64 go build -o ${OUT_DIR}/${BINARY_NAME}-darwin-amd64 main.go
GOOS=linux GOARCH=arm64 go build -o ${OUT_DIR}/${BINARY_NAME}-linux-arm64 main.go
GOOS=darwin GOARCH=arm64 go build -o ${OUT_DIR}/${BINARY_NAME}-darwin-arm64 main.go
prepare:
mkdir -p ${OUT_DIR}
clean:
rm -rf ${OUT_DIR}
.PHONY: all build prepare clean

65
PARAM.md Normal file
View File

@@ -0,0 +1,65 @@
# 环境变量参数说明
## ENV默认参数说明
| 参数 | 描述 | 类型 | 默认值 |
|-----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|-------------------------------------------------|
| ENV | 当前环境 (默认: production 表示生产环境, development 表示开发环境) | string | production |
| PORT | HTTP请求的端口号 ,非必要请勿更改 | int | 1188 |
| HTTPS_PORT | HTTPS请求的端口号 ,非必要请勿更改 | int | 443 |
| HOST | 主机地址 | int | 0.0.0.0 |
| LOGIN_PASSWORD | `login/device` 页面的访问密码, 用于部署在公共服务器上防止他人盗用服务, 默认空:表示不设置 | string | |
| TOKEN_SALT | JWT秘钥 **建议修改** | string | 7L3Gqrn24TUWzLwG |
| VS_COPILOT_CLIENT_ID | VS2022登录GitHub Copilot插件所需的客户端ID | string | a200baed193bb2088a6e |
| VS_COPILOT_CLIENT_SECRET | VS2022登录GitHub Copilot插件所需的客户端秘钥 | string | |
| HTTP_CLIENT_TIMEOUT | 全局 http 请求超时,单位秒 | int | 60 |
| ~~CERT_FILE~~ | HTTPS域名证书 **v0.1.0 已废弃** | string | ssl/mycopilot.crt |
| ~~KEY_FILE~~ | HTTPS域名证书秘钥 **v0.1.0 已废弃** | string | ssl/mycopilot.key |
| CODEX_API_BASE | 代码补全服务地址 , 详细参考[代码补全服务地址](#代码补全服务地址) | string | https://api.deepseek.com/beta/v1/completions |
| CODEX_API_KEY | 代码补全服务的API KEY, 支持多个轮询token用英文逗号分隔 | string | |
| CODEX_API_MODEL_NAME | 代码补全服务的模型名称 | string | |
| CODEX_MAX_TOKENS | 代码补全模型的最大响应tokens, 如果是Ollama建议设置小一点, 避免直接补全一长串代码 | int | 500 |
| CODEX_TEMPERATURE | 代码补全模型温度超参数,deepseek模型官方推荐设置为1, 如果要跟随插件动态设置,请设置为-1 (默认值为 `1`, 可以调整为 `0.1-1.0` 之间的值.) | int | 0 |
| CODEX_SERVICE_TYPE | 代码补全模型类型, 用于兼容本地模型 <br/>可选值: `default` `ollama` | string | default |
| CODEX_LIMIT_PROMPT | 限制代码补全 `prompt``suffix` 的行数, 可减少代码补全时消耗的tokens, 这可能会略微影响代码补全质量. <br/>(默认: 0, 表示不限制; 大于 0 表示限制 xx 行) | int | 0 |
| COPILOT_DEBOUNCE | 补全防抖时间, 单位:毫秒 | int | 200 |
| CHAT_API_BASE | 对话服务请求地址, 理论支持任何符合 `OpenAI` 接口规范的模型 | string | https://api.deepseek.com/v1/chat/completions |
| CHAT_API_KEY | 对话服务请求的API KEY | string | |
| CHAT_API_MODEL_NAME | 对话服务请求的模型名称 | string | deepseek-chat |
| CHAT_MAX_TOKENS | 对话模型的最大响应tokens , 常见的模型响应tokens是4k, 如果支持8k可以手动调整 | int | 4096 |
| CHAT_LOCALE | 指定国家,可实现中文回答 | string | zh_CN |
| CHAT_USE_TOOLS | 是否支持使用工具, 默认开启 (根据自己的模型支持来设置) | bool | true |
| EMBEDDING_API_BASE | Embedding模型接口 (**支持任意符合 `OpenAI` 接口格式的 Embedding 模型**) | string | 示例: http://127.0.0.1:5012/v1/embeddings |
| EMBEDDING_API_KEY | Embedding接口鉴权秘钥 | string | |
| EMBEDDING_API_MODEL_NAME | Embedding模型名称 | string | m3e |
| EMBEDDING_DIMENSION_SIZE | Embedding 模型维度 | int | 1536 |
| DEFAULT_BASE_URL | 默认的服务请求地址, 必须开启https. 可以替换任何二级域名, 但后续的服务域名必须与此域名有关 | string | https://mycopilot.com |
| API_BASE_URL | 默认的API服务请求地址, 必须开启https. 域名 `api` 前缀必须固定 | string | https://api.mycopilot.com |
| PROXY_BASE_URL | 默认的代理服务请求地址, 必须开启https. 域名 `copilot-proxy` 前缀必须固定 | string | https://copilot-proxy.mycopilot.com |
| TELEMETRY_BASE_URL | 默认的心跳服务请求地址, 必须开启https. 域名 `copilot-telemetry-service` 前缀必须固定 | string | https://copilot-telemetry-service.mycopilot.com |
| COPILOT_CLIENT_TYPE | copilot的客户端类型, 用于区分是否使用官方copilot服务<br/>可选值: `default` `github` | string | default |
| COPILOT_GHU_TOKEN | 官方copilot服务的ghu token, 如果 `COPILOT_CLIENT_TYPE` 值为 `github` 的时候必填<br/>支持多个轮询token用英文逗号分隔<br/>获取方法: 程序启动后访问 [获取 GitHub GHU](http://127.0.0.1:1188/github/login/device/code) 页面按提示操作即可 | string | |
| COPILOT_PROXY_ALL | 在使用官方Copilot服务的时候是否全代理 (可选值: `false` `true`) <br/> **有封号的风险, 请自行甄别后慎重使用** | bool | false |
| COPILOT_ACCOUNT_TYPE | github copilot 官方账号类型 (可选值: `individual` `business`)<br/> 企业版账号需要调整此参数, 否则在全代理模式下无法正常使用 | string | individual |
| DISGUISE_COPILOT_TOKEN_EXPIRES_AT | Copilot伪装token下发的有效期,单位秒 (如果是共享给他人的服务建议使用默认值, 自用的话可以设置很大来避免github copilot插件偶尔断连的问题) | int | 1800 |
| ~~DASHSCOPE_API_KEY~~ | ~~阿里灵石API KEY, 目前用于embedding模型服务, [API-KEY的获取与配置](https://help.aliyun.com/zh/dashscope/developer-reference/acquisition-and-configuration-of-api-key)~~ | string | |
| LIGHTWEIGHT_MODEL | 轻量模型名称, 关键字即可无需全部模型名称, 比如gpt-4o-mini-0429, 直接使用gpt-4o-mini即可, 符合轻量模型的调用走代码补全接口, 节省成本 | string | |
以上环境变量参数配置可以手动在以下几个地方更改进行覆盖默认的设置:
- 二进制文件同级目录下的 `.env` 文件, 如果没有可自行创建
- 系统的环境变量中设置, 例如 `export PORT=1188`
- `docker-compose.yml` 文件中的 `environment` 配置项
## 代码补全服务地址
兼容支持 `OpenAI` Chat 接口参数规范的所有地址, 下面是一些兼容常用的地址:
| 服务地址 | 描述 |
|--------------------------------------------------------------------|------------------------------------------|
| https://api.deepseek.com/beta/v1/completions | DeepSeek 官方API, 这里使用Beta地址是为了 8k 的prompt |
| https://api.siliconflow.cn/v1/completions | 硅基流动 官方API |
| https://api.mistral.ai/v1/fim/completions | Mistral 官方API |
| http://127.0.0.1:11434/v1/chat/completions | Ollama的Chat对话接口 |
| http://127.0.0.1:11434/api/generate | Ollama代码生成, 主要适配了 `suffix` 后缀参数的模型 |
| https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions | 阿里百炼平台API |

278
README.md Normal file
View File

@@ -0,0 +1,278 @@
# Github Copilot 后端代理服务
借助其他支持 `FIM` 接口的模型如DeepSeek来接管GitHub Copilot插件服务端, 廉价的模型+强大的补全插件相结合, 使得开发者可以更加高效的编写代码。
**免费的公共服务端点: [mycopilot.noteo.cn](https://mycopilot.noteo.cn/help), 感谢 [硅基流动](https://cloud.siliconflow.cn/i/NO6ShUc3) 提供免费的模型服务**
🚨 **破坏性更新提示: `v0.1.0` 版本更新之后需要做配置调整, 具体参考: [升级指南](https://gitee.com/ripperTs/github-copilot-proxies/releases/tag/v0.1.0) 部分内容.**
🚨 **破坏性更新提示: `v0.1.6` 版本更新对 `Embeddings模型` 进行了调整, 如果还想继续使用阿里灵石的模型, 请自行借助 `One API` 之类的中转系统进行接入.**
## 功能特性
- [x] 使用 `docker-compose` 部署, 简单方便
- [x] 多种IDE, 如: [VSCode](#vscode), [Jetbrains IDE系列](#jetbrains-ide系列), [Visual Studio 2022](#visual-studio-2022), [HBuilderX](#hbuilderx)
- [x] 任意符合 `OpenAI` 接口规范的模型, 和 `Ollama` 部署的本地模型
- [x] `GitHub Copilot` 插件各种API接口**全接管**, 无需担心插件升级导致服务失效
- [x] 代码补全请求防抖设置和自定义 prompt 精简, 避免过度消耗 Tokens
- [x] 使用 Github Copilot 官方服务, 参考: [使用GitHub Copilot官方服务](#使用github-copilot官方服务)
- [x] VSCode 对话编辑模式
- [x] 代码补全APIKEY支持多个轮询, 避免限频
- [x] 无需自有域名, 自动配置和续签 `Let's Encrypt` SSL证书 (每 60 天自动更新一次证书, 自动重载 https 服务)
- [x] 局域网共享, 可多台电脑共享一个服务端, 参考: [局域网共享方案](#局域网共享方案)
- [x] 完全纯离线部署, 无需任何外部网络支持, 参考: [纯内网离线部署方案](#纯内网离线部署方案)
- [x] 本地部署的 Embeddings 模型支持, 参考: [README.md](embeddings/README.md)
## 如何使用?
**在使用之前确保自己的环境是干净的, 也就是说不能使用过其他的激活服务, 可以先检查自己的环境变量将 `GITHUB` `COPILOT` 相关的环境变量删除, 然后将插件更新最新版本后重启IDE即可.**
**⚠️ 如果你本地有使用科学上网工具, 那必须将域名 `copilot.supercopilot.top` 系列域名添加直连名单中, 否则无法正常使用!**
### 快速使用步骤
1. **部署服务**: 可以使用[下载文件直接部署使用](#下载文件直接部署使用) 或 使用[docker部署](#docker部署).
2. **配置IDE**: 详细参考下面的[IDE设置方法](#ide设置方法).
3. **重启IDE**: 点击登录 `GitHub Copilot` 插件即可.
### Docker部署
**(推荐)** 懒人推荐使用此方案, 比较简单
**模型API KEY 替换为你的**, 然后执行以下命令即可启动服务:
```shell
# 启动服务
docker-compose up -d
# 停止服务
docker-compose down
# 更新服务
1. docker-compose pull
2. docker-compose down
3. docker-compose up -d
# 查看日志
docker-compose logs -f
```
镜像全部上传到阿里云容器镜像服务, 每个版本都有对应的镜像可使用或回滚.
### 下载文件直接部署使用
1. 下载最新版本的对应系统的可执行文件访问 [releases](https://gitee.com/ripperTs/github-copilot-proxies/releases).
2. 在可执行文件同级目录下创建 `.env` 文件, 参考 [.env.example](.env.example) 文件配置.
3. 启动服务后然后按照[IDE设置方法](#ide设置方法)配置IDE.
4. 重启IDE,登录 `GitHub Copilot` 插件.
### 自有服务器部署
1. 使用 `docker-compose` 或下载可执行文件运行起程序 (如果已有 nginx, 避免 443 端口占用可直接修改其他端口, 后面借助nginx 反向代理实现 https)
2. 解析四个域名到服务器IP, 假设你的域名是: `domain.com`, 那么你需要解析的域名分别是 (**特别注意: 域名前缀不可变**):
```
domain.com
api.domain.com
copilot-proxy.domain.com
copilot-telemetry-service.domain.com
```
3. 将四个域名全部配置好 `SSL` 证书
4. 配置 Nginx 反向代理或伪静态规则, 参考配置如下:
```nginx
location ^~ /
{
proxy_pass http://127.0.0.1:1188/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_http_version 1.1;
# proxy_hide_header Upgrade;
add_header X-Cache $upstream_cache_status;
proxy_redirect off;
proxy_buffering off;
proxy_max_temp_file_size 0;
client_max_body_size 10m;
client_body_buffer_size 128k;
proxy_connect_timeout 90;
proxy_send_timeout 90;
proxy_read_timeout 90;
proxy_buffer_size 4k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
proxy_temp_file_write_size 64k;
#Set Nginx Cache
set $static_filer5CIeZff 0;
if ( $uri ~* "\.(gif|png|jpg|css|js|woff|woff2)$" )
{
set $static_filer5CIeZff 1;
expires 1m;
}
if ( $static_filer5CIeZff = 0 )
{
add_header Cache-Control no-cache;
}
}
```
5. 最后将以上域名修改到对应的环境变量配置文件中.
6. 最终使用 https 方式访问四个域名必须是正常的, 不能有任何问题, 否则插件无法正常使用.
### 环境变量参数说明
详细参考: [环境变量参数说明](PARAM.md)
## IDE设置方法
### VSCode
1. 安装插件: `GitHub Copilot`
2. 修改 VSCode 的 settings.json 文件, 添加以下配置:
```json
"github.copilot.advanced": {
"authProvider": "github-enterprise",
"debug.overrideCAPIUrl": "https://api.copilot.supercopilot.top",
"debug.overrideProxyUrl": "https://copilot-proxy.copilot.supercopilot.top",
"debug.chatOverrideProxyUrl": "https://api.copilot.supercopilot.top/chat/completions",
"debug.overrideFastRewriteEngine": "v1/engines/copilot-centralus-h100",
"debug.overrideFastRewriteUrl": "https://api.copilot.supercopilot.top"
},
"github-enterprise.uri": "https://copilot.supercopilot.top"
```
### Jetbrains IDE系列
1. 找到`设置` > `语言与框架` > `GitHub Copilot` > `Authentication Provider`
2. 填写的值为: `copilot.supercopilot.top`
### Visual Studio 2022
1. 更新到最新版本(内置 Copilot 版本)至少是 `17.10.x` 以上
2. 首先, 开启 `Github Enterprise` 账户支持:工具->环境->账户->勾选 `包含 Github Enterprise 服务器账户`
3. 然后, 重启你的 `Visual Studio 2022` 编辑器
4. 最后, 点击添加 Github 账户,切换到 Github Enterprise 选项卡,输入 `https://copilot.supercopilot.top` 即可。
### HBuilderX
⚠️ 注意, 插件中的相关 domain 已经写死无法修改, 所以必须使用默认的 `copilot.supercopilot.top` 域名配置.
1. 下载 **[copilot-for-hbuilderx-v1.zip](https://pan.quark.cn/s/70e6849970e5)** 插件到本地
2. 将插件安装到 plugin目录下, 详细参考: [离线插件安装指南](https://hx.dcloud.net.cn/Tutorial/OfflineInstall)
3. 重启 Hbuilder X 后点击登录 `GitHub Copilot` 即可.
## 局域网共享方案
如果是局域网多台电脑共用一个服务端只需要更改hosts文件指向到内网服务器 ip 即可, 例如:
在局域网服务器(192.168.80.40)部署了copilot-proxies服务那么局域网内其他机器仅需要修改host为以下即可可以工作。 ( [@pennbay](https://gitee.com/pennbay) 提供实测反馈 )
```
192.168.80.40 copilot.supercopilot.top
192.168.80.40 api.copilot.supercopilot.top
192.168.80.40 copilot-proxy.copilot.supercopilot.top
192.168.80.40 copilot-telemetry-service.copilot.supercopilot.top
```
## 纯内网离线部署方案
`v0.1.0` 版本之后 ssl 证书调整为从网络上下载同步, 这对于纯内网部署造成了一些困难, 下面我提供一个简单的方案你需要做如下操作:
- 从外网下载最新证书文件, 远程下载地址参考 [certificate.go](pkg/certificate/certificate.go), 注意证书最长只有 60 天, 需要手动更新.
- 将两个证书文件放在 `/cert` 目录下.
- 因为也无法连接公共 DNS 服务器, 所以也需要更改本机 hosts 文件, 将以下域名手动指向到本机的 `127.0.0.1`:
- `copilot.supercopilot.top`
- `api.copilot.supercopilot.top`
- `copilot-proxy.copilot.supercopilot.top`
- `copilot-telemetry-service.copilot.supercopilot.top`
- 启动主程序.
还有一种方案, 依旧使用 `v0.1.0` 之前版本的自签证书, 但这会在未来 `GitHub Copilot` 插件更新后可能无法正常使用.
## 支持的模型
> 大部分Chat模型都兼容, 因此下面列出的模型是支持 FIM 的模型, 也就是说支持补全功能.
| 模型名称 (区分大小写) | 类型 | 接入地址 | 说明 |
|--------------------------------------------------------------------------------------------------------------------------------|---------|----------------------------------------------------------------------------------------------------------------|-----------------------------|
| [Qwen/Qwen2.5-Coder-7B-Instruct](https://docs.siliconflow.cn/features/fim) | 免费 | <details><summary>查看地址</summary>`https://api.siliconflow.cn/v1/completions`</details> | 硅基流动官方支持的 FIM 补全模型, 完美适配且免费 |
| [DeepSeek (API)](https://www.deepseek.com/) | 付费 | <details><summary>查看地址</summary>`https://api.deepseek.com/beta/v1/completions`</details> | 👍🏻完美适配且价格实惠, 推荐使用 |
| [deepseek-ai/DeepSeek-V2.5](https://docs.siliconflow.cn/features/fim) | 付费 | <details><summary>查看地址</summary>`https://api.siliconflow.cn/v1/completions`</details> | 硅基流动官方支持的 FIM 补全模型, 完美适配 |
| [deepseek-ai/DeepSeek-Coder-V2-Instruct](https://docs.siliconflow.cn/features/fim) | 付费 | <details><summary>查看地址</summary>`https://api.siliconflow.cn/v1/completions`</details> | 硅基流动官方支持的 FIM 补全模型, 完美适配 |
| [codestral-latest (API)](https://docs.mistral.ai/api/#tag/fim) | 免费 / 付费 | <details><summary>查看地址</summary>`https://api.mistral.ai/v1/fim/completions`</details> | Mistral 出品, 免费计划有非常严重的频率限制 |
| [stable-code](https://ollama.com/library/stable-code) | 免费 | <details><summary>查看地址</summary>`http://127.0.0.1:11434/v1/chat/completions`</details> | Ollama部署本地的超小量级补全模型 |
| [codegemma](https://ollama.com/library/codegemma) | 免费 | <details><summary>查看地址</summary>`http://127.0.0.1:11434/v1/chat/completions`</details> | Ollama部署本地的补全模型 |
| [codellama](https://ollama.com/library/codellama) | 免费 | <details><summary>查看地址</summary>`http://127.0.0.1:11434/v1/chat/completions`</details> | Ollama部署本地的补全模型 |
| [qwen-coder-turbo-latest](https://help.aliyun.com/zh/model-studio/user-guide/qwen-coder?spm=a2c4g.11186623.0.0.a5234823I6LvAG) | 收费 | <details><summary>查看地址</summary>`https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions`</details> | 阿里通义代码补全模型 |
| [mike/deepseek-coder-v2](https://ollama.com/mike/deepseek-coder-v2) | 免费 | <details><summary>查看地址</summary>`http://127.0.0.1:11434/api/generate`</details> | Ollama支持的 `suffix` 参数方式实现 |
| [deepseek-coder-v2](https://ollama.com/library/deepseek-coder-v2) | 免费 | <details><summary>查看地址</summary>`http://127.0.0.1:11434/api/generate`</details> | Ollama支持的 `suffix` 参数方式实现 |
**💡以上接入的模型除了 `DeepSeek` 模型与 `硅基流动` 模型之外, 效果均不理想, 这里仅做接入更多模型的Demo参考.**,
理论上后续如果有API支持标准的FIM补全, 都可以接入.
## 使用Github Copilot官方服务
> 前提条件: 必须有官方正版的 `GitHub Copilot` 订阅权限, 否则无法使用.
**应用场景:**
- 适用于 **"月抛"** 的Github账号, 避免每个月切换Github账号后都要重复登录多个IDE中的插件操作, 只需要更改环境变量中的
`COPILOT_GHU_TOKEN` 参数一处即可.
- 适用于 **"多人共享"** 的Github账号, 共享者只需要使用此服务即可, 不需要告知Github账号密码.
### 使用方法
- 设置环境变量参数 `COPILOT_CLIENT_TYPE=github` (设置此参数后其他的Copilot相关配置都可以不用设置了, 因为这里已经使用了官方的服务).
- 启动服务访问 [https://copilot.supercopilot.top/github/login/device/code](https://copilot.supercopilot.top/github/login/device/code) 获取 `ghu_` 的参数
- 将获取到的 `ghu_` 参数填写到 `COPILOT_GHU_TOKEN` 环境变量中.
- 重启服务, 重启IDE即可.
### 全代理模式
> 即所有请求走依旧服务端,然后由服务端发起请求到github, 在多人共享账号的情况下所有请求全部统一出口, 可以略微降低被风控的情况.
- 设置环境变量参数 `COPILOT_PROXY_ALL=true` (默认值为 `false`).
- 重启服务即可.
- 全代理模式的 `/embeddings``/chunks` 接口即将推出.
**🚨 全代理模式有封号的风险, 请自行甄别谨慎使用.** 补全和对话接口的请求频率都有阀值限制的, 共享人数过多肯定会触发风控.
## Embeddings模型配置
> 目前仅 VSCode 最新版本的 `Github Copilot Chat` 插件支持使用 Embeddings 模型, 其他IDE可以不用考虑.
支持使用任意符合 `OpenAI` 接口格式的模型, 推荐本地Docker部署bge-m3的模型, 具体步骤如下参考: [README.md](embeddings/README.md) 中的Docker运行部分内容.
**然后配置服务端**
修改下面相关环境变量文件内容:
```
EMBEDDING_API_BASE=http://127.0.0.1/v1/embeddings
EMBEDDING_API_KEY=sk-aaabbbcccdddeeefffggghhhiiijjjkkk
EMBEDDING_API_MODEL_NAME=bge-m3
EMBEDDING_DIMENSION_SIZE=1024
```
如果你是 `One API` 之类的中转站, 也可以按照上面环境变量内容直接接入.
⚠️ 如果使用第三方API的Embedding模型, 可能会有隐私相关风险以及请求限频问题.
## 问题排查
如果本地部署遇到了 `无法登录` `无法对话` `无法补全` 等问题, 可以参考下面的排查方法:
1. 确认最新版本服务
2. `ping copilot.supercopilot.top` 是否指向 `127.0.0.1` ,如果不是则表明受到了代理工具影响
3. 访问 [https://copilot.supercopilot.top](https://copilot.supercopilot.top/help) 是否出现帮助页面
4. 检查目录下是否含有 `cert` 目录
5. 特别注意检查 https 端口不可被占用
## 注意事项
1. 请勿将本服务用于商业用途, 仅供学习交流使用
2. 请勿将本服务用于非法用途, 一切后果自负
## 鸣谢
- [copilot_take_over](https://gitee.com/LoveA/copilot_take_over)
- [override](https://github.com/linux-do/override)
- [硅基流动](https://cloud.siliconflow.cn/i/NO6ShUc3)

49
docker-compose.yml Normal file
View File

@@ -0,0 +1,49 @@
version: "3.3"
services:
copilot-app:
container_name: copilot-app
#image: registry.cn-hangzhou.aliyuncs.com/ripper/copilot-proxies:latest
build: .
restart: always
ports:
- 1188:1188
#- "443:443"
environment:
- PORT=1188
- LOGIN_PASSWORD=1188
- CHAT_LOCALE=zh-CN
- HTTP_CLIENT_TIMEOUT=60
- CODEX_API_BASE=https://for.shuo.bar/v1/chat/completions
- CODEX_API_KEY=sk-oc25vXlSaSoi1tURqJ2ZfhyGj3jRkpPp8TrBIZHE6aEEz1Ov # 代码补全API地址
- CODEX_API_MODEL_NAME=glm-4.5 # 代码补全API密钥, 支持多个轮询APIKEY用英文逗号分隔
- CODEX_MAX_TOKENS=15000 # 代码补全API模型名称
- CODEX_TEMPERATURE=-1 # 代码补全API最大返回token数
- CODEX_SERVICE_TYPE=default # 代码补全API温度, 默认值为:1, deepseek模型官方推荐设置为1, 如果要跟随插件动态设置,请设置为-1
- CODEX_LIMIT_PROMPT=20 # 补全服务类型, 默认值为:default, 可选值: ollama
- CHAT_API_BASE=https://for.shuo.bar/v1/chat/completions # 限制prompt行数可减少代码补全时消耗的tokens
- CHAT_API_KEY=sk-oc25vXlSaSoi1tURqJ2ZfhyGj3jRkpPp8TrBIZHE6aEEz1Ov # 聊天补全API地址
- CHAT_API_MODEL_NAME=glm-4.5 # 聊天补全API密钥
- CHAT_MAX_TOKENS=40960 # 聊天补全API模型名称
- CHAT_USE_TOOLS=true # 聊天API最大返回token数
- DEFAULT_BASE_URL=https://mycopilot.01061220.xyz
- COPILOT_DEBOUNCE=200
- API_BASE_URL=https://api.mycopilot.01061220.xyz # 补全防抖时间(毫秒)
- PROXY_BASE_URL=https://copilot-proxy.mycopilot.01061220.xyz
- TELEMETRY_BASE_URL=https://copilot-telemetry-service.mycopilot.01061220.xyz
- COPILOT_CLIENT_TYPE=default
- COPILOT_GHU_TOKEN=ghu_xxxx # copilot的客户端类型, 用于区分是否使用官方copilot服务
- COPILOT_PROXY_ALL=false # 支持多个轮询token用英文逗号分隔
- COPILOT_ACCOUNT_TYPE=individual # 在使用官方Copilot服务的时候是否全代理请求
- DISGUISE_COPILOT_TOKEN_EXPIRES_AT=1800 # github copilot 官方账号类型, 企业版账号需要调整此参数, 否则在全代理模式下无法正常使用 (可选值: individual/business)
- EMBEDDING_API_BASE=http://127.0.0.1:5012/v1/embeddings # Copilot伪装token下发的有效期,单位秒
- EMBEDDING_API_KEY=
- EMBEDDING_API_MODEL_NAME=m3e
- EMBEDDING_DIMENSION_SIZE=1536
- LIGHTWEIGHT_MODEL=do-not-use
- VS_COPILOT_CLIENT_ID=a200baed193bb2088a6e
- VS_COPILOT_CLIENT_SECRET=
- TOKEN_SALT=1188
volumes:
- ./logs:/app/logs
- ./models.json:/root/models.json
networks: {}

37
embeddings/.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
*/target
*/tomcat
*.iml
.idea/
*.class
target/
tomcat/
.project
.settings/
.classpath
src/main/resources/META-INF
.DS_Store
logs/*
.idea/*
application-local.properties
rebel.xml
LOG_DIR_IS_UNDEFINED
index/*
lora.json
ptuning.json
lora
applogs
__pycache__
answers.json
answers.jsonl
answers_back.json
keys.pkl
data.pkl
make_dataset_schedule.d
config.json
venv/
.env
temp/
.ipynb_checkpoints
/modules/*
cert_cache/*
models

18
embeddings/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# 使用官方Python运行时作为父镜像
FROM registry.cn-hangzhou.aliyuncs.com/ripper/python:3.9-slim
# 设置工作目录
WORKDIR /app
# 将当前目录内容复制到容器的/app中
ADD . /app
RUN pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple
# 安装程序需要的包
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 运行时监听的端口
EXPOSE 6008
# 运行app.py时的命令及其参数
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "6008"]

55
embeddings/README.md Normal file
View File

@@ -0,0 +1,55 @@
# 本地 Embeddings 模型API服务
下载本地 `Embedding模型` 并转为 `OpenAI` 接口格式的 API 服务。
## 准备工作
- Python 3.9+
- 选择合适的模型文件 (根据效果自行测试), 程序支持自动提升维度或降级维度到指定维度(接口中传递的 `dimensions` 参数, 默认为512)
- 下载模型文件,放置在 `./models` 目录下, 国内下载可以去 [魔搭社区](https://www.modelscope.cn/models/BAAI/bge-m3), 速度不受影响
## 环境变量参数
- `sk-key`: 服务的 `API KEY`,默认为 `sk-aaabbbcccdddeeefffggghhhiiijjjkkk`
- `auto-dim`: 是否自动进行维度操作, 若为 `true` 则会自动提升或降级维度到512, 默认为 `false`
- `model-name`: 模型目录名称, 默认为 `bge-m3`, 注意必须在models文件夹下有对应的模型文件夹
## 运行服务
```shell
pip install -r requirements.txt
```
```shell
python app.py
```
```bash
curl --location --request POST 'http://127.0.0.1:6008/v1/embeddings' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer sk-aaabbbcccdddeeefffggghhhiiijjjkkk' \
--data-raw '{
"input": [
"解析当前项目"
],
"model": "text-embedding-3-small",
"dimensions": 512
}'
```
## Docker 运行
将 [docker-compose.yml](docker-compose.yml) 文件放在任意目录下, 然后执行命令:
```bash
docker-compose up -d
```
镜像内已经将模型打包好了, 所以首次执行会比较慢, 如果需要更新或新增模型, 直接将新的模型文件放在 `./models` 目录下, 更改环境变量 `model-name` 即可
然后重新启动服务即可:
```bash
docker-compose restart
# 如果未生效, 可以先停止再启动
docker-compose down
docker-compose up -d
```

View File

@@ -0,0 +1,14 @@
version: '3.3'
services:
embeddings2openai:
container_name: embeddings2openai
image: registry.cn-hangzhou.aliyuncs.com/ripper/embeddings2openai:latest
restart: always
ports:
- 6008:6008
environment:
- sk-key=sk-aaabbbcccdddeeefffggghhhiiijjjkkk
- auto-dim=false
- model-name=bge-m3
# volumes:
# - ./models:/app/models # 如果需要自己更改模型, 将模型文件放到models文件夹下, 并取消注释

171
embeddings/main.py Normal file
View File

@@ -0,0 +1,171 @@
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sentence_transformers import SentenceTransformer
from pydantic import BaseModel
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
import tiktoken
import numpy as np
from scipy.interpolate import interp1d
from typing import List, Optional
from sklearn.preprocessing import PolynomialFeatures
from sklearn.decomposition import PCA
import torch
import os
# 接口秘钥环境变量传入
sk_key = os.environ.get('sk-key', 'sk-aaabbbcccdddeeefffggghhhiiijjjkkk')
# 是否自动进行维度操作的环境变量默认为false
auto_dim = os.environ.get('auto-dim', 'false').lower() == 'true'
# 模型名称, 必须在models文件夹下有对应的模型文件夹
model_name = os.environ.get('model-name', 'bge-m3')
# 创建一个FastAPI实例
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 创建一个HTTPBearer实例
security = HTTPBearer()
# 预加载模型
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 检测是否有GPU可用如果有则使用cuda设备否则使用cpu设备
if torch.cuda.is_available():
print('本次加载模型的设备为GPU: ', torch.cuda.get_device_name(0))
else:
print('本次加载模型的设备为CPU.')
print(f'加载模型: {model_name}')
model = SentenceTransformer(f'./models/{model_name}',device=device)
# 创建PCA降维模型
pca = None
class EmbeddingRequest(BaseModel):
input: List[str]
model: str
dimensions: Optional[int] = 512
class EmbeddingResponse(BaseModel):
data: list
model: str
object: str
usage: dict
def num_tokens_from_string(string: str) -> int:
"""Returns the number of tokens in a text string."""
encoding = tiktoken.get_encoding('cl100k_base')
num_tokens = len(encoding.encode(string))
return num_tokens
# 插值法
def interpolate_vector(vector, target_length):
original_indices = np.arange(len(vector))
target_indices = np.linspace(0, len(vector)-1, target_length)
f = interp1d(original_indices, vector, kind='linear')
return f(target_indices)
def expand_features(embedding, target_length):
poly = PolynomialFeatures(degree=2)
expanded_embedding = poly.fit_transform(embedding.reshape(1, -1))
expanded_embedding = expanded_embedding.flatten()
if len(expanded_embedding) > target_length:
# 如果扩展后的特征超过目标长度,可以通过截断或其他方法来减少维度
expanded_embedding = expanded_embedding[:target_length]
elif len(expanded_embedding) < target_length:
# 如果扩展后的特征少于目标长度,可以通过填充或其他方法来增加维度
expanded_embedding = np.pad(expanded_embedding, (0, target_length - len(expanded_embedding)))
return expanded_embedding
# 降维方法使用PCA将向量从1024维降到512维
def reduce_dimensions(embeddings, target_dim=512):
global pca
# 将列表转换为numpy数组
embeddings_array = np.array(embeddings)
# 检查样本数量
n_samples = embeddings_array.shape[0]
n_features = embeddings_array.shape[1]
# 如果只有一个样本无法使用PCA改用插值法
if n_samples == 1:
return [interpolate_vector(embeddings_array[0], target_dim)]
# 确保目标维度不超过可能的最大值
actual_target_dim = min(target_dim, n_samples, n_features)
if actual_target_dim < target_dim:
print(f"警告:目标维度{target_dim}超过了可能的最大值,已调整为{actual_target_dim}")
# 如果是第一次运行或者输入维度变化重新初始化PCA
if pca is None or pca.n_components != actual_target_dim:
pca = PCA(n_components=actual_target_dim)
# 先拟合再转换
reduced_embeddings = pca.fit_transform(embeddings_array)
else:
# 直接使用已训练的PCA模型转换
reduced_embeddings = pca.transform(embeddings_array)
# 如果实际降维后的维度小于目标维度,使用插值法扩展
if actual_target_dim < target_dim:
reduced_embeddings = [interpolate_vector(embedding, target_dim) for embedding in reduced_embeddings]
return list(reduced_embeddings)
@app.post("/v1/embeddings", response_model=EmbeddingResponse)
async def get_embeddings(request: EmbeddingRequest, credentials: HTTPAuthorizationCredentials = Depends(security)):
if credentials.credentials != sk_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authorization code",
)
# 计算嵌入向量和tokens数量
embeddings = [model.encode(text) for text in request.input]
# 检查是否需要进行维度操作
if auto_dim:
# 检查向量维度
embedding_dim = len(embeddings[0])
# 如果维度大于512则降维到512
if embedding_dim > request.dimensions:
embeddings = reduce_dimensions(embeddings, target_dim=request.dimensions)
# 如果维度小于512则使用插值法扩展到512
elif embedding_dim < request.dimensions:
embeddings = [interpolate_vector(embedding, request.dimensions) for embedding in embeddings]
# 归一化处理
embeddings = [embedding / np.linalg.norm(embedding) for embedding in embeddings]
# 将numpy数组转换为列表
embeddings = [embedding.tolist() for embedding in embeddings]
prompt_tokens = sum(len(text.split()) for text in request.input)
total_tokens = sum(num_tokens_from_string(text) for text in request.input)
response = {
"data": [
{
"embedding": embedding,
"index": index,
"object": "embedding"
} for index, embedding in enumerate(embeddings)
],
"model": model_name,
"object": "list",
"usage": {
"prompt_tokens": prompt_tokens,
"total_tokens": total_tokens,
}
}
return response
if __name__ == "__main__":
uvicorn.run("main:app", host='0.0.0.0', port=6008, workers=1)

View File

@@ -0,0 +1,11 @@
fastapi==0.100.0
pydantic==1.10.7
sentence-transformers==3.4.1
uvicorn==0.23.1
tiktoken==0.4.0
numpy==2.0.2
scipy==1.13.1
scikit-learn==1.6.1
torch
torchvision
torchaudio

51
go.mod Normal file
View File

@@ -0,0 +1,51 @@
module ripper
go 1.21
toolchain go1.22.2
require (
github.com/gin-gonic/gin v1.10.0
github.com/gofrs/uuid v4.4.0+incompatible
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/gomodule/redigo v1.9.2
github.com/joho/godotenv v1.5.1
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
golang.org/x/sync v0.8.0
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -0,0 +1,60 @@
package github_auth
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"github.com/gofrs/uuid"
"os"
"sort"
"strings"
)
func sha256Sign(data string) string {
hash := sha256.Sum256([]byte(data))
return hex.EncodeToString(hash[:])
}
func GetAccessTokenT() string {
t, _ := uuid.NewV4()
return t.String()
}
func JsonMap2Token(data map[string]interface{}) string {
if len(data) == 0 {
return ""
}
keys := make([]string, 0, len(data))
for k := range data {
keys = append(keys, k)
}
sort.Strings(keys)
var sb strings.Builder
for i, key := range keys {
if i > 0 {
sb.WriteString(";")
}
sb.WriteString(key)
sb.WriteString("=")
sb.WriteString(fmt.Sprintf("%v", data[key]))
}
return sb.String()
}
func JsonMap2SignToken(data map[string]interface{}) string {
token := JsonMap2Token(data)
if token == "" {
return ""
}
sign := Token2Sign(token)
return token + ";8kp=1:" + sign
}
func Token2Sign(token string) string {
sign := sha256Sign(token + fmt.Sprintf(";salt=%s", os.Getenv("TOKEN_SALT")))
return sign
}

View File

@@ -0,0 +1,162 @@
package github_auth
import (
"encoding/json"
"fmt"
"github.com/gofrs/uuid"
"github.com/gomodule/redigo/redis"
"ripper/internal/cache"
"strings"
)
type ClientAuthInfo struct {
ClientId string `json:"client_id"`
DisplayUserName string `json:"display_user_name,omitempty"`
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
CardCode string `json:"card_code"`
}
type ClientOAuthInfo struct {
ClientId string `json:"client_id" form:"client_id"`
Code string `json:"code" form:"code"`
ClientSecret string `json:"client_secret" form:"client_secret"`
Scope string `json:"scope" form:"scope"`
}
// BindClientToCode 绑定客户端到代码
// clientId 客户端ID
// exp 过期时间
// return 用户代码, 设备代码, 错误
func BindClientToCode(clientId string, exp int) (string, string, error) {
genCode := func() string {
newUUID, _ := uuid.NewV4()
uuidStr := strings.Replace(newUUID.String(), "-", "", -1)
return uuidStr[:6]
}
formattedUUID := genCode()
rep := 0
redisKey := fmt.Sprintf("copilot.proxy.%s", formattedUUID)
repeat, _ := cache.Exist(redisKey)
for repeat {
if rep > 5 {
return "", "", fmt.Errorf("gen code error")
}
formattedUUID = genCode()
redisKey = fmt.Sprintf("copilot.proxy.%s", formattedUUID)
repeat, _ = cache.Exist(redisKey)
rep++
}
devId := GenDevicesCode(40)
authInfo := ClientAuthInfo{
ClientId: clientId,
DeviceCode: devId,
UserCode: formattedUUID,
}
authInfoData, _ := json.Marshal(authInfo)
err := cache.Set(redisKey, authInfoData, exp)
if err != nil {
return "", "", err
}
redisKey = fmt.Sprintf("copilot.proxy.map.%s", devId)
err = cache.Set(redisKey, formattedUUID, exp)
return formattedUUID, devId, err
}
// GetClientAuthInfoByDeviceCode 通过设备代码获取客户端授权信息
func GetClientAuthInfoByDeviceCode(deviceCode string) (*ClientAuthInfo, error) {
redisKey := fmt.Sprintf("copilot.proxy.map.%s", deviceCode)
userCode, err := cache.Get(redisKey)
if err != nil {
return nil, err
}
redisKey = fmt.Sprintf("copilot.proxy.%s", userCode)
authInfoData, err := redis.Bytes(cache.Get(redisKey))
if err != nil {
return nil, err
}
authInfo := &ClientAuthInfo{}
err = json.Unmarshal(authInfoData, &authInfo)
return authInfo, err
}
func GetOAuthCodeInfoByClientIdAndCode(clientId string, code string) (*ClientOAuthInfo, error) {
cacheKey := "oauth2_authorize_" + clientId
oauthCodeData, err := redis.Bytes(cache.Get(cacheKey))
if err != nil {
return nil, err
}
var oauthCode ClientOAuthInfo
err = json.Unmarshal(oauthCodeData, &oauthCode)
if err != nil {
return nil, err
}
if oauthCode.Code != code {
return nil, fmt.Errorf("invalid oauth code")
}
return &oauthCode, nil
}
func GetClientAuthInfo(code string) (ClientAuthInfo, error) {
redisKey := fmt.Sprintf("copilot.proxy.%s", code)
authInfoData, err := redis.Bytes(cache.Get(redisKey))
if err != nil {
return ClientAuthInfo{}, err
}
var authInfo ClientAuthInfo
err = json.Unmarshal(authInfoData, &authInfo)
return authInfo, err
}
// GenDevicesCode 生成设备代码
func GenDevicesCode(codeLen int) string {
var newUUID string
for len(newUUID) < 64 {
ud, _ := uuid.NewV4()
newUUID += strings.Replace(ud.String(), "-", "", -1)
}
return newUUID[:codeLen]
}
// UpdateClientAuthStatusByDeviceCode 更新客户端授权码通过设备代码
func UpdateClientAuthStatusByDeviceCode(deviceCode string, cardCode string, displayUserName string) error {
redisKey := fmt.Sprintf("copilot.proxy.map.%s", deviceCode)
uCode, err := cache.Get(redisKey)
if err != nil {
return err
}
redisKey = fmt.Sprintf("copilot.proxy.%s", uCode)
authInfoData, err := redis.Bytes(cache.Get(redisKey))
if err != nil {
return err
}
authInfo := &ClientAuthInfo{}
err = json.Unmarshal(authInfoData, &authInfo)
if err != nil {
return err
}
authInfo.CardCode = cardCode
if displayUserName != "" {
authInfo.DisplayUserName = displayUserName
}
authInfoData, _ = json.Marshal(authInfo)
err = cache.Set(redisKey, authInfoData, -1)
return err
}
func RemoveClientAuthInfoByDeviceCode(deviceCode string) error {
redisKey := fmt.Sprintf("copilot.proxy.map.%s", deviceCode)
uCode, err := cache.Get(redisKey)
if err != nil {
return err
}
redisKey = fmt.Sprintf("copilot.proxy.%s", uCode)
err = cache.Del(redisKey)
if err != nil {
return err
}
redisKey = fmt.Sprintf("copilot.proxy.map.%s", deviceCode)
err = cache.Del(redisKey)
return err
}

8
internal/cache/cacheable.go vendored Normal file
View File

@@ -0,0 +1,8 @@
package cache
type Cacheable interface {
Set(key string, value interface{}, ttl int) error
Get(key string) (interface{}, error)
Exist(key string) (bool, error)
Del(key string) error
}

100
internal/cache/memory.go vendored Normal file
View File

@@ -0,0 +1,100 @@
package cache
import (
"fmt"
"sync"
"time"
)
// MemoryMap 用于内存缓存
type MemoryMap struct {
cache map[string]interface{}
expirations map[string]int64
mu sync.Mutex
}
func NewMemoryMap() *MemoryMap {
m := &MemoryMap{}
m.init()
return m
}
// init 初始化 MemoryMap 的缓存
func (m *MemoryMap) init() {
m.cache = make(map[string]interface{})
m.expirations = make(map[string]int64)
}
func (m *MemoryMap) Get(key string) (interface{}, error) {
m.mu.Lock()
defer m.mu.Unlock()
expiration, exists := m.expirations[key]
currentTime := time.Now().UnixMilli()
if exists && currentTime > expiration {
// 键已过期,删除并返回 nil
fmt.Printf("Get: key=%s has expired, deleting...\n", key)
delete(m.cache, key)
delete(m.expirations, key)
return nil, nil
}
value, ok := m.cache[key]
if !ok {
return nil, nil
}
return value, nil
}
// Set 设置缓存中的值,并指定过期时间(秒)
func (m *MemoryMap) Set(key string, value interface{}, ttl int) error {
m.mu.Lock()
defer m.mu.Unlock()
m.cache[key] = value
if ttl == 0 {
// 默认半小时
ttl = 30 * 60
}
if ttl == -1 {
// -1 表示永久缓存,不设置过期时间
delete(m.expirations, key)
} else {
expiration := time.Now().UnixMilli() + int64(ttl*1000)
m.expirations[key] = expiration
}
return nil
}
func (m *MemoryMap) Exist(key string) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
expiration, exists := m.expirations[key]
currentTime := time.Now().UnixMilli()
if exists && currentTime > expiration {
// 键已过期,删除并返回 nil
fmt.Printf("Get: key=%s has expired, deleting...\n", key)
delete(m.cache, key)
delete(m.expirations, key)
}
_, ok := m.cache[key]
return ok, nil
}
func (m *MemoryMap) Del(key string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.cache[key]; !ok {
return nil
}
delete(m.cache, key)
delete(m.expirations, key)
return nil
}
// 编译时检查
var _ Cacheable = (*MemoryMap)(nil)

25
internal/cache/operation.go vendored Normal file
View File

@@ -0,0 +1,25 @@
package cache
var cache Cacheable
func init() {
cache = NewMemoryMap()
// 已废弃redis缓存实现
/*host := os.Getenv("REDIS_HOST")
port := os.Getenv("REDIS_PORT")
psw := os.Getenv("REDIS_PASSWORD")
cache = NewRedisInstance(host, port, psw)*/
}
func Set(key string, value interface{}, ttl int) error {
return cache.Set(key, value, ttl)
}
func Get(key string) (interface{}, error) {
return cache.Get(key)
}
func Exist(key string) (bool, error) {
return cache.Exist(key)
}
func Del(key string) error {
return cache.Del(key)
}

82
internal/cache/redis.go vendored Normal file
View File

@@ -0,0 +1,82 @@
package cache
import (
"fmt"
"github.com/gomodule/redigo/redis"
"strconv"
"time"
)
type Redis struct {
Host string
Port string
Psw string
Pool *redis.Pool
}
func NewRedisInstance(host string, port string, psw string) *Redis {
r := &Redis{Host: host, Port: port, Psw: psw}
r.init()
return r
}
func (r *Redis) Get(k string) (interface{}, error) {
return r.getConn().Do("get", k)
}
func (r *Redis) Set(k string, v interface{}, ttl int) error {
_, err := r.getConn().Do("set", k, v, "EX", ttl)
return err
}
func (r *Redis) Exist(k string) (bool, error) {
return redis.Bool(r.getConn().Do("EXISTS", k))
}
func (r *Redis) Del(k string) error {
_, err := r.getConn().Do("del", k)
return err
}
func (r *Redis) getConn() redis.Conn {
return r.Pool.Get()
}
func (r *Redis) init() {
r.Pool = &redis.Pool{
// Maximum number of connections allocated by the pool at a given time.
// When zero, there is no limit on the number of connections in the pool.
//最大活跃连接数0代表无限
MaxActive: 1000,
//最大闲置连接数
// Maximum number of idle connections in the pool.
MaxIdle: 50,
//闲置连接的超时时间
// Close connections after remaining idle for this duration. If the value
// is zero, then idle connections are not closed. Applications should set
// the timeout to a value less than the server's timeout.
IdleTimeout: time.Second * 100,
//定义拨号获得连接的函数
// Dial is an application supplied function for creating and configuring a
// connection.
//
// The connection returned from Dial must not be in a special state
// (subscribed to pubsub channel, transaction started, ...).
Dial: func() (redis.Conn, error) {
port, _ := strconv.Atoi(r.Port)
c, err := redis.Dial("tcp", fmt.Sprintf("%s:%d", r.Host, port))
if err != nil {
return nil, err
}
password := r.Psw
if password != "" {
if _, err := c.Do("AUTH", password); err != nil {
c.Close()
return nil, err
}
}
return c, err
},
}
}

View File

@@ -0,0 +1,153 @@
package auth
import (
_ "embed"
"fmt"
"github.com/gin-gonic/gin"
"net/http"
"os"
"ripper/internal/app/github_auth"
"ripper/internal/middleware"
"ripper/internal/response"
jwtpkg "ripper/pkg/jwt"
"time"
)
type postLoginDeviceCodeRequest struct {
ClientId string `json:"client_id" form:"client_id"`
}
type postLoginDeviceCodeResponse struct {
DeviceCode string `json:"device_code"` // 设备代码
UserCode string `json:"user_code"` // 用户代码
VerificationUrl string `json:"verification_uri"` // 验证地址
ExpiresIn int `json:"expires_in"` // 过期时间
Interval int `json:"interval"` // 间隔时间
}
type loginDeviceRequestInfo struct {
Code string `json:"code"`
Authorization string `json:"authorization"`
DisplayUserName string `json:"displayUserName,omitempty"`
Password string `json:"password"`
}
func postLoginDeviceCode(ctx *gin.Context) {
cli := postLoginDeviceCodeRequest{}
if err := ctx.ShouldBind(&cli); err != nil {
response.FailJson(ctx, response.FailStruct{
Code: -1,
Msg: "Invalid client id.",
}, false)
return
}
if cli.ClientId == "" {
response.FailJson(ctx, response.FailStruct{
Code: -1,
Msg: "Client id is required.",
}, false)
return
}
uid, devid, err := github_auth.BindClientToCode(cli.ClientId, 1800)
if err != nil {
response.FailJson(ctx, response.FailStruct{
Code: -1,
Msg: err.Error(),
}, false)
return
}
ctx.JSON(http.StatusOK, postLoginDeviceCodeResponse{
DeviceCode: devid,
UserCode: uid,
VerificationUrl: fmt.Sprintf("%s/login/device?user_code=%s", os.Getenv("DEFAULT_BASE_URL"), uid),
ExpiresIn: 1800,
Interval: 5,
})
}
func postLoginOauthAccessToken(ctx *gin.Context) {
v, exists := ctx.Get("client_auth_info")
if !exists {
ctx.JSON(http.StatusOK, gin.H{
"error": "authorization_pending",
"error_description": "The authorization request is still pending.",
"error_uri": "https://docs.github.com/developers/apps/authorizing-oauth-apps#error-codes-for-the-device-flow",
})
return
}
cliAuthInfo := v.(*github_auth.ClientAuthInfo)
t := time.Now()
t.Add(24 * 3 * time.Hour)
u, err := github_auth.GetClientAuthInfo(cliAuthInfo.UserCode)
if err != nil {
ctx.JSON(http.StatusOK, gin.H{
"error": "access_denied",
"error_description": "You must make a new request for a device code.",
"error_uri": "https://docs.github.com/developers/apps/authorizing-oauth-apps#error-codes-for-the-device-flow",
})
return
}
tk, _ := jwtpkg.CreateToken(&middleware.UserLoad{
UserDisplayName: cliAuthInfo.DisplayUserName,
CardCode: u.CardCode,
Client: cliAuthInfo.ClientId,
RegisteredClaims: jwtpkg.CreateStandardClaims(t.Unix(), "user"),
})
_ = github_auth.RemoveClientAuthInfoByDeviceCode(cliAuthInfo.ClientId)
ctx.JSON(http.StatusOK, gin.H{
"access_token": tk,
"scope": "",
"token_type": "bearer",
})
}
func postLoginDevice(ctx *gin.Context) {
var info loginDeviceRequestInfo
if err := response.BindStruct(ctx, &info); err != nil {
response.FailJson(ctx, response.FailStruct{
Code: 422,
Msg: "请求参数错误",
}, false)
return
}
// 验证密码
loginPassword := os.Getenv("LOGIN_PASSWORD")
if loginPassword != "" && info.Password != loginPassword {
response.FailJson(ctx, response.FailStruct{
Code: 422,
Msg: "访问密码错误",
}, false)
return
}
// 检查code是否存在
authInfo, err := github_auth.GetClientAuthInfo(info.Code)
if err != nil {
response.FailJson(ctx, response.FailStruct{
Code: 422,
Msg: "授权码填写错误",
}, false)
return
}
err = github_auth.UpdateClientAuthStatusByDeviceCode(authInfo.DeviceCode, info.Authorization, info.DisplayUserName)
if err != nil {
response.FailJson(ctx, response.FailStruct{
Code: 500,
Msg: "系统异常, 请稍后再试",
}, false)
return
}
response.SuccessJson(ctx, "ok")
}
func getLoginDevice(ctx *gin.Context) {
ctx.Header("Content-Type", "text/html; charset=utf-8")
ctx.HTML(http.StatusOK, "code.html", gin.H{})
}
func getHelpPage(ctx *gin.Context) {
ctx.Header("Content-Type", "text/html; charset=utf-8")
ctx.HTML(http.StatusOK, "help.html", gin.H{})
}

View File

@@ -0,0 +1,105 @@
package auth
import (
"bytes"
"encoding/json"
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
"ripper/internal/response"
)
const (
clientID = "Iv1.b507a08c87ecfe98"
deviceCodeURL = "https://github.com/login/device/code"
tokenURL = "https://github.com/login/oauth/access_token"
)
type githubLoginDeviceRequest struct {
DeviceCode string `form:"device_code" json:"device_code" binding:"required"`
}
// getDeviceCode returns the device code for GitHub login.
func getDeviceCode(c *gin.Context) {
body := map[string]string{
"client_id": clientID,
}
result, err := makeRequest(c, http.MethodPost, deviceCodeURL, body)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
// getGhuToken returns the GitHub user token.
func getGhuToken(c *gin.Context) {
var params githubLoginDeviceRequest
if err := c.ShouldBind(&params); err != nil {
response.FailJson(c, response.FailStruct{
Code: -1,
Msg: "Invalid request: " + err.Error(),
}, false)
return
}
body := map[string]string{
"client_id": clientID,
"device_code": params.DeviceCode,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
}
result, err := makeRequest(c, http.MethodPost, tokenURL, body)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
// getGithubLoginDevice returns the login page for GitHub.
func getGithubLoginDevice(ctx *gin.Context) {
ctx.Header("Content-Type", "text/html; charset=utf-8")
ctx.HTML(http.StatusOK, "login.html", gin.H{})
}
// makeRequest makes a request to the given URL with the given method and body.
func makeRequest(c *gin.Context, method, url string, body map[string]string) (interface{}, error) {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(c, method, url, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, err
}
req.Header.Set("accept", "application/json")
req.Header.Set("content-type", "application/json")
req.Header.Set("editor-plugin-version", "copilot-intellij/1.5.21.6667")
req.Header.Set("copilot-language-server-version", "1.228.0")
req.Header.Set("user-agent", "GithubCopilot/1.228.0")
req.Header.Set("editor-version", "JetBrains-IU/242.21829.142")
httpClientTimeout, _ := time.ParseDuration(os.Getenv("HTTP_CLIENT_TIMEOUT") + "s")
client := &http.Client{Timeout: httpClientTimeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
if err != nil {
return nil, err
}
return result, nil
}

View File

@@ -0,0 +1,100 @@
package auth
import (
"encoding/json"
"github.com/gin-gonic/gin"
"net/http"
"os"
"ripper/internal/app/github_auth"
"ripper/internal/cache"
"ripper/internal/middleware"
"ripper/internal/response"
jwtpkg "ripper/pkg/jwt"
"time"
)
type getLoginOauthAuthorizeRequest struct {
ClientId string `json:"client_id" form:"client_id"`
Prompt string `json:"prompt" form:"prompt"`
RedirectUri string `json:"redirect_uri" form:"redirect_uri"`
Scope string `json:"scope" form:"scope"`
State string `json:"state" form:"state"`
}
func getLoginOauthAuthorize(ctx *gin.Context) {
req := getLoginOauthAuthorizeRequest{}
err := ctx.BindQuery(&req)
if err != nil {
response.FailJson(ctx, response.FailStruct{
Code: -1,
Msg: "Invalid request.",
}, false)
return
}
vsCopilotClientId := os.Getenv("VS_COPILOT_CLIENT_ID")
if req.ClientId != vsCopilotClientId {
response.FailJson(ctx, response.FailStruct{
Code: -1,
Msg: "Invalid client id.",
}, false)
return
}
oauthCode := github_auth.GenDevicesCode(20)
cai := github_auth.ClientOAuthInfo{
ClientId: req.ClientId,
Code: oauthCode,
Scope: req.Scope,
}
cacheKey := "oauth2_authorize_" + req.ClientId
caiInfo, _ := json.Marshal(cai)
err = cache.Set(cacheKey, caiInfo, 300)
if err != nil {
response.FailJson(ctx, response.FailStruct{
Code: -1,
Msg: "Internal error.",
}, false)
return
}
// Redirect to the client's redirect_uri
browserSessionId := github_auth.GenDevicesCode(64)
ctx.Redirect(302, req.RedirectUri+"?browserSessionId="+browserSessionId+"&code="+oauthCode+"&state="+req.State)
}
func postLoginOauthAccessTokenForVs2022(ctx *gin.Context) {
v, exists := ctx.Get("client_auth_info")
if !exists {
response.FailJson(ctx, response.FailStruct{
Code: -1,
Msg: "Invalid client id.",
}, false)
return
}
cliAuthInfo := v.(*github_auth.ClientOAuthInfo)
t := time.Now()
t.Add(24 * 3 * time.Hour)
tk, _ := jwtpkg.CreateToken(&middleware.UserLoad{
CardCode: cliAuthInfo.Code,
Client: cliAuthInfo.ClientId,
RegisteredClaims: jwtpkg.CreateStandardClaims(t.Unix(), "user"),
})
ctx.JSON(http.StatusOK, gin.H{
"access_token": tk,
"scope": cliAuthInfo.Scope,
"token_type": "bearer",
})
}
func getSiteSha(ctx *gin.Context) {
ctx.Header("X-GitHub-Request-Id", "C0E1:6A1A:1A1F:2A1D:1A1F:1A1F:1A1F:1A1F")
ctx.JSON(http.StatusOK, gin.H{})
}
func getLoginConfig(ctx *gin.Context) {
loginPassword := os.Getenv("LOGIN_PASSWORD")
ctx.JSON(http.StatusOK, gin.H{
"is_login_password": loginPassword != "",
})
}

View File

@@ -0,0 +1,42 @@
package auth
import (
"github.com/gin-gonic/gin"
"ripper/internal/middleware"
"strings"
)
func GinApi(g *gin.RouterGroup) {
g.GET("/help", getHelpPage)
// 启动设备代码登录流程
g.POST("/login/device/code", postLoginDeviceCode)
g.POST("/login/device", postLoginDevice)
g.GET("/login/device", getLoginDevice)
g.POST("/login/oauth/access_token", func(ctx *gin.Context) {
if strings.Index(ctx.Request.UserAgent(), "VSTeamExplorer") != -1 {
middleware.AuthCodeFlowCheckAuth(ctx)
} else {
middleware.DeviceCodeCheckAuth(ctx)
}
}, func(ctx *gin.Context) {
if strings.Index(ctx.Request.UserAgent(), "VSTeamExplorer") != -1 {
postLoginOauthAccessTokenForVs2022(ctx)
} else {
postLoginOauthAccessToken(ctx)
}
})
// oauth2 登录
g.GET("/login/oauth/authorize", getLoginOauthAuthorize)
// enterprise 验证
g.GET("/site/sha", getSiteSha)
// 获取登录页面配置
g.GET("/login/config", getLoginConfig)
// GitHub模拟登录获取 ghu_token
g.GET("/github/login/device/code", getGithubLoginDevice)
g.POST("/github/login/device/code", getDeviceCode)
g.POST("/github/login/ghu-token", getGhuToken)
}

View File

@@ -0,0 +1,29 @@
package copilot
import (
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
"net/http"
)
// GetAgents 获取代理列表
func GetAgents(c *gin.Context) {
requestID := uuid.Must(uuid.NewV4()).String()
c.Header("x-github-request-id", requestID)
c.JSON(http.StatusOK, gin.H{
"agents": []gin.H{
{
"id": "github/copilot-workspace",
"name": "@workspace",
"description": "Ask questions and get answers about your codebase.",
"version": "1.0.0",
"publisher": "github",
"model": "gpt-4o-mini-2024-07-18",
"capabilities": "workspace",
"default_model": "gpt-4o-mini-2024-07-18",
"capabilities_model": "gpt-4o-mini-2024-07-18",
},
},
})
}

View File

@@ -0,0 +1,188 @@
package copilot
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"io"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// ChatCompletions chat对话接口
func ChatCompletions(c *gin.Context) {
ctx := c.Request.Context()
// 添加响应头, 解决vscode校验github所属问题
requestID := uuid.Must(uuid.NewV4()).String()
c.Header("x-github-request-id", requestID)
body, err := io.ReadAll(c.Request.Body)
if nil != err {
c.AbortWithStatus(http.StatusBadRequest)
return
}
apiModelName := gjson.GetBytes(body, "model").String()
// 默认设置的对话模型
envModelName := os.Getenv("CHAT_API_MODEL_NAME")
// 默认设置的对话请求地址
chatAPIURL := os.Getenv("CHAT_API_BASE")
// 默认设置的对话模型key
apiKey := os.Getenv("CHAT_API_KEY")
// 轻量模型直接走代码补全接口, 节约成本
if strings.Contains(apiModelName, os.Getenv("LIGHTWEIGHT_MODEL")) {
envModelName = os.Getenv("CODEX_API_MODEL_NAME")
codexAPIURL := os.Getenv("CODEX_API_BASE")
parsedURL, err := url.Parse(codexAPIURL)
if err != nil {
fmt.Println("URL解析错误:", err)
return
}
chatAPIURL = "https://" + parsedURL.Hostname() + "/v1/chat/completions"
apiKey = os.Getenv("CODEX_API_KEY")
}
c.Header("Content-Type", "text/event-stream")
body, _ = sjson.SetBytes(body, "model", envModelName)
body, _ = sjson.SetBytes(body, "stream", true) // 强制流式输出
if !gjson.GetBytes(body, "function_call").Exists() {
messages := gjson.GetBytes(body, "messages").Array()
for i, msg := range messages {
toolCalls := msg.Get("tool_calls").Array()
if len(toolCalls) == 0 {
body, _ = sjson.DeleteBytes(body, fmt.Sprintf("messages.%d.tool_calls", i))
}
}
lastIndex := len(messages) - 1
chatLocale := os.Getenv("CHAT_LOCALE")
if chatLocale != "" && !strings.Contains(messages[lastIndex].Get("content").String(), "Respond in the following locale") {
body, _ = sjson.SetBytes(body, "messages."+strconv.Itoa(lastIndex)+".content", messages[lastIndex].Get("content").String()+"Respond in the following locale: "+chatLocale+".")
}
}
body, _ = sjson.DeleteBytes(body, "intent")
body, _ = sjson.DeleteBytes(body, "intent_threshold")
body, _ = sjson.DeleteBytes(body, "intent_content")
body, _ = sjson.DeleteBytes(body, "logprobs") // #IBZYCA
// 是否支持使用工具, 避免模型不支持相关功能报错
chatUseTools, _ := strconv.ParseBool(os.Getenv("CHAT_USE_TOOLS"))
if !chatUseTools {
body, _ = sjson.DeleteBytes(body, "tools")
body, _ = sjson.DeleteBytes(body, "tool_call")
body, _ = sjson.DeleteBytes(body, "functions")
body, _ = sjson.DeleteBytes(body, "function_call")
body, _ = sjson.DeleteBytes(body, "tool_choice")
}
ChatMaxTokens, _ := strconv.Atoi(os.Getenv("CHAT_MAX_TOKENS"))
if int(gjson.GetBytes(body, "max_tokens").Int()) > ChatMaxTokens {
body, _ = sjson.SetBytes(body, "max_tokens", ChatMaxTokens)
}
if gjson.GetBytes(body, "n").Int() > 1 {
body, _ = sjson.SetBytes(body, "n", 1)
}
messages := gjson.GetBytes(body, "messages").Array()
userAgent := c.GetHeader("User-Agent")
// 拦截处理vscode对话首次预处理请求, 减少等待时间
firstRole := gjson.GetBytes(body, "messages.0.role").String()
firstContent := gjson.GetBytes(body, "messages.0.content").String()
if strings.Contains(firstRole, "system") && strings.Contains(firstContent, "You are a helpful AI programming assistant to a user") &&
!strings.Contains(firstContent, "If you cannot choose just one category, or if none of the categories seem like they would provide the user with a better result, you must always respond with") &&
!gjson.GetBytes(body, "tool_choice").Exists() {
_, _ = c.Writer.WriteString("data: [DONE]\n\n")
c.Writer.Flush()
return
}
// vs2022客户端的兼容处理
if strings.Contains(userAgent, "VSCopilotClient") {
lastMessage := messages[len(messages)-1]
messageRole := lastMessage.Get("role").String()
messageContent := lastMessage.Get("content").String()
if strings.Contains(firstRole, "system") && strings.Contains(firstContent, "You are an AI programming assistant") {
vs2022FirstChatTemplate(c)
return
}
if messageRole == "user" && messageContent == "Write a short one-sentence question that I can ask that naturally follows from the previous few questions and answers. It should not ask a question which is already answered in the conversation. It should be a question that you are capable of answering. Reply with only the text of the question and nothing else." {
_, _ = c.Writer.WriteString("data: [DONE]\n\n")
c.Writer.Flush()
return
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatAPIURL, io.NopCloser(bytes.NewBuffer(body)))
if nil != err {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
httpClientTimeout, _ := time.ParseDuration(os.Getenv("HTTP_CLIENT_TIMEOUT") + "s")
client := &http.Client{
Timeout: httpClientTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
resp, err := client.Do(req)
if nil != err {
if errors.Is(err, context.Canceled) {
c.AbortWithStatus(http.StatusRequestTimeout)
return
}
log.Println("request conversation failed:", err.Error())
c.AbortWithStatus(http.StatusInternalServerError)
return
}
defer CloseIO(resp.Body)
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Println("request completions failed:", string(body))
resp.Body = io.NopCloser(bytes.NewBuffer(body))
}
c.Status(resp.StatusCode)
_, _ = io.Copy(c.Writer, resp.Body)
}
// vs2022FirstChatTemplate is a template for the first chat completion response
func vs2022FirstChatTemplate(c *gin.Context) {
fixedOutput := `data: {"id":"f6202f6f-9d13-4518-b34f-65e945b0a1a2","object":"chat.completion.chunk","model":"gpt-4o-mini-2024-07-18","created":1734752124,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"b2ab39cb-9a84-4006-b470-93a5965c6d69","object":"chat.completion.chunk","model":"gpt-4o-mini-2024-07-18","created":1734752124,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"df5f9ce7-b653-4ffb-8d92-e21856ce1ffc","object":"chat.completion.chunk","model":"gpt-4o-mini-2024-07-18","created":1734752124,"choices":[{"index":0,"delta":{"role":"assistant","content":"Explain"},"finish_reason":null}]}
data: {"id":"fb58d66e-bb16-43f2-8470-2de0c8662533","object":"chat.completion.chunk","model":"gpt-4o-mini-2024-07-18","created":1734752124,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"22ea16e2-766f-4b10-84d0-68399abc9181","object":"chat.completion.chunk","model":"gpt-4o-mini-2024-07-18","created":1734752124,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop"}]}
data: [DONE]
`
_, _ = c.Writer.WriteString(fixedOutput)
c.Writer.Flush()
}

View File

@@ -0,0 +1,273 @@
package copilot
import (
"context"
"crypto/sha256"
"fmt"
"github.com/gofrs/uuid"
"net/http"
"os"
"strconv"
"strings"
"sync"
"github.com/gin-gonic/gin"
)
// 常量定义
const (
defaultDimensionSize = 1536 // 默认向量维度
markdownFilePrefix = "File: `%s`\n```shell\n"
markdownFileSuffix = "```"
)
// 获取向量维度大小
func getDimensionSize() int {
dimensionSize := defaultDimensionSize
if dimSizeStr := os.Getenv("EMBEDDING_DIMENSION_SIZE"); dimSizeStr != "" {
if dimSize, err := strconv.Atoi(dimSizeStr); err == nil {
dimensionSize = dimSize
}
}
return dimensionSize
}
// 计算块大小
func getChunkSize() int {
// 根据维度大小调整块大小这里设置为维度的1.5倍左右
return getDimensionSize() * 3 / 2
}
// ChunkRequest 表示分块请求
type ChunkRequest struct {
Content string `json:"content" binding:"required"`
Path string `json:"path" binding:"required"`
Embed bool `json:"embed"`
}
// Chunk 表示内容块
type Chunk struct {
Hash string `json:"hash"`
Text string `json:"text"`
Range Range `json:"range"`
Embedding Embedding `json:"embedding,omitempty"`
}
// Range 表示文本范围
type Range struct {
Start int `json:"start"`
End int `json:"end"`
}
// Embedding 表示向量嵌入
type Embedding struct {
Embedding []float32 `json:"embedding"`
}
// ChunkResponse 表示分块响应
type ChunkResponse struct {
Chunks []Chunk `json:"chunks"`
EmbeddingModel string `json:"embedding_model"`
}
// ChunkService 处理文本分块和嵌入的服务
type ChunkService struct {
embeddingClient *EmbeddingClient
modelName string
}
// NewChunkService 创建新的分块服务
func NewChunkService() (*ChunkService, error) {
client, err := NewEmbeddingClient(getDimensionSize())
if err != nil {
return nil, fmt.Errorf("failed to create embedding client: %w", err)
}
return &ChunkService{
embeddingClient: client,
modelName: os.Getenv("EMBEDDING_API_MODEL_NAME"),
}, nil
}
// HandleChunks 处理分块请求的HTTP处理器
func HandleChunks(c *gin.Context) {
var req ChunkRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
service, err := NewChunkService()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to initialize service: %v", err)})
return
}
chunks := service.SplitIntoChunks(req.Content, req.Path)
if req.Embed {
if err := service.GenerateEmbeddings(c.Request.Context(), chunks); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to generate embeddings: %v", err)})
return
}
}
resp := ChunkResponse{
Chunks: chunks,
EmbeddingModel: service.modelName,
}
requestID := uuid.Must(uuid.NewV4()).String()
c.Header("x-github-request-id", requestID)
c.JSON(http.StatusOK, resp)
}
// SplitIntoChunks 将内容分割成块
func (s *ChunkService) SplitIntoChunks(content, path string) []Chunk {
var chunks []Chunk
lines := strings.Split(content, "\n")
chunkSize := getChunkSize()
// 预分配切片容量,减少内存重新分配
estimatedChunks := len(content)/chunkSize + 1
chunks = make([]Chunk, 0, estimatedChunks)
var sb strings.Builder
start := 0
for _, line := range lines {
lineWithNewline := line + "\n"
// 如果当前块加上新行会超过chunkSize并且当前块不为空
if sb.Len()+len(lineWithNewline) > chunkSize && sb.Len() > 0 {
// 创建新的chunk
chunkText := sb.String()
chunk := s.createChunk(chunkText, path, start, start+len(chunkText))
chunks = append(chunks, chunk)
start += len(chunkText)
sb.Reset()
sb.WriteString(lineWithNewline)
} else {
sb.WriteString(lineWithNewline)
}
}
// 添加最后一个chunk
if sb.Len() > 0 {
chunkText := sb.String()
chunk := s.createChunk(chunkText, path, start, start+len(chunkText))
chunks = append(chunks, chunk)
}
return chunks
}
// createChunk 创建一个新的内容块
func (s *ChunkService) createChunk(text, path string, start, end int) Chunk {
// 计算文本的SHA-256哈希
hash := sha256.Sum256([]byte(text))
return Chunk{
Hash: fmt.Sprintf("%x", hash),
Text: fmt.Sprintf(markdownFilePrefix+"%s"+markdownFileSuffix, path, text),
Range: Range{
Start: start,
End: end,
},
Embedding: Embedding{
Embedding: make([]float32, 0), // 初始化为空切片
},
}
}
// GenerateEmbeddings 为所有块生成嵌入向量
func (s *ChunkService) GenerateEmbeddings(ctx context.Context, chunks []Chunk) error {
if len(chunks) == 0 {
return nil
}
// 对于少量块,直接串行处理
if len(chunks) <= 5 {
return s.generateEmbeddingsSerial(ctx, chunks)
}
// 对于大量块,使用并行处理
return s.generateEmbeddingsParallel(ctx, chunks)
}
// generateEmbeddingsSerial 串行生成嵌入向量
func (s *ChunkService) generateEmbeddingsSerial(ctx context.Context, chunks []Chunk) error {
for i := range chunks {
text := s.extractPlainText(chunks[i].Text)
embedding, err := s.embeddingClient.GetEmbedding(ctx, text)
if err != nil {
return fmt.Errorf("failed to generate embedding for chunk %d: %w", i, err)
}
chunks[i].Embedding.Embedding = embedding
}
return nil
}
// generateEmbeddingsParallel 并行生成嵌入向量
func (s *ChunkService) generateEmbeddingsParallel(ctx context.Context, chunks []Chunk) error {
var wg sync.WaitGroup
errChan := make(chan error, len(chunks))
// 限制并发数量,避免过多的并发请求
semaphore := make(chan struct{}, 10)
for i := range chunks {
wg.Add(1)
go func(idx int) {
defer wg.Done()
// 获取信号量
semaphore <- struct{}{}
defer func() { <-semaphore }()
text := s.extractPlainText(chunks[idx].Text)
embedding, err := s.embeddingClient.GetEmbedding(ctx, text)
if err != nil {
errChan <- fmt.Errorf("failed to generate embedding for chunk %d: %w", idx, err)
return
}
chunks[idx].Embedding.Embedding = embedding
}(i)
}
// 等待所有goroutine完成
wg.Wait()
close(errChan)
// 检查是否有错误
select {
case err := <-errChan:
if err != nil {
return err
}
default:
// 没有错误
}
return nil
}
// extractPlainText 从markdown格式的文本中提取纯文本
func (s *ChunkService) extractPlainText(text string) string {
// 移除第一行 File: 标记
if idx := strings.Index(text, "\n"); idx != -1 {
text = text[idx+1:]
}
// 移除 ```shell 和结尾的 ```
text = strings.TrimPrefix(text, "```shell\n")
text = strings.TrimSuffix(text, "```")
return text
}

View File

@@ -0,0 +1,373 @@
package copilot
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// CodeCompletions 代码补全
func CodeCompletions(c *gin.Context) {
ctx := c.Request.Context()
requestID := uuid.Must(uuid.NewV4()).String()
c.Header("x-github-request-id", requestID)
debounceTime, _ := strconv.Atoi(os.Getenv("COPILOT_DEBOUNCE"))
time.Sleep(time.Duration(debounceTime) * time.Millisecond)
if ctx.Err() != nil {
abortCodex(c, http.StatusRequestTimeout)
return
}
body, err := io.ReadAll(c.Request.Body)
if nil != err {
abortCodex(c, http.StatusBadRequest)
return
}
c.Header("Content-Type", "text/event-stream")
codexServiceType := os.Getenv("CODEX_SERVICE_TYPE")
body = ConstructRequestBody(body, codexServiceType)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, os.Getenv("CODEX_API_BASE"), io.NopCloser(bytes.NewBuffer(body)))
if nil != err {
abortCodex(c, http.StatusInternalServerError)
return
}
req.Header.Set("Content-Type", "application/json")
apiKeys := strings.Split(os.Getenv("CODEX_API_KEY"), ",")
// 检查 apiKeys 是否有效
if len(apiKeys) == 0 || (len(apiKeys) == 1 && apiKeys[0] == "") {
abortCodex(c, http.StatusInternalServerError)
return
}
randGen := rand.New(rand.NewSource(time.Now().UnixNano()))
selectedKey := strings.TrimSpace(apiKeys[randGen.Intn(len(apiKeys))])
if selectedKey == "" {
abortCodex(c, http.StatusInternalServerError)
return
}
req.Header.Set("Authorization", "Bearer "+selectedKey)
httpClientTimeout, _ := time.ParseDuration(os.Getenv("HTTP_CLIENT_TIMEOUT") + "s")
client := &http.Client{
Timeout: httpClientTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
resp, err := client.Do(req)
if nil != err {
if errors.Is(err, context.Canceled) {
abortCodex(c, http.StatusRequestTimeout)
return
}
log.Println("request completions failed:", err.Error())
abortCodex(c, http.StatusInternalServerError)
return
}
defer CloseIO(resp.Body)
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Println("request completions failed:", string(body))
abortCodex(c, resp.StatusCode)
return
}
c.Status(resp.StatusCode)
// 处理 Ollama 服务的流式响应
if codexServiceType == "ollama" {
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
break
}
break
}
if strings.TrimSpace(line) == "" {
continue
}
// json解析 line
lineJson := gjson.Parse(line)
uuid := uuid.Must(uuid.NewV4()).String()
done := lineJson.Get("done").Bool()
doneReason := lineJson.Get("done_reason").Str
response := lineJson.Get("response").Str
timestamp := time.Now().Unix()
choice := map[string]interface{}{
"text": response,
"index": 0,
"logprobs": nil,
"finish_reason": doneReason,
}
choices := []map[string]interface{}{choice}
constructLineData := map[string]interface{}{
"id": uuid,
"choices": choices,
"created": timestamp,
"model": lineJson.Get("model").Str,
"system_fingerprint": "fp_1c141eb703",
"object": "text_completion",
}
if done && strings.Contains(doneReason, "stop") {
usage := map[string]interface{}{
"prompt_tokens": lineJson.Get("prompt_eval_count").Int(),
"completion_tokens": lineJson.Get("eval_count").Int(),
"total_tokens": lineJson.Get("prompt_eval_count").Int(),
"prompt_cache_hit_tokens": lineJson.Get("prompt_eval_count").Int(),
"prompt_cache_miss_tokens": lineJson.Get("eval_count").Int(),
}
constructLineData["usage"] = usage
}
// 将修改后的数据重新编码为 JSON
modifiedJSON, err := json.Marshal(constructLineData)
if err != nil {
continue
}
// 发送修改后的数据
_, _ = c.Writer.WriteString("data: " + string(modifiedJSON) + "\n\n")
c.Writer.Flush()
}
_, _ = c.Writer.WriteString("data: [DONE]\n\n")
c.Writer.Flush()
return
}
// 处理默认服务的响应
_, _ = io.Copy(c.Writer, resp.Body)
}
// ConstructRequestBody 重新构建请求体
func ConstructRequestBody(body []byte, codexServiceType string) []byte {
envCodexModel := os.Getenv("CODEX_API_MODEL_NAME")
body, _ = sjson.SetBytes(body, "model", envCodexModel)
body, _ = sjson.SetBytes(body, "stream", true) // 强制流式输出
body, _ = sjson.DeleteBytes(body, "extra")
body, _ = sjson.DeleteBytes(body, "nwo")
// 限制 prompt 和 suffix 的长度
body = applyPromptLengthLimit(body)
temperature, _ := strconv.ParseFloat(os.Getenv("CODEX_TEMPERATURE"), 64)
if temperature != -1 {
body, _ = sjson.SetBytes(body, "temperature", temperature)
}
codeMaxTokens, _ := strconv.Atoi(os.Getenv("CODEX_MAX_TOKENS"))
if int(gjson.GetBytes(body, "max_tokens").Int()) > codeMaxTokens {
body, _ = sjson.SetBytes(body, "max_tokens", codeMaxTokens)
}
if gjson.GetBytes(body, "n").Int() > 1 {
body, _ = sjson.SetBytes(body, "n", 1)
}
// https://ollama.com/library/stable-code || https://ollama.com/library/codegemma
if strings.Contains(envCodexModel, "stable-code") || strings.Contains(envCodexModel, "codegemma") {
return constructWithStableCodeModel(body)
}
// https://ollama.com/library/codellama
if strings.Contains(envCodexModel, "codellama") {
return constructWithCodeLlamaModel(body)
}
// https://help.aliyun.com/zh/model-studio/user-guide/qwen-coder?spm=a2c4g.11186623.0.0.a5234823I6LvAG
if strings.Contains(envCodexModel, "qwen-coder-turbo") {
return constructWithQwenCoderTurboModel(body)
}
// 支持 Ollama FIM 的模型, 如:https://ollama.com/library/deepseek-coder-v2
if codexServiceType == "ollama" {
return constructWithOllamaModel(body, codeMaxTokens)
}
return body
}
// applyPromptLengthLimit 对 prompt 和 suffix 应用长度限制
func applyPromptLengthLimit(body []byte) []byte {
envLimitPrompt := os.Getenv("CODEX_LIMIT_PROMPT")
limitPrompt, err := strconv.Atoi(envLimitPrompt)
if err != nil || limitPrompt <= 0 {
return body
}
body = limitPromptLength(body, limitPrompt)
body = limitSuffixLength(body, limitPrompt)
return body
}
// limitPromptLength 限制 prompt 长度
func limitPromptLength(body []byte, limitRows int) []byte {
prompt := gjson.GetBytes(body, "prompt")
if !prompt.Exists() {
return body
}
rows := strings.Split(prompt.Str, "\n")
if len(rows) <= limitRows {
return body
}
// 保留后面的内容
newPrompt := strings.Join(rows[len(rows)-limitRows:], "\n")
body, _ = sjson.SetBytes(body, "prompt", newPrompt)
return body
}
// limitSuffixLength 限制 suffix 长度
func limitSuffixLength(body []byte, limitRows int) []byte {
suffix := gjson.GetBytes(body, "suffix")
if !suffix.Exists() {
return body
}
rows := strings.Split(suffix.Str, "\n")
if len(rows) <= limitRows {
return body
}
// 保留前面的内容
newSuffix := strings.Join(rows[:limitRows], "\n")
body, _ = sjson.SetBytes(body, "suffix", newSuffix)
return body
}
// constructWithCodeLlamaModel 重写codeLlama模型要求的请求体
func constructWithCodeLlamaModel(body []byte) []byte {
suffix := gjson.GetBytes(body, "suffix")
prompt := gjson.GetBytes(body, "prompt")
content := fmt.Sprintf("<PRE> %s <SUF> %s <MID>", prompt, suffix)
return constructWithChatModel(body, content)
}
// constructWithStableCodeModel 重写StableCode模型要求的请求体
func constructWithStableCodeModel(body []byte) []byte {
suffix := gjson.GetBytes(body, "suffix")
prompt := gjson.GetBytes(body, "prompt")
content := fmt.Sprintf("<fim_prefix>%s<fim_suffix>%s<fim_middle>", prompt, suffix)
return constructWithChatModel(body, content)
}
// constructWithChatModel 重写Chat请求体
func constructWithChatModel(body []byte, content string) []byte {
// 创建新的 JSON 对象并添加到 body 中
messages := []map[string]string{
{
"role": "user",
"content": content,
},
}
body, _ = sjson.SetBytes(body, "messages", messages)
jsonStr := string(body)
jsonStr = strings.ReplaceAll(jsonStr, "\\u003c", "<")
jsonStr = strings.ReplaceAll(jsonStr, "\\u003e", ">")
return []byte(jsonStr)
}
// constructWithQwenCoderTurboModel 重写QwenCoderTurbo模型要求的请求体
func constructWithQwenCoderTurboModel(body []byte) []byte {
if gjson.GetBytes(body, "n").Int() > 1 {
body, _ = sjson.SetBytes(body, "n", 1)
}
suffix := gjson.GetBytes(body, "suffix")
prompt := gjson.GetBytes(body, "prompt")
codeLanguage := gjson.GetBytes(body, "extra.language")
messages := []map[string]interface{}{
{
"role": "system",
"content": "You are an expert in " + codeLanguage.Str + " programming, highly skilled at understanding and continuing to write code.",
},
{
"role": "user",
"content": "Combined with subsequent code snippets, help me complete the code:\n\n" +
"Code subsequent content:\n```" + codeLanguage.Str + "\n" + suffix.Str + "```\n\n" +
"Remember:\n" +
"- Do not generate content outside of the code.\n" +
"- Do not directly fill in all the code content, the maximum number of lines of code should not exceed 5 lines.\n" +
"- Answer must refer to the code suffix content, do not exceed the boundary, otherwise repeated code will occur.\n" +
"- If you don't know how to answer, just reply with an empty string.",
},
{
"role": "assistant",
"content": prompt.Str,
"partial": true,
},
}
body, _ = sjson.SetBytes(body, "messages", messages)
body, _ = sjson.DeleteBytes(body, "prompt")
return body
}
// constructWithOllamaModel 重写Ollama模型要求的请求体
func constructWithOllamaModel(body []byte, codeMaxTokens int) []byte {
body, _ = sjson.SetBytes(body, "options.temperature", 0)
// stop参数处理
stopArray := gjson.GetBytes(body, "stop").Array()
stopSlice := make([]interface{}, len(stopArray))
for i, v := range stopArray {
stopSlice[i] = v.String()
}
body, _ = sjson.SetBytes(body, "options.stop", stopSlice)
body, _ = sjson.SetBytes(body, "stream", true)
maxTokens := gjson.GetBytes(body, "max_tokens").Int()
if int(maxTokens) > codeMaxTokens {
body, _ = sjson.SetBytes(body, "options.num_predict", codeMaxTokens)
} else {
body, _ = sjson.SetBytes(body, "options.num_predict", maxTokens)
}
return body
}
// abortCodex 中断请求
func abortCodex(c *gin.Context, status int) {
c.Header("Content-Type", "text/event-stream")
c.String(status, "data: [DONE]\n\n")
c.Abort()
}

View File

@@ -0,0 +1,167 @@
package copilot
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"sync"
"time"
)
// 常量定义
const (
defaultTimeout = 30 * time.Second
contentTypeJSON = "application/json"
)
// EmbeddingRequest 表示向嵌入API发送的请求
type EmbeddingRequest struct {
Model string `json:"model"`
Input []string `json:"input"`
Dimensions int `json:"dimensions"`
}
// EmbeddingResponse 表示从嵌入API接收的响应
type EmbeddingResponse struct {
Data []EmbeddingData `json:"data"`
Model string `json:"model"`
Object string `json:"object"`
Usage Usage `json:"usage"`
}
// EmbeddingData 表示单个嵌入数据
type EmbeddingData struct {
Embedding []float32 `json:"embedding"`
Index int `json:"index"`
Object string `json:"object"`
}
// Usage 表示API使用情况
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
TotalTokens int `json:"total_tokens"`
}
// 移除未使用的类型
// Parameters 和 EmbeddingsRequest, EmbeddingsResponse 已被移除
// EmbeddingClient 封装了与嵌入API交互的功能
type EmbeddingClient struct {
apiURL string
apiKey string
model string
dimensions int
httpClient *http.Client
clientMutex sync.RWMutex
}
// NewEmbeddingClient 创建一个新的嵌入客户端
func NewEmbeddingClient(dimensions int) (*EmbeddingClient, error) {
apiURL := os.Getenv("EMBEDDING_API_BASE")
apiKey := os.Getenv("EMBEDDING_API_KEY")
if apiURL == "" || apiKey == "" {
return nil, fmt.Errorf("EMBEDDING_API_BASE or EMBEDDING_API_KEY environment variable not set")
}
if os.Getenv("EMBEDDING_API_MODEL_NAME") == "" {
return nil, fmt.Errorf("EMBEDDING_API_MODEL_NAME environment variable not set")
}
// 解析超时时间,如果未设置或解析失败则使用默认值
timeout := defaultTimeout
if timeoutStr := os.Getenv("HTTP_CLIENT_TIMEOUT"); timeoutStr != "" {
if parsedTimeout, err := time.ParseDuration(timeoutStr + "s"); err == nil {
timeout = parsedTimeout
}
}
client := &http.Client{
Timeout: timeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
return &EmbeddingClient{
apiURL: apiURL,
apiKey: apiKey,
model: os.Getenv("EMBEDDING_API_MODEL_NAME"),
dimensions: dimensions,
httpClient: client,
}, nil
}
// SetModel 设置嵌入模型
func (c *EmbeddingClient) SetModel(model string) {
c.clientMutex.Lock()
defer c.clientMutex.Unlock()
c.model = model
}
// GetEmbedding 获取单个文本的嵌入
func (c *EmbeddingClient) GetEmbedding(ctx context.Context, text string) ([]float32, error) {
resp, err := c.GetEmbeddings(ctx, []string{text})
if err != nil {
return nil, err
}
if len(resp.Data) == 0 {
return nil, fmt.Errorf("no embeddings returned")
}
return resp.Data[0].Embedding, nil
}
// GetEmbeddings 批量获取多个文本的嵌入
func (c *EmbeddingClient) GetEmbeddings(ctx context.Context, texts []string) (*EmbeddingResponse, error) {
c.clientMutex.RLock()
dimensions := c.dimensions
c.clientMutex.RUnlock()
reqBody := EmbeddingRequest{
Model: os.Getenv("EMBEDDING_API_MODEL_NAME"),
Input: texts,
Dimensions: dimensions,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %v", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %v", err)
}
req.Header.Set("Content-Type", contentTypeJSON)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to make request: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %v", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
}
var embeddingResp EmbeddingResponse
if err := json.Unmarshal(body, &embeddingResp); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %v", err)
}
return &embeddingResp, nil
}

View File

@@ -0,0 +1,58 @@
package copilot
import (
"github.com/gofrs/uuid"
"net/http"
"os"
"strconv"
"github.com/gin-gonic/gin"
)
// EmbeddingsAPIRequest 表示嵌入API的请求结构
type EmbeddingsAPIRequest struct {
Input []string `json:"input" binding:"required"`
Model string `json:"model,omitempty"`
Dimensions int `json:"dimensions,omitempty"`
}
// HandleEmbeddings 处理嵌入请求的HTTP处理器
func HandleEmbeddings(c *gin.Context) {
requestID := uuid.Must(uuid.NewV4()).String()
c.Header("x-github-request-id", requestID)
var req EmbeddingsAPIRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 从环境变量获取维度大小默认为1536
dimensionSize := 1536
if dimSizeStr := os.Getenv("EMBEDDING_DIMENSION_SIZE"); dimSizeStr != "" {
if dimSize, err := strconv.Atoi(dimSizeStr); err == nil {
dimensionSize = dimSize
}
}
// 创建嵌入客户端
client, err := NewEmbeddingClient(dimensionSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 如果请求中指定了模型,则使用请求中的模型
if req.Model != "" {
client.SetModel(req.Model)
}
// 获取嵌入,使用请求上下文以支持取消操作
resp, err := client.GetEmbeddings(c.Request.Context(), req.Input)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}

View File

@@ -0,0 +1,147 @@
package copilot
import (
"encoding/json"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
"log"
"math/rand"
"net/http"
"os"
"ripper/internal/app/github_auth"
"ripper/internal/cache"
"strconv"
"strings"
"time"
)
// GetDisguiseCopilotInternalV2Token 返回伪装的token
func GetDisguiseCopilotInternalV2Token(ctx *gin.Context) {
requestID := uuid.Must(uuid.NewV4()).String()
ctx.Header("x-github-request-id", requestID)
trackingId, _ := uuid.NewV4()
now := time.Now().Unix()
dcAt, _ := strconv.Atoi(os.Getenv("DISGUISE_COPILOT_TOKEN_EXPIRES_AT"))
expiresAt := now + int64(dcAt)
sku := "copilot_for_business_seat"
copilotToken := github_auth.JsonMap2SignToken(map[string]interface{}{
"tid": trackingId,
"exp": expiresAt,
"sku": sku,
"st": "dotcom",
"chat": 1,
"u": "github",
})
endpoints := make(map[string]interface{})
endpoints["api"] = os.Getenv("API_BASE_URL")
endpoints["origin-tracker"] = "https://origin-tracker.individual.githubcopilot.com"
endpoints["proxy"] = os.Getenv("PROXY_BASE_URL")
endpoints["telemetry"] = os.Getenv("TELEMETRY_BASE_URL")
gout := gin.H{
"annotations_enabled": true,
"chat_enabled": true,
"chat_jetbrains_enabled": true,
"code_quote_enabled": true,
"code_review_enabled": false,
"codesearch": true,
"copilot_ide_agent_chat_gpt4_small_prompt": false,
"copilotignore_enabled": false,
"endpoints": endpoints,
"expires_at": expiresAt,
"individual": true,
"nes_enabled": false,
"prompt_8k": true,
"public_suggestions": "disabled",
"refresh_in": 1500,
"sku": sku,
"snippy_load_test_enabled": false,
"telemetry": "disabled",
"token": copilotToken,
"tracking_id": trackingId,
"intellij_editor_fetcher": false,
"vsc_electron_fetcher": false,
"vs_editor_fetcher": false,
"vsc_panel_v2": false,
"xcode": true,
"xcode_chat": true,
"limited_user_quotas": nil,
"limited_user_reset_date": nil,
"vsc_electron_fetcher_v2": false,
}
ctx.JSON(http.StatusOK, gout)
}
// GetCopilotInternalV2Token 获取github copilot官方token
func GetCopilotInternalV2Token(c *gin.Context) {
ghuTokens := strings.Split(os.Getenv("COPILOT_GHU_TOKEN"), ",")
if len(ghuTokens) == 0 {
return
}
rand.Seed(time.Now().UnixNano())
ghu := ghuTokens[rand.Intn(len(ghuTokens))]
if ghu == "" {
log.Println("ghu token is empty")
c.JSON(http.StatusUnprocessableEntity, gin.H{
"message": "ghu token is empty",
})
return
}
cacheKey := "copilot_internal_v2_token"
token, err := cache.Get(cacheKey)
if err != nil {
log.Println(err.Error())
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
cache.Del(cacheKey)
return
}
if token != nil {
c.JSON(http.StatusOK, token)
return
}
url := "https://api.github.com/copilot_internal/v2/token"
req, err := http.NewRequestWithContext(c, "GET", url, nil)
if err != nil {
log.Println(err.Error())
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
req.Header.Set("authorization", "token "+ghu)
req.Header.Set("editor-plugin-version", "copilot-intellij/1.5.21.6667")
req.Header.Set("editor-version", "JetBrains-IU/242.21829.142")
req.Header.Set("user-agent", "GithubCopilot/1.228.0")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Println(err.Error())
c.JSON(resp.StatusCode, gin.H{"error": err.Error()})
return
}
if resp.StatusCode != 200 {
errorMsg := "获取 Token 失败, 当前 ghu_token 账户可能并未订阅 github copilot 服务!" + ghu
c.JSON(resp.StatusCode, gin.H{"error": errorMsg})
log.Println(errorMsg)
return
}
defer resp.Body.Close()
var result interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
if err != nil {
log.Println(err.Error())
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
cache.Set(cacheKey, result, 1500)
c.JSON(resp.StatusCode, result)
}

View File

@@ -0,0 +1,373 @@
package copilot
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"github.com/gofrs/uuid"
"io"
"io/ioutil"
"log"
"math/rand"
"net/http"
"os"
"ripper/internal/cache"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// CodexCompletions 全代理GitHub的代码补全接口
func CodexCompletions(c *gin.Context) {
ctx := c.Request.Context()
requestID := uuid.Must(uuid.NewV4()).String()
c.Header("x-github-request-id", requestID)
urlModelName := c.Param("model-name")
debounceTime, _ := strconv.Atoi(os.Getenv("COPILOT_DEBOUNCE"))
time.Sleep(time.Duration(debounceTime) * time.Millisecond)
if ctx.Err() != nil {
abortCodex(c, http.StatusRequestTimeout)
return
}
body, err := io.ReadAll(c.Request.Body)
if nil != err {
abortCodex(c, http.StatusBadRequest)
return
}
copilotAccountType := os.Getenv("COPILOT_ACCOUNT_TYPE")
url := "https://proxy." + copilotAccountType + ".githubcopilot.com/v1/engines/" + urlModelName + "/completions"
req, err := http.NewRequestWithContext(c, "POST", url, bytes.NewBuffer(body))
if nil != err {
abortCodex(c, http.StatusInternalServerError)
return
}
// 合并请求头
if err := mergeHeaders(c.Request.Header, req); err != nil {
log.Println(err)
abortCodex(c, http.StatusInternalServerError)
return
}
httpClientTimeout, _ := time.ParseDuration(os.Getenv("HTTP_CLIENT_TIMEOUT") + "s")
client := &http.Client{
Timeout: httpClientTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
resp, err := client.Do(req)
if nil != err {
if errors.Is(err, context.Canceled) {
abortCodex(c, http.StatusRequestTimeout)
return
}
log.Println("request completions failed:", err.Error())
abortCodex(c, http.StatusInternalServerError)
return
}
defer CloseIO(resp.Body)
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Println("请求GitHub官方补全接口失败:", string(body))
abortCodex(c, resp.StatusCode)
return
}
c.Status(resp.StatusCode)
c.Header("Content-Type", resp.Header.Get("Content-Type"))
_, _ = io.Copy(c.Writer, resp.Body)
}
// ChatsCompletions 全代理GitHub的聊天补全接口
func ChatsCompletions(c *gin.Context) {
ctx := c.Request.Context()
if ctx.Err() != nil {
abortCodex(c, http.StatusRequestTimeout)
return
}
body, err := io.ReadAll(c.Request.Body)
if nil != err {
abortCodex(c, http.StatusBadRequest)
return
}
copilotAccountType := os.Getenv("COPILOT_ACCOUNT_TYPE")
url := "https://api." + copilotAccountType + ".githubcopilot.com/chat/completions"
req, err := http.NewRequestWithContext(c, "POST", url, bytes.NewBuffer(body))
if nil != err {
abortCodex(c, http.StatusInternalServerError)
return
}
// 合并请求头
if err := mergeHeaders(c.Request.Header, req); err != nil {
log.Println(err)
abortCodex(c, http.StatusInternalServerError)
return
}
httpClientTimeout, _ := time.ParseDuration(os.Getenv("HTTP_CLIENT_TIMEOUT") + "s")
client := &http.Client{
Timeout: httpClientTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
resp, err := client.Do(req)
if nil != err {
if errors.Is(err, context.Canceled) {
abortCodex(c, http.StatusRequestTimeout)
return
}
log.Println("request completions failed:", err.Error())
abortCodex(c, http.StatusInternalServerError)
return
}
defer CloseIO(resp.Body)
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Println("请求GitHub官方对话接口失败:", string(body))
abortCodex(c, resp.StatusCode)
return
}
c.Status(resp.StatusCode)
c.Header("Content-Type", resp.Header.Get("Content-Type"))
_, _ = io.Copy(c.Writer, resp.Body)
}
// ChatEditCompletions 聊天编辑补全接口
func ChatEditCompletions(c *gin.Context) {
ctx := c.Request.Context()
if ctx.Err() != nil {
abortCodex(c, http.StatusRequestTimeout)
return
}
body, err := io.ReadAll(c.Request.Body)
if nil != err {
abortCodex(c, http.StatusBadRequest)
return
}
copilotAccountType := os.Getenv("COPILOT_ACCOUNT_TYPE")
url := "https://proxy." + copilotAccountType + ".githubcopilot.com/v1/engines/copilot-centralus-h100/speculation"
req, err := http.NewRequestWithContext(c, "POST", url, bytes.NewBuffer(body))
if nil != err {
abortCodex(c, http.StatusInternalServerError)
return
}
// 合并请求头
if err := mergeHeaders(c.Request.Header, req); err != nil {
log.Println(err)
abortCodex(c, http.StatusInternalServerError)
return
}
httpClientTimeout, _ := time.ParseDuration(os.Getenv("HTTP_CLIENT_TIMEOUT") + "s")
client := &http.Client{
Timeout: httpClientTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
resp, err := client.Do(req)
if nil != err {
if errors.Is(err, context.Canceled) {
abortCodex(c, http.StatusRequestTimeout)
return
}
log.Println("request failed:", err.Error())
abortCodex(c, http.StatusInternalServerError)
return
}
defer CloseIO(resp.Body)
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Println("请求 Chat 编辑接口失败:", string(body))
abortCodex(c, resp.StatusCode)
return
}
c.Status(resp.StatusCode)
c.Header("Content-Type", resp.Header.Get("Content-Type"))
requestID := uuid.Must(uuid.NewV4()).String()
c.Header("x-github-request-id", requestID)
_, _ = io.Copy(c.Writer, resp.Body)
}
// getAuthToken 获取GitHub Copilot的临时Token
func getAuthToken() (string, error) {
ghuTokens := strings.Split(os.Getenv("COPILOT_GHU_TOKEN"), ",")
if len(ghuTokens) == 0 {
return "", fmt.Errorf("COPILOT_GHU_TOKEN environment variable is empty or malformed")
}
rand.Seed(time.Now().UnixNano())
ghu := ghuTokens[rand.Intn(len(ghuTokens))]
cacheKey := "github:copilot_internal_v2_token:" + ghu
token, err := cache.Get(cacheKey)
if err != nil {
cache.Del(cacheKey)
return "", err
}
if token != nil {
return token.(string), nil
}
url := "https://api.github.com/copilot_internal/v2/token"
httpClientTimeout, _ := time.ParseDuration(os.Getenv("HTTP_CLIENT_TIMEOUT") + "s")
client := &http.Client{
Timeout: httpClientTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
req.Header.Set("authorization", "token "+ghu)
req.Header.Set("host", "api.github.com")
req.Header.Set("accept", "*/*")
req.Header.Set("editor-plugin-version", "copilot-intellij/1.5.21.6667")
req.Header.Set("copilot-language-server-version", "1.228.0")
req.Header.Set("user-agent", "GithubCopilot/1.228.0")
req.Header.Set("editor-version", "JetBrains-IU/242.21829.142")
res, err := client.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return "", fmt.Errorf("获取 Token 失败" + ghu)
}
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", err
}
// 解析json
var result map[string]interface{}
err = json.Unmarshal(body, &result)
if err != nil {
return "", err
}
newToken := result["token"].(string)
err = cache.Set(cacheKey, newToken, 1500)
if err != nil {
return "", err
}
return newToken, nil
}
// mergeHeaders 合并请求头,固定请求头会覆盖原有请求头
func mergeHeaders(originalHeader http.Header, req *http.Request) error {
// 复制原始请求头
for key, values := range originalHeader {
for _, value := range values {
req.Header.Add(key, value)
}
}
// 获取token
token, err := getAuthToken()
if err != nil {
return fmt.Errorf("获取GitHub Copilot的临时Token失败: %w", err)
}
// 固定请求头
fixedHeaders := map[string]string{
"authorization": "Bearer " + token,
"editor-plugin-version": "copilot-intellij/1.5.21.6667",
"copilot-language-server-version": "1.228.0",
"user-agent": "GithubCopilot/1.228.0",
"editor-version": "JetBrains-IU/242.21829.142",
}
// 设置固定请求头(覆盖原有的)
for key, value := range fixedHeaders {
req.Header.Set(key, value)
}
return nil
}
// GetCopilotModels 获取GitHub Copilot的模型列表
func GetCopilotModels(c *gin.Context) {
copilotAccountType := os.Getenv("COPILOT_ACCOUNT_TYPE")
url := "https://api." + copilotAccountType + ".githubcopilot.com/models"
req, err := http.NewRequestWithContext(c, "GET", url, nil)
if nil != err {
abortCodex(c, http.StatusInternalServerError)
return
}
// 合并请求头
if err := mergeHeaders(c.Request.Header, req); err != nil {
log.Println(err)
abortCodex(c, http.StatusInternalServerError)
return
}
httpClientTimeout, _ := time.ParseDuration(os.Getenv("HTTP_CLIENT_TIMEOUT") + "s")
client := &http.Client{
Timeout: httpClientTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
resp, err := client.Do(req)
if nil != err {
if errors.Is(err, context.Canceled) {
abortCodex(c, http.StatusRequestTimeout)
return
}
log.Println("获取模型列表失败:", err.Error())
abortCodex(c, http.StatusInternalServerError)
return
}
defer CloseIO(resp.Body)
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Println("请求GitHub Copilot模型列表失败:", string(body))
abortCodex(c, resp.StatusCode)
return
}
// 转发原始响应
c.Status(resp.StatusCode)
c.Header("Content-Type", resp.Header.Get("Content-Type"))
requestID := uuid.Must(uuid.NewV4()).String()
c.Header("x-github-request-id", requestID)
_, _ = io.Copy(c.Writer, resp.Body)
}

View File

@@ -0,0 +1,24 @@
package copilot
import (
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
"net/http"
)
// GetMembership 获取团队成员信息
func GetMembership(c *gin.Context) {
requestID := uuid.Must(uuid.NewV4()).String()
c.Header("x-github-request-id", requestID)
teamID := c.Param("teamID")
username := c.Param("username")
c.JSON(http.StatusOK, gin.H{
"message": "Not Found",
"documentation_url": "https://docs.github.com/rest/teams/members#get-team-membership-for-a-user-legacy",
"status": "404",
"teamID": teamID,
"username": username,
})
}

View File

@@ -0,0 +1,49 @@
package copilot
import (
"github.com/gin-gonic/gin"
"net/http"
)
func V3meta(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{})
}
func Cliv3(c *gin.Context) {
c.Header("X-OAuth-Scopes", "gist, read:org, repo, user, workflow, write:public_key")
c.JSON(http.StatusOK, gin.H{
"current_user_url": "https://api.github.com/user",
"current_user_authorizations_html_url": "https://github.com/settings/connections/applications{/client_id}",
"authorizations_url": "https://api.github.com/authorizations",
"code_search_url": "https://api.github.com/search/code?q={query}{&page,per_page,sort,order}",
"commit_search_url": "https://api.github.com/search/commits?q={query}{&page,per_page,sort,order}",
"emails_url": "https://api.github.com/user/emails",
"emojis_url": "https://api.github.com/emojis",
"events_url": "https://api.github.com/events",
"feeds_url": "https://api.github.com/feeds",
"followers_url": "https://api.github.com/user/followers",
"following_url": "https://api.github.com/user/following{/target}",
"gists_url": "https://api.github.com/gists{/gist_id}",
"hub_url": "https://api.github.com/hub",
"issue_search_url": "https://api.github.com/search/issues?q={query}{&page,per_page,sort,order}",
"issues_url": "https://api.github.com/issues",
"keys_url": "https://api.github.com/user/keys",
"label_search_url": "https://api.github.com/search/labels?q={query}&repository_id={repository_id}{&page,per_page}",
"notifications_url": "https://api.github.com/notifications",
"organization_url": "https://api.github.com/orgs/{org}",
"organization_repositories_url": "https://api.github.com/orgs/{org}/repos{?type,page,per_page,sort}",
"organization_teams_url": "https://api.github.com/orgs/{org}/teams",
"public_gists_url": "https://api.github.com/gists/public",
"rate_limit_url": "https://api.github.com/rate_limit",
"repository_url": "https://api.github.com/repos/{owner}/{repo}",
"repository_search_url": "https://api.github.com/search/repositories?q={query}{&page,per_page,sort,order}",
"current_user_repositories_url": "https://api.github.com/user/repos{?type,page,per_page,sort}",
"starred_url": "https://api.github.com/user/starred{/owner}{/repo}",
"starred_gists_url": "https://api.github.com/gists/starred",
"topic_search_url": "https://api.github.com/search/topics?q={query}{&page,per_page}",
"user_url": "https://api.github.com/users/{user}",
"user_organizations_url": "https://api.github.com/user/orgs",
"user_repositories_url": "https://api.github.com/users/{user}/repos{?type,page,per_page,sort}",
"user_search_url": "https://api.github.com/search/users?q={query}{&page,per_page,sort,order}",
})
}

View File

@@ -0,0 +1,158 @@
package copilot
import (
"fmt"
"github.com/gin-gonic/gin"
"log"
"os"
"ripper/internal/middleware"
"strconv"
)
type Config struct {
ClientType string
CopilotProxyAll bool
}
// loadConfig loads the configuration from environment variables.
func loadConfig() (*Config, error) {
proxyAll, err := strconv.ParseBool(os.Getenv("COPILOT_PROXY_ALL"))
if err != nil {
return nil, fmt.Errorf("invalid boolean value for COPILOT_PROXY_ALL: %v", err)
}
return &Config{
ClientType: os.Getenv("COPILOT_CLIENT_TYPE"),
CopilotProxyAll: proxyAll,
}, nil
}
// GinApi 注册路由
func GinApi(g *gin.RouterGroup) {
config, err := loadConfig()
if err != nil {
log.Fatal(err)
}
// 基础路由
setupBasicRoutes(g, config)
// 用户相关路由
setupUserRoutes(g)
// Copilot相关路由
setupCopilotRoutes(g, config)
// API v3相关路由
setupV3Routes(g)
}
// setupBasicRoutes 设置基础路由
func setupBasicRoutes(g *gin.RouterGroup, config *Config) {
g.GET("/models", createModelsHandler(config))
g.GET("/_ping", GetPing)
g.POST("/telemetry", PostTelemetry)
g.GET("/agents", GetAgents)
g.GET("/copilot_internal/user", GetCopilotInternalUser)
}
// setupUserRoutes 设置用户相关路由
func setupUserRoutes(g *gin.RouterGroup) {
authMiddleware := middleware.AccessTokenCheckAuth()
userGroup := g.Group("")
userGroup.Use(authMiddleware)
{
userGroup.GET("/user", GetLoginUser)
userGroup.GET("/user/orgs", GetUserOrgs)
userGroup.GET("/api/v3/user", GetLoginUser)
userGroup.GET("/api/v3/user/orgs", GetUserOrgs)
userGroup.GET("/teams/:teamID/memberships/:username", GetMembership)
userGroup.POST("/chunks", HandleChunks)
}
}
// setupCopilotRoutes 设置Copilot相关路由
func setupCopilotRoutes(g *gin.RouterGroup, config *Config) {
tokenMiddleware := middleware.TokenCheckAuth()
// Copilot token endpoint
g.GET("/copilot_internal/v2/token",
middleware.AccessTokenCheckAuth(),
createTokenHandler(config))
// Completions endpoints
completionsGroup := g.Group("")
completionsGroup.Use(tokenMiddleware)
{
completionsGroup.POST("/v1/engines/:model-name/completions", createCompletionsHandler(config))
completionsGroup.POST("/v1/engines/copilot-codex", createCompletionsHandler(config))
completionsGroup.POST("/chat/completions", createChatHandler(config))
completionsGroup.POST("/agents/chat", createChatHandler(config))
completionsGroup.POST("/v1/chat/completions", createChatHandler(config))
completionsGroup.POST("/v1/engines/copilot-centralus-h100/speculation", createChatEditCompletionsHandler(config))
completionsGroup.POST("/embeddings", HandleEmbeddings)
}
}
// setupV3Routes 设置API v3相关路由
func setupV3Routes(g *gin.RouterGroup) {
g.GET("/api/v3/meta", V3meta)
g.GET("/api/v3/", Cliv3)
g.GET("/", Cliv3)
}
// 处理函数生成器
func createTokenHandler(config *Config) gin.HandlerFunc {
return func(c *gin.Context) {
if config.ClientType == "github" && !config.CopilotProxyAll {
GetCopilotInternalV2Token(c)
} else {
GetDisguiseCopilotInternalV2Token(c)
}
}
}
// createCompletionsHandler 生成代码补全处理函数
func createCompletionsHandler(config *Config) gin.HandlerFunc {
return func(c *gin.Context) {
if config.ClientType == "github" && config.CopilotProxyAll {
CodexCompletions(c)
} else {
CodeCompletions(c)
}
}
}
// createChatHandler 生成聊天补全处理函数
func createChatHandler(config *Config) gin.HandlerFunc {
return func(c *gin.Context) {
if config.ClientType == "github" && config.CopilotProxyAll {
ChatsCompletions(c)
} else {
ChatCompletions(c)
}
}
}
// createChatEditCompletionsHandler 生成聊天编辑补全处理函数
func createChatEditCompletionsHandler(config *Config) gin.HandlerFunc {
return func(c *gin.Context) {
if config.ClientType == "github" && config.CopilotProxyAll {
ChatEditCompletions(c)
} else {
CodeCompletions(c)
}
}
}
// createModelsHandler 生成模型处理函数
func createModelsHandler(config *Config) gin.HandlerFunc {
return func(c *gin.Context) {
if config.ClientType == "github" && config.CopilotProxyAll {
GetCopilotModels(c)
} else {
GetModels(c)
}
}
}

View File

@@ -0,0 +1,20 @@
package copilot
import (
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
"net/http"
)
// PostTelemetry 接收并处理来自GitHub Copilot的遥测数据
func PostTelemetry(c *gin.Context) {
requestID := uuid.Must(uuid.NewV4()).String()
c.Header("x-github-request-id", requestID)
c.JSON(http.StatusOK, gin.H{
"itemsReceived": 0,
"itemsAccepted": 0,
"appId": nil,
"errors": []string{},
})
}

View File

@@ -0,0 +1,109 @@
package copilot
import (
"crypto/md5"
"encoding/hex"
"fmt"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
"math/rand"
"net/http"
"ripper/internal/middleware"
jwtpkg "ripper/pkg/jwt"
"time"
)
// GetLoginUser 获取登录用户信息
func GetLoginUser(ctx *gin.Context) {
userDisplayName := "github"
token, _ := jwtpkg.GetJwtProto(ctx, &middleware.UserLoad{})
if token != nil && token.UserDisplayName != "" {
userDisplayName = token.UserDisplayName
}
ctx.Header("X-OAuth-Scopes", "gist, read:org, repo, user, workflow, write:public_key")
requestID := uuid.Must(uuid.NewV4()).String()
ctx.Header("x-github-request-id", requestID)
ctx.JSON(http.StatusOK, gin.H{
"login": userDisplayName,
"id": 9919,
"node_id": "DEyOk9yZ2FuaXphdGlvbjk5MTk=",
"avatar_url": "https://avatars.githubusercontent.com/u/9919?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/github",
"html_url": "https://github.com/github",
"followers_url": "https://api.github.com/users/github/followers",
"following_url": "https://api.github.com/users/github/following{/other_user}",
"gists_url": "https://api.github.com/users/github/gists{/gist_id}",
"starred_url": "https://api.github.com/users/github/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/github/subscriptions",
"organizations_url": "https://api.github.com/users/github/orgs",
"repos_url": "https://api.github.com/users/github/repos",
"events_url": "https://api.github.com/users/github/events{/privacy}",
"received_events_url": "https://api.github.com/users/github/received_events",
"type": "User",
"site_admin": false,
"name": "GitHub",
"company": nil,
"blog": "",
"location": "San Francisco, CA",
"email": nil,
"hireable": nil,
"bio": nil,
"twitter_username": nil,
"public_repos": 498,
"public_gists": 0,
"followers": 42848,
"following": 0,
"created_at": "2008-05-11T04:37:31Z",
"updated_at": "2022-11-29T19:44:55Z",
})
}
func GetUserOrgs(ctx *gin.Context) {
ctx.Header("X-OAuth-Scopes", "gist, read:org, repo, user, workflow, write:public_key")
ctx.JSON(http.StatusOK, []interface{}{})
}
// generateTrackingID 生成模拟的 analytics_tracking_id
func generateTrackingID() string {
// 生成一个随机字符串并计算其 MD5
randomStr := fmt.Sprintf("%d%d", time.Now().UnixNano(), rand.Int())
hash := md5.Sum([]byte(randomStr))
return hex.EncodeToString(hash[:])
}
// generateAssignedDate 生成模拟的 assigned_date
func generateAssignedDate() string {
// 生成最近30天内的随机时间
now := time.Now()
daysAgo := rand.Intn(30)
randomTime := now.AddDate(0, 0, -daysAgo)
// 随机增加小时和分钟
randomHour := rand.Intn(24)
randomMinute := rand.Intn(60)
randomTime = randomTime.Add(time.Duration(randomHour) * time.Hour)
randomTime = randomTime.Add(time.Duration(randomMinute) * time.Minute)
// 返回格式化的时间字符串
return randomTime.Format(time.RFC3339)
}
// GetCopilotInternalUser 获取 Copilot 内部用户信息
func GetCopilotInternalUser(ctx *gin.Context) {
requestID := uuid.Must(uuid.NewV4()).String()
ctx.Header("x-github-request-id", requestID)
ctx.JSON(http.StatusOK, gin.H{
"access_type_sku": "free_educational",
"copilot_plan": "individual",
"analytics_tracking_id": generateTrackingID(),
"assigned_date": generateAssignedDate(),
"can_signup_for_limited": false,
"chat_enabled": true,
"organization_login_list": []interface{}{},
"organization_list": []interface{}{},
})
}

View File

@@ -0,0 +1,78 @@
package copilot
import (
_ "embed"
"encoding/json"
"github.com/gofrs/uuid"
"io"
"log"
"net/http"
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
)
type Pong struct {
Now int `json:"now"`
Status string `json:"status"`
Ns1 string `json:"ns1"`
}
// GetPing 模拟ping接口
func GetPing(ctx *gin.Context) {
requestID := uuid.Must(uuid.NewV4()).String()
ctx.Header("x-github-request-id", requestID)
ctx.JSON(http.StatusOK, Pong{
Now: time.Now().Second(),
Status: "ok",
Ns1: "200 OK",
})
}
// ModelsResponse 模型列表响应结构
type ModelsResponse struct {
Data []interface{} `json:"data"`
Object string `json:"object"`
}
// GetModels 获取模型列表
func GetModels(ctx *gin.Context) {
// 从根目录下读取models.json文件
jsonFile, err := os.Open(filepath.Join("models.json"))
if err != nil {
log.Printf("无法打开models.json文件: %v", err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "无法读取模型列表数据"})
return
}
defer CloseIO(jsonFile)
// 解析JSON数据
jsonData, err := io.ReadAll(jsonFile)
if err != nil {
log.Printf("读取models.json内容失败: %v", err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "无法读取模型列表数据"})
return
}
var modelsResponse ModelsResponse
if err := json.Unmarshal(jsonData, &modelsResponse); err != nil {
log.Printf("解析models.json失败: %v", err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "无法解析模型列表数据"})
return
}
// 返回模型列表数据
requestID := uuid.Must(uuid.NewV4()).String()
ctx.Header("x-github-request-id", requestID)
ctx.JSON(http.StatusOK, modelsResponse)
}
func CloseIO(c io.Closer) {
err := c.Close()
if nil != err {
log.Println(err)
}
}

206
internal/middleware/auth.go Normal file
View File

@@ -0,0 +1,206 @@
package middleware
import (
"fmt"
"github.com/gin-gonic/gin"
"net/http"
"os"
"ripper/internal/app/github_auth"
"ripper/internal/response"
jwtpkg "ripper/pkg/jwt"
"strconv"
"strings"
"time"
)
type OAuthCheck struct {
ClientId string `json:"client_id" form:"client_id"`
DeviceCode string `json:"device_code" form:"device_code"`
GrantType string `json:"grant_type" form:"grant_type"`
}
func DeviceCodeCheckAuth(ctx *gin.Context) {
checkInfo := &OAuthCheck{}
if err := ctx.ShouldBind(&checkInfo); err != nil {
response.FailJson(ctx, response.FailStruct{
Code: -1,
Msg: "Invalid client id.",
}, false)
ctx.Abort()
return
}
info, _ := github_auth.GetClientAuthInfoByDeviceCode(checkInfo.DeviceCode)
if info.CardCode == "" {
ctx.JSON(http.StatusOK, gin.H{
"error": "authorization_pending",
"error_description": "The authorization request is still pending.",
"error_uri": "https://docs.github.com/developers/apps/authorizing-oauth-apps#error-codes-for-the-device-flow",
})
ctx.Abort()
return
}
ctx.Set("client_auth_info", info)
ctx.Next()
}
func AuthCodeFlowCheckAuth(ctx *gin.Context) {
checkInfoClient := &github_auth.ClientOAuthInfo{}
err := ctx.Bind(&checkInfoClient)
if err != nil {
response.FailJson(ctx, response.FailStruct{
Code: -1,
Msg: "Invalid client id.",
}, false)
ctx.Abort()
return
}
oauthCodeInfo, err := github_auth.GetOAuthCodeInfoByClientIdAndCode(checkInfoClient.ClientId, checkInfoClient.Code)
if err != nil {
response.FailJson(ctx, response.FailStruct{
Code: -1,
Msg: "Invalid client id.",
}, false)
ctx.Abort()
return
}
ctx.Set("client_auth_info", oauthCodeInfo)
ctx.Next()
}
func AccessTokenCheckAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.Request.Header.Get("Authorization")
if token == "" {
response.FailJsonAndStatusCode(c, http.StatusForbidden, response.NoAccess, false)
c.Abort()
return
}
last := strings.Index(token, " ")
if len(token) < last || last == -1 {
response.FailJsonAndStatusCode(c, http.StatusForbidden, response.TokenWrongful, false)
c.Abort()
return
}
token = token[last+1:]
chk, jwter, err := jwtpkg.CheckToken(token, &UserLoad{}, "user")
if err != nil {
errmsg := response.TokenWrongful
errmsg.Msg = "令牌验证错误"
response.FailJsonAndStatusCode(c, http.StatusForbidden, errmsg, true, err.Error())
c.Abort()
return
}
if !chk {
response.FailJsonAndStatusCode(c, http.StatusForbidden, response.NoAccess, true, "破损令牌")
c.Abort()
return
}
chs := true
issuerStr := ""
issuerStr, err = jwter.GetIssuer()
if err != nil {
chs = false
c.Abort()
return
}
if "user" != issuerStr && issuerStr != "" {
chs = false
c.Abort()
return
}
if !chs {
errmsg := response.TokenWrongful
errmsg.Msg = "签名错误"
response.FailJsonAndStatusCode(c, http.StatusForbidden, errmsg, true, err.Error())
c.Abort()
return
}
c.Set("token", jwter)
c.Set("tokenStr", token)
c.Set("token.issuer", issuerStr)
c.Next()
}
}
func TokenCheckAuth() gin.HandlerFunc {
return func(c *gin.Context) {
clientType := os.Getenv("COPILOT_CLIENT_TYPE")
copilotProxyAll, err := strconv.ParseBool(os.Getenv("COPILOT_PROXY_ALL"))
if clientType == "github" && !copilotProxyAll {
c.Next()
return
}
token := c.Request.Header.Get("Authorization")
if token == "" {
response.FailJsonAndStatusCode(c, http.StatusUnauthorized, response.TokenWrongful, false)
c.Abort()
return
}
last := strings.Index(token, " ")
if len(token) < last || last == -1 {
response.FailJsonAndStatusCode(c, http.StatusUnauthorized, response.TokenWrongful, false)
c.Abort()
return
}
token = token[last+1:]
parsedToken := parseAuthorizationToken(token)
// 校验exp是否过期
expired, err := isExpired(parsedToken["exp"])
if err != nil {
response.FailJsonAndStatusCode(c, http.StatusUnauthorized, response.TokenWrongful, false)
c.Abort()
return
} else {
if expired {
response.FailJsonAndStatusCode(c, http.StatusUnauthorized, response.TokenOverdue, false)
c.Abort()
return
}
}
rawToken := github_auth.JsonMap2Token(map[string]interface{}{
"tid": parsedToken["tid"],
"exp": parsedToken["exp"],
"sku": parsedToken["sku"],
"st": parsedToken["st"],
"chat": parsedToken["chat"],
"u": parsedToken["u"],
})
sign := "1:" + github_auth.Token2Sign(rawToken)
if sign != parsedToken["8kp"] {
response.FailJsonAndStatusCode(c, http.StatusUnauthorized, response.TokenWrongful, false)
c.Abort()
return
}
c.Next()
}
}
func parseAuthorizationToken(token string) map[string]string {
result := make(map[string]string)
pairs := strings.Split(token, ";")
for _, pair := range pairs {
kv := strings.SplitN(pair, "=", 2)
if len(kv) == 2 {
key := kv[0]
value := kv[1]
if key == "tid" || key == "exp" || key == "sku" || key == "st" || key == "8kp" || key == "chat" || key == "u" {
result[key] = value
}
}
}
return result
}
func isExpired(expStr string) (bool, error) {
exp, err := strconv.ParseInt(expStr, 10, 64)
if err != nil {
return false, fmt.Errorf("invalid exp timestamp: %v", err)
}
now := time.Now().Unix()
return now > exp, nil
}

View File

@@ -0,0 +1,30 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
//Cors 跨域中间件
func Cors() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.Request.Header.Get("origin") //请求头部
if len(origin) == 0 {
origin = c.Request.Header.Get("Origin")
}
//接收客户端发送的origin (重要!)
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
//允许客户端传递校验信息比如 cookie (重要)
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
//服务器支持的所有跨域请求的方法
c.Writer.Header().Set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PUT, DELETE, UPDATE")
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
// 设置预验请求有效期为 86400 秒
c.Writer.Header().Set("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(200)
return
}
c.Next()
}
}

102
internal/middleware/jwt.go Normal file
View File

@@ -0,0 +1,102 @@
package middleware
import (
"github.com/gin-gonic/gin"
"ripper/internal/response"
jwtpkg "ripper/pkg/jwt"
)
// JWTCheck 检查是否登陆
// 检查完毕会将jwt结构体写入到Context
// 适用于同时用于公开与鉴权的路由
func JWTCheck(c *gin.Context, model jwtpkg.LoadModel, issure ...string) (bool, error) {
token := c.Request.Header.Get("Authorization")
if token == "" {
return false, nil
}
if len(token) < 8 {
return false, nil
}
token = token[7:]
chk, jwter, err := jwtpkg.CheckToken(token, model, "")
if err != nil {
return false, err
}
chs := true
for _, v := range issure {
jwt, err := jwter.GetIssuer()
if err != nil {
chs = false
break
}
if v != jwt {
chs = false
break
}
}
if !chs {
return false, nil
}
if !chk {
return false, nil
}
c.Set("token", jwter)
c.Next()
return true, nil
}
// JWTAuth 为JWT中间件客户端下需要在header带上Authorization: Bearer <token>
// issure 为可选验证签名,支持多参选择
func JWTAuth(model jwtpkg.LoadModel, issure ...string) gin.HandlerFunc {
return func(c *gin.Context) {
token := c.Request.Header.Get("Authorization")
if token == "" {
response.FailJson(c, response.NoAccess, false)
c.Abort()
return
}
if len(token) < 8 {
response.FailJson(c, response.TokenWrongful, false)
c.Abort()
return
}
token = token[7:]
chk, jwter, err := jwtpkg.CheckToken(token, model, "")
if err != nil {
errmsg := response.TokenWrongful
errmsg.Msg = "令牌验证错误"
response.FailJson(c, errmsg, true, err.Error())
c.Abort()
return
}
if !chk {
response.FailJson(c, response.NoAccess, true, "破损令牌")
c.Abort()
return
}
chs := true
issuerStr := ""
for _, v := range issure {
issuerStr, err = jwter.GetIssuer()
if err != nil {
chs = false
break
}
if v != issuerStr {
chs = false
break
}
}
if !chs {
errmsg := response.TokenWrongful
errmsg.Msg = "签名错误"
response.FailJson(c, errmsg, true, err.Error())
c.Abort()
return
}
c.Set("token", jwter)
c.Set("tokenStr", token)
c.Set("token.issuer", issuerStr)
c.Next()
}
}

View File

@@ -0,0 +1,23 @@
package middleware
import (
"github.com/golang-jwt/jwt/v5"
jwtPkg "ripper/pkg/jwt"
)
type AdminLoad struct {
Username string `json:"username"`
}
type UserLoad struct {
UserDisplayName string `json:"userDisplayName,omitempty"`
CardCode string `json:"token"`
Client string `json:"client"`
jwt.RegisteredClaims
}
func NewUserLoad(ID uint, ExpiresAt int64, Issuer string) *UserLoad {
return &UserLoad{
RegisteredClaims: jwtPkg.CreateStandardClaims(ExpiresAt, Issuer),
}
}

View File

@@ -0,0 +1 @@
package middleware

View File

@@ -0,0 +1,72 @@
package response
/*
code约定:
code代表错误无错误始终为0
200 OK - [GET]:服务器成功返回用户请求的数据;
201 CREATED - [POST/PUT/PATCH]:用户新建或修改数据成功;
202 Accepted - [*]:表示一个请求已经进入后台排队(异步任务);
204 NO CONTENT - [DELETE]:用户删除数据成功;
400 INVALID REQUEST - [POST/PUT/PATCH]:用户发出的请求有错误,服务器没有进行新建或修改数据的操作;
401 Unauthorized - [*]:表示用户没有权限(令牌、用户名、密码错误);
403 Forbidden - [*] 表示用户得到授权与401错误相对但是访问是被禁止的
404 NOT FOUND - [*]:用户发出的请求针对的是不存在的记录,服务器没有进行操作;
406 Not Acceptable - [GET]:用户请求的格式不可得;
410 Gone -[GET]:用户请求的资源被永久删除,且不会再得到的;
422 Unprocesable entity - [POST/PUT/PATCH] 当创建一个对象时,发生一个验证错误;
500 INTERNAL SERVER ERROR - [*]:服务器发生错误,用户将无法判断发出的请求是否成功。
*/
var (
NoAccess = FailStruct{
Code: 401,
Msg: "无权访问",
}
TokenWrongful = FailStruct{
Code: 401,
Msg: "Token不合法",
}
TokenOverdue = FailStruct{
Code: 401,
Msg: "Token过期",
}
NoIntactParameters = FailStruct{
Code: -10001,
Msg: "参数提交不完整,请重试",
}
UserError = FailStruct{
Code: 10001,
Msg: "帐号或密码错误",
}
SignError = FailStruct{
Code: 10002,
Msg: "",
}
CaptchaError = FailStruct{
Code: 10003,
Msg: "生成验证码错误",
}
CaptchaVefError = FailStruct{
Code: 10004,
Msg: "验证码错误",
}
WechatLoginError = FailStruct{
Code: 10005,
Msg: "微信登陆错误",
}
)
type FailStruct struct {
Code int
Msg string
}
type Message struct {
ErrCode int `json:"error"`
Data interface{} `json:"data"`
Msg string `json:"message"`
}
type Token struct {
Token string `json:"token"`
}

View File

@@ -0,0 +1,62 @@
package response
import (
"errors"
"github.com/gin-gonic/gin"
"net/http"
)
/*
HTTP状态码约定
服务器访问正常始终200,错误交给code
*/
func BindStruct(c *gin.Context, bind interface{}) error {
if err := c.ShouldBindJSON(bind); err != nil {
FailJson(c, NoIntactParameters, false, "结构体绑定错误")
return errors.New("BindError")
}
return nil
}
func SuccessJson(c *gin.Context, msg string, data ...interface{}) {
var tmps interface{}
if len(data) > 0 {
tmps = data[0]
}
c.JSON(http.StatusOK, Message{
ErrCode: 0,
Data: tmps,
Msg: msg,
})
}
func FailJson(c *gin.Context, load FailStruct, WriteLog bool, logMsh ...string) {
if WriteLog {
var werrmsg string
for _, v := range logMsh {
werrmsg += v + "\n"
}
}
c.JSON(http.StatusOK, Message{
ErrCode: load.Code,
Msg: load.Msg,
})
}
func FailJsonAndStatusCode(c *gin.Context, code int, load FailStruct, WriteLog bool, logMsh ...string) {
if WriteLog {
var werrmsg string
for _, v := range logMsh {
werrmsg += v + "\n"
}
}
c.JSON(code, Message{
ErrCode: load.Code,
Msg: load.Msg,
})
}
func SuccessByte(c *gin.Context, data []byte) {
c.Writer.Write(data)
}

View File

@@ -0,0 +1,25 @@
package router
import (
"github.com/gin-gonic/gin"
"html/template"
authApi "ripper/internal/controller/auth"
"ripper/internal/controller/copilot"
"ripper/internal/middleware"
"ripper/static"
)
func NewHTTPRouter(r *gin.Engine) {
rootRouter := r.Group("/")
tmpl := template.Must(template.New("").ParseFS(static.Public, "public/*.html"))
r.SetHTMLTemplate(tmpl)
apiRouter := r.Group("/api")
rootRouter.Use(middleware.Cors())
apiRouter.Use(middleware.Cors())
authApi.GinApi(rootRouter)
copilot.GinApi(rootRouter)
}

242
main.go Normal file
View File

@@ -0,0 +1,242 @@
package main
import (
"context"
"crypto/tls"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"ripper/pkg/certificate"
"ripper/pkg/message"
"strconv"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"golang.org/x/sync/errgroup"
"ripper/internal/router"
)
// 检查端口是否被占用,如果被占用则退出程序
func checkPortAndExit(host string, port int) {
addr := fmt.Sprintf("%s:%d", host, port)
conn, err := net.Listen("tcp", addr)
if err != nil {
log.Fatalf("端口: %d 已被占用, 运行结束!", port)
}
conn.Close()
}
func main() {
// 设置日志输出
setupLogging()
// 在非生产环境中加载 .env 文件
if os.Getenv("ENV") != "production" {
if err := godotenv.Load(); err != nil {
log.Printf("Warning: Error loading .env file: %v", err)
}
}
log.Println("Current Environment: ", os.Getenv("ENV"))
// 设置默认环境变量
initDefaultEnv()
r := gin.Default()
// 添加 HSTS 中间件
r.Use(func(c *gin.Context) {
c.Header("Strict-Transport-Security", "max-age=0")
c.Next()
})
//初始化router
router.NewHTTPRouter(r)
//获取配置
httpPort, _ := strconv.Atoi(os.Getenv("PORT"))
httpsPort, _ := strconv.Atoi(os.Getenv("HTTPS_PORT"))
host := os.Getenv("HOST")
// 初始化证书
certFile, keyFile, reloadChan, err := certificate.InitCertificates()
if err != nil {
log.Fatalf("Failed to initialize certificates: %v", err)
}
// 检查端口是否被占用
checkPortAndExit(host, httpPort)
checkPortAndExit(host, httpsPort)
// 创建一个带取消功能的上下文
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 创建一个错误组
g, groupCtx := errgroup.WithContext(ctx)
// 创建一个通道来表示服务器已经启动
serverStarted := make(chan struct{}, 2)
// 启动HTTP服务器
httpServer := &http.Server{
Addr: fmt.Sprintf("%s:%d", host, httpPort),
Handler: r,
}
g.Go(func() error {
log.Printf("Starting HTTP server on %s\n", httpServer.Addr)
serverStarted <- struct{}{}
return httpServer.ListenAndServe()
})
// 创建一个函数来启动HTTPS服务器
var httpsServer *http.Server
startHTTPSServer := func() *http.Server {
server := &http.Server{
Addr: fmt.Sprintf("%s:%d", host, httpsPort),
Handler: r,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS10,
MaxVersion: tls.VersionTLS13,
},
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
}
g.Go(func() error {
log.Printf("Starting HTTPS server on %s\n", server.Addr)
if httpsServer == nil { // 仅在首次启动时发送信号
serverStarted <- struct{}{}
}
return server.ListenAndServeTLS(certFile, keyFile)
})
return server
}
// 启动初始HTTPS服务器
httpsServer = startHTTPSServer()
// 等待两个服务器都启动
<-serverStarted
<-serverStarted
// 显示消息或消息框
message.ShowAppLaunchMessage()
// 监听证书更新和关闭信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// 处理证书更新和服务器关闭
go func() {
for {
select {
case <-reloadChan:
log.Println("Certificate update detected, reloading HTTPS server...")
// 创建关闭超时上下文
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
// 关闭当前的HTTPS服务器
if err := httpsServer.Shutdown(shutdownCtx); err != nil {
log.Printf("Error shutting down HTTPS server: %v", err)
}
shutdownCancel()
// 启动新的HTTPS服务器
httpsServer = startHTTPSServer()
case <-quit:
log.Println("Shutdown signal received, exiting...")
cancel()
return
case <-groupCtx.Done():
log.Println("Unexpected exit, trying to shutdown gracefully...")
cancel()
return
}
}
}()
// 等待取消信号
<-ctx.Done()
// 给服务器一些时间来完成正在处理的请求
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
// 优雅地关闭服务器
if err := httpServer.Shutdown(shutdownCtx); err != nil {
log.Printf("HTTP server Shutdown: %v", err)
}
if err := httpsServer.Shutdown(shutdownCtx); err != nil {
log.Printf("HTTPS server Shutdown: %v", err)
}
// 等待所有 goroutine 完成
if err := g.Wait(); err != nil && err != http.ErrServerClosed {
log.Printf("Error during server operations: %v", err)
}
}
func setupLogging() {
// 创建日志目录
logDir := "logs"
err := os.MkdirAll(logDir, 0755)
if err != nil {
log.Fatal("无法创建日志目录:", err)
}
// 创建日志文件,使用当前日期作为文件名
currentTime := time.Now()
logFileName := filepath.Join(logDir, fmt.Sprintf("%s.log", currentTime.Format("2006-01-02")))
logFile, err := os.OpenFile(logFileName, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
log.Fatal("无法创建日志文件:", err)
}
// 设置 gin 的日志输出到文件和控制台
gin.DefaultWriter = io.MultiWriter(logFile, os.Stdout)
// 设置标准日志输出到文件和控制台
log.SetOutput(io.MultiWriter(logFile, os.Stdout))
log.SetPrefix("[Copilot Proxies] ")
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
}
// initDefaultEnv 初始化默认环境变量
func initDefaultEnv() {
if os.Getenv("COPILOT_PROXY_ALL") == "" {
os.Setenv("COPILOT_PROXY_ALL", "false")
}
if os.Getenv("COPILOT_CLIENT_TYPE") == "" {
os.Setenv("COPILOT_CLIENT_TYPE", "default")
}
if os.Getenv("DISGUISE_COPILOT_TOKEN_EXPIRES_AT") == "" {
os.Setenv("DISGUISE_COPILOT_TOKEN_EXPIRES_AT", "1800")
}
if os.Getenv("HTTP_CLIENT_TIMEOUT") == "" {
os.Setenv("DISGUISE_COPILOT_TOKEN_EXPIRES_AT", "60")
}
if os.Getenv("COPILOT_ACCOUNT_TYPE") == "" {
os.Setenv("COPILOT_ACCOUNT_TYPE", "individual")
}
if os.Getenv("LIGHTWEIGHT_MODEL") == "" {
os.Setenv("LIGHTWEIGHT_MODEL", "gpt-4o-mini")
}
}

1012
models.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,153 @@
package certificate
import (
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sync"
"time"
)
const (
certURL = "https://data-1251486259.cos.ap-beijing.myqcloud.com/copilot-ssl/ssl.pem"
keyURL = "https://data-1251486259.cos.ap-beijing.myqcloud.com/copilot-ssl/ssl.key"
certPath = "cert/ssl.pem"
keyPath = "cert/ssl.key"
checkInterval = 1 * time.Hour
)
var (
mutex sync.Mutex
stopChan chan struct{}
httpsServerReload chan struct{} // 用于通知需要重载服务器
)
// InitCertificates 初始化证书管理
func InitCertificates() (string, string, chan struct{}, error) {
// 确保证书目录存在
if err := os.MkdirAll(filepath.Dir(certPath), 0755); err != nil {
return "", "", nil, fmt.Errorf("failed to create cert directory: %v", err)
}
httpsServerReload = make(chan struct{}, 1)
// 首次检查和更新证书
if err := checkAndUpdateCertificates(); err != nil {
return "", "", nil, err
}
// 启动定时检查
startPeriodicCheck()
return certPath, keyPath, httpsServerReload, nil
}
// StopPeriodicCheck 停止定时检查
func StopPeriodicCheck() {
if stopChan != nil {
close(stopChan)
}
}
func startPeriodicCheck() {
stopChan = make(chan struct{})
go func() {
ticker := time.NewTicker(checkInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := checkAndUpdateCertificates(); err != nil {
log.Printf("Error checking certificates: %v", err)
}
case <-stopChan:
return
}
}
}()
}
func checkAndUpdateCertificates() error {
mutex.Lock()
defer mutex.Unlock()
needsUpdate, err := certificateNeedsUpdate()
if err != nil {
return fmt.Errorf("failed to check certificate: %v", err)
}
if needsUpdate {
if err := downloadFile(certURL, certPath); err != nil {
return fmt.Errorf("failed to download certificate: %v", err)
}
if err := downloadFile(keyURL, keyPath); err != nil {
return fmt.Errorf("failed to download key: %v", err)
}
// 通知需要重载服务器
select {
case httpsServerReload <- struct{}{}:
log.Println("Certificates updated, triggering server reload")
default:
// 如果通道已满,说明已经有一个重载信号在等待处理
log.Println("Server reload already pending")
}
}
return nil
}
func certificateNeedsUpdate() (bool, error) {
if !fileExists(certPath) || !fileExists(keyPath) {
return true, nil
}
certData, err := os.ReadFile(certPath)
if err != nil {
return false, fmt.Errorf("failed to read certificate: %v", err)
}
block, _ := pem.Decode(certData)
if block == nil {
return true, nil
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return false, fmt.Errorf("failed to parse certificate: %v", err)
}
return time.Now().Add(24 * time.Hour).After(cert.NotAfter), nil
}
func downloadFile(url string, filepath string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s", resp.Status)
}
out, err := os.Create(filepath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func fileExists(filename string) bool {
_, err := os.Stat(filename)
return !os.IsNotExist(err)
}

96
pkg/crypto/AES.go Normal file
View File

@@ -0,0 +1,96 @@
package crypto
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"errors"
)
//高级加密标准Adevanced Encryption Standard ,AES
//key不能泄露
//PKCS7Padding PKCS7 填充模式
func PKCS7Padding(ciphertext []byte, blockSize int) []byte {
padding := blockSize - len(ciphertext)%blockSize
//Repeat()函数的功能是把切片[]byte{byte(padding)}复制padding个然后合并成新的字节切片返回
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
return append(ciphertext, padtext...)
}
//PKCS7UnPadding 填充的反向操作,删除填充字符串
func PKCS7UnPadding(origData []byte) ([]byte, error) {
//获取数据长度
length := len(origData)
if length == 0 {
return nil, errors.New("加密字符串错误!")
} else {
//获取填充字符串长度
unpadding := int(origData[length-1])
//截取切片,删除填充字节,并且返回明文
return origData[:(length - unpadding)], nil
}
}
//AesEcrypt 实现加密
func AesEcrypt(origData []byte, key []byte) ([]byte, error) {
//创建加密算法实例
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
//获取块的大小
blockSize := block.BlockSize()
//对数据进行填充,让数据长度满足需求
origData = PKCS7Padding(origData, blockSize)
//采用AES加密方法中CBC加密模式
blocMode := cipher.NewCBCEncrypter(block, key[:blockSize])
crypted := make([]byte, len(origData))
//执行加密
blocMode.CryptBlocks(crypted, origData)
return crypted, nil
}
//AesDeCrypt 实现解密
func AesDeCrypt(cypted []byte, key []byte) ([]byte, error) {
//创建加密算法实例
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
//获取块大小
blockSize := block.BlockSize()
//创建加密客户端实例
blockMode := cipher.NewCBCDecrypter(block, key[:blockSize])
origData := make([]byte, len(cypted))
//这个函数也可以用来解密
blockMode.CryptBlocks(origData, cypted)
//去除填充字符串
origData, err = PKCS7UnPadding(origData)
if err != nil {
return nil, err
}
return origData, err
}
//EnPwdCode 加密base64
func EnPwdCode(pwd, PwdKey []byte) (string, error) {
result, err := AesEcrypt(pwd, PwdKey)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(result), err
}
//DePwdCode 解密
func DePwdCode(pwd string, PwdKey []byte) ([]byte, error) {
//解密base64字符串
pwdByte, err := base64.StdEncoding.DecodeString(pwd)
if err != nil {
return nil, err
}
//执行AES解密
return AesDeCrypt(pwdByte, PwdKey)
}

16
pkg/crypto/md5.go Normal file
View File

@@ -0,0 +1,16 @@
package crypto
import (
"crypto/md5"
"encoding/hex"
)
//GetMd5 生成32位md5字串
func GetMd5(s string) string {
if s == "" {
return ""
}
h := md5.New()
h.Write([]byte(s))
return hex.EncodeToString(h.Sum(nil))
}

104
pkg/crypto/sing.go Normal file
View File

@@ -0,0 +1,104 @@
package crypto
import (
"bytes"
"crypto/md5"
"encoding/hex"
"fmt"
"math/rand"
"reflect"
"sort"
"strconv"
"time"
)
// GetSign get the sign info
func GetSign(data interface{}, appSecret string) string {
md5ctx := md5.New()
switch v := reflect.ValueOf(data); v.Kind() {
case reflect.String:
md5ctx.Write([]byte(v.String() + appSecret))
return hex.EncodeToString(md5ctx.Sum(nil))
case reflect.Struct:
orderStr := StructToMapSing(v.Interface(), appSecret)
md5ctx.Write([]byte(orderStr))
return hex.EncodeToString(md5ctx.Sum(nil))
case reflect.Ptr:
originType := v.Elem().Type()
if originType.Kind() != reflect.Struct {
return ""
}
dataType := reflect.TypeOf(data).Elem()
dataVal := v.Elem()
orderStr := buildOrderStr(dataType, dataVal, appSecret)
md5ctx.Write([]byte(orderStr))
return hex.EncodeToString(md5ctx.Sum(nil))
default:
return ""
}
}
func buildOrderStr(t reflect.Type, v reflect.Value, appSecret string) (returnStr string) {
keys := make([]string, 0, t.NumField())
var data = make(map[string]interface{})
for i := 0; i < t.NumField(); i++ {
if t.Field(i).Tag.Get("json") == "sign" {
continue
}
data[t.Field(i).Tag.Get("json")] = v.Field(i).Interface()
keys = append(keys, t.Field(i).Tag.Get("json"))
}
sort.Sort(sort.StringSlice(keys))
var buf bytes.Buffer
for _, k := range keys {
if data[k] == "" {
continue
}
if buf.Len() > 0 {
buf.WriteByte('&')
}
buf.WriteString(k)
buf.WriteByte('=')
switch vv := data[k].(type) {
case string:
buf.WriteString(vv)
case int:
case int8:
case int16:
case int32:
case int64:
buf.WriteString(strconv.FormatInt(int64(vv), 10))
default:
continue
}
}
buf.WriteString("&secret=" + appSecret)
returnStr = buf.String()
return returnStr
}
func StructToMapSing(content interface{}, appSecret string) (returnStr string) {
t := reflect.TypeOf(content)
v := reflect.ValueOf(content)
returnStr = buildOrderStr(t, v, appSecret)
return returnStr
}
func EnSign(query, body, key string) string {
//加密算法
//r随机数
//t时间戳
//qquery参数md5
//bBody参数md5
//k密钥
//组合成 k=%s&r=%d&t=%d&q=%s&b=%s
//进行md5
//最后组合成 r,t,md5
rand.Seed(time.Now().Unix())
r := rand.Intn(800000) + 100000
t := time.Now().Unix()
str := fmt.Sprintf("k=%s&r=%d&t=%d&q=%s&b=%s", key, r, t, GetMd5(query), GetMd5(body))
return fmt.Sprintf("%d,%d,%s", r, t, GetSign(str, key))
}

15
pkg/integral/file.go Normal file
View File

@@ -0,0 +1,15 @@
package integral
import "os"
//PathExists 路径是否存在
func PathExists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}

179
pkg/jwt/core.go Normal file
View File

@@ -0,0 +1,179 @@
package jwt
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"os"
"reflect"
"time"
)
// LoadModel 成员中套上jwt.RegisteredClaims
type LoadModel interface {
GetExpirationTime() (*jwt.NumericDate, error)
GetIssuedAt() (*jwt.NumericDate, error)
GetNotBefore() (*jwt.NumericDate, error)
GetIssuer() (string, error)
GetSubject() (string, error)
GetAudience() (jwt.ClaimStrings, error)
}
// JWT jwt对象
type JWT struct {
// 声明签名信息
SigningKey []byte
}
// NewJWT 初始化jwt对象
func NewJWT() *JWT {
return &JWT{
[]byte(os.Getenv("TOKEN_SALT")),
}
}
// CreateToken 调用jwt-go库生成token,编码的算法为jwt.SigningMethodHS256
func (j *JWT) CreateToken(payload jwt.Claims) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, payload)
return token.SignedString(j.SigningKey)
}
// ParserToken 将token解码并验证
func (j *JWT) ParserToken(tokenString string, model LoadModel) (jwt.Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, model, func(token *jwt.Token) (interface{}, error) {
return j.SigningKey, nil
})
if err != nil {
return nil, err
}
_, err = parserToken(token, model)
if token.Valid {
return token.Claims, nil
}
return nil, fmt.Errorf("token无效")
}
func parserToken[T LoadModel](token *jwt.Token, model T) (jwt.Claims, error) {
if claims, ok := token.Claims.(T); !ok {
return nil, fmt.Errorf("token结构错误")
} else {
return claims, nil
}
}
// CreateToken 用于快速生成一个Token
// UserLoad 用户负载
// ExpiresAt 多少秒之后过期,单位:秒
// Issuer 签名颁发着
func CreateToken(JWTLoad jwt.Claims) (Token string, err error) {
// 构造SignKey: 签名和解签名需要使用一个值
j := NewJWT()
// 构造用户claims信息(负荷)
token, err := j.CreateToken(JWTLoad)
if err != nil {
return "", err
}
return token, nil
}
// CreateStandardClaims 快速创建签名数据
func CreateStandardClaims(ExpiresAt int64, Issuer string) jwt.RegisteredClaims {
return jwt.RegisteredClaims{
Issuer: Issuer, // 签名颁发者
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second * time.Duration(ExpiresAt))), // 签名过期时间
IssuedAt: jwt.NewNumericDate(time.Now()), //
NotBefore: jwt.NewNumericDate(time.Now()), // 签名生效时间
}
}
// CheckToken 用于检查Token是否有效
// issuer为可选参数
func CheckToken[T LoadModel](token string, model T, issuer string) (bool, T, error) {
j := NewJWT()
load, err := j.ParserToken(token, model)
if err != nil {
return false, model, err
}
expTime, err := load.GetExpirationTime()
if expTime.Unix() < time.Now().Unix() {
return false, model, errors.New("token已过期请重新登录")
}
loadIssuer, err := load.GetIssuer()
if err != nil {
return false, model, err
}
if issuer == loadIssuer {
return true, load.(T), nil
}
return false, model, errors.New("token签名错误请重新登录")
}
// GetJwtProto 从Gin中获取Jwt原型体
// model为业务内jwt模型
func GetJwtProto[T any](c *gin.Context, model T) (T, error) {
tokener, _ := c.Get("token")
if tokener == nil {
return model, errors.New("JWT Is NULL")
}
token, ok := tokener.(T)
if !ok {
return model, errors.New("JWTLoad Error")
}
return token, nil
}
// GetTokenLoad 用于从GinContext中取回JWTUsermapm
func GetTokenLoad(c *gin.Context) (*JWTLoad, map[string]interface{}) {
token, _ := c.Get("token")
if token == nil {
return nil, nil
}
load := token.(*JWTLoad)
if load.UserLoad == nil {
return nil, nil
}
return load, load.UserLoad.(map[string]interface{})
}
func ShouldBindTokenLoad(c *gin.Context, obj any) error {
var e error
token, _ := c.Get("token")
if token == nil {
e = errors.New("token is invalid")
}
load := token.(*JWTLoad)
if load.UserLoad == nil {
e = errors.New("token is illegal")
}
for k, v := range load.UserLoad.(map[string]interface{}) {
fmt.Println(v, k)
e = SetField(obj, k, v)
if e != nil {
return e
}
}
return nil
}
func SetField(obj interface{}, name string, value interface{}) error {
structValue := reflect.ValueOf(obj).Elem()
structFieldValue := structValue.FieldByName(name)
fmt.Println(structFieldValue)
if !structFieldValue.IsValid() {
return fmt.Errorf("no such field: %s in obj", name)
}
if !structFieldValue.CanSet() {
return fmt.Errorf("cannot set %s field value", name)
}
structFieldType := structFieldValue.Type()
val := reflect.ValueOf(value)
if structFieldType != val.Type() {
return errors.New("provided value type didn't match obj field type")
}
structFieldValue.Set(val)
return nil
}

46
pkg/jwt/examples_test.go Normal file
View File

@@ -0,0 +1,46 @@
package jwt_test
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"ripper/internal/middleware"
"ripper/internal/response"
jwtpkg "ripper/pkg/jwt"
)
// 业务模型
type UserLoad struct {
ID uint `json:"id"`
jwt.RegisteredClaims
}
// 快速创建接口
func newUserLoad(ID uint, ExpiresAt int64, Issuer string) *UserLoad {
return &UserLoad{
ID: ID,
RegisteredClaims: jwtpkg.CreateStandardClaims(ExpiresAt, Issuer),
}
}
func testHandler(c *gin.Context) {
// 获取jwt数据并处理错误
// 可以在中间件完成这一步,根据业务自行扩展
token, err := jwtpkg.GetJwtProto(c, &UserLoad{})
if err != nil {
response.FailJson(c, response.FailStruct{
Code: 401,
Msg: err.Error(),
}, false)
}
fmt.Println(token)
}
func main() {
r := gin.Default()
//使用中间件时绑定业务jwt模型,并且可以设置验证的签发人
r.Use(middleware.JWTAuth(&UserLoad{}, "is"))
r.GET("/test", testHandler)
r.Run()
}

9
pkg/jwt/model.go Normal file
View File

@@ -0,0 +1,9 @@
package jwt
import "github.com/golang-jwt/jwt/v5"
type JWTLoad struct {
UserLoad interface{} `json:"user_load"`
Version int64
jwt.RegisteredClaims
}

5
pkg/logger/error.go Normal file
View File

@@ -0,0 +1,5 @@
package logger
func Error(err error) {
}

View File

@@ -0,0 +1,11 @@
//go:build !windows
package message
import (
"log"
)
func ShowAppLaunchMessage() {
log.Printf("%s: %s\n", "运行成功", "服务已经启动.")
}

View File

@@ -0,0 +1,24 @@
//go:build windows
package message
import (
"syscall"
"unsafe"
)
const (
MB_OK = 0x00000000
MB_ICONINFORMATION = 0x00000040
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
messageBoxW = user32.NewProc("MessageBoxW")
)
func ShowAppLaunchMessage() {
titlePtr, _ := syscall.UTF16PtrFromString("运行成功")
textPtr, _ := syscall.UTF16PtrFromString("服务已经启动, GUI的程序将会以后台方式运行, 如需关闭请手动结束进程.")
messageBoxW.Call(0, uintptr(unsafe.Pointer(textPtr)), uintptr(unsafe.Pointer(titlePtr)), MB_OK|MB_ICONINFORMATION)
}

19
pkg/util/crypto.go Normal file
View File

@@ -0,0 +1,19 @@
package util
import (
"crypto/md5"
"fmt"
"ripper/pkg/crypto"
)
// CheckPassword 用于将用户输入的密码与数据库取出的密码进行比对
func CheckPassword(PlainText, SecretKey, CipherText string) bool {
chK, _ := crypto.AesEcrypt([]byte(PlainText), []byte(SecretKey))
return fmt.Sprintf("%x", md5.Sum(chK)) == CipherText
}
// CreatePassword 用于将用户输入的密码进行加密
func CreatePassword(SecretKey, PlainText string) string {
chK, _ := crypto.AesEcrypt([]byte(PlainText), []byte(SecretKey))
return fmt.Sprintf("%x", md5.Sum(chK))
}

63
pkg/util/encrypt.go Normal file
View File

@@ -0,0 +1,63 @@
package util
import (
"encoding/base64"
"regexp"
"strconv"
"strings"
)
// EmojiEncode Emoji表情转码
func EmojiDecode(s string) string {
//emoji表情的数据表达式
var re *regexp.Regexp
var reg *regexp.Regexp
var src []string
re = regexp.MustCompile("\\[[\\\\u0-9a-zA-Z]")
//提取emoji数据表达式
reg = regexp.MustCompile("\\[\\\\u|]")
src = re.FindAllString(s, -1)
for i := 0; i < len(src); i++ {
var e = reg.ReplaceAllString(src[i], "")
var p, err = strconv.ParseInt(e, 16, 32)
if err == nil {
s = strings.Replace(s, src[i], string(rune(p)), -1)
}
}
return s
}
// 表情转换
func EmojiCode(s string) string {
var ret string
var rs []rune
rs = []rune(s)
for i := 0; i < len(rs); i++ {
if len(string(rs[i])) == 4 {
ret += `[\u` + strconv.FormatInt(int64(rs[i]), 16) + `]`
} else {
ret += string(rs[i])
}
}
return ret
}
// BaseEncode Base64编码
func BaseEncode(s string) string {
data := []byte(s)
dst := make([]byte, base64.StdEncoding.EncodedLen(len(data)))
base64.StdEncoding.Encode(dst, data)
return string(dst)
}
// BaseDecode Base64解码
func BaseDecode(s string) string {
data := []byte(s)
dst := make([]byte, base64.StdEncoding.DecodedLen(len(data)))
n, err := base64.StdEncoding.Decode(dst, data)
if err != nil {
return ""
}
return string(dst[:n])
}

9
pkg/util/memory.go Normal file
View File

@@ -0,0 +1,9 @@
package util
import "unsafe"
func DeepCoyp(s string) string {
b := make([]byte, len(s))
copy(b, s)
return *(*string)(unsafe.Pointer(&b))
}

50
pkg/util/misc.go Normal file
View File

@@ -0,0 +1,50 @@
package util
import (
"github.com/gofrs/uuid"
"math/rand"
"time"
"unsafe"
)
// Ifs 三目运算的函数
func Ifs[T any](a bool, b, c T) T {
if a {
return b
}
return c
}
func GetUUID() (string, error) {
u2, err := uuid.NewV4()
return u2.String(), err
}
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
var src = rand.NewSource(time.Now().UnixNano())
const (
// 6 bits to represent a letter index
letterIdBits = 6
// All 1-bits as many as letterIdBits
letterIdMask = 1<<letterIdBits - 1
letterIdMax = 63 / letterIdBits
)
func RandomStr(n int) string {
b := make([]byte, n)
// A rand.Int63() generates 63 random bits, enough for letterIdMax letters!
for i, cache, remain := n-1, src.Int63(), letterIdMax; i >= 0; {
if remain == 0 {
cache, remain = src.Int63(), letterIdMax
}
if idx := int(cache & letterIdMask); idx < len(letters) {
b[i] = letters[idx]
i--
}
cache >>= letterIdBits
remain--
}
return *(*string)(unsafe.Pointer(&b))
}

236
static/public/code.html Normal file
View File

@@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 Github Copilot</title>
<style>
:root {
--primary-color: #0066CC;
--hover-color: #0256A8;
--background-light: #ffffff;
--text-color: #1d1d1f;
--input-background: #fbfbfd;
--input-border: #d2d2d7;
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
body, html {
height: 100%;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
background-color: var(--background-light);
color: var(--text-color);
}
.login-container {
background-color: var(--background-light);
padding: 3em 4em;
border-radius: 20px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
text-align: center;
width: 90%;
max-width: 440px;
transition: var(--transition);
}
h1 {
font-size: 24px;
font-weight: 500;
margin-bottom: 1.5em;
color: var(--text-color);
}
form {
display: flex;
flex-direction: column;
align-items: center;
}
.input-wrapper {
width: 100%;
display: flex;
justify-content: center;
margin-bottom: 16px;
}
input {
width: 100%;
max-width: 300px;
padding: 12px 16px;
margin: 6px 0;
border: 1px solid var(--input-border);
border-radius: 12px;
background-color: var(--input-background);
font-size: 15px;
transition: var(--transition);
outline: none;
}
input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 4px rgba(0, 102, 204, 0.1);
}
button {
width: 100%;
max-width: 300px;
padding: 12px;
margin-top: 24px;
border: none;
border-radius: 12px;
background-color: var(--primary-color);
color: white;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
}
button:hover {
background-color: var(--hover-color);
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
}
.footer {
margin-top: 32px;
font-size: 13px;
color: #86868b;
}
.footer a {
color: var(--primary-color);
text-decoration: none;
transition: var(--transition);
}
.footer a:hover {
text-decoration: underline;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
:root {
--background-light: #1d1d1f;
--text-color: #f5f5f7;
--input-background: #2c2c2e;
--input-border: #3a3a3c;
}
.login-container {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
}
}
#password {
display: none;
}
</style>
</head>
<body>
<div class="login-container">
<h1>登录 Github Copilot</h1>
<form onsubmit="submitForm()">
<div class="input-wrapper">
<input type="text" id="password"
placeholder="请输入访问密码">
</div>
<div class="input-wrapper">
<input type="text" id="authorization"
placeholder="请输入授权码" autofocus>
</div>
<div class="input-wrapper">
<input type="text" id="displayUserName"
placeholder="GitHub 用户名(可选)">
</div>
<button type="submit">登录</button>
</form>
<div class="footer">
© 2024 Open Source Contributors
</div>
</div>
<script>
async function getLoginConfig() {
const result = await fetch('/login/config');
const resultJson = await result.json();
if (resultJson.is_login_password) {
document.getElementById('password').style.display = 'block';
}
}
getLoginConfig();
function getQueryParam(param) {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(param);
}
function setCodeInputValue(){
const code = getQueryParam('user_code');
if (code !== null) {
document.getElementById('authorization').value = code;
}
}
setCodeInputValue();
async function submitForm() {
event.preventDefault();
const authorization = document.getElementById('authorization').value;
const password = document.getElementById('password').value;
const displayUserName = document.getElementById('displayUserName').value;
const code = getQueryParam('user_code');
if (code === null) {
alert('链接打开方式不正确,请重新打开');
return false;
}
if (authorization === '') {
alert('请输入授权码');
return false;
}
try {
const response = await fetch('/login/device', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
code,
authorization,
password,
displayUserName
})
})
if (!response.ok) {
alert('HTTP error! status: ' + response.status);
return false;
}
const result = await response.json();
if (result?.error !== 0) {
alert(result.message);
return false;
}
alert('登录成功, 请返回IDE查看并使用');
window.close();
} catch (e) {
alert('提交表单时出错,请稍后再试');
}
}
</script>
</body>
</html>

278
static/public/help.html Normal file
View File

@@ -0,0 +1,278 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Copilot 配置指南</title>
<style>
:root {
--primary-color: #0066CC;
--background-light: #ffffff;
--text-color: #1d1d1f;
--code-background: #f5f7fa;
--border-color: #d2d2d7;
--warning-background: #fff7ed;
--warning-border: #fec589;
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Icons', 'Helvetica Neue', sans-serif;
line-height: 1.6;
color: var(--text-color);
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background-color: var(--background-light);
}
h1 {
font-size: 2.5rem;
font-weight: 600;
margin-bottom: 2rem;
color: var(--text-color);
}
h2 {
font-size: 1.5rem;
font-weight: 600;
margin-top: 3rem;
margin-bottom: 1rem;
color: var(--text-color);
}
.step {
margin-bottom: 1rem;
padding-left: 1.5rem;
position: relative;
}
.step::before {
content: "";
position: absolute;
left: 0;
top: 0.5rem;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--primary-color);
}
.step a {
color: var(--primary-color);
text-decoration: none;
transition: var(--transition);
}
pre {
background-color: var(--code-background);
border-radius: 12px;
padding: 1.5rem;
overflow-x: auto;
margin: 1.5rem 0;
font-family: 'SF Mono', Monaco, Consolas, monospace;
font-size: 0.9rem;
line-height: 1.5;
}
code {
font-family: 'SF Mono', Monaco, Consolas, monospace;
background-color: var(--code-background);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.9em;
}
.warning {
background-color: var(--warning-background);
border: 1px solid var(--warning-border);
border-radius: 12px;
padding: 1rem 1.5rem;
margin: 1.5rem 0;
font-size: 0.95rem;
}
.warning::before {
content: "🚨";
margin-right: 0.5rem;
}
.note {
background-color: var(--code-background);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1rem 1.5rem;
margin: 1.5rem 0;
font-size: 0.95rem;
}
.note::before {
content: "🚧";
margin-right: 0.5rem;
}
@media (prefers-color-scheme: dark) {
:root {
--background-light: #1d1d1f;
--text-color: #f5f5f7;
--code-background: #2c2c2e;
--border-color: #3a3a3c;
--warning-background: #3a3123;
--warning-border: #8b5e34;
}
}
@media (max-width: 768px) {
body {
padding: 1rem;
}
h1 {
font-size: 2rem;
}
pre {
padding: 1rem;
}
}
.config-wrapper {
position: relative;
}
.copy-button {
position: absolute;
top: 1rem;
right: 1rem;
padding: 0.5rem 1rem;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
transition: var(--transition);
}
.copy-button:hover {
background-color: var(--hover-color);
}
.dynamic-domain {
color: var(--primary-color);
font-weight: 500;
}
#show-hb {
display: none;
}
#hidden-hb {
display: block;
}
.footer {
display: flex;
justify-content: center;
align-items: center;
margin-top: 32px;
font-size: 13px;
color: #86868b;
}
.footer a {
color: var(--primary-color);
text-decoration: none;
transition: var(--transition);
}
.footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<h1>Copilot 配置指南</h1>
<h2>VSCode</h2>
<div class="step">安装插件: <code>GitHub Copilot</code></div>
<div class="step">修改 VSCode 的 settings.json 文件, 添加以下配置:</div>
<div class="config-wrapper">
<pre id="configCode">{
"github.copilot.advanced": {
"authProvider": "github-enterprise",
"debug.overrideCAPIUrl": "https://api.<span class="dynamic-domain">loading...</span>",
"debug.overrideProxyUrl": "https://copilot-proxy.<span class="dynamic-domain">loading...</span>",
"debug.chatOverrideProxyUrl": "https://api.<span class="dynamic-domain">loading...</span>/chat/completions",
"debug.overrideFastRewriteEngine": "v1/engines/copilot-centralus-h100",
"debug.overrideFastRewriteUrl": "https://api.<span class="dynamic-domain">loading...</span>"
},
"github-enterprise.uri": "https://<span class="dynamic-domain">loading...</span>"
}</pre>
<button id="copyBtn" class="copy-button">复制配置</button>
</div>
<h2>Jetbrains IDE系列</h2>
<div class="step">找到<code>设置</code> > <code>语言与框架</code> > <code>GitHub Copilot</code> > <code>Authentication
Provider</code></div>
<div class="step">填写的值为: <code><span class="dynamic-domain">loading...</span></code></div>
<h2>Visual Studio 2022</h2>
<div class="step">更新到最新版本(内置 Copilot 版本)至少是 <code>17.10.x</code> 以上</div>
<div class="step">首先, 开启 Github Enterprise 账户支持:工具->环境->账户->勾选 <code>包含 Github Enterprise 服务器账户</code></div>
<div class="step">然后, 重启你的 <code>Visual Studio 2022</code> 编辑器</div>
<div class="step">最后, 点击添加 Github 账户,切换到 Github Enterprise 选项卡,输入 <code>https://<span
class="dynamic-domain">loading...</span></code> 即可。
</div>
<h2>HBuilderX</h2>
<div id="show-hb">
<div class="step">点击下载 <code><a href="https://pan.quark.cn/s/70e6849970e5" target="_blank">copilot-for-hbuilderx-v1.zip</a></code>
插件到本地
</div>
<div class="step">将插件安装到 plugin目录下, 具体教程参考: <code><a
href="https://hx.dcloud.net.cn/Tutorial/OfflineInstall" target="_blank">离线插件安装指南</a></code></div>
<div class="step">重启 Hbuilder X 后点击登录 <code>GitHub Copilot</code> 即可.</div>
</div>
<div id="hidden-hb" class="warning">
当前部署方式不支持 HBuilderX
</div>
<div class="footer">
© 2024 Open Source Contributors
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
// 获取当前域名和端口
const currentHost = window.location.host; // 这将获取 "domain:port" 格式
// 只要是默认本地部署的域名,就显示 HBuilderX 的安装步骤
if (currentHost.includes('copilot.supercopilot.top')) {
document.getElementById('show-hb').style.display = 'block';
document.getElementById('hidden-hb').style.display = 'none';
} else {
document.getElementById('show-hb').style.display = 'none';
document.getElementById('hidden-hb').style.display = 'block';
}
// 更新所有需要替换的地方
const domainElements = document.querySelectorAll('.dynamic-domain');
domainElements.forEach(element => {
element.textContent = currentHost;
});
});
document.getElementById('copyBtn').addEventListener('click', function () {
const configText = document.getElementById('configCode').textContent;
navigator.clipboard.writeText(configText).then(() => {
const originalText = this.textContent;
this.textContent = '已复制!';
setTimeout(() => {
this.textContent = originalText;
}, 2000);
});
});
</script>
</body>
</html>

265
static/public/login.html Normal file
View File

@@ -0,0 +1,265 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>获取 GitHub GHU</title>
<style>
:root {
--primary-color: #0066CC;
--hover-color: #0256A8;
--background-light: #ffffff;
--text-color: #1d1d1f;
--input-background: #fbfbfd;
--input-border: #d2d2d7;
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
body, html {
height: 100%;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Icons', 'Helvetica Neue', sans-serif;
background-color: var(--background-light);
color: var(--text-color);
}
.login-container {
background-color: var(--background-light);
padding: 3.5em 4em;
border-radius: 20px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
text-align: center;
width: 90%;
max-width: 440px;
transition: var(--transition);
}
h1 {
font-size: 24px;
font-weight: 500;
margin-bottom: 1.5em;
color: var(--text-color);
}
.form-box {
display: flex;
flex-direction: column;
align-items: center;
}
input {
width: 100%;
padding: 12px 16px;
margin: 6px 0;
border: 1px solid var(--input-border);
border-radius: 12px;
background-color: var(--input-background);
font-size: 15px;
transition: var(--transition);
outline: none;
box-sizing: border-box;
}
input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 4px rgba(0, 102, 204, 0.1);
}
.submit-button {
width: 90%;
padding: 12px;
margin-top: 24px;
border: none;
border-radius: 12px;
background-color: var(--primary-color);
color: white;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
text-align: center;
}
.submit-button:hover {
background-color: var(--hover-color);
transform: translateY(-1px);
}
.submit-button:active {
transform: translateY(0);
}
.footer {
margin-top: 32px;
font-size: 13px;
color: #86868b;
}
.footer a {
color: var(--primary-color);
text-decoration: none;
transition: var(--transition);
}
.footer a:hover {
text-decoration: underline;
}
.code-content {
display: flex;
justify-content: center;
align-items: center;
height: 60px;
margin: 20px 0;
}
#code {
font-size: 32px;
letter-spacing: 8px;
font-weight: 600;
color: var(--primary-color);
font-family: SF Mono, monospace;
}
.measure-time {
background-color: transparent;
border: 1px solid var(--primary-color);
color: var(--primary-color);
}
#token-box {
display: none;
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
:root {
--background-light: #1d1d1f;
--text-color: #f5f5f7;
--input-background: #2c2c2e;
--input-border: #3a3a3c;
}
.login-container {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
}
input::placeholder {
color: #86868b;
}
}
</style>
</head>
<body>
<div class="login-container">
<h1>获取 GitHub GHU</h1>
<div class="form-box">
<div id="token-box">
<div class="code-content">
<div id="code"></div>
</div>
<input type="text" id="token" placeholder="正在获取ghu_" readonly/>
<div id="timeing" class="submit-button measure-time">剩余时间: 900 秒</div>
</div>
<div id="submit-btn" class="submit-button" onclick="onSubmit()">登录 GitHub 获取授权码</div>
</div>
<div class="footer">
© 2024 Open Source Contributors
</div>
</div>
<script type="text/javascript">
let device_code = null;
let timer = null;
function countDown(time = 900) {
const timeing = document.getElementById('timeing');
timer = setInterval(() => {
time -= 1;
timeing.innerText = `剩余时间: ${time}`;
if (time <= 0) {
clearInterval(timer);
document.getElementById('submit-btn').style.display = 'block';
timeing.style.display = 'none';
alert("授权码已过期,请重新获取");
window.location.reload();
}
}, 1000);
}
async function onSubmit() {
const response = await fetch('/github/login/device/code', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
if (response.status !== 200) {
alert('获取授权码失败')
window.location.reload()
return
}
const resultJson = await response.json()
confirm(`打开浏览器访问: ${resultJson.verification_uri}, 并输入授权码: ${resultJson.user_code}`)
copyCode(resultJson.user_code)
device_code = resultJson.device_code
document.getElementById('submit-btn').style.display = 'none';
document.getElementById('token-box').style.display = 'block';
document.getElementById("code").innerText = resultJson.user_code;
countDown(resultJson.expires_in);
window.open(resultJson.verification_uri);
// 根据user_code获取ghu_token
await getGhuToken();
}
function copyCode(user_code) {
const input = document.createElement('input');
input.value = user_code;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
}
async function getGhuToken() {
await new Promise(resolve => setTimeout(resolve, 5000))
const response = await fetch('/github/login/ghu-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({device_code})
})
if (response.status !== 200) {
alert('获取ghu_token失败')
window.location.reload()
return false;
}
const resultJson = await response.json()
if (resultJson.error === "slow_down" && resultJson.error_description === "Too many requests have been made in the same timeframe.") {
await new Promise(resolve => setTimeout(resolve, resultJson.interval * 1000))
}
const access_token = resultJson?.access_token || null
if (access_token !== null) {
document.getElementById('token').value = access_token
clearInterval(timer);
document.getElementById("timeing").style.display = 'none';
document.getElementById("code").style.display = 'none';
return false;
}
await getGhuToken()
}
</script>
</body>
</html>

8
static/static.go Normal file
View File

@@ -0,0 +1,8 @@
package static
import "embed"
var (
//go:embed public
Public embed.FS
)