diff --git a/contributors.yml b/contributors.yml index f609931122b..4a1b02b1832 100644 --- a/contributors.yml +++ b/contributors.yml @@ -129,6 +129,7 @@ - gonzoscript - graham42 - GregBrimble +- GSt4r - guatedude2 - guerra08 - gunners6518 diff --git a/examples/file-and-s3-upload/.env.sample b/examples/file-and-s3-upload/.env.sample new file mode 100644 index 00000000000..17b368d399e --- /dev/null +++ b/examples/file-and-s3-upload/.env.sample @@ -0,0 +1,4 @@ +STORAGE_ACCESS_KEY= +STORAGE_SECRET= +STORAGE_REGION= +STORAGE_BUCKET= \ No newline at end of file diff --git a/examples/file-and-s3-upload/.eslintrc.js b/examples/file-and-s3-upload/.eslintrc.js new file mode 100644 index 00000000000..ced78085f86 --- /dev/null +++ b/examples/file-and-s3-upload/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], +}; diff --git a/examples/file-and-s3-upload/.gitignore b/examples/file-and-s3-upload/.gitignore new file mode 100644 index 00000000000..3f7bf98da3e --- /dev/null +++ b/examples/file-and-s3-upload/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/public/build +.env diff --git a/examples/file-and-s3-upload/README.md b/examples/file-and-s3-upload/README.md new file mode 100644 index 00000000000..36249dcd670 --- /dev/null +++ b/examples/file-and-s3-upload/README.md @@ -0,0 +1,35 @@ +# Upload images to S3 + +This is a simple example of using the remix built-in [uploadHandler](https://remix.run/docs/en/v1/api/remix#uploadhandler) and Form with multipart data to upload a file with the built-in local uploader and upload an image file to S3 with a custom uploader and display it. You can test it locally by running the dev server and opening the path `/s3-upload` in your browser. + +The relevent files are: + +``` +├── app +│ ├── routes +│ │ ├── s3-upload.tsx // upload to S3 +│ └── utils +│ └── s3.server.ts // init S3 client on server side +|── .env // holds AWS S3 credentails +``` + +## Steps to set up an S3 bucket + +- Sign up for an [AWS account](https://portal.aws.amazon.com/billing/signup) - this will require a credit card +- Create an S3 bucket in your desired region +- Create an access key pair for an IAM user that has access to the bucket +- Copy the .env.sample to .env and fill in the S3 bucket, the region as well as the access key and secret key from the IAM user + +Note: in order for the image to be displayed after being uploaded to your S3 bucket in this example, the bucket needs to have public access enabled, which is potentially dangerous. + +> :warning: Lambda imposes a [limit of 6MB](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html) on the invocation payload size. If you use this example with Remix running on Lambda, you can only update files with a size smaller than 6MB. + +Open this example on [CodeSandbox](https://codesandbox.com): + +[![Open in CodeSandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/remix-run/remix/tree/main/examples/file-and-s3-upload) + +## Related Links + +- [Handle Multiple Part Forms (File Uploads)](https://remix.run/docs/en/v1/api/remix#unstable_parsemultipartformdata-node) +- [Upload Handler](https://remix.run/docs/en/v1/api/remix#uploadhandler) +- [Custom Uploader](https://remix.run/docs/en/v1/api/remix#custom-uploadhandler) diff --git a/examples/file-and-s3-upload/app/entry.client.tsx b/examples/file-and-s3-upload/app/entry.client.tsx new file mode 100644 index 00000000000..3eec1fd0a02 --- /dev/null +++ b/examples/file-and-s3-upload/app/entry.client.tsx @@ -0,0 +1,4 @@ +import { RemixBrowser } from "@remix-run/react"; +import { hydrate } from "react-dom"; + +hydrate(, document); diff --git a/examples/file-and-s3-upload/app/entry.server.tsx b/examples/file-and-s3-upload/app/entry.server.tsx new file mode 100644 index 00000000000..5afa18235cc --- /dev/null +++ b/examples/file-and-s3-upload/app/entry.server.tsx @@ -0,0 +1,21 @@ +import type { EntryContext } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import { renderToString } from "react-dom/server"; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + const markup = renderToString( + + ); + + responseHeaders.set("Content-Type", "text/html"); + + return new Response("" + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +} diff --git a/examples/file-and-s3-upload/app/root.tsx b/examples/file-and-s3-upload/app/root.tsx new file mode 100644 index 00000000000..927a0f745df --- /dev/null +++ b/examples/file-and-s3-upload/app/root.tsx @@ -0,0 +1,32 @@ +import type { MetaFunction } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +export const meta: MetaFunction = () => ({ + charset: "utf-8", + title: "New Remix App", + viewport: "width=device-width,initial-scale=1", +}); + +export default function App() { + return ( + + + + + + + + + + + + + ); +} diff --git a/examples/file-and-s3-upload/app/routes/s3-upload.tsx b/examples/file-and-s3-upload/app/routes/s3-upload.tsx new file mode 100644 index 00000000000..e4dc02d8b1b --- /dev/null +++ b/examples/file-and-s3-upload/app/routes/s3-upload.tsx @@ -0,0 +1,60 @@ +import type { ActionFunction, UploadHandler } from "@remix-run/node"; +import { + json, + unstable_composeUploadHandlers as composeUploadHandlers, + unstable_createMemoryUploadHandler as createMemoryUploadHandler, + unstable_parseMultipartFormData as parseMultipartFormData, +} from "@remix-run/node"; +import { useFetcher } from "@remix-run/react"; +import { s3UploadHandler } from "~/utils/s3.server"; + +type ActionData = { + errorMsg?: string; + imgSrc?: string; + imgDesc?: string; +}; + +export const action: ActionFunction = async ({ request }) => { + const uploadHandler: UploadHandler = composeUploadHandlers( + s3UploadHandler, + createMemoryUploadHandler() + ); + const formData = await parseMultipartFormData(request, uploadHandler); + const imgSrc = formData.get("img"); + const imgDesc = formData.get("desc"); + console.log(imgDesc) + if (!imgSrc) { + return json({ + errorMsg: "Something went wrong while uploading", + }); + } + return json({ + imgSrc, + imgDesc, + }); +}; + +export default function Index() { + const fetcher = useFetcher(); + return ( + <> + + + + + + + + {fetcher.type === "done" ? ( + fetcher.data.errorMsg ? ( +

{fetcher.data.errorMsg}

+ ) : ( + <> +
File has been uploaded to S3 and is available under the following URL (if the bucket has public access enabled):
+
{fetcher.data.imgSrc}
+ {fetcher.data.imgDesc + + )) : null} + + ); +} diff --git a/examples/file-and-s3-upload/app/utils/s3.server.ts b/examples/file-and-s3-upload/app/utils/s3.server.ts new file mode 100644 index 00000000000..5f755c83fff --- /dev/null +++ b/examples/file-and-s3-upload/app/utils/s3.server.ts @@ -0,0 +1,46 @@ +import AWS from "aws-sdk" +import type { UploadHandler } from "@remix-run/node" +import { writeAsyncIterableToWritable } from "@remix-run/node" +import { PassThrough } from "stream" + +const { STORAGE_ACCESS_KEY, STORAGE_SECRET, STORAGE_REGION, STORAGE_BUCKET } = process.env + +if (!(STORAGE_ACCESS_KEY && STORAGE_SECRET && STORAGE_REGION && STORAGE_BUCKET)) { + throw new Error(`Storage is missing required configuration.`) +} + +const uploadStream = ({ Key }: Pick) => { + const s3 = new AWS.S3({ + credentials: { + accessKeyId: STORAGE_ACCESS_KEY, + secretAccessKey: STORAGE_SECRET, + }, + region: STORAGE_REGION, + }) + const pass = new PassThrough() + return { + writeStream: pass, + promise: s3.upload({ Bucket: STORAGE_BUCKET, Key, Body: pass }).promise(), + } +} + +export async function uploadStreamToS3(data: any, filename: string) { + const stream = uploadStream({ + Key: filename, + }) + await writeAsyncIterableToWritable(data, stream.writeStream) + const file = await stream.promise + return file.Location +} + +export const s3UploadHandler: UploadHandler = async ({ + name, + filename, + data, +}) => { + if (name !== "img") { + return undefined; + } + const uploadedFileLocation = await uploadStreamToS3(data, filename!) + return uploadedFileLocation +} diff --git a/examples/file-and-s3-upload/package.json b/examples/file-and-s3-upload/package.json new file mode 100644 index 00000000000..edd814eb782 --- /dev/null +++ b/examples/file-and-s3-upload/package.json @@ -0,0 +1,28 @@ +{ + "private": true, + "sideEffects": false, + "scripts": { + "build": "remix build", + "dev": "remix dev", + "start": "remix-serve build" + }, + "dependencies": { + "@remix-run/node": "1.5.1", + "@remix-run/react": "1.5.1", + "@remix-run/serve": "1.5.1", + "aws-sdk": "^2.1152.0", + "react": "^17.0.2", + "react-dom": "^17.0.2" + }, + "devDependencies": { + "@remix-run/dev": "1.5.1", + "@remix-run/eslint-config": "1.5.1", + "@types/react": "^17.0.39", + "@types/react-dom": "^17.0.13", + "eslint": "^8.10.0", + "typescript": "^4.6.2" + }, + "engines": { + "node": ">=14" + } +} diff --git a/examples/file-and-s3-upload/public/favicon.ico b/examples/file-and-s3-upload/public/favicon.ico new file mode 100644 index 00000000000..8830cf6821b Binary files /dev/null and b/examples/file-and-s3-upload/public/favicon.ico differ diff --git a/examples/file-and-s3-upload/remix.config.js b/examples/file-and-s3-upload/remix.config.js new file mode 100644 index 00000000000..260b82c7cb1 --- /dev/null +++ b/examples/file-and-s3-upload/remix.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ["**/.*"], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", +}; diff --git a/examples/file-and-s3-upload/remix.env.d.ts b/examples/file-and-s3-upload/remix.env.d.ts new file mode 100644 index 00000000000..72e2affe311 --- /dev/null +++ b/examples/file-and-s3-upload/remix.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/examples/file-and-s3-upload/sandbox.config.json b/examples/file-and-s3-upload/sandbox.config.json new file mode 100644 index 00000000000..4363d87a30d --- /dev/null +++ b/examples/file-and-s3-upload/sandbox.config.json @@ -0,0 +1,6 @@ +{ + "hardReloadOnChange": true, + "container": { + "port": 3000 + } +} diff --git a/examples/file-and-s3-upload/tsconfig.json b/examples/file-and-s3-upload/tsconfig.json new file mode 100644 index 00000000000..20f8a386a6c --- /dev/null +++ b/examples/file-and-s3-upload/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2019"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "node", + "resolveJsonModule": true, + "target": "ES2019", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Remix takes care of building everything in `remix build`. + "noEmit": true + } +}