diff --git a/.gitignore b/.gitignore index 189f9046..95560ae5 100644 --- a/.gitignore +++ b/.gitignore @@ -177,6 +177,7 @@ dist pgdata/ minio_data/ dump/ +minio-dump/ *.csv *.sql \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a78e1b0b..642821f7 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -64,6 +64,9 @@ services: - type: bind source: ./dump target: /tmp/dump + - type: bind + source: ./minio-dump + target: /tmp/minio-dump minio: container_name: interapp-minio diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4feaf13c..94b569b1 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -42,7 +42,7 @@ services: context: ./interapp-backend dockerfile: scheduler/prod.Dockerfile env_file: - - ./interapp-backend/.env.development + - ./interapp-backend/.env.production networks: - interapp-network depends_on: @@ -52,6 +52,7 @@ services: condition: service_healthy volumes: - ./dump:/tmp/dump + - ./minio-dump:/tmp/minio-dump minio: container_name: interapp-minio diff --git a/interapp-backend/scheduler/dev.Dockerfile b/interapp-backend/scheduler/dev.Dockerfile index 3785c337..fbd235aa 100644 --- a/interapp-backend/scheduler/dev.Dockerfile +++ b/interapp-backend/scheduler/dev.Dockerfile @@ -15,6 +15,19 @@ RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg m # update and install postgresql-client-16, tzdata RUN apt-get update && apt-get install -y --fix-missing postgresql-client-16 tzdata && apt-get clean +RUN ARCH=$(case "$(uname -m)" in "x86_64") echo "amd64";; "ppc64le") echo "ppc64le";; *) echo "Unsupported architecture"; exit 1;; esac) && \ + DOWNLOAD_URL=$(case "$ARCH" in "amd64") echo "https://dl.min.io/client/mc/release/linux-amd64/mc";; "ppc64le") echo "https://dl.min.io/client/mc/release/linux-ppc64le/mc";; *) echo "Unsupported architecture"; exit 1;; esac) && \ + # Install wget to download MinIO client + apt-get update && apt-get install -y wget && \ + # Download MinIO client binary + wget -O /usr/local/bin/mc "$DOWNLOAD_URL" && \ + # Make MinIO client binary executable + chmod +x /usr/local/bin/mc && \ + # Clean up + apt-get clean && rm -rf /var/lib/apt/lists/* && \ + # Output success message + echo "MinIO client installed successfully." + ENV TZ=Asia/Singapore RUN ln -snf /usr/share/zoneinfo/"$TZ" /etc/localtime && echo "$TZ" > /etc/timezone RUN apt-get clean diff --git a/interapp-backend/scheduler/prod.Dockerfile b/interapp-backend/scheduler/prod.Dockerfile index ce249c53..99f72104 100644 --- a/interapp-backend/scheduler/prod.Dockerfile +++ b/interapp-backend/scheduler/prod.Dockerfile @@ -15,6 +15,19 @@ RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg m # update and install postgresql-client-16, tzdata RUN apt-get update && apt-get install -y --fix-missing postgresql-client-16 tzdata && apt-get clean +RUN ARCH=$(case "$(uname -m)" in "x86_64") echo "amd64";; "ppc64le") echo "ppc64le";; *) echo "Unsupported architecture"; exit 1;; esac) && \ + DOWNLOAD_URL=$(case "$ARCH" in "amd64") echo "https://dl.min.io/client/mc/release/linux-amd64/mc";; "ppc64le") echo "https://dl.min.io/client/mc/release/linux-ppc64le/mc";; *) echo "Unsupported architecture"; exit 1;; esac) && \ + # Install wget to download MinIO client + apt-get update && apt-get install -y wget && \ + # Download MinIO client binary + wget -O /usr/local/bin/mc "$DOWNLOAD_URL" && \ + # Make MinIO client binary executable + chmod +x /usr/local/bin/mc && \ + # Clean up + apt-get clean && rm -rf /var/lib/apt/lists/* && \ + # Output success message + echo "MinIO client installed successfully." + ENV TZ=Asia/Singapore RUN ln -snf /usr/share/zoneinfo/"$TZ" /etc/localtime && echo "$TZ" > /etc/timezone RUN apt-get clean diff --git a/interapp-backend/scheduler/scheduler.ts b/interapp-backend/scheduler/scheduler.ts index 057ef3f9..29cb29ce 100644 --- a/interapp-backend/scheduler/scheduler.ts +++ b/interapp-backend/scheduler/scheduler.ts @@ -155,7 +155,7 @@ schedule('0 0 0 */1 * *', async () => { await $`find ${path} -type f -mtime +7 -exec rm {} +`; const d = new Date(); - const fmted = `interapp_${d.toLocaleDateString('en-GB').replace(/\//g, '_')}`; + const fmted = `interapp_db_${d.toLocaleDateString('en-GB').replace(/\//g, '_')}`; const newFile = `${path}/${fmted}.sql`; await $`touch ${newFile}`; @@ -163,3 +163,47 @@ schedule('0 0 0 */1 * *', async () => { console.info('db snapshot taken at location: ', newFile); }); + +// Minio backup task +const requiredEnv = [ + 'MINIO_ENDPOINT', + 'MINIO_ADDRESS', + 'MINIO_ROOT_USER', + 'MINIO_ROOT_PASSWORD', + 'MINIO_BUCKETNAME', +]; +const missingEnv = requiredEnv.filter((env) => !process.env[env]); +if (missingEnv.length > 0) { + console.error('Missing required environment variables: ', missingEnv); + process.exit(1); +} + +// define minio variables +const minioURL = `http://${process.env.MINIO_ENDPOINT}${process.env.MINIO_ADDRESS}`; +const minioAccessKey = process.env.MINIO_ROOT_USER as string; +const minioSecretKey = process.env.MINIO_ROOT_PASSWORD as string; +const minioBucketName = process.env.MINIO_BUCKETNAME as string; +const minioAliasName = 'minio'; + +const minioBackupTask = schedule( + '0 0 0 */1 * *', + async () => { + const path = '/tmp/minio-dump'; + if (!existsSync(path)) mkdirSync(path); + // remove all files older than 7 days + await $`find ${path} -type f -mtime +7 -exec rm {} +`; + + const d = new Date(); + const fmted = `interapp_minio_${d.toLocaleDateString('en-GB').replace(/\//g, '_')}`; + const newFile = `${path}/${fmted}.tar.gz`; + + await $`mc mirror ${minioAliasName}/${minioBucketName} /tmp/minio-dump/temp`; + await $`cd /tmp && tar -cvf ${newFile} minio-dump/temp`; + await $`rm -rf /tmp/minio-dump/temp`; + }, + { scheduled: false }, +); + +// set minio alias to allow mc to access minio server +await $`mc alias set ${minioAliasName} ${minioURL} ${minioAccessKey} ${minioSecretKey}`; +minioBackupTask.start(); diff --git a/interapp-frontend/src/app/admin/AdminTable/AdminTable.tsx b/interapp-frontend/src/app/admin/AdminTable/AdminTable.tsx index 51fccdb7..db27ca1a 100644 --- a/interapp-frontend/src/app/admin/AdminTable/AdminTable.tsx +++ b/interapp-frontend/src/app/admin/AdminTable/AdminTable.tsx @@ -10,14 +10,32 @@ import DeleteAction from './DeleteAction/DeleteAction'; import PageController, { paginateItems } from '@components/PageController/PageController'; import { IconSearch } from '@tabler/icons-react'; import './styles.css'; -import PageSkeleton from '@/components/PageSkeleton/PageSkeleton'; +import PageSkeleton from '@components/PageSkeleton/PageSkeleton'; +import { ClientError } from '@utils/.'; const fetchUserData = async () => { const apiClient = new APIClient().instance; - const users: Omit[] = (await apiClient.get('/user')).data; - const perms: { [username: string]: Permissions[] } = (await apiClient.get('/user/permissions')) - .data; + const usersResponse = await apiClient.get('/user'); + if (usersResponse.status !== 200) + throw new ClientError({ + message: 'Failed to fetch users', + responseStatus: usersResponse.status, + responseBody: usersResponse.data, + }); + + const users: Omit[] = usersResponse.data; + + const permsResponse = await apiClient.get('/user/permissions'); + if (permsResponse.status !== 200) + throw new ClientError({ + message: 'Failed to fetch permissions', + responseStatus: permsResponse.status, + responseBody: permsResponse.data, + }); + + const perms: { [username: string]: Permissions[] } = permsResponse.data; + const usersWithPerms = users.map((user) => ({ ...user, permissions: perms[user.username] })); return usersWithPerms; }; diff --git a/interapp-frontend/src/app/route_permissions.ts b/interapp-frontend/src/app/route_permissions.ts index 0184639b..97f21b52 100644 --- a/interapp-frontend/src/app/route_permissions.ts +++ b/interapp-frontend/src/app/route_permissions.ts @@ -33,3 +33,7 @@ export const routePermissions = { [Permissions.ATTENDANCE_MANAGER]: ['/exports'], [Permissions.ADMIN]: ['/admin'], } as const; + +export const allRoutes = new Set( + (Object.values(routePermissions).flat() as string[]).concat(noLoginRequiredRoutes), +); diff --git a/interapp-frontend/src/app/service_sessions/ServiceSessionContent/ServiceSessionRow/ServiceSessionRow.tsx b/interapp-frontend/src/app/service_sessions/ServiceSessionContent/ServiceSessionRow/ServiceSessionRow.tsx index 95929e84..45fbc727 100644 --- a/interapp-frontend/src/app/service_sessions/ServiceSessionContent/ServiceSessionRow/ServiceSessionRow.tsx +++ b/interapp-frontend/src/app/service_sessions/ServiceSessionContent/ServiceSessionRow/ServiceSessionRow.tsx @@ -38,8 +38,8 @@ const ServiceSessionRow = ({ {service_name} - {new Date(start_time).toLocaleString('en-GB')} -{' '} - {new Date(end_time).toLocaleString('en-GB')}{' '} + Start: {new Date(start_time).toLocaleString('en-GB')} + End: {new Date(end_time).toLocaleString('en-GB')} {ad_hoc_enabled ? Yes : No} diff --git a/interapp-frontend/src/app/service_sessions/ServiceSessionContent/ServiceSessionRow/styles.css b/interapp-frontend/src/app/service_sessions/ServiceSessionContent/ServiceSessionRow/styles.css index 53d51df6..96006748 100644 --- a/interapp-frontend/src/app/service_sessions/ServiceSessionContent/ServiceSessionRow/styles.css +++ b/interapp-frontend/src/app/service_sessions/ServiceSessionContent/ServiceSessionRow/styles.css @@ -1,13 +1,13 @@ .service-session-img { width: 100%; - height: 5rem; + height: 3.5rem; object-fit: scale-down; } .service-session-users { display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 0.2rem; + grid-template-columns: repeat(4, 1fr); + gap: 0.1rem; justify-items: center; } @@ -17,6 +17,6 @@ @media (max-width: 768px) { .service-session-users { - grid-template-columns: repeat(2, 1fr); + grid-template-columns: repeat(3, 1fr); } } diff --git a/interapp-frontend/src/providers/AuthProvider/AuthProvider.tsx b/interapp-frontend/src/providers/AuthProvider/AuthProvider.tsx index 8b5f78e4..e5ed40f3 100644 --- a/interapp-frontend/src/providers/AuthProvider/AuthProvider.tsx +++ b/interapp-frontend/src/providers/AuthProvider/AuthProvider.tsx @@ -10,9 +10,9 @@ import { } from './types'; import APIClient from '@api/api_client'; import { useRouter, usePathname, useSearchParams } from 'next/navigation'; -import { routePermissions, noLoginRequiredRoutes } from '@/app/route_permissions'; +import { routePermissions, noLoginRequiredRoutes, allRoutes } from '@/app/route_permissions'; import { notifications } from '@mantine/notifications'; -import { ClientError, remapAssetUrl, wildcardMatcher } from '@utils/.'; +import { remapAssetUrl, wildcardMatcher } from '@utils/.'; export const AuthContext = createContext({ user: null, @@ -23,6 +23,25 @@ export const AuthContext = createContext({ registerUserAccount: async () => 0, }); +const authCheckErrors = { + INVALID_USER_TYPE: 'Invalid user type in local storage', + NOT_AUTHORISED: 'You must be logged in to access this page', + NO_PERMISSION: 'You do not have permission to access this page', + ALREADY_LOGGED_IN: 'You are already logged in. Redirecting to home page.', +} as const; + +const showNotification = (title: keyof typeof authCheckErrors) => { + notifications.show({ + // CAPS_CASE to Title Case + title: title + .toLowerCase() + .replace(/_/g, ' ') + .replace(/(^\w|\s\w)/g, (m) => m.toUpperCase()), + message: authCheckErrors[title], + color: 'red', + }); +}; + export const AuthProvider = ({ children }: AuthProviderProps) => { const [user, setUser] = useState(null); const [justLoggedIn, setJustLoggedIn] = useState(false); // used to prevent redirecting to home page after login @@ -55,27 +74,20 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { const validUser = validateUserType(user); if (!validUser) { logout(); - notifications.show({ - title: 'Error', - message: 'Invalid user type in local storage', - color: 'red', - }); - /* - throw new ClientError({ - message: 'Invalid user type in local storage' + JSON.stringify(user), - }); - */ + showNotification('INVALID_USER_TYPE'); } + const disallowedRoutes = Array.from(allRoutes).filter( + (route) => !allowedRoutes.includes(route), + ); + + // check if the current route is allowed if (allowedRoutes.some((route) => memoWildcardMatcher(pathname, route))) { return; } + // if the current route is not allowed, check if the user is logged in if (!user) { - notifications.show({ - title: 'Error', - message: 'You must be logged in to access this page', - color: 'red', - }); + showNotification('NOT_AUTHORISED'); // convert search params to an object and then to a query string const constructedSearchParams = Object.entries(Object.fromEntries(params)) .map(([key, value]) => `${key}=${value}`) @@ -85,21 +97,17 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { return; } + // if the user is logged in and tries to access the login or signup page if (user && (pathname === '/auth/login' || pathname === '/auth/signup')) { - notifications.show({ - title: 'Info', - message: 'You are already logged in. Redirecting to home page.', - color: 'red', - }); + showNotification('ALREADY_LOGGED_IN'); router.replace('/'); return; } - notifications.show({ - title: 'Error', - message: 'You do not have permission to access this page', - color: 'red', - }); - router.replace('/'); + // if the user is logged in but does not have permission to access the current route + if (disallowedRoutes.some((route) => memoWildcardMatcher(pathname, route))) { + showNotification('NO_PERMISSION'); + router.replace('/'); + } }, [user, loading]); useEffect(() => { @@ -118,10 +126,9 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { logout(); return; } - localStorage.setItem( - 'access_token', - (res.data as Omit).access_token, - ); + const data = res.data as Omit; + localStorage.setItem('access_token', data.access_token); + localStorage.setItem('access_token_expire', data.expire.toString()); }) .catch(logout); } diff --git a/interapp-frontend/src/utils/getAllUsernames.ts b/interapp-frontend/src/utils/getAllUsernames.ts index 1e9d483a..d2f17843 100644 --- a/interapp-frontend/src/utils/getAllUsernames.ts +++ b/interapp-frontend/src/utils/getAllUsernames.ts @@ -1,10 +1,18 @@ import { APIClient } from '@api/api_client'; import { User } from '@providers/AuthProvider/types'; +import { ClientError } from './parseClientError'; export async function getAllUsernames() { const apiClient = new APIClient().instance; const get_all_users = await apiClient.get('/user'); + if (get_all_users.status !== 200) + throw new ClientError({ + message: 'Failed to get all users', + responseStatus: get_all_users.status, + responseBody: get_all_users.data, + }); + const all_users: Omit[] = get_all_users.data; const allUsersNames = all_users !== undefined ? all_users.map((user) => user.username) : []; return allUsersNames; diff --git a/nginx/default.conf b/nginx/default.conf index 51b2dda8..0b39a840 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -32,9 +32,6 @@ server { location /_next/static { proxy_cache STATIC; proxy_pass http://frontend; - - # For testing cache - remove before deploying to production - add_header X-Cache-Status $upstream_cache_status; } location /static { @@ -42,9 +39,6 @@ server { proxy_ignore_headers Cache-Control; proxy_cache_valid 60m; proxy_pass http://frontend; - - # For testing cache - remove before deploying to production - add_header X-Cache-Status $upstream_cache_status; } location /api {