Enhanced file uploading for production (thanks citrinitas3421!)

This commit is contained in:
Ahmed Al-Taiar
2024-08-17 22:27:20 -04:00
parent a82caf96bf
commit 1c46a8e963
15 changed files with 320 additions and 51 deletions

View File

@ -20,10 +20,15 @@ NAME=Ahmed Al-Taiar
GMAIL=example@gmail.com GMAIL=example@gmail.com
GMAIL_SMTP_PASSWORD=chan geme xyza bcde GMAIL_SMTP_PASSWORD=chan geme xyza bcde
DOMAIN=example.com
API_DOMAIN=api.example.com
# Must not end with "/" # Must not end with "/"
DOMAIN_PROD=https://example.com ADDRESS_PROD=https://example.com
DOMAIN_DEV=http://localhost:8910 ADDRESS_DEV=http://localhost:8910
API_ADDRESS_PROD=https://api.example.com
API_ADDRESS_DEV=http://localhost:8911
MAX_HTTP_CONNECTIONS_PER_MINUTE=60
DATABASE_URL=postgresql://user:password@localhost:5432/rw_portfolio DATABASE_URL=postgresql://user:password@localhost:5432/rw_portfolio
SESSION_SECRET=regenerate_me

View File

@ -8,10 +8,15 @@ NAME=Firstname Lastname
GMAIL=example@gmail.com GMAIL=example@gmail.com
GMAIL_SMTP_PASSWORD=chan geme xyza bcde GMAIL_SMTP_PASSWORD=chan geme xyza bcde
DOMAIN=example.com
API_DOMAIN=api.example.com
# Must not end with "/" # Must not end with "/"
DOMAIN_PROD=https://example.com ADDRESS_PROD=https://example.com
DOMAIN_DEV=http://localhost:8910 ADDRESS_DEV=http://localhost:8910
API_ADDRESS_PROD=https://api.example.com
API_ADDRESS_DEV=http://localhost:8911
MAX_HTTP_CONNECTIONS_PER_MINUTE=60
DATABASE_URL=postgresql://user:password@localhost:5432/rw_portfolio DATABASE_URL=postgresql://user:password@localhost:5432/rw_portfolio
SESSION_SECRET=regenerate_me

2
.gitignore vendored
View File

@ -22,4 +22,4 @@ api/src/lib/generateGraphiQLHeader.*
!.yarn/releases !.yarn/releases
!.yarn/sdks !.yarn/sdks
!.yarn/versions !.yarn/versions
files files_*

View File

@ -3,6 +3,8 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@fastify/cors": "^9.0.1",
"@fastify/rate-limit": "^9.1.0",
"@redwoodjs/api": "7.7.4", "@redwoodjs/api": "7.7.4",
"@redwoodjs/api-server": "7.7.4", "@redwoodjs/api-server": "7.7.4",
"@redwoodjs/auth-dbauth-api": "7.7.4", "@redwoodjs/auth-dbauth-api": "7.7.4",

7
api/quick-lint-js.config Normal file
View File

@ -0,0 +1,7 @@
{
"globals": {
"context": {
"writable": false
}
}
}

View File

@ -1,5 +1,6 @@
import type { APIGatewayProxyEvent, Context } from 'aws-lambda' import type { APIGatewayProxyEvent, Context } from 'aws-lambda'
import { isProduction } from '@redwoodjs/api/dist/logger'
import { import {
DbAuthHandler, DbAuthHandler,
PasswordValidationError, PasswordValidationError,
@ -16,11 +17,9 @@ export const handler = async (
) => { ) => {
const forgotPasswordOptions: DbAuthHandlerOptions['forgotPassword'] = { const forgotPasswordOptions: DbAuthHandlerOptions['forgotPassword'] = {
handler: async (user, resetToken) => { handler: async (user, resetToken) => {
const domain = const domain = isProduction
(process.env.NODE_ENV || 'development') == 'production' ? process.env.ADDRESS_PROD
? process.env.DOMAIN_PROD : process.env.ADDRESS_DEV
: process.env.DOMAIN_DEV
const text = `If this wasn't you, please disregard this email. const text = `If this wasn't you, please disregard this email.
Hello, Hello,
@ -76,9 +75,7 @@ ${domain}/reset-password?resetToken=${resetToken}
// didn't validate their email yet), throw an error and it will be returned // didn't validate their email yet), throw an error and it will be returned
// by the `logIn()` function from `useAuth()` in the form of: // by the `logIn()` function from `useAuth()` in the form of:
// `{ message: 'Error message' }` // `{ message: 'Error message' }`
handler: (user) => { handler: (user) => user,
return user
},
errors: { errors: {
usernameOrPasswordMissing: 'Both username and password are required', usernameOrPasswordMissing: 'Both username and password are required',
@ -199,6 +196,14 @@ ${domain}/reset-password?resetToken=${resetToken}
resetTokenExpiresAt: 'resetTokenExpiresAt', resetTokenExpiresAt: 'resetTokenExpiresAt',
}, },
cors: {
origin: isProduction
? [process.env.ADDRESS_PROD, process.env.API_ADDRESS_PROD]
: [process.env.ADDRESS_DEV, process.env.API_ADDRESS_DEV],
credentials: true,
methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'DELETE', 'HEAD', 'PATCH'],
},
// A list of fields on your user object that are safe to return to the // A list of fields on your user object that are safe to return to the
// client when invoking a handler that returns a user (like forgotPassword // client when invoking a handler that returns a user (like forgotPassword
// and signup). This list should be as small as possible to be sure not to // and signup). This list should be as small as possible to be sure not to
@ -211,12 +216,10 @@ ${domain}/reset-password?resetToken=${resetToken}
attributes: { attributes: {
HttpOnly: true, HttpOnly: true,
Path: '/', Path: '/',
SameSite: 'Strict', SameSite: isProduction ? 'None' : 'Strict',
Secure: process.env.NODE_ENV !== 'development', Secure: isProduction,
Domain: isProduction ? 'localhost' : 'localhost',
// If you need to allow other domains (besides the api side) access to // Domain: isProduction ? process.env.DOMAIN : 'localhost',
// the dbAuth session cookie:
// Domain: 'example.com',
}, },
name: cookieName, name: cookieName,
}, },

View File

@ -1,5 +1,10 @@
import type { Decoded } from '@redwoodjs/api' import type { Decoded } from '@redwoodjs/api'
import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server' import { decryptSession, getSession } from '@redwoodjs/auth-dbauth-api'
import {
AuthenticationError,
ForbiddenError,
ValidationError,
} from '@redwoodjs/graphql-server'
import { db } from './db' import { db } from './db'
@ -12,6 +17,10 @@ import { db } from './db'
*/ */
export const cookieName = 'session_%port%' export const cookieName = 'session_%port%'
const cookieRegex = /([a-zA-Z0-9+/|=]{110})\w+/
const tokenRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/** /**
* The session object sent in as the first argument to getCurrentUser() will * The session object sent in as the first argument to getCurrentUser() will
* have a single key `id` containing the unique ID of the logged in user * have a single key `id` containing the unique ID of the logged in user
@ -30,9 +39,8 @@ export const cookieName = 'session_%port%'
* seen if someone were to open the Web Inspector in their browser. * seen if someone were to open the Web Inspector in their browser.
*/ */
export const getCurrentUser = async (session: Decoded) => { export const getCurrentUser = async (session: Decoded) => {
if (!session || typeof session.id !== 'number') { if (!session || typeof session.id !== 'number')
throw new Error('Invalid session') throw new Error('Invalid session')
}
return await db.user.findUnique({ return await db.user.findUnique({
where: { id: session.id }, where: { id: session.id },
@ -107,11 +115,29 @@ export const hasRole = (roles: AllowedRoles): boolean => {
* @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples * @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples
*/ */
export const requireAuth = ({ roles }: { roles?: AllowedRoles } = {}) => { export const requireAuth = ({ roles }: { roles?: AllowedRoles } = {}) => {
if (!isAuthenticated()) { if (!isAuthenticated())
throw new AuthenticationError("You don't have permission to do that.") throw new AuthenticationError("You don't have permission to do that.")
}
if (roles && !hasRole(roles)) { if (roles && !hasRole(roles))
throw new ForbiddenError("You don't have access to do that.") throw new ForbiddenError("You don't have access to do that.")
} }
export const validateSessionCookie = (sessionCookie: string) => {
const sessionCookieContent = sessionCookie.substring(
sessionCookie.indexOf('=') + 1
)
if (!cookieRegex.test(sessionCookieContent))
throw new ValidationError('Invalid token format')
}
export const decryptAndValidateSession = (sessionCookie: string) => {
const cookie = cookieName.replace('%port%', '8911')
const [session, csrfToken] = decryptSession(getSession(sessionCookie, cookie))
if (!session.id) throw new ValidationError('Invalid session')
if (!tokenRegex.test(csrfToken))
throw new ValidationError('Invalid session format')
return session.id
} }

19
api/src/lib/cors.ts Normal file
View File

@ -0,0 +1,19 @@
import type { FastifyReply } from 'fastify'
import { isProduction } from '@redwoodjs/api/dist/logger'
export const setCorsHeaders = (res: FastifyReply) => {
res.raw.setHeader(
'Access-Control-Allow-Origin',
isProduction ? process.env.ADDRESS_PROD : process.env.ADDRESS_DEV
)
res.raw.setHeader(
'Access-Control-Allow-Methods',
'GET, POST, OPTIONS, PATCH, HEAD'
)
res.raw.setHeader(
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept, Authorization, Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset'
)
res.raw.setHeader('Access-Control-Allow-Credentials', 'true')
}

View File

@ -15,7 +15,7 @@ const transporter = nodemailer.createTransport({
}, },
}) })
export async function sendEmail({ to, subject, text, html }: Options) { export const sendEmail = async ({ to, subject, text, html }: Options) => {
return await transporter.sendMail({ return await transporter.sendMail({
from: `"${process.env.NAME} (noreply)" <${process.env.GMAIL}>`, from: `"${process.env.NAME} (noreply)" <${process.env.GMAIL}>`,
to: Array.isArray(to) ? to : [to], to: Array.isArray(to) ? to : [to],
@ -25,7 +25,7 @@ export async function sendEmail({ to, subject, text, html }: Options) {
}) })
} }
export function censorEmail(email: string): string { export const censorEmail = (email: string): string => {
const [localPart, domain] = email.split('@') const [localPart, domain] = email.split('@')
if (localPart.length <= 2) return `${localPart}@${domain}` if (localPart.length <= 2) return `${localPart}@${domain}`

106
api/src/lib/tus.ts Normal file
View File

@ -0,0 +1,106 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Server } from '@tus/server'
import type { FastifyReply, FastifyRequest } from 'fastify'
import { isProduction } from '@redwoodjs/api/dist/logger'
import { ValidationError } from '@redwoodjs/graphql-server'
import { decryptAndValidateSession, validateSessionCookie } from 'src/lib/auth'
import { setCorsHeaders } from 'src/lib/cors'
import { db } from 'src/lib/db'
interface User {
id: number
username: string
email: string
hashedPassword: string
salt: string
resetToken: string | null
resetTokenExpiresAt: Date | null
}
export const handleTusUpload = (
req: FastifyRequest,
res: FastifyReply,
tusHandler: Server,
isPublicEndpoint: boolean
) => {
if (isProduction) {
if (req.method === 'OPTIONS') handleOptionsRequest(res)
else if (isPublicEndpoint && req.method === 'GET')
tusHandler.handle(req.raw, res.raw)
else if (['GET', 'POST', 'HEAD', 'PATCH'].includes(req.method)) {
if (req.headers.cookie) handleAuthenticatedRequest(req, res, tusHandler)
else {
res.raw.statusCode = 401
res.raw.end('Unauthenticated')
}
} else {
res.raw.statusCode = 405
res.raw.end('Method not allowed')
}
} else tusHandler.handle(req.raw, res.raw)
}
const handleAuthenticatedRequest = async (
req: FastifyRequest,
res: FastifyReply,
tusHandler: Server
) => {
try {
const sessionCookie = extractSessionCookie(req.headers.cookie)
validateSessionCookie(sessionCookie)
const userId = decryptAndValidateSession(sessionCookie)
if (userId) {
res.raw.setHeader('Access-Control-Allow-Credentials', 'true')
try {
const user = await db.user.findUnique({
where: {
id: userId,
},
})
addUserMetadataToRequest(req, user)
if ((req.raw as any).userId) tusHandler.handle(req.raw, res.raw)
else {
res.raw.statusCode = 500
res.raw.end('Server error')
}
} catch (error) {
res.raw.statusCode = 500
res.raw.end('Server error')
}
} else {
res.raw.statusCode = 403
res.raw.end('Forbidden')
}
} catch (error) {
res.raw.statusCode = 401
res.raw.end('Unauthenticated')
}
}
const addUserMetadataToRequest = (req: FastifyRequest, user: User) => {
;(req.raw as any).userId = user.id
;(req.raw as any).userEmail = user.email
}
const handleOptionsRequest = (res: FastifyReply) => {
setCorsHeaders(res)
res.raw.statusCode = 204
res.raw.end()
}
const extractSessionCookie = (cookie: string) => {
const sessionCookie = cookie
.split(';')
.find((item) => item.trim().startsWith('session_8911'))
?.trim()
if (!sessionCookie) throw new ValidationError('Invalid token')
return sessionCookie
}

View File

@ -1,17 +1,40 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import Cors from '@fastify/cors'
import RateLimit from '@fastify/rate-limit'
import { FileStore } from '@tus/file-store' import { FileStore } from '@tus/file-store'
import { Server } from '@tus/server' import { Server } from '@tus/server'
import { isProduction } from '@redwoodjs/api/dist/logger'
import { createServer } from '@redwoodjs/api-server' import { createServer } from '@redwoodjs/api-server'
import { logger } from 'src/lib/logger' import { logger } from 'src/lib/logger'
import { handleTusUpload } from 'src/lib/tus'
;(async () => { ;(async () => {
const server = await createServer({ const server = await createServer({
logger, logger,
configureApiServer: async (server) => {
await server.register(Cors, {
origin: isProduction
? [process.env.ADDRESS_PROD, process.env.API_ADDRESS_PROD]
: [process.env.ADDRESS_DEV, process.env.API_ADDRESS_DEV],
methods: ['GET', 'POST', 'OPTIONS', 'PATCH', 'HEAD'],
credentials: isProduction ? true : false,
})
await server.register(RateLimit, {
max: Number(process.env.MAX_HTTP_CONNECTIONS_PER_MINUTE),
timeWindow: `1 minute`,
})
},
}) })
const tusServer = new Server({ const tusServer = new Server({
path: '/files', path: '/files',
datastore: new FileStore({ directory: './files' }), respectForwardedHeaders: true,
datastore: new FileStore({
directory: `./files_${isProduction ? 'prod' : 'dev'}`,
}),
onResponseError: (_req, res, _err) => logger.error(res),
}) })
server.addContentTypeParser( server.addContentTypeParser(
@ -19,15 +42,12 @@ import { logger } from 'src/lib/logger'
(_request, _payload, done) => done(null) (_request, _payload, done) => done(null)
) )
server.all('/files', (req, res) => { server.all('/files', (req, res) =>
tusServer.handle(req.raw, res.raw) handleTusUpload(req, res, tusServer, false)
return res )
}) server.all('/files/*', (req, res) =>
handleTusUpload(req, res, tusServer, true)
server.all('/files/*', (req, res) => { )
tusServer.handle(req.raw, res.raw)
return res
})
await server.start() await server.start()
})() })()

View File

@ -9,7 +9,7 @@
title = "${NAME}" title = "${NAME}"
port = 8910 port = 8910
apiUrl = "/api" apiUrl = "/api"
includeEnvironmentVariables = ["NAME"] includeEnvironmentVariables = ["NAME", "API_ADDRESS_PROD", "API_ADDRESS_DEV"]
[generate] [generate]
tests = false tests = false
stories = false stories = false

View File

@ -2,26 +2,62 @@ import { useState } from 'react'
import Compressor from '@uppy/compressor' import Compressor from '@uppy/compressor'
import Uppy from '@uppy/core' import Uppy from '@uppy/core'
import type { UploadResult, Meta } from '@uppy/core'
import ImageEditor from '@uppy/image-editor' import ImageEditor from '@uppy/image-editor'
import { Dashboard } from '@uppy/react' import { Dashboard } from '@uppy/react'
import Tus from '@uppy/tus' import Tus from '@uppy/tus'
import { isProduction } from '@redwoodjs/api/dist/logger'
import '@uppy/image-editor/dist/style.min.css' import '@uppy/image-editor/dist/style.min.css'
import '@uppy/core/dist/style.min.css' import '@uppy/core/dist/style.min.css'
import '@uppy/dashboard/dist/style.min.css' import '@uppy/dashboard/dist/style.min.css'
const Uploader = () => { interface Props {
const [uppy] = useState(() => onComplete?(result: UploadResult<Meta, Record<string, never>>): void
new Uppy({ }
const apiDomain = isProduction
? process.env.API_ADDRESS_PROD
: process.env.API_ADDRESS_DEV
const Uploader = ({ onComplete }: Props) => {
const [uppy] = useState(() => {
const instance = new Uppy({
restrictions: { restrictions: {
allowedFileTypes: ['image/*'], allowedFileTypes: [
'image/webp',
'image/png',
'image/jpg',
'image/jpeg',
],
maxNumberOfFiles: 10, maxNumberOfFiles: 10,
maxFileSize: 25 * 1024 * 1024,
},
onBeforeUpload: (files) => {
for (const [key, file] of Object.entries(files)) {
instance.setFileMeta(key, {
name: new Date().getTime().toString(),
type: file.type,
contentType: file.type,
})
}
return true
}, },
}) })
.use(Tus, { endpoint: 'http://localhost:8911/files' }) // TODO: check if env is production and change endpoint accordingly .use(Tus, {
endpoint: `${apiDomain}/files`,
withCredentials: true,
removeFingerprintOnSuccess: true,
})
.use(ImageEditor) .use(ImageEditor)
.use(Compressor) .use(Compressor, {
) mimeType: 'image/webp',
})
return instance.on('complete', onComplete)
})
return <Dashboard uppy={uppy} proudlyDisplayPoweredByUppy={false} /> return <Dashboard uppy={uppy} proudlyDisplayPoweredByUppy={false} />
} }

View File

@ -5,6 +5,7 @@ import Uploader from 'src/components/Uploader/Uploader'
const HomePage = () => { const HomePage = () => {
const { isAuthenticated } = useAuth() const { isAuthenticated } = useAuth()
return ( return (
<> <>
<Metadata title="Home" description="Home page" /> <Metadata title="Home" description="Home page" />

View File

@ -2419,6 +2419,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@fastify/cors@npm:^9.0.1":
version: 9.0.1
resolution: "@fastify/cors@npm:9.0.1"
dependencies:
fastify-plugin: "npm:^4.0.0"
mnemonist: "npm:0.39.6"
checksum: 10c0/4db9d3d02edbca741c8ed053819bf3b235ecd70e07c640ed91ba0fc1ee2dc8abedbbffeb79ae1a38ccbf59832e414cad90a554ee44227d0811d5a2d062940611
languageName: node
linkType: hard
"@fastify/error@npm:^3.0.0, @fastify/error@npm:^3.3.0, @fastify/error@npm:^3.4.0": "@fastify/error@npm:^3.0.0, @fastify/error@npm:^3.3.0, @fastify/error@npm:^3.4.0":
version: 3.4.1 version: 3.4.1
resolution: "@fastify/error@npm:3.4.1" resolution: "@fastify/error@npm:3.4.1"
@ -2456,6 +2466,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@fastify/rate-limit@npm:^9.1.0":
version: 9.1.0
resolution: "@fastify/rate-limit@npm:9.1.0"
dependencies:
"@lukeed/ms": "npm:^2.0.1"
fastify-plugin: "npm:^4.0.0"
toad-cache: "npm:^3.3.1"
checksum: 10c0/55914e4af5d93fff29e94ad4e3f369a371a3e7b97e501e796167dd49115c5d8dfa94aec8ec78ffcdb058268465b072a0892880523774b7b90a27a8ff304fea82
languageName: node
linkType: hard
"@fastify/reply-from@npm:^9.0.0": "@fastify/reply-from@npm:^9.0.0":
version: 9.8.0 version: 9.8.0
resolution: "@fastify/reply-from@npm:9.8.0" resolution: "@fastify/reply-from@npm:9.8.0"
@ -7254,6 +7275,8 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "api@workspace:api" resolution: "api@workspace:api"
dependencies: dependencies:
"@fastify/cors": "npm:^9.0.1"
"@fastify/rate-limit": "npm:^9.1.0"
"@redwoodjs/api": "npm:7.7.4" "@redwoodjs/api": "npm:7.7.4"
"@redwoodjs/api-server": "npm:7.7.4" "@redwoodjs/api-server": "npm:7.7.4"
"@redwoodjs/auth-dbauth-api": "npm:7.7.4" "@redwoodjs/auth-dbauth-api": "npm:7.7.4"
@ -16052,6 +16075,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"mnemonist@npm:0.39.6":
version: 0.39.6
resolution: "mnemonist@npm:0.39.6"
dependencies:
obliterator: "npm:^2.0.1"
checksum: 10c0/a538945ea547976136ee6e16f224c0a50983143619941f6c4d2c82159e36eb6f8ee93d69d3a1267038fc5b16f88e2d43390023de10dfb145fa15c5e2befa1cdf
languageName: node
linkType: hard
"module-not-found-error@npm:^1.0.1": "module-not-found-error@npm:^1.0.1":
version: 1.0.1 version: 1.0.1
resolution: "module-not-found-error@npm:1.0.1" resolution: "module-not-found-error@npm:1.0.1"
@ -16620,6 +16652,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"obliterator@npm:^2.0.1":
version: 2.0.4
resolution: "obliterator@npm:2.0.4"
checksum: 10c0/ff2c10d4de7d62cd1d588b4d18dfc42f246c9e3a259f60d5716f7f88e5b3a3f79856b3207db96ec9a836a01d0958a21c15afa62a3f4e73a1e0b75f2c2f6bab40
languageName: node
linkType: hard
"obuf@npm:^1.0.0, obuf@npm:^1.1.2": "obuf@npm:^1.0.0, obuf@npm:^1.1.2":
version: 1.1.2 version: 1.1.2
resolution: "obuf@npm:1.1.2" resolution: "obuf@npm:1.1.2"
@ -20632,7 +20671,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"toad-cache@npm:^3.3.0, toad-cache@npm:^3.7.0": "toad-cache@npm:^3.3.0, toad-cache@npm:^3.3.1, toad-cache@npm:^3.7.0":
version: 3.7.0 version: 3.7.0
resolution: "toad-cache@npm:3.7.0" resolution: "toad-cache@npm:3.7.0"
checksum: 10c0/7dae2782ee20b22c9798bb8b71dec7ec6a0091021d2ea9dd6e8afccab6b65b358fdba49a02209fac574499702e2c000660721516c87c2538d1b2c0ba03e8c0c3 checksum: 10c0/7dae2782ee20b22c9798bb8b71dec7ec6a0091021d2ea9dd6e8afccab6b65b358fdba49a02209fac574499702e2c000660721516c87c2538d1b2c0ba03e8c0c3