Skip to content

Commit

Permalink
Implement s3 integration
Browse files Browse the repository at this point in the history
  • Loading branch information
jotjern committed Feb 19, 2024
1 parent 8ecc53b commit 787f44b
Show file tree
Hide file tree
Showing 13 changed files with 441 additions and 32 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ jobs:
NEXT_PUBLIC_AUTH_CLIENT_ID: ${{ secrets.NEXT_PUBLIC_AUTH_CLIENT_ID }}
NEXT_PUBLIC_OW4_ADDRESS: ${{ secrets.NEXT_PUBLIC_OW4_ADDRESS }}
NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN }}
NEXT_AWS_REGION: ${{ secrets.NEXT_AWS_REGION }}
NEXT_AWS_S3_BUCKET_NAME: ${{ secrets.NEXT_AWS_S3_BUCKET_NAME }}
- name: Trigger deploy.sh remotely
uses: appleboy/ssh-action@master
with:
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
"version": "2.2.1",
"private": true,
"dependencies": {
"@dotkomonline/design-system": "^0.22.0",
"@dotkomonline/design-system": "^0.22.2",
"@reduxjs/toolkit": "^1.4.0",
"@sentry/browser": "^5.0.3",
"@sentry/node": "^5.4.3",
"@types/aws-sdk": "^2.7.0",
"@types/file-saver": "^2.0.0",
"@types/get-stream": "^3.0.2",
"@types/jsdom": "^16.2.4",
Expand All @@ -19,6 +20,7 @@
"@types/react-dom": "16.9.8",
"@types/react-redux": "^7.1.0",
"@types/styled-components": "^5.1.4",
"aws-sdk": "^2.1560.0",
"core-js": "^3.0.1",
"file-saver": "^2.0.1",
"get-stream": "^6.0.1",
Expand Down
1 change: 1 addition & 0 deletions src/constants/backend.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const LAMBDA_ENDPOINT = '/api/generate-receipt';
export const LAMBDA_PRESIGN_UPLOAD_ENDPOINT = '/api/presign-upload-url';

export const OW4_ADDRESS = process.env.NEXT_PUBLIC_OW4_ADDRESS || 'https://online.ntnu.no';
14 changes: 10 additions & 4 deletions src/form/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { ApiBodyError } from './../lambda/errors';
import { Group } from 'models/groups';
import { readDataUrlAsFile2 } from 'utils/readDataUrlAsFile';
import { readFileAsDataUrl } from 'utils/readFileAsDataUrl';
import { uploadFile } from "../utils/uploadFile";
import { downloadFileFromS3Bucket } from "../utils/downloadFileFromS3Bucket";

export type ReceiptType = 'card' | 'deposit';
export type SendMode = 'download' | 'email' | 'teapot';
Expand Down Expand Up @@ -56,7 +58,9 @@ export interface IDeserializedState {
}

export const deserializeReceipt = async (state: IState): Promise<IDeserializedState> => {
const attachments = await Promise.all(state.attachments.map(async (file) => readFileAsDataUrl(file)));
const attachments = await Promise.all(
state.attachments.map(async (file) => uploadFile(file))
);
const signature = await readFileAsDataUrl(state.signature || new File([], 'newfile'));
return {
...state,
Expand All @@ -67,9 +71,11 @@ export const deserializeReceipt = async (state: IState): Promise<IDeserializedSt

export const serializeReceipt = async (deserializedState: IDeserializedState): Promise<IState> => {
try {
const attachments = await Promise.all(
deserializedState.attachments.map(async (dataUrl) => readDataUrlAsFile2(dataUrl))
);
const illegalAttachment = deserializedState.attachments.find((attachment) => !attachment.startsWith("uploads/"));
if (illegalAttachment) {
throw new TypeError('Illegal attachment');
}
const attachments = await Promise.all(deserializedState.attachments.map(downloadFileFromS3Bucket));
const signature = await readDataUrlAsFile2(deserializedState.signature);
return {
...deserializedState,
Expand Down
11 changes: 8 additions & 3 deletions src/lambda/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { readFileAsDataUrl } from 'utils/readFileAsDataUrl';
import { sendEmail } from './sendEmail';
import { ApiBodyError, ApiValidationError } from './errors';
import { pdfGenerator } from './browserlessGenerator';
import { uploadFileToS3Bucket } from "../utils/uploadFileToS3Bucket";

export interface SuccessBody {
message: string;
Expand Down Expand Up @@ -48,12 +49,16 @@ export const generateReceipt = async (data: IDeserializedState | null): Promise<
}
const validState = state as NonNullableState;
const pdf = await pdfGenerator(validState);
const pdfFile = new File([pdf], 'receipt.pdf', { type: 'application/pdf' });
const pdfString = await readFileAsDataUrl(pdfFile);

if (state.mode === 'download') {
return DOWNLOAD_SUCCESS_MESSAGE(pdfString);
const dato = `${new Date().toISOString().split('T')[0]}`;
const filename = `kvittering-${dato}-${+Date.now()}.pdf`;
const downloadUrl = await uploadFileToS3Bucket(pdf, `receipts/${filename}`);
return DOWNLOAD_SUCCESS_MESSAGE(downloadUrl);
} else if (state.mode === 'email') {
const pdfFile = new File([pdf], 'receipt.pdf', { type: 'application/pdf' });
const pdfString = await readFileAsDataUrl(pdfFile);

await sendEmail(pdfString, state);
return EMAIL_SUCCESS_MESSAGE;
} else if (state.mode === 'teapot') {
Expand Down
27 changes: 27 additions & 0 deletions src/pages/api/presign-upload-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { NextApiRequest, NextApiResponse, PageConfig } from 'next';

import { SuccessBody } from 'lambda/handler';
import { sentryMiddleware } from 'lambda/sentry';
import { ErrorData } from 'lambda/errors';
import { getPresignedS3URL } from "../../utils/getPresignedS3URL";

const handler = async (req: NextApiRequest, res: NextApiResponse<SuccessBody | ErrorData>) => {
const { filename, contentType } = req.body;

try {
const data = await getPresignedS3URL(filename, contentType);
res.status(200).json({ message: "Presigned URL", data: JSON.stringify(data) });
} catch (error) {
res.status(500).json({ message: "Failed to get presigned URL", data: error });
}
};

export const config: PageConfig = {
api: {
bodyParser: {
sizeLimit: '25mb',
},
},
};

export default sentryMiddleware(handler);
1 change: 1 addition & 0 deletions src/redux/actions/authActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export const catchCallbackAction = createAsyncThunk('user/catchCallback', async
window.location.hash = '';
} catch (err) {
/** Do nothing if no user data is present */
window.location.hash = '';
return;
}
});
13 changes: 7 additions & 6 deletions src/redux/actions/submitActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';

import { NonNullableState } from 'lambda/generatePDF';
import { getFileName } from 'lambda/tools/format';
import { downloadFile } from 'utils/download';
import { postReceipt } from 'utils/postReceipt';
import { readDataUrlAsFile } from 'utils/readDataUrlAsFile';
import { downloadFinished, downloadStarted, loadingDone, setResponse } from 'redux/reducers/statusReducer';
import { State } from 'redux/store';
import { SuccessBody } from 'lambda/handler';
Expand All @@ -14,10 +12,13 @@ const handleDownload = async (response: SuccessBody, state: NonNullableState) =>
if (response.data) {
/** Use the same filename that would be generated when sending a mail */
const fileName = getFileName(state);
const pdfFile = await readDataUrlAsFile(response.data, fileName);
if (pdfFile) {
downloadFile(pdfFile);
}
// response.data is a URL to the file

const a = document.createElement('a');
document.body.appendChild(a);
a.href = response.data;
a.download = fileName;
a.click();
}
};

Expand Down
24 changes: 24 additions & 0 deletions src/utils/downloadFileFromS3Bucket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import AWS from "aws-sdk";

AWS.config.update({
region: process.env.NEXT_AWS_REGION,
credentials: {
accessKeyId: process.env.NEXT_AWS_ACCESS_KEY_ID as string,
secretAccessKey: process.env.NEXT_AWS_SECRET_ACCESS_KEY as string,
},
})

export async function downloadFileFromS3Bucket(key: string): Promise<File> {
const s3 = new AWS.S3({
apiVersion: '2006-03-01',
});

const params = {
Bucket: process.env.NEXT_AWS_S3_BUCKET_NAME as string,
Key: key,
};

const data = await s3.getObject(params).promise();

return new File([data.Body as Blob], key, { type: data.ContentType });
}
32 changes: 32 additions & 0 deletions src/utils/getPresignedS3URL.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import AWS from "aws-sdk";

if (process.env.NEXT_AWS_ACCESS_KEY_ID === undefined) {
throw new Error('NEXT_AWS_ACCESS_KEY_ID is not defined')
}

if (process.env.NEXT_AWS_SECRET_ACCESS_KEY === undefined) {
throw new Error('NEXT_AWS_SECRET_ACCESS_KEY is not defined')
}

AWS.config.update({
region: process.env.NEXT_AWS_REGION,
credentials: {
accessKeyId: process.env.NEXT_AWS_ACCESS_KEY_ID as string,
secretAccessKey: process.env.NEXT_AWS_SECRET_ACCESS_KEY as string,
},
})

export const getPresignedS3URL = async (name: string, contentType: string) => {
const s3 = new AWS.S3({
apiVersion: '2006-03-01',
params: { Bucket: process.env.NEXT_AWS_S3_BUCKET_NAME },
})

const params = {
Bucket: process.env.NEXT_AWS_S3_BUCKET_NAME,
Key: `uploads/${+new Date()}-${name}`,
ContentType: contentType,
}

return { url: await s3.getSignedUrlPromise('putObject', params), key: params.Key }
}
44 changes: 44 additions & 0 deletions src/utils/uploadFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { LAMBDA_PRESIGN_UPLOAD_ENDPOINT } from "constants/backend";
import { SuccessBody } from 'lambda/handler';

export const uploadFile = async (file: File): Promise<string> => {
if (!LAMBDA_PRESIGN_UPLOAD_ENDPOINT) {
throw new Error('LAMBDA_PRESIGN_UPLOAD_ENDPOINT is not set');
}
const response = await fetch(LAMBDA_PRESIGN_UPLOAD_ENDPOINT, {
body: JSON.stringify({ filename: file.name, contentType: file.type }),
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
});

const body = await response.json();

if (!response.ok) {
throw new Error('Failed to get presigned URL');
}

const { data } = body as SuccessBody;

if (!data) {
throw new Error('Failed to get presigned URL');
}

const { url, key } = JSON.parse(data);

const uploadResponse = await fetch(url, {
body: file,
method: 'PUT',
headers: {
'Content-Type': file.type,
},
});

if (!uploadResponse.ok) {
throw new Error('Failed to upload file');
}

return key;
};
28 changes: 28 additions & 0 deletions src/utils/uploadFileToS3Bucket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import AWS from "aws-sdk";

AWS.config.update({
region: process.env.NEXT_AWS_REGION,
credentials: {
accessKeyId: process.env.NEXT_AWS_ACCESS_KEY_ID as string,
secretAccessKey: process.env.NEXT_AWS_SECRET_ACCESS_KEY as string,
},
})

// upload file to S3 bucket and make it publicly downloadable
export async function uploadFileToS3Bucket(file: Uint8Array, key: string): Promise<string> {
const s3 = new AWS.S3({
apiVersion: '2006-03-01',
params: { Bucket: process.env.NEXT_AWS_S3_BUCKET_NAME as string },
});

const params = {
Bucket: process.env.NEXT_AWS_S3_BUCKET_NAME as string,
Key: key,
Body: file,
ACL: 'public-read',
};

const result = await s3.upload(params).promise();

return result.Location;
}
Loading

0 comments on commit 787f44b

Please sign in to comment.