Files
knowledge-governance/internal/httpapi/dingtalk_session.go
T
姚章浩 e60b10256a fix(settings): add DingTalk spaces and render the real login QR
Address two gaps in the first DingTalk-session UI:
- add an explicit admin-only '+ 新增钉钉知识库' action on the data-source
  panel; it accepts an alidocs space URL, resolves the live name/root through
  the authenticated sidecar, writes the inventory root atomically, upserts a
  deterministic source connection, and enqueues its first catalog refresh
- read DingTalk's real QR canvas pixels rather than taking a DOM screenshot;
  select by dark-pixel content, composite onto a white quiet zone, and render
  at 320x320 so the dialog contains a large complete code, not a page thumbnail

The live refresh path resolves registered roots from the connection locator,
while existing name-based DingTalk sources remain compatible.
2026-09-30 10:54:07 +08:00

227 lines
7.4 KiB
Go

package httpapi
import (
"bytes"
"context"
"encoding/json"
"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
}
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(),
})
if err != nil {
writeJSON(w, http.StatusConflict, map[string]string{"error": "space_registration_conflict"})
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
}