aws-amplify / amplify-js

A declarative JavaScript library for application development using cloud services.
https://docs.amplify.aws/lib/q/platform/js
Apache License 2.0
9.41k stars 2.11k forks source link

Method get of Storage is not supported for SSR #7505

Closed MontoyaAndres closed 8 months ago

MontoyaAndres commented 3 years ago

Describe the bug I'm trying to get an image using the method get from Storage, like this:

export const getServerSideProps: GetServerSideProps = async ({
  params,
  req,
}) => {
  const { id } = params;
  const SSR = withSSRContext({ req });

  try {
    const { data } = await (SSR.API.graphql({
      query: getBasicUserInformation,
      variables: {
        id,
      },
    }) as Promise<{ data: GetUserQuery }>);

    if (data.getUser.id) {
      const picture = data.getUser.picture
        // the method get does not exist
        ? ((await SSR.Storage.get(data.getUser.picture)) as string)
        : null;

      return {
        props: { ...data.getUser, picture },
      };
    }
  } catch (error) {
    console.error(error);
    return {
      props: {
        id: null,
      },
    };
  }
};

This is the error I get:

TypeError: Cannot read property 'get' of null
    at getServerSideProps (webpack-internal:///./src/pages/perfil/[id]/index.tsx:71:64)
    at runMicrotasks (<anonymous>)
    at processTicksAndRejections (internal/process/task_queues.js:93:5)
    at async renderToHTML (/home/andres/Entrepreneurship/TeVi/node_modules/next/dist/next-server/server/render.js:39:215)
    at async /home/andres/Entrepreneurship/TeVi/node_modules/next/dist/next-server/server/next-server.js:99:97
    at async /home/andres/Entrepreneurship/TeVi/node_modules/next/dist/next-server/server/next-server.js:92:142
    at async DevServer.renderToHTMLWithComponents (/home/andres/Entrepreneurship/TeVi/node_modules/next/dist/next-server/server/next-server.js:124:387)
    at async DevServer.renderToHTML (/home/andres/Entrepreneurship/TeVi/node_modules/next/dist/next-server/server/next-server.js:125:874)
    at async DevServer.renderToHTML (/home/andres/Entrepreneurship/TeVi/node_modules/next/dist/server/next-dev-server.js:34:578)
    at async DevServer.render (/home/andres/Entrepreneurship/TeVi/node_modules/next/dist/next-server/server/next-server.js:72:236)
    at async Object.fn (/home/andres/Entrepreneurship/TeVi/node_modules/next/dist/next-server/server/next-server.js:56:618)
    at async Router.execute (/home/andres/Entrepreneurship/TeVi/node_modules/next/dist/next-server/server/router.js:23:67)
    at async DevServer.run (/home/andres/Entrepreneurship/TeVi/node_modules/next/dist/next-server/server/next-server.js:66:1042)
    at async DevServer.handleRequest (/home/andres/Entrepreneurship/TeVi/node_modules/next/dist/next-server/server/next-server.js:34:1081)

Expected behavior Get the image from the getServerSideProps function

amhinson commented 3 years ago

@MontoyaAndres Currently, Amplify only supports the API, Auth, & DataStore categories for SSR when using withSSRContext. However, I'm going to mark this as a feature request so it is accounted for in future SSR updates 👍

https://github.com/aws-amplify/amplify-js/blob/b3761aea625e4e1c266d5170a6cdc247eaa54bba/packages/aws-amplify/src/withSSRContext.ts#L16-L17

For the time being, you'll likely need to defer the Storage.get() call to the client.

MontoyaAndres commented 3 years ago

Thanks @amhinson is there a way to add Storage as a module? I'm seeing that the method withSSRContext has a parameter for adding modules...

ericclemmons commented 3 years ago

There's good news & bad news to this.

The good news is that you can provide modules to withSSRContext to get new instances of categories per-request:

import { Amplify, API, Storage, withSSRContext } from 'aws-amplify'
import { GRAPHQL_AUTH_MODE, GraphQLResult } from '@aws-amplify/api-graphql'
import { NextApiRequest, NextApiResponse } from 'next'

import awsconfig from '../../src/aws-exports'
import { ListTodosQuery } from '../../src/API'
import { listTodos } from '../../src/graphql/queries'

Amplify.configure(awsconfig)

export default async (req: NextApiRequest, res: NextApiResponse) => {
  // 👇  Notice how I'm explicitly created the SSR-specific instances of API & Storage
  const SSR = withSSRContext({ modules: [API, Storage], req })

  try {
    const result = (await SSR.API.graphql({
      // API has been configured with
      // GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS as the default authMode
      authMode: GRAPHQL_AUTH_MODE.AWS_IAM,
      query: listTodos,
    })) as GraphQLResult<ListTodosQuery>

    // 👇  This will fail on the server     
    console.log(await SSR.Storage.get('foo'))

    return res.status(200).json({ data: result?.data?.listTodos?.items })
  } catch (error) {
    console.error(error)
    return res.status(500).json({ error })
  }
}

The bad news is that the Storage category uses AWSS3Provider, which does not have access to request-specific credentials.


Note to self – proposal on how to fix this

The proposed changes would make Storage such that:

[WARN] 16:11.124 AWSS3Provider - ensure credentials error No Cognito Identity pool provided for unauthenticated access No credentials

This means there's more work for us to do to add proper Storage support for SSR:

  1. AWSS3Provider uses a single instance of Credentials, but should use a scoped instance (e.g. this.Credentials)

    https://github.com/aws-amplify/amplify-js/blob/e0789af92ad043bd4c069d766158942a5e6b2cae/packages/storage/src/providers/AWSS3Provider.ts#L455-L457

  2. Even with this.Credentials used within AWSS3Provider, they still need to be injected. Storage first needs to declare Credentials as an instance variable.

    Other categories like Auth have Credentials injected into them because they declare an instance property:

    https://github.com/aws-amplify/amplify-js/blob/e0789af92ad043bd4c069d766158942a5e6b2cae/packages/auth/src/Auth.ts#L91-L101

  3. AWSS3Provider is injected by default when no other pluggables are defined:

    https://github.com/aws-amplify/amplify-js/blob/e0789af92ad043bd4c069d766158942a5e6b2cae/packages/storage/src/Storage.ts#L155-L157

    When pluggables are configured, they can use also pass { Credentials = this.Credentials }:

    https://github.com/aws-amplify/amplify-js/blob/e0789af92ad043bd4c069d766158942a5e6b2cae/packages/storage/src/Storage.ts#L64

  4. Finally, AWSS3Provider can override this.Credentials based on the value in config:

    https://github.com/aws-amplify/amplify-js/blob/e0789af92ad043bd4c069d766158942a5e6b2cae/packages/storage/src/providers/AWSS3Provider.ts#L106-L115

ericclemmons commented 3 years ago

One way that I audited the codebase for all potential SSR "misses" was simply searching for \sCredentials.get().

If the call wasn't using this.Credentials.get(), then that means it's using a singleton which won't be request-specific on the server. 🔎

MontoyaAndres commented 3 years ago

Thanks! If someone wants to do something similar, one quick and simple way is to just use Credentials from @aws-amplify/core and get the image using the aws-sdk, something like this (In my case I'm using the api from next.js/vercel):

import Amplify from 'aws-amplify';
import { Credentials } from '@aws-amplify/core';
import * as S3 from 'aws-sdk/clients/s3';
import { S3Customizations } from 'aws-sdk/lib/services/s3';
import { NowRequest, NowResponse } from '@vercel/node';

import awsconfig from 'aws-exports';

Amplify.configure({ ...awsconfig, ssr: true });

interface IParams {
  Bucket: string;
  Key: string;
}

function getImage(s3: S3Customizations, params: IParams) {
  return new Promise<string>((resolve, rejected) => {
    try {
      const url = s3.getSignedUrl('getObject', params);
      resolve(url);
    } catch (error) {
      console.error(error);
      rejected(error);
    }
  });
}

export default async (request: NowRequest, response: NowResponse) => {
  const { body } = request;

  try {
    const credentials = await Credentials.get();
    const s3 = new S3({
      apiVersion: '2006-03-01',
      params: { Bucket: awsconfig.aws_user_files_s3_bucket },
      signatureVersion: 'v4',
      region: awsconfig.aws_user_files_s3_bucket_region,
      credentials,
    });
    const picture = await getImage(s3, {
      Bucket: awsconfig.aws_user_files_s3_bucket,
      Key: (`public/${body.key}` as string) || '',
    });

    response.json({ picture });
  } catch (error) {
    console.error(error);
    response.json({ error });
  }
};

That's it, works fine for me, while this is resolved by the Amplify team :).

aaronmcadam commented 3 years ago

I've just hit this problem today. Was any progress made with the above ideas to make Storage.get aware of Credentials?

foobarnes commented 1 year ago

An update for anyone having issues with @MontoyaAndres workaround:

import { Amplify, withSSRContext } from 'aws-amplify';
import * as S3 from 'aws-sdk/clients/s3';
import awsconfig from 'src/aws-exports';

Amplify.configure({ ...awsconfig, ssr: true });

export default async function handler(req, res) {
  const { Auth } = withSSRContext({ req });

  const credentials = await Auth.currentCredentials();

  const s3 = new S3({
    params: {
      Bucket: awsconfig.aws_user_files_s3_bucket,
    },
    region: awsconfig.aws_user_files_s3_bucket_region,
    credentials
  });

  // Do what that S3 do

}
joekiller commented 1 year ago

I was working on this as well and it appears that although the types do not show it, you can inject credentials into all the storage commands. So another workaround is:

import { Amplify, API, Auth, withSSRContext } from 'aws-amplify';
import awsconfig from 'src/aws-exports';

Amplify.configure({ ...awsconfig, ssr: true });

export default async function handler(req, res) {
  const SSR = withSSRContext({ modules: [Storage, Auth], req })

  const credentials = await SSR.Auth.currentCredentials();
  // Just pass the creds into the Storage object
  // @ts-ignore
  await SSR.Storage.put(key, value, {resumable: false, credentials}  
  // @ts-ignore
  await SSR.Storage.remove(key, {credentials})
  // @ts-ignore
  const result = await SSR.Storage.get(key, { download: true, credentials}) as GetObjectCommandOutput;
}

However I ran into the dreaded https://github.com/aws-amplify/amplify-js/issues/5504 which makes this solution a non-starter. Please do not try this and use the s3 client directly for nodejs compatibility.

so I ended up with:

import {withSSRContext} from "aws-amplify";
import {S3} from "@aws-sdk/client-s3";
import awsconfig from 'src/aws-exports';

Amplify.configure({ ...awsconfig, ssr: true });

export default async function handler(req, res) {
  const { Auth } = withSSRContext({ req })
  const credentials = await Auth.currentCredentials();
  const s3 = new S3({region: awsmobile.aws_user_files_s3_bucket_region, credentials, forcePathStyle: true, tls: false, endpoint: 'http://localhost:20005'})
}

Note that forcePathStyle: true, tls: false, endpoint: 'http://localhost:20005' all needed to be added manually.

After more twiddling. I've consolidated the S3 client into a function. I also imported the aws-amplify namespace as amplify to make some variables easier to read.

import {NextApiRequest, NextApiResponse} from "next";
import * as amplify from "aws-amplify";
import {ICredentials} from "@aws-amplify/core";
import {S3, S3Client} from "@aws-sdk/client-s3";
import awsExports from "../../aws-exports";
import {createQuery} from "../graphql/queries";
amplify.Amplify.configure({ ...awsExports, ssr: true });

function s3Client(Storage: typeof amplify.Storage, credentials: ICredentials): { s3: S3Client, bucket: string } {
  // @ts-ignore
  const config = Storage._config;
  if (config.AWSS3.dangerouslyConnectToHttpEndpointForTesting) {
    return {
      s3: new S3({
        region: config.AWSS3.region,
        credentials,
        forcePathStyle: true,
        tls: false,
        endpoint: 'http://localhost:20005'
      }),
      bucket: config.AWSS3.bucket
    }
  } else {
    return {
      s3: new S3({region: config.AWSS3.region, credentials}),
      bucket: config.AWSS3.bucket
    }
  }
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const {API, Auth, Storage} = amplify.withSSRContext({ modules: [amplify.API, amplify.Auth, amplify.Storage], req })
  const credentials = await Auth.currentCredentials();
  const {s3, bucket} = s3Client(Storage, credentials);
  await API.graphql({authMode: "AWS_IAM", query: createQuery, variables: {input: {key: 'key', value: 'value'}}}))
}

This seems to work well with amplify mock for both storage and API.

acro5piano commented 1 year ago

@MontoyaAndres Thanks! Your solution works like a charm.

The following code is Next.js api sample:

import { NextApiHandler } from 'next'
import { Storage } from 'aws-amplify'
import { Credentials } from '@aws-amplify/core'
import { Amplify } from 'aws-amplify'
import awsConfig from 'src/aws-exports'

// You need to configure on every API 
Amplify.configure({
  ...awsConfig,
  ssr: true,
})

const handler: NextApiHandler = async (req, res) => {
  const s3Key = 'xxxxxxxxxxxxxx.png'
  const credentials = await Credentials.get()
  const presignedUrl = await Storage.get(s3Key, { credentials })
  console.log(presignedUrl)
  // https://xxxxxx-dev.s3.ap-northeast-1.amazonaws.com/public/xxxxxxxxxxxxxx.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-ContentX-Amz-Credential....&x-id=GetObject
}

export default handler
Tomekmularczyk commented 1 year ago

I'm having a NEXTjs@13 with appDir enabled. I managed to make authorized requests ad described #11074. But none of the solutions here work for Storage.get

ey-bitzstein commented 1 year ago

Here's a complete example that shows how to put and get files in S3 from a backend API route:

import { Amplify, withSSRContext } from 'aws-amplify';
import * as S3 from 'aws-sdk/clients/s3';
import awsConfig from '../../aws-exports';
import axios from 'axios'

Amplify.configure({
  ...awsConfig,
});

export default async (req, res) => {
  try {
    const { Auth } = withSSRContext({ req });
    const credentials = await Auth.currentCredentials();
    const s3 = new S3({
      apiVersion: '2006-03-01',
      params: { Bucket: awsConfig.aws_user_files_s3_bucket },
      signatureVersion: 'v4',
      region: awsConfig.aws_user_files_s3_bucket_region,
      credentials,
    });

    const s3Key = `protected/${credentials.identityId}/${(new Date()).toISOString()}-${crypto.randomUUID()}.txt`;
    const s3Value = 'Hello!';

    // Upload file to S3
    const putUrl = await s3.getSignedUrl(
      'putObject',
      {
        Bucket: awsConfig.aws_user_files_s3_bucket,
        Key: s3Key,
      });

    await axios.put(putUrl, s3Value);

    // Get file from S3
    const getUrl = await s3.getSignedUrl(
      'getObject',
      {
        Bucket: awsConfig.aws_user_files_s3_bucket,
        Key: s3Key,
      });

    const response = await axios.get(getUrl).then((r) => r.data);

    res.status(200).json({
      location: `s3://${awsConfig.aws_user_files_s3_bucket}/${s3Key}`,
      value: response });
  } catch (error) {
    console.log(error);
    res.status(500).json({ error: `${error.name}: ${error.message}` });
  }
};
nadetastic commented 8 months ago

With the release of the latest major version of Amplify (aws-amplify@>6), this issue should now be resolved! Please refer to our release announcement, migration guide, and documentation for more information.