Calling AWS SDK v2 Directly from CFML - Part 3: Moderating Uploaded Images with Amazon Rekognition

Keep newly uploaded images private until Amazon Rekognition and our application agree that they should be published.

In part two, we allowed a browser to upload a file directly to a private AWS S3 bucket. ColdFusion authorized the upload, chose the object key, and verified what arrived without carrying the file through a ColdFusion request.

That solved the transportation problem. It didn’t solve the internet problem.

Users can upload profile photos, event images, product photographs, and a surprising number of files that appear to document the collapse of civilization. Before we publish any of them, we need to inspect the content.

Amazon Rekognition can examine an image stored in Amazon Simple Storage Service without sending the image through ColdFusion. Our application identifies the object, calls DetectModerationLabels, and receives a collection of labels describing potentially inappropriate content. Rekognition doesn’t decide whether an image is acceptable. It supplies labels and confidence values. The application still makes the decision.

This distinction is important. We’re buying image classification, not outsourcing morality to a web service. We tried outsourcing morality to the internet already. That’s how we got here.

Our completed workflow will look like this:

  1. The browser uploads an image to a private incoming/ location.
  2. ColdFusion verifies the object belongs to the authenticated upload.
  3. Amazon Rekognition examines the object directly.
  4. ColdFusion applies the application’s moderation policy.
  5. Approved images may be published.
  6. Rejected images remain private and are deleted or retained according to policy.
  7. Service failures leave the image unpublished.

Nothing in the incoming/ location should be publicly available. An image isn’t approved merely because the upload completed.

Granting the Required Permissions

The identity used by ColdFusion needs permission to call DetectModerationLabels. Because Rekognition will inspect an object in Amazon Simple Storage Service, that identity also needs permission to read objects from the incoming upload location. A minimal Identity and Access Management policy looks like this:

{
    "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/*"
        }
    ]
}

Remove s3:GetObjectVersion if the bucket doesn’t use versioning.

The AWS S3 bucket and the Rekognition client must use the same Amazon region. Rekognition accepts JPEG and PNG images. An image supplied as an S3 object can be no larger than 15 megabytes. The minimum dimensions are 80 pixels in each direction, and DetectModerationLabels accepts dimensions up to 10,000 pixels in each direction. Amazon maintains the current limits in its Rekognition guidelines and quotas.

We should enforce those limits before requesting moderation. Rekognition will reject invalid input, but making a paid network request merely to discover that a 40-megabyte animated file isn’t a JPEG seems unnecessarily ceremonial.

Creating the Moderation Service

Part one gave us an aws_client_factory.cfc component capable of creating a reusable Rekognition client. Create 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();
    }

}

The component owns three pieces of policy:

  • The bucket it may inspect
  • The object-key prefix it may inspect
  • The minimum confidence required for Rekognition to return a label

The browser doesn’t choose the bucket. It also shouldn’t be allowed to submit an arbitrary object key for inspection. The application should retrieve the object key from the pending upload record created when it authorized the upload. Otherwise, we’ve built a small authenticated service that lets users spend our money inspecting random objects. I have accidentally built worse things, but generally not on purpose. And certainly not sober.

The optional version identifier matters when bucket versioning is enabled. It allows Rekognition to inspect the exact version ColdFusion verified instead of whichever version happens to occupy the key when moderation begins.

Using unique, non-reusable object keys is still preferable. Shared mutable filenames have a way of turning straightforward workflows into forensic investigations.

Creating One Reusable Client

Create the service when the ColdFusion application starts:

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;
}

Close the client when the application ends:

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

Amazon service clients are designed to be reused. We don’t need to construct and destroy one for every image unless we’ve developed an unusual hostility toward connection pooling.

Inspecting an Uploaded Object

After the completion endpoint has authenticated the user, loaded the pending upload, and verified the object, call the moderation service:

<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>

The repository methods represent application-specific database work. Replace them with whatever persistence layer your application uses.

Don’t return the moderation labels directly to an anonymous uploader unless the product genuinely needs to expose them. Detailed rejection reasons can help a legitimate user, but they can also help an abusive user tune an image until it slips below the threshold. “The image couldn’t be accepted” is less educational.

A rejected response might internally resemble this:

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

Amazon documents the hierarchical label structure in its moderation interface guide. Labels can include a name, parent name, confidence value, and taxonomy level.

Store the model version with the result. Amazon can update its moderation model and taxonomy. If an approval decision is questioned later, knowing which model produced it is considerably more useful than shrugging and pointing toward the cloud.

Deciding What “Acceptable” Means

Our first implementation rejects an image whenever Rekognition returns at least one moderation label above the configured confidence threshold. That’s deliberately conservative:

acceptable: !arrayLen( labels )

It’s also only a starting policy. An application might permit suggestive content while rejecting explicit nudity. A historical archive may need to accept images that a children’s sports application should reject immediately. Different uses require different policies.

The minimum_confidence value controls which labels Rekognition returns. It doesn’t mean an image with 79 percent confidence is safe while an image with 80 percent confidence has become evil. It means we chose a line and should test whether that line behaves sensibly with representative images.

Amazon’s DetectModerationLabels documentation explicitly leaves the final suitability decision to the application. A mature moderation policy may eventually contain three outcomes:

  • Approve
  • Reject
  • Hold for human review

For now, approve only when no configured moderation labels are returned. Keep everything else private.

Failing Closed

A service failure isn’t the same as a rejected image. If credentials expire, permissions change, Amazon throttles the request, or Rekognition is temporarily unavailable, we shouldn’t tell the user that the image violated the content policy. We also shouldn’t publish it because the moderation service had a difficult afternoon.

The component throws ImageModerationService.Unavailable. The calling workflow should catch that exception, leave the upload pending and private, and either retry later or ask the user to try again.

<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: "The image couldn't be checked yet."
        } ) );

        abort;
    }
</cfscript>

The image remains private until moderation succeeds. This is what “fail closed” means in practice: an infrastructure failure doesn’t quietly become permission to publish.

We now have a direct upload that ColdFusion authorizes, AWS S3 receives, and Amazon Rekognition inspects. The file data still hasn’t passed through ColdFusion or the web server. ColdFusion remains responsible for the parts that actually require judgment: ownership, object selection, confidence thresholds, publication, retention, and failure behaviour.

In part four, we’ll leave uploaded files alone and use the same Java integration to manage domain records with Amazon Route 53. Nothing makes a deployment feel consequential quite like giving application code permission to edit the Domain Name System.