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
653 lines
21 KiB
Go
653 lines
21 KiB
Go
package types
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Tencent/WeKnora/internal/utils"
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Data source types and constants
|
|
const (
|
|
// Connector types
|
|
ConnectorTypeFeishu = "feishu"
|
|
// ConnectorTypeLark is Feishu's international edition (open.larksuite.com).
|
|
// It shares the Feishu connector; only the API host and tenant differ.
|
|
ConnectorTypeLark = "lark"
|
|
// ConnectorTypeFeishuDrive is the Feishu Drive (云盘) mode: syncs documents
|
|
// under a user-supplied Drive folder_token, as opposed to a Wiki space.
|
|
// Shares the feishu connector package; only resource enumeration + fetch differ.
|
|
ConnectorTypeFeishuDrive = "feishu_drive"
|
|
// ConnectorTypeLarkDrive is the Lark (international) Drive mode, the
|
|
// international counterpart of ConnectorTypeFeishuDrive.
|
|
ConnectorTypeLarkDrive = "lark_drive"
|
|
ConnectorTypeNotion = "notion"
|
|
ConnectorTypeConfluence = "confluence"
|
|
ConnectorTypeYuque = "yuque"
|
|
ConnectorTypeGitHub = "github"
|
|
ConnectorTypeGoogleDrive = "google_drive"
|
|
ConnectorTypeOneDrive = "onedrive"
|
|
ConnectorTypeDingTalk = "dingtalk"
|
|
ConnectorTypeWebCrawler = "web_crawler"
|
|
ConnectorTypeSlack = "slack"
|
|
ConnectorTypeIMAP = "imap"
|
|
ConnectorTypeRSS = "rss"
|
|
ConnectorTypeGitLab = "gitlab"
|
|
ConnectorTypeIMA = "ima"
|
|
|
|
// Sync modes
|
|
SyncModeIncremental = "incremental"
|
|
SyncModeFull = "full"
|
|
|
|
// Data source status
|
|
DataSourceStatusActive = "active"
|
|
DataSourceStatusPaused = "paused"
|
|
DataSourceStatusError = "error"
|
|
DataSourceStatusDeleted = "deleted"
|
|
|
|
// Sync log status
|
|
SyncLogStatusRunning = "running"
|
|
SyncLogStatusSuccess = "success"
|
|
SyncLogStatusPartial = "partial"
|
|
SyncLogStatusFailed = "failed"
|
|
SyncLogStatusCanceled = "canceled"
|
|
|
|
// Conflict resolution strategies
|
|
ConflictStrategyOverwrite = "overwrite"
|
|
ConflictStrategySkip = "skip"
|
|
)
|
|
|
|
// DataSource represents a configured external data source for synchronization
|
|
type DataSource struct {
|
|
// Unique identifier
|
|
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
|
|
|
|
// Workspace ID for multi-workspace isolation
|
|
TenantID uint64 `json:"tenant_id" gorm:"index"`
|
|
|
|
// Target knowledge base ID
|
|
KnowledgeBaseID string `json:"knowledge_base_id" gorm:"index"`
|
|
|
|
// User-friendly name
|
|
Name string `json:"name"`
|
|
|
|
// Connector type (feishu, notion, confluence, etc.)
|
|
Type string `json:"type" gorm:"type:varchar(50);index"`
|
|
|
|
// Encrypted configuration (API credentials, tokens, etc.)
|
|
// Stored as JSON with AES-256-GCM encryption
|
|
Config JSON `json:"config" gorm:"type:jsonb"`
|
|
|
|
// Cron expression for scheduled syncs (e.g., "0 */6 * * *" = every 6 hours)
|
|
SyncSchedule string `json:"sync_schedule"`
|
|
|
|
// Sync mode: "incremental" (recommended) or "full"
|
|
SyncMode string `json:"sync_mode" gorm:"type:varchar(20);default:'incremental'"`
|
|
|
|
// Current status: active, paused, error
|
|
Status string `json:"status" gorm:"type:varchar(32);default:'active'"`
|
|
|
|
// Conflict resolution strategy: overwrite or skip
|
|
ConflictStrategy string `json:"conflict_strategy" gorm:"type:varchar(32);default:'overwrite'"`
|
|
|
|
// Whether to sync deletions from source
|
|
SyncDeletions bool `json:"sync_deletions" gorm:"default:true"`
|
|
|
|
// Last successful sync timestamp
|
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
|
|
|
// Cursor or state for incremental sync (connector-specific)
|
|
LastSyncCursor JSON `json:"last_sync_cursor" gorm:"type:jsonb"`
|
|
|
|
// Summary of last sync result
|
|
LastSyncResult JSON `json:"last_sync_result" gorm:"type:jsonb"`
|
|
|
|
// Error message if status is "error"
|
|
ErrorMessage string `json:"error_message"`
|
|
|
|
// Number of days to keep sync logs (default: 30)
|
|
SyncLogRetentionDays int `json:"sync_log_retention_days" gorm:"default:30"`
|
|
|
|
// Creation timestamp
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
// Last update timestamp
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
|
|
// Soft delete timestamp
|
|
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
|
|
|
// Total items synced (not stored in DB, calculated on query)
|
|
TotalItemsSynced int64 `json:"total_items_synced" gorm:"-"`
|
|
|
|
// Latest sync log (not stored in DB, populated on query)
|
|
LatestSyncLog *SyncLog `json:"latest_sync_log" gorm:"-"`
|
|
}
|
|
|
|
// TableName specifies the table name for DataSource
|
|
func (d *DataSource) TableName() string {
|
|
return "data_sources"
|
|
}
|
|
|
|
// BeforeCreate hook to generate UUID
|
|
func (d *DataSource) BeforeCreate(tx *gorm.DB) error {
|
|
if d.ID == "" {
|
|
d.ID = uuid.New().String()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SyncLog records the execution of a sync task
|
|
type SyncLog struct {
|
|
// Unique identifier
|
|
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
|
|
|
|
// Reference to the data source
|
|
DataSourceID string `json:"data_source_id" gorm:"index"`
|
|
|
|
// Workspace ID
|
|
TenantID uint64 `json:"tenant_id" gorm:"index"`
|
|
|
|
// Sync status: running, success, partial, failed, canceled
|
|
Status string `json:"status" gorm:"type:varchar(32);index"`
|
|
|
|
// Sync start time
|
|
StartedAt time.Time `json:"started_at"`
|
|
|
|
// Sync completion time
|
|
FinishedAt *time.Time `json:"finished_at"`
|
|
|
|
// Total items fetched from source
|
|
ItemsTotal int `json:"items_total"`
|
|
|
|
// New items created in knowledge base
|
|
ItemsCreated int `json:"items_created"`
|
|
|
|
// Existing items updated
|
|
ItemsUpdated int `json:"items_updated"`
|
|
|
|
// Items deleted from knowledge base
|
|
ItemsDeleted int `json:"items_deleted"`
|
|
|
|
// Items skipped (no changes detected)
|
|
ItemsSkipped int `json:"items_skipped"`
|
|
|
|
// Items that failed to sync
|
|
ItemsFailed int `json:"items_failed"`
|
|
|
|
// Error details if status is "failed"
|
|
ErrorMessage string `json:"error_message"`
|
|
|
|
// Detailed sync result (JSON-encoded)
|
|
Result JSON `json:"result" gorm:"type:jsonb"`
|
|
|
|
// Creation timestamp (usually same as StartedAt)
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
// Last update timestamp
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// TableName specifies the table name for SyncLog
|
|
func (s *SyncLog) TableName() string {
|
|
return "sync_logs"
|
|
}
|
|
|
|
// BeforeCreate hook to generate UUID
|
|
func (s *SyncLog) BeforeCreate(tx *gorm.DB) error {
|
|
if s.ID == "" {
|
|
s.ID = uuid.New().String()
|
|
}
|
|
if s.StartedAt.IsZero() {
|
|
s.StartedAt = time.Now().UTC()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DataSourceConfig represents the unencrypted configuration structure
|
|
// Each connector type will have its own specific fields.
|
|
//
|
|
// Credential management lives in the dedicated /credentials subresource
|
|
// (see internal/handler/datasource_credentials.go). Secret values are never
|
|
// included in API responses — handlers serialize via dto.NewDataSourceResponse
|
|
// which strips the Credentials map by construction.
|
|
type DataSourceConfig struct {
|
|
// Common fields applicable to most connectors
|
|
Type string `json:"type"`
|
|
|
|
// OAuth/API credentials (varies by connector)
|
|
Credentials map[string]interface{} `json:"credentials"`
|
|
|
|
// Selected resource IDs to sync (e.g., folder IDs, space IDs)
|
|
ResourceIDs []string `json:"resource_ids"`
|
|
|
|
// Connector-specific configuration
|
|
Settings map[string]interface{} `json:"settings"`
|
|
|
|
// MultimodalEnabled mirrors the target knowledge base's VLM/multimodal
|
|
// setting for the current sync run. The service populates it before each fetch
|
|
// and it is never persisted (json:"-") — the KB owns the setting. Connectors
|
|
// use it to decide whether extracting embedded images for OCR is worthwhile:
|
|
// ingesting an image into a KB without VLM is rejected, so image extraction is
|
|
// skipped when this is false.
|
|
MultimodalEnabled bool `json:"-"`
|
|
}
|
|
|
|
// HasCredentials reports whether the credentials map carries any value at
|
|
// all. Used by the Update path and by the credential subresource to decide
|
|
// whether to run live-connector validation.
|
|
func (d DataSourceConfig) HasCredentials() bool {
|
|
return len(d.Credentials) > 0
|
|
}
|
|
|
|
// HasConfiguredCredentials reports whether user-facing secret credentials are
|
|
// stored. RSS feed URLs are non-secret configuration (settings); only
|
|
// auth_headers count as credentials for that connector.
|
|
func (d DataSourceConfig) HasConfiguredCredentials(connectorType string) bool {
|
|
if len(d.Credentials) == 0 {
|
|
return false
|
|
}
|
|
switch connectorType {
|
|
case ConnectorTypeRSS:
|
|
raw, ok := d.Credentials["auth_headers"]
|
|
if !ok {
|
|
return false
|
|
}
|
|
s, ok := raw.(string)
|
|
return ok && strings.TrimSpace(s) != ""
|
|
default:
|
|
return len(d.Credentials) > 0
|
|
}
|
|
}
|
|
|
|
// StripNonSecretCredentials removes non-secret values mistakenly stored in the
|
|
// credentials map before persistence.
|
|
func (d *DataSourceConfig) StripNonSecretCredentials(connectorType string) {
|
|
if d == nil || d.Credentials == nil {
|
|
return
|
|
}
|
|
switch connectorType {
|
|
case ConnectorTypeRSS:
|
|
delete(d.Credentials, "feed_urls")
|
|
if len(d.Credentials) == 0 {
|
|
d.Credentials = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Resource represents a syncable resource (document, folder, space) from external system
|
|
type Resource struct {
|
|
// Unique identifier in the external system
|
|
ExternalID string `json:"external_id"`
|
|
|
|
// Display name
|
|
Name string `json:"name"`
|
|
|
|
// Resource type (document, folder, space, page, etc.)
|
|
Type string `json:"type"`
|
|
|
|
// Optional description
|
|
Description string `json:"description"`
|
|
|
|
// URL to access in external system
|
|
URL string `json:"url"`
|
|
|
|
// Last modified time in external system
|
|
ModifiedAt time.Time `json:"modified_at"`
|
|
|
|
// For hierarchical resources (parent ID if applicable)
|
|
ParentID string `json:"parent_id,omitempty"`
|
|
|
|
// Whether this resource has children that can be expanded
|
|
HasChildren bool `json:"has_children,omitempty"`
|
|
|
|
// Additional metadata
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
// FetchedItem represents a single document/content item fetched from external source
|
|
type FetchedItem struct {
|
|
// Unique ID in the external system
|
|
ExternalID string `json:"external_id"`
|
|
|
|
// Title of the content
|
|
Title string `json:"title"`
|
|
|
|
// Content in bytes (Markdown format preferred)
|
|
Content []byte `json:"content"`
|
|
|
|
// MIME type (text/markdown, text/html, application/pdf, etc.)
|
|
ContentType string `json:"content_type"`
|
|
|
|
// Suggested file name
|
|
FileName string `json:"file_name"`
|
|
|
|
// Original URL in external system
|
|
URL string `json:"url"`
|
|
|
|
// When last modified in external system
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
|
|
// Additional metadata to preserve
|
|
Metadata map[string]string `json:"metadata"`
|
|
|
|
// Whether the item was deleted in the source
|
|
IsDeleted bool `json:"is_deleted"`
|
|
|
|
// Source resource ID (e.g., folder ID this document belongs to)
|
|
SourceResourceID string `json:"source_resource_id"`
|
|
|
|
// ReplacesSubtree, when true, tells ingestion to reconcile this item's
|
|
// sub-items: after the parent is (re)ingested, every existing knowledge item
|
|
// whose external_id starts with SubtreeChildPrefix(ExternalID) that is NOT
|
|
// listed in SubtreeKeep is deleted as stale. Used by connectors that fan one
|
|
// source node out into a parent document plus attachment/image sub-items
|
|
// (e.g. Feishu docx).
|
|
//
|
|
// PRECONDITIONS the sweep relies on — a connector setting this MUST honour:
|
|
// 1. Child external_ids are built with SubtreeChildID, so they share the
|
|
// '#' prefix the sweep matches. A child ID without that prefix is never
|
|
// swept (silently orphaned); an unrelated item that happens to start
|
|
// with the prefix would be wrongly swept.
|
|
// 2. The parent is emitted (streaming) / listed (batch) no later than its
|
|
// children, and SubtreeKeep already names every still-present child when
|
|
// the parent is ingested. The sweep runs at parent-ingest time against
|
|
// the PRIOR sync's children, so a child emitted after its parent's sweep
|
|
// but absent from SubtreeKeep could be deleted right after being added.
|
|
ReplacesSubtree bool `json:"replaces_subtree,omitempty"`
|
|
|
|
// SubtreeKeep lists the external_ids of sub-items that still exist in the
|
|
// source this sync. Consulted only when ReplacesSubtree is true: the sweep
|
|
// preserves these children even if they could not be re-ingested this cycle
|
|
// (transient download/parse failure, or an unclassifiable filename), so a
|
|
// still-present attachment never loses its previously-synced good copy. Only
|
|
// children absent from this set — genuinely removed from the source — are
|
|
// swept.
|
|
//
|
|
// CONTRACT: an empty (or nil) SubtreeKeep with ReplacesSubtree=true means
|
|
// "keep nothing" and sweeps EVERY existing child under the prefix. That is
|
|
// correct for a node whose attachments all vanished, so a connector setting
|
|
// ReplacesSubtree MUST populate SubtreeKeep with every child still present.
|
|
// The field is consumed in-process (fetch → ingest, no serialization), so
|
|
// nil and empty are equivalent here; the omitempty tag is for API/debug
|
|
// exposure only and must not be relied on to distinguish "unset" from "empty".
|
|
SubtreeKeep []string `json:"subtree_keep,omitempty"`
|
|
}
|
|
|
|
// SubtreeChildID builds the external_id of a sub-item fanned out from a parent
|
|
// source node — e.g. a docx node's attachment or embedded image. The shape is
|
|
// "<parentExternalID>#<kind>#<token>". The '#' separator is the contract the
|
|
// subtree sweep depends on (see SubtreeChildPrefix and FetchedItem.ReplacesSubtree):
|
|
// producers MUST build child IDs with this helper so the producer and the sweep
|
|
// consumer share one source of truth. kind is a short discriminator ("file",
|
|
// "image"); token is the source system's id for the child and is assumed to be
|
|
// '#'-free (Feishu/Notion/etc. tokens are).
|
|
func SubtreeChildID(parentExternalID, kind, token string) string {
|
|
return parentExternalID + "#" + kind + "#" + token
|
|
}
|
|
|
|
// SubtreeChildPrefix is the external_id prefix that matches every child of a
|
|
// parent node built with SubtreeChildID. The sweep deletes prior children whose
|
|
// external_id starts with this prefix and are absent from SubtreeKeep. It must
|
|
// move in lockstep with SubtreeChildID: both encode the same '#' separator.
|
|
func SubtreeChildPrefix(parentExternalID string) string {
|
|
return parentExternalID + "#"
|
|
}
|
|
|
|
// SyncCursor represents the position/state for incremental sync
|
|
// Connector-specific structure allows flexibility
|
|
type SyncCursor struct {
|
|
// Timestamp of last sync
|
|
LastSyncTime time.Time `json:"last_sync_time"`
|
|
|
|
// Connector-specific cursor (e.g., pagination token, offset, etc.)
|
|
ConnectorCursor map[string]interface{} `json:"connector_cursor"`
|
|
|
|
// Hash of the last full sync to detect schema changes
|
|
LastSchemaHash string `json:"last_schema_hash"`
|
|
}
|
|
|
|
// SyncResult summarizes the outcome of a sync operation
|
|
type SyncResult struct {
|
|
// Total items processed
|
|
Total int `json:"total"`
|
|
|
|
// Items created
|
|
Created int `json:"created"`
|
|
|
|
// Items updated
|
|
Updated int `json:"updated"`
|
|
|
|
// Items deleted
|
|
Deleted int `json:"deleted"`
|
|
|
|
// Items skipped (no changes)
|
|
Skipped int `json:"skipped"`
|
|
|
|
// Items that failed
|
|
Failed int `json:"failed"`
|
|
|
|
// Deleted items whose KB deletion failed (a subset of Failed). Past the
|
|
// connector cursor, so normally only a later full sync retries them.
|
|
DeletionFailed int `json:"deletion_failed,omitempty"`
|
|
|
|
// Per-item failure samples (capped), shown in the sync-log UI.
|
|
Errors []SyncItemError `json:"errors,omitempty"`
|
|
|
|
// Updated cursor for next incremental sync
|
|
NextCursor *SyncCursor `json:"next_cursor,omitempty"`
|
|
}
|
|
|
|
// SyncItemError is one user-facing failure sample. It carries a stable i18n
|
|
// Code (+ interpolation Params) so the frontend localises it to the viewer's
|
|
// language, plus a Message fallback for clients without the key. The raw API
|
|
// status/body/log_id is never stored here — that stays in the server logs.
|
|
type SyncItemError struct {
|
|
// Title is the document title (user content, not translated).
|
|
Title string `json:"title,omitempty"`
|
|
// Code is a stable key the frontend maps to a localized string, e.g.
|
|
// "feishu_rate_limited" → datasource.syncError.feishu_rate_limited.
|
|
Code string `json:"code,omitempty"`
|
|
// Params are interpolation values for the localized string, e.g. {"code":"1663"}.
|
|
Params map[string]string `json:"params,omitempty"`
|
|
// Message is a human fallback used when the client has no i18n key for Code.
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
// Display renders the sample as a single plain string for server-side use
|
|
// (logs, fatal-error detail). The localised UI string is built on the frontend
|
|
// from Code/Params; this is only a non-localised fallback.
|
|
func (e SyncItemError) Display() string {
|
|
switch {
|
|
case e.Title != "" && e.Message != "":
|
|
return e.Title + ": " + e.Message
|
|
case e.Message != "":
|
|
return e.Message
|
|
default:
|
|
return e.Title
|
|
}
|
|
}
|
|
|
|
// UnmarshalJSON keeps old sync logs readable: historically each error was a
|
|
// plain JSON string, so a bare string decodes into Message.
|
|
func (e *SyncItemError) UnmarshalJSON(b []byte) error {
|
|
var s string
|
|
if err := json.Unmarshal(b, &s); err == nil {
|
|
e.Message = s
|
|
return nil
|
|
}
|
|
type alias SyncItemError
|
|
var a alias
|
|
if err := json.Unmarshal(b, &a); err != nil {
|
|
return err
|
|
}
|
|
*e = SyncItemError(a)
|
|
return nil
|
|
}
|
|
|
|
// DataSourceSyncPayload represents the asynq task payload for data source sync
|
|
type DataSourceSyncPayload struct {
|
|
TracingContext
|
|
Initiator TaskInitiator `json:"initiator,omitempty"`
|
|
// Trigger distinguishes a user-requested run from a scheduler-created run.
|
|
Trigger string `json:"trigger,omitempty"`
|
|
|
|
// Data source ID to sync
|
|
DataSourceID string `json:"data_source_id"`
|
|
|
|
// Workspace ID
|
|
TenantID uint64 `json:"tenant_id"`
|
|
|
|
// Sync log ID (for tracking)
|
|
SyncLogID string `json:"sync_log_id"`
|
|
|
|
// Force full sync even if incremental mode is configured
|
|
ForceFull bool `json:"force_full"`
|
|
|
|
// Maximum number of items to fetch (0 = unlimited)
|
|
MaxItems int `json:"max_items,omitempty"`
|
|
}
|
|
|
|
// ToJSON converts a DataSourceConfig to the JSON blob stored in
|
|
// DataSource.Config.
|
|
//
|
|
// When SYSTEM_AES_KEY is configured, every string value inside
|
|
// Credentials is AES-256-GCM encrypted before serialization. Non-string
|
|
// values (numbers, bools, nested objects) pass through untouched. This is
|
|
// the only write path through which credentials reach the DB (the GORM
|
|
// JSON type itself is a byte passthrough), so encrypting here is
|
|
// sufficient to keep DataSource.Config at rest fully encrypted.
|
|
//
|
|
// Encryption operates on a shallow copy of Credentials to avoid mutating
|
|
// the caller's in-memory map (subsequent reads would otherwise see
|
|
// ciphertext).
|
|
func (d *DataSourceConfig) ToJSON() (JSON, error) {
|
|
if d == nil {
|
|
return nil, nil
|
|
}
|
|
out := *d
|
|
if key := utils.GetAESKey(); key != nil && len(out.Credentials) > 0 {
|
|
encCreds := make(map[string]interface{}, len(out.Credentials))
|
|
for k, v := range out.Credentials {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
if enc, err := utils.EncryptAESGCM(s, key); err == nil {
|
|
encCreds[k] = enc
|
|
continue
|
|
}
|
|
}
|
|
encCreds[k] = v
|
|
}
|
|
out.Credentials = encCreds
|
|
}
|
|
bytes, err := json.Marshal(&out)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return JSON(bytes), nil
|
|
}
|
|
|
|
// ToJSON converts a value to JSON type
|
|
func (r *SyncCursor) ToJSON() (JSON, error) {
|
|
if r == nil {
|
|
return nil, nil
|
|
}
|
|
bytes, err := json.Marshal(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return JSON(bytes), nil
|
|
}
|
|
|
|
// ToJSON converts a value to JSON type
|
|
func (r *SyncResult) ToJSON() (JSON, error) {
|
|
if r == nil {
|
|
return nil, nil
|
|
}
|
|
bytes, err := json.Marshal(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return JSON(bytes), nil
|
|
}
|
|
|
|
// ParseConfig deserializes DataSource.Config and decrypts any encrypted
|
|
// Credentials entries.
|
|
//
|
|
// DecryptStoredSecret transparently handles three cases per credential:
|
|
// - empty string: untouched
|
|
// - legacy plaintext (no enc:v1: prefix): returned as-is, so historical
|
|
// rows continue to work without a migration step
|
|
// - enc:v1: encrypted: decrypted with SYSTEM_AES_KEY; missing/rotated
|
|
// key surfaces as an error so we fail loudly rather than handing
|
|
// ciphertext to the upstream connector as the credential
|
|
func (d *DataSource) ParseConfig() (*DataSourceConfig, error) {
|
|
if len(d.Config) == 0 {
|
|
return nil, nil
|
|
}
|
|
var config DataSourceConfig
|
|
if err := json.Unmarshal(d.Config, &config); err != nil {
|
|
return nil, err
|
|
}
|
|
for k, v := range config.Credentials {
|
|
s, ok := v.(string)
|
|
if !ok || s == "" {
|
|
continue
|
|
}
|
|
if plain, ok := utils.DecryptStoredSecretLenient(s); ok {
|
|
config.Credentials[k] = plain
|
|
continue
|
|
}
|
|
// Same rationale as the other Scan paths: don't fail the load —
|
|
// blank the field so the row stays visible. ParseConfig callers
|
|
// then see an empty credential string; HasCredentials() returns
|
|
// false; the UI surfaces "credential not configured" and the
|
|
// user can re-enter without losing the rest of the data source.
|
|
log.Printf(
|
|
"[crypto] datasource credential %q: decrypt failed (SYSTEM_AES_KEY missing/rotated?), treating as unconfigured",
|
|
k,
|
|
)
|
|
config.Credentials[k] = ""
|
|
}
|
|
return &config, nil
|
|
}
|
|
|
|
// ParseSyncCursor parses the cursor JSON
|
|
func (d *DataSource) ParseSyncCursor() (*SyncCursor, error) {
|
|
if len(d.LastSyncCursor) == 0 {
|
|
return nil, nil
|
|
}
|
|
var cursor SyncCursor
|
|
if err := json.Unmarshal(d.LastSyncCursor, &cursor); err != nil {
|
|
return nil, err
|
|
}
|
|
return &cursor, nil
|
|
}
|
|
|
|
// ParseSyncResult parses the result JSON
|
|
func (d *DataSource) ParseSyncResult() (*SyncResult, error) {
|
|
if len(d.LastSyncResult) == 0 {
|
|
return nil, nil
|
|
}
|
|
var result SyncResult
|
|
if err := json.Unmarshal(d.LastSyncResult, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// ParseSyncLogResult parses the result JSON from sync log
|
|
func (s *SyncLog) ParseResult() (*SyncResult, error) {
|
|
if len(s.Result) == 0 {
|
|
return nil, nil
|
|
}
|
|
var result SyncResult
|
|
if err := json.Unmarshal(s.Result, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|