Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91dbe9ae11 | |||
| e43b7dc869 | |||
| f1f75d59ac | |||
| 46fc21bf7b | |||
| e125e758fb | |||
| c15e6c9065 | |||
| 4262fd6d71 | |||
| 335d587c89 |
@@ -30,34 +30,30 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Necessario per MinVer: deve percorrere tutta la storia Git per trovare i tag
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
dotnet-version: '9.0.x'
|
||||||
|
|
||||||
- name: Generate version.json with MinVer
|
- name: Calcola versione e genera version.json
|
||||||
run: |
|
run: |
|
||||||
# Fetch all tags for MinVer to work correctly
|
# Calcola versione tramite git describe (non richiede dotnet build)
|
||||||
git fetch --tags --force
|
git fetch --tags --force
|
||||||
|
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||||
# Build project to trigger MinVer (calcola versione automaticamente)
|
if [ -z "$LATEST_TAG" ]; then
|
||||||
cd Data_Coupler
|
echo "Warning: Nessun tag Git trovato. Uso fallback."
|
||||||
dotnet build -c Release /p:ContinuousIntegrationBuild=true
|
VERSION="2.3.2"
|
||||||
|
else
|
||||||
# Extract version calculated by MinVer from build output
|
VERSION="${LATEST_TAG#v}"
|
||||||
VERSION=$(dotnet msbuild -getProperty:Version -p:ContinuousIntegrationBuild=true 2>/dev/null | tail -1)
|
|
||||||
|
|
||||||
# Fallback if MinVer fails (no tags)
|
|
||||||
if [ -z "$VERSION" ] || [ "$VERSION" = "0.0.0-alpha.0" ]; then
|
|
||||||
echo "Warning: No git tags found. MinVer returned default. Using fallback."
|
|
||||||
VERSION="2.1.0-alpha.0.$(git rev-list --count HEAD)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "MinVer calculated version: $VERSION"
|
echo "Versione calcolata: $VERSION (da tag: $LATEST_TAG)"
|
||||||
|
|
||||||
# Create version.json
|
# Genera version.json
|
||||||
cat > wwwroot/version.json <<EOF
|
cat > Data_Coupler/wwwroot/version.json <<EOF
|
||||||
{
|
{
|
||||||
"version": "${VERSION}",
|
"version": "${VERSION}",
|
||||||
"commitSha": "${GITHUB_SHA:0:7}",
|
"commitSha": "${GITHUB_SHA:0:7}",
|
||||||
@@ -68,8 +64,10 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo "Generated version.json:"
|
echo "Generated version.json:"
|
||||||
cat wwwroot/version.json
|
cat Data_Coupler/wwwroot/version.json
|
||||||
cd ..
|
|
||||||
|
# Esporta la versione come variabile d'ambiente per il Docker build
|
||||||
|
echo "APP_VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -136,6 +134,7 @@ jobs:
|
|||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
# Aumenta timeout per registry lenti
|
# Aumenta timeout per registry lenti
|
||||||
build-args: |
|
build-args: |
|
||||||
|
APP_VERSION=${{ env.APP_VERSION }}
|
||||||
BUILDKIT_STEP_LOG_MAX_SIZE=50000000
|
BUILDKIT_STEP_LOG_MAX_SIZE=50000000
|
||||||
provenance: false
|
provenance: false
|
||||||
sbom: false
|
sbom: false
|
||||||
@@ -159,7 +158,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository with Git
|
- name: Checkout repository with Git
|
||||||
run: |
|
run: |
|
||||||
git clone --depth 1 --branch ${{ github.ref_name }} https://alessio:%REGISTRY_TOKEN%@gitea.home-nas-ds.org/${{ github.repository }}.git .
|
git clone --branch ${{ github.ref_name }} https://alessio:%REGISTRY_TOKEN%@gitea.home-nas-ds.org/${{ github.repository }}.git .
|
||||||
if not exist Dockerfile.windows (
|
if not exist Dockerfile.windows (
|
||||||
echo ERROR: Dockerfile.windows not found
|
echo ERROR: Dockerfile.windows not found
|
||||||
exit /b 1
|
exit /b 1
|
||||||
@@ -175,33 +174,26 @@ jobs:
|
|||||||
dotnet --version
|
dotnet --version
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Generate version.json with MinVer
|
- name: Calcola versione e genera version.json
|
||||||
run: |
|
run: |
|
||||||
# Fetch all tags for MinVer to work correctly
|
# Calcola versione tramite git describe (non richiede dotnet build)
|
||||||
git fetch --tags --force
|
git fetch --tags --force
|
||||||
|
$LATEST_TAG = git describe --tags --abbrev=0 2>$null
|
||||||
# Build project to trigger MinVer
|
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($LATEST_TAG)) {
|
||||||
cd Data_Coupler
|
Write-Host "Warning: Nessun tag Git trovato. Uso fallback."
|
||||||
dotnet build -c Release /p:ContinuousIntegrationBuild=true
|
$VERSION = "2.3.2"
|
||||||
|
} else {
|
||||||
# Extract version calculated by MinVer
|
$VERSION = $LATEST_TAG -replace '^v', ''
|
||||||
$VERSION = dotnet msbuild -getProperty:Version -p:ContinuousIntegrationBuild=true 2>$null | Select-Object -Last 1
|
|
||||||
|
|
||||||
# Fallback if MinVer fails (no tags)
|
|
||||||
if ([string]::IsNullOrWhiteSpace($VERSION) -or $VERSION -eq "0.0.0-alpha.0") {
|
|
||||||
Write-Host "Warning: No git tags found. MinVer returned default. Using fallback."
|
|
||||||
$commitCount = git rev-list --count HEAD
|
|
||||||
$VERSION = "2.1.0-alpha.0.$commitCount"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "MinVer calculated version: $VERSION"
|
Write-Host "Versione calcolata: $VERSION (da tag: $LATEST_TAG)"
|
||||||
|
|
||||||
$COMMIT_SHA = "${{ github.sha }}"
|
$COMMIT_SHA = "${{ github.sha }}"
|
||||||
$SHORT_SHA = $COMMIT_SHA.Substring(0, 7)
|
$SHORT_SHA = $COMMIT_SHA.Substring(0, 7)
|
||||||
$BRANCH = "${{ github.ref_name }}"
|
$BRANCH = "${{ github.ref_name }}"
|
||||||
$BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss UTC")
|
$BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss UTC")
|
||||||
|
|
||||||
# Create version.json
|
# Genera version.json
|
||||||
$versionJson = @{
|
$versionJson = @{
|
||||||
version = $VERSION
|
version = $VERSION
|
||||||
commitSha = $SHORT_SHA
|
commitSha = $SHORT_SHA
|
||||||
@@ -210,11 +202,14 @@ jobs:
|
|||||||
buildEnvironment = "Gitea Actions"
|
buildEnvironment = "Gitea Actions"
|
||||||
} | ConvertTo-Json
|
} | ConvertTo-Json
|
||||||
|
|
||||||
$versionJson | Out-File -FilePath "wwwroot\version.json" -Encoding UTF8
|
$versionJson | Out-File -FilePath "Data_Coupler\wwwroot\version.json" -Encoding UTF8
|
||||||
|
|
||||||
Write-Host "Generated version.json:"
|
Write-Host "Generated version.json:"
|
||||||
Get-Content "wwwroot\version.json"
|
Get-Content "Data_Coupler\wwwroot\version.json"
|
||||||
cd ..
|
|
||||||
|
# Esporta la versione come variabile d'ambiente per il Docker build
|
||||||
|
"APP_VERSION=$VERSION" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||||
|
Write-Host "APP_VERSION=$VERSION esportata per Docker build"
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Debug - Verify files
|
- name: Debug - Verify files
|
||||||
@@ -265,7 +260,7 @@ jobs:
|
|||||||
)
|
)
|
||||||
|
|
||||||
echo Building Windows Docker image...
|
echo Building Windows Docker image...
|
||||||
docker build -t temp-windows -f Dockerfile.windows .
|
docker build --build-arg APP_VERSION=%APP_VERSION% -t temp-windows -f Dockerfile.windows .
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
echo Build failed!
|
echo Build failed!
|
||||||
exit /b 1
|
exit /b 1
|
||||||
|
|||||||
@@ -107,8 +107,11 @@
|
|||||||
- **Parallel Processing**: Elaborazione parallela batch multipli
|
- **Parallel Processing**: Elaborazione parallela batch multipli
|
||||||
- **Performance**: 10-25x più veloce per grandi dataset
|
- **Performance**: 10-25x più veloce per grandi dataset
|
||||||
- **Riduzione API Calls**: 60-90% in meno chiamate
|
- **Riduzione API Calls**: 60-90% in meno chiamate
|
||||||
|
- **Batch Describe Metadata**: `BatchDescribeSObjectsAsync` raggruppa le describe degli SObject in chunk da 25 (N chiamate singole → ⌈N/25⌉ richieste batch); per 200 SObject: da 201 a 9 chiamate
|
||||||
|
- **Discovery Parallela**: `DiscoverEntitySummariesAsync` e `DiscoverEntitiesAsync` eseguite in parallelo; UI interattiva dopo le summaries, dettagli completano in background
|
||||||
|
|
||||||
#### Metodi Batch Implementati:
|
#### Metodi Batch Implementati:
|
||||||
|
- `BatchDescribeSObjectsAsync`: Describe batch SObject tramite Composite API (max 25 per request) — discovery metadati ottimizzata
|
||||||
- `BatchExecuteQueriesAsync`: Esecuzione parallela multiple query SOQL
|
- `BatchExecuteQueriesAsync`: Esecuzione parallela multiple query SOQL
|
||||||
- `BatchFindEntitiesByKeysAsync`: Ricerca batch entità con diverse chiavi
|
- `BatchFindEntitiesByKeysAsync`: Ricerca batch entità con diverse chiavi
|
||||||
- `BatchGetEntitiesByIdsAsync`: Recupero batch tramite ID (max 200 per query)
|
- `BatchGetEntitiesByIdsAsync`: Recupero batch tramite ID (max 200 per query)
|
||||||
@@ -117,6 +120,10 @@
|
|||||||
- `ExtractLargeDatasetAsync`: Estrattore intelligente con auto-detect strategia
|
- `ExtractLargeDatasetAsync`: Estrattore intelligente con auto-detect strategia
|
||||||
- `ExtractRecentlyModifiedAsync`: Sincronizzazione incrementale
|
- `ExtractRecentlyModifiedAsync`: Sincronizzazione incrementale
|
||||||
|
|
||||||
|
#### Correzioni Scheduler (Febbraio 2026):
|
||||||
|
- **ExternalIdRelationshipsJson / DefaultValuesJson preservati**: Fix ai blocchi di update profilo esistente in `DataCoupler.razor.cs` — i campi JSON venivano ignorati nella copia e quindi azzerati; ora entrambi i path (riattivazione + sovrascrittura) li propagano correttamente
|
||||||
|
- **Esclusione campi External ID dal mapping normale**: In `ScheduledProfileExecutionService.TransformRecordForRest`, i campi sorgente usati nelle External ID Relationships vengono ora esclusi dal loop di field mapping standard (comportamento allineato alla UI manuale)
|
||||||
|
|
||||||
#### File Chiave:
|
#### File Chiave:
|
||||||
- `Data_Coupler/Pages/DataCoupler.razor.cs`
|
- `Data_Coupler/Pages/DataCoupler.razor.cs`
|
||||||
- `DataConnection/REST/Implementations/SalesforceServiceClient.cs`
|
- `DataConnection/REST/Implementations/SalesforceServiceClient.cs`
|
||||||
@@ -528,8 +535,8 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Versione**: 2.1
|
**Versione**: 2.2
|
||||||
**Ultimo Aggiornamento**: 2 Febbraio 2026
|
**Ultimo Aggiornamento**: 20 Febbraio 2026
|
||||||
**Framework**: .NET 9.0
|
**Framework**: .NET 9.0
|
||||||
**Sviluppatore**: Alessio Dalsanto
|
**Sviluppatore**: Alessio Dalsanto
|
||||||
**Repository**: https://github.com/AlessioDalsi/Data-Coupler
|
**Repository**: https://github.com/AlessioDalsi/Data-Coupler
|
||||||
|
|||||||
@@ -31,6 +31,42 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Necessario per MinVer: deve percorrere tutta la storia Git per trovare i tag
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '9.0.x'
|
||||||
|
|
||||||
|
- name: Calcola versione e genera version.json
|
||||||
|
run: |
|
||||||
|
git fetch --tags --force
|
||||||
|
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||||
|
if [ -z "$LATEST_TAG" ]; then
|
||||||
|
echo "Warning: Nessun tag Git trovato su questo remote. Uso fallback."
|
||||||
|
VERSION="2.3.2"
|
||||||
|
else
|
||||||
|
VERSION="${LATEST_TAG#v}"
|
||||||
|
fi
|
||||||
|
echo "Versione calcolata: $VERSION (da tag: $LATEST_TAG)"
|
||||||
|
|
||||||
|
# Genera version.json
|
||||||
|
cat > Data_Coupler/wwwroot/version.json <<EOF
|
||||||
|
{
|
||||||
|
"version": "${VERSION}",
|
||||||
|
"commitSha": "${GITHUB_SHA:0:7}",
|
||||||
|
"branch": "${GITHUB_REF_NAME}",
|
||||||
|
"buildDate": "$(date -u +"%Y-%m-%d %H:%M:%S UTC")",
|
||||||
|
"buildEnvironment": "GitHub Actions"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Generated version.json:"
|
||||||
|
cat Data_Coupler/wwwroot/version.json
|
||||||
|
|
||||||
|
echo "APP_VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -75,6 +111,8 @@ jobs:
|
|||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ env.APP_VERSION }}
|
||||||
|
|
||||||
- name: Generate artifact attestation
|
- name: Generate artifact attestation
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
@@ -95,6 +133,42 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Necessario per MinVer: deve percorrere tutta la storia Git per trovare i tag
|
||||||
|
|
||||||
|
- name: Calcola versione e genera version.json
|
||||||
|
run: |
|
||||||
|
git fetch --tags --force
|
||||||
|
$LATEST_TAG = git describe --tags --abbrev=0 2>$null
|
||||||
|
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($LATEST_TAG)) {
|
||||||
|
Write-Host "Warning: Nessun tag Git trovato su questo remote. Uso fallback."
|
||||||
|
$VERSION = "2.3.2"
|
||||||
|
} else {
|
||||||
|
$VERSION = $LATEST_TAG -replace '^v', ''
|
||||||
|
}
|
||||||
|
Write-Host "Versione calcolata: $VERSION (da tag: $LATEST_TAG)"
|
||||||
|
|
||||||
|
$COMMIT_SHA = "${{ github.sha }}"
|
||||||
|
$SHORT_SHA = $COMMIT_SHA.Substring(0, 7)
|
||||||
|
$BRANCH = "${{ github.ref_name }}"
|
||||||
|
$BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss UTC")
|
||||||
|
|
||||||
|
# Genera version.json
|
||||||
|
$versionJson = @{
|
||||||
|
version = $VERSION
|
||||||
|
commitSha = $SHORT_SHA
|
||||||
|
branch = $BRANCH
|
||||||
|
buildDate = $BUILD_DATE
|
||||||
|
buildEnvironment = "GitHub Actions"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$versionJson | Out-File -FilePath "Data_Coupler\wwwroot\version.json" -Encoding UTF8
|
||||||
|
|
||||||
|
Write-Host "Generated version.json:"
|
||||||
|
Get-Content "Data_Coupler\wwwroot\version.json"
|
||||||
|
|
||||||
|
"APP_VERSION=$VERSION" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -128,7 +202,7 @@ jobs:
|
|||||||
$imageName = "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}".ToLower()
|
$imageName = "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}".ToLower()
|
||||||
|
|
||||||
# Build with temporary tag
|
# Build with temporary tag
|
||||||
docker build -t "${imageName}:temp-windows" -f Dockerfile.windows .
|
docker build --build-arg "APP_VERSION=$env:APP_VERSION" -t "${imageName}:temp-windows" -f Dockerfile.windows .
|
||||||
|
|
||||||
# Parse and push all tags
|
# Parse and push all tags
|
||||||
$tags = "${{ steps.meta.outputs.tags }}" -split "`n"
|
$tags = "${{ steps.meta.outputs.tags }}" -split "`n"
|
||||||
|
|||||||
@@ -13,6 +13,30 @@
|
|||||||
- **Backup e Ripristino**: Sistema completo di backup/restore per configurazioni e dati
|
- **Backup e Ripristino**: Sistema completo di backup/restore per configurazioni e dati
|
||||||
- **Amministrazione Avanzata**: Interfaccia unificata per gestione sistema e sicurezza
|
- **Amministrazione Avanzata**: Interfaccia unificata per gestione sistema e sicurezza
|
||||||
|
|
||||||
|
## 🚀 **NUOVE FUNZIONALITÀ - Salesforce Optimizations (Febbraio 2026)**
|
||||||
|
|
||||||
|
### Salesforce Batch Describe via Composite API
|
||||||
|
**Data Aggiornamento**: Febbraio 2026
|
||||||
|
|
||||||
|
La discovery dei metadati Salesforce è stata ottimizzata tramite la Composite Batch API:
|
||||||
|
|
||||||
|
#### **`BatchDescribeSObjectsAsync`** (nuovo metodo privato in `SalesforceServiceClient`)
|
||||||
|
- Raggruppa i nomi degli SObject in chunk da 25
|
||||||
|
- Ogni chunk viene inviato come singola `POST /services/data/vXX.0/composite/batch`
|
||||||
|
- I risultati vengono processati in parallelo via `Task.WhenAll`
|
||||||
|
- **Risparmio concreto**: per 200 SObject, da 201 chiamate API a sole 9
|
||||||
|
|
||||||
|
#### **Discovery Parallela in `RESTMethod.cs`**
|
||||||
|
- `DiscoverEntitySummariesAsync` (rapida, 1 chiamata) e `DiscoverEntitiesAsync` (batch) partono in parallelo
|
||||||
|
- La lista entità diventa interattiva dopo ~0.3 s; i dettagli completano in background
|
||||||
|
- `StateHasChanged()` chiamato dopo le summaries per aggiornare subito la UI
|
||||||
|
|
||||||
|
#### **Fix Scheduler: External ID Relationships e Default Values**
|
||||||
|
- **Bug 1** (`DataCoupler.razor.cs`): in entrambi i blocchi di update profilo esistente (riattivazione profilo inattivo + sovrascrittura profilo attivo), i campi `ExternalIdRelationshipsJson` e `DefaultValuesJson` venivano omessi nella copia → cancellati silenziosamente ad ogni re-salvataggio
|
||||||
|
- **Bug 2** (`ScheduledProfileExecutionService.cs`): `TransformRecordForRest` non escludeva i campi sorgente usati nelle External ID Relationships dal loop di mapping normale, causando dati duplicati nell'entità destinazione (stessa logica già presente nella UI manuale, ora allineata allo scheduler)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🚀 **NUOVE FUNZIONALITÀ - Salesforce Batch Extraction**
|
## 🚀 **NUOVE FUNZIONALITÀ - Salesforce Batch Extraction**
|
||||||
|
|
||||||
### Miglioramenti Significativi alle Performance REST
|
### Miglioramenti Significativi alle Performance REST
|
||||||
@@ -1151,7 +1175,7 @@ builder.Services.AddScoped<Data_Coupler.Services.IBackupService, Data_Coupler.Se
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Versione**: 1.0
|
**Versione**: 1.1
|
||||||
**Ultimo Aggiornamento**: Settembre 2024
|
**Ultimo Aggiornamento**: 20 Febbraio 2026
|
||||||
**Framework**: .NET 9.0
|
**Framework**: .NET 9.0
|
||||||
**Sviluppatore**: Alessio Dalsanto
|
**Sviluppatore**: Alessio Dalsanto
|
||||||
@@ -109,6 +109,7 @@ public class DataCouplerProfileService : IDataCouplerProfileService
|
|||||||
existingProfile.DestinationTable = profile.DestinationTable;
|
existingProfile.DestinationTable = profile.DestinationTable;
|
||||||
existingProfile.DestinationEndpoint = profile.DestinationEndpoint;
|
existingProfile.DestinationEndpoint = profile.DestinationEndpoint;
|
||||||
existingProfile.FieldMappingJson = profile.FieldMappingJson;
|
existingProfile.FieldMappingJson = profile.FieldMappingJson;
|
||||||
|
existingProfile.DefaultValuesJson = profile.DefaultValuesJson;
|
||||||
existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson;
|
existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson;
|
||||||
existingProfile.SourceKeyField = profile.SourceKeyField;
|
existingProfile.SourceKeyField = profile.SourceKeyField;
|
||||||
existingProfile.UseRecordAssociations = profile.UseRecordAssociations;
|
existingProfile.UseRecordAssociations = profile.UseRecordAssociations;
|
||||||
|
|||||||
@@ -175,70 +175,43 @@ namespace DataConnection.REST.Implementations
|
|||||||
var entities = new List<RestEntityInfo>();
|
var entities = new List<RestEntityInfo>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// First, get list of all SObjects
|
// Step 1: get list of all SObjects (1 API call)
|
||||||
var sobjectsEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/";
|
var sobjectsEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/";
|
||||||
var response = await _httpClient.GetAsync(sobjectsEndpoint, cancellationToken);
|
var response = await _httpClient.GetAsync(sobjectsEndpoint, cancellationToken);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var sobjectsResponse = await response.Content.ReadFromJsonAsync<SalesforceSObjectsResponse>(cancellationToken: cancellationToken); if (sobjectsResponse?.SObjects != null)
|
var sobjectsResponse = await response.Content.ReadFromJsonAsync<SalesforceSObjectsResponse>(cancellationToken: cancellationToken);
|
||||||
|
if (sobjectsResponse?.SObjects != null)
|
||||||
{
|
{
|
||||||
// For demo purposes, limit to first 20 objects to avoid too many API calls
|
var sObjectNames = sobjectsResponse.SObjects
|
||||||
var limitedSObjects = sobjectsResponse.SObjects.ToList();
|
.Where(s => !string.IsNullOrEmpty(s.Name))
|
||||||
|
.Select(s => s.Name!)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
// Process SObjects in parallel for better performance
|
Console.WriteLine($"DiscoverEntities: {sObjectNames.Count} SObjects. Using Composite Batch API ({Math.Ceiling((double)sObjectNames.Count / 25)} request(s) instead of {sObjectNames.Count}).");
|
||||||
var semaphore = new SemaphoreSlim(20, 20); // Limit concurrent requests to 5
|
|
||||||
var tasks = limitedSObjects.Where(sobject => !string.IsNullOrEmpty(sobject.Name))
|
// Step 2: batch describe all SObjects via Composite Batch API (25 per request)
|
||||||
.Select(async sobject =>
|
var describeResults = await BatchDescribeSObjectsAsync(sObjectNames, cancellationToken);
|
||||||
|
|
||||||
|
foreach (var sobject in sobjectsResponse.SObjects)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(sobject.Name)) continue;
|
||||||
|
if (!describeResults.TryGetValue(sobject.Name, out var describeResult) || describeResult?.Fields == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var entityInfo = new RestEntityInfo { Name = sobject.Name };
|
||||||
|
foreach (var field in describeResult.Fields)
|
||||||
{
|
{
|
||||||
await semaphore.WaitAsync(cancellationToken);
|
if (string.IsNullOrEmpty(field.Name)) continue;
|
||||||
try
|
entityInfo.Properties.Add(new RestPropertyInfo
|
||||||
{
|
{
|
||||||
// Get detailed field information for each SObject
|
Name = field.Name,
|
||||||
var describeEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/{sobject.Name}/describe/";
|
Type = field.Type ?? "string",
|
||||||
var describeResponse = await _httpClient.GetAsync(describeEndpoint, cancellationToken);
|
IsKey = field.Name.Equals("Id", StringComparison.OrdinalIgnoreCase)
|
||||||
|
});
|
||||||
if (describeResponse.IsSuccessStatusCode)
|
}
|
||||||
{
|
entities.Add(entityInfo);
|
||||||
var describeResult = await describeResponse.Content.ReadFromJsonAsync<SalesforceDescribeResponse>(cancellationToken: cancellationToken);
|
}
|
||||||
|
|
||||||
if (describeResult?.Fields != null)
|
|
||||||
{
|
|
||||||
var entityInfo = new RestEntityInfo
|
|
||||||
{
|
|
||||||
Name = sobject.Name
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (var field in describeResult.Fields)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(field.Name)) continue;
|
|
||||||
|
|
||||||
var propInfo = new RestPropertyInfo
|
|
||||||
{
|
|
||||||
Name = field.Name,
|
|
||||||
Type = field.Type ?? "string",
|
|
||||||
IsKey = field.Name.Equals("Id", StringComparison.OrdinalIgnoreCase)
|
|
||||||
};
|
|
||||||
entityInfo.Properties.Add(propInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
return entityInfo;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Error describing SObject {sobject.Name}: {ex.Message}");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
semaphore.Release();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var results = await Task.WhenAll(tasks);
|
|
||||||
entities.AddRange(results.Where(result => result != null)!);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (HttpRequestException ex)
|
catch (HttpRequestException ex)
|
||||||
@@ -382,6 +355,116 @@ namespace DataConnection.REST.Implementations
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Describes multiple SObjects in batches using the Salesforce Composite Batch API.
|
||||||
|
/// Reduces API calls from N (one per object) to ceil(N/25) by grouping up to 25 describe
|
||||||
|
/// requests per Composite Batch call.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Dictionary<string, SalesforceDescribeResponse?>> BatchDescribeSObjectsAsync(
|
||||||
|
List<string> sObjectNames, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const int maxBatchSize = 25;
|
||||||
|
var allResults = new Dictionary<string, SalesforceDescribeResponse?>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Split into batches of 25 (Salesforce Composite Batch limit)
|
||||||
|
var batches = new List<(List<string> Names, int BatchNumber)>();
|
||||||
|
for (int i = 0; i < sObjectNames.Count; i += maxBatchSize)
|
||||||
|
{
|
||||||
|
var chunk = sObjectNames.Skip(i).Take(maxBatchSize).ToList();
|
||||||
|
batches.Add((chunk, (i / maxBatchSize) + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"BatchDescribeSObjects: {sObjectNames.Count} objects → {batches.Count} Composite Batch request(s)");
|
||||||
|
|
||||||
|
var batchEndpoint = $"{_instanceUrl}/services/data/v60.0/composite/batch";
|
||||||
|
|
||||||
|
// Execute all batches in parallel
|
||||||
|
var batchTasks = batches.Select(async b =>
|
||||||
|
{
|
||||||
|
Console.WriteLine($"BatchDescribeSObjects: sending batch {b.BatchNumber}/{batches.Count} ({b.Names.Count} objects)");
|
||||||
|
var batchRequest = new SalesforceBatchDescribeRequest
|
||||||
|
{
|
||||||
|
BatchRequests = b.Names.Select(name => new SalesforceBatchDescribeSubRequest
|
||||||
|
{
|
||||||
|
Method = "GET",
|
||||||
|
Url = $"/services/data/v60.0/sobjects/{name}/describe/"
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
var jsonContent = new StringContent(
|
||||||
|
JsonSerializer.Serialize(batchRequest, SalesforceJsonOptions),
|
||||||
|
System.Text.Encoding.UTF8,
|
||||||
|
"application/json"
|
||||||
|
);
|
||||||
|
|
||||||
|
var batchResults = new Dictionary<string, SalesforceDescribeResponse?>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.PostAsync(batchEndpoint, jsonContent, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var err = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
Console.WriteLine($"BatchDescribeSObjects batch {b.BatchNumber} failed: {response.StatusCode} - {err}");
|
||||||
|
foreach (var name in b.Names) batchResults[name] = null;
|
||||||
|
return batchResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var batchResponse = JsonSerializer.Deserialize<SalesforceBatchDescribeResponse>(responseContent, SalesforceJsonOptions);
|
||||||
|
|
||||||
|
if (batchResponse?.Results != null)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < b.Names.Count; i++)
|
||||||
|
{
|
||||||
|
var objectName = b.Names[i];
|
||||||
|
if (i >= batchResponse.Results.Count)
|
||||||
|
{
|
||||||
|
batchResults[objectName] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var subResponse = batchResponse.Results[i];
|
||||||
|
if (subResponse.StatusCode >= 200 && subResponse.StatusCode < 300 && subResponse.Result.HasValue)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
batchResults[objectName] = JsonSerializer.Deserialize<SalesforceDescribeResponse>(
|
||||||
|
subResponse.Result.Value.GetRawText(), SalesforceJsonOptions);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"BatchDescribeSObjects: failed to parse describe for {objectName}: {ex.Message}");
|
||||||
|
batchResults[objectName] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"BatchDescribeSObjects: describe for {objectName} returned status {subResponse.StatusCode}");
|
||||||
|
batchResults[objectName] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"BatchDescribeSObjects: exception in batch {b.BatchNumber}: {ex.Message}");
|
||||||
|
foreach (var name in b.Names) batchResults[name] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return batchResults;
|
||||||
|
});
|
||||||
|
|
||||||
|
var allBatchResults = await Task.WhenAll(batchTasks);
|
||||||
|
foreach (var batchResult in allBatchResults)
|
||||||
|
foreach (var kvp in batchResult)
|
||||||
|
allResults[kvp.Key] = kvp.Value;
|
||||||
|
|
||||||
|
var successCount = allResults.Values.Count(v => v != null);
|
||||||
|
Console.WriteLine($"BatchDescribeSObjects completed: {successCount}/{sObjectNames.Count} objects described successfully.");
|
||||||
|
return allResults;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new SObject in Salesforce.
|
/// Creates a new SObject in Salesforce.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1631,6 +1714,43 @@ namespace DataConnection.REST.Implementations
|
|||||||
public string? NextRecordsUrl { get; set; }
|
public string? NextRecordsUrl { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Composite Batch API models (for parallel describe calls) =====
|
||||||
|
|
||||||
|
private class SalesforceBatchDescribeRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("batchRequests")]
|
||||||
|
public List<SalesforceBatchDescribeSubRequest> BatchRequests { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SalesforceBatchDescribeSubRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("method")]
|
||||||
|
public string Method { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("url")]
|
||||||
|
public string Url { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SalesforceBatchDescribeResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("hasErrors")]
|
||||||
|
public bool HasErrors { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("results")]
|
||||||
|
public List<SalesforceBatchDescribeSubResponse> Results { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SalesforceBatchDescribeSubResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("statusCode")]
|
||||||
|
public int StatusCode { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("result")]
|
||||||
|
public JsonElement? Result { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Composite API models (for create/update/query operations) =====
|
||||||
|
|
||||||
private class SalesforceCompositeRequest
|
private class SalesforceCompositeRequest
|
||||||
{
|
{
|
||||||
[JsonPropertyName("compositeRequest")]
|
[JsonPropertyName("compositeRequest")]
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CredentialManager", "Creden
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Components", "Components\Components.csproj", "{B5114CAC-3E03-4150-B93C-652882F66CB7}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Components", "Components\Components.csproj", "{B5114CAC-3E03-4150-B93C-652882F66CB7}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MachineGuard", "MachineGuard\MachineGuard.csproj", "{AFF3AD52-0356-4879-A0C8-67819611445A}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MachineGuardSetup", "MachineGuardSetup\MachineGuardSetup.csproj", "{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -69,6 +73,30 @@ Global
|
|||||||
{B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x64.Build.0 = Release|Any CPU
|
{B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x86.ActiveCfg = Release|Any CPU
|
{B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x86.Build.0 = Release|Any CPU
|
{B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<ProjectReference Include="..\DataConnection\DataConnection.csproj" />
|
<ProjectReference Include="..\DataConnection\DataConnection.csproj" />
|
||||||
<ProjectReference Include="..\CredentialManager\CredentialManager.csproj" />
|
<ProjectReference Include="..\CredentialManager\CredentialManager.csproj" />
|
||||||
<ProjectReference Include="..\Components\Components.csproj" />
|
<ProjectReference Include="..\Components\Components.csproj" />
|
||||||
|
<ProjectReference Include="..\MachineGuard\MachineGuard.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -140,23 +140,29 @@ public partial class DataCoupler : ComponentBase
|
|||||||
|
|
||||||
Logger.LogInformation("Autenticazione completata con successo per il servizio REST {ServiceType}", credential.ServiceType);
|
Logger.LogInformation("Autenticazione completata con successo per il servizio REST {ServiceType}", credential.ServiceType);
|
||||||
|
|
||||||
// Discovery delle entità disponibili usando il metodo batch ottimizzato
|
// Avvia entrambe le discovery in parallelo:
|
||||||
Logger.LogInformation("Iniziando discovery batch delle entità REST...");
|
// - DiscoverEntitySummariesAsync è veloce (1 API call) → sblocca la UI subito
|
||||||
restEntities = await currentRestDiscovery.DiscoverEntitySummariesAsync();
|
// - DiscoverEntitiesAsync è pesante (batch describe) → completa in background
|
||||||
isRestConnected = true;
|
Logger.LogInformation("Avvio discovery parallela: entity summaries + entity details (batch)...");
|
||||||
|
|
||||||
Logger.LogInformation("Discovery batch completato: trovate {EntityCount} entità REST", restEntities.Count);
|
var summariesTask = currentRestDiscovery.DiscoverEntitySummariesAsync();
|
||||||
|
var entitiesTask = currentRestDiscovery.DiscoverEntitiesAsync();
|
||||||
// Carica anche i dettagli completi delle entità per External ID Relationships
|
|
||||||
|
// Attendi le summaries (veloci) e rendi la UI interattiva immediatamente
|
||||||
|
restEntities = await summariesTask;
|
||||||
|
isRestConnected = true;
|
||||||
|
StateHasChanged();
|
||||||
|
Logger.LogInformation("Entity summaries completate: {EntityCount} entità. UI interattiva.", restEntities.Count);
|
||||||
|
|
||||||
|
// Attendi i dettagli completi (già in esecuzione in parallelo)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Logger.LogInformation("Caricamento dettagli entità per External ID Relationships...");
|
availableRelationshipObjects = await entitiesTask;
|
||||||
availableRelationshipObjects = await currentRestDiscovery.DiscoverEntitiesAsync();
|
Logger.LogInformation("Entity details (batch) completati: {Count} oggetti disponibili per External ID Relationships.", availableRelationshipObjects.Count);
|
||||||
Logger.LogInformation("Caricati {Count} oggetti disponibili per External ID Relationships", availableRelationshipObjects.Count);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogWarning(ex, "Impossibile caricare i dettagli delle entità per External ID Relationships");
|
Logger.LogWarning(ex, "Impossibile completare il caricamento dei dettagli entità per External ID Relationships");
|
||||||
availableRelationshipObjects = new List<RestEntityInfo>();
|
availableRelationshipObjects = new List<RestEntityInfo>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,8 +441,26 @@ public partial class DataCoupler : ComponentBase
|
|||||||
if (relationships != null && relationships.Any())
|
if (relationships != null && relationships.Any())
|
||||||
{
|
{
|
||||||
externalIdRelationships.Clear();
|
externalIdRelationships.Clear();
|
||||||
externalIdRelationships.AddRange(relationships);
|
|
||||||
Logger.LogInformation("External ID Relationships caricate - Totale: {Count}", externalIdRelationships.Count);
|
// Normalizza i RelationshipName in base al tipo di oggetto destinazione
|
||||||
|
bool isDestinationCustom = selectedRestEntity?.Name?.EndsWith("__c") ?? false;
|
||||||
|
|
||||||
|
foreach (var rel in relationships)
|
||||||
|
{
|
||||||
|
// Normalizza il RelationshipName
|
||||||
|
string normalizedName = NormalizeRelationshipName(rel.RelatedObjectName, isDestinationCustom);
|
||||||
|
|
||||||
|
if (normalizedName != rel.RelationshipName)
|
||||||
|
{
|
||||||
|
Logger.LogInformation("Normalizzato RelationshipName: {Old} → {New} (Destination: {Destination}, IsCustom: {IsCustom})",
|
||||||
|
rel.RelationshipName, normalizedName, selectedRestEntity?.Name, isDestinationCustom);
|
||||||
|
rel.RelationshipName = normalizedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
externalIdRelationships.Add(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.LogInformation("External ID Relationships caricate e normalizzate - Totale: {Count}", externalIdRelationships.Count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -546,6 +564,8 @@ public partial class DataCoupler : ComponentBase
|
|||||||
existingProfile.DestinationTable = profile.DestinationTable;
|
existingProfile.DestinationTable = profile.DestinationTable;
|
||||||
existingProfile.DestinationEndpoint = profile.DestinationEndpoint;
|
existingProfile.DestinationEndpoint = profile.DestinationEndpoint;
|
||||||
existingProfile.FieldMappingJson = profile.FieldMappingJson;
|
existingProfile.FieldMappingJson = profile.FieldMappingJson;
|
||||||
|
existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson;
|
||||||
|
existingProfile.DefaultValuesJson = profile.DefaultValuesJson;
|
||||||
existingProfile.SourceKeyField = profile.SourceKeyField;
|
existingProfile.SourceKeyField = profile.SourceKeyField;
|
||||||
existingProfile.UseRecordAssociations = profile.UseRecordAssociations;
|
existingProfile.UseRecordAssociations = profile.UseRecordAssociations;
|
||||||
existingProfile.IsActive = true;
|
existingProfile.IsActive = true;
|
||||||
@@ -579,6 +599,8 @@ public partial class DataCoupler : ComponentBase
|
|||||||
existingProfile.DestinationTable = profile.DestinationTable;
|
existingProfile.DestinationTable = profile.DestinationTable;
|
||||||
existingProfile.DestinationEndpoint = profile.DestinationEndpoint;
|
existingProfile.DestinationEndpoint = profile.DestinationEndpoint;
|
||||||
existingProfile.FieldMappingJson = profile.FieldMappingJson;
|
existingProfile.FieldMappingJson = profile.FieldMappingJson;
|
||||||
|
existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson;
|
||||||
|
existingProfile.DefaultValuesJson = profile.DefaultValuesJson;
|
||||||
existingProfile.SourceKeyField = profile.SourceKeyField;
|
existingProfile.SourceKeyField = profile.SourceKeyField;
|
||||||
existingProfile.UseRecordAssociations = profile.UseRecordAssociations;
|
existingProfile.UseRecordAssociations = profile.UseRecordAssociations;
|
||||||
|
|
||||||
@@ -1550,20 +1572,13 @@ public partial class DataCoupler : ComponentBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determina il nome della relazione in base al tipo di oggetto
|
// Determina il nome della relazione usando il metodo helper
|
||||||
// Salesforce: oggetti STANDARD usano solo il nome (es. "Account")
|
bool isDestinationCustom = selectedRestEntity?.Name?.EndsWith("__c") ?? false;
|
||||||
// oggetti CUSTOM (finiscono con __c) usano __r (es. "CustomObject__r")
|
string relationshipName = NormalizeRelationshipName(selectedRelationshipObject, isDestinationCustom);
|
||||||
string relationshipName;
|
|
||||||
if (selectedRelationshipObject.EndsWith("__c"))
|
Logger.LogDebug("Creazione relazione - Destinazione: {Destination} (Custom: {IsCustom}), Correlato: {Related}, RelationshipName: {RelationshipName}",
|
||||||
{
|
selectedRestEntity?.Name, isDestinationCustom, selectedRelationshipObject, relationshipName);
|
||||||
// Oggetto custom: rimuovi __c e aggiungi __r
|
|
||||||
relationshipName = selectedRelationshipObject.Replace("__c", "__r");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Oggetto standard: usa solo il nome
|
|
||||||
relationshipName = selectedRelationshipObject;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crea la relazione
|
// Crea la relazione
|
||||||
var relationship = new ExternalIdRelationshipDto
|
var relationship = new ExternalIdRelationshipDto
|
||||||
@@ -1606,6 +1621,47 @@ public partial class DataCoupler : ComponentBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizza il nome della relazione in base al tipo di oggetto destinazione.
|
||||||
|
/// Salesforce External ID Relationships:
|
||||||
|
/// - Se l'oggetto DESTINAZIONE è CUSTOM → usa sempre __r per tutte le relazioni
|
||||||
|
/// - Se l'oggetto DESTINAZIONE è STANDARD → usa __r solo per oggetti custom correlati
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="relatedObjectName">Nome dell'oggetto correlato (es. "Account", "Custom_Company__c")</param>
|
||||||
|
/// <param name="isDestinationCustom">True se l'oggetto destinazione è custom</param>
|
||||||
|
/// <returns>Nome normalizzato della relazione (es. "Account__r", "Account", "Custom_Company__r")</returns>
|
||||||
|
private string NormalizeRelationshipName(string relatedObjectName, bool isDestinationCustom)
|
||||||
|
{
|
||||||
|
if (isDestinationCustom)
|
||||||
|
{
|
||||||
|
// Destinazione CUSTOM: tutte le relazioni usano __r
|
||||||
|
if (relatedObjectName.EndsWith("__c"))
|
||||||
|
{
|
||||||
|
// Oggetto correlato custom: rimuovi __c e aggiungi __r
|
||||||
|
return relatedObjectName.Replace("__c", "__r");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Oggetto correlato standard: aggiungi __r
|
||||||
|
return relatedObjectName + "__r";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Destinazione STANDARD: solo oggetti custom correlati usano __r
|
||||||
|
if (relatedObjectName.EndsWith("__c"))
|
||||||
|
{
|
||||||
|
// Oggetto correlato custom: rimuovi __c e aggiungi __r
|
||||||
|
return relatedObjectName.Replace("__c", "__r");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Oggetto correlato standard: usa solo il nome
|
||||||
|
return relatedObjectName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private List<string> GetExternalIdFieldsForSelectedObject()
|
private List<string> GetExternalIdFieldsForSelectedObject()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(selectedRelationshipObject))
|
if (string.IsNullOrEmpty(selectedRelationshipObject))
|
||||||
@@ -2838,25 +2894,42 @@ public partial class DataCoupler : ComponentBase
|
|||||||
if (!trimmedQuery.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase))
|
if (!trimmedQuery.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Lista di parole chiave vietate per sicurezza
|
// Parole chiave complete: devono essere token SQL isolati (\bKEYWORD\b)
|
||||||
var forbiddenKeywords = new[]
|
// Evita falsi positivi su nomi di colonne come UpdateDate, CreateDate, DeletedAt, ecc.
|
||||||
|
var forbiddenFullKeywords = new[]
|
||||||
{
|
{
|
||||||
"INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE",
|
"INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE",
|
||||||
"EXEC", "EXECUTE", "sp_", "xp_", "BULK", "OPENROWSET", "OPENDATASOURCE"
|
"EXEC", "EXECUTE", "BULK", "OPENROWSET", "OPENDATASOURCE"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Prefissi pericolosi: devono iniziare la parola (sp_anything, xp_anything)
|
||||||
|
// Non si usa \bprefix\b perché _ è un word-char e mancherebbe sp_executesql, xp_cmdshell, ecc.
|
||||||
|
var forbiddenPrefixes = new[] { "SP_", "XP_" };
|
||||||
|
|
||||||
var upperQuery = trimmedQuery.ToUpperInvariant();
|
var upperQuery = trimmedQuery.ToUpperInvariant();
|
||||||
|
|
||||||
// Verifica che non contenga parole chiave vietate
|
// Verifica parole chiave complete con word boundary
|
||||||
foreach (var keyword in forbiddenKeywords)
|
foreach (var keyword in forbiddenFullKeywords)
|
||||||
{
|
{
|
||||||
if (upperQuery.Contains(keyword))
|
var pattern = $@"\b{System.Text.RegularExpressions.Regex.Escape(keyword)}\b";
|
||||||
|
if (System.Text.RegularExpressions.Regex.IsMatch(upperQuery, pattern))
|
||||||
{
|
{
|
||||||
Logger.LogWarning("Query rifiutata: contiene parola chiave vietata '{Keyword}'", keyword);
|
Logger.LogWarning("Query rifiutata: contiene parola chiave vietata '{Keyword}'", keyword);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verifica prefissi stored procedure: \bSP_ cattura sp_anything, xp_anything
|
||||||
|
foreach (var prefix in forbiddenPrefixes)
|
||||||
|
{
|
||||||
|
var pattern = $@"\b{System.Text.RegularExpressions.Regex.Escape(prefix)}";
|
||||||
|
if (System.Text.RegularExpressions.Regex.IsMatch(upperQuery, pattern))
|
||||||
|
{
|
||||||
|
Logger.LogWarning("Query rifiutata: contiene prefisso stored procedure vietato '{Prefix}'", prefix);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using CredentialManager;
|
|||||||
using Data_Coupler.Services;
|
using Data_Coupler.Services;
|
||||||
using Data_Coupler.BackgroundServices;
|
using Data_Coupler.BackgroundServices;
|
||||||
using CredentialManager.Services;
|
using CredentialManager.Services;
|
||||||
|
using MachineGuard;
|
||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
@@ -130,6 +131,9 @@ builder.Services.AddScoped<Data_Coupler.Services.IDeletionSyncService, Data_Coup
|
|||||||
// Register Background Services (solo uno per evitare duplicazioni)
|
// Register Background Services (solo uno per evitare duplicazioni)
|
||||||
builder.Services.AddHostedService<Data_Coupler.BackgroundServices.ScheduledJobService>();
|
builder.Services.AddHostedService<Data_Coupler.BackgroundServices.ScheduledJobService>();
|
||||||
|
|
||||||
|
// Register MachineGuard — protezione machine-binding tramite DPAPI
|
||||||
|
builder.Services.AddMachineGuard(builder.Configuration);
|
||||||
|
|
||||||
// Configurazione URL e timeout per servizio Windows
|
// Configurazione URL e timeout per servizio Windows
|
||||||
var urls = builder.Configuration.GetValue<string>("Urls") ?? "http://*:7550";
|
var urls = builder.Configuration.GetValue<string>("Urls") ?? "http://*:7550";
|
||||||
builder.WebHost.UseUrls(urls);
|
builder.WebHost.UseUrls(urls);
|
||||||
@@ -143,6 +147,38 @@ builder.WebHost.ConfigureKestrel(serverOptions =>
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
#region MachineGuard — verifica autorizzazione macchina
|
||||||
|
// Questa verifica deve avvenire PRIMA di qualsiasi altra inizializzazione.
|
||||||
|
// Se la macchina non è autorizzata, l'applicazione viene arrestata immediatamente.
|
||||||
|
{
|
||||||
|
var machineGuard = app.Services.GetRequiredService<IMachineGuard>();
|
||||||
|
if (!machineGuard.Verify())
|
||||||
|
{
|
||||||
|
var critLogger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||||
|
critLogger.LogCritical(
|
||||||
|
"MachineGuard: questa macchina NON è autorizzata a eseguire Data Coupler. " +
|
||||||
|
"Eseguire MachineGuardSetup.exe come Amministratore per configurare questa macchina. " +
|
||||||
|
"Applicazione arrestata.");
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var eventLog = new System.Diagnostics.EventLog("Application");
|
||||||
|
eventLog.Source = "DataCouplerService";
|
||||||
|
eventLog.WriteEntry(
|
||||||
|
"MachineGuard: macchina non autorizzata. " +
|
||||||
|
"Eseguire MachineGuardSetup.exe come Amministratore. Applicazione arrestata.",
|
||||||
|
System.Diagnostics.EventLogEntryType.Error);
|
||||||
|
}
|
||||||
|
catch { /* Ignora errori di scrittura EventLog */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
// Initialize database con timeout e retry
|
// Initialize database con timeout e retry
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -171,18 +171,25 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
|
|||||||
_logger.LogInformation("Caricate {Count} External ID Relationships dal profilo", externalIdRelationships.Count);
|
_logger.LogInformation("Caricate {Count} External ID Relationships dal profilo", externalIdRelationships.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4.6. Parse Default Values
|
||||||
|
var defaultValues = ParseDefaultValues(profile.DefaultValuesJson);
|
||||||
|
if (defaultValues.Any())
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Caricati {Count} default values dal profilo", defaultValues.Count);
|
||||||
|
}
|
||||||
|
|
||||||
// 5. Determina se utilizzare Salesforce Composite API
|
// 5. Determina se utilizzare Salesforce Composite API
|
||||||
bool useSalesforceComposite = restClient is DataConnection.REST.Implementations.SalesforceServiceClient;
|
bool useSalesforceComposite = restClient is DataConnection.REST.Implementations.SalesforceServiceClient;
|
||||||
|
|
||||||
if (useSalesforceComposite)
|
if (useSalesforceComposite)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Utilizzo Salesforce Composite API per il trasferimento");
|
_logger.LogInformation("Utilizzo Salesforce Composite API per il trasferimento");
|
||||||
return await ExecuteDataTransferWithCompositeAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, externalIdRelationships, enableDeletionSync);
|
return await ExecuteDataTransferWithCompositeAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, defaultValues, externalIdRelationships, enableDeletionSync);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Utilizzo metodo trasferimento standard per il trasferimento");
|
_logger.LogInformation("Utilizzo metodo trasferimento standard per il trasferimento");
|
||||||
return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, externalIdRelationships, enableDeletionSync);
|
return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, defaultValues, externalIdRelationships, enableDeletionSync);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -417,6 +424,53 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
|
|||||||
return relationships;
|
return relationships;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse del JSON dei default values
|
||||||
|
/// </summary>
|
||||||
|
private Dictionary<string, (object? Value, string? Type)> ParseDefaultValues(string? defaultValuesJson)
|
||||||
|
{
|
||||||
|
var defaultValues = new Dictionary<string, (object? Value, string? Type)>();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(defaultValuesJson))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("DefaultValues JSON è vuoto o null");
|
||||||
|
return defaultValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Parsing DefaultValues JSON: {Json}", defaultValuesJson);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
|
||||||
|
var deserializedDefaults = JsonSerializer.Deserialize<Dictionary<string, DefaultValueDto>>(defaultValuesJson, options);
|
||||||
|
if (deserializedDefaults != null)
|
||||||
|
{
|
||||||
|
foreach (var entry in deserializedDefaults)
|
||||||
|
{
|
||||||
|
defaultValues[entry.Key] = (entry.Value.Value, entry.Value.Type);
|
||||||
|
_logger.LogDebug("Default value: {Field} = {Value} ({Type})",
|
||||||
|
entry.Key, entry.Value.Value, entry.Value.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Trovati {Count} default values nel JSON", defaultValues.Count);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Deserializzazione ritornato null per DefaultValues JSON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Errore nel parsing dei default values: {Json}", defaultValuesJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValues;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ottiene tutti i record dal database
|
/// Ottiene tutti i record dal database
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -685,6 +739,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
|
|||||||
RestEntitySummary restEntity,
|
RestEntitySummary restEntity,
|
||||||
RestApiCredential restCredential,
|
RestApiCredential restCredential,
|
||||||
Dictionary<string, string> fieldMappings,
|
Dictionary<string, string> fieldMappings,
|
||||||
|
Dictionary<string, (object? Value, string? Type)> defaultValues,
|
||||||
List<ExternalIdRelationshipDto> externalIdRelationships,
|
List<ExternalIdRelationshipDto> externalIdRelationships,
|
||||||
bool enableDeletionSync = false)
|
bool enableDeletionSync = false)
|
||||||
{
|
{
|
||||||
@@ -699,8 +754,8 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 1. Trasforma il record utilizzando i field mappings e External ID Relationships
|
// 1. Trasforma il record utilizzando i field mappings, default values e External ID Relationships
|
||||||
var restData = TransformRecordForRest(record, fieldMappings, externalIdRelationships);
|
var restData = TransformRecordForRest(record, fieldMappings, defaultValues, externalIdRelationships);
|
||||||
|
|
||||||
// 2. Gestione associazioni record se abilitata
|
// 2. Gestione associazioni record se abilitata
|
||||||
string? entityId = null;
|
string? entityId = null;
|
||||||
@@ -810,6 +865,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
|
|||||||
RestEntitySummary restEntity,
|
RestEntitySummary restEntity,
|
||||||
RestApiCredential restCredential,
|
RestApiCredential restCredential,
|
||||||
Dictionary<string, string> fieldMappings,
|
Dictionary<string, string> fieldMappings,
|
||||||
|
Dictionary<string, (object? Value, string? Type)> defaultValues,
|
||||||
List<ExternalIdRelationshipDto> externalIdRelationships,
|
List<ExternalIdRelationshipDto> externalIdRelationships,
|
||||||
bool enableDeletionSync = false)
|
bool enableDeletionSync = false)
|
||||||
{
|
{
|
||||||
@@ -820,7 +876,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
|
|||||||
if (!(restClient is DataConnection.REST.Implementations.SalesforceServiceClient salesforceClient))
|
if (!(restClient is DataConnection.REST.Implementations.SalesforceServiceClient salesforceClient))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Client REST non è SalesforceServiceClient, fallback al metodo standard");
|
_logger.LogWarning("Client REST non è SalesforceServiceClient, fallback al metodo standard");
|
||||||
return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential, fieldMappings, externalIdRelationships, enableDeletionSync);
|
return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential, fieldMappings, defaultValues, externalIdRelationships, enableDeletionSync);
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -851,7 +907,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
|
|||||||
var recordNumber = indexedRecord.RecordNumber;
|
var recordNumber = indexedRecord.RecordNumber;
|
||||||
|
|
||||||
// Trasforma il record in base ai mapping e External ID Relationships (operazione locale, thread-safe)
|
// Trasforma il record in base ai mapping e External ID Relationships (operazione locale, thread-safe)
|
||||||
var restData = TransformRecordForRest(record, fieldMappings, externalIdRelationships);
|
var restData = TransformRecordForRest(record, fieldMappings, defaultValues, externalIdRelationships);
|
||||||
|
|
||||||
// Genera la chiave sorgente e l'hash dei dati per questo record (include MAPPING_SIGNATURE)
|
// Genera la chiave sorgente e l'hash dei dati per questo record (include MAPPING_SIGNATURE)
|
||||||
var sourceKey = GenerateSourceKey(record, profile.SourceKeyField);
|
var sourceKey = GenerateSourceKey(record, profile.SourceKeyField);
|
||||||
@@ -1144,12 +1200,30 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
|
|||||||
private Dictionary<string, object> TransformRecordForRest(
|
private Dictionary<string, object> TransformRecordForRest(
|
||||||
Dictionary<string, object> sourceRecord,
|
Dictionary<string, object> sourceRecord,
|
||||||
Dictionary<string, string> fieldMappings,
|
Dictionary<string, string> fieldMappings,
|
||||||
|
Dictionary<string, (object? Value, string? Type)> defaultValues,
|
||||||
List<ExternalIdRelationshipDto>? externalIdRelationships = null)
|
List<ExternalIdRelationshipDto>? externalIdRelationships = null)
|
||||||
{
|
{
|
||||||
var restData = new Dictionary<string, object>();
|
var restData = new Dictionary<string, object>();
|
||||||
|
|
||||||
|
// Costruisce un set dei campi sorgente usati esclusivamente come External ID Relationship:
|
||||||
|
// questi NON devono essere inviati anche come mapping normale (stessa logica della UI manuale).
|
||||||
|
var externalIdSourceFields = (externalIdRelationships != null)
|
||||||
|
? externalIdRelationships
|
||||||
|
.Where(r => !string.IsNullOrWhiteSpace(r.SourceField))
|
||||||
|
.Select(r => r.SourceField)
|
||||||
|
.ToHashSet()
|
||||||
|
: new HashSet<string>();
|
||||||
|
|
||||||
|
// 1. Applica field mappings (escludendo i campi sorgente usati per External ID Relationships)
|
||||||
foreach (var mapping in fieldMappings)
|
foreach (var mapping in fieldMappings)
|
||||||
{
|
{
|
||||||
|
// Salta il campo se è usato come sorgente in un External ID Relationship
|
||||||
|
if (externalIdSourceFields.Contains(mapping.Key))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Campo sorgente '{SourceField}' usato in External ID Relationship, escluso dal mapping normale", mapping.Key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (sourceRecord.ContainsKey(mapping.Key))
|
if (sourceRecord.ContainsKey(mapping.Key))
|
||||||
{
|
{
|
||||||
var value = sourceRecord[mapping.Key];
|
var value = sourceRecord[mapping.Key];
|
||||||
@@ -1164,7 +1238,22 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aggiungi External ID Relationships (per Salesforce)
|
// 2. Applica default values (solo se il campo non è già stato mappato)
|
||||||
|
foreach (var defaultValue in defaultValues)
|
||||||
|
{
|
||||||
|
if (!restData.ContainsKey(defaultValue.Key))
|
||||||
|
{
|
||||||
|
var (value, type) = defaultValue.Value;
|
||||||
|
if (value != null)
|
||||||
|
{
|
||||||
|
restData[defaultValue.Key] = value;
|
||||||
|
_logger.LogDebug("Applicato default value: {Field} = {Value} ({Type})",
|
||||||
|
defaultValue.Key, value, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Aggiungi External ID Relationships (per Salesforce)
|
||||||
if (externalIdRelationships != null && externalIdRelationships.Any())
|
if (externalIdRelationships != null && externalIdRelationships.Any())
|
||||||
{
|
{
|
||||||
foreach (var relationship in externalIdRelationships)
|
foreach (var relationship in externalIdRelationships)
|
||||||
|
|||||||
+7
-2
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
# Stage 1: Build
|
# Stage 1: Build
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||||
|
# Versione calcolata da MinVer sul runner CI/CD e passata come build-arg
|
||||||
|
ARG APP_VERSION=0.0.0-alpha.0
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Copia i file di progetto e ripristina le dipendenze
|
# Copia i file di progetto e ripristina le dipendenze
|
||||||
@@ -20,15 +22,18 @@ COPY . .
|
|||||||
|
|
||||||
# Build del progetto principale
|
# Build del progetto principale
|
||||||
WORKDIR "/src/Data_Coupler"
|
WORKDIR "/src/Data_Coupler"
|
||||||
RUN dotnet build "Data_Coupler.csproj" -c Release -o /app/build /p:ContinuousIntegrationBuild=true
|
RUN dotnet build "Data_Coupler.csproj" -c Release -o /app/build /p:ContinuousIntegrationBuild=true /p:MinVerVersionOverride=${APP_VERSION}
|
||||||
|
|
||||||
# Stage 2: Publish
|
# Stage 2: Publish
|
||||||
FROM build AS publish
|
FROM build AS publish
|
||||||
|
# Necessario ridichiarare ARG dopo FROM in multi-stage build
|
||||||
|
ARG APP_VERSION=0.0.0-alpha.0
|
||||||
RUN dotnet publish "Data_Coupler.csproj" -c Release -o /app/publish \
|
RUN dotnet publish "Data_Coupler.csproj" -c Release -o /app/publish \
|
||||||
/p:SelfContained=false \
|
/p:SelfContained=false \
|
||||||
/p:PublishTrimmed=false \
|
/p:PublishTrimmed=false \
|
||||||
/p:PublishSingleFile=false \
|
/p:PublishSingleFile=false \
|
||||||
/p:ContinuousIntegrationBuild=true
|
/p:ContinuousIntegrationBuild=true \
|
||||||
|
/p:MinVerVersionOverride=${APP_VERSION}
|
||||||
|
|
||||||
# Stage 3: Runtime
|
# Stage 3: Runtime
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
|
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
|
||||||
|
|||||||
+6
-2
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
# Stage 1: Build
|
# Stage 1: Build
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:9.0-nanoserver-ltsc2022 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:9.0-nanoserver-ltsc2022 AS build
|
||||||
|
# Versione calcolata da MinVer sul runner CI/CD e passata come build-arg
|
||||||
|
ARG APP_VERSION=0.0.0-alpha.0
|
||||||
WORKDIR /s
|
WORKDIR /s
|
||||||
|
|
||||||
# Copia i file di progetto e ripristina le dipendenze con nomi originali
|
# Copia i file di progetto e ripristina le dipendenze con nomi originali
|
||||||
@@ -23,11 +25,13 @@ COPY ["Components/", "Components/"]
|
|||||||
|
|
||||||
# Build del progetto principale con output path corto
|
# Build del progetto principale con output path corto
|
||||||
WORKDIR "/s/Data_Coupler"
|
WORKDIR "/s/Data_Coupler"
|
||||||
RUN dotnet build "Data_Coupler.csproj" -c Release -o /o --no-restore
|
RUN dotnet build "Data_Coupler.csproj" -c Release -o /o --no-restore /p:ContinuousIntegrationBuild=true /p:MinVerVersionOverride=%APP_VERSION%
|
||||||
|
|
||||||
# Stage 2: Publish
|
# Stage 2: Publish
|
||||||
FROM build AS publish
|
FROM build AS publish
|
||||||
RUN dotnet publish "Data_Coupler.csproj" -c Release -o /p --no-restore -r win-x64 --self-contained false
|
# Necessario ridichiarare ARG dopo FROM in multi-stage build
|
||||||
|
ARG APP_VERSION=0.0.0-alpha.0
|
||||||
|
RUN dotnet publish "Data_Coupler.csproj" -c Release -o /p --no-restore -r win-x64 --self-contained false /p:ContinuousIntegrationBuild=true /p:MinVerVersionOverride=%APP_VERSION%
|
||||||
|
|
||||||
# Stage 3: Runtime
|
# Stage 3: Runtime
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0-nanoserver-ltsc2022 AS final
|
FROM mcr.microsoft.com/dotnet/aspnet:9.0-nanoserver-ltsc2022 AS final
|
||||||
|
|||||||
@@ -196,40 +196,126 @@ VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0');
|
|||||||
|
|
||||||
## 📊 Formato Dati Salesforce
|
## 📊 Formato Dati Salesforce
|
||||||
|
|
||||||
### Esempio di Trasformazione
|
### ⚠️ REGOLA IMPORTANTE: Formato Basato sull'Oggetto DESTINAZIONE
|
||||||
|
|
||||||
|
Il formato delle External ID Relationships dipende dal **tipo dell'oggetto DESTINAZIONE** (quello che stai creando/aggiornando), **NON** dal tipo dell'oggetto correlato:
|
||||||
|
|
||||||
|
#### **Se l'Oggetto DESTINAZIONE è CUSTOM** (es. `Sales_Quote__c`, `Custom_Order__c`):
|
||||||
|
- ✅ Tutte le relazioni usano `__r`, sia per oggetti standard che custom
|
||||||
|
- **Oggetto Standard**: `"Account__r": { "External_ID__c": "value" }`
|
||||||
|
- **Oggetto Custom**: `"Custom_Company__r": { "External_ID__c": "value" }`
|
||||||
|
|
||||||
|
#### **Se l'Oggetto DESTINAZIONE è STANDARD** (es. `Opportunity`, `Contact`):
|
||||||
|
- ✅ Solo oggetti custom correlati usano `__r`
|
||||||
|
- **Oggetto Standard**: `"Account": { "External_ID__c": "value" }`
|
||||||
|
- **Oggetto Custom**: `"Custom_Company__r": { "External_ID__c": "value" }`
|
||||||
|
|
||||||
|
### Esempi Pratici
|
||||||
|
|
||||||
|
#### Esempio 1: Destinazione CUSTOM → Relazione a Oggetto STANDARD
|
||||||
|
|
||||||
|
**Scenario**: Creo un record `Sales_Quote__c` collegato ad `Account` standard
|
||||||
|
|
||||||
**Configurazione:**
|
**Configurazione:**
|
||||||
- **Relationship Name**: `Account__r`
|
- **Destination Object**: `Sales_Quote__c` (CUSTOM)
|
||||||
|
- **Relationship Name**: `Account__r` ⚠️ **Usa __r anche se Account è standard!**
|
||||||
|
- **Related Object**: `Account`
|
||||||
|
- **External ID Field**: `Codice_ERP__c`
|
||||||
|
- **Source Field**: `customerCode`
|
||||||
|
|
||||||
|
**Record Trasformato:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Name": "Quote 2024-001",
|
||||||
|
"Quote_Code__c": "Q001",
|
||||||
|
"Account__r": {
|
||||||
|
"Codice_ERP__c": "C60000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Esempio 2: Destinazione STANDARD → Relazione a Oggetto STANDARD
|
||||||
|
|
||||||
|
**Scenario**: Creo un record `Opportunity` collegato ad `Account`
|
||||||
|
|
||||||
|
**Configurazione:**
|
||||||
|
- **Destination Object**: `Opportunity` (STANDARD)
|
||||||
|
- **Relationship Name**: `Account` ⚠️ **NON usa __r**
|
||||||
- **Related Object**: `Account`
|
- **Related Object**: `Account`
|
||||||
- **External ID Field**: `Country__c`
|
- **External ID Field**: `Country__c`
|
||||||
- **Source Field**: `CountryCode` (dalla tabella sorgente)
|
- **Source Field**: `CountryCode`
|
||||||
|
|
||||||
|
**Record Trasformato:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Name": "New Deal 2024",
|
||||||
|
"StageName": "Prospecting",
|
||||||
|
"Account": {
|
||||||
|
"Country__c": "US"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Esempio 3: Destinazione CUSTOM → Relazione a Oggetto CUSTOM
|
||||||
|
|
||||||
|
**Configurazione:**
|
||||||
|
- **Destination Object**: `Sales_Quote__c` (CUSTOM)
|
||||||
|
- **Relationship Name**: `Custom_Territory__r`
|
||||||
|
- **Related Object**: `Custom_Territory__c`
|
||||||
|
- **External ID Field**: `Territory_Code__c`
|
||||||
|
- **Source Field**: `territoryCode`
|
||||||
|
|
||||||
**Record Sorgente:**
|
**Record Sorgente:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ProductName": "Widget A",
|
"quoteName": "Quote A",
|
||||||
"Price": 99.99,
|
"territoryCode": "NORTH-WEST"
|
||||||
"CountryCode": "US"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Record Trasformato per Salesforce:**
|
**Record Trasformato:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"Name": "Widget A",
|
"Name": "Quote A",
|
||||||
"Price__c": 99.99,
|
"Custom_Territory__r": {
|
||||||
"Account__r": {
|
"Territory_Code__c": "NORTH-WEST"
|
||||||
"Country__c": "US"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Logica di Normalizzazione Automatica
|
||||||
|
|
||||||
|
Il sistema implementa il metodo `NormalizeRelationshipName()` che garantisce il formato corretto:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private string NormalizeRelationshipName(string relatedObjectName, bool isDestinationCustom)
|
||||||
|
{
|
||||||
|
if (isDestinationCustom)
|
||||||
|
{
|
||||||
|
// Destinazione CUSTOM: tutte le relazioni usano __r
|
||||||
|
if (relatedObjectName.EndsWith("__c"))
|
||||||
|
return relatedObjectName.Replace("__c", "__r"); // Custom_Obj__c → Custom_Obj__r
|
||||||
|
else
|
||||||
|
return relatedObjectName + "__r"; // Account → Account__r
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Destinazione STANDARD: solo oggetti custom usano __r
|
||||||
|
if (relatedObjectName.EndsWith("__c"))
|
||||||
|
return relatedObjectName.Replace("__c", "__r"); // Custom_Obj__c → Custom_Obj__r
|
||||||
|
else
|
||||||
|
return relatedObjectName; // Account → Account (no suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Vantaggi External ID
|
### Vantaggi External ID
|
||||||
|
|
||||||
1. **Nessun ID Salesforce Richiesto**: Non serve conoscere l'ID Salesforce dell'Account
|
1. **Nessun ID Salesforce Richiesto**: Non serve conoscere l'ID Salesforce dell'Account
|
||||||
2. **Lookup Automatico**: Salesforce cerca automaticamente l'Account con `Country__c = "US"`
|
2. **Lookup Automatico**: Salesforce cerca automaticamente l'oggetto correlato tramite External ID
|
||||||
3. **Upsert Intelligente**: Se non trova l'Account, può crearlo automaticamente (se configurato)
|
3. **Upsert Intelligente**: Se non trova l'oggetto, può crearlo automaticamente (se configurato)
|
||||||
4. **Manutenzione Semplificata**: I codici esterni sono più stabili degli ID interni
|
4. **Manutenzione Semplificata**: I codici esterni sono più stabili degli ID interni
|
||||||
|
5. **Normalizzazione Automatica**: Il sistema corregge automaticamente i nomi quando carica profili salvati
|
||||||
|
|
||||||
## 🔄 Flusso Operativo
|
## 🔄 Flusso Operativo
|
||||||
|
|
||||||
@@ -307,6 +393,14 @@ VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0');
|
|||||||
- Solo dopo field mappings configurati (`fieldMappings.Any()`)
|
- Solo dopo field mappings configurati (`fieldMappings.Any()`)
|
||||||
- Migliora UX evitando confusione per altre API
|
- Migliora UX evitando confusione per altre API
|
||||||
|
|
||||||
|
5. **⭐ Normalizzazione Automatica RelationshipName (FIX CRITICO - 17 Feb 2026)**
|
||||||
|
- **Problema Risolto**: Errore `"No such column 'Account' on sobject of type Sales_Quote__c"`
|
||||||
|
- **Causa**: Il formato dipende dall'oggetto DESTINAZIONE, non dall'oggetto correlato
|
||||||
|
- **Soluzione**: Metodo `NormalizeRelationshipName()` controlla tipo oggetto destinazione
|
||||||
|
- **Funzionalità**: Corregge automaticamente i RelationshipName al caricamento profili
|
||||||
|
- **Regola**: Se destinazione è custom → usa SEMPRE `__r` per tutte le relazioni
|
||||||
|
- **Benefici**: Profili esistenti vengono corretti automaticamente senza intervento manuale
|
||||||
|
|
||||||
### Potenziali Estensioni Future
|
### Potenziali Estensioni Future
|
||||||
|
|
||||||
1. **Validazione Avanzata**: Verifica esistenza oggetto/campo su Salesforce prima di salvare
|
1. **Validazione Avanzata**: Verifica esistenza oggetto/campo su Salesforce prima di salvare
|
||||||
@@ -319,6 +413,13 @@ VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0');
|
|||||||
|
|
||||||
### Errori Comuni
|
### Errori Comuni
|
||||||
|
|
||||||
|
**⚠️ Errore: "No such column 'Account' on sobject of type Sales_Quote__c"**
|
||||||
|
- **Causa**: RelationshipName incorretto per oggetto destinazione custom
|
||||||
|
- **Spiegazione**: Quando l'oggetto DESTINAZIONE è custom (es. `Sales_Quote__c`), TUTTE le relazioni devono usare `__r`, anche per oggetti standard
|
||||||
|
- **Soluzione AUTOMATICA**: ✅ Il sistema ora normalizza automaticamente i nomi delle relazioni
|
||||||
|
- **Esempio**: Se destinazione è `Sales_Quote__c` e correlato è `Account` → usa `Account__r` (non `Account`)
|
||||||
|
- **Fix Manuale**: Se usi profili vecchi, il sistema correggerà automaticamente al caricamento
|
||||||
|
|
||||||
**Errore: "External ID field not found"**
|
**Errore: "External ID field not found"**
|
||||||
- Causa: Campo External ID non esiste sull'oggetto Salesforce
|
- Causa: Campo External ID non esiste sull'oggetto Salesforce
|
||||||
- Soluzione: Verificare che il campo sia configurato come External ID in Salesforce
|
- Soluzione: Verificare che il campo sia configurato come External ID in Salesforce
|
||||||
@@ -343,7 +444,8 @@ VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0');
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Implementazione Completata**: 3 Febbraio 2026
|
**Implementazione Iniziale**: 3 Febbraio 2026
|
||||||
|
**Ultimo Aggiornamento**: 17 Febbraio 2026 - ⭐ **FIX CRITICO**: Normalizzazione automatica RelationshipName
|
||||||
**Framework**: .NET 9.0
|
**Framework**: .NET 9.0
|
||||||
**Pattern**: Repository + DTO + Service Layer
|
**Pattern**: Repository + DTO + Service Layer
|
||||||
**Database**: SQLite con Entity Framework Core
|
**Database**: SQLite con Entity Framework Core
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace MachineGuard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementazione Windows-only della protezione machine-binding tramite DPAPI.
|
||||||
|
/// Utilizza <see cref="ProtectedData"/> con scope <see cref="DataProtectionScope.LocalMachine"/>:
|
||||||
|
/// il dato cifrato è legato fisicamente alla macchina e non può essere decifrato
|
||||||
|
/// su un'altra macchina, anche con gli stessi account o credenziali.
|
||||||
|
/// </summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
internal sealed class DpapiMachineGuard : IMachineGuard
|
||||||
|
{
|
||||||
|
private readonly MachineGuardOptions _options;
|
||||||
|
private readonly ILogger<DpapiMachineGuard> _logger;
|
||||||
|
|
||||||
|
public DpapiMachineGuard(IOptions<MachineGuardOptions> options, ILogger<DpapiMachineGuard> logger)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool Verify()
|
||||||
|
{
|
||||||
|
var secretPath = ResolveSecretFilePath();
|
||||||
|
|
||||||
|
if (!File.Exists(secretPath))
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"MachineGuard: file secret non trovato in '{Path}'. " +
|
||||||
|
"Eseguire MachineGuardSetup.exe su questa macchina per inizializzare l'autorizzazione.",
|
||||||
|
secretPath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var encryptedBytes = File.ReadAllBytes(secretPath);
|
||||||
|
var decryptedBytes = ProtectedData.Unprotect(encryptedBytes, null, DataProtectionScope.LocalMachine);
|
||||||
|
var decryptedToken = Encoding.UTF8.GetString(decryptedBytes);
|
||||||
|
|
||||||
|
if (!string.Equals(decryptedToken, MachineGuardToken.ExpectedToken, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"MachineGuard: il token decifrato non corrisponde al token atteso. " +
|
||||||
|
"Questa macchina non è autorizzata a eseguire questa applicazione.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("MachineGuard: autorizzazione macchina verificata con successo.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (CryptographicException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex,
|
||||||
|
"MachineGuard: decifrazione fallita. " +
|
||||||
|
"Il file secret potrebbe provenire da un'altra macchina o essere corrotto. " +
|
||||||
|
"Percorso: '{Path}'", secretPath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex,
|
||||||
|
"MachineGuard: errore imprevisto durante la verifica. Percorso: '{Path}'", secretPath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveSecretFilePath()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.SecretFilePath))
|
||||||
|
return _options.SecretFilePath;
|
||||||
|
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
|
||||||
|
if (string.IsNullOrEmpty(appData))
|
||||||
|
appData = @"C:\ProgramData";
|
||||||
|
|
||||||
|
return Path.Combine(appData, "DataCoupler", "machine.guard");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace MachineGuard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interfaccia per il meccanismo di protezione machine-binding tramite DPAPI.
|
||||||
|
/// </summary>
|
||||||
|
public interface IMachineGuard
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verifica che l'applicazione sia in esecuzione su una macchina autorizzata.
|
||||||
|
/// Restituisce <c>true</c> se l'autorizzazione è confermata, <c>false</c> altrimenti.
|
||||||
|
/// </summary>
|
||||||
|
bool Verify();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.0" />
|
||||||
|
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="9.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace MachineGuard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Metodi di estensione per la registrazione di MachineGuard nel container DI.
|
||||||
|
/// </summary>
|
||||||
|
public static class MachineGuardExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registra il servizio <see cref="IMachineGuard"/> nel container DI.
|
||||||
|
/// <para>
|
||||||
|
/// Se <c>MachineGuard:Enabled</c> è <c>false</c> in appsettings, viene registrato
|
||||||
|
/// un guard no-op che approva sempre la verifica (utile per sviluppo/CI).
|
||||||
|
/// Su piattaforme non-Windows, DPAPI non è disponibile e il guard viene disabilitato automaticamente.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddMachineGuard(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.Configure<MachineGuardOptions>(
|
||||||
|
configuration.GetSection(MachineGuardOptions.SectionName));
|
||||||
|
|
||||||
|
services.AddSingleton<IMachineGuard>(sp =>
|
||||||
|
{
|
||||||
|
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||||
|
var logger = loggerFactory.CreateLogger("MachineGuard.Startup");
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
// In build Debug la protezione è sempre disabilitata — nessuna configurazione richiesta.
|
||||||
|
logger.LogInformation(
|
||||||
|
"MachineGuard: build DEBUG — protezione machine-binding disabilitata automaticamente.");
|
||||||
|
return new NullMachineGuard();
|
||||||
|
#else
|
||||||
|
// In build Release la protezione è sempre attiva.
|
||||||
|
// Può essere disabilitata esplicitamente via MachineGuard:Enabled = false
|
||||||
|
// (utile per ambienti Linux/Docker o casi eccezionali).
|
||||||
|
var options = sp.GetRequiredService<IOptions<MachineGuardOptions>>();
|
||||||
|
|
||||||
|
if (!options.Value.Enabled)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"MachineGuard: protezione machine-binding DISABILITATA via configurazione. " +
|
||||||
|
"Impostare MachineGuard:Enabled = true in produzione.");
|
||||||
|
return new NullMachineGuard();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"MachineGuard: DPAPI non è disponibile su piattaforme non-Windows. " +
|
||||||
|
"La protezione machine-binding è bypassata automaticamente.");
|
||||||
|
return new NullMachineGuard();
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateWindowsGuard(sp, options);
|
||||||
|
#endif
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||||
|
private static IMachineGuard CreateWindowsGuard(
|
||||||
|
IServiceProvider sp,
|
||||||
|
IOptions<MachineGuardOptions> options)
|
||||||
|
{
|
||||||
|
var logger = sp.GetRequiredService<ILogger<DpapiMachineGuard>>();
|
||||||
|
return new DpapiMachineGuard(options, logger);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace MachineGuard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opzioni di configurazione per MachineGuard.
|
||||||
|
/// Configurabili tramite appsettings.json nella sezione "MachineGuard".
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MachineGuardOptions
|
||||||
|
{
|
||||||
|
/// <summary>Nome della sezione in appsettings.json.</summary>
|
||||||
|
public const string SectionName = "MachineGuard";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imposta a <c>false</c> per disabilitare completamente la protezione machine-binding.
|
||||||
|
/// Utile in ambienti di sviluppo o CI. Default: <c>true</c>.
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Percorso del file secret cifrato.
|
||||||
|
/// Se vuoto, viene usato il percorso predefinito:
|
||||||
|
/// Windows: %ProgramData%\DataCoupler\machine.guard
|
||||||
|
/// Linux: /etc/datacoupler/machine.guard
|
||||||
|
/// </summary>
|
||||||
|
public string? SecretFilePath { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace MachineGuard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper pubblico per la scrittura e la verifica del file secret di MachineGuard.
|
||||||
|
/// Usato da MachineGuardSetup e da eventuali script di deployment.
|
||||||
|
/// </summary>
|
||||||
|
public static class MachineGuardSetupHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Cifra il token interno con DPAPI (LocalMachine scope) e lo scrive nel percorso specificato.
|
||||||
|
/// Crea la directory di destinazione se non esiste.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="secretFilePath">
|
||||||
|
/// Percorso completo del file in cui salvare il secret cifrato.
|
||||||
|
/// Usare <see cref="GetDefaultSecretFilePath"/> per il percorso di default.
|
||||||
|
/// </param>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public static void WriteSecret(string secretFilePath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(secretFilePath);
|
||||||
|
|
||||||
|
var tokenBytes = Encoding.UTF8.GetBytes(MachineGuardToken.ExpectedToken);
|
||||||
|
var encryptedBytes = ProtectedData.Protect(tokenBytes, null, DataProtectionScope.LocalMachine);
|
||||||
|
|
||||||
|
var directory = Path.GetDirectoryName(secretFilePath);
|
||||||
|
if (!string.IsNullOrEmpty(directory))
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
File.WriteAllBytes(secretFilePath, encryptedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifica che il file secret nel percorso specificato decifrabile con successo prima del deployment.
|
||||||
|
/// </summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public static bool VerifySecret(string secretFilePath)
|
||||||
|
{
|
||||||
|
if (!File.Exists(secretFilePath))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var encryptedBytes = File.ReadAllBytes(secretFilePath);
|
||||||
|
var decryptedBytes = ProtectedData.Unprotect(encryptedBytes, null, DataProtectionScope.LocalMachine);
|
||||||
|
var decryptedToken = Encoding.UTF8.GetString(decryptedBytes);
|
||||||
|
return string.Equals(decryptedToken, MachineGuardToken.ExpectedToken, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restituisce il percorso predefinito del file secret in base al sistema operativo corrente.
|
||||||
|
/// </summary>
|
||||||
|
public static string GetDefaultSecretFilePath()
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
|
||||||
|
if (string.IsNullOrEmpty(appData))
|
||||||
|
appData = @"C:\ProgramData";
|
||||||
|
return Path.Combine(appData, "DataCoupler", "machine.guard");
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/etc/datacoupler/machine.guard";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace MachineGuard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contiene il token di machine-binding cablato nel codice.
|
||||||
|
/// Modificare questo valore per ogni distribuzione autorizzata,
|
||||||
|
/// quindi eseguire MachineGuardSetup per scrivere il secret su ogni macchina target.
|
||||||
|
/// </summary>
|
||||||
|
internal static class MachineGuardToken
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Token atteso. Deve corrispondere esattamente al valore firmato durante il setup.
|
||||||
|
/// </summary>
|
||||||
|
internal const string ExpectedToken = "DC-F47AC10B-58CC-4372-A567-0E02B2C3D479";
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace MachineGuard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementazione no-op di <see cref="IMachineGuard"/>.
|
||||||
|
/// Usata quando la protezione è disabilitata via configurazione
|
||||||
|
/// o quando il sistema operativo non supporta DPAPI (es. Linux, macOS).
|
||||||
|
/// Restituisce sempre <c>true</c> senza eseguire alcuna verifica.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class NullMachineGuard : IMachineGuard
|
||||||
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool Verify() => true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AssemblyName>MachineGuardSetup</AssemblyName>
|
||||||
|
<RootNamespace>MachineGuardSetup</RootNamespace>
|
||||||
|
<!-- Standalone: publish as single self-contained exe for easy deployment -->
|
||||||
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
<SelfContained>true</SelfContained>
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MachineGuard\MachineGuard.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
using MachineGuard;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// MachineGuardSetup — Strumento di configurazione machine-binding
|
||||||
|
// Utilizzo: eseguire come Amministratore su ogni server autorizzato
|
||||||
|
// Indipendente da Data Coupler — nessuna dipendenza dall'applicazione
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||||
|
|
||||||
|
PrintBanner();
|
||||||
|
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
PrintError("Questo strumento richiede Windows (DPAPI è un'API esclusiva di Windows).");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsRunningAsAdministrator())
|
||||||
|
{
|
||||||
|
PrintWarning("Attenzione: l'applicazione non è in esecuzione come Amministratore.");
|
||||||
|
PrintWarning("La scrittura in C:\\ProgramData potrebbe fallire senza privilegi elevati.");
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
return RunSetupWindows();
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Entry point per Windows (isolato per soddisfare l'analizzatore)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||||
|
static int RunSetupWindows()
|
||||||
|
{
|
||||||
|
var defaultPath = MachineGuardSetupHelper.GetDefaultSecretFilePath();
|
||||||
|
|
||||||
|
Console.WriteLine("╔══════════════════════════════════════════════════════════╗");
|
||||||
|
Console.WriteLine("║ CONFIGURAZIONE MACHINE-BINDING ║");
|
||||||
|
Console.WriteLine("╚══════════════════════════════════════════════════════════╝");
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine($" Percorso predefinito secret: {defaultPath}");
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
// Chiedi conferma o percorso personalizzato
|
||||||
|
Console.Write(" Usare il percorso predefinito? [S/n]: ");
|
||||||
|
var input = Console.ReadLine()?.Trim().ToUpperInvariant();
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
string targetPath;
|
||||||
|
if (string.IsNullOrEmpty(input) || input == "S" || input == "Y")
|
||||||
|
{
|
||||||
|
targetPath = defaultPath;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Write(" Inserire il percorso completo del file secret: ");
|
||||||
|
var customPath = Console.ReadLine()?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(customPath))
|
||||||
|
{
|
||||||
|
PrintError("Percorso non valido. Operazione annullata.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
targetPath = customPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica se esiste già un secret
|
||||||
|
if (File.Exists(targetPath))
|
||||||
|
{
|
||||||
|
Console.WriteLine($" ⚠ Il file secret esiste già: {targetPath}");
|
||||||
|
Console.Write(" Sovrascrivere? [s/N]: ");
|
||||||
|
var overwrite = Console.ReadLine()?.Trim().ToUpperInvariant();
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
if (overwrite != "S" && overwrite != "Y")
|
||||||
|
{
|
||||||
|
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||||
|
Console.WriteLine(" Operazione annullata dall'utente.");
|
||||||
|
Console.ResetColor();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica se il secret attuale è già valido
|
||||||
|
Console.WriteLine(" Verifica del secret esistente...");
|
||||||
|
if (MachineGuardSetupHelper.VerifySecret(targetPath))
|
||||||
|
{
|
||||||
|
Console.ForegroundColor = ConsoleColor.Green;
|
||||||
|
Console.WriteLine(" ✓ Il secret esistente è GIÀ valido per questa macchina.");
|
||||||
|
Console.ResetColor();
|
||||||
|
Console.Write(" Continuare comunque e riscrivere? [s/N]: ");
|
||||||
|
var rewrite = Console.ReadLine()?.Trim().ToUpperInvariant();
|
||||||
|
Console.WriteLine();
|
||||||
|
if (rewrite != "S" && rewrite != "Y")
|
||||||
|
{
|
||||||
|
Console.WriteLine(" Operazione annullata. Il secret esistente rimane invariato.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrittura del secret
|
||||||
|
Console.WriteLine($" Scrittura del secret cifrato con DPAPI (LocalMachine) in:");
|
||||||
|
Console.WriteLine($" {targetPath}");
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MachineGuardSetupHelper.WriteSecret(targetPath);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException ex)
|
||||||
|
{
|
||||||
|
PrintError($"Accesso negato: {ex.Message}");
|
||||||
|
PrintError("Riprovare eseguendo il programma come Amministratore (tasto destro → Esegui come amministratore).");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
PrintError($"Errore durante la scrittura del secret: {ex.Message}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica post-scrittura
|
||||||
|
Console.WriteLine(" Verifica del secret appena scritto...");
|
||||||
|
if (MachineGuardSetupHelper.VerifySecret(targetPath))
|
||||||
|
{
|
||||||
|
Console.ForegroundColor = ConsoleColor.Green;
|
||||||
|
Console.WriteLine(" ✓ Secret scritto e verificato con successo!");
|
||||||
|
Console.ResetColor();
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine(" Questa macchina è ora autorizzata a eseguire Data Coupler.");
|
||||||
|
Console.WriteLine();
|
||||||
|
PrintFileInfo(targetPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PrintError("Verifica post-scrittura fallita. Il file potrebbe essere corrotto.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine(" Premere un tasto per uscire...");
|
||||||
|
if (!Console.IsInputRedirected)
|
||||||
|
Console.ReadKey(intercept: true);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Funzioni di utilità
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static void PrintBanner()
|
||||||
|
{
|
||||||
|
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine(" ██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ██╗ ██╗██████╗ ██╗ ███████╗██████╗ ");
|
||||||
|
Console.WriteLine(" ██╔══██╗██╔══██╗╚══██╔══╝██╔══██╗ ██╔════╝██╔═══██╗██║ ██║██╔══██╗██║ ██╔════╝██╔══██╗");
|
||||||
|
Console.WriteLine(" ██║ ██║███████║ ██║ ███████║ ██║ ██║ ██║██║ ██║██████╔╝██║ █████╗ ██████╔╝");
|
||||||
|
Console.WriteLine(" ██║ ██║██╔══██║ ██║ ██╔══██║ ██║ ██║ ██║██║ ██║██╔═══╝ ██║ ██╔══╝ ██╔══██╗");
|
||||||
|
Console.WriteLine(" ██████╔╝██║ ██║ ██║ ██║ ██║ ╚██████╗╚██████╔╝╚██████╔╝██║ ███████╗███████╗██║ ██║");
|
||||||
|
Console.WriteLine(" ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝╚══════╝╚═╝ ╚═╝");
|
||||||
|
Console.ResetColor();
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.ForegroundColor = ConsoleColor.White;
|
||||||
|
Console.WriteLine(" MachineGuardSetup — Configurazione protezione machine-binding per Data Coupler");
|
||||||
|
Console.WriteLine(" Versione: 1.0 | Tecnologia: DPAPI (DataProtectionScope.LocalMachine)");
|
||||||
|
Console.ResetColor();
|
||||||
|
Console.WriteLine(new string('─', 70));
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void PrintError(string message)
|
||||||
|
{
|
||||||
|
Console.ForegroundColor = ConsoleColor.Red;
|
||||||
|
Console.WriteLine($" ✗ ERRORE: {message}");
|
||||||
|
Console.ResetColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void PrintWarning(string message)
|
||||||
|
{
|
||||||
|
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||||
|
Console.WriteLine($" ⚠ {message}");
|
||||||
|
Console.ResetColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void PrintFileInfo(string path)
|
||||||
|
{
|
||||||
|
var fi = new FileInfo(path);
|
||||||
|
Console.WriteLine(" Dettagli file:");
|
||||||
|
Console.WriteLine($" Percorso : {fi.FullName}");
|
||||||
|
Console.WriteLine($" Dimensione: {fi.Length} byte (cifrati con DPAPI)");
|
||||||
|
Console.WriteLine($" Creato : {fi.CreationTime:dd/MM/yyyy HH:mm:ss}");
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool IsRunningAsAdministrator()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows()) return false;
|
||||||
|
using var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
|
||||||
|
var principal = new System.Security.Principal.WindowsPrincipal(identity);
|
||||||
|
return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
|
||||||
|
}
|
||||||
@@ -8,6 +8,11 @@ Data-Coupler è una soluzione integrata per la gestione di connessioni dati e cr
|
|||||||
- **DataConnection**: Libreria per connessioni a database e API REST
|
- **DataConnection**: Libreria per connessioni a database e API REST
|
||||||
- **Data_Coupler**: Applicazione Blazor Server per l'interfaccia utente
|
- **Data_Coupler**: Applicazione Blazor Server per l'interfaccia utente
|
||||||
|
|
||||||
|
### 🆕 Novità Recenti (Febbraio 2026)
|
||||||
|
- ✅ **Salesforce Batch Describe via Composite API**: I metadati degli SObject vengono ora recuperati in batch (25 per chiamata) invece di N chiamate singole, riducendo drasticamente il consumo di API durante la discovery
|
||||||
|
- ✅ **Discovery REST Parallela**: `DiscoverEntitySummariesAsync` e `DiscoverEntitiesAsync` vengono eseguite in parallelo; la lista entità diventa interattiva quasi subito, i dettagli arrivano in background
|
||||||
|
- ✅ **Fix Scheduler External ID Relationships**: Corretti due bug nello schedulatore — `ExternalIdRelationshipsJson` e `DefaultValuesJson` venivano azzerati al re-salvataggio del profilo; i campi sorgente usati nelle relazioni External ID non venivano esclusi dal mapping normale
|
||||||
|
|
||||||
### 🆕 Novità Recenti (Gennaio 2026)
|
### 🆕 Novità Recenti (Gennaio 2026)
|
||||||
- ✅ **Schedulazione File CSV/Excel**: Supporto completo per schedulare trasferimenti da file
|
- ✅ **Schedulazione File CSV/Excel**: Supporto completo per schedulare trasferimenti da file
|
||||||
- ✅ **Validazione Percorsi**: Validazione file prima del salvataggio profili
|
- ✅ **Validazione Percorsi**: Validazione file prima del salvataggio profili
|
||||||
|
|||||||
Reference in New Issue
Block a user