diff --git a/packages/api/src/routers/svc/pdf_attachments.ts b/packages/api/src/routers/svc/pdf_attachments.ts new file mode 100644 index 000000000..192f781ee --- /dev/null +++ b/packages/api/src/routers/svc/pdf_attachments.ts @@ -0,0 +1,120 @@ +import express from 'express' +import { env } from '../../env' +import * as jwt from 'jsonwebtoken' +import { PageType, UploadFileStatus } from '../../generated/graphql' +import { + generateUploadFilePathName, + generateUploadSignedUrl, + getStorageFileDetails, +} from '../../utils/uploads' +import { initModels } from '../../server' +import { kx } from '../../datalayer/knex_config' +import { analytics } from '../../utils/analytics' + +export function pdfAttachmentsRouter() { + const router = express.Router() + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + router.post('/upload', async (req, res) => { + console.log('pdf-attachments/upload') + + const { email, fileName } = req.body as { + email: string + fileName: string + } + + const token = req?.headers?.authorization + if (!token || !jwt.verify(token, env.server.jwtSecret)) { + return res.status(401).send('UNAUTHORIZED') + } + + const models = initModels(kx, false) + const user = await models.user.getWhere({ email }) + + if (!user) { + return res.status(401).send('UNAUTHORIZED') + } + + analytics.track({ + userId: user.id, + event: 'pdf-attachment-upload', + properties: { + env: env.server.apiEnv, + }, + }) + + const contentType = 'application/pdf' + const uploadFileData = await models.uploadFile.create({ + url: '', + userId: user.id, + fileName: fileName, + status: UploadFileStatus.Initialized, + contentType: contentType, + }) + + if (uploadFileData.id) { + const uploadFilePathName = generateUploadFilePathName( + uploadFileData.id, + fileName + ) + const uploadSignedUrl = await generateUploadSignedUrl( + uploadFilePathName, + contentType + ) + res.send({ + id: uploadFileData.id, + url: uploadSignedUrl, + }) + } else { + res.status(400).send('BAD REQUEST') + } + }) + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + router.post('create-article', async (req, res) => { + console.log('pdf-attachments/create-article') + + const { email, uploadFileId } = req.body as { + email: string + uploadFileId: string + } + + const token = req?.headers?.authorization + if (!token || !jwt.verify(token, env.server.jwtSecret)) { + return res.status(401).send('UNAUTHORIZED') + } + + const models = initModels(kx, false) + const user = await models.user.getWhere({ email }) + + if (!user) { + return res.status(401).send('UNAUTHORIZED') + } + + analytics.track({ + userId: user.id, + event: 'create-article', + properties: { + env: env.server.apiEnv, + }, + }) + + const uploadFile = await models.uploadFile.getWhere({ + id: uploadFileId, + userId: user.id, + }) + if (!uploadFile) { + return res.status(400).send('BAD REQUEST') + } + const uploadFileDetails = await getStorageFileDetails( + uploadFileId, + uploadFile.fileName + ) + const uploadFileHash = uploadFileDetails.md5Hash + const userArticleUrl = uploadFileDetails.fileUrl + const canonicalUrl = uploadFile.url + const pageType = PageType.File + }) + + return router +} diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index c72e0f853..b0352da67 100755 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -37,6 +37,7 @@ import { emailsServiceRouter } from './routers/svc/emails' import ReminderModel from './datalayer/reminders' import { remindersServiceRouter } from './routers/svc/reminders' import { ApolloServer } from 'apollo-server-express' +import { pdfAttachmentsRouter } from './routers/svc/pdf_attachments' const PORT = process.env.PORT || 4000 @@ -97,6 +98,7 @@ export const createApp = (): { app.use('/svc/pubsub/newsletters', newsletterServiceRouter()) app.use('/svc/pubsub/emails', emailsServiceRouter()) app.use('/svc/reminders', remindersServiceRouter()) + app.use('/svc/pdf-attachments', pdfAttachmentsRouter()) if (env.dev.isLocal) { app.use('/local/debug', localDebugRouter()) diff --git a/packages/inbound-email-handler/src/index.ts b/packages/inbound-email-handler/src/index.ts index 121c1194a..57c4a73ac 100644 --- a/packages/inbound-email-handler/src/index.ts +++ b/packages/inbound-email-handler/src/index.ts @@ -13,6 +13,7 @@ import { isNewsletter, } from './newsletter' import { PubSub } from '@google-cloud/pubsub' +import { handlePdfAttachment } from './pdf' const NON_NEWSLETTER_EMAIL_TOPIC = 'nonNewsletterEmailReceived' const pubsub = new PubSub() @@ -23,9 +24,14 @@ export const inboundEmailHandler = Sentry.GCPFunction.wrapHttpFunction( const parsed: Record = {} for (const part of parts) { - const { name, data } = part + const { name, data, type, filename } = part if (name && data) { parsed[name] = data.toString() + } else if (type === 'application/pdf' && data) { + parsed['pdf-attachment-data'] = data.toString() + parsed['pdf-attachment-filename'] = filename + ? filename + : 'attachment.pdf' } else { console.log('no data or name for ', part) } @@ -76,7 +82,15 @@ export const inboundEmailHandler = Sentry.GCPFunction.wrapHttpFunction( if (isConfirmationEmail(from)) { console.log('handleConfirmation', from, recipientAddress) await handleConfirmation(recipientAddress, subject) + } else if (parsed['pdf-attachment']) { + console.log('handle PDF attachment', from, recipientAddress) + await handlePdfAttachment( + recipientAddress, + parsed['pdf-attachment-filename'], + parsed['pdf-attachment-data'] + ) } + // queue non-newsletter emails await pubsub.topic(NON_NEWSLETTER_EMAIL_TOPIC).publishMessage({ json: { diff --git a/packages/inbound-email-handler/src/pdf.ts b/packages/inbound-email-handler/src/pdf.ts new file mode 100644 index 000000000..6cefe4c55 --- /dev/null +++ b/packages/inbound-email-handler/src/pdf.ts @@ -0,0 +1,91 @@ +import axios, { AxiosResponse } from 'axios' +import { promisify } from 'util' +import * as jwt from 'jsonwebtoken' + +const signToken = promisify(jwt.sign) + +type UploadResponse = { + id: string + url: string +} + +export const handlePdfAttachment = async ( + email: string, + fileName: string, + data: string +): Promise => { + const uploadResult = await getUploadIdAndSignedUrl(email, fileName) + await uploadToSignedUrl(uploadResult.url, data) + await createArticle(email, uploadResult.id) +} + +const getUploadIdAndSignedUrl = async ( + email: string, + fileName: string +): Promise => { + if (process.env.JWT_SECRET === undefined) { + throw new Error('JWT_SECRET is not defined') + } + const auth = await signToken(email, process.env.JWT_SECRET) + const data = { + fileName, + email, + } + + if (process.env.REST_BACKEND_ENDPOINT === undefined) { + throw new Error('REST_BACKEND_ENDPOINT is not defined') + } + const response = await axios.post( + `${process.env.REST_BACKEND_ENDPOINT}/svc/pdf-attachments/upload`, + data, + { + headers: { + Authorization: `${auth as string}`, + 'Content-Type': 'application/json', + }, + } + ) + return response.data as UploadResponse +} + +const uploadToSignedUrl = ( + uploadUrl: string, + data: string +): Promise => { + return axios.put(uploadUrl, data, { + headers: { + 'Content-Type': 'application/pdf', + }, + maxBodyLength: 1000000000, + maxContentLength: 100000000, + }) +} + +const createArticle = async ( + email: string, + uploadFileId: string +): Promise => { + const data = { + email, + uploadFileId, + } + + if (process.env.JWT_SECRET === undefined) { + throw new Error('JWT_SECRET is not defined') + } + const auth = await signToken(email, process.env.JWT_SECRET) + + if (process.env.REST_BACKEND_ENDPOINT === undefined) { + throw new Error('REST_BACKEND_ENDPOINT is not defined') + } + return axios.post( + `${process.env.REST_BACKEND_ENDPOINT}/svc/pdf-attachments/create-article`, + data, + { + headers: { + Authorization: `${auth as string}`, + 'Content-Type': 'application/json', + }, + } + ) +}