e60b10256a
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.
227 lines
7.4 KiB
Go
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
|
|
}
|