Beetroot LogoBeetroot

Pre-signed Thumbnail URLs

Update the API Lambda to return temporary S3 URLs so frontend can render images without making buckets public.

Goal

Right now, the API returns S3 keys like repThumbKey or photoKey, but the browser cannot render an S3 key directly.

In this phase, you will update your API Lambda so it returns pre-signed URLs:

  • People grid (GET /persons)

    • repThumbKey
    • repThumbURL (frontend renders this)
  • Person detail (GET /persons/{personId}/photos)

    • photoURL (full photo URL for the grid)

Why pre-signed URLs?

Pre-signed URLs let your frontend access private S3 objects for a short time (for example, 1 hour) without making your bucket public.

What changes in this phase

The API returned S3 metadata:

  • repThumbKey
  • photoBucket + photoKey

  • thumbKey

But the frontend still couldn't render images directly.

The API also returns URLs:

  • repThumbURL (People thumbnail)

  • photoURL (full photo for Person)

Now React can do:

  • <img src={repThumbURL} />
  • <img src={photoURL} />

Update API Lambda IAM Role

Your API Lambda already reads DynamoDB tables (Persons / Occurrences / Photos). Now we also need S3 permissions so that the API can generate URLs that actually work in the browser.

Update inline policy

  1. Open the role: IAM → Roles → beetroot-api-role
  2. PermissionsBeetrootAPIPolicy
  3. Use the Visual editor
  1. Select service: S3

  2. Under Actions:

    • Expand Read
    • Select: GetObject
  3. Under Resources:

    • Choose Specific
    • In the Object section, click Add ARN
  4. In the Add ARN popup, fill:

    • Resource bucket name: beetroot-raw
    • Resource object name: photos-raw/*

    The ARN should resolve to:

    • Resource ARN: arn:aws:s3:::beetroot-raw/photos-raw/*
  5. Click Add to save the ARN.

  6. Click Add additional permissions (for S3 Thumbs Bucket GetObject)

  1. Select service: S3

  2. Under Actions:

    • Expand Read
    • Select: GetObject
  3. Under Resources:

    • Choose Specific
    • In the Object section, click Add ARN
  4. In the Add ARN popup, fill:

    • Resource bucket name: beetroot-thumbs
    • Resource object name: faces-thumbs/*

    The ARN should resolve to:

    • Resource ARN: arn:aws:s3:::beetroot-thumbs/faces-thumbs/*
  5. Click Add to save the ARN.

  6. Click Add additional permissions (for S3 Thumbs Bucket ListBucket)

  1. Select service: S3

  2. Under Actions:

    • Expand List
    • Select: ListBucket
  3. Under Resources:

    • Choose Specific
    • In the bucket section, click Add ARN
  4. In the Add ARN popup, fill:

    • Resource bucket name: beetroot-thumbs

    The ARN should resolve to:

    • Resource ARN: arn:aws:s3:::beetroot-thumbs
  5. Click Add to save the ARN.

Why ListBucket too?

Some S3 requests against the bucket endpoint may require a bucket-level permission check.

Allowing ListBucket only on the thumbs bucket (optionally limited to the faces-thumbs/prefix) prevents AccessDenied when opening a pre-signed URL in the browser.

Save the inline policy

  1. Click Next
  2. Click on Save changes

Updated Policy JSON

If you prefer pasting JSON instead of using the visual editor, use this and replace bucket names if yours differs:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DynamoReadForApi",
      "Effect": "Allow",
      "Action": ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:Scan"],
      "Resource": [
        "arn:aws:dynamodb:us-east-1:<ACCOUNT_ID>:table/Persons",
        "arn:aws:dynamodb:us-east-1:<ACCOUNT_ID>:table/Occurrences",
        "arn:aws:dynamodb:us-east-1:<ACCOUNT_ID>:table/Photos"
      ]
    },
    {
      "Sid": "ThumbsGetObjects",
      "Effect": "Allow",
      "Action": ["s3:GetObject"],
      "Resource": "arn:aws:s3:::beetroot-thumbs/faces-thumbs/*"
    },
    {
      "Sid": "ThumbsListBucket",
      "Effect": "Allow",
      "Action": ["s3:ListBucket"],
      "Resource": "arn:aws:s3:::beetroot-thumbs",
      "Condition": {
        "StringLike": {
          "s3:prefix": ["faces-thumbs/*"]
        }
      }
    },
    {
      "Sid": "AllowReadRawPhotosForPresign",
      "Effect": "Allow",
      "Action": ["s3:GetObject"],
      "Resource": ["arn:aws:s3:::beetroot-raw/photos-raw/*"]
    }
  ]
}

Update API Lambda

Code Changes

  • Adds an S3 client + pre-sign helper
  • Route 1 (GET /persons) returns repThumbURL
  • Route 2 (GET /persons/{personId}/photos) returns photoURL (and thumbURL)

Part 1: Add the S3 client + env vars

  • THUMBS_BUCKET = beetroot-thumbs
  • PRESIGN_EXPIRES = 3600 (optional)
s3 = boto3.client("s3")

THUMBS_BUCKET = os.environ["THUMBS_BUCKET"]
PRESIGN_EXPIRES = int(os.environ.get("PRESIGN_EXPIRES", "3600"))

Where does the raw bucket come from?

We don't need a new env var for raw photos because the raw bucket + key are already stored in DynamoDB (Occurrences or Photos).

Part 2: Helper to generate pre-signed GET URL

What this does

Given (bucket, key), it returns a temporary HTTPS URL that can be used in the browser.

def presign_get_object(bucket: str, key: str) -> str:
    return s3.generate_presigned_url(
        "get_object",
        Params={"Bucket": bucket, "Key": key},
        ExpiresIn=PRESIGN_EXPIRES,
    )

Part 3: Patch Route 1 — return repThumbURL

What this route did before

  • scanned Persons
  • sorted by photoCount
  • returned the raw items (including repThumbKey)

What we add now

Before returning, build a clean list where each person includes:

  • repThumbKey
  • repThumbURL (generated from that key)
out = []
for p in items[:100]:
    rep_key = p.get("repThumbKey")
    rep_url = presign_get_object(THUMBS_BUCKET, rep_key) if rep_key else None
    out.append({
        "personId": p.get("personId"),
        "photoCount": p.get("photoCount", 0),
        "repThumbKey": rep_key,
        "repThumbURL": rep_url,
    })

return _resp(200, {"persons": out})
rep_key = p.get("repThumbKey")
rep_url = presign_get_object(THUMBS_BUCKET, rep_key) if rep_key else None

Example Persons item:

{
  "personId": "0c7d...",
  "photoCount": 2,
  "repThumbKey": "faces-thumbs/0c7d.../fe40..._face_1.jpg"
}

UI-ready entry:

{
  "personId": "0c7d...",
  "photoCount": 2,
  "repThumbKey": "faces-thumbs/0c7d.../fe40..._face_1.jpg",
  "repThumbURL": "https://...signed-url..."
}

Why return both key and URL?

The key is stable metadata (good for debugging and storage). The URL is temporary and may expire, so frontend should treat it as a render-only field.

Part 4: Patch Route 2 — return photoURL

It returns the list of photo appearances. Now we add:

  • photoURL → pre-signed URL for the full image

Resolve raw photo location

Depending on what you stored, Occurrences may already contain:

  • photoBucket + photoKey

If not, fallback to the Photos table using photoId.

    photo_bucket = o.get("photoBucket")
    photo_key = o.get("photoKey")

    if not photo_bucket or not photo_key:
        p = PHOTOS_TABLE.get_item(Key={"photoId": photo_id}).get("Item")
        if not p:
            continue
        photo_bucket = photo_bucket or p.get("s3Bucket")
        photo_key = photo_key or p.get("s3Key")

Generate URL

    photo_url = presign_get_object(photo_bucket, photo_key) if (photo_bucket and photo_key) else None

Build the response object

    photos.append(
        {
            "photoId": photo_id,
            "photoBucket": photo_bucket,
            "photoKey": photo_key,
            "thumbKey": thumb_key,
            "photoURL": photo_url,
        }
    )

Updated API Lambda code

beetroot-api/lambda_function.py
import json
import os
from decimal import Decimal

import boto3
from boto3.dynamodb.conditions import Key

ddb = boto3.resource("dynamodb")
s3 = boto3.client("s3")

PERSONS_TABLE = ddb.Table(os.environ.get("PERSONS_TABLE", "Persons"))
OCC_TABLE = ddb.Table(os.environ.get("OCCURRENCES_TABLE", "Occurrences"))
PHOTOS_TABLE = ddb.Table(os.environ.get("PHOTOS_TABLE", "Photos"))

THUMBS_BUCKET = os.environ["THUMBS_BUCKET"]
PRESIGN_EXPIRES = int(os.environ.get("PRESIGN_EXPIRES", "3600"))


def _json_default(o):
    if isinstance(o, Decimal):
        if o % 1 == 0:
            return int(o)
        return float(o)
    raise TypeError(f"Object of type {o.__class__.__name__} is not JSON serializable")


def _resp(status: int, body: dict):
    return {
        "statusCode": status,
        "headers": {
            "Content-Type": "application/json",
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Methods": "GET,OPTIONS",
            "Access-Control-Allow-Headers": "Content-Type",
        },
        "body": json.dumps(body, default=_json_default),
    }


def presign_get_object(bucket: str, key: str) -> str:
    return s3.generate_presigned_url(
        "get_object",
        Params={"Bucket": bucket, "Key": key},
        ExpiresIn=PRESIGN_EXPIRES,
    )


def lambda_handler(event, context):
    method = (
        event.get("requestContext", {}).get("http", {}).get("method")
        or event.get("httpMethod")
    )
    path = event.get("rawPath") or event.get("path") or ""

    if method == "OPTIONS":
        return _resp(200, {"ok": True})

    # Route 1: GET /persons
    if method == "GET" and path == "/persons":
        items = []
        resp = PERSONS_TABLE.scan(Limit=200)
        items.extend(resp.get("Items", []))
        while "LastEvaluatedKey" in resp and len(items) < 200:
            resp = PERSONS_TABLE.scan(
                ExclusiveStartKey=resp["LastEvaluatedKey"],
                Limit=200,
            )
            items.extend(resp.get("Items", []))

        items.sort(key=lambda x: int(x.get("photoCount", 0)), reverse=True)

        out = []
        for p in items[:100]:
            rep_key = p.get("repThumbKey")
            rep_url = presign_get_object(THUMBS_BUCKET, rep_key) if rep_key else None
            out.append(
                {
                    "personId": p.get("personId"),
                    "photoCount": p.get("photoCount", 0),
                    "repThumbKey": rep_key,
                    "repThumbURL": rep_url,
                }
            )

        return _resp(200, {"persons": out})

    # Route 2: GET /persons/{personId}/photos
    if method == "GET" and path.startswith("/persons/") and path.endswith("/photos"):
        parts = path.strip("/").split("/")
        if len(parts) != 3:
            return _resp(400, {"error": "bad path"})
        person_id = parts[1]

        occ_items = OCC_TABLE.query(
            KeyConditionExpression=Key("personId").eq(person_id),
            Limit=200,
        ).get("Items", [])

        photos = []
        for o in occ_items:
            photo_id = o.get("photoId")
            if not photo_id:
                continue

            photo_bucket = o.get("photoBucket")
            photo_key = o.get("photoKey")

            if not photo_bucket or not photo_key:
                p = PHOTOS_TABLE.get_item(Key={"photoId": photo_id}).get("Item")
                if not p:
                    continue
                photo_bucket = photo_bucket or p.get("s3Bucket")
                photo_key = photo_key or p.get("s3Key")

            photo_url = presign_get_object(photo_bucket, photo_key) if (photo_bucket and photo_key) else None

            photos.append(
                {
                    "photoId": photo_id,
                    "photoBucket": photo_bucket,
                    "photoKey": photo_key,
                    "thumbKey": thumb_key,
                    "photoURL": photo_url,
                }
            )

        return _resp(200, {"personId": person_id, "photos": photos})

    return _resp(404, {"error": "not found", "path": path})

Test

Use your API Gateway invoke URL.

People grid endpoint

curl -X GET "<INVOKE_URL>/persons"

You should now see repThumbURL included in each person object.

Copy one repThumbURL and paste it in a browser tab. If everything is correct, you should see the face thumbnail load.

Presigned URL preview on browser

Person detail endpoint

curl -X GET "<INVOKE_URL>/persons/<PERSON_ID>/photos"

You should now see photoURL.

Copy one photoURL and paste it in a browser tab. If everything is correct, you should see the photo load.

Presigned URL preview on browser

Common mistakes

Your API Lambda role likely doesn't have s3:GetObject for the thumbs bucket objects. Add permission for your thumbs bucket prefix (for example faces-thumbs/*).

If Lambda errors with something like KeyError: 'THUMBS_BUCKET', add THUMBS_BUCKET in the API Lambda environment variables.

This usually means the person item has no repThumbKey yet. Upload more photos (so persons gets created + repThumbKey is set).

On this page