320 lines
12 KiB
Go
320 lines
12 KiB
Go
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/<id> 匹配。
|
|
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
|
|
}
|