diff --git a/.env.example b/.env.example index ea9061fb3ff9..f35eb2cb952d 100644 --- a/.env.example +++ b/.env.example @@ -79,6 +79,10 @@ LINUX_DO_TOKEN_ENDPOINT=https://connect.linux.do/oauth2/token LINUX_DO_USER_ENDPOINT=https://connect.linux.do/api/user +# NodeLoc相关配置 +NODELOC_TOKEN_ENDPOINT=https://www.nodeloc.com/oauth-provider/token +NODELOC_USER_ENDPOINT=https://www.nodeloc.com/oauth-provider/userinfo + # 节点类型 # 如果是主节点则为master # NODE_TYPE=master diff --git a/.github/workflows/docker-image-alpha.yml b/.github/workflows/docker-image-alpha.yml index 2a7d43ad53ff..87423a2ddde5 100644 --- a/.github/workflows/docker-image-alpha.yml +++ b/.github/workflows/docker-image-alpha.yml @@ -66,7 +66,7 @@ jobs: uses: docker/metadata-action@v5 with: images: | - calciumion/new-api + nodeloc/new-api ghcr.io/${{ env.GHCR_REPOSITORY }} - name: Build & push single-arch (to both registries) @@ -76,8 +76,8 @@ jobs: platforms: ${{ matrix.platform }} push: true tags: | - calciumion/new-api:alpha-${{ matrix.arch }} - calciumion/new-api:${{ steps.version.outputs.value }}-${{ matrix.arch }} + nodeloc/new-api:alpha-${{ matrix.arch }} + nodeloc/new-api:${{ steps.version.outputs.value }}-${{ matrix.arch }} ghcr.io/${{ env.GHCR_REPOSITORY }}:alpha-${{ matrix.arch }} ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ steps.version.outputs.value }}-${{ matrix.arch }} labels: ${{ steps.meta.outputs.labels }} @@ -118,16 +118,16 @@ jobs: - name: Create & push manifest (Docker Hub - alpha) run: | docker buildx imagetools create \ - -t calciumion/new-api:alpha \ - calciumion/new-api:alpha-amd64 \ - calciumion/new-api:alpha-arm64 + -t nodeloc/new-api:alpha \ + nodeloc/new-api:alpha-amd64 \ + nodeloc/new-api:alpha-arm64 - name: Create & push manifest (Docker Hub - versioned alpha) run: | docker buildx imagetools create \ - -t calciumion/new-api:${VERSION} \ - calciumion/new-api:${VERSION}-amd64 \ - calciumion/new-api:${VERSION}-arm64 + -t nodeloc/new-api:${VERSION} \ + nodeloc/new-api:${VERSION}-amd64 \ + nodeloc/new-api:${VERSION}-arm64 - name: Log in to GHCR uses: docker/login-action@v3 diff --git a/.github/workflows/docker-image-arm64.yml b/.github/workflows/docker-image-arm64.yml index 78517af0ee2d..f17f07e1a9bb 100644 --- a/.github/workflows/docker-image-arm64.yml +++ b/.github/workflows/docker-image-arm64.yml @@ -63,7 +63,7 @@ jobs: uses: docker/metadata-action@v5 with: images: | - calciumion/new-api + nodeloc/new-api # ghcr.io/${{ env.GHCR_REPOSITORY }} - name: Build & push single-arch (to both registries) @@ -73,8 +73,8 @@ jobs: platforms: ${{ matrix.platform }} push: true tags: | - calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }} - calciumion/new-api:latest-${{ matrix.arch }} + nodeloc/new-api:${{ env.TAG }}-${{ matrix.arch }} + nodeloc/new-api:latest-${{ matrix.arch }} # ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ env.TAG }}-${{ matrix.arch }} # ghcr.io/${{ env.GHCR_REPOSITORY }}:latest-${{ matrix.arch }} labels: ${{ steps.meta.outputs.labels }} @@ -104,16 +104,16 @@ jobs: - name: Create & push manifest (Docker Hub - version) run: | docker buildx imagetools create \ - -t calciumion/new-api:${TAG} \ - calciumion/new-api:${TAG}-amd64 \ - calciumion/new-api:${TAG}-arm64 + -t nodeloc/new-api:${TAG} \ + nodeloc/new-api:${TAG}-amd64 \ + nodeloc/new-api:${TAG}-arm64 - name: Create & push manifest (Docker Hub - latest) run: | docker buildx imagetools create \ - -t calciumion/new-api:latest \ - calciumion/new-api:latest-amd64 \ - calciumion/new-api:latest-arm64 + -t nodeloc/new-api:latest \ + nodeloc/new-api:latest-amd64 \ + nodeloc/new-api:latest-arm64 # ---- GHCR ---- # - name: Log in to GHCR diff --git a/VERSION b/VERSION index e69de29bb2d1..e4c0d46e55ff 100644 --- a/VERSION +++ b/VERSION @@ -0,0 +1 @@ +1.0.3 \ No newline at end of file diff --git a/common/constants.go b/common/constants.go index e33a64b221fc..49d9b9c0e604 100644 --- a/common/constants.go +++ b/common/constants.go @@ -45,6 +45,7 @@ var PasswordRegisterEnabled = true var EmailVerificationEnabled = false var GitHubOAuthEnabled = false var LinuxDOOAuthEnabled = false +var NodeLocOAuthEnabled = false var WeChatAuthEnabled = false var TelegramOAuthEnabled = false var TurnstileCheckEnabled = false @@ -85,6 +86,9 @@ var GitHubClientSecret = "" var LinuxDOClientId = "" var LinuxDOClientSecret = "" var LinuxDOMinimumTrustLevel = 0 +var NodeLocClientId = "" +var NodeLocClientSecret = "" +var NodeLocMinimumTrustLevel = 0 var WeChatServerAddress = "" var WeChatServerToken = "" diff --git a/controller/misc.go b/controller/misc.go index 4d299fc81423..70c6be043e3b 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -56,20 +56,21 @@ func GetStatus(c *gin.Context) { "discord_client_id": system_setting.GetDiscordSettings().ClientId, "linuxdo_oauth": common.LinuxDOOAuthEnabled, "linuxdo_client_id": common.LinuxDOClientId, - "linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel, - "telegram_oauth": common.TelegramOAuthEnabled, - "telegram_bot_name": common.TelegramBotName, - "system_name": common.SystemName, - "logo": common.Logo, - "footer_html": common.Footer, - "wechat_qrcode": common.WeChatAccountQRCodeImageURL, - "wechat_login": common.WeChatAuthEnabled, - "server_address": system_setting.ServerAddress, - "turnstile_check": common.TurnstileCheckEnabled, - "turnstile_site_key": common.TurnstileSiteKey, - "top_up_link": common.TopUpLink, - "docs_link": operation_setting.GetGeneralSetting().DocsLink, - "quota_per_unit": common.QuotaPerUnit, + "linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel, "nodeloc_oauth": common.NodeLocOAuthEnabled, + "nodeloc_client_id": common.NodeLocClientId, + "nodeloc_minimum_trust_level": common.NodeLocMinimumTrustLevel, "telegram_oauth": common.TelegramOAuthEnabled, + "telegram_bot_name": common.TelegramBotName, + "system_name": common.SystemName, + "logo": common.Logo, + "footer_html": common.Footer, + "wechat_qrcode": common.WeChatAccountQRCodeImageURL, + "wechat_login": common.WeChatAuthEnabled, + "server_address": system_setting.ServerAddress, + "turnstile_check": common.TurnstileCheckEnabled, + "turnstile_site_key": common.TurnstileSiteKey, + "top_up_link": common.TopUpLink, + "docs_link": operation_setting.GetGeneralSetting().DocsLink, + "quota_per_unit": common.QuotaPerUnit, // 兼容旧前端:保留 display_in_currency,同时提供新的 quota_display_type "display_in_currency": operation_setting.IsCurrencyDisplay(), "quota_display_type": operation_setting.GetQuotaDisplayType(), diff --git a/controller/nodeloc.go b/controller/nodeloc.go new file mode 100644 index 000000000000..de00da135be7 --- /dev/null +++ b/controller/nodeloc.go @@ -0,0 +1,315 @@ +package controller + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +// NodeLocUser represents the user information returned by NodeLoc OAuth API. +type NodeLocUser struct { + Id int `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + AvatarUrl string `json:"avatar_url"` + TrustLevel int `json:"trust_level"` + Email string `json:"email"` +} + +// NodeLocBind handles binding a NodeLoc account to an existing user account. +// It requires the user to be logged in and will associate their NodeLoc ID with their account. +func NodeLocBind(c *gin.Context) { + if !common.NodeLocOAuthEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 NodeLoc 登录以及注册", + }) + return + } + + code := c.Query("code") + nodelocUser, err := getNodeLocUserInfoByCode(code, c) + if err != nil { + common.ApiError(c, err) + return + } + + user := model.User{ + NodeLocId: strconv.Itoa(nodelocUser.Id), + } + + if model.IsNodeLocIdAlreadyTaken(user.NodeLocId) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该 NodeLoc 账户已被绑定", + }) + return + } + + session := sessions.Default(c) + id := session.Get("id") + // Safe type assertion to avoid panic + userId, ok := id.(int) + if !ok { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "用户未登录或会话无效", + }) + return + } + user.Id = userId + + err = user.FillUserById() + if err != nil { + common.ApiError(c, err) + return + } + + user.NodeLocId = strconv.Itoa(nodelocUser.Id) + err = user.Update(false) + if err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "bind", + }) +} + +// getNodeLocUserInfoByCode exchanges the OAuth authorization code for an access token +// and retrieves the user information from NodeLoc API. +func getNodeLocUserInfoByCode(code string, c *gin.Context) (*NodeLocUser, error) { + if code == "" { + return nil, errors.New("invalid code") + } + + // Get access token + tokenEndpoint := common.GetEnvOrDefaultString("NODELOC_TOKEN_ENDPOINT", "https://www.nodeloc.com/oauth-provider/token") + + // Get redirect URI from ServerAddress config + redirectURI := strings.TrimSuffix(system_setting.ServerAddress, "/") + "/oauth/nodeloc" + + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", code) + data.Set("redirect_uri", redirectURI) + data.Set("client_id", common.NodeLocClientId) + data.Set("client_secret", common.NodeLocClientSecret) + + req, err := http.NewRequest("POST", tokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + client := http.Client{Timeout: 5 * time.Second} + res, err := client.Do(req) + if err != nil { + return nil, errors.New("failed to connect to NodeLoc server") + } + defer res.Body.Close() + + // Validate HTTP status code for token exchange + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("NodeLoc token endpoint returned status %d", res.StatusCode) + } + + var tokenRes struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` + } + if err := json.NewDecoder(res.Body).Decode(&tokenRes); err != nil { + return nil, err + } + + if tokenRes.Error != "" { + return nil, fmt.Errorf("failed to get access token: %s - %s", tokenRes.Error, tokenRes.ErrorDesc) + } + + if tokenRes.AccessToken == "" { + return nil, errors.New("failed to get access token") + } + + // Get user info + userEndpoint := common.GetEnvOrDefaultString("NODELOC_USER_ENDPOINT", "https://www.nodeloc.com/oauth-provider/userinfo") + req, err = http.NewRequest("GET", userEndpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+tokenRes.AccessToken) + req.Header.Set("Accept", "application/json") + + res2, err := client.Do(req) + if err != nil { + return nil, errors.New("failed to get user info from NodeLoc") + } + defer res2.Body.Close() + + // Validate HTTP status code for user info + if res2.StatusCode != http.StatusOK { + return nil, fmt.Errorf("NodeLoc userinfo endpoint returned status %d", res2.StatusCode) + } + + var nodelocUser NodeLocUser + if err := json.NewDecoder(res2.Body).Decode(&nodelocUser); err != nil { + return nil, err + } + + if nodelocUser.Id == 0 { + return nil, errors.New("invalid user info returned") + } + + // If name is empty, use username + if nodelocUser.Name == "" { + nodelocUser.Name = nodelocUser.Username + } + + return &nodelocUser, nil +} + +// NodeLocOAuth handles the NodeLoc OAuth callback for login and registration. +// It validates the OAuth state, exchanges the code for user info, and either logs in +// an existing user or creates a new account. +func NodeLocOAuth(c *gin.Context) { + session := sessions.Default(c) + + errorCode := c.Query("error") + if errorCode != "" { + errorDescription := c.Query("error_description") + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": errorDescription, + }) + return + } + + state := c.Query("state") + // Safe type assertion for oauth_state validation + sessionState := session.Get("oauth_state") + expectedState, ok := sessionState.(string) + if state == "" || sessionState == nil || !ok || state != expectedState { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "state is empty or not same", + }) + return + } + + username := session.Get("username") + if username != nil { + NodeLocBind(c) + return + } + + if !common.NodeLocOAuthEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 NodeLoc 登录以及注册", + }) + return + } + + code := c.Query("code") + nodelocUser, err := getNodeLocUserInfoByCode(code, c) + if err != nil { + common.ApiError(c, err) + return + } + + user := model.User{ + NodeLocId: strconv.Itoa(nodelocUser.Id), + } + + // Check if user exists + if model.IsNodeLocIdAlreadyTaken(user.NodeLocId) { + err := user.FillUserByNodeLocId() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + if user.Id == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户已注销", + }) + return + } + } else { + if common.RegisterEnabled { + if nodelocUser.TrustLevel >= common.NodeLocMinimumTrustLevel { + // 使用 NodeLoc 用户名,如果已存在则添加唯一后缀 + // 使用时间戳和NodeLoc ID组合避免竞态条件 + username := nodelocUser.Username + if model.IsUsernameExist(username) { + username = fmt.Sprintf("%s_%d_%d", nodelocUser.Username, nodelocUser.Id, time.Now().UnixNano()%10000) + } + user.Username = username + user.DisplayName = nodelocUser.Name + user.Role = common.RoleCommonUser + user.Status = common.UserStatusEnabled + + affCode := session.Get("aff") + inviterId := 0 + // Safe type assertion for affCode + if affCode != nil { + if affCodeStr, ok := affCode.(string); ok { + inviterId, _ = model.GetUserIdByAffCode(affCodeStr) + } + } + + if err := user.Insert(inviterId); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + common.SysLog(fmt.Sprintf("NodeLoc user created: id=%d, username=%s, nodeloc_id=%s", user.Id, user.Username, user.NodeLocId)) + } else { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "NodeLoc 信任等级未达到管理员设置的最低信任等级", + }) + return + } + } else { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员关闭了新用户注册", + }) + return + } + } + + if user.Status != common.UserStatusEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "用户已被封禁", + "success": false, + }) + return + } + + setupLogin(&user, c) +} diff --git a/controller/option.go b/controller/option.go index a2db95326268..5d56e98e626a 100644 --- a/controller/option.go +++ b/controller/option.go @@ -100,6 +100,14 @@ func UpdateOption(c *gin.Context) { }) return } + case "NodeLocOAuthEnabled": + if option.Value == "true" && common.NodeLocClientId == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用 NodeLoc OAuth,请先填入 NodeLoc Client Id 以及 NodeLoc Client Secret!", + }) + return + } case "EmailDomainRestrictionEnabled": if option.Value == "true" && len(common.EmailDomainWhitelist) == 0 { c.JSON(http.StatusOK, gin.H{ diff --git a/controller/user.go b/controller/user.go index 1fc83c99e7e4..941f72fa6fad 100644 --- a/controller/user.go +++ b/controller/user.go @@ -466,6 +466,7 @@ func GetSelf(c *gin.Context) { "aff_history_quota": user.AffHistoryQuota, "inviter_id": user.InviterId, "linux_do_id": user.LinuxDOId, + "nodeloc_id": user.NodeLocId, "setting": user.Setting, "stripe_customer": user.StripeCustomer, "sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段 diff --git a/docs/nodeloc-oauth.md b/docs/nodeloc-oauth.md new file mode 100644 index 000000000000..bc1e47dc1e0d --- /dev/null +++ b/docs/nodeloc-oauth.md @@ -0,0 +1,110 @@ +# NodeLoc OAuth2 登录集成说明 + +本项目已成功集成 NodeLoc OAuth2 登录功能,参考了 Linux DO 的实现和 NodeLoc 官方文档。 + +## 实现的功能 + +1. **用户登录**:使用 NodeLoc 账户登录系统 +2. **用户注册**:首次登录时自动创建账户(需满足信任等级要求) +3. **账户绑定**:已登录用户可以绑定 NodeLoc 账户 + +## 配置步骤 + +### 1. 创建 NodeLoc OAuth 应用 + +访问 NodeLoc 论坛创建 OAuth 应用: +``` +https://www.nodeloc.com/oauth-provider/applications +``` + +记录以下信息: +- **Client ID**: 应用的唯一标识符 +- **Client Secret**: 应用密钥(请妥善保管) +- **Redirect URI**: 设置为 `http(s)://your-domain.com/api/oauth/nodeloc` + +### 2. 配置环境变量 + +在 `.env` 文件或环境变量中添加以下配置: + +```bash +# NodeLoc OAuth 端点(可选,使用默认值) +NODELOC_TOKEN_ENDPOINT=https://www.nodeloc.com/oauth-provider/token +NODELOC_USER_ENDPOINT=https://www.nodeloc.com/oauth-provider/userinfo +``` + +### 3. 在系统中启用 NodeLoc OAuth + +在系统设置中配置以下选项: +- `NodeLocClientId`: 您的 Client ID +- `NodeLocClientSecret`: 您的 Client Secret +- `NodeLocMinimumTrustLevel`: 最低信任等级(默认为 0) +- `NodeLocOAuthEnabled`: 设置为 `true` 启用功能 + +## 技术实现 + +### 新增文件 +- `controller/nodeloc.go`: NodeLoc OAuth2 控制器 + +### 修改文件 +- `model/user.go`: 添加 `NodeLocId` 字段和相关方法 +- `common/constants.go`: 添加 NodeLoc 相关常量 +- `controller/option.go`: 添加 NodeLoc OAuth 配置验证 +- `router/api-router.go`: 添加 `/api/oauth/nodeloc` 路由 +- `controller/user.go`: 在用户信息中返回 `nodeloc_id` +- `.env.example`: 添加 NodeLoc 配置示例 + +### OAuth2 流程 + +1. 用户点击"使用 NodeLoc 登录" +2. 重定向到 NodeLoc 授权页面: + ``` + https://www.nodeloc.com/oauth-provider/authorize? + client_id=YOUR_CLIENT_ID& + redirect_uri=YOUR_REDIRECT_URI& + response_type=code& + scope=openid%20profile& + state=RANDOM_STATE + ``` +3. 用户授权后,NodeLoc 重定向回 `/api/oauth/nodeloc?code=xxx&state=xxx` +4. 系统使用 code 交换 access_token +5. 使用 access_token 获取用户信息 +6. 根据用户信息登录或注册账户 + +### 返回的用户信息 + +```json +{ + "id": 123, + "username": "user1", + "name": "user1", + "avatar_url": "https://www.nodeloc.com/avatar.png", + "trust_level": 2, + "email": "user1@example.com" +} +``` + +## 安全特性 + +- 使用 state 参数防止 CSRF 攻击 +- 支持信任等级验证,确保用户质量 +- 支持账户绑定而非强制覆盖 +- 密钥信息不会通过 API 返回给前端 + +## 注意事项 + +1. **信任等级**: 如果设置了最低信任等级,新用户必须达到该等级才能注册 +2. **账户绑定**: 一个 NodeLoc 账户只能绑定一个系统账户 +3. **HTTPS**: 生产环境建议使用 HTTPS 协议 +4. **邮箱权限**: 如果需要获取用户邮箱,需要在 NodeLoc 应用设置中申请 `email` scope 并等待管理员审核 + +## 数据库变化 + +需要在 `users` 表中添加新字段: +```sql +ALTER TABLE users ADD COLUMN nodeloc_id VARCHAR(255); +CREATE INDEX idx_users_nodeloc_id ON users(nodeloc_id); +``` + +## 参考文档 + +- NodeLoc OAuth 文档: https://docs.nodeloc.com/api-reference/introduction \ No newline at end of file diff --git a/model/option.go b/model/option.go index 24cf7862df39..d66e260acf80 100644 --- a/model/option.go +++ b/model/option.go @@ -39,6 +39,7 @@ func InitOptionMap() { common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled) common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled) common.OptionMap["LinuxDOOAuthEnabled"] = strconv.FormatBool(common.LinuxDOOAuthEnabled) + common.OptionMap["NodeLocOAuthEnabled"] = strconv.FormatBool(common.NodeLocOAuthEnabled) common.OptionMap["TelegramOAuthEnabled"] = strconv.FormatBool(common.TelegramOAuthEnabled) common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled) common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled) @@ -95,6 +96,9 @@ func InitOptionMap() { common.OptionMap["PayMethods"] = operation_setting.PayMethods2JsonString() common.OptionMap["GitHubClientId"] = "" common.OptionMap["GitHubClientSecret"] = "" + common.OptionMap["NodeLocClientId"] = "" + common.OptionMap["NodeLocClientSecret"] = "" + common.OptionMap["NodeLocMinimumTrustLevel"] = strconv.Itoa(common.NodeLocMinimumTrustLevel) common.OptionMap["TelegramBotToken"] = "" common.OptionMap["TelegramBotName"] = "" common.OptionMap["WeChatServerAddress"] = "" @@ -227,6 +231,8 @@ func updateOptionMap(key string, value string) (err error) { common.GitHubOAuthEnabled = boolValue case "LinuxDOOAuthEnabled": common.LinuxDOOAuthEnabled = boolValue + case "NodeLocOAuthEnabled": + common.NodeLocOAuthEnabled = boolValue case "WeChatAuthEnabled": common.WeChatAuthEnabled = boolValue case "TelegramOAuthEnabled": @@ -367,6 +373,12 @@ func updateOptionMap(key string, value string) (err error) { common.LinuxDOClientSecret = value case "LinuxDOMinimumTrustLevel": common.LinuxDOMinimumTrustLevel, _ = strconv.Atoi(value) + case "NodeLocClientId": + common.NodeLocClientId = value + case "NodeLocClientSecret": + common.NodeLocClientSecret = value + case "NodeLocMinimumTrustLevel": + common.NodeLocMinimumTrustLevel, _ = strconv.Atoi(value) case "Footer": common.Footer = value case "SystemName": diff --git a/model/user.go b/model/user.go index 395daa0b5da1..c12ab1a77232 100644 --- a/model/user.go +++ b/model/user.go @@ -44,6 +44,7 @@ type User struct { InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"` DeletedAt gorm.DeletedAt `gorm:"index"` LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"` + NodeLocId string `json:"nodeloc_id" gorm:"column:nodeloc_id;index"` Setting string `json:"setting" gorm:"type:text;column:setting"` Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"` StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"` @@ -579,6 +580,10 @@ func IsEmailAlreadyTaken(email string) bool { return DB.Unscoped().Where("email = ?", email).Find(&User{}).RowsAffected == 1 } +func IsUsernameExist(username string) bool { + return DB.Unscoped().Where("username = ?", username).Find(&User{}).RowsAffected == 1 +} + func IsWeChatIdAlreadyTaken(wechatId string) bool { return DB.Unscoped().Where("wechat_id = ?", wechatId).Find(&User{}).RowsAffected == 1 } @@ -921,6 +926,20 @@ func (user *User) FillUserByLinuxDOId() error { return err } +func IsNodeLocIdAlreadyTaken(nodelocId string) bool { + var user User + err := DB.Where("nodeloc_id = ?", nodelocId).First(&user).Error + return !errors.Is(err, gorm.ErrRecordNotFound) +} + +func (user *User) FillUserByNodeLocId() error { + if user.NodeLocId == "" { + return errors.New("nodeloc id is empty") + } + err := DB.Where("nodeloc_id = ?", user.NodeLocId).First(user).Error + return err +} + func RootUserExists() bool { var user User err := DB.Where("role = ?", common.RoleRootUser).First(&user).Error diff --git a/router/api-router.go b/router/api-router.go index f3ae4d970d2b..9368a5f4d7b6 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -33,6 +33,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/oauth/discord", middleware.CriticalRateLimit(), controller.DiscordOAuth) apiRouter.GET("/oauth/oidc", middleware.CriticalRateLimit(), controller.OidcAuth) apiRouter.GET("/oauth/linuxdo", middleware.CriticalRateLimit(), controller.LinuxdoOAuth) + apiRouter.GET("/oauth/nodeloc", middleware.CriticalRateLimit(), controller.NodeLocOAuth) apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode) apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth) apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), controller.WeChatBind) diff --git a/server.log b/server.log new file mode 100644 index 000000000000..61c33f95aca4 --- /dev/null +++ b/server.log @@ -0,0 +1,197 @@ +[SYS] 2026/01/20 - 00:08:33 | initializing token encoders +[SYS] 2026/01/20 - 00:08:33 | token encoders initialized +[SYS] 2026/01/20 - 00:08:33 | SQL_DSN not set, using SQLite as database +[SYS] 2026/01/20 - 00:08:33 | database migration started +[SYS] 2026/01/20 - 00:08:33 | system is already initialized at: 2026-01-19 23:24:53 +0800 CST +[SYS] 2026/01/20 - 00:08:33 | REDIS_CONN_STRING not set, Redis is not enabled +[SYS] 2026/01/20 - 00:08:33 | New API v0.0.0 started +[SYS] 2026/01/20 - 00:08:33 | 正在更新数据看板数据... +[INFO] 2026/01/20 - 00:08:33 | SYSTEM | codex credential auto-refresh task started: tick=10m0s threshold=24h0m0s +[SYS] 2026/01/20 - 00:08:33 | 保存数据看板数据成功,共保存0条数据 + + New API v0.0.0 ready in 168 ms + + ➜ Local: http://localhost:3001/ + ➜ Network: http://10.255.255.254:3001/ + ➜ Network: http://172.26.191.183:3001/ + ➜ Network: http://172.17.0.1:3001/ + ➜ Network: http://172.18.0.1:3001/ + +[SYS] 2026/01/20 - 00:08:47 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:08:47 | 任务进度轮询完成 +[GIN] 2026/01/20 - 00:08:53 | 202601200008532329726691SZ5lvNP | 200 | 1.115746ms | ::1 | GET /login +[GIN] 2026/01/20 - 00:08:53 | 20260120000853248762310IGOdvGNr | 200 | 3.102522ms | ::1 | GET /assets/i18n-CM-fgV6w.js +[GIN] 2026/01/20 - 00:08:53 | 20260120000853244969239jgnbbRtO | 200 | 10.835567ms | ::1 | GET /assets/react-core-XXF3bvz8.js +[GIN] 2026/01/20 - 00:08:53 | 20260120000853252730506jyEkVUXe | 200 | 3.844394ms | ::1 | GET /assets/tools-CuH1b7IF.js +[GIN] 2026/01/20 - 00:08:53 | 202601200008532584055756DHVQ9iy | 200 | 2.501461ms | ::1 | GET /.well-known/appspecific/com.chrome.devtools.json +[GIN] 2026/01/20 - 00:08:53 | 20260120000853258419927En9LDmIH | 200 | 3.267489ms | ::1 | GET /assets/react-components-C-LuPRGm.js +[GIN] 2026/01/20 - 00:08:53 | 202601200008532471374608urPl3DL | 200 | 14.844964ms | ::1 | GET /assets/semi-ui-oSS6FHvu.css +[GIN] 2026/01/20 - 00:08:53 | 20260120000853248103360ipm8ocJ5 | 200 | 16.011729ms | ::1 | GET /assets/index-CJowMZRu.css +[GIN] 2026/01/20 - 00:08:53 | 20260120000853250671802TAmv5g3V | 200 | 47.944997ms | ::1 | GET /assets/semi-ui-DcSuht6H.js +[GIN] 2026/01/20 - 00:08:53 | 2026012000085324497878671fPv4R7 | 200 | 171.762845ms | ::1 | GET /assets/index--sb4Mim2.js +[GIN] 2026/01/20 - 00:08:53 | 20260120000853612449459awH4FJtu | 200 | 1.093562ms | ::1 | GET /api/status +[GIN] 2026/01/20 - 00:08:53 | 20260120000853630206253pm2Tugxo | 200 | 159.031µs | ::1 | GET /logo.png +[SYS] 2026/01/20 - 00:09:02 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:09:02 | 任务进度轮询完成 +[GIN] 2026/01/20 - 00:09:05 | 20260120000905428344851QBIgN0Ty | 200 | 129.939µs | ::1 | GET /login +[GIN] 2026/01/20 - 00:09:05 | 20260120000905437982915rdyYmhCm | 200 | 1.179829ms | ::1 | GET /assets/react-components-C-LuPRGm.js +[GIN] 2026/01/20 - 00:09:05 | 20260120000905437915575eKxu8oi9 | 200 | 2.179057ms | ::1 | GET /assets/tools-CuH1b7IF.js +[GIN] 2026/01/20 - 00:09:05 | 20260120000905437371086XWLBnp2O | 200 | 2.862235ms | ::1 | GET /assets/i18n-CM-fgV6w.js +[GIN] 2026/01/20 - 00:09:05 | 20260120000905437400648B51mzHTe | 200 | 5.381602ms | ::1 | GET /assets/react-core-XXF3bvz8.js +[GIN] 2026/01/20 - 00:09:05 | 20260120000905443706498jdEAIrK0 | 200 | 151.305µs | ::1 | GET /.well-known/appspecific/com.chrome.devtools.json +[GIN] 2026/01/20 - 00:09:05 | 20260120000905440676214yYkE7Dmk | 200 | 11.239898ms | ::1 | GET /assets/semi-ui-oSS6FHvu.css +[GIN] 2026/01/20 - 00:09:05 | 20260120000905441285566XJlxQebX | 200 | 11.483295ms | ::1 | GET /assets/index-CJowMZRu.css +[GIN] 2026/01/20 - 00:09:05 | 20260120000905437785314CLqhiekf | 200 | 41.496963ms | ::1 | GET /assets/semi-ui-DcSuht6H.js +[GIN] 2026/01/20 - 00:09:05 | 202601200009054365113444hE7hZou | 200 | 145.50019ms | ::1 | GET /assets/index--sb4Mim2.js +[GIN] 2026/01/20 - 00:09:05 | 202601200009057730938278dIJ6Z3Z | 200 | 210.914µs | ::1 | GET /api/status +[GIN] 2026/01/20 - 00:09:05 | 20260120000905788067264IMYRnf19 | 200 | 120.633µs | ::1 | GET /logo.png +[GIN] 2026/01/20 - 00:09:06 | 20260120000906789042861ORNImfTo | 200 | 130.103µs | ::1 | GET /login +[GIN] 2026/01/20 - 00:09:06 | 20260120000906800076094Su0rlCBv | 200 | 979.464µs | ::1 | GET /assets/react-components-C-LuPRGm.js +[GIN] 2026/01/20 - 00:09:06 | 20260120000906799662705TDl3hknH | 200 | 1.535171ms | ::1 | GET /assets/i18n-CM-fgV6w.js +[GIN] 2026/01/20 - 00:09:06 | 20260120000906800085727UdYY761H | 200 | 2.089155ms | ::1 | GET /assets/tools-CuH1b7IF.js +[GIN] 2026/01/20 - 00:09:06 | 20260120000906803518702FWQNVdHi | 200 | 104.498µs | ::1 | GET /.well-known/appspecific/com.chrome.devtools.json +[GIN] 2026/01/20 - 00:09:06 | 20260120000906799456635Kqxz6v5B | 200 | 5.150418ms | ::1 | GET /assets/react-core-XXF3bvz8.js +[GIN] 2026/01/20 - 00:09:06 | 202601200009068027761197yRrscNo | 200 | 9.352477ms | ::1 | GET /assets/semi-ui-oSS6FHvu.css +[GIN] 2026/01/20 - 00:09:06 | 20260120000906802960199w6pAin2Y | 200 | 11.051568ms | ::1 | GET /assets/index-CJowMZRu.css +[GIN] 2026/01/20 - 00:09:06 | 20260120000906799805900MG4Uga3y | 200 | 39.64366ms | ::1 | GET /assets/semi-ui-DcSuht6H.js +[GIN] 2026/01/20 - 00:09:06 | 20260120000906797499423wMqa1MWr | 200 | 154.261831ms | ::1 | GET /assets/index--sb4Mim2.js +[GIN] 2026/01/20 - 00:09:07 | 20260120000907151487732wzMud8QH | 200 | 1.036989ms | ::1 | GET /api/status +[GIN] 2026/01/20 - 00:09:07 | 20260120000907167781479Km1CzTrp | 200 | 118.714µs | ::1 | GET /logo.png +[SYS] 2026/01/20 - 00:09:16 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:09:16 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:09:31 | syncing options from database +[SYS] 2026/01/20 - 00:09:31 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:09:31 | 任务进度轮询完成 +[GIN] 2026/01/20 - 00:09:44 | 20260120000944349309502HRDAvbVu | 200 | 111.715µs | ::1 | GET /api/status +[SYS] 2026/01/20 - 00:09:45 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:09:45 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:10:00 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:10:00 | 任务进度轮询完成 +[GIN] 2026/01/20 - 00:10:01 | 20260120001001980008671brXJkhrh | 200 | 143.636µs | ::1 | GET /login +[GIN] 2026/01/20 - 00:10:01 | 20260120001001990117818d38nq08h | 200 | 150.005µs | ::1 | GET /.well-known/appspecific/com.chrome.devtools.json +[GIN] 2026/01/20 - 00:10:02 | 20260120001002320173328elNmIoqo | 200 | 1.256477ms | ::1 | GET /api/status +[GIN] 2026/01/20 - 00:10:04 | 20260120001004532648608NJ2n8F2w | 200 | 128.17µs | ::1 | GET /login +[GIN] 2026/01/20 - 00:10:04 | 20260120001004542230873rQc9VbyW | 200 | 133.971µs | ::1 | GET /.well-known/appspecific/com.chrome.devtools.json +[GIN] 2026/01/20 - 00:10:04 | 20260120001004628963275jFVkMUx5 | 200 | 225.662µs | ::1 | GET /api/status +[GIN] 2026/01/20 - 00:10:06 | 20260120001006228241273KTzzdA0Z | 200 | 135.168µs | ::1 | GET /login +[GIN] 2026/01/20 - 00:10:06 | 202601200010062374897846R0Sgqjf | 200 | 143.085µs | ::1 | GET /.well-known/appspecific/com.chrome.devtools.json +[GIN] 2026/01/20 - 00:10:06 | 20260120001006324252969HlgnJsNT | 200 | 235.612µs | ::1 | GET /api/status +[GIN] 2026/01/20 - 00:10:09 | 2026012000100967301196zHivOVO1 | 200 | 129.532µs | ::1 | GET /login +[GIN] 2026/01/20 - 00:10:09 | 2026012000100978956765LsPyK3fp | 200 | 1.026667ms | ::1 | GET /assets/react-components-C-LuPRGm.js +[GIN] 2026/01/20 - 00:10:09 | 20260120001009789219577QKXfI1g | 200 | 1.459668ms | ::1 | GET /assets/i18n-CM-fgV6w.js +[GIN] 2026/01/20 - 00:10:09 | 2026012000100978999282XvOOFHbq | 200 | 2.478334ms | ::1 | GET /assets/tools-CuH1b7IF.js +[GIN] 2026/01/20 - 00:10:09 | 20260120001009834337782DLRAdDb | 200 | 105.461µs | ::1 | GET /.well-known/appspecific/com.chrome.devtools.json +[GIN] 2026/01/20 - 00:10:09 | 2026012000100978838474ZPIIpleB | 200 | 4.719335ms | ::1 | GET /assets/react-core-XXF3bvz8.js +[GIN] 2026/01/20 - 00:10:09 | 2026012000100980775382kscy8Kd3 | 200 | 9.303584ms | ::1 | GET /assets/semi-ui-oSS6FHvu.css +[GIN] 2026/01/20 - 00:10:09 | 2026012000100981900338TvjpUG7R | 200 | 11.079953ms | ::1 | GET /assets/index-CJowMZRu.css +[GIN] 2026/01/20 - 00:10:09 | 2026012000100978891343p4mojm0b | 200 | 40.305112ms | ::1 | GET /assets/semi-ui-DcSuht6H.js +[GIN] 2026/01/20 - 00:10:09 | 2026012000100978856331GqQqSqjc | 200 | 152.30305ms | ::1 | GET /assets/index--sb4Mim2.js +[GIN] 2026/01/20 - 00:10:09 | 20260120001009429731329pwpwsHkU | 200 | 231.773µs | ::1 | GET /api/status +[GIN] 2026/01/20 - 00:10:09 | 20260120001009445883604jG5DTcvI | 200 | 122.441µs | ::1 | GET /logo.png +[SYS] 2026/01/20 - 00:10:14 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:10:14 | 任务进度轮询完成 +[GIN] 2026/01/20 - 00:10:17 | 20260120001017348380913DmDjVJo1 | 200 | 1.016511ms | ::1 | GET /api/status +[GIN] 2026/01/20 - 00:10:17 | 20260120001017353562296wuDT9ysv | 200 | 116.527µs | ::1 | GET /login +[GIN] 2026/01/20 - 00:10:17 | 202601200010173660771286XX7qWmX | 200 | 131.644µs | ::1 | GET /.well-known/appspecific/com.chrome.devtools.json +[GIN] 2026/01/20 - 00:10:17 | 20260120001017679230055bN6yCxXi | 200 | 201.594µs | ::1 | GET /api/status +[SYS] 2026/01/20 - 00:10:29 | syncing options from database +[SYS] 2026/01/20 - 00:10:29 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:10:29 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:10:43 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:10:43 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:10:58 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:10:58 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:11:12 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:11:12 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:11:27 | syncing options from database +[SYS] 2026/01/20 - 00:11:27 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:11:27 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:11:41 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:11:41 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:11:56 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:11:56 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:12:11 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:12:11 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:12:26 | syncing options from database +[SYS] 2026/01/20 - 00:12:26 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:12:26 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:12:40 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:12:40 | 任务进度轮询完成 +[GIN] 2026/01/20 - 00:12:46 | 20260120001246158217180t7Nf5tzO | 200 | 184.293µs | ::1 | GET /login +[GIN] 2026/01/20 - 00:12:46 | 20260120001246168543158BznVOUM8 | 200 | 143.101µs | ::1 | GET /.well-known/appspecific/com.chrome.devtools.json +[GIN] 2026/01/20 - 00:12:46 | 20260120001246266758372g54cdEqu | 200 | 206.086µs | ::1 | GET /api/status +[SYS] 2026/01/20 - 00:12:55 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:12:55 | 任务进度轮询完成 +[GIN] 2026/01/20 - 00:13:04 | 2026012000130447954485051q14rzC | 200 | 712.451µs | ::1 | GET /login +[GIN] 2026/01/20 - 00:13:04 | 20260120001304493286275qA68t0vl | 200 | 879.923µs | ::1 | GET /.well-known/appspecific/com.chrome.devtools.json +[GIN] 2026/01/20 - 00:13:04 | 20260120001304493572044sPCe7OoY | 200 | 2.17266ms | ::1 | GET /assets/i18n-CM-fgV6w.js +[GIN] 2026/01/20 - 00:13:04 | 20260120001304493766199ZHtgEjlv | 200 | 4.356685ms | ::1 | GET /assets/tools-CuH1b7IF.js +[GIN] 2026/01/20 - 00:13:04 | 202601200013044993836043kFO7uVI | 200 | 876.128µs | ::1 | GET /assets/react-components-C-LuPRGm.js +[GIN] 2026/01/20 - 00:13:04 | 20260120001304493259676WWc3jgMj | 200 | 7.438249ms | ::1 | GET /assets/react-core-XXF3bvz8.js +[GIN] 2026/01/20 - 00:13:04 | 20260120001304495269820aQKNjKFr | 200 | 12.409017ms | ::1 | GET /assets/semi-ui-oSS6FHvu.css +[GIN] 2026/01/20 - 00:13:04 | 20260120001304496993737spNt7RX2 | 200 | 13.50186ms | ::1 | GET /assets/index-CJowMZRu.css +[GIN] 2026/01/20 - 00:13:04 | 20260120001304493696647Sjkjnh7x | 200 | 45.693088ms | ::1 | GET /assets/semi-ui-DcSuht6H.js +[GIN] 2026/01/20 - 00:13:04 | 202601200013044932714145i2cpOBS | 200 | 147.980431ms | ::1 | GET /assets/index--sb4Mim2.js +[GIN] 2026/01/20 - 00:13:04 | 20260120001304838035427J6xCsl0F | 200 | 392.863µs | ::1 | GET /api/status +[GIN] 2026/01/20 - 00:13:04 | 20260120001304857907642zBapB4cf | 200 | 107.024µs | ::1 | GET /logo.png +[SYS] 2026/01/20 - 00:13:09 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:13:09 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:13:24 | 正在更新数据看板数据... +[SYS] 2026/01/20 - 00:13:24 | 保存数据看板数据成功,共保存0条数据 +[SYS] 2026/01/20 - 00:13:24 | syncing options from database +[SYS] 2026/01/20 - 00:13:24 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:13:24 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:13:38 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:13:38 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:13:53 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:13:53 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:14:07 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:14:07 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:14:22 | syncing options from database +[SYS] 2026/01/20 - 00:14:22 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:14:22 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:14:36 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:14:36 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:14:51 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:14:51 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:15:05 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:15:05 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:15:20 | syncing options from database +[SYS] 2026/01/20 - 00:15:20 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:15:20 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:15:34 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:15:34 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:15:49 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:15:49 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:16:03 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:16:03 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:16:18 | syncing options from database +[SYS] 2026/01/20 - 00:16:18 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:16:18 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:16:32 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:16:32 | 任务进度轮询完成 +[GIN] 2026/01/20 - 00:16:40 | 202601200016406102417625AnhAUaw | 200 | 157.954µs | ::1 | GET /login +[GIN] 2026/01/20 - 00:16:40 | 20260120001640620272690E7ohrPwQ | 200 | 1.441782ms | ::1 | GET /assets/i18n-CM-fgV6w.js +[GIN] 2026/01/20 - 00:16:40 | 20260120001640620458818Xa3h9rHX | 200 | 2.113849ms | ::1 | GET /assets/react-components-C-LuPRGm.js +[GIN] 2026/01/20 - 00:16:40 | 20260120001640620389347k9n30I0e | 200 | 3.409747ms | ::1 | GET /assets/tools-CuH1b7IF.js +[GIN] 2026/01/20 - 00:16:40 | 20260120001640620019618r8HwatJP | 200 | 5.458472ms | ::1 | GET /assets/react-core-XXF3bvz8.js +[GIN] 2026/01/20 - 00:16:40 | 20260120001640625808030d3KPeEgg | 200 | 122.702µs | ::1 | GET /.well-known/appspecific/com.chrome.devtools.json +[GIN] 2026/01/20 - 00:16:40 | 20260120001640623111088Ayv1g6Ye | 200 | 10.342418ms | ::1 | GET /assets/semi-ui-oSS6FHvu.css +[GIN] 2026/01/20 - 00:16:40 | 20260120001640625589218uT86yFOV | 200 | 12.448626ms | ::1 | GET /assets/index-CJowMZRu.css +[GIN] 2026/01/20 - 00:16:40 | 20260120001640620377628fOnsU79J | 200 | 42.688769ms | ::1 | GET /assets/semi-ui-DcSuht6H.js +[GIN] 2026/01/20 - 00:16:40 | 202601200016406198996928KI3Z5yW | 200 | 151.731816ms | ::1 | GET /assets/index--sb4Mim2.js +[GIN] 2026/01/20 - 00:16:41 | 202601200016411011052UZcwCyr7 | 200 | 1.117096ms | ::1 | GET /api/status +[GIN] 2026/01/20 - 00:16:41 | 2026012000164117004123NXu1aatV | 200 | 198.301µs | ::1 | GET /logo.png +[SYS] 2026/01/20 - 00:16:47 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:16:47 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:17:01 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:17:01 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:17:16 | syncing options from database +[SYS] 2026/01/20 - 00:17:16 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:17:16 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:17:30 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:17:30 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:17:45 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:17:45 | 任务进度轮询完成 +[SYS] 2026/01/20 - 00:18:00 | 任务进度轮询开始 +[SYS] 2026/01/20 - 00:18:00 | 任务进度轮询完成 diff --git a/web/src/App.jsx b/web/src/App.jsx index 995c64499697..4e0cd661fb89 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -225,6 +225,14 @@ function App() { } /> + } key={location.pathname}> + + + } + /> { const [discordLoading, setDiscordLoading] = useState(false); const [oidcLoading, setOidcLoading] = useState(false); const [linuxdoLoading, setLinuxdoLoading] = useState(false); + const [nodelocLoading, setNodelocLoading] = useState(false); const [emailLoginLoading, setEmailLoginLoading] = useState(false); const [loginLoading, setLoginLoading] = useState(false); const [resetPasswordLoading, setResetPasswordLoading] = useState(false); @@ -357,6 +360,21 @@ const LoginForm = () => { } }; + // 包装的NodeLoc登录点击处理 + const handleNodeLocClick = () => { + if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { + showInfo(t('请先阅读并同意用户协议和隐私政策')); + return; + } + setNodelocLoading(true); + try { + onNodeLocOAuthClicked(status.nodeloc_client_id, { shouldLogout: true }); + } finally { + // 由于重定向,这里不会执行到,但为了完整性添加 + setTimeout(() => setNodelocLoading(false), 3000); + } + }; + // 包装的邮箱登录选项点击处理 const handleEmailLoginClick = () => { setEmailLoginLoading(true); @@ -548,6 +566,27 @@ const LoginForm = () => { )} + {status.nodeloc_oauth && ( + + )} + {status.telegram_oauth && (
{ status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || + status.nodeloc_oauth || status.telegram_oauth) && ( <> @@ -896,6 +936,7 @@ const LoginForm = () => { status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || + status.nodeloc_oauth || status.telegram_oauth ) ? renderEmailLoginForm() diff --git a/web/src/components/auth/OAuth2Callback.jsx b/web/src/components/auth/OAuth2Callback.jsx index e43e9e0330ed..eae5dc4dc49d 100644 --- a/web/src/components/auth/OAuth2Callback.jsx +++ b/web/src/components/auth/OAuth2Callback.jsx @@ -48,7 +48,10 @@ const OAuth2Callback = (props) => { const { success, message, data } = resData; if (!success) { - throw new Error(message || 'OAuth2 callback error'); + // 业务错误不重试,直接显示错误并返回 + showError(message || t('授权失败')); + navigate('/login'); + return; } if (message === 'bind') { @@ -63,15 +66,16 @@ const OAuth2Callback = (props) => { navigate('/console/token'); } } catch (error) { + // 网络错误才重试 if (retry < MAX_RETRIES) { // 递增的退避等待 await new Promise((resolve) => setTimeout(resolve, (retry + 1) * 2000)); return sendCode(code, state, retry + 1); } - // 重试次数耗尽,提示错误并返回设置页面 + // 重试次数耗尽,提示错误并返回登录页面 showError(error.message || t('授权失败')); - navigate('/console/personal'); + navigate('/login'); } }; @@ -79,10 +83,10 @@ const OAuth2Callback = (props) => { const code = searchParams.get('code'); const state = searchParams.get('state'); - // 参数缺失直接返回 + // 参数缺失直接返回登录页 if (!code) { showError(t('未获取到授权码')); - navigate('/console/personal'); + navigate('/login'); return; } diff --git a/web/src/components/auth/RegisterForm.jsx b/web/src/components/auth/RegisterForm.jsx index 6dabb516dcf1..0c5d4a1f7a5d 100644 --- a/web/src/components/auth/RegisterForm.jsx +++ b/web/src/components/auth/RegisterForm.jsx @@ -44,10 +44,12 @@ import { import { onGitHubOAuthClicked, onLinuxDOOAuthClicked, + onNodeLocOAuthClicked, onOIDCClicked, } from '../../helpers'; import OIDCIcon from '../common/logo/OIDCIcon'; import LinuxDoIcon from '../common/logo/LinuxDoIcon'; +import NodeLocIcon from '../common/logo/NodeLocIcon'; import WeChatIcon from '../common/logo/WeChatIcon'; import TelegramLoginButton from 'react-telegram-login/src'; import { UserContext } from '../../context/User'; @@ -82,6 +84,7 @@ const RegisterForm = () => { const [discordLoading, setDiscordLoading] = useState(false); const [oidcLoading, setOidcLoading] = useState(false); const [linuxdoLoading, setLinuxdoLoading] = useState(false); + const [nodelocLoading, setNodelocLoading] = useState(false); const [emailRegisterLoading, setEmailRegisterLoading] = useState(false); const [registerLoading, setRegisterLoading] = useState(false); const [verificationCodeLoading, setVerificationCodeLoading] = useState(false); @@ -305,6 +308,15 @@ const RegisterForm = () => { } }; + const handleNodeLocClick = () => { + setNodelocLoading(true); + try { + onNodeLocOAuthClicked(status.nodeloc_client_id, { shouldLogout: true }); + } finally { + setTimeout(() => setNodelocLoading(false), 3000); + } + }; + const handleEmailRegisterClick = () => { setEmailRegisterLoading(true); setShowEmailRegister(true); @@ -447,6 +459,27 @@ const RegisterForm = () => { )} + {status.nodeloc_oauth && ( + + )} + {status.telegram_oauth && (
{ status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || + status.nodeloc_oauth || status.telegram_oauth) && ( <> @@ -727,6 +761,7 @@ const RegisterForm = () => { status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || + status.nodeloc_oauth || status.telegram_oauth ) ? renderEmailRegisterForm() diff --git a/web/src/components/common/logo/NodeLocIcon.jsx b/web/src/components/common/logo/NodeLocIcon.jsx new file mode 100644 index 000000000000..ecedfbfec0f3 --- /dev/null +++ b/web/src/components/common/logo/NodeLocIcon.jsx @@ -0,0 +1,51 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Icon } from '@douyinfe/semi-ui'; + +const NodeLocIcon = (props) => { + function CustomIcon() { + return ( + + + + + + + + + + + + ); + } + + return } {...props} />; +}; + +export default NodeLocIcon; diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index 6b356468e5db..6f710cdd83d4 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -98,6 +98,10 @@ const SystemSetting = () => { LinuxDOClientId: '', LinuxDOClientSecret: '', LinuxDOMinimumTrustLevel: '', + NodeLocOAuthEnabled: '', + NodeLocClientId: '', + NodeLocClientSecret: '', + NodeLocMinimumTrustLevel: '', ServerAddress: '', // SSRF防护配置 'fetch_setting.enable_ssrf_protection': true, @@ -118,6 +122,7 @@ const SystemSetting = () => { const [showPasswordLoginConfirmModal, setShowPasswordLoginConfirmModal] = useState(false); const [linuxDOOAuthEnabled, setLinuxDOOAuthEnabled] = useState(false); + const [nodeLocOAuthEnabled, setNodeLocOAuthEnabled] = useState(false); const [emailToAdd, setEmailToAdd] = useState(''); const [domainFilterMode, setDomainFilterMode] = useState(true); const [ipFilterMode, setIpFilterMode] = useState(true); @@ -182,6 +187,7 @@ const SystemSetting = () => { case 'EmailAliasRestrictionEnabled': case 'SMTPSSLEnabled': case 'LinuxDOOAuthEnabled': + case 'NodeLocOAuthEnabled': case 'discord.enabled': case 'oidc.enabled': case 'passkey.enabled': @@ -632,6 +638,36 @@ const SystemSetting = () => { } }; + const submitNodeLocOAuth = async () => { + const options = []; + + if (originInputs['NodeLocClientId'] !== inputs.NodeLocClientId) { + options.push({ key: 'NodeLocClientId', value: inputs.NodeLocClientId }); + } + if ( + originInputs['NodeLocClientSecret'] !== inputs.NodeLocClientSecret && + inputs.NodeLocClientSecret !== '' + ) { + options.push({ + key: 'NodeLocClientSecret', + value: inputs.NodeLocClientSecret, + }); + } + if ( + originInputs['NodeLocMinimumTrustLevel'] !== + inputs.NodeLocMinimumTrustLevel + ) { + options.push({ + key: 'NodeLocMinimumTrustLevel', + value: inputs.NodeLocMinimumTrustLevel, + }); + } + + if (options.length > 0) { + await updateOptions(options); + } + }; + const submitPasskeySettings = async () => { // 使用formApi直接获取当前表单值 const formValues = formApiRef.current?.getValues() || {}; @@ -682,6 +718,9 @@ const SystemSetting = () => { if (optionKey === 'LinuxDOOAuthEnabled') { setLinuxDOOAuthEnabled(value); } + if (optionKey === 'NodeLocOAuthEnabled') { + setNodeLocOAuthEnabled(value); + } }; const handlePasswordLoginConfirm = async () => { @@ -1057,6 +1096,15 @@ const SystemSetting = () => { > {t('允许通过 Linux DO 账户登录 & 注册')} + + handleCheckboxChange('NodeLocOAuthEnabled', e) + } + > + {t('允许通过 NodeLoc 账户登录 & 注册')} + { + + + + {t( + '用以支持通过 NodeLoc 进行登录注册,' + )} + + {t('点击查看详细文档')} + + + + + + + + + + + + + + + + + {t('用以支持通过微信进行登录注册')} diff --git a/web/src/components/settings/personal/cards/AccountManagement.jsx b/web/src/components/settings/personal/cards/AccountManagement.jsx index 9c61bbf0f20d..5b7dbaa8615d 100644 --- a/web/src/components/settings/personal/cards/AccountManagement.jsx +++ b/web/src/components/settings/personal/cards/AccountManagement.jsx @@ -45,6 +45,7 @@ import { onGitHubOAuthClicked, onOIDCClicked, onLinuxDOOAuthClicked, + onNodeLocOAuthClicked, onDiscordOAuthClicked, } from '../../../../helpers'; import TwoFASetting from '../components/TwoFASetting'; @@ -447,6 +448,80 @@ const AccountManagement = ({
+ + {/* NodeLoc绑定 */} + +
+
+
+ + + + + + + + +
+
+
+ {t('NodeLoc')} +
+
+ {renderAccountInfo( + userState.user?.nodeloc_id, + t('NodeLoc ID'), + )} +
+
+
+
+ +
+
+
diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index 6e09bf43ca9c..d9d910d1e445 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -296,6 +296,15 @@ export async function onLinuxDOOAuthClicked( ); } +export async function onNodeLocOAuthClicked( + nodeloc_client_id, + options = { shouldLogout: false }, +) { + const state = await prepareOAuthState(options); + if (!state) return; + window.location.href = `https://www.nodeloc.com/oauth-provider/authorize?response_type=code&client_id=${nodeloc_client_id}&redirect_uri=${encodeURIComponent(window.location.origin + '/oauth/nodeloc')}&state=${state}&scope=openid%20profile`; +} + let channelModels = undefined; export async function loadChannelModels() { const res = await API.get('/api/models'); diff --git a/web/vite.config.js b/web/vite.config.js index 73e46212a587..4528eb8c77ec 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -29,6 +29,7 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, './src'), + '@douyinfe/semi-ui/dist/css/semi.css': path.resolve(__dirname, 'node_modules/@douyinfe/semi-ui/dist/css/semi.css'), }, }, plugins: [