<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Gioxx.org</title>
	<atom:link href="https://gioxx.org/feed/" rel="self" type="application/rss+xml" />
	<link>https://gioxx.org</link>
	<description>Life is short. Live your dream and share your passion</description>
	<lastBuildDate>Thu, 15 Oct 2026 19:31:03 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.3</generator>

<image>
	<url>https://gioxx.org/wp-content/uploads/2018/02/cropped-ICON_path231-32x32.png</url>
	<title>Gioxx.org</title>
	<link>https://gioxx.org</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>ServiceDesk Plus Cloud: merge &#8220;fai-da-te&#8221; dei richiedenti</title>
		<link>https://gioxx.org/2026/10/16/servicedesk-plus-cloud-merge/</link>
					<comments>https://gioxx.org/2026/10/16/servicedesk-plus-cloud-merge/#respond</comments>
		
		<dc:creator><![CDATA[Gioxx]]></dc:creator>
		<pubDate>Fri, 16 Oct 2026 07:30:33 +0000</pubDate>
				<category><![CDATA[Lavoro]]></category>
		<category><![CDATA[API]]></category>
		<category><![CDATA[ManageEngine ServiceDesk Plus]]></category>
		<category><![CDATA[PowerShell]]></category>
		<guid isPermaLink="false">https://gioxx.org/?p=125945</guid>

					<description><![CDATA[<p>Alcuni degli indirizzi di posta elettronica gestiti e inseriti tra i richiedenti del ServiceDesk Plus Cloud aziendale hanno subito un aggiornamento (migrazione del dominio, NdR), questo ha generato dei doppioni lasciando che le richieste rimanessero a storico degli account &#8220;sbagliati&#8220;. Lo scenario è quindi molto semplice, c&#8217;è il vecchio indirizzo ... <a title="ServiceDesk Plus Cloud: merge &#8220;fai-da-te&#8221; dei richiedenti" class="read-more" href="https://gioxx.org/2026/10/16/servicedesk-plus-cloud-merge/" aria-label="Read more about ServiceDesk Plus Cloud: merge &#8220;fai-da-te&#8221; dei richiedenti">Leggi tutto</a></p>
<p><a rel="nofollow" href="https://gioxx.org/2026/10/16/servicedesk-plus-cloud-merge/">ServiceDesk Plus Cloud: merge &#8220;fai-da-te&#8221; dei richiedenti</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></description>
										<content:encoded><![CDATA[<p>Alcuni degli indirizzi di posta elettronica gestiti e inseriti tra i richiedenti del ServiceDesk Plus Cloud aziendale hanno subito un aggiornamento (migrazione del dominio, NdR), questo ha generato dei doppioni<strong> lasciando che le richieste rimanessero a storico degli account &#8220;<em>sbagliati</em>&#8220;</strong>. Lo scenario è quindi molto semplice, c&#8217;è il vecchio indirizzo / richiedente (solo per fare un esempio userò <code>a.rossi@example.org</code> il nuovo (che basandomi sul precedente esempio, potrebbe allora essere <code>alberto.rossi@example.org</code>).</p>
<p>Stessa persona, due ID diversi, e lo storico dei ticket rimasto legato alla vecchia identità, come già scritto <em>qualche parola fa</em>.</p>
<p>Nella versione Cloud di ServiceDesk Plus non ho trovato &#8211; almeno in interfaccia grafica &#8211; niente di equivalente al <strong>Merge User</strong> che avevo e sfruttavo (e sfrutto ancora, in realtà) con le installazioni on-prem. Ho dato una sbirciata alla documentazione e messo in moto ChatGPT per darmi una mano con le API v3 di SDP in Cloud, passando da PowerShell per facilitarmi la vita.</p>
<p>Perciò, <em>lista della spesa</em>:</p>
<ol>
<li>identificare e mappare correttamente vecchio e nuovo richiedente;</li>
<li>verificare i  ticket legati al vecchio account;</li>
<li>spostarli / riassegnarli al nuovo;</li>
<li>controllare che sul vecchio non rimanga più nulla;</li>
<li>eliminare l&#8217;account non più necessario;</li>
<li>ripetere la stessa procedura sugli altri utenti, in blocco.</li>
</ol>
<p>Qui di seguito trovi solo degli esempi: domini, utenti, ID, URL del portale e credenziali vanno sostituiti con quelli che appartengono alla tua installazione. Prima di partire sul serio, però, è d&#8217;obbligo rispolverare il <em>messaggio di servizio</em>:</p>
<div class="gb-block-notice warning">
    <div class="gb-notice-title warning"><p>Attenzione</p></div>
	<div class="gb-notice-text warning">
		<p>
          Da qui in poi scenderò molto nel dettaglio, ci saranno snippet, script, riferimenti e tecnicismi in abbondanza.</p>
<p>Se non hai la minima idea di cosa sto parlando, abbandona la nave prima che sia troppo tardi e non leggere l&#8217;articolo, non voglio farti addormentare ovunque tu ti trovi, e non voglio neanche che tu pensi che sono una brutta persona più di quanto io già lo sia, <em>salvati finché sei in tempo</em>.
		</p>
	</div>
</div>
<h1>Il mio ambiente</h1>
<p>Ho usato PowerShell 7 e un <strong>Self Client OAuth</strong> creato nella Zoho API Console del proprio data center.<br />
Per il data center europeo, gli endpoint sono questi:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">https://accounts.zoho.eu
https://sdpondemand.manageengine.eu</pre>
<p>Nel mio caso il portale ServiceDesk Plus è:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">https://sdpondemand.manageengine.eu/app/itdesk</pre>
<p><code>itdesk</code> è solo un esempio (anche se spesso corrisponde alla realtà, è il predefinito suggerito da ServiceDesk Plus Cloud per la tua prima installazione / istanza di SDP), tu sostituiscilo con il nome reale del tuo portale (o mantienilo se non lo hai mai cambiato).</p>
<h2>Scope OAuth</h2>
<p>Per l’intero processo ho autorizzato gli scope necessari a lettura utenti, lettura/modifica ticket e cancellazione richiedenti.<br />
Gli scope che ti servono davvero dipendono da cosa vuoi fare: nel mio caso il Self Client copriva lettura, aggiornamento e cancellazione, che poi è quello che usano gli script che ti propongo qui di seguito.</p>
<h1>Da Grant a Refresh Token</h1>
<p>Il <strong>Grant Token</strong> generato dal Self Client è temporaneo e monouso.<br />
Va scambiato &#8211; manco fosse una figurina dei calciatori &#8211; con un <strong>Refresh Token</strong>, perché questo lo puoi conservare e riutilizzare per far generare agli script nuovi Access Token in autonomia.</p>
<p><code>get-refreshtoken.ps1</code>:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">#requires -Version 7.0

# OAuth Self Client credentials
$ClientId     = "YOUR_CLIENT_ID"
$ClientSecret = "YOUR_CLIENT_SECRET"
$GrantToken   = "YOUR_GRANT_TOKEN"

$Body = @{
    grant_type    = "authorization_code"
    client_id     = $ClientId
    client_secret = $ClientSecret
    code          = $GrantToken
}

$Response = Invoke-RestMethod `
    -Method Post `
    -Uri "https://accounts.zoho.eu/oauth/v2/token" `
    -ContentType "application/x-www-form-urlencoded" `
    -Body $Body

$Response

$RefreshToken = $Response.refresh_token

if ($RefreshToken) {
    Write-Host "Refresh token successfully obtained." -ForegroundColor Green
}
else {
    Write-Host "No refresh token returned." -ForegroundColor Red
}</pre>
<p>Il valore da conservare è &#8211; mi ripeto &#8211; <code>refresh_token</code>. Il Grant Token non serve più dopo lo scambio, mentre l’Access Token è temporaneo e viene rigenerato al volo usando il Refresh Token.</p>
<p><strong>Occhio:</strong> Client Secret, Grant Token, Access Token e Refresh Token sono a tutti gli effetti credenziali. Non lasciarli sul disco se non ti servono, <em>non perderne il controllo</em>.</p>
<h1>1. Verifica i due richiedenti</h1>
<p>Prima di toccare qualsiasi dato, ti conviene verificare l&#8217;esistenza delle due identità e contare i ticket associati a ciascuna, così cerchi di capire se tutto ti torna rispetto a una semplice ricerca (identica nel risultato finale) via interfaccia grafica di SDP (ti basta fare una ricerca avanzata nella schermata delle richieste includendo i filtri adatti a mostrarti quanti ticket appartengono al richiedente <em>vecchio</em>).</p>
<p><code>check-merge.ps1</code>:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">#requires -Version 7.0

&lt;#
.SYNOPSIS
    Audits two ServiceDesk Plus Cloud requester accounts.

.DESCRIPTION
    The script:
    - Generates a fresh OAuth access token using a refresh token
    - Finds two SDP users by email address
    - Retrieves requests associated with each requester
    - Displays a comparison
    - Makes no changes to ServiceDesk Plus
#&gt;

$ClientId     = "YOUR_CLIENT_ID"
$ClientSecret = "YOUR_CLIENT_SECRET"
$RefreshToken = "YOUR_REFRESH_TOKEN"

$AccountsUrl = "https://accounts.zoho.eu"
$BaseUrl     = "https://sdpondemand.manageengine.eu/app/itdesk"

$OldEmail = "a.rossi@example.org"
$NewEmail = "alberto.rossi@example.org"

function Get-SdpAccessToken {
    $Body = @{
        refresh_token = $RefreshToken
        client_id     = $ClientId
        client_secret = $ClientSecret
        grant_type    = "refresh_token"
    }

    $Response = Invoke-RestMethod `
        -Uri "$AccountsUrl/oauth/v2/token" `
        -Method Post `
        -Body $Body `
        -ContentType "application/x-www-form-urlencoded"

    if (-not $Response.access_token) {
        throw "OAuth response did not contain an access_token."
    }

    return $Response.access_token
}

function Invoke-SdpGet {
    param (
        [Parameter(Mandatory)]
        [string]$Uri,

        [hashtable]$Query
    )

    if ($Query) {
        $QueryString = (
            $Query.GetEnumerator() |
            ForEach-Object {
                "$([uri]::EscapeDataString($_.Key))=$([uri]::EscapeDataString([string]$_.Value))"
            }
        ) -join "&amp;"

        $Uri = "$Uri`?$QueryString"
    }

    return Invoke-RestMethod `
        -Uri $Uri `
        -Method Get `
        -Headers $Script:SdpHeaders
}

function Get-SdpUserByEmail {
    param (
        [Parameter(Mandatory)]
        [string]$EmailAddress
    )

    $InputData = @{
        list_info = @{
            row_count       = 100
            start_index     = 1
            get_total_count = $true
            search_fields   = @{
                email_id = $EmailAddress
            }
        }
    } | ConvertTo-Json -Depth 10 -Compress

    $Response = Invoke-SdpGet `
        -Uri "$BaseUrl/api/v3/users" `
        -Query @{
            input_data = $InputData
        }

    if (-not $Response.users) {
        return $null
    }

    return $Response.users |
        Where-Object {
            $_.email_id -ieq $EmailAddress
        } |
        Select-Object -First 1
}

function Get-SdpRequestsByRequester {
    param (
        [Parameter(Mandatory)]
        [string]$RequesterId
    )

    $AllRequests = [System.Collections.Generic.List[object]]::new()

    $StartIndex = 1
    $RowCount   = 100

    do {
        $InputData = @{
            list_info = @{
                row_count       = $RowCount
                start_index     = $StartIndex
                get_total_count = $true
                search_criteria = @{
                    field     = "requester.id"
                    condition = "is"
                    value     = $RequesterId
                }
            }
        } | ConvertTo-Json -Depth 10 -Compress

        $Response = Invoke-SdpGet `
            -Uri "$BaseUrl/api/v3/requests" `
            -Query @{
                input_data = $InputData
            }

        if ($Response.requests) {
            foreach ($Request in $Response.requests) {
                $AllRequests.Add($Request)
            }
        }

        $HasMoreRows = [bool]$Response.list_info.has_more_rows
        $StartIndex += $RowCount

    } while ($HasMoreRows)

    return $AllRequests
}

function Show-SdpUser {
    param (
        [Parameter(Mandatory)]
        [string]$Label,

        [Parameter(Mandatory)]
        $User,

        [Parameter(Mandatory)]
        $Requests
    )

    Write-Host ""
    Write-Host "===== $Label =====" -ForegroundColor Cyan

    [PSCustomObject]@{
        ID           = $User.id
        Name         = $User.name
        Email        = $User.email_id
        LoginName    = $User.login_name
        Site         = $User.site.name
        Department   = $User.department.name
        JobTitle     = $User.jobtitle
        IsTechnician = $User.is_technician
        Requests     = $Requests.Count
    } | Format-List
}

Write-Host ""
Write-Host "Generating OAuth access token..." -ForegroundColor Yellow

$AccessToken = Get-SdpAccessToken

$Script:SdpHeaders = @{
    Authorization = "Zoho-oauthtoken $AccessToken"
    Accept        = "application/vnd.manageengine.sdp.v3+json"
}

Write-Host "OAuth token obtained." -ForegroundColor Green

$OldUser = Get-SdpUserByEmail -EmailAddress $OldEmail

if (-not $OldUser) {
    throw "Old requester not found: $OldEmail"
}

$NewUser = Get-SdpUserByEmail -EmailAddress $NewEmail

if (-not $NewUser) {
    throw "New requester not found: $NewEmail"
}

$OldRequests = @(
    Get-SdpRequestsByRequester -RequesterId $OldUser.id
)

$NewRequests = @(
    Get-SdpRequestsByRequester -RequesterId $NewUser.id
)

Show-SdpUser `
    -Label "OLD USER" `
    -User $OldUser `
    -Requests $OldRequests

Show-SdpUser `
    -Label "NEW USER" `
    -User $NewUser `
    -Requests $NewRequests

$Summary = @(
    foreach ($Request in $OldRequests) {
        [PSCustomObject]@{
            Account     = "OLD"
            RequestId   = $Request.id
            Subject     = $Request.subject
            Status      = $Request.status.name
            Requester   = $Request.requester.name
            Email       = $Request.requester.email_id
            CreatedTime = $Request.created_time.display_value
        }
    }

    foreach ($Request in $NewRequests) {
        [PSCustomObject]@{
            Account     = "NEW"
            RequestId   = $Request.id
            Subject     = $Request.subject
            Status      = $Request.status.name
            Requester   = $Request.requester.name
            Email       = $Request.requester.email_id
            CreatedTime = $Request.created_time.display_value
        }
    }
)

$Summary |
    Sort-Object Account, RequestId |
    Format-Table -AutoSize

Write-Host ""
Write-Host "Audit completed. No changes were made." -ForegroundColor Yellow</pre>
<p>Nel mio test il risultato era concettualmente questo:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">OLD USER
Email    : a.rossi@example.org
Requests : 2

NEW USER
Email    : alberto.rossi@example.org
Requests : 0</pre>
<p>Questo ti permette di capire quale ID far sopravvivere.</p>
<h1>2. Sposta i ticket sull&#8217;utente &#8216;<em>nuovo</em>&#8216;</h1>
<p>Il secondo script usa l’ID del nuovo richiedente per aggiornare il campo <code>requester</code> dei ticket ancora legati alla vecchia identità. Quando lo lanci senza parametri parte sempre in simulazione così da evitare di modificare dati e fare potenziali danni, le modifiche scattano solo includendo <code>-Apply</code>.</p>
<p><code>move-sdp-requests.ps1</code>:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">#requires -Version 7.0

param (
    [switch]$Apply
)

$ClientId     = "YOUR_CLIENT_ID"
$ClientSecret = "YOUR_CLIENT_SECRET"
$RefreshToken = "YOUR_REFRESH_TOKEN"

$AccountsUrl = "https://accounts.zoho.eu"
$BaseUrl     = "https://sdpondemand.manageengine.eu/app/itdesk"

$OldUserId = "OLD_REQUESTER_ID"
$NewUserId = "NEW_REQUESTER_ID"

$OldEmail = "a.rossi@example.org"
$NewEmail = "alberto.rossi@example.org"

function Get-SdpAccessToken {
    $Body = @{
        refresh_token = $RefreshToken
        client_id     = $ClientId
        client_secret = $ClientSecret
        grant_type    = "refresh_token"
    }

    $Response = Invoke-RestMethod `
        -Uri "$AccountsUrl/oauth/v2/token" `
        -Method Post `
        -Body $Body `
        -ContentType "application/x-www-form-urlencoded"

    if (-not $Response.access_token) {
        throw "OAuth response did not contain an access token."
    }

    return $Response.access_token
}

function Get-SdpRequestsByRequester {
    param (
        [Parameter(Mandatory)]
        [string]$RequesterId
    )

    $AllRequests = [System.Collections.Generic.List[object]]::new()
    $StartIndex = 1
    $RowCount   = 100

    do {
        $InputData = @{
            list_info = @{
                row_count       = $RowCount
                start_index     = $StartIndex
                get_total_count = $true
                search_criteria = @{
                    field     = "requester.id"
                    condition = "is"
                    value     = $RequesterId
                }
            }
        } | ConvertTo-Json -Depth 10 -Compress

        $EncodedInputData = [uri]::EscapeDataString($InputData)
        $Uri = "$BaseUrl/api/v3/requests?input_data=$EncodedInputData"

        $Response = Invoke-RestMethod `
            -Uri $Uri `
            -Method Get `
            -Headers $Script:SdpHeaders

        if ($Response.requests) {
            foreach ($Request in $Response.requests) {
                $AllRequests.Add($Request)
            }
        }

        $HasMoreRows = [bool]$Response.list_info.has_more_rows
        $StartIndex += $RowCount

    } while ($HasMoreRows)

    return $AllRequests
}

function Set-SdpRequestRequester {
    param (
        [Parameter(Mandatory)]
        [string]$RequestId,

        [Parameter(Mandatory)]
        [string]$RequesterId
    )

    $InputData = @{
        request = @{
            requester = @{
                id = $RequesterId
            }
        }
    } | ConvertTo-Json -Depth 10 -Compress

    $Body = @{
        input_data = $InputData
    }

    Invoke-RestMethod `
        -Uri "$BaseUrl/api/v3/requests/$RequestId" `
        -Method Put `
        -Headers $Script:SdpHeaders `
        -ContentType "application/x-www-form-urlencoded" `
        -Body $Body
}

Write-Host ""
Write-Host "Generating OAuth access token..." -ForegroundColor Yellow

$AccessToken = Get-SdpAccessToken

$Script:SdpHeaders = @{
    Authorization = "Zoho-oauthtoken $AccessToken"
    Accept        = "application/vnd.manageengine.sdp.v3+json"
}

$Requests = @(
    Get-SdpRequestsByRequester -RequesterId $OldUserId
)

Write-Host "Found $($Requests.Count) request(s)." -ForegroundColor Cyan

$Requests |
    Select-Object `
        id,
        subject,
        @{Name = "Status"; Expression = { $_.status.name } },
        @{Name = "Requester"; Expression = { $_.requester.email_id } } |
    Format-Table -AutoSize

if (-not $Apply) {
    Write-Host ""
    Write-Host "SIMULATION MODE" -ForegroundColor Yellow
    Write-Host "No changes were made."
    Write-Host ""
    Write-Host "To apply the changes, run:"
    Write-Host ".\move-sdp-requests.ps1 -Apply"
    exit
}

foreach ($Request in $Requests) {
    Write-Host "Updating request $($Request.id): $($Request.subject)"

    $Result = Set-SdpRequestRequester `
        -RequestId $Request.id `
        -RequesterId $NewUserId

    if ($Result.response_status.status -eq "success") {
        Write-Host "  Success" -ForegroundColor Green
    }
    else {
        Write-Host "  API returned an unexpected status." -ForegroundColor Yellow
        $Result.response_status | Format-List
    }
}

Write-Host ""
Write-Host "Verifying remaining requests on old requester..." -ForegroundColor Yellow

$RemainingRequests = @(
    Get-SdpRequestsByRequester -RequesterId $OldUserId
)

if ($RemainingRequests.Count -eq 0) {
    Write-Host "Old requester has no remaining requests." -ForegroundColor Green
}
else {
    Write-Host "$($RemainingRequests.Count) request(s) still reference the old requester." -ForegroundColor Red
}</pre>
<p>Prima:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">.\move-sdp-requests.ps1</pre>
<p>Dopo (dopo aver controllato l’elenco):</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">.\move-sdp-requests.ps1 -Apply</pre>
<p>Il controllo finale qui è fondamentale: il vecchio richiedente non deve più avere ticket aperti a suo nome, altrimenti fermati e non andare avanti, c&#8217;è qualcosa di sbagliato o presumibilmente &#8216;<em>saltato</em>&#8216; durante i precedenti passaggi.<br />
<em>Ritenta, sarai più fortunato.</em></p>
<h1>3. Controlla il richiedente &#8216;<em>vecchio</em>&#8216;</h1>
<p>A questo punto fai un controllo (quindi anche stavolta in sola lettura) sul vecchio richiedente.<br />
Più <em>uno scrupolo</em> che altro.</p>
<p><code>audit-sdp-requester.ps1</code>:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">#requires -Version 7.0

$ClientId     = "YOUR_CLIENT_ID"
$ClientSecret = "YOUR_CLIENT_SECRET"
$RefreshToken = "YOUR_REFRESH_TOKEN"

$AccountsUrl = "https://accounts.zoho.eu"
$BaseUrl     = "https://sdpondemand.manageengine.eu/app/itdesk"

$OldUserId = "OLD_REQUESTER_ID"
$OldEmail  = "a.rossi@example.org"

function Get-SdpAccessToken {
    $Body = @{
        refresh_token = $RefreshToken
        client_id     = $ClientId
        client_secret = $ClientSecret
        grant_type    = "refresh_token"
    }

    $Response = Invoke-RestMethod `
        -Uri "$AccountsUrl/oauth/v2/token" `
        -Method Post `
        -Body $Body `
        -ContentType "application/x-www-form-urlencoded"

    if (-not $Response.access_token) {
        throw "OAuth response did not contain an access token."
    }

    return $Response.access_token
}

function Invoke-SdpGet {
    param (
        [Parameter(Mandatory)]
        [string]$Uri
    )

    Invoke-RestMethod `
        -Uri $Uri `
        -Method Get `
        -Headers $Script:SdpHeaders `
        -ErrorAction Stop
}

function Get-SdpRequestsByRequester {
    param (
        [Parameter(Mandatory)]
        [string]$RequesterId
    )

    $AllRequests = [System.Collections.Generic.List[object]]::new()
    $StartIndex = 1
    $RowCount   = 100

    do {
        $InputData = @{
            list_info = @{
                row_count       = $RowCount
                start_index     = $StartIndex
                get_total_count = $true
                search_criteria = @{
                    field     = "requester.id"
                    condition = "is"
                    value     = $RequesterId
                }
            }
        } | ConvertTo-Json -Depth 10 -Compress

        $EncodedInputData = [uri]::EscapeDataString($InputData)

        $Response = Invoke-SdpGet `
            -Uri "$BaseUrl/api/v3/requests?input_data=$EncodedInputData"

        if ($Response.requests) {
            foreach ($Request in $Response.requests) {
                $AllRequests.Add($Request)
            }
        }

        $HasMoreRows = [bool]$Response.list_info.has_more_rows
        $StartIndex += $RowCount

    } while ($HasMoreRows)

    return $AllRequests
}

Write-Host ""
Write-Host "Generating OAuth access token..." -ForegroundColor Yellow

$AccessToken = Get-SdpAccessToken

$Script:SdpHeaders = @{
    Authorization = "Zoho-oauthtoken $AccessToken"
    Accept        = "application/vnd.manageengine.sdp.v3+json"
}

Write-Host "Auditing old requester:" -ForegroundColor Cyan
Write-Host "$OldEmail [$OldUserId]"

$UserResponse = Invoke-SdpGet `
    -Uri "$BaseUrl/api/v3/users/$OldUserId"

$User = $UserResponse.user

[PSCustomObject]@{
    ID         = $User.id
    Name       = $User.name
    Email      = $User.email_id
    Site       = $User.site.name
    Department = $User.department.name
} | Format-List

$Requests = @(
    Get-SdpRequestsByRequester -RequesterId $OldUserId
)

Write-Host ""
Write-Host "Requests: $($Requests.Count)"

if ($Requests.Count -eq 0) {
    Write-Host "No requests remain associated with the old requester." -ForegroundColor Green
}

Write-Host ""
Write-Host "Audit completed. No changes were made." -ForegroundColor Yellow</pre>
<p>Per eventuali associazioni non esposte da questa API vale la pena fare anche un controllo dall&#8217;interfaccia grafica di SDP, tramite <strong>View Associations</strong>, prima della cancellazione definitiva.</p>
<h1>4. Elimina il vecchio richiedente</h1>
<p>Una volta verificato che l’identità vecchia non serve più, puoi eliminata tramite l’endpoint <code>/api/v3/requesters/{id}</code>. Anche questo script parte in sola lettura fino a quando non lo rilanci con il parametro per applicare davvero la modifica.</p>
<p><code>delete-sdp-requester.ps1</code>:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">#requires -Version 7.0

param (
    [switch]$Apply
)

$ClientId     = "YOUR_CLIENT_ID"
$ClientSecret = "YOUR_CLIENT_SECRET"
$RefreshToken = "YOUR_REFRESH_TOKEN"

$AccountsUrl = "https://accounts.zoho.eu"
$BaseUrl     = "https://sdpondemand.manageengine.eu/app/itdesk"

$RequesterId    = "OLD_REQUESTER_ID"
$RequesterEmail = "a.rossi@example.org"

function Get-SdpAccessToken {
    $Body = @{
        refresh_token = $RefreshToken
        client_id     = $ClientId
        client_secret = $ClientSecret
        grant_type    = "refresh_token"
    }

    $Response = Invoke-RestMethod `
        -Uri "$AccountsUrl/oauth/v2/token" `
        -Method Post `
        -Body $Body `
        -ContentType "application/x-www-form-urlencoded"

    if (-not $Response.access_token) {
        throw "OAuth response did not contain an access token."
    }

    return $Response.access_token
}

Write-Host ""
Write-Host "Generating OAuth access token..." -ForegroundColor Yellow

$AccessToken = Get-SdpAccessToken

$Headers = @{
    Authorization = "Zoho-oauthtoken $AccessToken"
    Accept        = "application/vnd.manageengine.sdp.v3+json"
}

Write-Host "Requester scheduled for deletion:"
Write-Host "$RequesterEmail [$RequesterId]"

if (-not $Apply) {
    Write-Host ""
    Write-Host "SIMULATION MODE" -ForegroundColor Yellow
    Write-Host "No changes were made."
    Write-Host ""
    Write-Host "To delete the requester, run:"
    Write-Host ".\delete-sdp-requester.ps1 -Apply" -ForegroundColor Cyan
    exit
}

$Response = Invoke-RestMethod `
    -Uri "$BaseUrl/api/v3/requesters/$RequesterId" `
    -Method Delete `
    -Headers $Headers `
    -ErrorAction Stop

Write-Host ""
Write-Host "Requester deleted successfully." -ForegroundColor Green

if ($Response) {
    $Response | ConvertTo-Json -Depth 10
}</pre>
<p>Prima:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">.\delete-sdp-requester.ps1</pre>
<p>e solo dopo aver verificato che va a eliminare chi ti aspetti, lo rilanci:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">.\delete-sdp-requester.ps1 -Apply</pre>
<p>A questo punto il hai concluso, sei passato al nuovo richiedente e fatto piazza pulita del vecchio.</p>
<h1>5. Ora ripeti, ma con tutti gli altri</h1>
<p>Quando il giro per singolo utente ha sortito l&#8217;effetto desiderato sono passato a farlo per gli altri tutti insieme. In una migrazione reale ci sono sempre eccezioni, alias e omonimie che rovinano qualsiasi logica automatica, quindi valuta bene quello che fai, non pensare di dare tutto in pasto agli script, non presidiare e andarti a prendere un caffè sperando di non trovare sbavature al rientro.</p>
<p>Per la sicurezza e tranquillità della tua pausa caffè, preparati un file di mappatura in un CSV, così facendo hai la certezza di mettere vecchio e nuovo richiedente sul giusto livello, senza scherzi, senza &#8220;dedurre il nuovo dal vecchio&#8221;.</p>
<p><code>requester-mapping.csv</code>:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">OldEmail,NewEmail
a.verdi@example.org,andrea.verdi@example.org
d.bianchi@example.org,daniela.bianchi@example.org
e.neri@example.org,eleonora.neri@example.org
f.gialli@example.org,fabio.gialli@example.org</pre>
<h2>Script &#8220;bulk&#8221; (in un colpo solo)</h2>
<p><code>bulk-merge-sdp-requesters.ps1</code>:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">#requires -Version 7.0

param (
    [Parameter()]
    [string]$MappingFile = ".\requester-mapping.csv",

    [switch]$Apply,

    [switch]$DeleteOld
)

$ClientId     = "YOUR_CLIENT_ID"
$ClientSecret = "YOUR_CLIENT_SECRET"
$RefreshToken = "YOUR_REFRESH_TOKEN"

$AccountsUrl = "https://accounts.zoho.eu"
$BaseUrl     = "https://sdpondemand.manageengine.eu/app/itdesk"

$Timestamp  = Get-Date -Format "yyyyMMdd-HHmmss"
$ReportFile = ".\sdp-merge-report-$Timestamp.csv"

function Get-SdpAccessToken {
    $Body = @{
        refresh_token = $RefreshToken
        client_id     = $ClientId
        client_secret = $ClientSecret
        grant_type    = "refresh_token"
    }

    $Response = Invoke-RestMethod `
        -Uri "$AccountsUrl/oauth/v2/token" `
        -Method Post `
        -Body $Body `
        -ContentType "application/x-www-form-urlencoded" `
        -ErrorAction Stop

    if (-not $Response.access_token) {
        throw "OAuth response did not contain an access token."
    }

    return $Response.access_token
}

function Invoke-SdpGet {
    param (
        [Parameter(Mandatory)]
        [string]$Uri
    )

    Invoke-RestMethod `
        -Uri $Uri `
        -Method Get `
        -Headers $Script:SdpHeaders `
        -ErrorAction Stop
}

function Get-SdpUserByEmail {
    param (
        [Parameter(Mandatory)]
        [string]$EmailAddress
    )

    $InputData = @{
        list_info = @{
            row_count       = 100
            start_index     = 1
            get_total_count = $true
            search_fields   = @{
                email_id = $EmailAddress
            }
        }
    } | ConvertTo-Json -Depth 10 -Compress

    $EncodedInputData = [uri]::EscapeDataString($InputData)

    $Response = Invoke-SdpGet `
        -Uri "$BaseUrl/api/v3/users?input_data=$EncodedInputData"

    $Matches = @(
        $Response.users |
        Where-Object {
            $_.email_id -ieq $EmailAddress
        }
    )

    if ($Matches.Count -eq 0) {
        return $null
    }

    if ($Matches.Count -gt 1) {
        throw "Multiple SDP users found with email address $EmailAddress."
    }

    return $Matches[0]
}

function Get-SdpRequestsByRequester {
    param (
        [Parameter(Mandatory)]
        [string]$RequesterId
    )

    $AllRequests = [System.Collections.Generic.List[object]]::new()
    $StartIndex = 1
    $RowCount   = 100

    do {
        $InputData = @{
            list_info = @{
                row_count       = $RowCount
                start_index     = $StartIndex
                get_total_count = $true
                search_criteria = @{
                    field     = "requester.id"
                    condition = "is"
                    value     = $RequesterId
                }
            }
        } | ConvertTo-Json -Depth 10 -Compress

        $EncodedInputData = [uri]::EscapeDataString($InputData)

        $Response = Invoke-SdpGet `
            -Uri "$BaseUrl/api/v3/requests?input_data=$EncodedInputData"

        if ($Response.requests) {
            foreach ($Request in $Response.requests) {
                $AllRequests.Add($Request)
            }
        }

        $HasMoreRows = [bool]$Response.list_info.has_more_rows
        $StartIndex += $RowCount

    } while ($HasMoreRows)

    return $AllRequests
}

function Set-SdpRequestRequester {
    param (
        [Parameter(Mandatory)]
        [string]$RequestId,

        [Parameter(Mandatory)]
        [string]$RequesterId
    )

    $InputData = @{
        request = @{
            requester = @{
                id = $RequesterId
            }
        }
    } | ConvertTo-Json -Depth 10 -Compress

    $Body = @{
        input_data = $InputData
    }

    Invoke-RestMethod `
        -Uri "$BaseUrl/api/v3/requests/$RequestId" `
        -Method Put `
        -Headers $Script:SdpHeaders `
        -ContentType "application/x-www-form-urlencoded" `
        -Body $Body `
        -ErrorAction Stop
}

function Remove-SdpRequester {
    param (
        [Parameter(Mandatory)]
        [string]$RequesterId
    )

    Invoke-RestMethod `
        -Uri "$BaseUrl/api/v3/requesters/$RequesterId" `
        -Method Delete `
        -Headers $Script:SdpHeaders `
        -ErrorAction Stop
}

if (-not (Test-Path $MappingFile)) {
    throw "Mapping file not found: $MappingFile"
}

$Mappings = @(Import-Csv $MappingFile)

if ($Mappings.Count -eq 0) {
    throw "Mapping file contains no entries."
}

foreach ($Mapping in $Mappings) {
    if (-not $Mapping.OldEmail -or -not $Mapping.NewEmail) {
        throw "Every CSV row must contain OldEmail and NewEmail."
    }

    if ($Mapping.OldEmail -ieq $Mapping.NewEmail) {
        throw "OldEmail and NewEmail are identical: $($Mapping.OldEmail)"
    }
}

Write-Host ""
Write-Host "Generating OAuth access token..." -ForegroundColor Yellow

$AccessToken = Get-SdpAccessToken

$Script:SdpHeaders = @{
    Authorization = "Zoho-oauthtoken $AccessToken"
    Accept        = "application/vnd.manageengine.sdp.v3+json"
}

if (-not $Apply) {
    Write-Host "SIMULATION MODE" -ForegroundColor Yellow
    Write-Host "No changes will be made."
}
elseif (-not $DeleteOld) {
    Write-Host "APPLY MODE" -ForegroundColor Cyan
    Write-Host "Requests will be reassigned."
    Write-Host "Old requester accounts will NOT be deleted."
}
else {
    Write-Host "APPLY + DELETE MODE" -ForegroundColor Red
    Write-Host "Requests will be reassigned and empty old requester accounts will be deleted."
}

$Results = [System.Collections.Generic.List[object]]::new()
$Index = 0

foreach ($Mapping in $Mappings) {
    $Index++

    $OldEmail = $Mapping.OldEmail.Trim()
    $NewEmail = $Mapping.NewEmail.Trim()

    Write-Host ""
    Write-Host "============================================================"
    Write-Host "[$Index/$($Mappings.Count)] $OldEmail"
    Write-Host "                     -&gt; $NewEmail"
    Write-Host "============================================================"

    $Result = [ordered]@{
        OldEmail          = $OldEmail
        NewEmail          = $NewEmail
        OldUserId         = $null
        NewUserId         = $null
        RequestsFound     = 0
        RequestsMoved     = 0
        RequestsRemaining = $null
        OldUserDeleted    = $false
        Status            = $null
        Error             = $null
    }

    try {
        $OldUser = Get-SdpUserByEmail -EmailAddress $OldEmail

        if (-not $OldUser) {
            $Result.Status = "Old user not found"
            $Results.Add([PSCustomObject]$Result)
            continue
        }

        $Result.OldUserId = $OldUser.id

        $NewUser = Get-SdpUserByEmail -EmailAddress $NewEmail

        if (-not $NewUser) {
            $Result.Status = "New user not found"
            $Results.Add([PSCustomObject]$Result)
            continue
        }

        $Result.NewUserId = $NewUser.id

        if ($OldUser.id -eq $NewUser.id) {
            $Result.Status = "Same SDP user"
            $Results.Add([PSCustomObject]$Result)
            continue
        }

        $Requests = @(
            Get-SdpRequestsByRequester -RequesterId $OldUser.id
        )

        $Result.RequestsFound = $Requests.Count

        if ($Requests.Count -gt 0) {
            $Requests |
                Select-Object `
                    id,
                    subject,
                    @{Name = "Status"; Expression = { $_.status.name } } |
                Format-Table -AutoSize
        }

        if (-not $Apply) {
            $Result.Status = "Simulation"
            $Results.Add([PSCustomObject]$Result)
            continue
        }

        foreach ($Request in $Requests) {
            Write-Host "Moving request $($Request.id)..." -NoNewline

            $Response = Set-SdpRequestRequester `
                -RequestId $Request.id `
                -RequesterId $NewUser.id

            if ($Response.response_status.status -eq "success") {
                $Result.RequestsMoved++
                Write-Host " OK" -ForegroundColor Green
            }
            else {
                throw "Unexpected API status while moving request $($Request.id)."
            }
        }

        $RemainingRequests = @(
            Get-SdpRequestsByRequester -RequesterId $OldUser.id
        )

        $Result.RequestsRemaining = $RemainingRequests.Count

        if ($RemainingRequests.Count -gt 0) {
            $Result.Status = "Requests remain"
            $Results.Add([PSCustomObject]$Result)
            continue
        }

        if ($DeleteOld) {
            Write-Host "Deleting old requester..." -NoNewline

            Remove-SdpRequester -RequesterId $OldUser.id

            Write-Host " OK" -ForegroundColor Green

            $Result.OldUserDeleted = $true
            $Result.Status = "Merged and deleted"
        }
        else {
            $Result.Status = "Requests merged"
        }
    }
    catch {
        Write-Host ""
        Write-Host "FAILED: $($_.Exception.Message)" -ForegroundColor Red

        $Result.Status = "Failed"
        $Result.Error  = $_.Exception.Message
    }

    $Results.Add([PSCustomObject]$Result)
}

$Results |
    Export-Csv `
        -Path $ReportFile `
        -NoTypeInformation `
        -Encoding UTF8

Write-Host ""
Write-Host "============================================================"
Write-Host "SUMMARY" -ForegroundColor Cyan
Write-Host "============================================================"

$Results |
    Format-Table `
        OldEmail,
        NewEmail,
        RequestsFound,
        RequestsMoved,
        RequestsRemaining,
        OldUserDeleted,
        Status `
        -AutoSize

Write-Host ""
Write-Host "Report:"
Write-Host $ReportFile -ForegroundColor Cyan</pre>
<h2>Consiglio spassionato</h2>
<p>La modalità più prudente è affrontarla in tre passaggi.</p>
<h3>1. Sola lettura, pura simulazione</h3>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">.\bulk-merge-sdp-requesters.ps1</pre>
<p>Non modifica nulla. Verifica tutte le corrispondenze e mostra i ticket trovati, così puoi ancora verificare che tutto corrisponda, in sola lettura, senza danni collaterali.</p>
<h3>2. Spostamento dei ticket</h3>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">.\bulk-merge-sdp-requesters.ps1 -Apply</pre>
<p>Sposta i ticket, ma lascia intatti i vecchi richiedenti.</p>
<h3>3. Eliminazione delle vecchie identità</h3>
<p>Dopo aver verificato il report:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">.\bulk-merge-sdp-requesters.ps1 -Apply -DeleteOld</pre>
<p>Lo script ricontrolla che il vecchio richiedente non abbia più ticket prima di eliminarlo. Al termine viene mostrato (e salvato) in automatico un report CSV simile a questo:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">OldEmail              NewEmail                     RequestsFound RequestsMoved RequestsRemaining OldUserDeleted Status
--------              --------                     ------------- ------------- ----------------- -------------- ------
a.verdi@example.org    andrea.verdi@example.org                 7             7                 0           True Merged and deleted
d.bianchi@example.org  daniela.bianchi@example.org              3             3                 0           True Merged and deleted
e.neri@example.org     eleonora.neri@example.org                0             0                 0           True Merged and deleted</pre>
<h1>In conclusione</h1>
<p>La parte più importante di questa procedura non è la <code>DELETE</code>, è tutto quello che viene prima. Cancellare gli utenti è solo l&#8217;operazione ultima per non lasciare <em>sporco in giro</em>. Un utente di ServiceDesk Plus non lo elimini semplicemente perché il suo indirizzo e-mail è diventato obsoleto: prima identifichi l’account che lo sostituisce, trasferisci lo storico necessario e poi verifichi che non siano rimaste tracce su quello che andrai a spazzare via.</p>
<p>Per pochi account puoi procedere uno alla volta senza problemi, se lo preferisci e ti fa sentire più sicuro di quello che stai per fare. In una migrazione di dominio, però, il mapping CSV e lo script bulk rendono il processo più corretto, privo di sbavature (a meno di averle fatte durante la preparazione di script e file di corrispondenza), lo scopo è cercare di portare a zero (o per lo meno alla minima cifra possibile) gli errori manuali.</p>
<p>Con un minimo di pianificazione e la maggiore attenzione possibile (ed ecco perché gli script che ti ho proposto sopra prevedono di lavorare sempre in sola lettura a meno di essere lanciati con uno specifico parametro), le API v3 di SDP in Cloud diventano l&#8217;unico strumento efficace per questo tipo di operazioni amministrative che da interfaccia grafica non hai a disposizione nonostante tu si amministratore dell&#8217;istanza.</p>
<p>L’area commenti è a tua disposizione se ti sei trovato in una situazione simile, se hai bisogno di delucidazioni sullo script o di chiedere banalmente una mano, nei limiti del possibile e del fattibile da qui :-)</p>
<p><strong>#KeepItSimple</strong></p>
<hr />
<h6>Immagine di copertina <a class="stockpack-author" href="https://unsplash.com/@israelandrxde?utm_source=Stockpack&amp;utm_medium=referral&amp;utm_campaign=api-credit" target="_blank" rel="nofollow noopener noreferrer">Israel Andrade</a> on <a class="stockpack-provider" href="https://unsplash.com/photos/YI_9SivVt_s?utm_source=Stockpack&amp;utm_medium=referral" target="_blank" rel="nofollow noopener noreferrer">Unsplash</a></h6>
<p><a rel="nofollow" href="https://gioxx.org/2026/10/16/servicedesk-plus-cloud-merge/">ServiceDesk Plus Cloud: merge &#8220;fai-da-te&#8221; dei richiedenti</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://gioxx.org/2026/10/16/servicedesk-plus-cloud-merge/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Azure DevOps e il cambio di tenant Entra ID: quando il remapping automatico non basta</title>
		<link>https://gioxx.org/2026/09/28/azure-devops-e-il-cambio-di-tenant-entra-id-quando-il-remapping-automatico-non-basta/</link>
					<comments>https://gioxx.org/2026/09/28/azure-devops-e-il-cambio-di-tenant-entra-id-quando-il-remapping-automatico-non-basta/#respond</comments>
		
		<dc:creator><![CDATA[Gioxx]]></dc:creator>
		<pubDate>Mon, 28 Sep 2026 07:30:12 +0000</pubDate>
				<category><![CDATA[Lavoro]]></category>
		<category><![CDATA[Azure DevOps]]></category>
		<category><![CDATA[Microsoft]]></category>
		<category><![CDATA[Microsoft Azure]]></category>
		<category><![CDATA[Microsoft Entra]]></category>
		<category><![CDATA[PowerShell]]></category>
		<guid isPermaLink="false">https://gioxx.org/?p=125944</guid>

					<description><![CDATA[<p>Oggi ti parlo di imprecazione esperienza di vita lavorativa vissuta. Sulla carta, cambiare il tenant Microsoft Entra ID collegato a un’organizzazione Azure DevOps sembra un’operazione lineare: prepari la nuova directory, fai lo switch, lasci che Microsoft rimappi gli utenti verso le nuove identità (dopo che tu gli avrai dato una ... <a title="Azure DevOps e il cambio di tenant Entra ID: quando il remapping automatico non basta" class="read-more" href="https://gioxx.org/2026/09/28/azure-devops-e-il-cambio-di-tenant-entra-id-quando-il-remapping-automatico-non-basta/" aria-label="Read more about Azure DevOps e il cambio di tenant Entra ID: quando il remapping automatico non basta">Leggi tutto</a></p>
<p><a rel="nofollow" href="https://gioxx.org/2026/09/28/azure-devops-e-il-cambio-di-tenant-entra-id-quando-il-remapping-automatico-non-basta/">Azure DevOps e il cambio di tenant Entra ID: quando il remapping automatico non basta</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></description>
										<content:encoded><![CDATA[<p>Oggi ti parlo di <del>imprecazione</del> esperienza di vita lavorativa vissuta. Sulla carta, cambiare il tenant Microsoft Entra ID collegato a un’organizzazione Azure DevOps sembra un’operazione lineare: prepari la nuova directory, fai lo switch, lasci che Microsoft rimappi gli utenti verso le nuove identità (<em>dopo che tu gli avrai dato una sorta di griglia di corrispondenza tra &#8216;vecchio&#8217; e &#8216;nuovo&#8217;</em>) e via.</p>
<p>Nella pratica, almeno nel mio caso, <strong>il remapping automatico ha fatto solo metà del lavoro</strong>. L’organizzazione e il progetto sono sopravvissuti bene al cambio di directory, insieme a repository, board e storico. Alcune identità, però, sono rimaste appese ai vecchi account, e a quel punto mi è toccato rimboccarmi le maniche, prendere un due di picche su un potenziale ticket a pagamento verso il supporto di Microsoft (nonostante in documentazione venga consigliata quella come unica via per risolvere questo tipo di magagne), lucidare la PowerShell e <em>mettere sul piede di guerra</em> ChatGPT che mi ha seguito durante tutto l&#8217;iter per evitare di dimenticarmi qualche passaggio.</p>
<p>In questo post metto giù la procedura che ho seguito per arrivare a una situazione pulita:</p>
<ul>
<li>nuove identità presenti e corrette in Azure DevOps;</li>
<li>nessun Work Item ancora assegnato ai vecchi account;</li>
<li>membership dei nuovi utenti verificate;</li>
<li>vecchi utenti rimossi dall’organizzazione;</li>
<li>storico di revisioni e commenti preservato così com’è;</li>
<li>repository, branch, policy e ACL Git controllati;</li>
<li>nessun permesso Git esplicito ancora legato alle identità legacy.</li>
</ul>
<p>L’obiettivo non era cancellare ogni traccia dei vecchi utenti (avrebbe voluto dire alterare l’audit trail, e no grazie), ma assicurarmi che <strong>nessuna identità legacy fosse ancora necessaria</strong> per far girare il progetto oggi. Gli esempi che segui sono ovviamente anonimizzati: ID, UPN, organizzazione e progetto vanno adattati al tuo ambiente.</p>
<p>Prima di partire sul serio, ci vuole il giusto disclaimer:</p>
<div class="gb-block-notice warning">
    <div class="gb-notice-title warning"><p>Attenzione</p></div>
	<div class="gb-notice-text warning">
		<p>
          Da qui in poi scenderò molto nel dettaglio, ci saranno snippet, script, riferimenti e tecnicismi in abbondanza. Se non hai la minima idea di cosa sto parlando, abbandona la nave prima che sia troppo tardi e non leggere l&#8217;articolo, non voglio farti addormentare ovunque tu ti trovi, e non voglio neanche che tu pensi che sono una brutta persona più di quanto io già lo sia, <em>salvati finché sei in tempo</em>.</p>
<p>Se vai avanti con la lettura vuol dire che vuoi farti seriamente del male, che sei un addetto ai lavori o che ti sei ritrovato nella mia stessa bega. In tal caso ti abbraccio, fratello. <strong>I feel you.</strong>
		</p>
	</div>
</div>
<h1>Lo scenario</h1>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">Tenant sorgente
└── oldtenant.onmicrosoft.com
    ├── a.rossi-cutover@oldtenant.onmicrosoft.com
    └── m.bianchi-cutover@oldtenant.onmicrosoft.com

Azure DevOps
└── MyOrganization
    └── MyProject

Tenant destinazione
└── newtenant.onmicrosoft.com
    ├── andrea.rossi@example.com
    └── marco.bianchi@example.com</pre>
<p>Gli utenti del tenant sorgente non dovevano restare come guest: esistevano già identità native nel tenant di destinazione, ed erano quelle che volevo vedere usate da Azure DevOps.Il cambio di directory dell’organizzazione è filato liscio (come ti avevo già scritto poco sopra), il problema è saltato fuori dopo, controllando cosa fosse effettivamente successo al remapping delle identità.</p>
<h1>Il remapping di Microsoft si è fermato a metà strada</h1>
<p>Dopo lo switch, alcune identità risultavano collegate correttamente, altre erano rimaste in uno stato un po’ anomalo.<br />
Una identità nuova e collegata bene a Entra ID si presentava così:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">principalName : andrea.rossi@example.com
origin         : aad
originId       : &lt;Entra Object ID&gt;
descriptor     : aad....</pre>
<p>Alcune vecchie identità, invece, così:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">principalName : a.rossi-cutover@oldtenant.onmicrosoft.com
origin         : aad
originId       :
descriptor     : bnd....</pre>
<p>L’assenza di <code>originId</code> e il descriptor <code>bnd.*</code> sono stati il campanello: lo switch della directory era riuscito, ma non potevo considerare il lavoro finito solo per quello. Ho anche provato ad aspettare qualche ora nell&#8217;inutile (con il senno di poi) speranza che Microsoft ci mettesse più tempo del previsto a rimettere le cose a posto (ho lasciato passare la notte dopo la migrazione), ma nulla da fare. Ho quindi gestito a mano le parti che restavano: nuove identità, assegnazioni correnti, membership e infine la rimozione dei vecchi utenti dall’organizzazione.</p>
<h1>Preparare la sessione PowerShell</h1>
<p>REST API di Azure DevOps e PowerShell 7. Inizia a prepararti le variabili che ti serviranno più volte, nei passaggi successivi.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$Organization = "MyOrganization"
$Project      = "MyProject"</pre>
<p>Vai a crearti un PAT (Personal Access Token) sul DevOps, io ne ho tirato in piedi uno della durata di un giorno, così da evitare poi di dimenticarmelo lì attivo e potenzialmente &#8220;<em>dannoso</em>&#8220;.<br />
Per farlo digerire e gestire dagli script PowerShell ho evitato di scriverlo in chiaro da qualche parte, l&#8217;ho fatto richiedere direttamente &#8220;<em>live</em>&#8220;:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$Pat = Read-Host "Azure DevOps PAT" -AsSecureString

$Ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Pat)

try {
    $PlainPat = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($Ptr)

    $Token = [Convert]::ToBase64String(
        [Text.Encoding]::ASCII.GetBytes(":$PlainPat")
    )
}
finally {
    [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($Ptr)
}

$Headers = @{
    Authorization = "Basic $Token"
}</pre>
<p>Gli scope del PAT dipendono da cosa devi fare: per l’audit bastano quelli di lettura sulle aree coinvolte, per modifiche e rimozioni servono i relativi permessi di gestione, io ammetto di aver preso la scorciatoia e creato un PAT con FullAccess dopo aver inizialmente <em>perso tempo</em> con un PAT limitato.</p>
<h1>Recuperare il progetto</h1>
<p>Prima tappa: recupero l&#8217;ID del progetto, che mi serve in praticamente tutte le chiamate successive.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$ProjectUri = "https://dev.azure.com/$Organization/_apis/projects/${Project}?api-version=7.1"

$ProjectInfo = Invoke-RestMethod `
    -Uri $ProjectUri `
    -Headers $Headers `
    -Method Get

$ProjectInfo |
    Select-Object id,name,state |
    Format-List

$ProjectId = $ProjectInfo.id</pre>
<p>Nota PowerShell da tenere a mente: quando una variabile è seguita subito da <code>?</code> in una URL, delimitala con <code>${...}</code>, altrimenti PowerShell la interpreta come vuole lui, chiediti perché te lo sto scrivendo qui (sì, pur sapendolo ho fatto il classico errore da principiante al primo giro).</p>
<h1>Inventario degli utenti dell’organizzazione</h1>
<p>Punto di partenza: chi ha accesso oggi all&#8217;organizzazione, con licenza e nome visualizzato (il <code>displayName</code>) così da avere un riferimento da confrontare con quello che troverai più avanti.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$EntitlementsUri = "https://vsaex.dev.azure.com/$Organization/_apis/userentitlements?api-version=7.1"

$Entitlements = Invoke-RestMethod `
    -Uri $EntitlementsUri `
    -Headers $Headers `
    -Method Get

$OrgUsers = $Entitlements.items

$OrgUsers |
    Select-Object `
        id,
        @{N="DisplayName";E={$_.user.displayName}},
        @{N="PrincipalName";E={$_.user.principalName}},
        @{N="License";E={$_.accessLevel.accountLicenseType}} |
    Format-Table -AutoSize</pre>
<p>Dettaglio che mi ha fatto perdere un po’ di tempo: l’ID dell’entitlement è <strong><code>id</code> a livello principale</strong>, non <code>user.id</code>. Segnati bene chi è chi, ti tornerà utile più avanti.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$OrgUsers |
    Select-Object `
        id,
        @{N="DisplayName";E={$_.user.displayName}},
        @{N="PrincipalName";E={$_.user.principalName}},
        @{N="License";E={$_.accessLevel.accountLicenseType}} |
    Export-Csv `
        -Path ".\AzureDevOps-Users-BeforeCleanup.csv" `
        -NoTypeInformation `
        -Encoding UTF8</pre>
<h1>Inventario Azure DevOps Graph</h1>
<p>Stesso discorso, ma lato Graph: qui trovi origin e descriptor, cioè i due campi che ti dicono se un&#8217;identità è collegata bene a Entra ID oppure no.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$GraphUsersUri = "https://vssps.dev.azure.com/$Organization/_apis/graph/users?api-version=7.1-preview.1"

$GraphUsersResult = Invoke-RestMethod `
    -Uri $GraphUsersUri `
    -Headers $Headers `
    -Method Get

$GraphUsers = $GraphUsersResult.value

$GraphUsers |
    Select-Object `
        displayName,
        principalName,
        origin,
        originId,
        descriptor |
    Sort-Object displayName |
    Format-Table -AutoSize</pre>
<p>Dal risultato della tabella che ti comparirà a video, otterrai una matrice esplicita vecchio/nuovo utente:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">Vecchia identità                                    Nuova identità
-------------------------------------------------   ------------------------------
a.rossi-cutover@oldtenant.onmicrosoft.com           andrea.rossi@example.com
m.bianchi-cutover@oldtenant.onmicrosoft.com         marco.bianchi@example.com</pre>
<p>Prima di rimuovere un account <em>legacy</em> (quindi appartenente al vecchio DevOps), devi sapere con certezza quale identità lo sostituisce. Sembra ovvio scritto così, ma è il punto dove è più facile fare confusione (e io mi sono fatto due giri con esportazione CSV giusto per non fare danni stupidi da distrazione).</p>
<h1>Recuperare tutti i Work Item</h1>
<p>Serve l&#8217;elenco completo degli ID: da qui in poi ogni controllo (Assigned To, revisioni, commenti) scandisce questa lista uno per uno.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$WiqlUri = "https://dev.azure.com/$Organization/${Project}/_apis/wit/wiql?api-version=7.1"

$WiqlBody = @{
    query = @"
SELECT [System.Id]
FROM WorkItems
WHERE [System.TeamProject] = '$Project'
ORDER BY [System.Id]
"@
} | ConvertTo-Json

$WiqlResult = Invoke-RestMethod `
    -Uri $WiqlUri `
    -Headers $Headers `
    -Method Post `
    -ContentType "application/json" `
    -Body $WiqlBody

$WorkItemIds = @($WiqlResult.workItems.id)

Write-Host "Work items found: $($WorkItemIds.Count)"</pre>
<p>E una funzione di lettura riutilizzabile per i passaggi successivi:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">function Get-AdoWorkItem {
    param(
        [Parameter(Mandatory)]
        [int]$Id
    )

    $Uri = "https://dev.azure.com/$Organization/${Project}/_apis/wit/workitems/${Id}?`$expand=fields&amp;api-version=7.1"

    Invoke-RestMethod `
        -Uri $Uri `
        -Headers $Headers `
        -Method Get
}</pre>
<h1>Trovare gli Assigned To legacy</h1>
<p>Scandisco ogni Work Item e tengo solo quelli ancora assegnati a uno dei vecchi UPN.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$LegacyUpns = @(
    "a.rossi-cutover@oldtenant.onmicrosoft.com",
    "m.bianchi-cutover@oldtenant.onmicrosoft.com"
)

$LegacyAssignments = foreach ($Id in $WorkItemIds) {

    $WorkItem = Get-AdoWorkItem -Id $Id
    $Assigned = $WorkItem.fields.'System.AssignedTo'

    if ($null -ne $Assigned) {

        $AssignedUpn = $Assigned.uniqueName

        if ($AssignedUpn -in $LegacyUpns) {
            [pscustomobject]@{
                Id          = $Id
                Title       = $WorkItem.fields.'System.Title'
                AssignedTo  = $Assigned.displayName
                AssignedUpn = $AssignedUpn
            }
        }
    }
}

$LegacyAssignments |
    Group-Object AssignedUpn |
    Select-Object Name,Count |
    Sort-Object Count -Descending |
    Format-Table -AutoSize</pre>
<h1>Rimappare gli Assigned To a mano</h1>
<p>Con un array di UserMapping vado a dichiarare esplicitamente chi sostituisce chi, poi applico il mapping ai Work Item trovati nel passaggio precedente.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$UserMapping = @{
    "a.rossi-cutover@oldtenant.onmicrosoft.com" = "andrea.rossi@example.com"
    "m.bianchi-cutover@oldtenant.onmicrosoft.com" = "marco.bianchi@example.com"
}</pre>
<p>Così:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">function Set-AdoWorkItemAssignedTo {
    param(
        [Parameter(Mandatory)]
        [int]$Id,

        [Parameter(Mandatory)]
        [string]$NewAssignedTo
    )

    $Uri = "https://dev.azure.com/$Organization/${Project}/_apis/wit/workitems/${Id}?api-version=7.1"

    $Patch = @(
        @{
            op    = "replace"
            path  = "/fields/System.AssignedTo"
            value = $NewAssignedTo
        }
    ) | ConvertTo-Json

    Invoke-RestMethod `
        -Uri $Uri `
        -Headers $Headers `
        -Method Patch `
        -ContentType "application/json-patch+json" `
        -Body $Patch
}</pre>
<p>E applicazione del mapping:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$Successful = 0
$Skipped    = 0
$Failed     = 0

foreach ($Assignment in $LegacyAssignments) {

    $OldUpn = $Assignment.AssignedUpn

    if (-not $UserMapping.ContainsKey($OldUpn)) {
        $Skipped++
        continue
    }

    $NewUpn = $UserMapping[$OldUpn]

    try {
        Set-AdoWorkItemAssignedTo `
            -Id $Assignment.Id `
            -NewAssignedTo $NewUpn

        $Successful++
    }
    catch {
        Write-Warning "Failed Work Item $($Assignment.Id): $($_.Exception.Message)"
        $Failed++
    }
}

Write-Host "Successful: $Successful"
Write-Host "Skipped:    $Skipped"
Write-Host "Failed:     $Failed"</pre>
<p>Occhio a testare il mapping su un singolo Work Item prima, verificando il risultato dalla UI, e solo dopo lanciarlo su tutto il resto.<br />
Su una migrazione di identità non è il campo dove vuoi scoprire un errore di mapping dopo averlo già applicato a 300 elementi. Meglio un&#8217;operazione &#8220;<em>ripetuta almeno un paio di volte</em>&#8221; che testare il brivido sull&#8217;intero contenitore, rifatti un po&#8217; ai consigli della nonna quando ti suggeriva di provare il nuovo detersivo su un lembo non visibile della maglia o della camicia <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f601.png" alt="😁" class="wp-smiley" style="height: 1em; max-height: 1em;" /></p>
<h1>Verificare gli Assigned To</h1>
<p>Stesso controllo di prima, ma dopo il mapping: serve a confermare che non sia rimasto nulla assegnato ai vecchi account.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$OldCurrentAssignments = foreach ($Id in $WorkItemIds) {

    $WorkItem = Get-AdoWorkItem -Id $Id
    $Assigned = $WorkItem.fields.'System.AssignedTo'

    if (
        $null -ne $Assigned -and
        $Assigned.uniqueName -in $LegacyUpns
    ) {
        [pscustomobject]@{
            Id          = $Id
            AssignedUpn = $Assigned.uniqueName
        }
    }
}

Write-Host "Old current assignments: $($OldCurrentAssignments.Count)"</pre>
<p>Il risultato che vuoi vedere è:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">Old current assignments: 0</pre>
<h1>Lo storico: cosa NON ho toccato</h1>
<p>Durante l’audit sono saltati fuori riferimenti ai vecchi utenti anche in altri campi:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">Microsoft.VSTS.Common.ActivatedBy
Microsoft.VSTS.Common.ClosedBy
System.AuthorizedAs
System.CreatedBy
System.ChangedBy</pre>
<p>Qui la distinzione è tutto. <code>System.AssignedTo</code> è uno stato operativo corrente, il “chi lavora su questa cosa adesso”. <code>CreatedBy</code>, <code>ChangedBy</code>, <code>ActivatedBy</code>, <code>ClosedBy</code> e simili raccontano invece <strong>chi ha fatto davvero un’azione nel passato</strong>.</p>
<p>Riscriverli avrebbe voluto dire attribuire alle nuove identità operazioni compiute dai vecchi account, cioè falsificare la storia del progetto. Li ho lasciati esattamente dove stavano.</p>
<h1>Audit delle revisioni</h1>
<p>Stesso principio del punto precedente, ma applicato allo storico: qui non guardo lo stato attuale, guardo chi ha modificato cosa nel tempo.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$RevisionLegacyUsers = [System.Collections.Generic.List[object]]::new()

foreach ($Id in $WorkItemIds) {

    $Uri = "https://dev.azure.com/$Organization/${Project}/_apis/wit/workitems/${Id}/revisions?api-version=7.1"

    $Revisions = (Invoke-RestMethod `
        -Uri $Uri `
        -Headers $Headers `
        -Method Get).value

    foreach ($Revision in $Revisions) {

        $ChangedBy = $Revision.fields.'System.ChangedBy'

        if (
            $null -ne $ChangedBy -and
            $ChangedBy.uniqueName -in $LegacyUpns
        ) {
            $RevisionLegacyUsers.Add(
                [pscustomobject]@{
                    WorkItemId = $Id
                    Revision   = $Revision.rev
                    ChangedBy  = $ChangedBy.uniqueName
                }
            )
        }
    }
}

$RevisionLegacyUsers |
    Group-Object ChangedBy |
    Select-Object Count,Name |
    Sort-Object Count -Descending |
    Format-Table -AutoSize</pre>
<p>Trovare risultati qui non è un errore: è semplicemente lo storico che fa il suo mestiere.</p>
<h1>Audit degli autori dei commenti</h1>
<p>Stesso identico controllo, stavolta sugli autori dei commenti ai Work Item.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$LegacyCommentAuthors = [System.Collections.Generic.List[object]]::new()

foreach ($Id in $WorkItemIds) {

    $Uri = "https://dev.azure.com/$Organization/${Project}/_apis/wit/workItems/${Id}/comments?api-version=7.1-preview.4"

    try {
        $Comments = (Invoke-RestMethod `
            -Uri $Uri `
            -Headers $Headers `
            -Method Get).comments
    }
    catch {
        continue
    }

    foreach ($Comment in $Comments) {

        $AuthorUpn = $Comment.createdBy.uniqueName

        if ($AuthorUpn -in $LegacyUpns) {
            $LegacyCommentAuthors.Add(
                [pscustomobject]@{
                    WorkItemId = $Id
                    CommentId  = $Comment.id
                    Author     = $AuthorUpn
                }
            )
        }
    }
}

$LegacyCommentAuthors |
    Group-Object Author |
    Select-Object Count,Name |
    Sort-Object Count -Descending |
    Format-Table -AutoSize</pre>
<p>Anche questi, lasciati dove stavano.</p>
<h1>Controllare le membership</h1>
<p>Prima di cancellare qualsiasi account, voglio sapere a quali gruppi appartiene ogni utente: elenco prima i gruppi dell&#8217;organizzazione, poi le membership vere e proprie.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$GroupsUri = "https://vssps.dev.azure.com/$Organization/_apis/graph/groups?api-version=7.1-preview.1"

$Groups = (Invoke-RestMethod `
    -Uri $GroupsUri `
    -Headers $Headers `
    -Method Get).value

$Groups |
    Select-Object displayName,principalName,descriptor |
    Sort-Object displayName |
    Format-Table -AutoSize</pre>
<p>Funzione per leggere le membership:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">function Get-AdoMembership {
    param(
        [Parameter(Mandatory)]
        [string]$Descriptor
    )

    $EncodedDescriptor = [System.Uri]::EscapeDataString($Descriptor)

    $Uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/memberships/${EncodedDescriptor}?direction=up&amp;depth=1&amp;api-version=7.1-preview.1"

    (Invoke-RestMethod `
        -Uri $Uri `
        -Headers $Headers `
        -Method Get).value
}</pre>
<p>E il report vero e proprio:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">foreach ($User in $GraphUsers) {

    $Memberships = Get-AdoMembership -Descriptor $User.descriptor

    foreach ($Membership in $Memberships) {

        $Group = $Groups |
            Where-Object descriptor -eq $Membership.containerDescriptor |
            Select-Object -First 1

        [pscustomobject]@{
            User  = $User.principalName
            Group = $Group.principalName
        }
    }
}</pre>
<p>Questo controllo va fatto <strong>prima</strong> di eliminare gli account precedenti, non dopo. Azure DevOps usa membership transitive: un utente può avere permessi ricevuti dal team o dai gruppi padre, non solo da assegnazioni dirette, e se non lo controlli prima rischi di scoprirlo nel modo sbagliato.</p>
<h1>Rimuovere gli utenti legacy</h1>
<p>A questo punto isolo gli utenti legacy ancora presenti nell&#8217;organizzazione, prima di passare alla cancellazione vera e propria.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$LegacyOrgUsers = $OrgUsers |
    Where-Object {
        $_.user.principalName -in $LegacyUpns
    }

$LegacyOrgUsers |
    Select-Object `
        id,
        @{N="DisplayName";E={$_.user.displayName}},
        @{N="PrincipalName";E={$_.user.principalName}} |
    Format-Table -AutoSize</pre>
<p>E la rimozione vera:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$Removed = 0
$Failed  = 0

foreach ($User in $LegacyOrgUsers) {

    $Uri = "https://vsaex.dev.azure.com/$Organization/_apis/userentitlements/$($User.id)?api-version=7.1"

    try {
        Invoke-RestMethod `
            -Uri $Uri `
            -Headers $Headers `
            -Method Delete

        Write-Host "Removed: $($User.user.principalName)"
        $Removed++
    }
    catch {
        Write-Warning "Failed: $($User.user.principalName)"
        Write-Warning $_.Exception.Message
        $Failed++
    }
}

Write-Host ""
Write-Host "Removed: $Removed"
Write-Host "Failed:  $Failed"</pre>
<p>Anche qui: l’identificativo giusto è <code>$User.id</code>, non <code>$User.user.id</code>. Lo stesso dettaglio dell’inventario iniziale, e lo stesso punto dove è facile inciampare due volte.</p>
<h1>Verifica finale degli utenti</h1>
<p>Richiamo di nuovo l&#8217;inventario degli utenti per essere sicuro che i vecchi account non ci siano più.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$Entitlements = Invoke-RestMethod `
    -Uri $EntitlementsUri `
    -Headers $Headers `
    -Method Get

$OrgUsers = $Entitlements.items

$LegacyStillPresent = $OrgUsers |
    Where-Object {
        $_.user.principalName -in $LegacyUpns
    }

Write-Host "Users remaining: $($OrgUsers.Count)"
Write-Host "Legacy users still present: $($LegacyStillPresent.Count)"</pre>
<p>Anche qui, il risultato voluto è secco:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">Legacy users still present: 0</pre>
<p>Esportazione finale, per avere una fotografia dello stato pulito:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$OrgUsers |
    Select-Object `
        @{N="DisplayName";E={$_.user.displayName}},
        @{N="PrincipalName";E={$_.user.principalName}},
        @{N="License";E={$_.accessLevel.accountLicenseType}} |
    Export-Csv `
        -Path ".\AzureDevOps-FinalUsers.csv" `
        -NoTypeInformation `
        -Encoding UTF8</pre>
<h1>Perché le vecchie identità possono restare in Graph</h1>
<p>Dopo la rimozione dagli entitlement, alcune identità legacy possono continuare a comparire nelle risposte di Azure DevOps Graph. Da solo, questo non significa che abbiano ancora accesso: nel mio caso erano ancora referenziate da revisioni, commenti e campi storici come <code>ChangedBy</code>, <code>CreatedBy</code> e <code>ClosedBy</code>, esattamente quelli che avevo deciso di preservare.</p>
<p>Per questo ho usato gli <strong>User Entitlements per stabilire chi avesse davvero accesso corrente</strong>, mentre Graph è rimasto uno strumento per analizzare identità e riferimenti storici, non per decidere chi può ancora entrare.</p>
<h1>Audit dei repository</h1>
<p>Chiuso il fronte utenti, passo a un altro fronte: i repository Git dell&#8217;organizzazione.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$ReposUri = "https://dev.azure.com/$Organization/${Project}/_apis/git/repositories?api-version=7.1"

$Repos = (Invoke-RestMethod `
    -Uri $ReposUri `
    -Headers $Headers `
    -Method Get).value

$Repos |
    Select-Object id,name,defaultBranch,isDisabled |
    Sort-Object name |
    Format-Table -AutoSize

Write-Host "Repositories found: $($Repos.Count)"</pre>
<h1>Enumerare i branch</h1>
<p>Per ogni repository elenco i branch: mi serve più avanti, quando incrocio i token delle ACL con i repository ancora esistenti.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$BranchReport = foreach ($Repo in $Repos) {

    $RefsUri = "https://dev.azure.com/$Organization/${Project}/_apis/git/repositories/$($Repo.id)/refs?filter=heads/&amp;api-version=7.1"

    $Refs = (Invoke-RestMethod `
        -Uri $RefsUri `
        -Headers $Headers `
        -Method Get).value

    foreach ($Ref in $Refs) {

        $BranchName = ($Ref.name -replace '^refs/heads/', '')

        [pscustomobject]@{
            Repository = $Repo.name
            Branch     = $BranchName
            ObjectId   = $Ref.objectId
        }
    }
}

$BranchReport |
    Sort-Object Repository,Branch |
    Export-Csv `
        -Path ".\AzureDevOps-Branches.csv" `
        -NoTypeInformation `
        -Encoding UTF8</pre>
<h1>Verificare le branch policy</h1>
<p>Controllo anche le policy sui branch, per essere sicuro che nessuna faccia ancora riferimento agli account legacy.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$PoliciesUri = "https://dev.azure.com/$Organization/${Project}/_apis/policy/configurations?api-version=7.1"

$Policies = (Invoke-RestMethod `
    -Uri $PoliciesUri `
    -Headers $Headers `
    -Method Get).value

Write-Host "Branch policies found: $($Policies.Count)"</pre>
<p>Nel mio scenario non c’erano branch policy da migrare o correggere, ma il controllo va fatto comunque: è un minuto di script contro il rischio di scoprire dopo che una policy puntava a un account che non esiste più.</p>
<h1>Audit delle ACL Git</h1>
<p>Il namespace di sicurezza Git è fisso:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87</pre>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$GitNamespaceId = "2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87"

$ProjectGitToken = "repoV2/$ProjectId"
$EncodedProjectGitToken = [System.Uri]::EscapeDataString($ProjectGitToken)

$AclUri = "https://dev.azure.com/$Organization/_apis/accesscontrollists/${GitNamespaceId}?token=$EncodedProjectGitToken&amp;recurse=true&amp;includeExtendedInfo=true&amp;api-version=7.1"

$GitAclResult = Invoke-RestMethod `
    -Uri $AclUri `
    -Headers $Headers `
    -Method Get</pre>
<p>Estrazione delle ACE:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$AclDescriptors = foreach ($Acl in $GitAclResult.value) {

    foreach ($Property in $Acl.acesDictionary.PSObject.Properties) {

        [pscustomobject]@{
            Token      = $Acl.token
            Descriptor = $Property.Name
            Allow      = $Property.Value.allow
            Deny       = $Property.Value.deny
        }
    }
}

$AclDescriptors |
    Format-Table Token,Descriptor,Allow,Deny -AutoSize</pre>
<h1>I descriptor delle ACL non sono quelli di Graph</h1>
<p>Nelle ACL Git ho trovato descriptor di questo tipo:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">Microsoft.TeamFoundation.Identity;S-1-9-...</pre>
<p>mentre Graph usa valori come:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">aad....
bnd....
vssgp....</pre>
<p>Non sono confrontabili direttamente, e la prima volta che li vedi affiancati viene da pensare di aver sbagliato qualcosa. Prima estrai i descriptor relativi alle identity:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$IdentityDescriptors = $AclDescriptors |
    Where-Object {
        $_.Descriptor -like "Microsoft.TeamFoundation.Identity;*"
    } |
    Select-Object -ExpandProperty Descriptor -Unique</pre>
<p>Poi li risolvi tramite l’API Identities:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$ResolvedIdentities = [System.Collections.Generic.List[object]]::new()

$BatchSize = 20

for (
    $Offset = 0;
    $Offset -lt $IdentityDescriptors.Count;
    $Offset += $BatchSize
) {

    $LastIndex = [Math]::Min(
        $Offset + $BatchSize - 1,
        $IdentityDescriptors.Count - 1
    )

    $Batch = @(
        $IdentityDescriptors[$Offset..$LastIndex]
    )

    $DescriptorList = $Batch -join ","

    $EncodedDescriptors = [System.Uri]::EscapeDataString(
        $DescriptorList
    )

    $Uri = "https://vssps.dev.azure.com/$Organization/_apis/identities?descriptors=$EncodedDescriptors&amp;queryMembership=None&amp;api-version=7.1"

    $Result = Invoke-RestMethod `
        -Uri $Uri `
        -Headers $Headers `
        -Method Get

    foreach ($Identity in $Result.value) {

        $ResolvedIdentities.Add(
            [pscustomobject]@{
                Descriptor          = $Identity.descriptor
                ProviderDisplayName = $Identity.providerDisplayName
                SubjectDescriptor   = $Identity.subjectDescriptor
                IsActive            = $Identity.isActive
                IsContainer         = $Identity.isContainer
                Id                  = $Identity.id
            }
        )
    }
}

$ResolvedIdentities |
    Sort-Object ProviderDisplayName |
    Format-Table `
        ProviderDisplayName,
        IsActive,
        IsContainer,
        SubjectDescriptor `
        -AutoSize</pre>
<p>Nel mio caso le ACL Git facevano riferimento solo a gruppi Azure DevOps, tra cui:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">[Organization]\Project Collection Administrators
[Organization]\Project Collection Build Service Accounts
[Organization]\Project Collection Service Accounts
[Project]\Build Administrators
[Project]\Contributors
[Project]\Project Administrators
[Project]\Readers</pre>
<p>Nessuna ACE Git esplicitamente intestata ai vecchi utenti. Bene così.</p>
<h1>ACL legate a repository che non esistono più</h1>
<p>Ultimo giro: confrontare i token ACL con i repository correnti, per beccare eventuali residui orfani.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$CurrentRepoIds = @(
    $Repos.id |
        ForEach-Object {
            $_.ToString().ToLowerInvariant()
        }
)

$RepositoryAclReport = foreach ($Acl in $GitAclResult.value) {

    if ($Acl.token -match '^repoV2/[^/]+/([^/]+)$') {

        $RepoId = $Matches[1].ToLowerInvariant()

        $Repo = $Repos |
            Where-Object {
                $_.id.ToString().ToLowerInvariant() -eq $RepoId
            } |
            Select-Object -First 1

        [pscustomobject]@{
            RepositoryId = $RepoId
            Repository   = $Repo.name
            Exists       = [bool]$Repo
            Token        = $Acl.token
        }
    }
}

$RepositoryAclReport |
    Where-Object Exists -eq $false |
    Format-Table RepositoryId,Repository,Exists,Token -AutoSize</pre>
<p>Nel mio ambiente è rimasto un token ACL relativo a un repository non più esistente. Non l’ho considerato un blocco per la migrazione delle identità, visto che le sue ACE non contenevano riferimenti agli utenti legacy: resta housekeeping da fare a parte, non un problema di identità.</p>
<h1>Checklist finale</h1>
<p>A fine giro, ecco il riepilogo secco di tutto quello controllato:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">[OK] Directory Azure DevOps collegata al nuovo tenant
[OK] Nuove identità presenti
[OK] Mapping vecchio -&gt; nuovo verificato
[OK] AssignedTo legacy = 0
[OK] Membership delle nuove identità verificate
[OK] Utenti legacy attivi nell'organizzazione = 0
[OK] Revisioni storiche preservate
[OK] Autori storici dei commenti preservati
[OK] Repository correnti verificati
[OK] Branch verificati
[OK] Branch policy verificate
[OK] ACL Git verificate
[OK] ACE Git esplicite associate a utenti legacy = 0</pre>
<p>Solo a questo punto ho considerato davvero conclusa la migrazione delle identità.</p>
<h1>In conclusione</h1>
<p>La cosa più importante che mi porto a casa da questa storia è che <strong>cambiare la directory di Azure DevOps e rimappare gli utenti non sono la stessa cosa</strong>, anche se Microsoft te la vende un po’ come se lo fossero. Nel mio caso lo switch del tenant è riuscito, ma il remapping automatico ha lasciato alcune identità a metà strada. E cercare di cancellare ogni traccia delle vecchie identità sarebbe stato un errore uguale e contrario: la distinzione che conta davvero è tra <strong>stato operativo corrente</strong> e <strong>storico del progetto</strong>.</p>
<p>Gli utenti legacy non devono più avere accesso, membership o assegnazioni attive. Ma devono poter continuare a comparire come autori di una modifica fatta in passato, di un commento, della chiusura di un Work Item: quella è storia, non uno stato da &#8220;<em>correggere</em>&#8220;.</p>
<p>Il risultato giusto non è &#8220;<em>Azure DevOps non conosce più le vecchie identità</em>&#8220;, è piuttosto: le vecchie identità non hanno più alcun ruolo operativo, ma Azure DevOps conserva correttamente ciò che hanno fatto. Ed è questa, nel mio caso, la verifica che mi ha permesso di dire davvero conclusa la migrazione.</p>
<p>L’area commenti è a tua disposizione se ti sei trovato in uno scenario simile o se hai un pezzo di script da confrontare. Ma anche per riprenderti dalla depressione che ti è venuta se hai letto fino a qui: il caffè lo offro io. Alcuni dei passaggi qui sopra li ho dovuti studiare (e in alcuni casi farmeli pure spiegare meglio dal povero agente AI di turno) perché mi erano totalmente sfuggiti, non c&#8217;è peccato nell&#8217;ammettere dei limiti, era la prima volta che mi ritrovavo in una situazione simile.</p>
<p><strong>#KeepItSimple</strong></p>
<hr />
<h6>Immagine di copertina: <a class="stockpack-author" href="https://unsplash.com/@ruisilvestrecreative?utm_source=Stockpack&amp;utm_medium=referral&amp;utm_campaign=api-credit" target="_blank" rel="nofollow noopener noreferrer">Rui Silvestre</a> on <a class="stockpack-provider" href="https://unsplash.com/photos/3CXR8yZ4WdA?utm_source=Stockpack&amp;utm_medium=referral" target="_blank" rel="nofollow noopener noreferrer">Unsplash</a></h6>
<p><a rel="nofollow" href="https://gioxx.org/2026/09/28/azure-devops-e-il-cambio-di-tenant-entra-id-quando-il-remapping-automatico-non-basta/">Azure DevOps e il cambio di tenant Entra ID: quando il remapping automatico non basta</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://gioxx.org/2026/09/28/azure-devops-e-il-cambio-di-tenant-entra-id-quando-il-remapping-automatico-non-basta/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>PowerShell: verificare l&#8217;esistenza di una serie di indirizzi di posta</title>
		<link>https://gioxx.org/2026/09/22/powershell-verificare-lesistenza-di-una-serie-di-indirizzi-di-posta/</link>
					<comments>https://gioxx.org/2026/09/22/powershell-verificare-lesistenza-di-una-serie-di-indirizzi-di-posta/#respond</comments>
		
		<dc:creator><![CDATA[Gioxx]]></dc:creator>
		<pubDate>Tue, 22 Sep 2026 07:30:14 +0000</pubDate>
				<category><![CDATA[Lavoro]]></category>
		<category><![CDATA[Microsoft]]></category>
		<category><![CDATA[Microsoft 365]]></category>
		<category><![CDATA[Microsoft Exchange]]></category>
		<category><![CDATA[Posta elettronica]]></category>
		<category><![CDATA[PowerShell]]></category>
		<guid isPermaLink="false">https://gioxx.org/?p=125938</guid>

					<description><![CDATA[<p>Te la farò breve: ho tirato fuori l&#8217;intero elenco delle utenze Dropbox aziendali. Di solito, quando un utente va via, le sue utenze vengono disattivate su ogni servizio a questo collegato. Talvolta, però, qualcosa può sfuggire, e ho quindi deciso di mettere a confronto la lista utenze Dropbox con quella ... <a title="PowerShell: verificare l&#8217;esistenza di una serie di indirizzi di posta" class="read-more" href="https://gioxx.org/2026/09/22/powershell-verificare-lesistenza-di-una-serie-di-indirizzi-di-posta/" aria-label="Read more about PowerShell: verificare l&#8217;esistenza di una serie di indirizzi di posta">Leggi tutto</a></p>
<p><a rel="nofollow" href="https://gioxx.org/2026/09/22/powershell-verificare-lesistenza-di-una-serie-di-indirizzi-di-posta/">PowerShell: verificare l&#8217;esistenza di una serie di indirizzi di posta</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></description>
										<content:encoded><![CDATA[<p>Te la farò breve: ho tirato fuori l&#8217;intero elenco delle utenze Dropbox aziendali. Di solito, quando un utente va via, le sue utenze vengono disattivate su ogni servizio a questo collegato. Talvolta, però, qualcosa può sfuggire, e ho quindi deciso di mettere a confronto la lista utenze Dropbox con quella di Microsoft 365 (quest&#8217;ultimo è totalmente automatizzato: se l&#8217;utenza non esiste più in AD, allora non esisterà più su Entra, non avrà più una casella di posta elettronica, ecc.). Una base dati di cui posso fidarmi (sì, l&#8217;AD è aggiornata di continuo, è la &#8220;fonte di verità&#8221; per ciò che riguarda il mondo degli utenti).</p>
<h1>Dimmi se la casella esiste</h1>
<p>Il confronto viene quindi molto semplice: esiste la casella di posta elettronica? Se la risposta è no, allora molto probabilmente ho un account di Dropbox ancora attivo sul tenant, che consuma inutilmente <em>un posto a sedere</em> (e mi fa spendere i soldi per la licenza), e che va perciò cessato. Data una lista di indirizzi di posta elettronica (classico file CSV a singola colonna, con intestazione <code>account</code>, giusto per fare un esempio che mi fa da base per questo articolo), lo script di PowerShell non è poi così complicato:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$ProgressPreference = 'Continue'
$ConfirmPreference = 'None'

Import-Csv .\check_utenti.csv | % {
    $email = $_.account.Trim()

    Write-Progress -Activity "Loading" -Status $email

    try {
        $destinatario = Get-Recipient `
            -Identity $email `
            -ErrorAction Stop `
            -WarningAction SilentlyContinue

        Write-Progress -Activity "Loading" -Status $destinatario.DisplayName
    }
    catch {
        [PSCustomObject]@{
            Email  = $email
            Esiste = $false
            Errore = $_.Exception.Message
        }
    }
}
</pre>
<p>Prendo l&#8217;account, per sicurezza tolgo eventuali spazi vuoti, poi riporto a video l&#8217;avanzamento (giusto per non far pensare che lo script sia rimasto inchiodato lì in attesa del nulla cosmico) e inizio a popolare un oggetto che alla fine presenterò agli occhi dell&#8217;utilizzatore, a tre colonne, riportando indirizzo e-mail analizzato, se esiste (ovviamente questa condizione sarà sempre falsa) e l&#8217;errore riscontrato sul tenant Microsoft (banalmente dovresti sempre ottenere il messaggio per casella di posta elettronica non trovata).</p>
<p>Nulla di più semplice, ma che ha risolto l&#8217;esigenza spicciola di non dover stare lì a confrontare centinaia di indirizzi di posta elettronica che avevo in pancia al tenant di Dropbox.</p>
<p>Domande? Dubbi? Tu l&#8217;avresti fatta meglio <em>con cinquemila lire</em>? Ne sono convinto (non fosse anche solo per le due colonne &#8220;<em>potenzialmente inutili</em>&#8221; riguardo la condizione di inesistenza e l&#8217;errore riscontrato sul tenant Microsoft). L&#8217;area commenti è al solito posto, usala!</p>
<p>Se sei proprio pignolo e vuoi sapere perché ho mantenuto quelle due colonne, è perché all&#8217;inizio avevo usato uno script un po&#8217; più verboso:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$ProgressPreference = 'Continue'
$ConfirmPreference = 'None'

$righe = Import-Csv .\check_utenti.csv

$risultati = foreach ($riga in $righe){
    $email = $riga.account.Trim()

    Write-Progress -Activity "Loading" -Status $email

    try {
        $destinatario = Get-Recipient `
            -Identity $email `
            -ErrorAction Stop `
            -WarningAction SilentlyContinue

        [PSCustomObject]@{
            Email       = $email
            Esiste      = $true
            DisplayName = $destinatario.DisplayName
            Tipo        = $destinatario.RecipientTypeDetails
            Errore      = $null
        }

        Write-Progress -Activity "Loading" -Status $destinatario.DisplayName
    }
    catch {
        [PSCustomObject]@{
            Email       = $email
            Esiste      = $false
            DisplayName = $null
            Tipo        = $null
            Errore      = $_.Exception.Message
        }
    }
}

Write-Progress `
    -Activity "Verifica destinatari Exchange" `
    -Completed

$risultati | Format-Table -AutoSize</pre>
<p>Ne hai davvero bisogno? No, a meno che non ti serva una situazione davvero completa e non solo gli account da ripulire.</p>
<p><strong>#KeepItSimple</strong></p>
<hr />
<h6>Immagine di copertina <a class="stockpack-author" href="https://unsplash.com/@mimithian?utm_source=Stockpack&amp;utm_medium=referral&amp;utm_campaign=api-credit" target="_blank" rel="nofollow noopener noreferrer">Mimi Thian</a> on <a class="stockpack-provider" href="https://unsplash.com/photos/zMcBf-n6qrs?utm_source=Stockpack&amp;utm_medium=referral" target="_blank" rel="nofollow noopener noreferrer">Unsplash</a></h6>
<p><a rel="nofollow" href="https://gioxx.org/2026/09/22/powershell-verificare-lesistenza-di-una-serie-di-indirizzi-di-posta/">PowerShell: verificare l&#8217;esistenza di una serie di indirizzi di posta</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://gioxx.org/2026/09/22/powershell-verificare-lesistenza-di-una-serie-di-indirizzi-di-posta/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Tikka Masala: metti al sicuro i tuoi tunnel Cloudflare</title>
		<link>https://gioxx.org/2026/09/03/tikka-masala-backup-cloudflare-tunnel/</link>
					<comments>https://gioxx.org/2026/09/03/tikka-masala-backup-cloudflare-tunnel/#respond</comments>
		
		<dc:creator><![CDATA[Gioxx]]></dc:creator>
		<pubDate>Thu, 03 Sep 2026 07:30:52 +0000</pubDate>
				<category><![CDATA[Tech & Coding]]></category>
		<category><![CDATA[Backup]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[GitHub]]></category>
		<category><![CDATA[In Evidenza]]></category>
		<category><![CDATA[Open Source]]></category>
		<category><![CDATA[Ricerca e Sviluppo]]></category>
		<category><![CDATA[Self-Hosted]]></category>
		<category><![CDATA[Software]]></category>
		<category><![CDATA[Tikka Masala]]></category>
		<guid isPermaLink="false">https://gioxx.org/?p=125880</guid>

					<description><![CDATA[<p>Era marzo di quest&#8217;anno. Uno di quei tentativi da dopocena, con i bambini messi a nanna e la possibilità di svaccarsi (termine tecnico eh, mica pizza e fichi) sul divano a guardare qualcosa di decente in TV con mia moglie, quando mi sono messo in testa di provare DockFlare. Me ... <a title="Tikka Masala: metti al sicuro i tuoi tunnel Cloudflare" class="read-more" href="https://gioxx.org/2026/09/03/tikka-masala-backup-cloudflare-tunnel/" aria-label="Read more about Tikka Masala: metti al sicuro i tuoi tunnel Cloudflare">Leggi tutto</a></p>
<p><a rel="nofollow" href="https://gioxx.org/2026/09/03/tikka-masala-backup-cloudflare-tunnel/">Tikka Masala: metti al sicuro i tuoi tunnel Cloudflare</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></description>
										<content:encoded><![CDATA[<p>Era marzo di quest&#8217;anno. Uno di quei tentativi da dopocena, con i bambini messi a nanna e la possibilità di <em>svaccarsi</em> (termine tecnico eh, mica pizza e fichi) sul divano a guardare qualcosa di decente in TV con mia moglie, quando mi sono messo in testa di provare <a href="https://dockflare.app/" rel="noopener">DockFlare</a>. Me lo aveva consigliato qualcuno (non ricordo più se su Reddit o nel solito gruppo di compari e nerd con cui chiacchiero ogni giorno) e l&#8217;idea di gestire i <a href="https://gioxx.org/2024/07/16/cloudflare-tunnel-cose-e-come-usarlo-con-docker/">tunnel di Cloudflare</a> direttamente a partire dai container Docker mi solleticava parecchio. Ormai sono passati tre anni e più da quando ho iniziato a tenere diverse cose in self-hosting tra le mura di casa (e su qualche servizio sparso online), quindi provo sempre cose nuove per migliorare l&#8217;esperienza d&#8217;uso dei prodotti sui quali mi appoggio nel quotidiano.</p>
<p>Sarà stata la stanchezza, sarà che quella sera ero semplicemente poco lucido, fatto sta che ho tirato in piedi un tunnel di test con cui giocare e rompere cose in santa pace.<br />
Peccato che poi, volendo sperimentare sul serio, ho dato a DockFlare la conferma per prendere <em>possesso</em> del tunnel principale, quello che tiene in piedi buona parte dei servizi di casa. Quello che non avevo capito è che nell&#8217;intervallo di tempo tra il mio clic e l&#8217;esecuzione dell&#8217;operazione avrei perso tutte le configurazioni, dalla prima all&#8217;ultima, nessuna route sopravvissuta, vuoto pneumatico.</p>
<p>Sia chiaro: non è colpa di DockFlare, la conferma l&#8217;ho data io e la distrazione era tutta mia. Però intanto ho buttato giù il monitor del Mac, ho imprecato quanto serviva e ho rimandato tutto al giorno dopo. Ricostruire era fattibile, ma significava buttare via un mucchio di tempo. Quando l&#8217;ho fatto, ho anche provato a salvarmi la configurazione da qualche parte, un export da poter reimportare velocemente in caso di nuovo disastro, e non ho trovato niente che facesse al caso mio.</p>
<h1>Il nome</h1>
<p>In quella chat che alimentiamo a pane, cazzate e lamentele ogni giorno si stava parlando di India, di supporto, di call center e di non ricordo neanche più cos&#8217;altro. E a me era pure venuta fame senza apparente motivo. È in quel contesto, mentre con l&#8217;aiuto di Claude Code mettevo in piedi le prime righe di questo progetto, che ho scelto un nome provvisorio: <strong>Tikka Masala</strong>. Mi fa sorridere ancora oggi, il provvisorio è diventato definitivo, e il software esiste per rimediare a una mia disattenzione: ne ho avuto bisogno in prima persona e l&#8217;ho reso pubblico nella speranza che eviti ad altri la stessa situazione (o per lo meno l&#8217;idea è quella di provarci, stile raccomandazione della nonna su cosa devi fare per <em>non prendere freddo</em>).</p>
<h1>Cosa fa</h1>
<p>Il sottotitolo che ho scelto per il progetto è <em>&#8220;backup and restore Cloudflare Tunnels, without the spreadsheet&#8221;</em>, e riassume abbastanza bene le intenzioni.</p>
<p>Gli dai un <strong>API token di Cloudflare</strong>, lui verifica che sia valido, ti elenca i tunnel che vede sull&#8217;account e ti permette di salvarne uno <strong>snapshot</strong>: un file JSON con la configurazione del tunnel così come la restituiscono gli endpoint di configurazione dei Cloudflare Tunnel (regole di ingress, hostname pubblici), più i metadati che servono a ritrovarla (data dell&#8217;export, account, ID e nome del tunnel, eventuali note). I metadati e lo storico stanno in un piccolo database SQLite, gli snapshot veri e propri sono file su disco.</p>
<p>Il perimetro è quello e solo quello: la configurazione dei tunnel. Non tocca le impostazioni Cloudflare a livello di account, e non salva &#8211; al momento &#8211; Access (Applications e Policies), per cui trovi solo delle scorciatoie che ti portano dritto alla sezione giusta della dashboard. Quando serve, gli chiedi di <strong>ripristinare</strong>: prende lo snapshot e sovrascrive la configurazione remota del tunnel con quella salvata. Puoi rimetterla sul tunnel di origine, su un altro tunnel o su un altro account, e ogni ripristino resta tracciato nello storico (quando, su quale account, su quale tunnel).</p>
<p>I backup li fai partire a mano oppure li lasci a uno scheduler interno (con <code>cron</code> e possibilità di scegliere il fuso orario). Qui la parte che uso di più: per ogni tunnel scegli una frequenza, a ogni esecuzione, una volta a settimana o una volta al mese, e decidi se coprirli tutti o solo un sottoinsieme. Nel mio caso i tunnel che tocco spesso girano a ogni ciclo, quelli fermi da mesi si accontentano del giro mensile. C&#8217;è una retention (<code>BACKUP_RETENTION_DAYS</code>, io la tengo a 90 giorni) che dopo ogni nuovo backup fa piazza pulita di quelli più vecchi della soglia, snapshot e relativo storico compresi.</p>
<p>Occhio a una cosa sui permessi del token: per far girare anche i ripristini, Tikka Masala avrà necessità del permesso <em>Cloudflare Tunnel</em> in scrittura (più <em>DNS</em> sulla zona), ma se ti interessa solo il backup puoi tenerlo al guinzaglio e sola lettura. Il token viene cifrato, non resta in chiaro sul disco.</p>
<p>Sotto il cofano è un&#8217;applicazione <strong>FastAPI</strong> in Python, niente di esotico, con un sistema di notifiche via webhook o Telegram per farti sapere quando un backup o un ripristino va storto. L&#8217;ho rilasciato pronto per <strong>Docker</strong> (immagine <code>gfsolone/tikkamasala</code> su Docker Hub o in alternativa <code>ghcr.io/gioxx/tikkamasala</code> via GitHub, con un <code>docker-compose</code> di esempio <a href="https://github.com/gioxx/TikkaMasala/blob/main/docker-compose.yml" rel="noopener">disponibile sul repository</a>): lo metti in piedi accanto agli altri servizi di casa, lo configuri la prima volta e te ne dimentichi (sempre però rispettando la regola che uno strumento di backup è definibile funzionante solo quando sei in grado di ripristinare senza il minimo errore, quindi provalo prima di metterti comodo sul tuo cuscino).</p>
<h1>I numeri, i miei</h1>
<p>Lo uso da marzo, senza interruzioni, e nel frattempo siamo arrivati alla versione 1.3.8.<br />
A oggi che scrivo queste righe la mia installazione personale mi restituisce questa situazione:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="raw">Unique tunnels     5
Routes archived    5095
Restores           12
Scheduled runs     170
Oldest backup      2026-05-31T03:00:04+00:00
Latest backup      2026-08-29T03:00:04+00:00

Archive retention  90 day(s)
Backups kept       127
Backup storage     1.1 MB
Database size      112.0 KB</pre>
<p>Cinque tunnel sotto controllo, 170 cicli di backup programmati, poco più di un mega di spazio occupato per mesi di storico e un database che sta in 112 KB. Il campo dei ripristini segna dodici ma si tratta dei miei test che ogni tanto lancio a tradimento, per fortuna non ho più dovuto usarlo <em>per davvero</em> (anche se i test hanno comunque sovrascritto tunnel di produzione), è il motivo per cui oggi mi fido a lasciarlo lì a lavorare da solo.</p>
<p>Dovrei prevedere anche delle conservazioni più a lungo termine (mensili, annuali), ma è una caratteristica che non è ancora disponibile, mi sono già promesso di tornarci sopra e lavorarci.</p>
<h1>In conclusione</h1>
<p>È un software piccolo e per certi versi banale, ma per come lavoro io è ormai fondamentale.<br />
Non fa niente di spettacolare: è un&#8217;assicurazione contro la tua stessa distrazione.</p>
<p>Trovi tutto su <a href="https://tikkamasala.gioxx.org">tikkamasala.gioxx.org</a> e sul repo <a href="https://github.com/gioxx/TikkaMasala" rel="noopener">github.com/gioxx/TikkaMasala</a>. Se lo provi, se ci trovi un bug o se hai idee per migliorarlo, le Issue su GitHub sono il canale più rapido per arrivare a me. Per tutto il resto, l&#8217;area commenti qui sotto è a tua disposizione, come sempre.</p>
<p><strong>#KeepItSimple</strong></p>
<hr />
<h6>Immagine di copertina: mia, prodotta con l&#8217;aiuto dell&#8217;intelligenza artificiale che ha lavorato allo sfondo che c&#8217;è alle spalle del logo ufficiale di Cloudflare.</h6>
<p><a rel="nofollow" href="https://gioxx.org/2026/09/03/tikka-masala-backup-cloudflare-tunnel/">Tikka Masala: metti al sicuro i tuoi tunnel Cloudflare</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://gioxx.org/2026/09/03/tikka-masala-backup-cloudflare-tunnel/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>YOURLS: il plugin antispam che cancellava i miei short URL</title>
		<link>https://gioxx.org/2026/08/31/yourls-antispam-plugin-cancella-shorturl-dnsbl/</link>
					<comments>https://gioxx.org/2026/08/31/yourls-antispam-plugin-cancella-shorturl-dnsbl/#comments</comments>
		
		<dc:creator><![CDATA[Gioxx]]></dc:creator>
		<pubDate>Mon, 31 Aug 2026 07:30:47 +0000</pubDate>
				<category><![CDATA[Tech & Coding]]></category>
		<category><![CDATA[Backup]]></category>
		<category><![CDATA[DNS]]></category>
		<category><![CDATA[Hosting]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Open Source]]></category>
		<category><![CDATA[Serverplan]]></category>
		<category><![CDATA[Sicurezza]]></category>
		<category><![CDATA[Software]]></category>
		<category><![CDATA[Spam]]></category>
		<category><![CDATA[Test Hw/Sw]]></category>
		<category><![CDATA[YOURLS]]></category>
		<category><![CDATA[YOURLS Plugins]]></category>
		<guid isPermaLink="false">https://gioxx.org/?p=125868</guid>

					<description><![CDATA[<p>Venerdì sera mi arriva una mail: [YOURLS] Short URL deleted: .... Ero in viaggio, in autostrada, in mezzo al traffico e ai campioni dello slalom tra corsie a velocità che porca miseria levati perché altrimenti ti prendo a schiaffi, non avevo sicuramente modo di accorgermene né tanto meno di capire ... <a title="YOURLS: il plugin antispam che cancellava i miei short URL" class="read-more" href="https://gioxx.org/2026/08/31/yourls-antispam-plugin-cancella-shorturl-dnsbl/" aria-label="Read more about YOURLS: il plugin antispam che cancellava i miei short URL">Leggi tutto</a></p>
<p><a rel="nofollow" href="https://gioxx.org/2026/08/31/yourls-antispam-plugin-cancella-shorturl-dnsbl/">YOURLS: il plugin antispam che cancellava i miei short URL</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></description>
										<content:encoded><![CDATA[<p>Venerdì sera mi arriva una mail: <code>[YOURLS] Short URL deleted: ...</code>.<br />
Ero in viaggio, in autostrada, in mezzo al traffico e ai <em>campioni dello slalom tra corsie a velocità che porca miseria levati perché altrimenti ti prendo a schiaffi</em>, non avevo sicuramente modo di accorgermene né tanto meno di capire perché mai il mio YOURLS avesse effettuato una cancellazione senza passare per una mia azione. Poco dopo un&#8217;altra, e un&#8217;altra, e poi un&#8217;altra ancora. Short URL che uso da anni, con decine di migliaia di clic alle spalle, che sparivano uno dopo l&#8217;altro dalla mia istanza di YOURLS. Il primo pensiero è stato quello che avresti fatto anche tu: qualcuno mi ha bucato lo shortener.</p>
<p>Spoiler: <strong>nessun attacco</strong>.<br />
Ma per arrivarci mi è servita un&#8217;oretta di log, e la spiegazione è abbastanza istruttiva da meritare un post, soprattutto se anche tu tieni attivo, come me, un plugin che avevi installato e dimenticato lì.</p>
<h2>La caccia all&#8217;intruso che non c&#8217;era</h2>
<p>Prima cosa, accesso SSH all&#8217;hosting e giù dentro i log di Apache.<br />
Cosa cercavo: richieste alla API di YOURLS (<code>yourls-api.php</code>), POST all&#8217;area <code>admin/</code>, qualunque cosa somigliasse a una cancellazione pilotata da fuori.</p>
<p><strong>Niente</strong>. Zero chiamate alla API in tutto il log. Un solo POST all&#8217;admin nella giornata: il mio, fatto <em>dopo</em> aver ricevuto l&#8217;allarme per andare a controllare. Il filesystem era pulito: nessuna webshell, nessun file PHP modificato di recente, <code>~/.ssh/authorized_keys</code> vuoto, nessun utente aggiunto all&#8217;array degli amministratori in <code>config.php</code>. Eppure i link sparivano davvero: me lo confermava il database, le righe non c&#8217;erano più, le notifiche ricevute dal mio <a href="https://yourls.gioxx.org/plugins/change-notifier">Change Notifier</a> erano la conferma ultima a mezzo posta elettronica (maledettamente comodo, <strong>diversamente non me ne sarei accorto per tempo</strong> e chissà quanti altri link avrei <em>lasciato per la strada</em>).</p>
<p>Un&#8217;azione che tocca il database di YOURLS ma non lascia traccia nei log web è il tipo di contraddizione che di solito significa che stai guardando nel posto sbagliato.</p>
<h2>L&#8217;indizio era nella mia stessa mail</h2>
<p>Come ti dicevo poco sopra, quelle notifiche me le manda un plugin che ho scritto io, <a href="https://github.com/gioxx/YOURLS-ChangeNotifier" rel="noopener">YOURLS-ChangeNotifier</a>: invia una mail a ogni creazione, modifica o cancellazione di uno short URL. Dando un&#8217;occhiata più approfondita al corpo del messaggio di uno dei messaggi di cancellazione, saltava fuori questo:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">Event:   DELETE
When:    2026-08-29T08:26:27+00:00
By:      user: (authenticated)
IP:      34.10.46.191</pre>
<p>Due dettagli che prima avevo letto di sfuggita. Il campo <code>By:</code> diceva <code>user: (authenticated)</code> <strong>senza nome utente</strong>: è l&#8217;ultima spiaggia del mio plugin, quello che scrive quando <code>yourls_is_valid_user()</code> dice &#8220;sì&#8221; ma non riesce a tirare fuori né un login, né un cookie di sessione, né un header di autenticazione. Tradotto: nessun accesso vero all&#8217;area riservata.</p>
<p>E l&#8217;<code>IP:</code> cambiava a ogni mail. In un caso era quello di un monitor esterno che mi passa a controllare i redirect ogni mattina alla stessa ora.<br />
Non un attaccante: solo chi, per caso, stava visitando quel link nel momento esatto della cancellazione.</p>
<p>Il che restringeva parecchio il campo: qualcosa <strong>dentro</strong> YOURLS cancellava i link <em>quando venivano visitati</em>.</p>
<h2>Il colpevole: un plugin antispam del 2012</h2>
<p><code>user/plugins/antispam/</code>. Il plugin <strong>Anti spam</strong> di Ozh, uno dei più vecchi dell&#8217;ecosistema YOURLS. Attivo da anni sulla mia istanza, mai toccato, completamente uscito dai radar. Fa due cose. Alla creazione di uno short URL controlla la destinazione contro alcune DNS blacklist e rifiuta se è elencata: legittimo. Ma poi c&#8217;è la seconda parte, agganciata all&#8217;evento <code>redirect_shorturl</code>, cioè eseguita <strong>a ogni click</strong>:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="php">// Has the remote link become compromised lately? Check on redirection
yourls_add_action( 'redirect_shorturl', 'ozh_yourls_antispam_check_redirect' );
function ozh_yourls_antispam_check_redirect( $url, $keyword = false ) {
    // ...
    if( $chances == mt_rand( 1, $chances ) ) {
        if( ozh_yourls_antispam_is_blacklisted( $url ) != false ) {
            // Delete link &amp; die
            yourls_delete_link_by_keyword( $keyword );
            yourls_die( 'This domain has been blacklisted. This short URL has been deleted from our record.', 'Domain blacklisted', '403' );
        }
    }
}</pre>
<p>Con probabilità 1 su 5 (link creato da meno di tre giorni) o 1 su 15, a ogni visita interroga la URL di destinazione contro cinque blacklist:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="raw">'zen.spamhaus.org',
'multi.surbl.org',
'bl.spamcop.net',
'combined.abuse.ch',
'dnsbl.sorbs.net',</pre>
<p>Se <strong>una sola</strong> risponde positivo, il link non viene sospeso, non finisce in quarantena, non ti manda un avviso: <code>yourls_delete_link_by_keyword()</code>. <strong>Cancellato</strong>.<br />
E l&#8217;utente si becca un 403, quella pagina da 866 byte che nel frattempo avevo visto passare decine di volte nei log senza dargli peso.</p>
<h2>Perché proprio adesso</h2>
<p>Perché quelle blacklist, nel 2026, sono in buona parte inaffidabili se non le usi nel modo giusto:</p>
<ul>
<li><strong>SORBS</strong> (<code>dnsbl.sorbs.net</code>) è stata <strong>spenta a giugno 2024</strong>. La zona DNS non è garantito che risponda in modo sensato.</li>
<li><strong>SpamCop</strong> e <strong>SURBL</strong> <em>degradano di proposito</em> le risposte quando le query arrivano da resolver pubblici o ad alto volume, cioè esattamente lo scenario di un hosting condiviso, dove il resolver è in comune con centinaia di altri account.</li>
</ul>
<p>Il risultato è falsi positivi a raffica. Il readme del plugin, va detto, non nasconde niente:</p>
<blockquote><p>Checking against DNS blacklists may or may not work for you [&#8230;] It may even result in all domains being blacklisted from your server. Try and see.</p></blockquote>
<p>L&#8217;ho provato: ha deciso che Reddit, Amazon, un blog tecnico di Microsoft e il mio canale Twitch erano tutti &#8220;domini blacklistati&#8221;, e li ha rimossi.</p>
<h2>Il fix</h2>
<p>Disattivazione del plugin: rimosso da <code>active_plugins</code> nel database e cartella rinominata in <code>.antispam.disabled</code>, solo per scrupolo, giuro che poi dopo passo pure a cancellare definitivamente.<br />
Da quel momento nessun link è stato più toccato.</p>
<p>Se anche tu tieni attivo questo plugin, il consiglio è di andare a vedere <em>subito</em> se ti sta erodendo lo storico: cerca i 403 anomali nei log e le eventuali mail di cancellazione. Come protezione antispam ci sono alternative che almeno non distruggono dati: <a href="https://github.com/YOURLS/google-safe-browsing" rel="noopener">Google Safe Browsing per YOURLS</a> (ma occhio, che pure questo è stato archiviato e non viene più aggiornato da quattro anni), o più banalmente un captcha sull&#8217;interfaccia pubblica. Io sto seriamente pensando di mettermi alla ricerca di un sostituto mantenuto ancora oggi o &#8211; in alternativa &#8211; tirare in piedi qualcosa di nuovo che svolga però questo <em>vecchio mestiere</em>.</p>
<h2>Il recupero: le statistiche erano ancora tutte lì</h2>
<p>Qui la notizia buona.<br />
Quando YOURLS cancella uno short URL rimuove la riga dalla tabella <code>yourls_url</code>, ma <strong>non tocca <code>yourls_log</code></strong>, dove vive lo storico dei click.<br />
Le statistiche c&#8217;erano ancora tutte, riga per riga, per ognuno dei link cancellati.</p>
<p>Ho recuperato destinazione, titolo e data di creazione dal dump notturno del database (Server Plan ne tiene uno automatico e non mi costringe così ad andare a recuperare il backup che mi porto in casa con <a href="https://cpanelvault.gioxx.org/">cPanelVault</a>), ho reinserito le righe con un <code>INSERT</code> mirato e ho rimesso i contatori <code>clicks</code> allineandoli al conteggio reale dei record ancora presenti nel log.</p>
<p>Verifica finale con un <code>curl</code> per keyword, per controllare che ogni redirect puntasse di nuovo dove doveva:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="raw">/r        301 -&gt; https://www.reddit.com/r/Gioxx/
/shopping 301 -&gt; https://www.amazon.it/shop/gioxx
/ee4li    301 -&gt; https://techcommunity.microsoft.com/.../ba-p/3851357
/twitch   301 -&gt; https://www.twitch.tv/gioxx85</pre>
<p>Come se non fosse successo niente, con un&#8217;unica eccezione: <code>twitch</code> era stato cancellato <em>prima</em> che partisse il backup della notte, quindi la sua destinazione non era in nessun archivio e ho dovuto ricostruirla a memoria (non che fosse così complicato, ma è giusto dirlo, altrimenti mi sarebbe andato a tirare fuori dall&#8217;armadio il backup di qualche giorno fa).</p>
<h2>In conclusione</h2>
<p>Il giudizio è netto: per come è scritto, cancellazione automatica e irreversibile al primo hit di una qualunque blacklist, per giunta durante il redirect, cioè nel percorso più caldo e meno sorvegliato, questo plugin non ha senso su un&#8217;installazione viva e usata nel 2026, e ancora meno su hosting condiviso. Il controllo in fase di <em>creazione</em> è un&#8217;altra storia e ci può stare; è l&#8217;azione distruttiva a runtime il problema.</p>
<p>Due cose che mi porto a casa.</p>
<ul>
<li>La prima: le notifiche via mail su creazione/modifica/cancellazione degli short URL, che tenevo più che altro per curiosità, si sono rivelate l&#8217;unico motivo per cui me ne sono accorto in tempo. Se hai un sistema di alert simile, quelle mail vanno lette quando arrivano, non archiviate.</li>
<li>La seconda: tenere YOURLS aggiornato all&#8217;ultima versione (oggi la 1.10.5) resta buona regola a prescindere, il mio <a href="https://gioxx.org/2025/05/14/yourls-diff-patch-per-aggiornare-la-tua-istanza-yourls/">YOURLS-diff</a> serve proprio a rendere la cosa meno noiosa, anche se stavolta la versione non c&#8217;entrava niente (ma per coincidenza, essendo stata pubblicata una manciata di ore prima rispetto all&#8217;accaduto, mi ha fatto sorgere il dubbio di uno 0-day corretto che però mieteva vittime <em>into the wild</em>).</li>
</ul>
<p>L&#8217;area commenti è a tua disposizione, soprattutto se ti è capitato di ritrovarti con dei link spariti e non sapevi da dove partire <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f605.png" alt="😅" class="wp-smiley" style="height: 1em; max-height: 1em;" /></p>
<p><strong>#KeepItSimple</strong></p>
<hr />
<h6>Immagine di copertina: <a class="stockpack-author" href="https://unsplash.com/@rocinante_11?utm_source=Stockpack&amp;utm_medium=referral&amp;utm_campaign=api-credit" target="_blank" rel="nofollow noopener noreferrer">Mick Haupt</a> on <a class="stockpack-provider" href="https://unsplash.com/photos/7Xw9yEA8rqQ?utm_source=Stockpack&amp;utm_medium=referral" target="_blank" rel="nofollow noopener noreferrer">Unsplash</a></h6>
<p><a rel="nofollow" href="https://gioxx.org/2026/08/31/yourls-antispam-plugin-cancella-shorturl-dnsbl/">YOURLS: il plugin antispam che cancellava i miei short URL</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://gioxx.org/2026/08/31/yourls-antispam-plugin-cancella-shorturl-dnsbl/feed/</wfw:commentRss>
			<slash:comments>4</slash:comments>
		
		
			</item>
		<item>
		<title>Waline: un sistema di commenti adatto a Docusaurus</title>
		<link>https://gioxx.org/2026/08/19/waline-docusaurus/</link>
					<comments>https://gioxx.org/2026/08/19/waline-docusaurus/#respond</comments>
		
		<dc:creator><![CDATA[Gioxx]]></dc:creator>
		<pubDate>Wed, 19 Aug 2026 09:00:47 +0000</pubDate>
				<category><![CDATA[Tech & Coding]]></category>
		<category><![CDATA[Commenti]]></category>
		<category><![CDATA[GitHub]]></category>
		<category><![CDATA[Javascript]]></category>
		<category><![CDATA[Marvellous Codeworks]]></category>
		<category><![CDATA[Self-Hosted]]></category>
		<category><![CDATA[Telegram]]></category>
		<category><![CDATA[Vercel]]></category>
		<category><![CDATA[Waline]]></category>
		<guid isPermaLink="false">https://gioxx.org/?p=125836</guid>

					<description><![CDATA[<p>Quando ho messo in piedi la Knowledge Base di Marvellous Codeworks (blog, documentazione, changelog dei progetti, TMS in primis), a un certo punto ho sentito la mancanza, e quindi la necessità, di integrare i commenti all&#8217;interno di Docusaurus, perché non tutti gli utenti sono disposti a compilare una richiesta di ... <a title="Waline: un sistema di commenti adatto a Docusaurus" class="read-more" href="https://gioxx.org/2026/08/19/waline-docusaurus/" aria-label="Read more about Waline: un sistema di commenti adatto a Docusaurus">Leggi tutto</a></p>
<p><a rel="nofollow" href="https://gioxx.org/2026/08/19/waline-docusaurus/">Waline: un sistema di commenti adatto a Docusaurus</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></description>
										<content:encoded><![CDATA[<p>Quando ho messo in piedi la <a href="https://kb.marvellouscode.works" rel="noopener">Knowledge Base di Marvellous Codeworks</a> (blog, documentazione, changelog dei progetti, TMS in primis), a un certo punto ho sentito la mancanza, e quindi la necessità, di integrare i commenti all&#8217;interno di Docusaurus, perché non tutti gli utenti sono disposti a compilare una richiesta di supporto o registrare un account su GitHub per inserire un commento.<br />
Volevo che chiunque potesse lasciare un feedback, segnalare un problema, ringraziare senza affidarsi al solito <em>colosso SaaS</em> che ti pianta tracker ovunque solo per farti scrivere due righe sotto un post.</p>
<p>Ho quindi fatto qualche ricerca per capire cosa offrisse il <em>mercato del self-hosting</em>, evitando però di tenere in considerazione l&#8217;idea e possibilità di ospitare il tutto in casa. Avevo bisogno di qualcosa che si integrasse con il mondo serverless.</p>
<h1>Prima scelta: Cusdis (e doccia fredda)</h1>
<p>Il primo nome che mi era saltato all&#8217;occhio era <a href="https://cusdis.com/" rel="noopener">Cusdis</a>, presentato proprio come &#8220;alternativa leggera e privacy-friendly a Disqus&#8221;. Sulla carta perfetto: piccolo, self-hostabile, pensato apposta per blog e siti statici.</p>
<p>Poi sono andato a controllare il repository su GitHub, per abitudine, prima di adottare qualsiasi cosa guardo sempre lo stato di manutenzione, e <a href="https://github.com/djyde/cusdis" rel="noopener">djyde/cusdis</a> risulta <strong>archiviato</strong>. Un progetto che ha avuto il suo pubblico, ma da metà luglio 2026 non riceve più commit e la repository è chiusa in sola lettura. Nessun annuncio ufficiale che spieghi il perché, semplicemente: <strong>fine corsa</strong>.</p>
<p>Occhio, non sto dicendo che sia stata una scelta sbagliata a monte, magari l&#8217;autore ha semplicemente deciso di chiudere un capitolo, succede. Ma per un progetto pubblico che è sempre esposto su Internet, e che deve restare in piedi per anni e magari ricevere patch di sicurezza, adottare qualcosa di già archiviato il primo giorno sarebbe stato un rischio che non valeva la pena correre.</p>
<h1>Waline</h1>
<p>Incrociando ulteriori ricerche sono arrivato a <a href="https://waline.js.org/" rel="noopener">Waline</a>, altro sistema di commenti self-hosted, e la differenza si vede subito: repository attiva (commit praticamente ogni giorno), community viva e un set di feature parecchio più ricco rispetto al necessario: supporto Markdown nei commenti, notifiche via email/Telegram/Discord/WeChat e altri canali ancora, pannello di amministrazione integrato, supporto per un bel po&#8217; di database (Postgres, MySQL, SQLite, MongoDB &#8230;).</p>
<p>Deploy semplice, tra l&#8217;altro: il pacchetto <code class="EnlighterJSRAW" data-enlighter-language="js">@waline/vercel</code> lo rende pronto per girare come funzione serverless su <a href="https://vercel.com" rel="noopener">Vercel</a>, con <a href="https://neon.tech" rel="noopener">Neon</a> come Postgres gestito dietro. Repository dedicata (ne ho messa in piedi una sul mio account personale), un <code>index.cjs</code> di poche righe che carica Waline, un <code>vercel.json</code> per i build, e in pochi minuti hai un endpoint di commenti collegato al frontend Docusaurus della KB.</p>
<p>Se vuoi provarlo, ti consiglio di iniziare da qui: <strong><a href="https://waline.js.org/it/guide/get-started/" rel="noopener">waline.js.org/it/guide/get-started</a></strong>.</p>
<h2>Le personalizzazioni</h2>
<p>Che poi è il classico vaso di pandora rispetto al &#8220;set &amp; forget&#8221;, quel frangente dello spazio-tempo dove la teoria del &#8220;lo metto in piedi in 5 minuti&#8221; incontra la pratica di un progetto vero e con alcuni ritocchi sotto al cofano praticamente necessari.</p>
<h3>Favicon statiche, per silenziare i log</h3>
<p>Prima cosa: ogni browser richiede automaticamente <code>/favicon.ico</code> e <code>/favicon.png</code> quando apre il sito. Waline, non avendoli, li trattava come richieste non gestite e ogni volta finiva un <em>NotFoundError </em>nei log di produzione. Innocuo, ma fastidioso da leggere quando stai cercando altro nei log. Basta aggiungere due file statici e dire a Vercel di servirli direttamente, senza passare dalla funzione Node:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="typescript">{
  "builds": [
    { "src": "favicon.ico", "use": "@vercel/static" },
    { "src": "favicon.png", "use": "@vercel/static" }
  ],
  "rewrites": [
    { "source": "/((?!favicon\\.ico$|favicon\\.png$).*)", "destination": "index.cjs" }
  ]
}</pre>
<h3>Le notifiche Telegram (e il template che non ne voleva sapere)</h3>
<p>Questa è stata quella più tosta. Volevo essere avvisato su Telegram ogni volta che arriva un commento in attesa di moderazione, così non devo tenere aperta la dashboard tutto il giorno. Waline lo supporta nativamente: bastano tre variabili d&#8217;ambiente su Vercel (<code>TG_BOT_TOKEN</code>, <code>TG_CHAT_ID</code>, ed eventualmente un <code>TG_TEMPLATE</code> per personalizzare il messaggio).</p>
<p>Peccato che, appena personalizzato il template, la notifica smettesse di arrivare, e senza errori evidenti. Ci ho girato attorno un bel po&#8217;: token giusto (verificato a mano con una chiamata <code>curl</code> diretta all&#8217;API di Telegram), chat ID giusto, tutto giusto, eppure niente.<br />
Alla fine, spulciando i log runtime di Vercel riga per riga, è saltato fuori il vero colpevole:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="raw">Telegram Notification Failed:{"ok":false,"error_code":400,"description":"Bad Request: can't parse entities: Character '.' is reserved and must be escaped with the preceding '\\'"}</pre>
<p>Telegram, quando usi il <code class="EnlighterJSRAW" data-enlighter-language="ini">parse_mode: MarkdownV2</code>, pretende che caratteri come <code>. - _ * [ ] ( ) ~ # + = | { } !</code> vengano &#8220;<em>escapati</em>&#8221; ovunque compaiano fuori da un blocco di codice o dalla parte URL di un link. Il mio template ci infilava l&#8217;URL del post e il nome del sito in punti <em>non protetti</em>, e ogni dominio ha un punto, ogni tanto un trattino: bastava questo per far cadere l&#8217;intero messaggio.</p>
<p>Ho provato a correggere il template più volte, ma affidarsi a un campo di testo multiriga su un pannello web per gestire un fence di codice <code>```</code> si è rivelato fragile: a ogni copia/incolla qualcosa andava storto tra a-capo reali e letterali. Alla fine ho spostato tutto nel codice: <code>index.cjs</code> costruisce il messaggio da sé, con una funzione di escaping scritta in JavaScript, e lo invia solo per i commenti con stato &#8220;in attesa di moderazione&#8221;, esattamente il caso che mi interessava:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="js">function escapeMarkdownV2(text = '') {
  return String(text).replace(/[_*[\]()~`&gt;#+=|{}.!\\-]/g, '\\$&amp;');
}

module.exports = Application({
  plugins: [],
  async postSave(comment) {
    if (comment.status !== 'waiting') return;
    // costruzione messaggio + fetch verso l'API Telegram
  },
});</pre>
<p>Per evitare la doppia notifica (quella integrata di Waline più la mia), ho disattivato la prima con <code class="EnlighterJSRAW" data-enlighter-language="ini">DISABLE_AUTHOR_NOTIFY=true</code>, lasciando intatte le email di risposta a chi commenta, che passano da un altro percorso.</p>
<h3>Versione <em>bloccata</em> e Dependabot</h3>
<p>Ultima rifinitura, più che altro una lezione imparata sul momento: il <code>package.json</code> puntava <code class="EnlighterJSRAW" data-enlighter-language="ini">@waline/vercel</code> sulla versione più aggiornata (<code>latest</code>), comodo perché a ogni deploy scarichi automaticamente l&#8217;ultima versione, ma anche un rischio silenzioso, un aggiornamento con un comportamento diverso può arrivare in produzione senza che tu lo sappia. Ho quindi scelto di utilizzare una versione precisa di Waline e ho aggiunto un <code>dependabot.yml</code> che apre una pull request quando viene pubblicata una nuova versione, così l&#8217;aggiornamento resta una scelta, non un <em>evento a sorpresa</em>.</p>
<h2>In conclusione</h2>
<p>La lezione più generale, al di là di Waline in sé: prima di adottare qualcosa, anche uno strumento piccolo come un sistema di commenti, vale sempre la pena guardare lo stato reale del progetto. Un repository archiviato oggi potrebbe risorgere domani con un fork, ma nel dubbio meglio partire da qualcosa di vivo. E il self-hosting, anche via serverless, resta assolutamente sostenibile, basta mettere in conto che i primi giorni si passano più a leggere log che a scrivere codice.</p>
<p>Se vuoi vedere il risultato, la sezione commenti la trovi in fondo a qualsiasi post della <a href="https://kb.marvellouscode.works" rel="noopener">KB di Marvellous Codeworks</a>.<br />
Se invece ti va di parlarne o dirmi a che software ti sei affidato tu per gestire commenti su un sito web statico, l&#8217;area commenti è a tua disposizione anche qui di seguito, come sempre :-)</p>
<p><strong>#KeepItSimple</strong></p>
<hr />
<h6>Immagine di copertina: <a class="stockpack-author" href="https://unsplash.com/@thoughtcatalog?utm_source=Stockpack&amp;utm_medium=referral&amp;utm_campaign=api-credit" target="_blank" rel="nofollow noopener noreferrer">Thought Catalog</a> on <a class="stockpack-provider" href="https://unsplash.com/photos/RdmLSJR-tq8?utm_source=Stockpack&amp;utm_medium=referral" target="_blank" rel="nofollow noopener noreferrer">Unsplash</a></h6>
<p><a rel="nofollow" href="https://gioxx.org/2026/08/19/waline-docusaurus/">Waline: un sistema di commenti adatto a Docusaurus</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://gioxx.org/2026/08/19/waline-docusaurus/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Graph: pulizia delle vecchie versioni del modulo dopo un update</title>
		<link>https://gioxx.org/2026/08/17/graph-modulo-powershell-rimozione-versioni-vecchie/</link>
					<comments>https://gioxx.org/2026/08/17/graph-modulo-powershell-rimozione-versioni-vecchie/#respond</comments>
		
		<dc:creator><![CDATA[Gioxx]]></dc:creator>
		<pubDate>Mon, 17 Aug 2026 09:30:57 +0000</pubDate>
				<category><![CDATA[Lavoro]]></category>
		<category><![CDATA[Account utente]]></category>
		<category><![CDATA[Microsoft]]></category>
		<category><![CDATA[Microsoft 365]]></category>
		<category><![CDATA[Microsoft Azure]]></category>
		<category><![CDATA[Microsoft Entra]]></category>
		<category><![CDATA[Microsoft Graph]]></category>
		<category><![CDATA[PowerShell]]></category>
		<guid isPermaLink="false">https://gioxx.org/?p=125829</guid>

					<description><![CDATA[<p>Ogni volta che lancio Update-Module Microsoft.Graph -Scope AllUsers il bimbo innocente che vive in me ripete sempre la stessa cosa: &#8220;stavolta andrà liscio&#8221;. E ogni volta, puntuale, quel bimbo ne esce deluso: l&#8217;aggiornamento installa la nuova versione e non tocca la vecchia (o le vecchie, nel caso in cui tu ... <a title="Graph: pulizia delle vecchie versioni del modulo dopo un update" class="read-more" href="https://gioxx.org/2026/08/17/graph-modulo-powershell-rimozione-versioni-vecchie/" aria-label="Read more about Graph: pulizia delle vecchie versioni del modulo dopo un update">Leggi tutto</a></p>
<p><a rel="nofollow" href="https://gioxx.org/2026/08/17/graph-modulo-powershell-rimozione-versioni-vecchie/">Graph: pulizia delle vecchie versioni del modulo dopo un update</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></description>
										<content:encoded><![CDATA[<p>Ogni volta che lancio <code class="EnlighterJSRAW" data-enlighter-language="powershell">Update-Module Microsoft.Graph -Scope AllUsers</code> il <em>bimbo innocente che vive in me</em> ripete sempre la stessa cosa: <em>&#8220;stavolta andrà liscio&#8221;</em>. E ogni volta, puntuale, quel bimbo ne esce deluso: l&#8217;aggiornamento installa la nuova versione e non tocca la vecchia (o le vecchie, nel caso in cui tu abbia fatto più aggiornamenti nel corso del tempo, senza curarti di ciò che lasciavi alle spalle), che resta lì, silenziosa, a occupare spazio e, nei casi peggiori, a creare conflitti quando PowerShell non sa bene quale versione dei cmdlet caricare.</p>
<p>Sia chiaro: so benissimo che è una scelta by-design, la pulizia dei vecchi moduli è a tuo carico, la maggior parte delle volte puoi &#8220;<em>ignorarla</em>&#8220;. Con <strong>Microsoft.Graph</strong> il problema è però amplificato dal fatto che non stiamo parlando di un modulo singolo, ma di una quarantina di sub-moduli (<code>Microsoft.Graph.Users</code>, <code>Microsoft.Graph.Groups</code>, <code>Microsoft.Graph.Applications</code> e così via). Se aggiorni e basta, ti ritrovi con due, tre, a volte più versioni della stessa libreria installate in parallelo su <code>C:\Program Files\PowerShell\Modules</code> (o nella tua cartella personale dei PowerShell Modules). Bello per chi ama fare ordine, pessimo per chi deve gestire ambienti di produzione dove ogni versione fantasma è un potenziale problema da capire poi, magari alle 2 di notte durante un incident.</p>
<h2>Il problema in pratica</h2>
<p>Dopo un aggiornamento mi ritrovo tipicamente così: nuova versione installata, ma la precedente ancora lì, con tutti i suoi sub-moduli:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">PS&gt; Update-Module Microsoft.graph -Scope AllUsers
PS&gt; Get-Module Microsoft.Graph* -ListAvailable |
&gt;&gt; Where-Object Version -ne '2.39.0' |
&gt;&gt; Select-Object Name, Version, Path

Name                                             Version Path
----                                             ------- ----
Microsoft.Graph                                 2.38.1  C:\Program Files\PowerShell\Modules\Microsoft.Graph\2.38.1\Mic…
Microsoft.Graph.Applications                    2.38.1  C:\Program Files\PowerShell\Modules\Microsoft.Graph.Applicatio…
Microsoft.Graph.Authentication                  2.38.1  C:\Program Files\PowerShell\Modules\Microsoft.Graph.Authentica…
[...]</pre>
<p>Quaranta righe, tutte con la stessa versione vecchia (2.38.1 nel caso oggi analizzato e buttato qui sul blog), tutte da rimuovere. A mano sarebbe una tortura, e infatti un <code class="EnlighterJSRAW" data-enlighter-language="powershell">Uninstall-Module Microsoft.Graph -RequiredVersion 2.38.1</code> da solo non basta: quel cmdlet toglie solo il modulo &#8220;<em>ombrello</em>&#8220;, non i sub-moduli che dipendono da lui.</p>
<h2>La pulizia (quella vera)</h2>
<p>La strada che mi ha portato a un risultato pulito è stata questa: prendo tutti i moduli della versione da buttare, li ordino mettendo per ultimo <code>Microsoft.Graph.Authentication</code> (da cui dipendono tutti gli altri, quindi va tolto per ultimo) e per primo <code>Microsoft.Graph</code> (l&#8217;ombrello, va tolto per primo):</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">$targetVersion = '2.38.1'

$oldGraph = Get-Module -Name 'Microsoft.Graph*' -ListAvailable |
    Where-Object Version -eq ([version]$targetVersion) |
    Sort-Object Name -Unique

$uninstallOrder = @(
    $oldGraph | Where-Object Name -eq 'Microsoft.Graph'

    $oldGraph | Where-Object {
        $_.Name -notin @(
            'Microsoft.Graph',
            'Microsoft.Graph.Authentication'
        )
    }

    $oldGraph | Where-Object Name -eq 'Microsoft.Graph.Authentication'
)

foreach ($module in $uninstallOrder) {
    Write-Host "Removing $($module.Name) $targetVersion ..."

    Uninstall-Module `
        -Name $module.Name `
        -RequiredVersion $targetVersion `
        -Force `
        -ErrorAction Stop
}</pre>
<p>Il <code>-Force</code> serve perché, senza, PowerShell si impunta se trova dipendenze ancora &#8220;in uso&#8221; secondo il suo registro interno (anche quando in realtà non c&#8217;è nessun processo che le sta usando). L&#8217;ordine di rimozione (ombrello prima, <code>Authentication</code> per ultimo) evita gli errori di dipendenza mancante che altrimenti PowerShell ti tira dietro a metà pulizia.</p>
<p>A operazione conclusa, una verifica veloce conferma che è rimasta una sola versione installata:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="powershell">Get-Module -Name 'Microsoft.Graph*' -ListAvailable |
    Group-Object Version |
    Sort-Object Name |
    Select-Object Name, Count

Name   Count
----   -----
2.39.0 40</pre>
<h2>In conclusione</h2>
<p>Il modulo <strong>Microsoft.Graph</strong> per PowerShell è potente ma <strong>non fa mai pulizia da solo</strong>: ogni <code>Update-Module</code> lascia dietro di sé la versione precedente, moltiplicata per una quarantina di sub-moduli. Se lavori su più macchine o gestisci ambienti condivisi, questo accumulo diventa presto un problema reale, spazio sprecato, tempi di caricamento del modulo più lunghi, e il rischio concreto di caricare per sbaglio la versione sbagliata di un cmdlet.</p>
<p>Lo script sopra non è elegantissimo ma fa il suo lavoro in modo ripetibile: prendi la versione vecchia, rispetta l&#8217;ordine di dipendenza, disinstalla tutto con <code>-Force</code>. Vale la pena tenerlo a portata di mano (magari come funzione riutilizzabile) ogni volta che aggiorni Graph.</p>
<p>In caso di errori, dubbi, boiate riportate in articolo sai già cosa fare, l&#8217;area commenti è a tua totale disposizione :-)</p>
<p><strong>#KeepItSimple</strong></p>
<p><a rel="nofollow" href="https://gioxx.org/2026/08/17/graph-modulo-powershell-rimozione-versioni-vecchie/">Graph: pulizia delle vecchie versioni del modulo dopo un update</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://gioxx.org/2026/08/17/graph-modulo-powershell-rimozione-versioni-vecchie/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Colonna auto-update plugin sparita? Controlla mu-plugins, potrebbe essere colpa di Installatron</title>
		<link>https://gioxx.org/2026/08/09/colonna-auto-update-plugin-sparita-installatron/</link>
					<comments>https://gioxx.org/2026/08/09/colonna-auto-update-plugin-sparita-installatron/#respond</comments>
		
		<dc:creator><![CDATA[Gioxx]]></dc:creator>
		<pubDate>Sun, 09 Aug 2026 11:26:56 +0000</pubDate>
				<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Hosting]]></category>
		<category><![CDATA[PHP]]></category>
		<category><![CDATA[Serverplan]]></category>
		<category><![CDATA[Sicurezza]]></category>
		<category><![CDATA[Software]]></category>
		<category><![CDATA[Sviluppo Web]]></category>
		<category><![CDATA[WordPress Plugins]]></category>
		<guid isPermaLink="false">https://gioxx.org/?p=125456</guid>

					<description><![CDATA[<p>Qualche giorno fa apro la pagina Plugin del blog e mi accorgo che manca un pezzo: la colonna che mi permette di scegliere, plugin per plugin, se abilitare l&#8217;auto-update oppure no. Non l&#8217;avevo tolta io, non ricordo aggiornamenti recenti di WordPress che l&#8217;abbiano spostata altrove, e su altri siti che ... <a title="Colonna auto-update plugin sparita? Controlla mu-plugins, potrebbe essere colpa di Installatron" class="read-more" href="https://gioxx.org/2026/08/09/colonna-auto-update-plugin-sparita-installatron/" aria-label="Read more about Colonna auto-update plugin sparita? Controlla mu-plugins, potrebbe essere colpa di Installatron">Leggi tutto</a></p>
<p><a rel="nofollow" href="https://gioxx.org/2026/08/09/colonna-auto-update-plugin-sparita-installatron/">Colonna auto-update plugin sparita? Controlla mu-plugins, potrebbe essere colpa di Installatron</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></description>
										<content:encoded><![CDATA[<p>Qualche giorno fa apro la pagina <strong>Plugin</strong> del blog e mi accorgo che manca un pezzo: la colonna che mi permette di scegliere, plugin per plugin, se abilitare l&#8217;auto-update oppure no. Non l&#8217;avevo tolta io, non ricordo aggiornamenti recenti di WordPress che l&#8217;abbiano spostata altrove, e su altri siti che gestisco è ancora lì tranquilla al suo posto. Quindi il sospetto è arrivato in fretta: qualcuno ha toccato qualcosa senza dirmelo, &#8220;<em>al piano di sopra</em>&#8220;.</p>
<h2>La verifica</h2>
<p>Prima cosa: escludere che fosse un cambiamento arrivato da un qualche <em>comportamento</em> mio, di uno dei plugin che utilizzo o un aggiornamento di WordPress (dato che ultimamente ne sono arrivati almeno un paio per mettere al sicuro WP 7). Ho controllato <code>wp-config.php</code> per le costanti dichiarate in documentazione:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="php">define( 'WP_AUTO_UPDATE_CORE', 'false' );</pre>
<p>Presente, ma quella riguarda solo gli aggiornamenti del <em>core</em> di WordPress, non i plugin, non è la causa. <code>DISALLOW_FILE_MODS</code> assente. Poi ho controllato via <code>wp-cli</code> se i dati di auto-update fossero comunque popolati nel database:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="php">$ wp plugin list --status=active
name           status  update  version  auto_update
akismet        active  none    5.7      on
wordfence      active  none    8.2.2    on
...</pre>
<p>I dati c&#8217;erano, tutti popolati correttamente. Quindi non era un problema di database: la funzione esisteva ancora, semplicemente la UI per gestirla era sparita.</p>
<h2>Il colpevole: un mu-plugin che non ho mai chiesto</h2>
<p>A quel punto sono andato a guardare dentro <code>wp-content/mu-plugins/</code>. Cartella che, lo ricordo, WordPress carica <strong>sempre</strong>, senza possibilità di disattivazione dalla dashboard (da lì il nome: <em>must-use</em>). E lì, tra i miei file, ne spunta uno che non ho mai scritto io:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="php">&lt;?php
/*
  Plugin Name: WordPress automation by Installatron
  Description: Management of this WordPress site is provided by Installatron.
*/

// Turn off WordPress automatic updates since these are managed externally.
add_filter( 'automatic_updater_disabled', '__return_true', -9999 );</pre>
<p>Installatron è il tool che molti hosting (incluso il mio, Serverplan) offrono per installare/gestire applicazioni web in un click. Non l&#8217;ho mai usato per questo blog, e sospetto che nemmeno tu lo utilizzi consapevolmente, se sei atterrato su questo articolo cercando la stessa colonna sparita. Eppure quel mu-plugin era lì, silenzioso, a filtrare <code>automatic_updater_disabled</code> forzandolo a <code>true</code> con priorità altissima (<code>-9999</code>, giusto per essere sicuri che nessun altro filtro potesse scavalcarlo). Risultato: WordPress disattiva <em>tutto</em> il sistema di auto-update: core, plugin, temi, e <strong>nasconde pure la UI corrispondente</strong>, colonna compresa.</p>
<p>Il commento nel file suggerisce un modo &#8220;ufficiale&#8221; per disattivarlo:</p>
<blockquote><p>To block this plugin, create an empty file in the same directory named: <code>block-automation-by-installatron.php</code></p></blockquote>
<p><strong>Peccato che il codice del plugin non controlli mai l&#8217;esistenza di quel file</strong>. È una convenzione pensata per essere letta (e rispettata) dall&#8217;automazione di Installatron stessa quando rigenera i suoi file, non da WordPress. Creare quel file vuoto, quindi, non serve a niente: il filtro resta attivo lo stesso.</p>
<h2>La soluzione</h2>
<p>L&#8217;unico modo efficace è intervenire direttamente sul file. Io ho scelto la via più pulita e reversibile: rinominarlo togliendogli l&#8217;estensione <code>.php</code>, così la cartella <code>mu-plugins</code> smette di caricarlo ma il file resta lì, intatto, nel caso servisse tornare indietro:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="bash">mv wp-content/mu-plugins/automation-by-installatron.php \
   wp-content/mu-plugins/automation-by-installatron.php.disabled</pre>
<p>Verifica rapida via <code>wp-cli</code> per confermare che il filtro non sia più attivo:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="bash">$ wp eval 'var_dump(apply_filters("automatic_updater_disabled", false));'
bool(false)</pre>
<p>Colonna auto-update tornata al suo posto nella pagina Plugin, tutto come previsto.</p>
<h2>In conclusione</h2>
<p>Se anche a te manca quella colonna e non ricordi di averla tolta, il primo posto dove guardare è <code>wp-content/mu-plugins/</code>, non la lista plugin normale, perché i <em>must-use plugin</em> non compaiono lì e non sono disattivabili dalla dashboard. Se trovi un file simile a <code>automation-by-installatron.php</code> (o comunque riconducibile al tuo hosting), sai già cosa cercare al suo interno e come toglierlo di mezzo.</p>
<p>Il punto che mi lascia più perplesso resta un altro: nessun avviso, nessuna nota nel pannello Serverplan, niente. Un mu-plugin che disattiva silenziosamente una funzione di sicurezza/manutenzione del sito è il genere di cosa che vorrei sapere <em>prima</em>, non scoprire per caso mesi dopo controllando una colonna mancante. Se gestisci un sito su hosting che offre Installatron (o strumenti simili), vale la pena dare un&#8217;occhiata preventiva a quella cartella, anche se non hai scelto consapevolmente di attivarlo.</p>
<p>L&#8217;area commenti è a tua disposizione in caso di ulteriori dubbi o magari per segnalare la medesima situazione su hosting differenti rispetto a quello da me utilizzato.</p>
<p><strong>#KeepItSimple</strong></p>
<p><a rel="nofollow" href="https://gioxx.org/2026/08/09/colonna-auto-update-plugin-sparita-installatron/">Colonna auto-update plugin sparita? Controlla mu-plugins, potrebbe essere colpa di Installatron</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://gioxx.org/2026/08/09/colonna-auto-update-plugin-sparita-installatron/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>TMS 9: ce l&#8217;abbiamo fatta, da lunedì si va in scena!</title>
		<link>https://gioxx.org/2026/07/31/tms-v9/</link>
					<comments>https://gioxx.org/2026/07/31/tms-v9/#respond</comments>
		
		<dc:creator><![CDATA[Gioxx]]></dc:creator>
		<pubDate>Fri, 31 Jul 2026 16:38:20 +0000</pubDate>
				<category><![CDATA[Tech & Coding]]></category>
		<category><![CDATA[Componenti aggiuntivi]]></category>
		<category><![CDATA[GitHub]]></category>
		<category><![CDATA[Google]]></category>
		<category><![CDATA[Google Chrome]]></category>
		<category><![CDATA[In Evidenza]]></category>
		<category><![CDATA[Microsoft Edge]]></category>
		<category><![CDATA[Open Source]]></category>
		<category><![CDATA[The Marvellous Suspender]]></category>
		<guid isPermaLink="false">https://gioxx.org/?p=125370</guid>

					<description><![CDATA[<p>Cinque settimane fa avevo dato il via a una serie di articoli ad-hoc per parlarti del nuovo look per una nuova era di The Marvellous Suspender, avevo usato direttamente il blog di Marvellous Codeworks e l&#8217;inglese come lingua principale per raggiungere quanti più lettori e utilizzatori possibili (non pensavo avrebbe ... <a title="TMS 9: ce l&#8217;abbiamo fatta, da lunedì si va in scena!" class="read-more" href="https://gioxx.org/2026/07/31/tms-v9/" aria-label="Read more about TMS 9: ce l&#8217;abbiamo fatta, da lunedì si va in scena!">Leggi tutto</a></p>
<p><a rel="nofollow" href="https://gioxx.org/2026/07/31/tms-v9/">TMS 9: ce l&#8217;abbiamo fatta, da lunedì si va in scena!</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></description>
										<content:encoded><![CDATA[<div class="gb-block-notice info">
    <div class="gb-notice-title info"><p><i class="fas fa-info-circle fa-pull-right"></i>Info</p></div>
	<div class="gb-notice-text info">
		<p>
          If you want to read this article in English, <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f1ec-1f1e7.png" alt="🇬🇧" class="wp-smiley" style="height: 1em; max-height: 1em;" /> <a href="https://kb.marvellouscode.works/blog/road-to-tms9-launch/" rel="noopener">you can click here</a>.
		</p>
	</div>
</div>
<p><img decoding="async" class="alignright wp-image-125371" src="https://gioxx.org/wp-content/uploads/2026/07/the-marvellous-suspender-v3-migration-volunteer-testers-wanted-updated-500x500.webp" alt="The Marvellous Suspender: V3 Migration, volunteer testers wanted! (Updated)" width="220" height="220" title="TMS 9: ce l&#039;abbiamo fatta, da lunedì si va in scena! 2" srcset="https://gioxx.org/wp-content/uploads/2026/07/the-marvellous-suspender-v3-migration-volunteer-testers-wanted-updated-500x500.webp 500w, https://gioxx.org/wp-content/uploads/2026/07/the-marvellous-suspender-v3-migration-volunteer-testers-wanted-updated-150x150.webp 150w, https://gioxx.org/wp-content/uploads/2026/07/the-marvellous-suspender-v3-migration-volunteer-testers-wanted-updated-200x200.webp 200w, https://gioxx.org/wp-content/uploads/2026/07/the-marvellous-suspender-v3-migration-volunteer-testers-wanted-updated.webp 768w" sizes="(max-width: 220px) 100vw, 220px" />Cinque settimane fa avevo dato il via a una serie di articoli ad-hoc per parlarti del <a href="https://kb.marvellouscode.works/blog/tags/road-to-tms9" rel="noopener">nuovo look per una nuova era</a> di <strong>The Marvellous Suspender</strong>, avevo usato direttamente il <a href="https://kb.marvellouscode.works/blog" rel="noopener">blog di Marvellous Codeworks</a> e l&#8217;inglese come lingua principale per raggiungere quanti più lettori e utilizzatori possibili (non pensavo avrebbe avuto senso ospitare il tutto, in italiano, anche qui sul mio blog personale). Poche ore fa Google ha dato il via libera alla pubblicazione della v9.0.0 e, a partire da <strong>lunedì mattina, 3 agosto</strong>, la nuova versione comincerà a essere distribuita sul Chrome Web Store. È l&#8217;ultima tappa di questo percorso, e te la racconto volentieri (con 40°C fuori e la voglia di mettere la testa sott&#8217;acqua, ma questo è un altro discorso).</p>
<h1 id="cosa-c-di-nuovo-davvero">TMS 9: cosa c&#8217;è di nuovo sotto al cofano</h1>
<ul>
<li><strong>Backup e ripristino delle sessioni</strong> (forse tra le feature più richieste in assoluto, dalla notte dei tempi), in locale o su Google Drive, programmabile o su richiesta, con supporto multi-dispositivo.</li>
<li>Una pagina di <em>Tab Health</em> che scansiona le schede sospese e ripara da sola il bug dei Tab Groups che si perdono al riavvio di Chrome, Edge e Brave (bug ormai risolto, ma considerato che non si sa mai, le funzioni sono state conservate e rese disponibili su richiesta / necessità).</li>
<li>Un feed di notizie che pesca direttamente dal blog di Marvellous Codeworks (che rimane l&#8217;unico ufficiale del progetto, in inglese, mentre qui sul mio blog personale continuerò a proporre articoli in italiano, talvolta traducendoli direttamente) raggiungibile tramite la barra laterale delle impostazioni dell&#8217;estensione.</li>
<li>Un redesign visivo completo che finalmente fa sembrare tutte le pagine delle impostazioni parte dello stesso prodotto.</li>
<li>16 lingue riportate in pari dopo essere rimaste indietro per chissà quanto tempo.</li>
</ul>
<p>Niente di tutto questo è arrivato per caso, e niente è arrivato da solo.</p>
<h2 id="le-chiacchierate-con-rob">Le chiacchierate con Rob</h2>
<p>Io e Rob non lavoriamo nello stesso ufficio, nello stesso Paese, e nemmeno vicini come fuso orario, per buona parte del tempo. Gran parte delle decisioni su TMS 9 sono state avviate in un gruppo Slack in orari che non avevano senso per nessuno dei due, discutendo se un permesso fosse troppo invasivo, se un&#8217;impostazione avesse bisogno di un tooltip, se &#8220;Ripristina da backup&#8221; dovesse stare sotto Sessioni o sotto Backup. Domande piccole, ma di quelle che decidono se un&#8217;estensione sembra curata o semplicemente pubblicata.</p>
<p>The Great-er Tab Discarder (TGD) e The Marvellous Suspender (TMS) sono due progetti distinti, con codebase separate, ma Marvellous Codeworks funziona solo perché siamo in due a tirare nella stessa direzione, a controllarci i punti ciechi a vicenda e, ogni tanto, a dirci l&#8217;un l&#8217;altro che un&#8217;idea fa schifo prima che finisca in produzione. TMS 9 ne guadagna, eccome, e speriamo di poter fare la stessa cosa con TGD, presto.</p>
<h2 id="la-community-che-si-fatta-sentire">La community che si è fatta sentire</h2>
<p>Nel changelog di TMS 9 ci sono anche due contributor al debutto: <a href="https://github.com/MatrixNeoKozak" rel="noopener">@MatrixNeoKozak</a>, che ha rifattorizzato il livello di storage eliminando serializzazioni JSON inutili, e <a href="https://github.com/iGhost" rel="noopener">@iGhost</a>, che ha aggiunto l&#8217;opzione per mantenere la posizione di riproduzione di YouTube quando una scheda viene sospesa. Una feature piccola, richiesta e costruita da chi il problema lo ha vissuto sul serio (io per esempio non avevo mai avuto necessità di questa caratteristica). È esattamente così che dovrebbe crescere un progetto open source: non solo bug report, ma pull request di persone che hanno deciso di sistemare la cosa di persona.</p>
<p>In più, sedici lingue hanno ricevuto un passaggio di traduzione assistita da AI (qui Claude ha avuto carta bianca) per colmare buchi che si trascinavano da diverse versioni. Non sono perfette: se usi TMS nella tua lingua e qualcosa suona storto, <a href="https://crowdin.com/project/tms" rel="noopener">Crowdin</a> è aperto, e le correzioni dei madrelingua <strong>sono esattamente ciò che serve per chiudere il cerchio</strong>.</p>
<h2 id="segnalare-un-problema-senza-account-github">Segnalare un problema senza account GitHub</h2>
<p>Per anni l&#8217;unico modo per dirci che qualcosa non andava era aprire una segnalazione su GitHub, il che significava avere un account GitHub, spesso visto come un muro che molti semplicemente non erano disposti a scavalcare solo per segnalare un bug. I problemi ci arrivavano di riflesso, nelle recensioni, nei thread di Reddit, mai dove potevamo davvero intervenire.</p>
<p><a href="https://marvellouscode.works/tms/report" rel="noopener">marvellouscode.works/tms/report</a> proverà a risolvere la cosa. Si compila un form, senza registrazione, senza account, e viene aperta in automatico una issue GitHub corretta, etichettata bene, con i campi che ci servono davvero per riprodurre il problema. Chi segnala riceve un link per seguire lo stato, noi riceviamo un report vero e utilizzabile invece di una recensione da una stella che dice solo &#8220;non funziona più&#8221;.</p>
<p>E a proposito: leggiamo tutto, comprese le recensioni lasciate direttamente sul Chrome Web Store. Stelline, recensioni scritte, richieste di supporto in quel piccolo box, tutto viene letto. Teniamo d&#8217;occhio in particolare le segnalazioni GitHub perché è lì che tracciamo e agiamo davvero, verificando comportamenti strani, seguendo le richieste, chiudendo le cose una volta risolte, ma una recensione lasciata sullo Store non è comunque gridare nel vuoto.</p>
<div class="gb-block-notice warning">
    <div class="gb-notice-title warning"><p>Attenzione</p></div>
	<div class="gb-notice-text warning">
		<p>
          Il form di segnalazione via web è sperimentale. Esiste per abbassare la barriera per chi non vuole creare un account GitHub solo per dirci che qualcosa è rotto, non per sostituire GitHub del tutto. Se mai venisse abusato, spam, rumore in <em>malafede</em>, qualunque forma prenda, sarà la prima cosa che disattiveremo, e le segnalazioni torneranno a passare solo da account GitHub che aprono segnalazioni a proprio nome, punto.<br />

		</p>
	</div>
</div>
<h2 id="un-sito-di-documentazione-costruito-da-zero">Un sito di documentazione costruito da zero</h2>
<p>Il vecchio wiki di TMS viveva sul repo GitHub, mezzo incompleto, datato in alcuni punti, e onestamente non il tipo di posto dove mandare qualcuno se volevate che capisse davvero l&#8217;estensione. Ora è disattivato.</p>
<p>Al suo posto: <a href="https://kb.marvellouscode.works/docs/intro" rel="noopener">kb.marvellouscode.works/docs</a>, popolato da zero sfruttando Docusaurus come base, pagina per pagina, specialmente per TMS 9 (anche se in realtà pensato per tutti i lavori che trovano spazio sotto l&#8217;ala di Marvellous Codeworks). Ogni pagina delle impostazioni ha il suo articolo: Impostazioni, Sessioni, Backup, Tab Health, Azioni Rapide, Scorciatoie da Tastiera, Notizie, Informazioni, che spiega cosa fa davvero ogni pulsante, non solo come si chiama. C&#8217;è una pagina dedicata ai <a href="https://kb.marvellouscode.works/docs/TMS/permissions" rel="noopener">permessi</a> che spiega perché TMS chiede quello che chiede e cosa è cambiato con lo scope Drive più ristretto in questa release. FAQ incluse. Niente nascosto in una pagina wiki stantia che nessuno aggiornava dal 2019.</p>
<p>L&#8217;abbiamo costruito pagina per pagina, decidendo cosa aveva davvero bisogno di essere spiegato e cosa era abbastanza ovvio da saltare. Il dominio <code>marvellouscode.works</code> era registrato e fermo da quasi un anno: ci sono volute settimane di riorganizzazione e riscrittura per trasformarlo nel sito, nella documentazione e nel flusso di segnalazione che puoi sfruttare oggi.</p>
<h2 id="cambia-tutto-non-cambia-niente-">Cambia tutto (ma non cambia niente)</h2>
<p>TMS 9 ha un aspetto diverso, è organizzato diversamente, permette di effettuare il backup diversamente, e parla più lingue di prima. Questa è la parte &#8220;cambia tutto&#8221;, ed è costata una riscrittura completa delle pagine di impostazioni, un nuovo sistema di backup e parecchie scambi in chat.</p>
<p>La parte &#8220;non cambia niente&#8221; è più semplice: TMS esiste ancora per prendere le schede che il browser accumula in memoria e metterle a dormire finché non servono davvero. Era vero il giorno in cui <a href="#">The Great Suspender è stato salvato</a>, lo è stato durante la riscrittura per Manifest V3, e lo è ancora nella v9.0.0. Ogni feature di questa release, Tab Health, backup, redesign, esiste per proteggere quel singolo compito, non per sostituirlo.</p>
<h2 id="non-piacer-a-tutti-e-va-bene-cos-">Non piacerà a tutti, e va bene così</h2>
<p>Siamo perfettamente consapevoli che non tutti saranno entusiasti del nuovo look. Il cambiamento raramente è visto di buon occhio, e un redesign di queste dimensioni &#8211; nuovi font, nuovi colori, nuovo layout su ogni singola pagina &#8211; è esattamente il tipo di cosa per cui alcuni avranno bisogno di tempo per abituarsi, o semplicemente non gli piacerà affatto. Fa parte della crescita di un progetto, fa parte del gioco.</p>
<p>Restiamo convinti che la &#8220;<em>ritinteggiatura</em>&#8221; fosse necessaria, e pensiamo sia solo il primo passo importante. Ci sono altre idee in cantiere, e ci sono già parecchi problemi e richieste di feature su GitHub che intendiamo affrontare per continuare a spingere TMS avanti. Ascoltiamo, prendiamo sul serio feedback e critiche costruttive: è tutto il senso dell&#8217;open source, in fondo.</p>
<h2 id="cosa-succede-ora">Cosa succede ora</h2>
<p>La revisione di Google è conclusa, il changelog è definitivo, e il rilascio parte lunedì mattina, 3 agosto. Gli aggiornamenti sul Chrome Web Store vengono distribuiti gradualmente e non a tutti insieme, quindi non allarmarti se non vedi la v9.0.0 nel secondo esatto in cui andrai a controllare: arriverà entro la normale finestra di aggiornamento. Questa serie di articoli si chiude ufficialmente qui. Grazie per averla seguita fino in fondo, per aver testato i feature branch, per le issue aperte, per le pull request, e per la pazienza.</p>
<p>Ci vediamo dall&#8217;altra parte, nella v9.0.0. L&#8217;area commenti è a tua disposizione, come sempre, per domande o per raccontarmi la tua personale esperienza con TMS 9.</p>
<p><strong>#KeepItSimple (<em>e buone vacanze!</em>)</strong></p>
<hr />
<p>Immagine di copertina: <a class="stockpack-author" href="https://unsplash.com/@growtika?utm_source=Stockpack&amp;utm_medium=referral&amp;utm_campaign=api-credit" target="_blank" rel="nofollow noopener noreferrer">Growtika</a> on <a class="stockpack-provider" href="https://unsplash.com/photos/vTG2GFOb1iA?utm_source=Stockpack&amp;utm_medium=referral" target="_blank" rel="nofollow noopener noreferrer">Unsplash</a></p>
<p><a rel="nofollow" href="https://gioxx.org/2026/07/31/tms-v9/">TMS 9: ce l&#8217;abbiamo fatta, da lunedì si va in scena!</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://gioxx.org/2026/07/31/tms-v9/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Project Nova per Firefox Nightly: bello il gioco, però ora basta.</title>
		<link>https://gioxx.org/2026/07/29/firefox-nightly-project-nova/</link>
					<comments>https://gioxx.org/2026/07/29/firefox-nightly-project-nova/#comments</comments>
		
		<dc:creator><![CDATA[Gioxx]]></dc:creator>
		<pubDate>Wed, 29 Jul 2026 12:30:48 +0000</pubDate>
				<category><![CDATA[Mozilla]]></category>
		<category><![CDATA[Browser]]></category>
		<category><![CDATA[Mozilla Firefox]]></category>
		<category><![CDATA[Mozilla Italia]]></category>
		<guid isPermaLink="false">https://gioxx.org/?p=125356</guid>

					<description><![CDATA[<p>Il 27 luglio scorso, via tweet di Firefox Nightly, Mozilla ha annunciato Project Nova: un nuovo aspetto grafico in test, al momento solo su Nightly, quindi niente panico se usi la versione stabile, per ora non ti tocca. Sul blog ufficiale di Nightly è comparso un post di annuncio piuttosto ... <a title="Project Nova per Firefox Nightly: bello il gioco, però ora basta." class="read-more" href="https://gioxx.org/2026/07/29/firefox-nightly-project-nova/" aria-label="Read more about Project Nova per Firefox Nightly: bello il gioco, però ora basta.">Leggi tutto</a></p>
<p><a rel="nofollow" href="https://gioxx.org/2026/07/29/firefox-nightly-project-nova/">Project Nova per Firefox Nightly: bello il gioco, però ora basta.</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></description>
										<content:encoded><![CDATA[<p>Il 27 luglio scorso, via <a href="https://x.com/FirefoxNightly/status/2081767446633959741">tweet di Firefox Nightly</a>, Mozilla ha annunciato <strong>Project Nova</strong>: un nuovo aspetto grafico in test, al momento solo su Nightly, quindi niente panico se usi la versione stabile, per ora non ti tocca.</p>
<p>Sul blog ufficiale di Nightly è comparso un post di annuncio piuttosto scarno (<a href="https://blog.nightly.mozilla.org/2026/07/27/new-firefox-design/" rel="noopener">New Firefox Design</a>), di quelli che dicono poco e lasciano parlare gli screenshot: giusto due righe di presentazione e via, senza un vero e proprio changelog o una spiegazione del perché di questa scelta proprio ora.</p>
<p>Nightly resta il mio Firefox di tutti i giorni, non a caso: mi piace sperimentare, dare un contributo (quando posso) alle idee che magari un domani arriveranno anche nelle versioni stabili. È rimasto quel poco che riesco ancora a fare dopo vent&#8217;anni e passa in Mozilla Italia, di cui i primi da contributor piuttosto attivo. Chi segue Firefox da un po&#8217; di anni sa che non è la prima volta che il browser cambia pelle: Australis, Photon, Proton sono nomi che a chi ha vissuto quelle transizioni diranno qualcosa, e ognuna ha portato con sé lo stesso copione: entusiasmo di facciata, malumori sotto traccia, e alla fine ci si abitua tutti (o quasi). <strong>Project Nova sembra volersi inserire in questa tradizione</strong>, solo che stavolta la reazione è arrivata più rumorosa e più in fretta del solito.</p>
<p>Project Nova me lo sono trovato in faccia da subito, senza nemmeno cercarlo, aggiornando Nightly come faccio ogni giorno, ed è giusto perché chi usa Nightly <em>accetta di fare da cavia</em> per queste sperimentazioni.</p>
<h2 id="cosa-dice-la-community">Cosa dice la community</h2>
<p>Su Mozilla Connect è partito un thread dedicato, <a href="https://connect.mozilla.org/t5/discussions/take-project-nova-for-a-spin-in-nightly/m-p/133231" rel="noopener">Take Project Nova for a spin in Nightly</a>, e &#8220;<em>nutrito</em>&#8221; è dire poco: pagine e pagine di commenti (caricarle tutte richiede un attimo di pazienza), e il tono generale non è esattamente da standing ovation. Ho lasciato anche il mio contributo al thread, <a href="https://connect.mozilla.org/t5/discussions/take-project-nova-for-a-spin-in-nightly/m-p/133229#M53496" rel="noopener">qui</a>, e riassumendo: il &#8220;<em>gioco</em>&#8221; del nuovo aspetto grafico può anche essere divertente da provare per qualche giorno, ma a conti fatti resta un esercizio di stile che toglie più di quanto aggiunge a chi usa il browser per lavorarci, non per ammirarlo.</p>
<p>Quello che mi ha colpito, scorrendo il thread, non è tanto il dissenso in sé (succede a ogni redesign, fa quasi parte del rito), quanto il volume e la rapidità con cui è arrivato. Di solito ci vogliono settimane prima che un cambiamento grafico venga metabolizzato dalla community, qui in pochi giorni si è già accumulata una mole di feedback che normalmente si vede solo dopo un rilascio stabile, non su un canale sperimentale che nasce apposta per essere rotto e discusso.</p>
<h2 id="come-tornare-indietro">Come tornare indietro</h2>
<p>Se anche a te Nova non convince e vuoi il vecchio aspetto &#8220;più umano&#8221;, il salvagente c&#8217;è ed è semplice:</p>
<ul>
<li>Apri <code>about:config</code> (il solito avviso: sai cosa stai facendo, ma occhio comunque a cosa tocchi, un flag sbagliato e ti ritrovi a chiederti perché qualcos&#8217;altro ha smesso di funzionare).</li>
<li>Cerca <code>browser.nova.enabled</code>.</li>
<li>Impostalo su <strong>false</strong></li>
</ul>
<p><img fetchpriority="high" decoding="async" class="alignnone size-large wp-image-125359" src="https://gioxx.org/wp-content/uploads/2026/07/project-nova-per-firefox-nightly-bello-il-gioco-pero-ora-basta-900x161.png" alt="Project Nova per Firefox Nightly: bello il gioco, però ora basta." width="900" height="161" title="Project Nova per Firefox Nightly: bello il gioco, però ora basta. 4" srcset="https://gioxx.org/wp-content/uploads/2026/07/project-nova-per-firefox-nightly-bello-il-gioco-pero-ora-basta-900x161.png 900w, https://gioxx.org/wp-content/uploads/2026/07/project-nova-per-firefox-nightly-bello-il-gioco-pero-ora-basta-500x89.png 500w, https://gioxx.org/wp-content/uploads/2026/07/project-nova-per-firefox-nightly-bello-il-gioco-pero-ora-basta-768x137.png 768w, https://gioxx.org/wp-content/uploads/2026/07/project-nova-per-firefox-nightly-bello-il-gioco-pero-ora-basta-200x36.png 200w, https://gioxx.org/wp-content/uploads/2026/07/project-nova-per-firefox-nightly-bello-il-gioco-pero-ora-basta.png 1210w" sizes="(max-width: 900px) 100vw, 900px" /></p>
<p>Fatto, il browser torna all&#8217;interfaccia che conosci, senza bisogno di reinstallare nulla o tornare a un canale diverso da Nightly, <strong>senza neanche riavviare Firefox</strong> che di solito è necessario per applicare le modifiche operate da <code>about:config</code>. Tieni presente che, trattandosi di un flag sperimentale, non è garantito che sopravviva tale e quale ai prossimi aggiornamenti: se un giorno lo cerchi e non lo trovi più (o si comporta diversamente), la causa più probabile è che nel frattempo Project Nova sia stato integrato più a fondo nel browser, magari con un meccanismo di disattivazione diverso.</p>
<h2 id="in-conclusione">Comprendo, non condivido</h2>
<p>Capisco la logica dietro Project Nova. Ogni tanto un browser ha bisogno di rinfrescarsi, e Nightly è esattamente il posto giusto dove provare queste cose prima che arrivino (se arrivano) nelle versioni stabili. Ma tra provarlo e tenerlo attivo di default anche su un canale già di per sé instabile fa una bella differenza, e il thread su Connect lo dimostra piuttosto bene. Per ora resto con <code>browser.nova.enabled</code> a <strong>false</strong>, e aspetto di vedere dove va a parare questo esperimento: se Mozilla ascolterà davvero quello che sta emergendo da Connect o se, come già successo in passato, si andrà avanti dritti per la propria strada aggiustando solo i dettagli più urlati.<br />
Magari tra qualche tempo (neanche tanto esagerato) torno ad attivarlo e vedere quali passi in avanti sono stati fatti.</p>
<p><strong>#KeepItSimple</strong></p>
<p><a rel="nofollow" href="https://gioxx.org/2026/07/29/firefox-nightly-project-nova/">Project Nova per Firefox Nightly: bello il gioco, però ora basta.</a> è stato scritto da <a rel="nofollow" href="https://gioxx.org/author/gioxx/">Gioxx</a> per <a rel="nofollow" href="https://gioxx.org">Gioxx.org</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://gioxx.org/2026/07/29/firefox-nightly-project-nova/feed/</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
			</item>
	</channel>
</rss>
