Enhanced file uploading for production (thanks citrinitas3421!)
This commit is contained in:
@ -1,5 +1,6 @@
|
||||
import type { APIGatewayProxyEvent, Context } from 'aws-lambda'
|
||||
|
||||
import { isProduction } from '@redwoodjs/api/dist/logger'
|
||||
import {
|
||||
DbAuthHandler,
|
||||
PasswordValidationError,
|
||||
@ -16,11 +17,9 @@ export const handler = async (
|
||||
) => {
|
||||
const forgotPasswordOptions: DbAuthHandlerOptions['forgotPassword'] = {
|
||||
handler: async (user, resetToken) => {
|
||||
const domain =
|
||||
(process.env.NODE_ENV || 'development') == 'production'
|
||||
? process.env.DOMAIN_PROD
|
||||
: process.env.DOMAIN_DEV
|
||||
|
||||
const domain = isProduction
|
||||
? process.env.ADDRESS_PROD
|
||||
: process.env.ADDRESS_DEV
|
||||
const text = `If this wasn't you, please disregard this email.
|
||||
|
||||
Hello,
|
||||
@ -76,9 +75,7 @@ ${domain}/reset-password?resetToken=${resetToken}
|
||||
// didn't validate their email yet), throw an error and it will be returned
|
||||
// by the `logIn()` function from `useAuth()` in the form of:
|
||||
// `{ message: 'Error message' }`
|
||||
handler: (user) => {
|
||||
return user
|
||||
},
|
||||
handler: (user) => user,
|
||||
|
||||
errors: {
|
||||
usernameOrPasswordMissing: 'Both username and password are required',
|
||||
@ -199,6 +196,14 @@ ${domain}/reset-password?resetToken=${resetToken}
|
||||
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
|
||||
// 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
|
||||
@ -211,12 +216,10 @@ ${domain}/reset-password?resetToken=${resetToken}
|
||||
attributes: {
|
||||
HttpOnly: true,
|
||||
Path: '/',
|
||||
SameSite: 'Strict',
|
||||
Secure: process.env.NODE_ENV !== 'development',
|
||||
|
||||
// If you need to allow other domains (besides the api side) access to
|
||||
// the dbAuth session cookie:
|
||||
// Domain: 'example.com',
|
||||
SameSite: isProduction ? 'None' : 'Strict',
|
||||
Secure: isProduction,
|
||||
Domain: isProduction ? 'localhost' : 'localhost',
|
||||
// Domain: isProduction ? process.env.DOMAIN : 'localhost',
|
||||
},
|
||||
name: cookieName,
|
||||
},
|
||||
|
@ -1,5 +1,10 @@
|
||||
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'
|
||||
|
||||
@ -12,6 +17,10 @@ import { db } from './db'
|
||||
*/
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
export const getCurrentUser = async (session: Decoded) => {
|
||||
if (!session || typeof session.id !== 'number') {
|
||||
if (!session || typeof session.id !== 'number')
|
||||
throw new Error('Invalid session')
|
||||
}
|
||||
|
||||
return await db.user.findUnique({
|
||||
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
|
||||
*/
|
||||
export const requireAuth = ({ roles }: { roles?: AllowedRoles } = {}) => {
|
||||
if (!isAuthenticated()) {
|
||||
if (!isAuthenticated())
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
||||
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
19
api/src/lib/cors.ts
Normal 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')
|
||||
}
|
@ -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({
|
||||
from: `"${process.env.NAME} (noreply)" <${process.env.GMAIL}>`,
|
||||
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('@')
|
||||
|
||||
if (localPart.length <= 2) return `${localPart}@${domain}`
|
||||
|
106
api/src/lib/tus.ts
Normal file
106
api/src/lib/tus.ts
Normal 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
|
||||
}
|
@ -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 { Server } from '@tus/server'
|
||||
|
||||
import { isProduction } from '@redwoodjs/api/dist/logger'
|
||||
import { createServer } from '@redwoodjs/api-server'
|
||||
|
||||
import { logger } from 'src/lib/logger'
|
||||
import { handleTusUpload } from 'src/lib/tus'
|
||||
;(async () => {
|
||||
const server = await createServer({
|
||||
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({
|
||||
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(
|
||||
@ -19,15 +42,12 @@ import { logger } from 'src/lib/logger'
|
||||
(_request, _payload, done) => done(null)
|
||||
)
|
||||
|
||||
server.all('/files', (req, res) => {
|
||||
tusServer.handle(req.raw, res.raw)
|
||||
return res
|
||||
})
|
||||
|
||||
server.all('/files/*', (req, res) => {
|
||||
tusServer.handle(req.raw, res.raw)
|
||||
return res
|
||||
})
|
||||
server.all('/files', (req, res) =>
|
||||
handleTusUpload(req, res, tusServer, false)
|
||||
)
|
||||
server.all('/files/*', (req, res) =>
|
||||
handleTusUpload(req, res, tusServer, true)
|
||||
)
|
||||
|
||||
await server.start()
|
||||
})()
|
||||
|
Reference in New Issue
Block a user