Merge pull request #23 from sambecker/s3-presigned-url

Switch S3 to presigned url strategy
This commit is contained in:
Sam Becker 2023-11-29 22:57:38 -06:00 committed by GitHub
commit 205d5fb0bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 565 additions and 524 deletions

View File

@ -18,6 +18,7 @@
"Makernote", "Makernote",
"nanoids", "nanoids",
"nextjs", "nextjs",
"presigner",
"Provia", "Provia",
"qaub", "qaub",
"QRSTUVWXYZ", "QRSTUVWXYZ",

View File

@ -91,31 +91,9 @@ Installation
}] }]
``` ```
- Store configuration - Store configuration
- Bucket name: `NEXT_PUBLIC_S3_BUCKET` - Bucket name: `NEXT_PUBLIC_AWS_S3_BUCKET`
- Bucket region: `NEXT_PUBLIC_S3_REGION` - Bucket region: `NEXT_PUBLIC_AWS_S3_REGION`
2. Setup client upload credentials 2. Setup credentials
- [Create IAM policy](https://console.aws.amazon.com/iam/home#/policies) using JSON editor:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:PutObjectACL"
],
"Resource": [
"arn:aws:s3:::{BUCKET_NAME}/upload-*"
]
}
]
}
```
- [Create IAM user](https://console.aws.amazon.com/iam/home#/users) by choosing "Attach policies directly." Create access key under "Security credentials," choose "Application running outside AWS," and store credentials:
- `NEXT_PUBLIC_S3_UPLOAD_ACCESS_KEY`
- `NEXT_PUBLIC_S3_UPLOAD_SECRET_ACCESS_KEY`
3. Setup server admin credentials
- [Create IAM policy](https://console.aws.amazon.com/iam/home#/policies) using JSON editor: - [Create IAM policy](https://console.aws.amazon.com/iam/home#/policies) using JSON editor:
```json ```json
{ {
@ -138,9 +116,9 @@ Installation
] ]
} }
``` ```
- [Create IAM user](https://console.aws.amazon.com/iam/home#/users) by choosing "Attach policies directly." Create access key under "Security credentials," choose "Application running outside AWS," and store credentials (⚠️ _Ensure admin environment variables are not prefixed with `NEXT_PUBLIC`_): - [Create IAM user](https://console.aws.amazon.com/iam/home#/users) by choosing "Attach policies directly." Create access key under "Security credentials," choose "Application running outside AWS," and store credentials (⚠️ _Ensure credential environment variables are not prefixed with `NEXT_PUBLIC`_):
- `S3_ADMIN_ACCESS_KEY` - `AWS_S3_ACCESS_KEY`
- `S3_ADMIN_SECRET_ACCESS_KEY` - `AWS_S3_SECRET_ACCESS_KEY`
FAQ FAQ
- -

View File

@ -7,10 +7,10 @@ const VERCEL_BLOB_HOSTNAME = VERCEL_BLOB_STORE_ID
: undefined; : undefined;
const AWS_S3_HOSTNAME = const AWS_S3_HOSTNAME =
process.env.NEXT_PUBLIC_S3_BUCKET && process.env.NEXT_PUBLIC_AWS_S3_BUCKET &&
process.env.NEXT_PUBLIC_S3_REGION process.env.NEXT_PUBLIC_AWS_S3_REGION
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
? `${process.env.NEXT_PUBLIC_S3_BUCKET}.s3.${process.env.NEXT_PUBLIC_S3_REGION}.amazonaws.com` ? `${process.env.NEXT_PUBLIC_AWS_S3_BUCKET}.s3.${process.env.NEXT_PUBLIC_AWS_S3_REGION}.amazonaws.com`
: undefined; : undefined;
const createRemotePattern = (hostname) => hostname const createRemotePattern = (hostname) => hostname

View File

@ -9,17 +9,18 @@
"analyze": "ANALYZE=true next build" "analyze": "ANALYZE=true next build"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.456.0", "@aws-sdk/client-s3": "3.462.0",
"@aws-sdk/s3-request-presigner": "3.462.0",
"@next/bundle-analyzer": "14.0.3", "@next/bundle-analyzer": "14.0.3",
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.1.4", "@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^14.1.2", "@testing-library/react": "^14.1.2",
"@types/jest": "^29.5.10", "@types/jest": "^29.5.10",
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
"@types/react": "18.2.38", "@types/react": "18.2.39",
"@types/react-dom": "18.2.17", "@types/react-dom": "18.2.17",
"@typescript-eslint/eslint-plugin": "^6.12.0", "@typescript-eslint/eslint-plugin": "^6.13.1",
"@typescript-eslint/parser": "^6.12.0", "@typescript-eslint/parser": "^6.13.1",
"@vercel/analytics": "^1.1.1", "@vercel/analytics": "^1.1.1",
"@vercel/blob": "^0.15.1", "@vercel/blob": "^0.15.1",
"@vercel/postgres": "0.5.1", "@vercel/postgres": "0.5.1",
@ -29,18 +30,18 @@
"eslint": "8.54.0", "eslint": "8.54.0",
"eslint-config-next": "14.0.3", "eslint-config-next": "14.0.3",
"exifr": "^7.1.3", "exifr": "^7.1.3",
"framer-motion": "^10.16.5", "framer-motion": "^10.16.7",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"nanoid": "^5.0.3", "nanoid": "^5.0.3",
"next": "14.0.3", "next": "14.0.3",
"next-auth": "5.0.0-beta.3", "next-auth": "5.0.0-beta.4",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"postcss": "8.4.31", "postcss": "8.4.31",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-icons": "^4.12.0", "react-icons": "^4.12.0",
"sonner": "^1.2.3", "sonner": "^1.2.4",
"tailwindcss": "3.3.5", "tailwindcss": "3.3.5",
"ts-exif-parser": "^0.2.2", "ts-exif-parser": "^0.2.2",
"typescript": "5.3.2" "typescript": "5.3.2"

930
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
import { auth } from '@/auth';
import {
awsS3Client,
awsS3PutObjectCommandForKey,
} from '@/services/blob/aws-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
export const runtime = 'edge';
export async function GET(
_: Request,
{ params: { key } }: { params: { key: string } },
) {
const session = await auth();
if (session?.user && key) {
const url = await getSignedUrl(
awsS3Client(),
awsS3PutObjectCommandForKey(key),
{ expiresIn: 3600 }
);
return new Response(
url,
{ headers: { 'content-type': 'text/plain' } },
);
} else {
return new Response('Unauthorized request', { status: 401 });
}
}

View File

@ -19,6 +19,6 @@ export async function GET() {
photos: photos.map(formatPhotoForApi), photos: photos.map(formatPhotoForApi),
}); });
} else { } else {
return Response.json({ message: 'API is disabled' }); return new Response('API access disabled', { status: 404 });
} }
} }

View File

@ -7,28 +7,23 @@ import {
PutObjectCommand, PutObjectCommand,
} from '@aws-sdk/client-s3'; } from '@aws-sdk/client-s3';
const S3_BUCKET = process.env.NEXT_PUBLIC_S3_BUCKET ?? ''; const AWS_S3_BUCKET = process.env.NEXT_PUBLIC_AWS_S3_BUCKET ?? '';
const S3_REGION = process.env.NEXT_PUBLIC_S3_REGION ?? ''; const AWS_S3_REGION = process.env.NEXT_PUBLIC_AWS_S3_REGION ?? '';
const S3_UPLOAD_ACCESS_KEY = const AWS_S3_ACCESS_KEY = process.env.AWS_S3_ACCESS_KEY ?? '';
process.env.NEXT_PUBLIC_S3_UPLOAD_ACCESS_KEY ?? ''; const AWS_S3_SECRET_ACCESS_KEY = process.env.AWS_S3_SECRET_ACCESS_KEY ?? '';
const S3_UPLOAD_SECRET_ACCESS_KEY =
process.env.NEXT_PUBLIC_S3_UPLOAD_SECRET_ACCESS_KEY ?? '';
const S3_ADMIN_ACCESS_KEY =
process.env.S3_ADMIN_ACCESS_KEY;
const S3_ADMIN_SECRET_ACCESS_KEY =
process.env.S3_ADMIN_SECRET_ACCESS_KEY;
const client = () => new S3Client({ const API_PATH_PRESIGNED_URL = '/api/aws-s3/presigned-url';
region: S3_REGION,
export const awsS3Client = () => new S3Client({
region: AWS_S3_REGION,
credentials: { credentials: {
// Fall back on upload credentials when admin credentials aren't available accessKeyId: AWS_S3_ACCESS_KEY,
accessKeyId: S3_ADMIN_ACCESS_KEY ?? S3_UPLOAD_ACCESS_KEY, secretAccessKey: AWS_S3_SECRET_ACCESS_KEY,
secretAccessKey: S3_ADMIN_SECRET_ACCESS_KEY ?? S3_UPLOAD_SECRET_ACCESS_KEY,
}, },
}); });
export const AWS_S3_BASE_URL = export const AWS_S3_BASE_URL =
`https://${S3_BUCKET}.s3.${S3_REGION}.amazonaws.com`; `https://${AWS_S3_BUCKET}.s3.${AWS_S3_REGION}.amazonaws.com`;
export const isUrlFromAwsS3 = (url: string) => export const isUrlFromAwsS3 = (url: string) =>
url.startsWith(AWS_S3_BASE_URL); url.startsWith(AWS_S3_BASE_URL);
@ -37,22 +32,24 @@ const urlForKey = (key?: string) => `${AWS_S3_BASE_URL}/${key}`;
const generateBlobId = () => generateNanoid(16); const generateBlobId = () => generateNanoid(16);
export const awsS3PutObjectCommandForKey = (Key: string) =>
new PutObjectCommand({ Bucket: AWS_S3_BUCKET, Key, ACL: 'public-read' });
export const awsS3UploadFromClient = async ( export const awsS3UploadFromClient = async (
file: File | Blob, file: File | Blob,
fileName: string, fileName: string,
extension: string, extension: string,
addRandomSuffix?: boolean, addRandomSuffix?: boolean,
) => { ) => {
const Key = addRandomSuffix const key = addRandomSuffix
? `${fileName}-${generateBlobId()}.${extension}` ? `${fileName}-${generateBlobId()}.${extension}`
: `${fileName}.${extension}`; : `${fileName}.${extension}`;
return client().send(new PutObjectCommand({
Bucket: S3_BUCKET, const url = await fetch(`${API_PATH_PRESIGNED_URL}/${key}`)
Key, .then((response) => response.text());
Body: file,
ACL: 'public-read', return fetch(url, { method: 'PUT', body: file })
})) .then(() => urlForKey(key));
.then(() => urlForKey(Key));
}; };
export const awsS3Copy = async ( export const awsS3Copy = async (
@ -65,8 +62,8 @@ export const awsS3Copy = async (
const Key = addRandomSuffix const Key = addRandomSuffix
? `${name}-${generateBlobId()}.${extension}` ? `${name}-${generateBlobId()}.${extension}`
: fileNameDestination; : fileNameDestination;
return client().send(new CopyObjectCommand({ return awsS3Client().send(new CopyObjectCommand({
Bucket: S3_BUCKET, Bucket: AWS_S3_BUCKET,
CopySource: fileNameSource, CopySource: fileNameSource,
Key, Key,
ACL: 'public-read', ACL: 'public-read',
@ -75,15 +72,15 @@ export const awsS3Copy = async (
}; };
export const awsS3Delete = async (Key: string) => { export const awsS3Delete = async (Key: string) => {
client().send(new DeleteObjectCommand({ awsS3Client().send(new DeleteObjectCommand({
Bucket: S3_BUCKET, Bucket: AWS_S3_BUCKET,
Key, Key,
})); }));
}; };
export const awsS3List = async (Prefix: string) => export const awsS3List = async (Prefix: string) =>
client().send(new ListObjectsCommand({ awsS3Client().send(new ListObjectsCommand({
Bucket: S3_BUCKET, Bucket: AWS_S3_BUCKET,
Prefix, Prefix,
})) }))
.then((data) => data.Contents?.map(({ Key }) => urlForKey(Key)) ?? []); .then((data) => data.Contents?.map(({ Key }) => urlForKey(Key)) ?? []);

View File

@ -13,7 +13,7 @@ import {
awsS3UploadFromClient, awsS3UploadFromClient,
isUrlFromAwsS3, isUrlFromAwsS3,
} from './aws-s3'; } from './aws-s3';
import { HAS_AWS_S3_STORAGE_CLIENT, HAS_AWS_S3_STORAGE } from '@/site/config'; import { HAS_AWS_S3_STORAGE, HAS_AWS_S3_STORAGE_CLIENT } from '@/site/config';
const PREFIX_UPLOAD = 'upload'; const PREFIX_UPLOAD = 'upload';
const PREFIX_PHOTO = 'photo'; const PREFIX_PHOTO = 'photo';

View File

@ -35,17 +35,15 @@ export const HAS_VERCEL_BLOB =
(process.env.BLOB_READ_WRITE_TOKEN ?? '').length > 0; (process.env.BLOB_READ_WRITE_TOKEN ?? '').length > 0;
// STORAGE: AWS S3 // STORAGE: AWS S3
// Includes separate check for client-side usage, i.e., uploading, // Includes separate check for client-side usage,
// i.e., uploading, url construction
export const HAS_AWS_S3_STORAGE_CLIENT = export const HAS_AWS_S3_STORAGE_CLIENT =
(process.env.NEXT_PUBLIC_S3_BUCKET ?? '').length > 0 && (process.env.NEXT_PUBLIC_AWS_S3_BUCKET ?? '').length > 0 &&
(process.env.NEXT_PUBLIC_S3_REGION ?? '').length > 0 && (process.env.NEXT_PUBLIC_AWS_S3_REGION ?? '').length > 0;
(process.env.NEXT_PUBLIC_S3_UPLOAD_ACCESS_KEY ?? '').length > 0 &&
(process.env.NEXT_PUBLIC_S3_UPLOAD_SECRET_ACCESS_KEY ?? '').length > 0;
export const HAS_AWS_S3_STORAGE = export const HAS_AWS_S3_STORAGE =
HAS_AWS_S3_STORAGE_CLIENT && HAS_AWS_S3_STORAGE_CLIENT &&
(process.env.S3_ADMIN_ACCESS_KEY ?? '').length > 0 && (process.env.AWS_S3_ACCESS_KEY ?? '').length > 0 &&
(process.env.S3_ADMIN_SECRET_ACCESS_KEY ?? '').length > 0; (process.env.AWS_S3_SECRET_ACCESS_KEY ?? '').length > 0;
// SETTINGS // SETTINGS