Gestire i file su Amazon S3 in WordPress

Amazon S3 (Simple Storage Service) è uno dei servizi di archiviazione oggetti più diffusi e affidabili disponibili nel cloud. Integrandolo con WordPress è possibile delegare la gestione dei file media a un'infrastruttura scalabile, riducendo il carico sui server web e migliorando le prestazioni generali del sito. In questo articolo vedremo come configurare e utilizzare Amazon S3 in WordPress, sia tramite plugin sia tramite codice PHP personalizzato, con esempi pratici che coprono upload, lettura, cancellazione e generazione di URL firmati.

Perché usare Amazon S3 con WordPress

WordPress per impostazione predefinita salva tutti i file caricati tramite la Media Library nella cartella wp-content/uploads del server web. Questa scelta funziona bene per siti piccoli, ma presenta dei limiti evidenti in contesti di produzione con traffico elevato:

  • I file statici occupano spazio disco sul server applicativo, aumentando i costi di hosting.
  • In ambienti multi-server o con container Docker stateless, i file caricati su un nodo non sono accessibili dagli altri.
  • La distribuzione dei file media via CDN richiede una configurazione aggiuntiva e spesso si appoggia comunque a un bucket S3.
  • Il backup dei file media è separato dal backup del database e richiede soluzioni dedicate.

Usando Amazon S3 come storage secondario o primario per i file media di WordPress, tutti questi problemi vengono risolti in modo elegante. S3 offre disponibilità del 99,999999999% (undici nove), versioning opzionale, politiche di ciclo di vita per la gestione automatica dei file e integrazione nativa con Amazon CloudFront per la distribuzione via CDN.

Prerequisiti

Prima di procedere è necessario disporre di:

  • Un account AWS attivo.
  • Un bucket S3 creato nella regione desiderata.
  • Un utente IAM con le autorizzazioni necessarie per operare sul bucket.
  • Le credenziali AWS (Access Key ID e Secret Access Key) dell'utente IAM.
  • PHP 8.1 o superiore con le estensioni curl e json abilitate.
  • Composer installato sul server per la gestione delle dipendenze.

Creare il bucket S3 e configurare IAM

Dal pannello AWS, naviga su S3 e crea un nuovo bucket scegliendo un nome univoco a livello globale e una regione geografica vicina ai tuoi utenti. Durante la creazione, valuta se abilitare il versioning degli oggetti e se lasciare attivo il blocco degli accessi pubblici (raccomandato per sicurezza).

Per consentire a WordPress di interagire con il bucket, crea un utente IAM dedicato. Nella console IAM, seleziona Utenti > Aggiungi utente, assegna un nome descrittivo come wordpress-s3-user e scegli l'accesso programmatico. Allega al nuovo utente la seguente policy inline, sostituendo nome-del-tuo-bucket con il nome effettivo del bucket:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "s3:DeleteObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::nome-del-tuo-bucket",
        "arn:aws:s3:::nome-del-tuo-bucket/*"
      ]
    }
  ]
}

Dopo aver creato l'utente, scarica o annota l'Access Key ID e la Secret Access Key: verranno usati nella configurazione del plugin o del codice personalizzato.

Installare l'SDK AWS per PHP

L'approccio più robusto per interagire con S3 da PHP è utilizzare l'AWS SDK for PHP. Per installarlo tramite Composer, esegui nella root del progetto WordPress:

composer require aws/aws-sdk-php

Assicurati che il file vendor/autoload.php venga incluso nel tuo codice. Se stai sviluppando un plugin WordPress personalizzato, aggiungi il require all'inizio del file principale del plugin:

<?php
// Carica l'autoloader di Composer
require_once plugin_dir_path(__FILE__) . 'vendor/autoload.php';

use Aws\S3\S3Client;
use Aws\Exception\AwsException;

Configurare il client S3

Le credenziali AWS non vanno mai inserite direttamente nel codice sorgente. La pratica corretta è definirle come costanti nel file wp-config.php oppure come variabili d'ambiente. Aggiungi le seguenti righe a wp-config.php:

<?php
// Credenziali AWS per la connessione a S3
define('AWS_ACCESS_KEY_ID', 'AKIAIOSFODNN7EXAMPLE');
define('AWS_SECRET_ACCESS_KEY', 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY');
define('AWS_S3_BUCKET', 'nome-del-tuo-bucket');
define('AWS_S3_REGION', 'eu-west-1');

Con le costanti definite, puoi istanziare il client S3 in questo modo:

<?php
// Crea il client S3 con le credenziali e la regione configurate
function create_s3_client(): S3Client {
    return new S3Client([
        'version'     => 'latest',
        'region'      => AWS_S3_REGION,
        'credentials' => [
            'key'    => AWS_ACCESS_KEY_ID,
            'secret' => AWS_SECRET_ACCESS_KEY,
        ],
    ]);
}

Caricare un file su S3

La funzione seguente mostra come caricare un file sul bucket S3. Il parametro $local_path è il percorso assoluto del file sul server, mentre $s3_key è il percorso (chiave) con cui il file verrà identificato all'interno del bucket:

<?php
// Carica un file locale su S3 e restituisce l'URL pubblico
function upload_file_to_s3(string $local_path, string $s3_key, string $content_type = 'application/octet-stream'): string|false {
    $client = create_s3_client();

    try {
        // Apre lo stream del file locale
        $file_stream = fopen($local_path, 'rb');

        if ($file_stream === false) {
            error_log('S3 Upload: impossibile aprire il file ' . $local_path);
            return false;
        }

        // Esegue l'upload verso il bucket configurato
        $result = $client->putObject([
            'Bucket'      => AWS_S3_BUCKET,
            'Key'         => $s3_key,
            'Body'        => $file_stream,
            'ContentType' => $content_type,
        ]);

        fclose($file_stream);

        // Restituisce l'URL dell'oggetto caricato
        return (string) $result['ObjectURL'];

    } catch (AwsException $e) {
        error_log('S3 Upload Error: ' . $e->getMessage());
        return false;
    }
}

Per caricare un file con accesso pubblico in lettura (utile per immagini pubbliche del sito), aggiungi il parametro ACL:

<?php
// Carica un'immagine rendendola accessibile pubblicamente
function upload_public_image_to_s3(string $local_path, string $s3_key): string|false {
    $client = create_s3_client();

    try {
        $result = $client->putObject([
            'Bucket'      => AWS_S3_BUCKET,
            'Key'         => $s3_key,
            'SourceFile'  => $local_path,
            'ContentType' => mime_content_type($local_path),
            'ACL'         => 'public-read',
        ]);

        return (string) $result['ObjectURL'];

    } catch (AwsException $e) {
        error_log('S3 Public Upload Error: ' . $e->getMessage());
        return false;
    }
}

Nota: per poter impostare ACL pubblici, nella configurazione del bucket S3 devi avere disabilitato il blocco degli ACL (Block public access settings for this bucket). In alternativa, puoi configurare una Bucket Policy che permetta lettura pubblica senza usare gli ACL per oggetto.

Intercettare l'upload della Media Library di WordPress

WordPress mette a disposizione diversi hook per intercettare il processo di upload dei file. Il filtro wp_handle_upload viene applicato subito dopo che WordPress ha salvato il file nella cartella uploads. Puoi usarlo per copiare automaticamente ogni file caricato su S3:

<?php
// Intercetta ogni upload di WordPress e lo replica su S3
add_filter('wp_handle_upload', function (array $upload_data): array {
    $local_path   = $upload_data['file'];
    $content_type = $upload_data['type'];

    // Costruisce la chiave S3 mantenendo la struttura di directory di WordPress
    $uploads_dir = wp_upload_dir();
    $relative    = str_replace($uploads_dir['basedir'] . '/', '', $local_path);
    $s3_key      = 'wp-uploads/' . $relative;

    // Carica il file su S3
    $s3_url = upload_file_to_s3($local_path, $s3_key, $content_type);

    if ($s3_url !== false) {
        // Salva la chiave S3 come metadato temporaneo per utilizzi successivi
        update_option('last_s3_upload_key', $s3_key);
    }

    // Restituisce i dati originali: WordPress continua a gestire il file localmente
    return $upload_data;
});

Se invece vuoi sostituire completamente l'URL dei file media con quello di S3, puoi agire anche sul filtro wp_get_attachment_url:

<?php
// Sostituisce l'URL degli allegati con l'URL corrispondente su S3
add_filter('wp_get_attachment_url', function (string $url, int $post_id): string {
    // Recupera la chiave S3 salvata come meta dell'allegato
    $s3_key = get_post_meta($post_id, '_s3_key', true);

    if (empty($s3_key)) {
        return $url;
    }

    // Costruisce l'URL pubblico del bucket S3
    $s3_base_url = sprintf(
        'https://%s.s3.%s.amazonaws.com/',
        AWS_S3_BUCKET,
        AWS_S3_REGION
    );

    return $s3_base_url . $s3_key;
}, 10, 2);

Leggere e scaricare un file da S3

Per leggere il contenuto di un file presente su S3 senza prima scaricarlo sul server, usa il metodo getObject:

<?php
// Legge il contenuto di un oggetto S3 e lo restituisce come stringa
function get_s3_object_content(string $s3_key): string|false {
    $client = create_s3_client();

    try {
        $result = $client->getObject([
            'Bucket' => AWS_S3_BUCKET,
            'Key'    => $s3_key,
        ]);

        // Il corpo della risposta è uno stream; lo converte in stringa
        return (string) $result['Body'];

    } catch (AwsException $e) {
        error_log('S3 Get Error: ' . $e->getMessage());
        return false;
    }
}

Per scaricare un file da S3 e salvarlo localmente sul server, puoi specificare il parametro SaveAs:

<?php
// Scarica un oggetto S3 e lo salva in un percorso locale
function download_s3_object(string $s3_key, string $local_destination): bool {
    $client = create_s3_client();

    try {
        $client->getObject([
            'Bucket' => AWS_S3_BUCKET,
            'Key'    => $s3_key,
            'SaveAs' => $local_destination,
        ]);

        return true;

    } catch (AwsException $e) {
        error_log('S3 Download Error: ' . $e->getMessage());
        return false;
    }
}

Eliminare un file da S3

La cancellazione di un oggetto da S3 avviene tramite il metodo deleteObject. È buona pratica chiamare questa funzione anche quando si cancella un allegato da WordPress, usando l'hook delete_attachment:

<?php
// Elimina un singolo oggetto dal bucket S3
function delete_s3_object(string $s3_key): bool {
    $client = create_s3_client();

    try {
        $client->deleteObject([
            'Bucket' => AWS_S3_BUCKET,
            'Key'    => $s3_key,
        ]);

        return true;

    } catch (AwsException $e) {
        error_log('S3 Delete Error: ' . $e->getMessage());
        return false;
    }
}

// Elimina il file da S3 quando un allegato viene rimosso da WordPress
add_action('delete_attachment', function (int $post_id): void {
    $s3_key = get_post_meta($post_id, '_s3_key', true);

    if (!empty($s3_key)) {
        delete_s3_object($s3_key);
    }
});

Per eliminare più oggetti in un'unica richiesta (più efficiente con grandi quantità di file) si usa deleteObjects:

<?php
// Elimina più oggetti S3 con una sola richiesta API
function delete_multiple_s3_objects(array $s3_keys): bool {
    $client = create_s3_client();

    // Costruisce la struttura richiesta dall'API AWS
    $objects = array_map(fn(string $key): array => ['Key' => $key], $s3_keys);

    try {
        $client->deleteObjects([
            'Bucket' => AWS_S3_BUCKET,
            'Delete' => [
                'Objects' => $objects,
                'Quiet'   => false,
            ],
        ]);

        return true;

    } catch (AwsException $e) {
        error_log('S3 Batch Delete Error: ' . $e->getMessage());
        return false;
    }
}

Generare URL firmati (pre-signed URL)

Quando un bucket S3 è configurato come privato (senza accesso pubblico), puoi comunque concedere accesso temporaneo a specifici oggetti tramite URL firmati. Un pre-signed URL include una firma crittografica che autorizza l'accesso per un periodo di tempo definito, dopodiché scade automaticamente. Questo meccanismo è ideale per file scaricabili riservati agli utenti autenticati, come PDF, ZIP o documenti personali.

<?php
// Genera un URL firmato temporaneo per accedere a un oggetto privato
function generate_presigned_url(string $s3_key, int $expiry_minutes = 60): string|false {
    $client = create_s3_client();

    try {
        // Crea il comando GetObject per l'oggetto richiesto
        $command = $client->getCommand('GetObject', [
            'Bucket' => AWS_S3_BUCKET,
            'Key'    => $s3_key,
        ]);

        // Genera l'URL firmato con scadenza configurabile
        $presigned_request = $client->createPresignedRequest(
            $command,
            sprintf('+%d minutes', $expiry_minutes)
        );

        return (string) $presigned_request->getUri();

    } catch (AwsException $e) {
        error_log('S3 Presigned URL Error: ' . $e->getMessage());
        return false;
    }
}

Puoi integrare questa funzione con un endpoint WordPress personalizzato che verifica l'autenticazione dell'utente prima di fornire il link:

<?php
// Endpoint REST API di WordPress per scaricare file privati da S3
add_action('rest_api_init', function (): void {
    register_rest_route('mio-plugin/v1', '/download/(?P<id>\d+)', [
        'methods'             => 'GET',
        'callback'            => 'handle_private_download_request',
        'permission_callback' => fn() => is_user_logged_in(),
        'args'                => [
            'id' => [
                'validate_callback' => fn($v) => is_numeric($v),
            ],
        ],
    ]);
});

// Gestisce la richiesta di download di un allegato privato
function handle_private_download_request(WP_REST_Request $request): WP_REST_Response|WP_Error {
    $attachment_id = (int) $request->get_param('id');

    // Recupera la chiave S3 salvata nei metadati dell'allegato
    $s3_key = get_post_meta($attachment_id, '_s3_key', true);

    if (empty($s3_key)) {
        return new WP_Error('not_found', 'File non trovato', ['status' => 404]);
    }

    // Genera il link firmato valido per 15 minuti
    $download_url = generate_presigned_url($s3_key, 15);

    if ($download_url === false) {
        return new WP_Error('s3_error', 'Errore nella generazione del link', ['status' => 500]);
    }

    return new WP_REST_Response(['url' => $download_url], 200);
}

Listare i file presenti nel bucket

Per ottenere l'elenco degli oggetti presenti in un bucket (o in una specifica "cartella" simulata tramite prefisso), si usa il metodo listObjectsV2. L'API S3 restituisce al massimo 1000 oggetti per richiesta; per recuperarne di più è necessario gestire la paginazione tramite il token di continuazione:

<?php
// Recupera tutti gli oggetti S3 sotto un determinato prefisso con paginazione
function list_s3_objects(string $prefix = ''): array {
    $client  = create_s3_client();
    $objects = [];
    $token   = null;

    do {
        $params = [
            'Bucket'  => AWS_S3_BUCKET,
            'Prefix'  => $prefix,
            'MaxKeys' => 1000,
        ];

        // Aggiunge il token di continuazione per le pagine successive
        if ($token !== null) {
            $params['ContinuationToken'] = $token;
        }

        try {
            $result = $client->listObjectsV2($params);

            foreach ($result['Contents'] ?? [] as $object) {
                $objects[] = [
                    'key'           => $object['Key'],
                    'size'          => $object['Size'],
                    'last_modified' => $object['LastModified']->format('Y-m-d H:i:s'),
                    'etag'          => trim($object['ETag'], '"'),
                ];
            }

            // Verifica se esistono ulteriori pagine di risultati
            $token = $result['IsTruncated'] ? $result['NextContinuationToken'] : null;

        } catch (AwsException $e) {
            error_log('S3 List Error: ' . $e->getMessage());
            break;
        }

    } while ($token !== null);

    return $objects;
}

Upload diretto dal browser con URL firmato (PUT pre-signed)

Per evitare che i file passino attraverso il server PHP prima di essere caricati su S3, è possibile generare un URL firmato per il metodo PUT e permettere al browser di caricare direttamente sul bucket. Questo approccio riduce il carico sul server e migliora i tempi di upload per i file di grandi dimensioni.

<?php
// Genera un URL firmato per consentire l'upload diretto dal browser a S3
function generate_upload_presigned_url(string $s3_key, string $content_type, int $expiry_minutes = 10): string|false {
    $client = create_s3_client();

    try {
        $command = $client->getCommand('PutObject', [
            'Bucket'      => AWS_S3_BUCKET,
            'Key'         => $s3_key,
            'ContentType' => $content_type,
        ]);

        $presigned_request = $client->createPresignedRequest(
            $command,
            sprintf('+%d minutes', $expiry_minutes)
        );

        return (string) $presigned_request->getUri();

    } catch (AwsException $e) {
        error_log('S3 PUT Presigned URL Error: ' . $e->getMessage());
        return false;
    }
}

Sul lato client, un semplice script JavaScript può usare l'URL firmato per inviare il file direttamente ad S3:

// Carica un file direttamente su S3 usando un URL firmato pre-generato
async function uploadFileDirectlyToS3(file, presignedUrl) {
    try {
        const response = await fetch(presignedUrl, {
            method: 'PUT',
            headers: {
                // Imposta il Content-Type corretto per la firma
                'Content-Type': file.type,
            },
            body: file,
        });

        if (!response.ok) {
            throw new Error(`Errore durante l'upload: ${response.status}`);
        }

        console.log('File caricato correttamente su S3');
        return true;

    } catch (error) {
        console.error('Errore:', error.message);
        return false;
    }
}

// Esempio di utilizzo con un input file HTML
document.getElementById('file-input').addEventListener('change', async function (event) {
    const selectedFile = event.target.files[0];

    if (!selectedFile) {
        return;
    }

    // Richiede al server PHP il pre-signed URL per il file selezionato
    const endpoint = '/wp-json/mio-plugin/v1/presigned-upload';
    const requestBody = JSON.stringify({
        filename:     selectedFile.name,
        content_type: selectedFile.type,
    });

    const metaResponse = await fetch(endpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: requestBody,
    });

    const { upload_url, s3_key } = await metaResponse.json();

    // Esegue il caricamento diretto su S3
    await uploadFileDirectlyToS3(selectedFile, upload_url);
});

L'endpoint REST PHP che genera il pre-signed URL per l'upload potrebbe essere strutturato come segue:

<?php
// Registra l'endpoint REST per generare URL firmati di upload
add_action('rest_api_init', function (): void {
    register_rest_route('mio-plugin/v1', '/presigned-upload', [
        'methods'             => 'POST',
        'callback'            => 'handle_presigned_upload_request',
        'permission_callback' => fn() => is_user_logged_in(),
    ]);
});

// Restituisce l'URL firmato per il caricamento diretto su S3
function handle_presigned_upload_request(WP_REST_Request $request): WP_REST_Response|WP_Error {
    $filename     = sanitize_file_name($request->get_param('filename'));
    $content_type = sanitize_text_field($request->get_param('content_type'));

    if (empty($filename) || empty($content_type)) {
        return new WP_Error('invalid_params', 'Parametri mancanti', ['status' => 400]);
    }

    // Genera una chiave unica per evitare collisioni nel bucket
    $s3_key = sprintf(
        'wp-uploads/%s/%s-%s',
        date('Y/m'),
        wp_unique_id(),
        $filename
    );

    $upload_url = generate_upload_presigned_url($s3_key, $content_type);

    if ($upload_url === false) {
        return new WP_Error('s3_error', 'Impossibile generare il link di upload', ['status' => 500]);
    }

    return new WP_REST_Response([
        'upload_url' => $upload_url,
        's3_key'     => $s3_key,
    ], 200);
}

Gestire i metadati degli allegati WordPress

Per mantenere la coerenza tra la Media Library di WordPress e il bucket S3, è utile salvare la chiave S3 come post meta ogni volta che un file viene caricato. In questo modo è possibile risalire al file S3 corrispondente partendo dall'ID dell'allegato WordPress:

<?php
// Salva la chiave S3 nei metadati dell'allegato WordPress dopo l'upload
add_action('add_attachment', function (int $post_id): void {
    $attached_file = get_attached_file($post_id);

    if (empty($attached_file) || !file_exists($attached_file)) {
        return;
    }

    $uploads_dir = wp_upload_dir();
    $relative    = str_replace($uploads_dir['basedir'] . '/', '', $attached_file);
    $s3_key      = 'wp-uploads/' . $relative;

    // Tenta l'upload su S3
    $s3_url = upload_file_to_s3($attached_file, $s3_key, get_post_mime_type($post_id));

    if ($s3_url !== false) {
        // Salva chiave e URL come metadati dell'allegato
        update_post_meta($post_id, '_s3_key', $s3_key);
        update_post_meta($post_id, '_s3_url', $s3_url);
    }
});

Considerazioni sulla configurazione CORS del bucket

Se il browser deve interagire direttamente con S3 (come nel caso dell'upload diretto), è necessario configurare le regole CORS (Cross-Origin Resource Sharing) sul bucket S3. Dalla console AWS, nella sezione Permissions > Cross-origin resource sharing (CORS) del bucket, inserisci una configurazione simile alla seguente:

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
    "AllowedOrigins": ["https://www.tuosito.com"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

Sostituisci https://www.tuosito.com con l'URL effettivo del tuo sito WordPress. In sviluppo locale puoi usare http://localhost oppure * (solo per testing, mai in produzione).

Usare WP Offload Media come alternativa plug-and-play

Se non vuoi scrivere codice personalizzato, il plugin WP Offload Media di Delicious Brains è la soluzione più completa e collaudata per integrare WordPress con S3. Disponibile in versione gratuita (Lite) e a pagamento, il plugin intercetta automaticamente tutti gli upload della Media Library, li carica su S3 e aggiorna gli URL degli allegati in modo trasparente.

La versione Lite supporta Amazon S3, DigitalOcean Spaces e Google Cloud Storage. La versione Pro aggiunge la sincronizzazione con Amazon CloudFront, la rimozione automatica dei file locali dopo l'upload su S3 e strumenti di migrazione massiva dei file esistenti.

Dopo aver installato e attivato il plugin, accedi a Impostazioni > Offload Media e inserisci le tue credenziali AWS. Il plugin ti guiderà nella selezione del bucket e nella configurazione delle opzioni di consegna dei file.

Ottimizzare i costi su S3

Amazon S3 applica costi distinti per lo spazio di archiviazione utilizzato, le richieste API e il trasferimento dati in uscita. Per un sito WordPress con molti file media, è importante ottimizzare la configurazione per contenere la spesa:

  • Storage class: usa STANDARD_IA (Infrequent Access) per file a cui si accede raramente, come archivi storici o backup. Puoi impostarlo durante l'upload tramite il parametro StorageClass dell'SDK.
  • Lifecycle rules: configura regole automatiche per spostare i file meno acceduti verso classi di storage più economiche (Glacier, Glacier Deep Archive) dopo un certo periodo.
  • CloudFront come CDN: posizionare una distribuzione CloudFront davanti al bucket S3 riduce drasticamente i costi di trasferimento dati e migliora le prestazioni globali grazie alla cache edge.
  • Compressione: comprimi i file (immagini, CSS, JavaScript) prima dell'upload per ridurre lo spazio occupato e i tempi di download.

Conclusioni

Integrare Amazon S3 con WordPress è una scelta architetturale che porta benefici concreti in termini di scalabilità, affidabilità e gestione dei costi per siti con esigenze di storage significative. Sia che tu scelga di sviluppare una soluzione personalizzata usando l'AWS SDK for PHP, sia che tu preferisca affidarti a un plugin collaudato come WP Offload Media, il risultato è un sistema più robusto e manutenibile.

La chiave è pianificare con attenzione la struttura delle chiavi S3, gestire correttamente le credenziali IAM e definire sin dall'inizio le policy di accesso agli oggetti in funzione dei requisiti di sicurezza del progetto. Con queste basi, Amazon S3 diventa un componente affidabile e trasparente dell'infrastruttura WordPress.