Appeler directement AWS SDK v2 à partir de CFML - Partie 3 : modérer les images téléversées avec Amazon Rekognition

Garder les images nouvellement téléversées privées jusqu'à ce qu'Amazon Rekognition et notre application conviennent qu'elles devraient être publiées.

Dans la partie deux, nous avons permis à un navigateur de téléverser un fichier directement dans un bucket AWS S3 privé. ColdFusion a autorisé le téléversement, a choisi la clé de l'objet et a vérifié ce qui est arrivé sans faire transiter le fichier par une requête ColdFusion.

Cela a réglé le problème de transport. Pas le problème d'Internet.

Les utilisateurs peuvent téléverser des photos de profil, des images d'événements, des photos de produits et un nombre surprenant de fichiers qui semblent documenter l'effondrement de la civilisation. Avant d'en publier un seul, nous devons inspecter le contenu.

Amazon Rekognition peut examiner une image stockée dans Amazon Simple Storage Service sans envoyer l'image par ColdFusion. Notre application identifie l'objet, appelle DetectModerationLabels et reçoit une collection d'étiquettes décrivant du contenu potentiellement inapproprié. Rekognition ne décide pas si une image est acceptable. Il fournit des étiquettes et des valeurs de confiance. L'application prend toujours la décision.

Cette distinction est importante. Nous achetons de la classification d'images, pas une externalisation de la morale à un service web. Nous avons déjà essayé d'externaliser la morale à Internet. C'est comme ça qu'on en est arrivés là.

Notre flux de travail complet ressemblera à ceci :

  1. Le navigateur téléverse une image dans un emplacement privé incoming/.
  2. ColdFusion vérifie que l'objet appartient au téléversement authentifié.
  3. Amazon Rekognition examine l'objet directement.
  4. ColdFusion applique la politique de modération de l'application.
  5. Les images approuvées peuvent être publiées.
  6. Les images rejetées demeurent privées et sont supprimées ou conservées selon la politique.
  7. Les échecs de service laissent l'image non publiée.

Rien dans l'emplacement incoming/ ne devrait être accessible publiquement. Une image n'est pas approuvée simplement parce que le téléversement s'est terminé.

Accorder les autorisations requises

L'identité utilisée par ColdFusion doit avoir l'autorisation d'appeler DetectModerationLabels. Comme Rekognition inspectera un objet dans Amazon Simple Storage Service, cette identité doit aussi avoir l'autorisation de lire les objets à partir de l'emplacement de téléversement entrant. Une politique minimale d'Identity and Access Management ressemble à ceci :

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "rekognition:DetectModerationLabels",
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:GetObjectVersion"
            ],
            "Resource": "arn:aws:s3:::example-private-upload-bucket/incoming/*"
        }
    ]
}

Supprimez s3:GetObjectVersion si le bucket n'utilise pas le versionnement.

Le bucket AWS S3 et le client Rekognition doivent utiliser la même région Amazon. Rekognition accepte les images JPEG et PNG. Une image fournie comme objet S3 ne peut pas dépasser 15 mégaoctets. Les dimensions minimales sont de 80 pixels dans chaque direction, et DetectModerationLabels accepte des dimensions allant jusqu'à 10 000 pixels dans chaque direction. Amazon maintient les limites actuelles dans ses directives et quotas Rekognition.

Nous devrions appliquer ces limites avant de demander la modération. Rekognition rejettera les entrées invalides, mais faire une requête réseau payante juste pour découvrir qu'un fichier animé de 40 mégaoctets n'est pas un JPEG semble inutilement cérémonial.

Créer le service de modération

La partie un nous a donné un composant aws_client_factory.cfc capable de créer un client Rekognition réutilisable. Créez models/image_moderation_service.cfc :

component output="false" {
    public any function init(
        required any rekognition_client,
        required string bucket_name,
        string upload_prefix = "incoming/",
        numeric minimum_confidence = 80
    ) {
        variables.rekognition_client = arguments.rekognition_client;
        variables.bucket_name = trim( arguments.bucket_name );
        variables.upload_prefix = trim( arguments.upload_prefix );
        variables.minimum_confidence = arguments.minimum_confidence;

        if ( !len( variables.bucket_name ) ) {
            throw(
                type = "ImageModerationService.InvalidConfiguration",
                message = "An Amazon Simple Storage Service bucket is required."
            );
        }

        if ( !len( variables.upload_prefix ) ) {
            throw(
                type = "ImageModerationService.InvalidConfiguration",
                message = "An incoming upload prefix is required."
            );
        }

        if ( variables.minimum_confidence < 0 || variables.minimum_confidence > 100 ) {
            throw(
                type = "ImageModerationService.InvalidConfiguration",
                message = "Minimum confidence must be between zero and one hundred."
            );
        }

        return this;
    }

    public struct function inspectObject(
        required string object_key,
        string version_id = ""
    ) {
        var selected_key = trim( arguments.object_key );

        if ( !len( selected_key ) ) {
            throw(
                type = "ImageModerationService.InvalidObjectKey",
                message = "An object key is required."
            );
        }

        if ( left( selected_key, len( variables.upload_prefix ) ) != variables.upload_prefix ) {
            throw(
                type = "ImageModerationService.InvalidObjectKey",
                message = "The object is outside the incoming upload location."
            );
        }

        var stored_object_builder = createObject(
            "java",
            "software.amazon.awssdk.services.rekognition.model.S3Object"
        )
            .builder()
            .bucket( variables.bucket_name )
            .name( selected_key );

        if ( len( trim( arguments.version_id ) ) ) {
            stored_object_builder.version( trim( arguments.version_id ) );
        }

        var image = createObject(
            "java",
            "software.amazon.awssdk.services.rekognition.model.Image"
        )
            .builder()
            .s3Object( stored_object_builder.build() )
            .build();

        var request = createObject(
            "java",
            "software.amazon.awssdk.services.rekognition.model.DetectModerationLabelsRequest"
        )
            .builder()
            .image( image )
            .minConfidence( javacast( "float", variables.minimum_confidence ) )
            .build();

        try {
            var response = variables.rekognition_client.detectModerationLabels( request );
        } catch ( any error ) {
            writeLog(
                file = "image-moderation",
                type = "error",
                text = serializeJSON( {
                    event: "IMAGE_MODERATION_FAILED",
                    object_key: selected_key,
                    error_type: error.type ?: "",
                    error_message: error.message ?: ""
                } )
            );

            throw(
                type = "ImageModerationService.Unavailable",
                message = "Image moderation couldn't be completed.",
                detail = error.message ?: ""
            );
        }

        var labels = [];
        var iterator = response.moderationLabels().iterator();

        while ( iterator.hasNext() ) {
            var label = iterator.next();

            arrayAppend( labels, {
                name: toString( label.name() ),
                parent_name: toString( label.parentName() ),
                confidence: val( toString( label.confidence() ) ),
                taxonomy_level: val( toString( label.taxonomyLevel() ) )
            } );
        }

        return {
            acceptable: !arrayLen( labels ),
            labels: labels,
            moderation_model_version: toString( response.moderationModelVersion() )
        };
    }

    public void function close() {
        variables.rekognition_client.close();
    }

}

Le composant possède trois éléments de politique :

  • Le bucket qu'il peut inspecter
  • Le préfixe de clé d'objet qu'il peut inspecter
  • Le niveau minimal de confiance requis pour que Rekognition renvoie une étiquette

Le navigateur ne choisit pas le bucket. Il ne devrait pas non plus être autorisé à soumettre une clé d'objet arbitraire à inspecter. L'application devrait récupérer la clé d'objet à partir de l'enregistrement de téléversement en attente créé lorsqu'elle a autorisé le téléversement. Sinon, nous avons construit un petit service authentifié qui permet aux utilisateurs de dépenser notre argent à inspecter des objets aléatoires. J'ai déjà construit des choses pires par accident, mais généralement pas intentionnellement. Et certainement pas sobre.

L'identifiant de version facultatif est important lorsque le versionnement du bucket est activé. Il permet à Rekognition d'inspecter la version exacte que ColdFusion a vérifiée, plutôt que n'importe quelle version qui occupe la clé au moment où la modération commence.

L'utilisation de clés d'objet uniques et non réutilisables reste préférable. Les noms de fichiers partagés et modifiables ont le don de transformer des processus simples en enquêtes médico-légales.

Créer un seul client réutilisable

Créez le service lorsque l'application ColdFusion démarre :

public boolean function onApplicationStart() {
    application.aws_client_factory = new models.aws_client_factory( default_region = "ca-central-1" );

    application.image_moderation_service = new models.image_moderation_service(
        rekognition_client = application.aws_client_factory.createClient( "rekognition" ),
        bucket_name = application.configuration.upload_bucket,
        upload_prefix = "incoming/",
        minimum_confidence = 80
    );

    return true;
}

Fermez le client lorsque l'application se termine :

public void function onApplicationEnd( required struct applicationScope ) {
    if ( structKeyExists( arguments.applicationScope, "image_moderation_service" ) ) {
        arguments.applicationScope.image_moderation_service.close();
    }
}

Les clients de services Amazon sont conçus pour être réutilisés. Nous n'avons pas besoin d'en construire et d'en détruire un pour chaque image, à moins que nous ayons développé une hostilité inhabituelle envers le regroupement des connexions.

Inspection d'un objet téléversé

Après que le point de terminaison de finalisation a authentifié l'utilisateur, chargé le téléversement en attente et vérifié l'objet, appelez le service de modération :

<cfscript>
    moderation = application.image_moderation_service.inspectObject(
        object_key = pending_upload.object_key,
        version_id = pending_upload.version_id
    );

    if ( !moderation.acceptable ) {
        upload_repository.markRejected(
            upload_id = pending_upload.id,
            moderation_labels = serializeJSON( moderation.labels ),
            moderation_model_version = moderation.moderation_model_version
        );

        response = {
            accepted: false,
            reason: "The uploaded image couldn't be accepted."
        };
    } else {
        upload_repository.markApproved(
            upload_id = pending_upload.id,
            moderation_model_version = moderation.moderation_model_version
        );

        response = { accepted: true };
    }

    cfcontent( type = "application/json" );
    writeOutput( serializeJSON( response ) );
</cfscript>

Les méthodes du dépôt représentent le travail de base de données propre à l'application. Remplacez-les par la couche de persistance utilisée par votre application.

N'envoyez pas directement les étiquettes de modération à un téléverseur anonyme, à moins que le produit ait réellement besoin de les exposer. Des raisons détaillées de rejet peuvent aider un utilisateur légitime, mais elles peuvent aussi aider un utilisateur malveillant à ajuster une image jusqu'à ce qu'elle passe sous le seuil. « L'image n'a pas pu être acceptée » est moins instructif.

Une réponse rejetée pourrait ressembler en interne à ceci :

{
    "acceptable": false,
    "moderation_model_version": "7.0",
    "labels": [
        {
            "name": "Graphic Violence",
            "parent_name": "Violence",
            "confidence": 96.7,
            "taxonomy_level": 2
        }
    ]
}

Amazon documente la structure hiérarchique des étiquettes dans son guide de l'interface de modération. Les étiquettes peuvent inclure un nom, un nom parent, une valeur de confiance et un niveau de taxonomie.

Conservez la version du modèle avec le résultat. Amazon peut mettre à jour son modèle et sa taxonomie de modération. Si une décision d'approbation est remise en question plus tard, savoir quel modèle l'a produite est considérablement plus utile que de hausser les épaules en pointant vers le nuage.

Décider ce que « acceptable » veut dire

Notre première implémentation rejette une image chaque fois que Rekognition renvoie au moins une étiquette de modération au-dessus du seuil de confiance configuré. C'est délibérément conservateur :

acceptable: !arrayLen( labels )

C'est aussi seulement une politique de départ. Une application pourrait permettre du contenu suggestif tout en rejetant la nudité explicite. Une archive historique peut devoir accepter des images qu'une application sportive pour enfants devrait rejeter immédiatement. Les différents usages exigent des politiques différentes.

La valeur minimum_confidence contrôle les étiquettes renvoyées par Rekognition. Cela ne veut pas dire qu'une image avec 79 pour cent de confiance est sûre tandis qu'une image avec 80 pour cent de confiance est devenue mauvaise. Cela veut dire que nous avons tracé une ligne et que nous devrions tester si cette ligne se comporte de façon sensée avec des images représentatives.

La documentation DetectModerationLabels d'Amazon laisse explicitement à l'application la décision finale de convenance. Une politique de modération mature peut éventuellement contenir trois résultats :

  • Approuver
  • Rejeter
  • Mettre en attente pour examen humain

Pour l'instant, approuvez seulement lorsqu'aucune étiquette de modération configurée n'est renvoyée. Gardez tout le reste privé.

Échouer de façon fermée

Une défaillance du service n'est pas la même chose qu'une image rejetée. Si les identifiants expirent, si les autorisations changent, si Amazon limite la requête ou si Rekognition est temporairement indisponible, nous ne devrions pas dire à l'utilisateur que l'image enfreignait la politique de contenu. Nous ne devrions pas non plus la publier parce que le service de modération a eu un après-midi difficile.

Le composant lance ImageModerationService.Unavailable. Le flux de travail appelant devrait intercepter cette exception, laisser le téléversement en attente et privé, puis réessayer plus tard ou demander à l'utilisateur de réessayer.

<cfscript>
    try {
        moderation = application.image_moderation_service.inspectObject(
            object_key = pending_upload.object_key,
            version_id = pending_upload.version_id
        );
    } catch ( ImageModerationService.Unavailable error ) {
        upload_repository.markModerationPending( upload_id = pending_upload.id );

        cfheader( statuscode = 503 );
        cfcontent( type = "application/json" );

        writeOutput( serializeJSON( {
            accepted: false,
            retryable: true,
            reason: "L'image n'a pas encore pu être vérifiée."
        } ) );

        abort;
    }
</cfscript>

L'image demeure privée jusqu'à ce que la modération réussisse. C'est ce que « fail closed » signifie en pratique : une panne d'infrastructure ne se transforme pas discrètement en permission de publier.

Nous avons maintenant un téléversement direct qu'autorise ColdFusion, qu'Amazon S3 reçoit et qu'Amazon Rekognition inspecte. Les données du fichier n'ont toujours pas transité par ColdFusion ni par le serveur Web. ColdFusion demeure responsable des aspects qui exigent vraiment un jugement : la propriété, la sélection d'objet, les seuils de confiance, la publication, la conservation et le comportement en cas d'échec.

Dans la quatrième partie, nous laisserons les fichiers téléversés tranquilles et utiliserons la même intégration Java pour gérer les enregistrements de domaine avec Amazon Route 53. Rien ne rend un déploiement aussi important que de donner au code de l'application la permission de modifier le Domain Name System.