diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 31913c1..843ffec 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -2,6 +2,7 @@ import type { BunRequest } from "bun"; import { withAuth, withCors, withCSRF, withRateLimit } from "./auth/middleware"; import { testDB } from "./db/client"; import { cleanupExpiredSessions } from "./db/queries"; +import { withAuthedLogging, withLogging } from "./logger"; import { routes } from "./routes"; const DEV = process.argv.find((arg) => ["--dev", "--developer", "-d"].includes(arg.toLowerCase())) != null; @@ -23,7 +24,10 @@ const startSessionCleanup = () => { type RouteHandler = (req: T) => Response | Promise; -const withGlobal = (handler: RouteHandler) => withCors(withRateLimit(handler)); +const withGlobal = (handler: RouteHandler) => + withLogging(withCors(withRateLimit(handler))); +const withGlobalAuthed = (handler: RouteHandler) => + withAuthedLogging(withCors(withRateLimit(handler))); const main = async () => { const server = Bun.serve({ @@ -35,62 +39,64 @@ const main = async () => { // routes that modify state require withCSRF middleware "/auth/register": withGlobal(routes.authRegister), "/auth/login": withGlobal(routes.authLogin), - "/auth/logout": withGlobal(withAuth(withCSRF(routes.authLogout))), - "/auth/me": withGlobal(withAuth(routes.authMe)), + "/auth/logout": withGlobalAuthed(withAuth(withCSRF(routes.authLogout))), + "/auth/me": withGlobalAuthed(withAuth(routes.authMe)), - "/user/by-username": withGlobal(withAuth(routes.userByUsername)), - "/user/update": withGlobal(withAuth(withCSRF(routes.userUpdate))), - "/user/upload-avatar": withGlobal(routes.userUploadAvatar), + "/user/by-username": withGlobalAuthed(withAuth(routes.userByUsername)), + "/user/update": withGlobalAuthed(withAuth(withCSRF(routes.userUpdate))), + "/user/upload-avatar": withGlobalAuthed(withAuth(routes.userUploadAvatar)), - "/issue/create": withGlobal(withAuth(withCSRF(routes.issueCreate))), - "/issue/by-id": withGlobal(withAuth(routes.issueById)), - "/issue/update": withGlobal(withAuth(withCSRF(routes.issueUpdate))), - "/issue/delete": withGlobal(withAuth(withCSRF(routes.issueDelete))), - "/issue-comment/create": withGlobal(withAuth(withCSRF(routes.issueCommentCreate))), - "/issue-comment/delete": withGlobal(withAuth(withCSRF(routes.issueCommentDelete))), + "/issue/create": withGlobalAuthed(withAuth(withCSRF(routes.issueCreate))), + "/issue/by-id": withGlobalAuthed(withAuth(routes.issueById)), + "/issue/update": withGlobalAuthed(withAuth(withCSRF(routes.issueUpdate))), + "/issue/delete": withGlobalAuthed(withAuth(withCSRF(routes.issueDelete))), + "/issue-comment/create": withGlobalAuthed(withAuth(withCSRF(routes.issueCommentCreate))), + "/issue-comment/delete": withGlobalAuthed(withAuth(withCSRF(routes.issueCommentDelete))), - "/issues/by-project": withGlobal(withAuth(routes.issuesByProject)), - "/issues/replace-status": withGlobal(withAuth(withCSRF(routes.issuesReplaceStatus))), - "/issues/replace-type": withGlobal(withAuth(withCSRF(routes.issuesReplaceType))), - "/issues/status-count": withGlobal(withAuth(routes.issuesStatusCount)), - "/issues/type-count": withGlobal(withAuth(routes.issuesTypeCount)), - "/issues/all": withGlobal(withAuth(routes.issues)), - "/issue-comments/by-issue": withGlobal(withAuth(routes.issueCommentsByIssue)), + "/issues/by-project": withGlobalAuthed(withAuth(routes.issuesByProject)), + "/issues/replace-status": withGlobalAuthed(withAuth(withCSRF(routes.issuesReplaceStatus))), + "/issues/replace-type": withGlobalAuthed(withAuth(withCSRF(routes.issuesReplaceType))), + "/issues/status-count": withGlobalAuthed(withAuth(routes.issuesStatusCount)), + "/issues/type-count": withGlobalAuthed(withAuth(routes.issuesTypeCount)), + "/issues/all": withGlobalAuthed(withAuth(routes.issues)), + "/issue-comments/by-issue": withGlobalAuthed(withAuth(routes.issueCommentsByIssue)), - "/organisation/create": withGlobal(withAuth(withCSRF(routes.organisationCreate))), - "/organisation/by-id": withGlobal(withAuth(routes.organisationById)), - "/organisation/update": withGlobal(withAuth(withCSRF(routes.organisationUpdate))), - "/organisation/delete": withGlobal(withAuth(withCSRF(routes.organisationDelete))), - "/organisation/upload-icon": withGlobal(withAuth(withCSRF(routes.organisationUploadIcon))), - "/organisation/add-member": withGlobal(withAuth(withCSRF(routes.organisationAddMember))), - "/organisation/members": withGlobal(withAuth(routes.organisationMembers)), - "/organisation/remove-member": withGlobal(withAuth(withCSRF(routes.organisationRemoveMember))), - "/organisation/update-member-role": withGlobal( + "/organisation/create": withGlobalAuthed(withAuth(withCSRF(routes.organisationCreate))), + "/organisation/by-id": withGlobalAuthed(withAuth(routes.organisationById)), + "/organisation/update": withGlobalAuthed(withAuth(withCSRF(routes.organisationUpdate))), + "/organisation/delete": withGlobalAuthed(withAuth(withCSRF(routes.organisationDelete))), + "/organisation/upload-icon": withGlobalAuthed(withAuth(withCSRF(routes.organisationUploadIcon))), + "/organisation/add-member": withGlobalAuthed(withAuth(withCSRF(routes.organisationAddMember))), + "/organisation/members": withGlobalAuthed(withAuth(routes.organisationMembers)), + "/organisation/remove-member": withGlobalAuthed( + withAuth(withCSRF(routes.organisationRemoveMember)), + ), + "/organisation/update-member-role": withGlobalAuthed( withAuth(withCSRF(routes.organisationUpdateMemberRole)), ), - "/organisations/by-user": withGlobal(withAuth(routes.organisationsByUser)), + "/organisations/by-user": withGlobalAuthed(withAuth(routes.organisationsByUser)), - "/project/create": withGlobal(withAuth(withCSRF(routes.projectCreate))), - "/project/update": withGlobal(withAuth(withCSRF(routes.projectUpdate))), - "/project/delete": withGlobal(withAuth(withCSRF(routes.projectDelete))), - "/project/with-creator": withGlobal(withAuth(routes.projectWithCreator)), + "/project/create": withGlobalAuthed(withAuth(withCSRF(routes.projectCreate))), + "/project/update": withGlobalAuthed(withAuth(withCSRF(routes.projectUpdate))), + "/project/delete": withGlobalAuthed(withAuth(withCSRF(routes.projectDelete))), + "/project/with-creator": withGlobalAuthed(withAuth(routes.projectWithCreator)), - "/projects/by-creator": withGlobal(withAuth(routes.projectsByCreator)), - "/projects/by-organisation": withGlobal(withAuth(routes.projectsByOrganisation)), - "/projects/all": withGlobal(withAuth(routes.projectsAll)), - "/projects/with-creators": withGlobal(withAuth(routes.projectsWithCreators)), + "/projects/by-creator": withGlobalAuthed(withAuth(routes.projectsByCreator)), + "/projects/by-organisation": withGlobalAuthed(withAuth(routes.projectsByOrganisation)), + "/projects/all": withGlobalAuthed(withAuth(routes.projectsAll)), + "/projects/with-creators": withGlobalAuthed(withAuth(routes.projectsWithCreators)), - "/sprint/create": withGlobal(withAuth(withCSRF(routes.sprintCreate))), - "/sprint/update": withGlobal(withAuth(withCSRF(routes.sprintUpdate))), - "/sprint/delete": withGlobal(withAuth(withCSRF(routes.sprintDelete))), - "/sprints/by-project": withGlobal(withAuth(routes.sprintsByProject)), + "/sprint/create": withGlobalAuthed(withAuth(withCSRF(routes.sprintCreate))), + "/sprint/update": withGlobalAuthed(withAuth(withCSRF(routes.sprintUpdate))), + "/sprint/delete": withGlobalAuthed(withAuth(withCSRF(routes.sprintDelete))), + "/sprints/by-project": withGlobalAuthed(withAuth(routes.sprintsByProject)), - "/timer/toggle": withGlobal(withAuth(withCSRF(routes.timerToggle))), - "/timer/end": withGlobal(withAuth(withCSRF(routes.timerEnd))), - "/timer/get": withGlobal(withAuth(withCSRF(routes.timerGet))), - "/timer/get-inactive": withGlobal(withAuth(withCSRF(routes.timerGetInactive))), - "/timers": withGlobal(withAuth(withCSRF(routes.timers))), + "/timer/toggle": withGlobalAuthed(withAuth(withCSRF(routes.timerToggle))), + "/timer/end": withGlobalAuthed(withAuth(withCSRF(routes.timerEnd))), + "/timer/get": withGlobalAuthed(withAuth(withCSRF(routes.timerGet))), + "/timer/get-inactive": withGlobalAuthed(withAuth(withCSRF(routes.timerGetInactive))), + "/timers": withGlobalAuthed(withAuth(withCSRF(routes.timers))), }, }); diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts new file mode 100644 index 0000000..f5a31dc --- /dev/null +++ b/packages/backend/src/logger.ts @@ -0,0 +1,165 @@ +import type { BunRequest } from "bun"; + +type LogLevel = "info" | "warn" | "error"; + +interface RequestLog { + timestamp: Date; + level: LogLevel; + method: string; + url: string; + status: number; + duration: number; + userId?: number; +} + +const LOG_LEVEL: LogLevel = (process.env.LOG_LEVEL as LogLevel) ?? "info"; + +const shouldLog = (level: LogLevel): boolean => { + const levels: LogLevel[] = ["info", "warn", "error"]; + return levels.indexOf(level) >= levels.indexOf(LOG_LEVEL); +}; + +const COLORS = { + reset: "\x1b[0m", + dim: "\x1b[2m", + cyan: "\x1b[36m", + green: "\x1b[32m", + yellow: "\x1b[33m", + red: "\x1b[31m", + magenta: "\x1b[35m", +}; + +const getStatusColor = (status: number): string => { + if (status >= 500) return COLORS.red; + if (status >= 400) return COLORS.yellow; + if (status >= 300) return COLORS.cyan; + return COLORS.green; +}; + +const getMethodColor = (method: string): string => { + switch (method) { + case "GET": + return COLORS.green; + case "POST": + return COLORS.cyan; + case "PUT": + case "PATCH": + return COLORS.yellow; + case "DELETE": + return COLORS.red; + default: + return COLORS.reset; + } +}; + +const formatTimestamp = (date: Date): string => { + const day = String(date.getDate()).padStart(2, "0"); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const year = String(date.getFullYear()).slice(-2); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + return `${day}-${month}-${year} ${hours}:${minutes}:${seconds}`; +}; + +const formatLog = (log: RequestLog): string => { + const timestamp = `${COLORS.dim}${formatTimestamp(log.timestamp)}${COLORS.reset}`; + const method = `${getMethodColor(log.method)}${log.method}${COLORS.reset}`; + const url = log.url; + const status = `${getStatusColor(log.status)}${log.status}${COLORS.reset}`; + const duration = `${COLORS.dim}${log.duration}ms${COLORS.reset}`; + const user = log.userId ? ` ${COLORS.magenta}user:${log.userId}${COLORS.reset}` : ""; + + return `${timestamp} ${method} ${url} ${status} ${duration}${user}`; +}; + +const writeLog = (log: RequestLog) => { + if (!shouldLog(log.level)) return; + console.log(formatLog(log)); +}; + +const getLogLevel = (status: number): LogLevel => { + if (status >= 500) return "error"; + if (status >= 400) return "warn"; + return "info"; +}; + +type RouteHandler = (req: T) => Response | Promise; + +export const withLogging = (handler: RouteHandler): RouteHandler => { + return async (req: T) => { + const start = performance.now(); + const url = new URL(req.url); + + try { + const res = await handler(req); + const duration = Math.round(performance.now() - start); + + const log: RequestLog = { + timestamp: new Date(), + level: getLogLevel(res.status), + method: req.method, + url: url.pathname, + status: res.status, + duration, + }; + + writeLog(log); + return res; + } catch (error) { + const duration = Math.round(performance.now() - start); + + const log: RequestLog = { + timestamp: new Date(), + level: "error", + method: req.method, + url: url.pathname, + status: 500, + duration, + }; + + writeLog(log); + throw error; + } + }; +}; + +export const withAuthedLogging = (handler: RouteHandler): RouteHandler => { + return async (req: T) => { + const start = performance.now(); + const url = new URL(req.url); + + try { + const res = await handler(req); + const duration = Math.round(performance.now() - start); + + const log: RequestLog = { + timestamp: new Date(), + level: getLogLevel(res.status), + method: req.method, + url: url.pathname, + status: res.status, + duration, + userId: (req as { userId?: number }).userId, + }; + + writeLog(log); + return res; + } catch (error) { + const duration = Math.round(performance.now() - start); + + const log: RequestLog = { + timestamp: new Date(), + level: "error", + method: req.method, + url: url.pathname, + status: 500, + duration, + userId: (req as { userId?: number }).userId, + }; + + writeLog(log); + throw error; + } + }; +};