diff --git a/.env.defaults b/.env.defaults index d4c199b..b9bcba8 100644 --- a/.env.defaults +++ b/.env.defaults @@ -20,10 +20,15 @@ NAME=Ahmed Al-Taiar GMAIL=example@gmail.com GMAIL_SMTP_PASSWORD=chan geme xyza bcde +DOMAIN=example.com +API_DOMAIN=api.example.com + # Must not end with "/" -DOMAIN_PROD=https://example.com -DOMAIN_DEV=http://localhost:8910 +ADDRESS_PROD=https://example.com +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 - -SESSION_SECRET=regenerate_me diff --git a/.env.example b/.env.example index b48244f..35cdd80 100644 --- a/.env.example +++ b/.env.example @@ -8,10 +8,15 @@ NAME=Firstname Lastname GMAIL=example@gmail.com GMAIL_SMTP_PASSWORD=chan geme xyza bcde +DOMAIN=example.com +API_DOMAIN=api.example.com + # Must not end with "/" -DOMAIN_PROD=https://example.com -DOMAIN_DEV=http://localhost:8910 +ADDRESS_PROD=https://example.com +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 - -SESSION_SECRET=regenerate_me diff --git a/.gitignore b/.gitignore index 41a2151..9ecfb59 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,4 @@ api/src/lib/generateGraphiQLHeader.* !.yarn/releases !.yarn/sdks !.yarn/versions -files +files_* diff --git a/api/package.json b/api/package.json index b886516..92c97dd 100644 --- a/api/package.json +++ b/api/package.json @@ -3,6 +3,8 @@ "version": "0.0.0", "private": true, "dependencies": { + "@fastify/cors": "^9.0.1", + "@fastify/rate-limit": "^9.1.0", "@redwoodjs/api": "7.7.4", "@redwoodjs/api-server": "7.7.4", "@redwoodjs/auth-dbauth-api": "7.7.4", diff --git a/api/quick-lint-js.config b/api/quick-lint-js.config new file mode 100644 index 0000000..7d88691 --- /dev/null +++ b/api/quick-lint-js.config @@ -0,0 +1,7 @@ +{ + "globals": { + "context": { + "writable": false + } + } +} diff --git a/api/src/functions/auth.ts b/api/src/functions/auth.ts index 977450f..8825c04 100644 --- a/api/src/functions/auth.ts +++ b/api/src/functions/auth.ts @@ -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, }, diff --git a/api/src/lib/auth.ts b/api/src/lib/auth.ts index d6af4c5..7110557 100644 --- a/api/src/lib/auth.ts +++ b/api/src/lib/auth.ts @@ -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 } diff --git a/api/src/lib/cors.ts b/api/src/lib/cors.ts new file mode 100644 index 0000000..b36e26c --- /dev/null +++ b/api/src/lib/cors.ts @@ -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') +} diff --git a/api/src/lib/email.ts b/api/src/lib/email.ts index 59f3a74..e6d32f5 100644 --- a/api/src/lib/email.ts +++ b/api/src/lib/email.ts @@ -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}` diff --git a/api/src/lib/tus.ts b/api/src/lib/tus.ts new file mode 100644 index 0000000..6222b52 --- /dev/null +++ b/api/src/lib/tus.ts @@ -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 +} diff --git a/api/src/server.ts b/api/src/server.ts index 50e8e61..2a1ee6a 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -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() })() diff --git a/redwood.toml b/redwood.toml index 74b6cdd..544cdcb 100644 --- a/redwood.toml +++ b/redwood.toml @@ -9,7 +9,7 @@ title = "${NAME}" port = 8910 apiUrl = "/api" - includeEnvironmentVariables = ["NAME"] + includeEnvironmentVariables = ["NAME", "API_ADDRESS_PROD", "API_ADDRESS_DEV"] [generate] tests = false stories = false diff --git a/web/src/components/Uploader/Uploader.tsx b/web/src/components/Uploader/Uploader.tsx index 57dbe2e..141375d 100644 --- a/web/src/components/Uploader/Uploader.tsx +++ b/web/src/components/Uploader/Uploader.tsx @@ -2,26 +2,62 @@ import { useState } from 'react' import Compressor from '@uppy/compressor' import Uppy from '@uppy/core' +import type { UploadResult, Meta } from '@uppy/core' import ImageEditor from '@uppy/image-editor' import { Dashboard } from '@uppy/react' import Tus from '@uppy/tus' +import { isProduction } from '@redwoodjs/api/dist/logger' + import '@uppy/image-editor/dist/style.min.css' import '@uppy/core/dist/style.min.css' import '@uppy/dashboard/dist/style.min.css' -const Uploader = () => { - const [uppy] = useState(() => - new Uppy({ +interface Props { + onComplete?(result: UploadResult>): void +} + +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: { - allowedFileTypes: ['image/*'], + allowedFileTypes: [ + 'image/webp', + 'image/png', + 'image/jpg', + 'image/jpeg', + ], 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(Compressor) - ) + .use(Compressor, { + mimeType: 'image/webp', + }) + + return instance.on('complete', onComplete) + }) return } diff --git a/web/src/pages/HomePage/HomePage.tsx b/web/src/pages/HomePage/HomePage.tsx index 3874b2c..29a5640 100644 --- a/web/src/pages/HomePage/HomePage.tsx +++ b/web/src/pages/HomePage/HomePage.tsx @@ -5,6 +5,7 @@ import Uploader from 'src/components/Uploader/Uploader' const HomePage = () => { const { isAuthenticated } = useAuth() + return ( <> diff --git a/yarn.lock b/yarn.lock index f1adcdd..a36a478 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2419,6 +2419,16 @@ __metadata: languageName: node 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": version: 3.4.1 resolution: "@fastify/error@npm:3.4.1" @@ -2456,6 +2466,17 @@ __metadata: languageName: node 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": version: 9.8.0 resolution: "@fastify/reply-from@npm:9.8.0" @@ -7254,6 +7275,8 @@ __metadata: version: 0.0.0-use.local resolution: "api@workspace:api" dependencies: + "@fastify/cors": "npm:^9.0.1" + "@fastify/rate-limit": "npm:^9.1.0" "@redwoodjs/api": "npm:7.7.4" "@redwoodjs/api-server": "npm:7.7.4" "@redwoodjs/auth-dbauth-api": "npm:7.7.4" @@ -16052,6 +16075,15 @@ __metadata: languageName: node 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": version: 1.0.1 resolution: "module-not-found-error@npm:1.0.1" @@ -16620,6 +16652,13 @@ __metadata: languageName: node 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": version: 1.1.2 resolution: "obuf@npm:1.1.2" @@ -20632,7 +20671,7 @@ __metadata: languageName: node 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 resolution: "toad-cache@npm:3.7.0" checksum: 10c0/7dae2782ee20b22c9798bb8b71dec7ec6a0091021d2ea9dd6e8afccab6b65b358fdba49a02209fac574499702e2c000660721516c87c2538d1b2c0ba03e8c0c3