feat: add HeiCode browser-login bridge endpoints

Add loopback-safe /heicode/oauth authorize/session endpoints for desktop browser login and wire routing/deployment overrides so local source builds include the new auth bridge.
This commit is contained in:
gongzhiyong
2026-04-29 20:57:27 +08:00
parent e0b6eb3a59
commit 412d7c4610
4 changed files with 251 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
package controller
import (
"errors"
"fmt"
"html"
"net/http"
"net/url"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// HeiCode-specific browser-login bridge.
//
// Flow:
//
// 1. HeiCode desktop opens
// GET /heicode/oauth/authorize?state=<S>&redirect_uri=<U>&provider_id=<id>
// 2. If the user is not signed in to new-api, we render a small HTML page
// that links to /login and polls /heicode/oauth/session every 1.5s
// until the user has authenticated. Then it auto-redirects back.
// 3. Once the session is valid, we find or create a token named "HeiCode"
// for the current user and 302-redirect back to redirect_uri with the
// token in the query string:
// <redirect_uri>?state=<S>&token=sk-XXXX&provider_id=<id>
//
// The redirect_uri is restricted to loopback addresses for safety.
const heicodeTokenName = "HeiCode"
func isLoopbackHost(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
return host == "127.0.0.1" || host == "localhost" || host == "::1"
}
// HeicodeOAuthAuthorize handles GET /heicode/oauth/authorize.
func HeicodeOAuthAuthorize(c *gin.Context) {
state := strings.TrimSpace(c.Query("state"))
redirectURI := strings.TrimSpace(c.Query("redirect_uri"))
providerID := strings.TrimSpace(c.Query("provider_id"))
if state == "" || redirectURI == "" {
c.String(http.StatusBadRequest, "missing state or redirect_uri")
return
}
parsed, err := url.Parse(redirectURI)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
c.String(http.StatusBadRequest, "invalid redirect_uri")
return
}
if !isLoopbackHost(parsed.Hostname()) {
c.String(http.StatusBadRequest, "redirect_uri must be a loopback address")
return
}
session := sessions.Default(c)
rawID := session.Get("id")
if rawID == nil {
renderHeicodeLoginRequired(c, state, redirectURI, providerID)
return
}
userID, ok := rawID.(int)
if !ok || userID <= 0 {
renderHeicodeLoginRequired(c, state, redirectURI, providerID)
return
}
tokenKey, err := getOrCreateHeicodeToken(userID)
if err != nil {
c.String(http.StatusInternalServerError, "failed to issue HeiCode token: "+err.Error())
return
}
q := parsed.Query()
q.Set("state", state)
q.Set("token", "sk-"+tokenKey)
if providerID != "" {
q.Set("provider_id", providerID)
}
parsed.RawQuery = q.Encode()
c.Redirect(http.StatusFound, parsed.String())
}
// HeicodeOAuthSession handles GET /heicode/oauth/session — used by the
// "please log in" fallback page to poll session readiness from JS.
func HeicodeOAuthSession(c *gin.Context) {
session := sessions.Default(c)
id := session.Get("id")
loggedIn := false
var userID int
if v, ok := id.(int); ok && v > 0 {
loggedIn = true
userID = v
}
c.JSON(http.StatusOK, gin.H{
"logged_in": loggedIn,
"user_id": userID,
})
}
func getOrCreateHeicodeToken(userID int) (string, error) {
var existing model.Token
err := model.DB.Where("user_id = ? AND name = ?", userID, heicodeTokenName).
Order("id desc").
First(&existing).Error
if err == nil {
if existing.Status != common.TokenStatusEnabled {
existing.Status = common.TokenStatusEnabled
if updateErr := existing.Update(); updateErr != nil {
return "", updateErr
}
}
return existing.GetFullKey(), nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return "", err
}
rawKey, err := common.GenerateKey()
if err != nil {
return "", err
}
now := common.GetTimestamp()
tok := model.Token{
UserId: userID,
Name: heicodeTokenName,
Key: rawKey,
Status: common.TokenStatusEnabled,
CreatedTime: now,
AccessedTime: now,
ExpiredTime: -1,
UnlimitedQuota: true,
}
if err := tok.Insert(); err != nil {
return "", err
}
return tok.GetFullKey(), nil
}
func renderHeicodeLoginRequired(c *gin.Context, state, redirectURI, providerID string) {
authorizeURL := fmt.Sprintf(
"/heicode/oauth/authorize?state=%s&redirect_uri=%s&provider_id=%s",
url.QueryEscape(state),
url.QueryEscape(redirectURI),
url.QueryEscape(providerID),
)
loginURL := "/login"
body := fmt.Sprintf(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>HeiCode 登录授权</title>
<style>
:root { color-scheme: light dark; }
html,body { margin:0; padding:0; }
body { font-family: system-ui, -apple-system, "PingFang SC", sans-serif; min-height:100vh; display:flex; align-items:center; justify-content:center; background:#f7f7f8; color:#111; }
.card { background:#fff; padding:32px 36px; border-radius:14px; box-shadow:0 8px 24px rgba(0,0,0,.08); max-width:440px; width:100%%; box-sizing:border-box; }
h1 { font-size:18px; margin:0 0 8px; }
p { color:#555; line-height:1.6; margin:8px 0; font-size:14px; }
a.btn { display:inline-block; margin-top:16px; padding:10px 18px; background:#111; color:#fff; border-radius:8px; text-decoration:none; font-size:14px; }
a.btn:hover { background:#000; }
.muted { color:#888; font-size:12px; margin-top:18px; }
code { background:#f1f1f3; padding:2px 6px; border-radius:4px; font-size:12px; }
</style>
</head>
<body>
<div class="card">
<h1>请先登录新 API 控制台</h1>
<p>HeiCode 客户端正在等待你完成授权。请在新打开的页面中登录到 <strong>%s</strong>,登录成功后本页会自动跳转回 HeiCode。</p>
<a id="goLogin" class="btn" href="%s" target="_blank" rel="noopener">打开登录页</a>
<p class="muted">如果浏览器没有自动跳转,请手动在登录后回到此页面,或刷新本页。</p>
<p class="muted">提供方:<code>%s</code></p>
</div>
<script>
(function(){
var authorizeUrl = %q;
var pollMs = 1500;
function check() {
fetch('/heicode/oauth/session', { credentials: 'include' })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data && data.logged_in) {
window.location.replace(authorizeUrl);
} else {
setTimeout(check, pollMs);
}
})
.catch(function () { setTimeout(check, pollMs); });
}
setTimeout(check, pollMs);
})();
</script>
</body>
</html>`,
html.EscapeString(providerID),
html.EscapeString(loginURL),
html.EscapeString(providerID),
authorizeURL,
)
c.Header("Content-Type", "text/html; charset=utf-8")
c.Status(http.StatusOK)
_, _ = c.Writer.WriteString(body)
}
+15
View File
@@ -0,0 +1,15 @@
# Override file: build new-api from local source so the HeiCode OAuth bridge
# (controller/heicode_oauth.go + router/heicode-router.go) is included.
#
# Usage:
# docker compose up -d --build new-api
#
# Remove this file (or rename it) to fall back to the upstream
# calciumion/new-api:latest image declared in docker-compose.yml.
services:
new-api:
image: new-api-heicode:local
build:
context: .
dockerfile: Dockerfile
+21
View File
@@ -0,0 +1,21 @@
package router
import (
"github.com/QuantumNous/new-api/controller"
"github.com/gin-gonic/gin"
)
// SetHeicodeOAuthRouter wires up HeiCode-specific browser-login bridge endpoints.
// These endpoints are mounted at /heicode/* (no /api prefix) so the redirect_uri
// from HeiCode desktop matches: http(s)://<new-api-host>/heicode/oauth/authorize.
func SetHeicodeOAuthRouter(router *gin.Engine) {
heicode := router.Group("/heicode")
{
oauth := heicode.Group("/oauth")
{
oauth.GET("/authorize", controller.HeicodeOAuthAuthorize)
oauth.GET("/session", controller.HeicodeOAuthSession)
}
}
}
+1
View File
@@ -17,6 +17,7 @@ func SetRouter(router *gin.Engine, assets ThemeAssets) {
SetDashboardRouter(router)
SetRelayRouter(router)
SetVideoRouter(router)
SetHeicodeOAuthRouter(router)
frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL")
if common.IsMasterNode && frontendBaseUrl != "" {
frontendBaseUrl = ""