package httpapi import ( "bytes" "context" "encoding/json" "errors" "io" "log" "net/http" "os" "regexp" "strings" "time" "knowledge-governance/internal/governance" "knowledge-governance/internal/jobs" postgresstore "knowledge-governance/internal/platform/postgres" ) // DingTalk login proxy. The scan-code login itself lives in the // dingtalk-login sidecar (headless Chromium); the API is the authenticated // gate and browser relay, so no QR or token crosses the web origin without a // session. classifyRole keeps every non-GET at engineer+; capture replacement // remains a deliberate human action. // // POST /api/v1/dingtalk-session -> start a login session {space?} // GET /api/v1/dingtalk-session/{id} -> poll state (qr_png while waiting) // DELETE /api/v1/dingtalk-session/{id} -> abort a session func dingtalkSidecarURL() string { return strings.TrimRight(strings.TrimSpace(os.Getenv("DINGTALK_LOGIN_SIDECAR_URL")), "/") } func requireDingtalkIdentity(w http.ResponseWriter, r *http.Request) bool { _, ok := requireSessionIdentity(w, r) return ok } func startDingtalkSession(w http.ResponseWriter, r *http.Request) { if !requireDingtalkIdentity(w, r) { return } var body struct { Space string `json:"space"` } if r.Body != nil && r.ContentLength != 0 { if err := decodeJSONStrict(r, &body); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_request_body"}) return } } payload, _ := json.Marshal(map[string]string{"space": strings.TrimSpace(body.Space)}) status, data := proxyDingtalk(http.MethodPost, "/sessions", payload) log.Printf("dingtalk-login session started by %s (status %d)", actorFor(r), status) writeProxyResult(w, status, data) } func getDingtalkSession(w http.ResponseWriter, r *http.Request) { if !requireDingtalkIdentity(w, r) { return } id := safeSessionID(r.PathValue("id")) if id == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_session_id"}) return } status, data := proxyDingtalk(http.MethodGet, "/sessions/"+id, nil) writeProxyResult(w, status, data) } func deleteDingtalkSession(w http.ResponseWriter, r *http.Request) { if !requireDingtalkIdentity(w, r) { return } id := safeSessionID(r.PathValue("id")) if id == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_session_id"}) return } status, data := proxyDingtalk(http.MethodDelete, "/sessions/"+id, nil) log.Printf("dingtalk-login session %s closed by %s", id, actorFor(r)) writeProxyResult(w, status, data) } // registerDingtalkSpace resolves a DingTalk knowledge-base space URL through // the login sidecar and, on success, registers the returned space as a // source connection (deterministic id, enabled, manual-only schedule until an // admin sets an interval). Re-registering the same space re-syncs name/root. func registerDingtalkSpace(w http.ResponseWriter, r *http.Request, config Config) { if !requireDingtalkIdentity(w, r) { return } operationID, ok := requireOperationID(w, r) if !ok { return } var input struct { URL string `json:"url"` } if err := decodeJSONStrict(r, &input); err != nil || strings.TrimSpace(input.URL) == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "url is required"}) return } // dws 已经持有有效凭据(access 自动续期、refresh 一个月),登记也该走它: // 只有 dws 解析不出这个空间时,才回落到老的扫码侧车。 if registered, ok := registerSpaceThroughDWS(w, r, config, strings.TrimSpace(input.URL), operationID); ok || registered { return } payload, _ := json.Marshal(map[string]string{"url": strings.TrimSpace(input.URL)}) status, data := proxyDingtalk(http.MethodPost, "/spaces/add", payload) if status != http.StatusOK { if status == 0 { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dingtalk_login_unavailable"}) return } // Relay the sidecar's error (login_required / space_not_found / // name_conflict) verbatim; bodies are machine codes, no secrets. writeProxyResult(w, status, data) return } var resolved struct { Key string `json:"key"` Name string `json:"name"` Root string `json:"root"` } if err := json.Unmarshal(data, &resolved); err != nil || resolved.Key == "" || resolved.Name == "" || resolved.Root == "" { writeJSON(w, http.StatusBadGateway, map[string]string{"error": "invalid sidecar registration response"}) return } store, ok := config.Store.(interface { RegisterDingtalkSpace(context.Context, postgresstore.RegisterDingtalkSpaceInput) (string, error) }) if !ok { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "registration_unavailable"}) return } spaceID := extractDingtalkSpaceID(input.URL) if spaceID == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid space url"}) return } connectionID, err := store.RegisterDingtalkSpace(r.Context(), postgresstore.RegisterDingtalkSpaceInput{ RootKey: resolved.Key, Name: resolved.Name, SpaceID: spaceID, RootID: resolved.Root, Now: config.Now(), Actor: actorFor(r), }) switch { case errors.Is(err, postgresstore.ErrDingtalkSpaceNameTaken): writeJSON(w, http.StatusConflict, map[string]string{ "error": "space_name_conflict", "hint": "已有同名来源仍在使用(未下架):请先在该来源卡片上「去掉来源」,或确认选的是同一个知识库空间。", }) return case errors.Is(err, postgresstore.ErrDingtalkSpaceIdentityConflict): writeJSON(w, http.StatusConflict, map[string]string{ "error": "space_identity_conflict", "hint": "这个登记键已指向另一个知识库空间:请刷新列表后重试;若仍失败,把该来源「去掉来源」后再登记。", }) return case err != nil: // 真正的数据库/内部故障不能伪装成"冲突":换个 500 才有人去查日志。 writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "space_registration_failed"}) return } // First scan is enqueued right away: the new connection has no catalog // head yet, so the generic sources/{id}/refresh endpoint would 404 until // the first snapshot exists. if enqueuer, ok := config.Store.(interface { EnqueueCatalogRefreshWithOperation(context.Context, governance.CatalogSource, string, time.Time) (jobs.Job, bool, error) }); ok { if _, _, enqueueErr := enqueuer.EnqueueCatalogRefreshWithOperation(r.Context(), governance.CatalogSource{ ConnectionID: connectionID, SourceType: governance.SourceTypeDingTalk, Name: resolved.Name, }, operationID+":first", config.Now().UTC()); enqueueErr != nil { log.Printf("dingtalk space %s registered but first refresh enqueue failed: %v", connectionID, enqueueErr) } } log.Printf("dingtalk space %q registered as connection %s by %s", resolved.Name, connectionID, actorFor(r)) writeJSON(w, http.StatusCreated, map[string]any{"data": map[string]string{ "connection_id": connectionID, "name": resolved.Name, }}) } var dingtalkSpacePattern = regexp.MustCompile(`/spaces/([A-Za-z0-9_-]+)`) func extractDingtalkSpaceID(raw string) string { match := dingtalkSpacePattern.FindStringSubmatch(raw) if len(match) == 2 { return match[1] } trimmed := strings.TrimSpace(raw) if regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`).MatchString(trimmed) { return trimmed } return "" } func safeSessionID(id string) string { id = strings.TrimSpace(id) if id == "" || len(id) > 64 { return "" } for _, c := range id { if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') { return "" } } return id } func writeProxyResult(w http.ResponseWriter, status int, data []byte) { if status == 0 || data == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dingtalk_login_unavailable"}) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write(data) } func proxyDingtalk(method, path string, body []byte) (int, []byte) { base := dingtalkSidecarURL() if base == "" { return 0, nil } var reader io.Reader if body != nil { reader = bytes.NewReader(body) } req, err := http.NewRequest(method, base+path, reader) if err != nil { return 0, nil } if body != nil { req.Header.Set("Content-Type", "application/json") } client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { return 0, nil } defer resp.Body.Close() data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if err != nil { return resp.StatusCode, nil } return resp.StatusCode, data } // resolveSpaceViaDWS 用 dws 列举结果定位一个空间:返回确定性登记键、名称与根节点。 // 认不出就 ok=false,调用方回落到老的扫码侧车(历史空间仍能被登记)。 func resolveSpaceViaDWS(ctx context.Context, lister dingtalkSourcesLister, rawURL string) (rootKey, name, rootID, spaceID string, ok bool) { spaceID = extractDingtalkSpaceID(rawURL) if lister == nil || spaceID == "" { return "", "", "", "", false } discovered, err := lister.ListAvailableDingtalkSources(ctx) if err != nil { return "", "", "", "", false } for _, base := range discovered.KnowledgeBases { if base.SpaceID == spaceID { // 知识库:URL 里的 id 就是空间 id,抓取侧按 spaces/ 匹配。 return "space-" + spaceID, base.Name, spaceID, spaceID, true } } for _, space := range discovered.DriveSpaces { if space.SpaceID == spaceID && space.RootFolderID != "" { return "space-" + spaceID, space.Name, space.RootFolderID, spaceID, true } } return "", "", "", "", false } // registerSpaceThroughDWS 用 dws 凭据直接登记来源,不再要求扫码会话。 func registerSpaceThroughDWS(w http.ResponseWriter, r *http.Request, config Config, rawURL, operationID string) (bool, bool) { ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() rootKey, name, rootID, spaceID, ok := resolveSpaceViaDWS(ctx, config.DingtalkSources, rawURL) if !ok { return false, false } store, wired := config.Store.(interface { RegisterDingtalkSpace(context.Context, postgresstore.RegisterDingtalkSpaceInput) (string, error) }) if !wired { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "registration_unavailable"}) return true, false } connectionID, err := store.RegisterDingtalkSpace(ctx, postgresstore.RegisterDingtalkSpaceInput{ RootKey: rootKey, Name: name, SpaceID: spaceID, RootID: rootID, Now: config.Now(), Actor: actorFor(r), }) switch { case errors.Is(err, postgresstore.ErrDingtalkSpaceNameTaken): writeJSON(w, http.StatusConflict, map[string]string{"error": "space_name_conflict", "hint": "已有同名来源,但空间标识不一致;请核对已有来源,不能按名称判定添加成功。"}) return true, false case errors.Is(err, postgresstore.ErrDingtalkSpaceIdentityConflict): writeJSON(w, http.StatusConflict, map[string]string{ "error": "space_identity_conflict", "hint": "这个登记键已指向另一个知识库空间:请刷新列表后重试。", }) return true, false case err != nil: writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "space_registration_failed"}) return true, false } if enqueuer, wired := config.Store.(interface { EnqueueCatalogRefreshWithOperation(context.Context, governance.CatalogSource, string, time.Time) (jobs.Job, bool, error) }); wired { if _, _, enqueueErr := enqueuer.EnqueueCatalogRefreshWithOperation(ctx, governance.CatalogSource{ ConnectionID: connectionID, SourceType: governance.SourceTypeDingTalk, Name: name, }, operationID+":first", config.Now().UTC()); enqueueErr != nil { log.Printf("dingtalk space %s registered but first refresh enqueue failed: %v", connectionID, enqueueErr) } } log.Printf("dingtalk space %q registered as connection %s by %s (dws)", name, connectionID, actorFor(r)) writeJSON(w, http.StatusCreated, map[string]any{"data": map[string]string{ "connection_id": connectionID, "name": name, }}) return true, true }