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