Skip to content

Refactor/leverage getdecorator api #102

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5be081f
fix: remove console.log
jean-michelet Jan 2, 2025
3460b55
Merge branch 'main' of github.com:jean-michelet/demo
jean-michelet Jan 5, 2025
2876816
Merge branch 'main' of github.com:jean-michelet/demo
jean-michelet Jan 5, 2025
ff90149
Merge branch 'main' of github.com:jean-michelet/demo
jean-michelet Jan 5, 2025
6420107
refactor: create and use tasks repository for tasks related db operat…
jean-michelet Mar 29, 2025
386e2cc
refactor: put db/file operations into their own plugins
jean-michelet Mar 29, 2025
8cc651e
refactor: encapsulate tasks file management
jean-michelet Mar 29, 2025
9b3c67a
fix: conflicts
jean-michelet Mar 30, 2025
e686c40
refactor: extract reusable file logic into its own plugin
jean-michelet Mar 30, 2025
154bcc1
docs: nit
jean-michelet Mar 30, 2025
8ae575a
refactor: reorganize password management decorators into one decorato…
jean-michelet Mar 30, 2025
9ada87e
refactor: encapsulate tasks stream into tasks repository
jean-michelet Mar 30, 2025
01ab812
refactor: let errors propagate
jean-michelet Mar 30, 2025
5f15dfe
fix: nit
jean-michelet Mar 30, 2025
05576ba
docs: document rename strategy limitations
jean-michelet Mar 30, 2025
cdf058d
refactor: rename safeUnlink to unlink
jean-michelet Mar 30, 2025
733ae12
feat: create safeJoin method for filename upload
jean-michelet Apr 8, 2025
4739778
Merge branch 'main' of github.com:jean-michelet/demo into refactorisa…
jean-michelet Apr 8, 2025
cd86174
fix: conflicts
jean-michelet Apr 8, 2025
2db56cd
refactor: leverage getdecorator api
jean-michelet Apr 15, 2025
b900627
fix: conflicts
jean-michelet Apr 15, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@
"@fastify/sensible": "^6.0.3",
"@fastify/session": "^11.1.0",
"@fastify/static": "^8.1.1",
"@fastify/swagger": "^9.4.2",
"@fastify/swagger": "^9.5.0",
"@fastify/swagger-ui": "^5.2.2",
"@fastify/type-provider-typebox": "^5.1.0",
"@fastify/under-pressure": "^9.0.3",
"@sinclair/typebox": "^0.34.33",
"concurrently": "^9.1.2",
"csv-stringify": "^6.5.2",
"fastify": "^5.2.2",
"fastify": "^5.3.0",
"fastify-cli": "^7.4.0",
"fastify-plugin": "^5.0.1",
"knex": "^3.1.0",
Expand All @@ -61,7 +61,7 @@
"postgrator": "^8.0.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@types/node": "^22.14.1",
"c8": "^10.1.3",
"eslint": "^9.24.0",
"fastify-tsconfig": "^3.0.0",
Expand Down
42 changes: 42 additions & 0 deletions src/plugins/app/authentication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { FastifyRequest } from 'fastify'
import fp from 'fastify-plugin'
import { SessionData } from '../external/session.js'
import { Auth } from '../../schemas/auth.js'

export type Authenticate = OmitThisParameter<typeof authenticate>
export const kAuth = Symbol('app.auth')
export const kAuthenticate = Symbol('app.authenticate')

function authenticate (this: FastifyRequest) {
const { auth } = this.getDecorator<SessionData>('session')
if (auth === undefined) {
return false
}

// Instead of accessing the session directly in your application,
// you should decorate the request.
// This reduces coupling with the authentication strategy.
// If you change or add new authentication strategies in the future,
// `request.auth` remains the single source of truth.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// `request.auth` remains the single source of truth.
// `request[kAuth]` remains the single source of truth.

this.setDecorator<Auth>(kAuth, auth)

return true
}

/**
* The use of fastify-plugin is required to be able
* to export the decorators to the outer scope
*
* @see {@link https://github.com/fastify/fastify-plugin}
*/
export default fp(
async function (fastify) {
// Always decorate object instances before they are instantiated and used
// @see https://fastify.dev/docs/latest/Reference/Decorators/#decorators
fastify.decorateRequest(kAuth, null)
fastify.decorateRequest(kAuthenticate, authenticate)
},
// You should name your plugins if you want to avoid name collisions
// and/or to perform dependency checks.
{ name: 'authentication' }
)
38 changes: 19 additions & 19 deletions src/plugins/app/authorization.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
import fp from 'fastify-plugin'
import { FastifyReply, FastifyRequest } from 'fastify'
import { Auth } from '../../schemas/auth.js'
import { kAuth } from './authentication.js'

declare module 'fastify' {
export interface FastifyRequest {
verifyAccess: typeof verifyAccess;
isModerator: typeof isModerator;
isAdmin: typeof isAdmin;
}
}
export type AuthorizationManager = ReturnType<typeof createChecker>
export const kAuthorizationManager = Symbol('app.authorizationManager')

function verifyAccess (this: FastifyRequest, reply: FastifyReply, role: string) {
if (!this.session.user.roles.includes(role)) {
reply.status(403).send('You are not authorized to access this resource.')
function createChecker () {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
function createChecker () {
function createManager () {

function ensureHasRole (request: FastifyRequest, reply: FastifyReply, role: string) {
const { roles } = request.getDecorator<Auth>(kAuth)
if (!roles.includes(role)) {
reply.status(403).send('You are not authorized to access this resource.')
}
}
}

async function isModerator (this: FastifyRequest, reply: FastifyReply) {
this.verifyAccess(reply, 'moderator')
}
return {
async ensureIsModerator (request: FastifyRequest, reply: FastifyReply) {
return ensureHasRole(request, reply, 'moderator')
},

async function isAdmin (this: FastifyRequest, reply: FastifyReply) {
this.verifyAccess(reply, 'admin')
async ensureIsAdmin (request: FastifyRequest, reply: FastifyReply) {
return ensureHasRole(request, reply, 'admin')
}
}
}

/**
Expand All @@ -31,9 +33,7 @@ async function isAdmin (this: FastifyRequest, reply: FastifyReply) {
*/
export default fp(
async function (fastify) {
fastify.decorateRequest('verifyAccess', verifyAccess)
fastify.decorateRequest('isModerator', isModerator)
fastify.decorateRequest('isAdmin', isAdmin)
fastify.decorate(kAuthorizationManager, createChecker())
},
// You should name your plugins if you want to avoid name collisions
// and/or to perform dependency checks.
Expand Down
10 changes: 3 additions & 7 deletions src/plugins/app/file-manager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { ReturnType } from '@sinclair/typebox'
import { FastifyInstance } from 'fastify'
import fp from 'fastify-plugin'
import fs from 'fs'
Expand All @@ -8,11 +7,8 @@ import fastifyMultipart from '../external/multipart.js'
import sanitize from 'sanitize-filename'
import path from 'node:path'

declare module 'fastify' {
export interface FastifyInstance {
fileManager: ReturnType<typeof createFileManager>
}
}
export type FileManager = ReturnType<typeof createFileManager>
export const kFileManager = Symbol('app.fileManager')

function createFileManager (fastify: FastifyInstance) {
return {
Expand Down Expand Up @@ -62,7 +58,7 @@ function isErrnoException (error: unknown): error is NodeJS.ErrnoException {
}

export default fp(async (fastify) => {
fastify.decorate('fileManager', createFileManager(fastify))
fastify.decorate(kFileManager, createFileManager(fastify))
}, {
name: 'file-manager'
})
9 changes: 3 additions & 6 deletions src/plugins/app/password-manager.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import fp from 'fastify-plugin'
import { scrypt, timingSafeEqual, randomBytes } from 'node:crypto'

declare module 'fastify' {
export interface FastifyInstance {
passwordManager: typeof passwordManager
}
}
export type PasswordManager = typeof passwordManager
export const kPasswordManager = Symbol('app.passwordManager')

const SCRYPT_KEYLEN = 32
const SCRYPT_COST = 65536
Expand Down Expand Up @@ -62,7 +59,7 @@ async function compare (value: string, hash: string): Promise<boolean> {
}

export default fp(async (fastify) => {
fastify.decorate('passwordManager', passwordManager)
fastify.decorate(kPasswordManager, passwordManager)
}, {
name: 'password-manager'
})
13 changes: 5 additions & 8 deletions src/plugins/app/tasks/tasks-file-manager.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import { ReturnType } from '@sinclair/typebox'
import { FastifyInstance } from 'fastify'
import fp from 'fastify-plugin'
import path from 'path'
import fastifyMultipart from '../../external/multipart.js'
import { FileManager, kFileManager } from '../file-manager.js'

declare module 'fastify' {
export interface FastifyInstance {
tasksFileManager: ReturnType<typeof createUploader>
}
}
export type TasksFileManager = ReturnType<typeof createUploader>
export const kTasksFileManager = Symbol('app.tasksFileManager')

function createUploader (fastify: FastifyInstance) {
const { fileManager } = fastify
const fileManager = fastify.getDecorator<FileManager>(kFileManager)

const uploadPath = path.join(
import.meta.dirname,
Expand Down Expand Up @@ -61,7 +58,7 @@ function createUploader (fastify: FastifyInstance) {
}

export default fp(async (fastify) => {
fastify.decorate('tasksFileManager', createUploader(fastify))
fastify.decorate(kTasksFileManager, createUploader(fastify))
}, {
name: 'tasks-file-manager',
dependencies: ['file-manager']
Expand Down
11 changes: 4 additions & 7 deletions src/plugins/app/tasks/tasks-repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ReturnType, Static } from '@sinclair/typebox'
import { Static } from '@sinclair/typebox'
import { FastifyInstance } from 'fastify'
import fp from 'fastify-plugin'
import {
Expand All @@ -9,11 +9,8 @@ import {
} from '../../../schemas/tasks.js'
import { Knex } from 'knex'

declare module 'fastify' {
export interface FastifyInstance {
tasksRepository: ReturnType<typeof createRepository>;
}
}
export type TasksRepository = ReturnType<typeof createRepository>
export const kTasksRepository = Symbol('app.tasksRepository')

type CreateTask = Static<typeof CreateTaskSchema>
type UpdateTask = Omit<Static<typeof UpdateTaskSchema>, 'assigned_user_id'> & {
Expand Down Expand Up @@ -109,7 +106,7 @@ function createRepository (fastify: FastifyInstance) {

export default fp(
function (fastify) {
fastify.decorate('tasksRepository', createRepository(fastify))
fastify.decorate(kTasksRepository, createRepository(fastify))
},
{
name: 'tasks-repository',
Expand Down
12 changes: 4 additions & 8 deletions src/plugins/app/users/users-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,10 @@ import { Knex } from 'knex'
import fp from 'fastify-plugin'
import { Auth } from '../../../schemas/auth.js'

declare module 'fastify' {
interface FastifyInstance {
usersRepository: ReturnType<typeof createUsersRepository>;
}
}
export type UsersRepository = ReturnType<typeof createRepository>
export const kUsersRepository = Symbol('app.usersRepository')

export function createUsersRepository (fastify: FastifyInstance) {
function createRepository (fastify: FastifyInstance) {
const knex = fastify.knex

return {
Expand Down Expand Up @@ -42,8 +39,7 @@ export function createUsersRepository (fastify: FastifyInstance) {

export default fp(
async function (fastify: FastifyInstance) {
const repo = createUsersRepository(fastify)
fastify.decorate('usersRepository', repo)
fastify.decorate(kUsersRepository, createRepository(fastify))
},
{
name: 'users-repository',
Expand Down
8 changes: 3 additions & 5 deletions src/plugins/external/session.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import fastifySession from '@fastify/session'
import fp from 'fastify-plugin'
import { Auth } from '../../schemas/auth.js'
import fastifyCookie from '@fastify/cookie'
import { Auth } from '../../schemas/auth.js'

declare module 'fastify' {
interface Session {
user: Auth
}
export interface SessionData {
auth?: Auth
}

/**
Expand Down
10 changes: 8 additions & 2 deletions src/routes/api/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ import {
Type
} from '@fastify/type-provider-typebox'
import { CredentialsSchema } from '../../../schemas/auth.js'
import { kUsersRepository, UsersRepository } from '../../../plugins/app/users/users-repository.js'
import { SessionData } from '../../../plugins/external/session.js'
import { kPasswordManager, PasswordManager } from '../../../plugins/app/password-manager.js'

const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
const { usersRepository, passwordManager } = fastify
const usersRepository = fastify.getDecorator<UsersRepository>(kUsersRepository)
const passwordManager = fastify.getDecorator<PasswordManager>(kPasswordManager)

fastify.post(
'/login',
{
Expand Down Expand Up @@ -37,7 +42,8 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
if (isPasswordValid) {
const roles = await usersRepository.findUserRolesByEmail(email, trx)

request.session.user = {
const session = request.getDecorator<SessionData>('session')
session.auth = {
id: user.id,
email: user.email,
username: user.username,
Expand Down
6 changes: 4 additions & 2 deletions src/routes/api/autohooks.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { FastifyInstance } from 'fastify'
import { Authenticate, kAuthenticate } from '../../plugins/app/authentication.js'

export default async function (fastify: FastifyInstance) {
fastify.addHook('onRequest', async (request, reply) => {
if (request.url.startsWith('/api/auth/login')) {
return
}

if (!request.session.user) {
reply.unauthorized('You must be authenticated to access this route.')
const success = request.getDecorator<Authenticate>(kAuthenticate)()
if (!success) {
return reply.unauthorized('You must be authenticated to access this route.')
}
})
}
7 changes: 5 additions & 2 deletions src/routes/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { FastifyInstance } from 'fastify'
import { Auth } from '../../schemas/auth.js'
import { kAuth } from '../../plugins/app/authentication.js'

export default async function (fastify: FastifyInstance) {
fastify.get('/', ({ session, protocol, hostname }) => {
fastify.get('/', (request) => {
const { username } = request.getDecorator<Auth>(kAuth)
return {
message:
`Hello ${session.user.username}! See documentation at ${protocol}://${hostname}/documentation`
`Hello ${username}! See documentation at ${request.protocol}://${request.hostname}/documentation`
}
})
}
17 changes: 13 additions & 4 deletions src/routes/api/tasks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,17 @@ import {
import path from 'node:path'
import { stringify } from 'csv-stringify'
import { createGzip } from 'node:zlib'
import { kTasksRepository, TasksRepository } from '../../../plugins/app/tasks/tasks-repository.js'
import { kTasksFileManager, TasksFileManager } from '../../../plugins/app/tasks/tasks-file-manager.js'
import { Auth } from '../../../schemas/auth.js'
import { AuthorizationManager, kAuthorizationManager } from '../../../plugins/app/authorization.js'
import { kAuth } from '../../../plugins/app/authentication.js'

const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
const { tasksRepository, tasksFileManager } = fastify
const tasksRepository = fastify.getDecorator<TasksRepository>(kTasksRepository)
const tasksFileManager = fastify.getDecorator<TasksFileManager>(kTasksFileManager)
const authorizationManager = fastify.getDecorator<AuthorizationManager>(kAuthorizationManager)

fastify.get(
'/',
{
Expand Down Expand Up @@ -72,9 +80,10 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
}
},
async function (request, reply) {
const auth = request.getDecorator<Auth>(kAuth)
const newTask = {
...request.body,
author_id: request.session.user.id,
author_id: auth.id,
status: TaskStatusEnum.New
}

Expand Down Expand Up @@ -126,7 +135,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
},
tags: ['Tasks']
},
preHandler: (request, reply) => request.isAdmin(reply)
preHandler: authorizationManager.ensureIsAdmin
},
async function (request, reply) {
const deleted = await tasksRepository.delete(request.params.id)
Expand Down Expand Up @@ -154,7 +163,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
},
tags: ['Tasks']
},
preHandler: (request, reply) => request.isModerator(reply)
preHandler: authorizationManager.ensureIsModerator
},
async function (request, reply) {
const { id } = request.params
Expand Down
Loading