Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement s3 integration #141

Merged
merged 9 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
4 changes: 2 additions & 2 deletions src/form/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ export const ACCOUNT_NUMBER_REGEX = new RegExp(/^\d{4} \d{2} \d{5}$/);
export const COMMITTEE_EMAIL_REGEX = new RegExp(/^.{2,50}@online\.ntnu\.no$/);
export const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9.!#$%&’*+/=?^_{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/);
export const CARD_DETAIL_REGEX = new RegExp(/^.{5,30}$/);
export const FILE_SIZE_WARN = 7 * 1024 * 1024; // 7 MB
export const FILE_SIZE_MAX = 9 * 1024 * 1024; // 9 MB
export const FILE_SIZE_WARN = 40 * 1024 * 1024; // 40 MB
export const FILE_SIZE_MAX = 50 * 1024 * 1024; // 50 MB
jotjern marked this conversation as resolved.
Show resolved Hide resolved

export const STATE_VALIDATION: StateValidators = {
fullname: [
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
28 changes: 28 additions & 0 deletions src/utils/downloadFileFromS3Bucket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import AWS from "aws-sdk";

const credentials = (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ?
{
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
} :
undefined;

AWS.config.update({
region: process.env.AWS_REGION,
credentials,
})

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

const params = {
Bucket: process.env.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 });
}
28 changes: 28 additions & 0 deletions src/utils/getPresignedS3URL.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import AWS from "aws-sdk";

const credentials = (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ?
{
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
} :
undefined;

AWS.config.update({
region: process.env.AWS_REGION,
credentials,
})

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

const params = {
Bucket: process.env.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;
};
32 changes: 32 additions & 0 deletions src/utils/uploadFileToS3Bucket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import AWS from "aws-sdk";

const credentials = (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ?
{
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
} :
undefined;

AWS.config.update({
region: process.env.AWS_REGION,
credentials,
})

// 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.AWS_S3_BUCKET_NAME as string },
});

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

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

return result.Location;
}
Loading
Loading