WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+241
View File
@@ -0,0 +1,241 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/application/service"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/handler/dto"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// registerByInviteRequest is the body for /auth/register-by-invite.
//
// Email is collected from the invitee themselves: share-link rows have
// no specific invitee, so we cannot pre-bind any address. The token
// IS the authorisation; the email is whatever account the invitee
// wants to register.
type registerByInviteRequest struct {
Token string `json:"token" binding:"required"`
Email string `json:"email" binding:"required,email"`
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=3"`
}
// invitationLookupResponse is the public projection of a share-link
// row returned by /auth/invitations/lookup. Kept narrow on purpose:
// just enough for the registration page to render context ("X invited
// you to Y") without exposing inviter audit fields.
type invitationLookupResponse struct {
TenantID uint64 `json:"tenant_id"`
TenantName string `json:"tenant_name,omitempty"`
Role types.TenantRole `json:"role"`
ExpiresAt string `json:"expires_at"`
}
// invitationLookupRequest carries the token in the request body
// instead of the URL path: GET /auth/invitations/:token would have
// landed the plaintext token in access logs, browser history, and
// tracing spans. POST + body keeps the token out of those surfaces
// at the cost of "this endpoint reads, why is it POST" — worth it
// because the token is the sole authorisation for registration.
type invitationLookupRequest struct {
Token string `json:"token" binding:"required"`
}
// LookupInvitationByToken godoc
// @Summary 解析共享邀请链接 token
// @Description 根据邀请链接中的 token 返回邀请上下文(空间名 / 角色 / 过期时间),
// @Description 供注册页展示。无认证;token 无效或被撤销返回 410。
// @Description 使用 POST + body 而非 GET + path,避免 token 落入访问日志 / 浏览器历史 / tracing。
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body invitationLookupRequest true "邀请 token"
// @Success 200 {object} invitationLookupResponse
// @Failure 410 {object} apperrors.AppError "链接无效或已撤销"
// @Router /auth/invitations/lookup [post]
func (h *AuthHandler) LookupInvitationByToken(c *gin.Context) {
ctx := c.Request.Context()
if h.invitationSvc == nil {
c.Error(apperrors.NewInternalServerError("invitation service unavailable"))
return
}
var req invitationLookupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(apperrors.NewValidationError("token is required").WithDetails(err.Error()))
return
}
token := strings.TrimSpace(req.Token)
if token == "" {
c.Error(apperrors.NewValidationError("token is required"))
return
}
inv, err := h.invitationSvc.LookupByToken(ctx, token)
if err != nil {
// Collapse "unknown / expired / revoked" into 410 to avoid
// leaking which slot a stolen token used to occupy.
c.Error(&apperrors.AppError{
Code: apperrors.ErrNotFound,
Message: "invitation link is invalid or has been revoked",
HTTPCode: http.StatusGone,
})
return
}
resp := invitationLookupResponse{
TenantID: inv.TenantID,
Role: inv.Role,
ExpiresAt: inv.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z07:00"),
}
if tenant, terr := h.tenantService.GetTenantByID(ctx, inv.TenantID); terr == nil && tenant != nil {
resp.TenantName = tenant.Name
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": resp,
})
}
// RegisterByInvite godoc
// @Summary 使用共享链接注册
// @Description 通过 Owner 生成的共享邀请链接 token 完成注册,绕过 invite_only 模式拦截。
// @Description 注册者自填邮箱(与 token 不绑定);注册成功后自动加入对应空间。
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body registerByInviteRequest true "邀请注册请求"
// @Success 201 {object} types.LoginResponse
// @Failure 400 {object} apperrors.AppError "请求参数错误"
// @Failure 409 {object} apperrors.AppError "邮箱已注册"
// @Failure 410 {object} apperrors.AppError "链接无效或已撤销"
// @Router /auth/register-by-invite [post]
//
// RegisterByInvite is intentionally NOT subject to the invite_only gate:
// the gate suppresses public registration, while this endpoint requires
// a valid token issued by an Owner. The token IS the authorisation.
func (h *AuthHandler) RegisterByInvite(c *gin.Context) {
ctx := c.Request.Context()
if h.invitationSvc == nil {
c.Error(apperrors.NewInternalServerError("invitation service unavailable"))
return
}
var req registerByInviteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(apperrors.NewValidationError("Invalid registration parameters").WithDetails(err.Error()))
return
}
req.Token = strings.TrimSpace(req.Token)
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
req.Username = strings.TrimSpace(req.Username)
// Password is intentionally NOT sanitized: SanitizeForLog strips
// \n, \r, \t and control chars to make a string safe to write into
// a log line — applying it to a real password would silently
// rewrite the stored credential and lock the user out. Passwords
// must never be logged, so they don't need that defence here.
if req.Token == "" || req.Email == "" || req.Username == "" || req.Password == "" {
c.Error(apperrors.NewValidationError("token, email, username and password are required"))
return
}
inv, err := h.invitationSvc.LookupByToken(ctx, req.Token)
if err != nil {
c.Error(&apperrors.AppError{
Code: apperrors.ErrNotFound,
Message: "invitation link is invalid or has been revoked",
HTTPCode: http.StatusGone,
})
return
}
// If the email already has an account we shouldn't quietly succeed
// here: register would fail with a duplicate-email error, but the
// share-link flow is "create new account + join tenant", not "join
// my existing account". Surface 409 so the SPA can prompt them to
// log in instead, and the invitation can be applied to their
// existing account via /me/invitations once we wire that path.
if existing, _ := h.userService.GetUserByEmail(ctx, req.Email); existing != nil {
c.Error(apperrors.NewConflictError(
"this email already has an account; please log in to join the workspace"))
return
}
if err := service.ValidatePasswordPolicy(req.Password, h.complexPasswordEnabled(ctx)); err != nil {
logger.Error(ctx, "Invalid password policy")
_ = c.Error(apperrors.NewValidationError(err.Error()))
return
}
user, err := h.userService.Register(ctx, &types.RegisterRequest{
Username: req.Username,
Email: req.Email,
Password: req.Password,
TenantProvisioning: types.TenantProvisioningTenantless,
})
if err != nil {
logger.Errorf(ctx, "register-by-invite: user create failed for %s: %v",
secutils.SanitizeForLog(req.Email), err)
c.Error(apperrors.NewBadRequestError(err.Error()))
return
}
// The invited tenant becomes the user's initial/default tenant. No
user.TenantID = inv.TenantID
if err := h.userService.UpdateUser(ctx, user); err != nil {
logger.Errorf(ctx, "register-by-invite: failed to set home tenant for user %s: %v", user.ID, err)
_ = h.userService.DeleteUser(ctx, user.ID)
c.Error(apperrors.NewInternalServerError("failed to finalise invited account").WithDetails(err.Error()))
return
}
if _, err := h.invitationSvc.AcceptByToken(ctx, req.Token, user.ID); err != nil {
// Race: link was revoked between Lookup and Accept. Keep the new
// account, but restore it to tenantless so it does not point at a
// tenant for which no membership was created. If even that repair
// fails, remove the half-provisioned identity.
logger.Errorf(ctx, "register-by-invite: accept failed for user %s: %v", user.ID, err)
user.TenantID = 0
if rollbackErr := h.userService.UpdateUser(ctx, user); rollbackErr != nil {
logger.Errorf(ctx, "register-by-invite: failed to restore tenantless user %s: %v", user.ID, rollbackErr)
_ = h.userService.DeleteUser(ctx, user.ID)
}
c.Error(&apperrors.AppError{
Code: apperrors.ErrNotFound,
Message: "invitation link is no longer valid; please log in to your new account",
HTTPCode: http.StatusGone,
})
return
}
accessToken, refreshToken, err := h.userService.GenerateTokens(ctx, user)
if err != nil {
logger.Errorf(ctx, "register-by-invite: token generation failed for user %s: %v", user.ID, err)
c.Error(apperrors.NewInternalServerError("token generation failed").WithDetails(err.Error()))
return
}
tenant, _ := h.tenantService.GetTenantByID(ctx, inv.TenantID)
c.JSON(http.StatusCreated, dto.NewAuthLoginResponse(&types.LoginResponse{
Success: true,
Message: "Registration successful",
User: user,
ActiveTenant: tenant,
Memberships: []types.Membership{{
TenantID: inv.TenantID,
TenantName: tenantNameOrEmpty(tenant),
Role: inv.Role,
}},
Token: accessToken,
RefreshToken: refreshToken,
}))
}