Share on facebook
Share on twitter
Share on linkedin

Build your own multi-user photo album app with React, GraphQL, and AWS Amplify — Part 3 of 3

A Cloud Guru News
A Cloud Guru News

Paginate photos, add security, and deploy our finished web app

Part 1 | Part 2 | Part 3
This is the third post in a three-part series that shows you how to build a scalable and highly available serverless web app on AWS that lets users upload photos to albums and share those albums privately with others.


Note: Since the original publication of this series, I’ve also re-worked this content into a self-paced workshop available online at: 
https://amplify-workshop.go-aws.com/


In Part One of this series we bootstrapped our app, added authentication, and integrated a GraphQL API for creating photo album records with a web frontend that let us list out album names and create new album names.

In Part Two, we added URL routing to switch between list and detail views, added photo uploads with automatic thumbnail generation, and an album details view that loads photos for an album.

Here in Part Three, we’ll finish things off with the following improvements:

  • Paginate photos on albums where we have many photos
  • Add the ability to add other usernames to an album
  • Only show albums that you are a member of
  • Explicitly deny access to an album’s details view if you are not a member of the album and prevent sneaky people from trying to list all photos uploaded to our S3 bucket
  • Deploy our finished web app behind a CDN so users can have faster load times all around the world

Ready? Let’s finish things up!

Paginating photos when viewing an album

One thing you may have noticed is that if you add a lot of photos to an album, they’re not all displayed on the album’s details page. This is because in our AppSync API, the resolver attached to the photos field on our Album type uses a DynamoDB Query operation, and this operation always returns paginated data (you can read more info in the DynamoDB Query docs). To fetch our paginated photos for an album in a nice way, we’ll need to make a few changes to our app.

Adding a ‘Load more photos’ button to our album view

First, we’ll want a way to fetch all of the photos in an album in reverse chronological order (newest on top). To do this, we’ll want a Global Secondary Index on the Photos table where the partition key is photoAlbumId and the sort Key is createdAt. The AWS Amplify CLI created an index on this table for the photoAlbumId primary key, but that index doesn’t have a sort key. We can’t edit an existing index, but if we delete and re-create an index with the same name and primary key, but with our desired sort key as well, we’ll be in good shape.

  1. In the AWS AppSync web console for our API, click ‘Data Sources’, then click on the resource link for PhotoTable. This takes you to the DynamoDB web console for the Photos table.
  2. Go to the Indexes tab, select the ‘gsi-AlbumPhotos’, click ‘Delete index’, and confirm the deletion
  3. Wait for the delete to finish (it can take a few moments)
  4. Click ‘Create index’
  5. In the ‘Partition key’ box, enter photoAlbumId, and leave the dropdown set to ‘String’
  6. Check the ‘Add sort key’ box and enter createdAt, and change the dropdown to ‘Number’
  7. Set the ‘Index name’ to gsi-AlbumPhotos
  8. Click ‘Create index’

This operation will take a few minutes to complete, giving us time to make the other changes we’ll need. Namely, we’ll modify our GetAlbum query so it gets pagination information back from our GraphQL API, we’ll modify our AlbumDetailsLoader component with a version that handles using this data to allow for loading more results, and we’ll add a button to the AlbumDetails component to request loading more photos.

Make these edits to photo-albums/src/App.js:

// photo-albums/src/App.js
// 1. EDIT: update the GetAlbum query so it looks like this:
const GetAlbum = `query GetAlbum($id: ID!, $nextTokenForPhotos: String) {
  getAlbum(id: $id) {
    id
    name
    photos(sortDirection: DESC, nextToken: $nextTokenForPhotos) {
      nextToken
      items {
        thumbnail {
          width
          height
          key
        }
      }
    }
  }
}
`;

// 2. EDIT: Replace the AlbumDetailsLoader component 
//    with this updated version which takes care of
//    paging through results and exposes a 
//    loadMorePhotos() function to the AlbumDetails component
class AlbumDetailsLoader extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      nextTokenForPhotos: null,
      hasMorePhotos: true,
      album: null,
      loading: true
    }
  }
  async loadMorePhotos() {
    if (!this.state.hasMorePhotos) return;
    this.setState({ loading: true });
    const { data } = await API.graphql(graphqlOperation(GetAlbum, {id: this.props.id, nextTokenForPhotos: this.state.nextTokenForPhotos}));
    let album;
    if (this.state.album === null) {
      album = data.getAlbum;
    } else {
      album = this.state.album;
      album.photos.items = album.photos.items.concat(data.getAlbum.photos.items);
    }
    this.setState({ 
      album: album,
      loading: false,
      nextTokenForPhotos: data.getAlbum.photos.nextToken,
      hasMorePhotos: data.getAlbum.photos.nextToken !== null
    });
  }
  componentDidMount() {
    this.loadMorePhotos();
  }
  render() {
    return <AlbumDetails loadingPhotos={this.state.loading} album={this.state.album} loadMorePhotos={this.loadMorePhotos.bind(this)} hasMorePhotos={this.state.hasMorePhotos}/>;
  }
}

// 3. EDIT: Replace the AlbumDetails component 
//    with this updated version
//    which shows an initial loading indicator and
//    adds a button to load more photos
class AlbumDetails extends Component {
  render() {
    if (!this.props.album) return 'Loading album...';
    return (
      <Segment>
        <Header as='h3'>{this.props.album.name}</Header>
        <S3ImageUpload albumId={this.props.album.id}/>        
        <PhotosList photos={this.props.album.photos.items} />
        {
          this.props.hasMorePhotos && 
          <Form.Button
            onClick={this.props.loadMorePhotos}
            icon='refresh'
            disabled={this.props.loadingPhotos}
            content={this.props.loadingPhotos ? 'Loading...' : 'Load more photos'}
          />
        }
      </Segment>
    )
  }
}

After these changes, if you add a bunch of photos (by default more than 10, since that’s what the Album’s photo field resolver uses as a default limit value), you should then be able to see and click the ‘Load more photos’ button to retrieve older photos. Nice!

If things aren’t working, make sure that the index creation finished in the DynamoDB console. Attempts to use an index while it’s still backfilling will cause an error (which you should be able to see in the JS console if the GraphQL query fails).
 
Now it’s time to move on to improving the security of our app.

Adding the ability to add other usernames to view an album

So far, any of the albums we create will be visible to anyone who logs into our app. Let’s fix this by adding a new members field to our Album type (defaulting to contain our own username on creation), then checking the current username against the list of members when someone tries to access an album, and finally making a new mutation to to add other usernames to an Album.
 
First, let’s add a new members field to Album. While we’re here, we’ll also add a new addUsernameToAlbum mutation. In the AppSync web console, edit our GraphQL schema:

type Album {
    # ...
    # NEW: add members field
    members: [String!]
    # ...
}
type Mutation {
    # ...
    # NEW: add another mutation
    addUsernameToAlbum(username: String!, albumId: String!): Album
}

Save the schema. Next, we’ll update the createAlbum mutation so that it creates new albums with our username as a member by default. It makes the most sense to use a StringSet type in DynamoDB for a field like this, so we’ll have to do a little bit of editing to our createAlbum resolver here, but it’s not much at all.
 
On the right side of the screen, in the Resolvers column, find the createAlbum(...):Album mutation and click the AlbumTable link to view and edit the resolver. Edit and save the request mapping template to look like this (the new additions and changes are bolded and noted below ## comments):

$util.qr($context.args.input.put("createdAt", $util.time.nowISO8601()))
$util.qr($context.args.input.put("updatedAt", $util.time.nowISO8601()))
$util.qr($context.args.input.put("__typename", "Album"))
$util.qr($context.args.input.put("owner", $context.identity.username))
## 1. NEW: Create an attributes variable that starts with all the contents of our input argument
##    Then, add in a members property string set with our username as the only initial value
##    Note: the lines below should start with # (two ##s is a comment in VTL, not one)
#set($attributes = $util.dynamodb.toMapValues($context.arguments.input))
#set($attributes.members = $util.dynamodb.toStringSet(["$context.identity.username"]))
{
  "version": "2017-02-28",
  "operation": "PutItem",
  "key": {
      "id": {
          "S": "$util.autoId()"
    }
  },
  
  ## 2. EDIT: use our $attributes value instead of $context.args.input
  "attributeValues": $util.toJson($attributes),
  "condition": {
      "expression": "attribute_not_exists(#id)",
      "expressionNames": {
          "#id": "id"
    }
  }
}

Now go back to the Queries section of the AppSync console and try out a new createAlbum mutation to see your username as a member of the album by default.

mutation {
  createAlbum(input:{name:"Test with members defaulting to self"}) {
    name
    members
  }
}

Now that we’re a member of all albums we own (for all new albums created from this point forward), update the getAlbum query to only return the album if our username is a member of it. 
 
In the AppSync Schema view, find the getAlbum query and click to edit its resolver. Change (and save) the response mapping template to:

#if ($context.result.members.contains($context.identity.username))
    $util.toJson($context.result)
#else
  $util.unauthorized()
#end

With that change done, we’re now safe from having anyone be able to load an album URL. Now no data will be returned from our API unless the user is a member of the album they’re trying to load.
 
Let’s move on to the front end for adding usernames to an album.
 
Find the addUsernameToAlbum mutation in the Resolvers list on the right-hand side and click ‘Attach Resolver’. Select ‘AlbumTable’ for the data source. Overwrite and save the request mapping template with this:

{
    "version" : "2017-02-28",
    "operation" : "UpdateItem",
    "key" : {
        "id": $util.dynamodb.toDynamoDBJson($ctx.args.albumId)
    },
    "update" : {
        "expression" : "ADD #members :username",
        "expressionNames" : {
             "#members" : "members"
         },
        "expressionValues" : {
             ":username" : $util.dynamodb.toStringSetJson([$ctx.args.username])
        }    
    }
}

That takes care of the API changes we’ll need to add a username to an album. Now let’s add the UI for adding a username to an album and for showing all the members of an album.
 
Make the following changes to photo-albums/src/App.js:

// photo-albums/src/App.js
// 1. NEW: add Icon and Input to semantic-ui-react imports
import { Divider, Form, Grid, Header, Icon, Input, List, Segment } from 'semantic-ui-react';

// 2. NEW: create an AddUsernameToAlbum component
class AddUsernameToAlbum extends Component {
  constructor(props) {
    super(props);
    this.state = { username: '' };
  }
handleChange = (e, { name, value }) => this.setState({ [name]: value })
handleSubmit = async (event) => {
    event.preventDefault();
    const AddUsernameToAlbum = `
      mutation AddUser($username: String!, $albumId: String!) {
          addUsernameToAlbum(username: $username, albumId: $albumId) {
              id
          }
      }`;
    const result = await API.graphql(graphqlOperation(AddUsernameToAlbum, { username: this.state.username, albumId: this.props.albumId }));
    console.log(`Added ${this.state.username} to album id ${result.data.addUsernameToAlbum.id}`);
    this.setState({ username: '' });
  }
render() {
    return (
      <Input
        type='text'
        placeholder='Username'
        icon='user plus'
        iconPosition='left'
        action={{ content: 'Add', onClick: this.handleSubmit }}
        name='username'
        value={this.state.username}
        onChange={this.handleChange}
      />
    )
  }
}

// 3. EDIT: add members field to our GetAlbum query string
const GetAlbum = `query GetAlbum($id: ID!, $nextTokenForPhotos: String) {
  getAlbum(id: $id) {
    id
    name
    # NEW: Add members field
    members
    photos(sortDirection: DESC, nextToken: $nextTokenForPhotos) {
      nextToken
      items {
        thumbnail {
          width
          height
          key
        }
      }
    }
  }
}
// 4. NEW: create an AlbumMembers component
const AlbumMembers = (props) => (
  <div>
    <Header as='h4'>
      <Icon name='user circle' />
      <Header.Content>Members</Header.Content>
    </Header>
    <List bulleted>
        {props.members && props.members.map((member) => <List.Item key={member}>{member}</List.Item>)}
    </List>
  </div>
);

// 5. EDIT: add the AddUsernameToAlbum component
//    and the AlbumMembers component 
//    to the AlbumDetails component's render()
class AlbumDetails extends Component {
  render() {
    if (!this.props.album) return 'Loading album...';
    return (
      <Segment>
        <Header as='h3'>{this.props.album.name}</Header>
        // NEW: Add member components
        <Segment.Group>
          <Segment>
            <AlbumMembers members={this.props.album.members} />
          </Segment>
          <Segment basic>
            <AddUsernameToAlbum albumId={this.props.album.id} />
          </Segment>
        </Segment.Group>
        <S3ImageUpload albumId={this.props.album.id}/>        
        <PhotosList photos={this.props.album.photos.items} />
        {
          this.props.hasMorePhotos && 
          <Form.Button
            onClick={this.props.loadMorePhotos}
            icon='refresh'
            disabled={this.props.loadingPhotos}
            content={this.props.loadingPhotos ? 'Loading...' : 'Load more photos'}
          />
        }
      </Segment>
    )
  }
}
Adding new usernames as members of an album

If you go back to the front end app now, you should be able to type a username to add it to an album. We haven’t wired up any subscriptions though, so after you add a new username you’ll have to reload the page to see the new username rendered in the members section.

Showing only the albums that a user belongs to

With album-level security taken care of, let’s move on and update our API so that it only returns the albums that a username is a member of.
 
In the AppSync web console, go to the Schema section, find the listAlbums query and edit its resolver. Edit the request mapping template, replacing the filter property with a new one (shown below). Here’s the full request mapping template annotated with the changed filter property:

#set( $limit = $util.defaultIfNull($context.args.limit, 10) )
{
  "version": "2017-02-28",
  "operation": "Scan",
  
  ## EDIT: This is our new filter, which will only return results where the current username is a member of the album
  "filter" : {
    "expression" : "contains(members, :cognitoId)",
    "expressionValues" : {
      ":cognitoId":  $util.dynamodb.toDynamoDBJson($ctx.identity.username)
    }
  },
  
  "limit": $limit,
  "nextToken":   #if( $context.args.nextToken )
    "$context.args.nextToken"
  #else
    null
  #end
}

After you make this change, if you log out, create another user, then log in with that other user, you should not see albums owned by your first user. Nice!

Preventing snooping eyes from listing all photos in our S3 bucket

Finally, the default permissions for the user files S3 storage bucket set up by the Amplify CLI allows anyone logged in to our app to list the contents of the bucket for any keys that start with ‘public/’ (and a few other prefixes too). While our app doesn’t expose this as an interaction, someone poking around might try to take their credentials from our app and make an API call to S3 directly to try and list the bucket where all the photos are going. We have no need to let users list bucket contents at all, so let’s add another policy to explicitly deny users the ability to list all S3 buckets. 
 
To do this, we’ll need to change some permissions in the Identity and Access Management (IAM) console. When we created our app, The Amplify CLI set up an IAM role that gets assigned to all authenticated users in our app. The easiest way to find this role and edit its permissions is to go through CloudFormation.
 
CloudFormation is an infrastructure-as-code tool from AWS that lets you describe cloud resources in JSON or YAML files and then provision those related resources together in a group called a ‘stack’. The AWS Amplify CLI uses CloudFormation under the hood, so there’s a CloudFormation stack we can view in the browser that will help take us to the IAM role we need to edit.

Editing the IAM policy for the role used by the PhotoTable AWS AppSync data source

Here’s how to find the IAM role we want to edit:

  1. In the AWS web console, go to the CloudFormation section
  2. Click on the stack name that looks like photoalbums-SOME-DATE-STAMP
  3. Expand the Resources section and click on the link in the AuthRole line.

This takes you to the IAM page for the authenticated users role. Now, all we need to do is add another policy that will explicitly deny the s3:ListBucket action for this role. Here’s how to do it:

  1. Click ‘Add inline policy’ (it’s at the right side of the screen, opposite the ‘Attach policies’ button
  2. Click the ‘JSON’ tab
  3. Paste in the following policy
    {
     "Version": "2012-10-17",
     "Statement": [
     {
     "Action": [
     "s3:ListBucket"
     ],
     "Resource": [
     "*"
     ],
     "Effect": "Deny"
     }
     ]
    }
  4. Click ‘Review Policy’
  5. Name the policy. Perhaps something like ‘denyS3ListBucket’
  6. Click ‘Create Policy’

And that’s it. Now nobody will be able to go directly to the S3 API and list all of the photos that our users have uploaded. We’re using UUIDs for album and photo IDs, so we shouldn’t have to worry about a curious user enumerating through patterns of IDs hoping to find photos to view.

Deploying our app to a CDN

As it says on the Amplify website’s Hosting section: “Amplify CLI provides a single-line deploy command that pushes your app’s static assets to the Content Delivery Network (CDN). Using a CDN dramatically increases your app’s loading performance by serving your content to your users from the nearest edge location.” 
 
This is a great feature to use in our app, and it’s super simple to enable. 
 
Run amplify hosting add (select a deployment mode and fill in some options), then amplify publish. In our case, we can accept all of the default options. Here’s an example:

$ amplify hosting add
? Select the environment setup: PROD (S3 with CloudFront using HTTPS)
? hosting bucket name photoalbums-19700101112233--hostingbucket
? index doc for the website index.html
? error doc for the website index.html
$ amplify publish
# -- Lots of content removed for the sake of brevity -- #
frontend build command exited with code 0
✔ Uploading files successful.
Your app is published successfully.

At the end of the output, you’ll see a URL for the version of your app deployed onto Amazon CloudFront, AWS’s CDN service. Any time you make new changes to your app, just re-run amplify publish whenever you want to push a new build out. Pretty easy, right?

Wrapping up

While no application is really ever ‘done’, we’ve certainly come a long way! We built a non-trivial web app, complete with authentication, file uploads, and user permissions. Our backend is entirely ‘serverless’, built with scalable and highly available services on AWS, and we’ve deployed our app to a worldwide content delivery network. Not bad for a few hours worth of work!
 
Please give yourself a huge pat on the back for making it all the way to the end! I hope this series was helpful for you, and if you have any comments or feedback, I’d love to hear it. To stay informed about future posts, please follow me on Twitter at Gabe Hollombe. That’s also the best way to reach me if you have any questions or feedback about this post.
 
 Thanks for reading, and build on!

Part 1 | Part 2 | Part 3
This is the third post in a three-part series that shows you how to build a scalable and highly available serverless web app on AWS that lets users upload photos to albums and share those albums privately with others.

Recommended

Get more insights, news, and assorted awesomeness around all things cloud learning.

Get Started
Who’s going to be learning?
Sign In
Welcome Back!
Thanks for reaching out!

You’ll hear from us shortly. In the meantime, why not check out what our customers have to say about ACG?

How many seats do you need?

  • $499 USD per seat per year
  • Billed Annually
  • Renews in 12 months

Ready to accelerate learning?

For over 25 licenses, a member of our sales team will walk you through a custom tailored solution for your business.


$2,495.00

Checkout