From 40131c77508c20dc494890d22d674510a3c696a4 Mon Sep 17 00:00:00 2001 From: maydayv7 Date: Wed, 9 Sep 2026 00:29:54 +0530 Subject: [PATCH 01/20] fix: Remove legacy admin routes --- admin/src/apis/student.js | 34 +- server/.env.example | 6 +- server/modules/admin/admin.controller.js | 224 ----------- server/modules/admin/admin.provisioning.js | 59 +++ server/modules/admin/admin.routes.js | 48 +-- server/modules/admin/admin.utils.js | 360 ------------------ server/modules/admin/auth.controller.js | 12 +- server/modules/course/course.controller.js | 37 +- server/modules/course/course.routes.js | 18 +- .../modules/onedrive/onedrive.controller.js | 215 +---------- server/modules/onedrive/onedrive.routes.js | 14 +- server/modules/student/student.controller.js | 66 +++- server/package.json | 5 +- server/scripts/provisionAdmin.js | 42 ++ server/scripts/seed_app.js | 104 ----- server/services/UploadFile.js | 80 +++- server/services/imagekit.js | 24 +- server/test/admin-provisioning.test.js | 156 ++++++++ .../integration/admin-provisioning.test.js | 199 ++++++++++ server/test/support/environment.js | 8 + server/utils/course.js | 25 +- 21 files changed, 693 insertions(+), 1043 deletions(-) delete mode 100644 server/modules/admin/admin.controller.js create mode 100644 server/modules/admin/admin.provisioning.js delete mode 100644 server/modules/admin/admin.utils.js create mode 100644 server/scripts/provisionAdmin.js delete mode 100644 server/scripts/seed_app.js create mode 100644 server/test/admin-provisioning.test.js create mode 100644 server/test/integration/admin-provisioning.test.js create mode 100644 server/test/support/environment.js diff --git a/admin/src/apis/student.js b/admin/src/apis/student.js index f4f5f750..764670e8 100644 --- a/admin/src/apis/student.js +++ b/admin/src/apis/student.js @@ -4,16 +4,14 @@ import { API_BASE_URL } from "./server.js"; // Pass brOnly=true to fetch only Branch Representatives. export const fetchStudents = async (brOnly = false) => { try { - const url = brOnly - ? `${API_BASE_URL}api/br/allBRs` - : `${API_BASE_URL}api/student/all`; - const response = await fetch(url, {credentials:"include"}); + const url = brOnly ? `${API_BASE_URL}api/br/allBRs` : `${API_BASE_URL}api/student/all`; + const response = await fetch(url, { credentials: "include" }); const data = await response.json(); - return { students : data.students || data.brs || []}; } - catch (error) { - console.error("Error fetching students :" , error ); - throw error ; - } + return { students: data.students || data.brs || [] }; + } catch (error) { + console.error("Error fetching students :", error); + throw error; + } }; // Search students by name or roll number. @@ -22,10 +20,9 @@ export const searchStudents = async (query, brOnly = false) => { try { const params = new URLSearchParams({ q: query }); if (brOnly) params.set("isBR", "true"); - const response = await fetch( - `${API_BASE_URL}api/student/search?${params.toString()}`, - { credentials: "include" } - ); + const response = await fetch(`${API_BASE_URL}api/student/search?${params.toString()}`, { + credentials: "include", + }); return await response.json(); } catch (error) { console.error("Error searching students:", error); @@ -42,7 +39,8 @@ export const refreshStudentCourses = async (id) => { credentials: "include", }); const result = await response.json(); - if (!response.ok) throw new Error(result.error || result.message || "Failed to refresh courses"); + if (!response.ok) + throw new Error(result.error || result.message || "Failed to refresh courses"); return result; } catch (error) { console.error("Error refreshing student courses:", error); @@ -58,7 +56,8 @@ export const deleteStudent = async (id) => { credentials: "include", }); const result = await response.json(); - if (!response.ok) throw new Error(result.error || result.message || "Failed to delete student"); + if (!response.ok) + throw new Error(result.error || result.message || "Failed to delete student"); return result; } catch (error) { console.error("Error deleting student:", error); @@ -66,7 +65,7 @@ export const deleteStudent = async (id) => { } }; -// Semester reset — deletes all UserUpdate records and clears courses for every student. +// Semester reset - deletes all UserUpdate records and clears courses for every student. export const semesterReset = async () => { try { const response = await fetch(`${API_BASE_URL}api/student/semester-reset`, { @@ -74,7 +73,8 @@ export const semesterReset = async () => { credentials: "include", }); const result = await response.json(); - if (!response.ok) throw new Error(result.error || result.message || "Semester reset failed"); + if (!response.ok) + throw new Error(result.error || result.message || "Semester reset failed"); return result; } catch (error) { console.error("Error during semester reset:", error); diff --git a/server/.env.example b/server/.env.example index 3b187f72..4aab568f 100644 --- a/server/.env.example +++ b/server/.env.example @@ -6,6 +6,10 @@ MONGO_URI= JWT_SECRET= ADMIN_JWT_SECRET= +# Administrator provisioning: npm run provision +ADMIN_USER_ID= # 1-128 characters +ADMIN_PASSWORD= # >12 characters + # Azure AD / Microsoft Graph auth AZURE_CLIENT_ID= AZURE_CLIENT_SECRET= @@ -19,7 +23,7 @@ API_BASE_URL= # OneDrive storage ONEDRIVE_FOLDER_ID= -# ImageKit — get from ImageKit dashboard > Developer Options +# ImageKit - get from ImageKit dashboard > Developer Options IMAGEKIT_PUBLIC_KEY= IMAGEKIT_PRIVATE_KEY= IMAGEKIT_URL_ENDPOINT= diff --git a/server/modules/admin/admin.controller.js b/server/modules/admin/admin.controller.js deleted file mode 100644 index 6d9b05e3..00000000 --- a/server/modules/admin/admin.controller.js +++ /dev/null @@ -1,224 +0,0 @@ -import AppError from "../../utils/appError.js"; -import CourseModel, { FileModel, FolderModel } from "../course/course.model.js"; -import Contribution from "../contribution/contribution.model.js"; -import { getAllCourseIds, visitCourseById } from "../onedrive/onedrive.controller.js"; -import SearchResults from "../search/search.model.js"; -import Admin from "./admin.model.js"; -import { - createCourseStructure, - getFolderIdByName, - getFolderVisitLink, - moveAllFolderFiles, - moveFile, -} from "./admin.utils.js"; -import fs from "fs"; -import csv from "csv-parser"; -import { normalizeCourseCode, getCourseCodeCaseInsensitiveRegex } from "../../utils/course.js"; -import logger from "../../utils/logger.js"; - -const buildCourseFilePrefixMatcher = (code) => ({ - $regex: `^${normalizeCourseCode(code)}\\s-\\s`, - $options: "i", -}); - -async function createAdmin(req, res, next) { - const { body } = req; - const newAdmin = await Admin.create(body); - return res.json(newAdmin); -} - -async function getAdmin(req, res, next) { - const admin = req.admin; - if (!admin) return next(new AppError(500, "Something went wrong!")); - return res.json({ - user: { - userId: admin.userId, - }, - }); -} - -async function getOnedriveCourses(req, res, next) { - const allCourses = await getAllCourseIds(); - res.json(allCourses); -} - -async function getDBCourses(req, res, next) { - const dbCourses = await CourseModel.find({}); - return res.json(dbCourses); -} - -async function deleteCourseByCode(req, res, next) { - const normalizedCode = normalizeCourseCode(req.params.code); - if (!normalizedCode) return next(new AppError(400, "invalid course code")); - const courseCodeRegex = getCourseCodeCaseInsensitiveRegex(normalizedCode); - - const search = await SearchResults.findOne({ code: courseCodeRegex }); - if (!search) return next(new AppError(400, "invalid course code")); - await CourseModel.deleteOne({ code: courseCodeRegex }); - const courseFolders = await FolderModel.find({ courses: courseCodeRegex }); - for (const folder of courseFolders) { - if (folder.courses.length > 1) { - await FolderModel.updateOne({ _id: folder._id }, { $pull: { courses: normalizedCode } }); - } else { - await FolderModel.deleteOne({ _id: folder._id }); - } - } - await FileModel.deleteMany({ course: buildCourseFilePrefixMatcher(normalizedCode) }); - await SearchResults.updateOne({ code: courseCodeRegex }, { isAvailable: false }); - return res.json({ deleted: true }); -} - -async function makeCourseById(req, res, next) { - let { body } = req; - let { id, code } = body; - const normalizedCode = normalizeCourseCode(code); - if (!normalizedCode) return next(new AppError(400, "invalid course code")); - const courseCodeRegex = getCourseCodeCaseInsensitiveRegex(normalizedCode); - - const search = await SearchResults.findOne({ code: courseCodeRegex }); - if (search) { - await CourseModel.deleteOne({ code: courseCodeRegex }); - const courseFolders = await FolderModel.find({ courses: courseCodeRegex }); - for (const folder of courseFolders) { - if (folder.courses.length > 1) { - await FolderModel.updateOne({ _id: folder._id }, { $pull: { courses: normalizedCode } }); - } else { - await FolderModel.deleteOne({ _id: folder._id }); - } - } - await FileModel.deleteMany({ course: buildCourseFilePrefixMatcher(normalizedCode) }); - await SearchResults.updateOne({ code: courseCodeRegex }, { isAvailable: false }); - } - await visitCourseById(id); - return res.json({ created: true }); -} - -async function uploadToFolder(req, res, next) { - let { body } = req; - let { contributionId, toFolderId, courseCode } = body; - courseCode = normalizeCourseCode(courseCode); - if (!courseCode) return next(new AppError(400, "invalid course code")); - const courseCodeRegex = getCourseCodeCaseInsensitiveRegex(courseCode); - const _fromFolderId = await getFolderIdByName(contributionId); - - const resp = await moveAllFolderFiles(_fromFolderId, toFolderId); - // mark contribution approved - const allCourses = await getAllCourseIds(); - - const courseIdObj = allCourses.find( - (course) => normalizeCourseCode(course.name.split("-")[0].trim()) === courseCode - ); - if (!courseIdObj) return next(new AppError(404, "Course not found in OneDrive")); - const courseId = courseIdObj.id; - const search = await SearchResults.findOne({ code: courseCodeRegex }); - if (search) { - await CourseModel.deleteOne({ code: courseCodeRegex }); - const courseFolders = await FolderModel.find({ courses: courseCodeRegex }); - for (const folder of courseFolders) { - if (folder.courses.length > 1) { - await FolderModel.updateOne({ _id: folder._id }, { $pull: { courses: courseCode } }); - } else { - await FolderModel.deleteOne({ _id: folder._id }); - } - } - await FileModel.deleteMany({ course: buildCourseFilePrefixMatcher(courseCode) }); - await SearchResults.updateOne({ code: courseCodeRegex }, { isAvailable: false }); - } - await visitCourseById(courseId); - await Contribution.updateOne({ contributionId: contributionId }, { approved: true }); - return res.json({ approved: true }); -} - -async function getCourseFolder(req, res, next) { - const code = normalizeCourseCode(req.params.code); - if (!code) return next(new AppError(400, "invalid course code")); - const existingCourse = await CourseModel.findOne({ code: getCourseCodeCaseInsensitiveRegex(code) }) - .populate({ - path: "children", - select: "-__v", - populate: { - path: "children", - select: "-__v", - populate: { - strictPopulate: false, - path: "children", - select: "-__v", - populate: { - strictPopulate: false, - path: "children", - select: "-__v", - populate: { - strictPopulate: false, - path: "children", - select: "-__v", - populate: { - strictPopulate: false, - path: "children", - select: "-__v", - populate: { - strictPopulate: false, - path: "children", - select: "-__v", - populate: { - strictPopulate: false, - path: "children", - select: "-__v", - }, - }, - }, - }, - }, - }, - }, - }) - .select("-__v"); - logger.debug("Existing course inspected", { attributes: { dependency: "mongodb", operation: "inspect-course", outcome: "success" } }); - if (!existingCourse) return next(new AppError(404, "Course not found")); - return res.json(existingCourse); -} -async function getFolderLink(req, res, next) { - const { folderName } = req.params; - - const visitLink = await getFolderVisitLink(folderName); - return res.json({ link: visitLink }); -} -async function getFolderId(req, res, next) { - const { folderName } = req.params; - - const id = await getFolderIdByName(folderName); - return res.json({ id: id }); -} - -async function createNewCourseFolders(req, res, next) { - let courseCodeName = req.body.code; - if (!courseCodeName) return next(new AppError(500, "code-name required")); - const resp = await createCourseStructure(courseCodeName); - return res.json({ - id: resp, - }); -} - -async function markApproved(req, res, next) { - let contri_id = req.body.id; - if (!contri_id) return next(new AppError(400, "Invalid request")); - const contribution = await Contribution.findOne({ contributionId: contri_id }); - if (!contribution) return next(new AppError(400, "Invalid Id")); - contribution.approved = true; - await contribution.save(); - return res.json("approved"); -} - -export default { - createAdmin, - getAdmin, - getOnedriveCourses, - getDBCourses, - deleteCourseByCode, - makeCourseById, - getCourseFolder, - uploadToFolder, - getFolderLink, - getFolderId, - createNewCourseFolders, - markApproved, -}; diff --git a/server/modules/admin/admin.provisioning.js b/server/modules/admin/admin.provisioning.js new file mode 100644 index 00000000..9a5ce206 --- /dev/null +++ b/server/modules/admin/admin.provisioning.js @@ -0,0 +1,59 @@ +import Admin from "./admin.model.js"; + +export class AdminProvisioningError extends Error { + constructor(code, message) { + super(message); + this.name = "AdminProvisioningError"; + this.code = code; + } +} + +export function validateAdminCredentials({ userId, password } = {}) { + if ( + typeof userId !== "string" || + !userId.trim() || + userId.trim().length > 128 || + /[\u0000-\u001f\u007f]/u.test(userId) + ) { + throw new AdminProvisioningError( + "INVALID_USER_ID", + "An explicit ADMIN_USER_ID is required (1-128 characters, without control characters).", + ); + } + // bcrypt uses at most 72 UTF-8 bytes + if ( + typeof password !== "string" || + !password.trim() || + Array.from(password).length < 12 || + Buffer.byteLength(password, "utf8") > 72 + ) { + throw new AdminProvisioningError( + "INVALID_PASSWORD", + "An explicit ADMIN_PASSWORD is required (at least 12 characters and at most 72 UTF-8 bytes).", + ); + } + return { userId: userId.trim(), password }; +} + +function existingAdministrator() { + return new AdminProvisioningError( + "ADMIN_EXISTS", + "This administrator already exists, credentials have not been changed.", + ); +} + +export async function provisionAdmin(input) { + const credentials = validateAdminCredentials(input); + // Also enforce the schema's existing unique index when autoIndex is disabled + await Admin.createIndexes(); + if (await Admin.exists({ userId: credentials.userId })) throw existingAdministrator(); + + try { + // Use the model save hook so credentials are hashed exactly as for login + const admin = await Admin.create(credentials); + return { id: admin._id.toString(), userId: admin.userId }; + } catch (error) { + if (error.code === 11000) throw existingAdministrator(); + throw error; + } +} diff --git a/server/modules/admin/admin.routes.js b/server/modules/admin/admin.routes.js index f7b96d87..a1316d79 100644 --- a/server/modules/admin/admin.routes.js +++ b/server/modules/admin/admin.routes.js @@ -1,12 +1,20 @@ import express from "express"; import catchAsync from "../../utils/catchAsync.js"; -import adminController from "./admin.controller.js"; -import AppError from "../../utils/appError.js"; import isAdmin from "../../middleware/isAdmin.js"; import multer from "multer"; -import { adminLogin, adminLogout } from "./auth.controller.js"; -import { uploadCourses, renameCourse, deleteCourse, linkLegacyCourse, bulkLinkCourses, syncCoursesCacheController } from "./adminDashboard.controller.js"; -import {getCourseDashboardData, handleContribution, deleteNode} from "./adminDashboard.controller.js" +import { adminLogin, adminLogout, getAdmin } from "./auth.controller.js"; +import { + getDBCourses, + uploadCourses, + renameCourse, + deleteCourse, + linkLegacyCourse, + bulkLinkCourses, + syncCoursesCacheController, + getCourseDashboardData, + handleContribution, + deleteNode, +} from "./adminDashboard.controller.js"; const router = express.Router(); const upload = multer({ dest: "uploads/" }); @@ -15,35 +23,11 @@ const upload = multer({ dest: "uploads/" }); router.post("/auth/login", catchAsync(adminLogin)); router.post("/auth/logout", catchAsync(adminLogout)); -router.post( - "/", - function (req, res, next) { - if (req.headers.authorization === "ankit99") { - return next(); - } - return next(new AppError(403, "Not Authorized")); - }, - catchAsync(adminController.createAdmin) -); - router.get("/course/:code/dashboard", isAdmin, catchAsync(getCourseDashboardData)); router.post("/contribution/action", isAdmin, catchAsync(handleContribution)); -router.delete("/node/:type/:id", isAdmin,catchAsync(deleteNode)); -router.get("/", isAdmin, catchAsync(adminController.getAdmin)); -router.get("/onedrivecourses", isAdmin, catchAsync(adminController.getOnedriveCourses)); -router.get("/dbcourses", isAdmin, catchAsync(adminController.getDBCourses)); -router.delete("/course/:code", isAdmin, catchAsync(adminController.deleteCourseByCode)); -router.post("/course", isAdmin, catchAsync(adminController.makeCourseById)); -router.get("/course/:code", isAdmin, catchAsync(adminController.getCourseFolder)); -router.post("/contribution/approve", isAdmin, catchAsync(adminController.uploadToFolder)); -router.get("/contribution/visit/:folderName", isAdmin, catchAsync(adminController.getFolderLink)); -router.get("/contribution/id/:folderName", isAdmin, catchAsync(adminController.getFolderId)); -router.post("/contribution/markapproved", isAdmin, catchAsync(adminController.markApproved)); -router.post( - "/contribution/bootstrapnewcourse", - isAdmin, - catchAsync(adminController.createNewCourseFolders) -); +router.delete("/node/:type/:id", isAdmin, catchAsync(deleteNode)); +router.get("/", isAdmin, catchAsync(getAdmin)); +router.get("/dbcourses", isAdmin, catchAsync(getDBCourses)); router.post("/courses/upload", isAdmin, upload.single("file"), uploadCourses); router.post("/courses/bulk-link", isAdmin, upload.single("file"), bulkLinkCourses); diff --git a/server/modules/admin/admin.utils.js b/server/modules/admin/admin.utils.js deleted file mode 100644 index 186b7420..00000000 --- a/server/modules/admin/admin.utils.js +++ /dev/null @@ -1,360 +0,0 @@ -import fs from "fs"; -import { getAccessToken, getRequest, postRequest, visitCourseById } from "../onedrive/onedrive.controller.js"; -import axios from "axios"; -import logger from "../../utils/logger.js"; - -export async function moveAllFolderFiles(fromFolderId, toFolderId) { - const accessToken = await getAccessToken(); - var headers = { - Authorization: `Bearer ${accessToken}`, - Host: "graph.microsoft.com", - }; - var url = `https://graph.microsoft.com/v1.0/me/drive/items/${fromFolderId}/children`; - var config = { - method: "get", - url: url, - headers: headers, - }; - const response = await axios.get(config.url, { - headers: config.headers, - }); - response.data.value.map(async (file) => { - await moveFile(file.id, toFolderId, file.name); - }); -} - -export async function moveFile(fileId, folderId, name) { - const accessToken = await getAccessToken(); - var headers = { - Authorization: `Bearer ${accessToken}`, - Host: "graph.microsoft.com", - }; - var url = `https://graph.microsoft.com/v1.0/me/drive/items/${fileId}`; - var config = { - method: "patch", - url: url, - headers: headers, - data: { - parentReference: { - id: folderId, - }, - name: name, - }, - }; - const response = await axios.patch(config.url, config.data, { - headers: config.headers, - }); - - return response?.data; -} -export async function getFolderVisitLink(folderName) { - var access_token = await getAccessToken(); - var headers = { - Authorization: `Bearer ${access_token}`, - Host: "graph.microsoft.com", - }; - var url = `https://graph.microsoft.com/v1.0/me/drive/root:/CourseHub Contributions/${folderName}:/`; - var data = await getRequest(url, headers); - return data["webUrl"]; -} -export async function getFolderIdByName(folderName) { - var access_token = await getAccessToken(); - var headers = { - Authorization: `Bearer ${access_token}`, - Host: "graph.microsoft.com", - }; - var url = `https://graph.microsoft.com/v1.0/me/drive/root:/CourseHub Contributions/${folderName}:/`; - var data = await getRequest(url, headers); - return data["id"]; -} - -function formatCourseAdmin(course) { - const root = course.children; - const ret = []; - - root.forEach((folder) => { - parseFolder(folder); - }); - fs.writeFileSync("resp.json", JSON.stringify(root)); -} - -function parseFolder(folder) { - logger.debug("Admin folder inspected", { attributes: { dependency: "microsoft-graph", operation: "inspect-folder", outcome: "success" } }); - if (folder.childType === "File") { - logger.debug("Nested admin folder inspected", { attributes: { dependency: "microsoft-graph", operation: "inspect-folder", outcome: "success" } }); - delete folder.children; - } else if (folder.childType === "Folder") { - parseFolder(folder.children); - } -} - -async function createFolder(folderName, parentFolderId) { - var access_token = await getAccessToken(); - var headers = { - Authorization: `Bearer ${access_token}`, - Host: "graph.microsoft.com", - }; - var url = `https://graph.microsoft.com/v1.0/me/drive/items/${parentFolderId}/children`; - let data = { - name: folderName, - folder: {}, - "@microsoft.graph.conflictBehavior": "rename", - }; - const resp = await axios.post(url, data, { headers }); - return resp?.data; -} - -export async function createCourseStructure(foldername) { - const rootCoursesFolder = "01OXYV37Y64PLOWXJRRBGKGSMVOFLO3OPZ"; - const resp = await createFolder(foldername, rootCoursesFolder); - const newFolderId = resp?.id; - if (!newFolderId) return; - const yearResp = await createFolder("All Years", newFolderId); - const newYearFolderId = yearResp?.id; - if (!newYearFolderId) return; - await createFolder("Exams", newYearFolderId); - await createFolder("Notes", newYearFolderId); - await createFolder("Books", newYearFolderId); - await createFolder("Slides", newYearFolderId); - await visitCourseById(newFolderId); - return newFolderId; -} - -const data = { - _id: "63fb920f68fe6671b91b0f47", - name: "group theory", - code: "ch224", - children: [ - { - _id: "63fb920e68fe6671b91b0e68", - course: "ch224", - id: "01OXYV376COKXM6WC3BVBJ2B5F5ITSHHIO", - name: "2022", - childType: "Folder", - path: "CH224 - Group Theory/", - children: [ - { - _id: "63fb91d968fe6671b91aea82", - course: "ch224", - id: "01OXYV377YUW6CTHNFMBHJ7QECDSJQHKXL", - name: "Books", - childType: "File", - path: "CH224 - Group Theory/2022/", - children: [ - { - _id: "63fb91d868fe6671b91aea2a", - course: "ch224 - group theory", - name: "Chemical Applications of Group Theory Solutions.pdf", - id: "01OXYV377NLCQRAOFREJF2LIJ3W33BAK7N", - size: "0.674368", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV377NLCQRAOFREJF2LIJ3W33BAK7N_ksWOAv0sPHe.jpg", - }, - { - _id: "63fb91d768fe6671b91ae9b2", - course: "ch224 - group theory", - name: "Chemical Applications of Group Theory.pdf", - id: "01OXYV373DQHVOX5OHKZD2TIBREIPSSUOH", - size: "18.623310999999998", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV373DQHVOX5OHKZD2TIBREIPSSUOH_hwj1_BO71M.jpg", - }, - ], - }, - { - _id: "63fb920d68fe6671b91b0e06", - course: "ch224", - id: "01OXYV37575D6MVVSSLJFISSKVMKNZKJ4X", - name: "Exams", - childType: "Folder", - path: "CH224 - Group Theory/2022/", - children: [ - { - _id: "63fb920a68fe6671b91b0c9a", - course: "ch224", - id: "01OXYV37YG4FZ5FA7UYNBYQMDPUIV3J3JC", - name: "Past Years", - childType: "File", - path: "CH224 - Group Theory/2022/Exams/", - children: [ - { - _id: "63fb91fc68fe6671b91b08fc", - course: "ch224 - group theory", - name: "32_Reducible representations.pdf", - id: "01OXYV377NT3JXS5ZPU5CZCWV5Z42J24ZK", - size: "0.019053999999999998", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV377NT3JXS5ZPU5CZCWV5Z42J24ZK_pnsEi7-MsYS.jpg", - }, - { - _id: "63fb91fc68fe6671b91b053e", - course: "ch224 - group theory", - name: "assignment 2 1.pdf", - id: "01OXYV37YSY6XB6TST5VFKUEENLKQ4RFAJ", - size: "0.09335199999999999", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV37YSY6XB6TST5VFKUEENLKQ4RFAJ_upgzt7wfMYU.jpg", - }, - { - _id: "63fb91fc68fe6671b91b04de", - course: "ch224 - group theory", - name: "assignment1 (1).pdf", - id: "01OXYV372JOXPWTQFZ6JC3YTVM5URR53BK", - size: "0.046338", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV372JOXPWTQFZ6JC3YTVM5URR53BK_hrP4O6-p2C.jpg", - }, - { - _id: "63fb91fb68fe6671b91b0062", - course: "ch224 - group theory", - name: "assignment1.pdf", - id: "01OXYV37YDSKK2HUJ6C5ALV5JGEOFJ276E", - size: "0.046338", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV37YDSKK2HUJ6C5ALV5JGEOFJ276E_Ob02geb00a4.jpg", - }, - { - _id: "63fb91fb68fe6671b91b002c", - course: "ch224 - group theory", - name: "endsem_12_april.pdf", - id: "01OXYV37345SBVG4HIXBE25EG5XQC7EILO", - size: "0.223436", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV37345SBVG4HIXBE25EG5XQC7EILO_jF7pOPASlfr.jpg", - }, - { - _id: "63fb91fb68fe6671b91aff22", - course: "ch224 - group theory", - name: "endsem_13_april.pdf", - id: "01OXYV372AWUJ2HVQ22FAJK2SOER2HLYY3", - size: "0.535994", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV372AWUJ2HVQ22FAJK2SOER2HLYY3_5ZGkxky7by.jpg", - }, - { - _id: "63fb91fb68fe6671b91aff52", - course: "ch224 - group theory", - name: "endsem_16_april.pdf", - id: "01OXYV377LEAY5UG5RCFBJULQMTNVBSLQP", - size: "0.9255009999999999", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV377LEAY5UG5RCFBJULQMTNVBSLQP_DvpsYJkQel.jpg", - }, - { - _id: "63fb91fc68fe6671b91b0728", - course: "ch224 - group theory", - name: "endsem_17_april.pdf", - id: "01OXYV377GQHI6SVI22ZHZZGVNCOCEDNCW", - size: "0.928761", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV377GQHI6SVI22ZHZZGVNCOCEDNCW_aRFGadLooQu.jpg", - }, - { - _id: "63fb91fc68fe6671b91b04c6", - course: "ch224 - group theory", - name: "endsem_19_april.pdf", - id: "01OXYV374KOFTS3T2ZX5FYSI6B6BP24KCY", - size: "0.934496", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV374KOFTS3T2ZX5FYSI6B6BP24KCY_nRqCbaeaBIg.jpg", - }, - { - _id: "63fb91fc68fe6671b91b084e", - course: "ch224 - group theory", - name: "endsem_20_april.pdf", - id: "01OXYV375OJ4574J6QE5CIQFH7QER7PIT5", - size: "0.887552", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV375OJ4574J6QE5CIQFH7QER7PIT5_8rBSPiqmMBk.jpg", - }, - { - _id: "63fb91fb68fe6671b91b00de", - course: "ch224 - group theory", - name: "M200122042.pdf", - id: "01OXYV3727NJFC2RBU5FEZZEX2BMKKKO3G", - size: "13.947334999999999", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV3727NJFC2RBU5FEZZEX2BMKKKO3G_6Pm6MA4tt5.jpg", - }, - { - _id: "63fb91fb68fe6671b91b006e", - course: "ch224 - group theory", - name: "Quiz 1 CH224.pdf", - id: "01OXYV377UACCV5TXSRJAJNKKW3EIT66KW", - size: "0.469167", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV377UACCV5TXSRJAJNKKW3EIT66KW_WJgBJgL-cXO.jpg", - }, - { - _id: "63fb91fb68fe6671b91b0246", - course: "ch224 - group theory", - name: "Quiz delocalisation energy etc.pdf", - id: "01OXYV375YQXWCKPH7INB2VUBQEDJYAGYT", - size: "5.403824", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV375YQXWCKPH7INB2VUBQEDJYAGYT_ZMg_O1DXNcl.jpg", - }, - { - _id: "63fb91fb68fe6671b91b0026", - course: "ch224 - group theory", - name: "quiz1.pdf", - id: "01OXYV374LBZCJTYV6OJDJEZEGADOC636Q", - size: "0.182163", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV374LBZCJTYV6OJDJEZEGADOC636Q_BIgYKETPH7Y.jpg", - }, - { - _id: "63fb91fb68fe6671b91affce", - course: "ch224 - group theory", - name: "quiz4.pdf", - id: "01OXYV374RM3577L37FFHIDNV4TS36XFJ3", - size: "0.13824799999999998", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV374RM3577L37FFHIDNV4TS36XFJ3_neMI2hfoon7.jpg", - }, - { - _id: "63fb91fc68fe6671b91b05c2", - course: "ch224 - group theory", - name: "Screenshot (49).png", - id: "01OXYV375SIHN272YPMZCI7I5MK4TELKGK", - size: "0.35863", - thumbnail: - "https://ik.imagekit.io/c7u7l7qa8/01OXYV375SIHN272YPMZCI7I5MK4TELKGK_87ei1JiaCGv.jpg", - }, - ], - }, - ], - }, - { - _id: "63fb91d368fe6671b91ae82c", - course: "ch224", - id: "01OXYV372UMTE4W5Y6K5A3K5HIGQOKKG6Z", - name: "Lectures Slides", - childType: "File", - path: "CH224 - Group Theory/2022/", - children: [], - }, - { - _id: "63fb91d368fe6671b91ae828", - course: "ch224", - id: "01OXYV376PAFPR5JNWYBCJUKFP34CASUGA", - name: "Notes", - childType: "File", - path: "CH224 - Group Theory/2022/", - children: [], - }, - { - _id: "63fb91d368fe6671b91ae84e", - course: "ch224", - id: "01OXYV37ZAEUEFJXPA4ZDZ6T5JKFDBS7DA", - name: "Tutorials", - childType: "File", - path: "CH224 - Group Theory/2022/", - children: [], - }, - ], - }, - ], - books: [], -}; -// formatCourseAdmin(data); diff --git a/server/modules/admin/auth.controller.js b/server/modules/admin/auth.controller.js index 0e15a23d..d1ad3f3b 100644 --- a/server/modules/admin/auth.controller.js +++ b/server/modules/admin/auth.controller.js @@ -27,4 +27,14 @@ export const adminLogout = async (req, res) => { return res.json({ success: true }); }; -export default { adminLogin, adminLogout }; +export const getAdmin = async (req, res, next) => { + const admin = req.admin; + if (!admin) return next(new AppError(500, "Something went wrong!")); + return res.json({ + user: { + userId: admin.userId, + }, + }); +}; + +export default { adminLogin, adminLogout, getAdmin }; diff --git a/server/modules/course/course.controller.js b/server/modules/course/course.controller.js index 50d65292..469a9a27 100644 --- a/server/modules/course/course.controller.js +++ b/server/modules/course/course.controller.js @@ -1,6 +1,6 @@ import AppError from "../../utils/appError.js"; import User from "../user/user.model.js"; -import CourseModel, { FolderModel, FileModel } from "./course.model.js"; +import CourseModel from "./course.model.js"; import logger from "../../utils/logger.js"; import SearchResults from "../search/search.model.js"; import courselist from "./course.list.js"; @@ -73,7 +73,7 @@ export const getCourse = async (req, res, next) => { if (a?.name > b?.name) return 1; else if (a?.name < b?.name) return -1; else return 1; - } + }; if (courseObj?.children && courseObj.children.length > 0) { for (const childFolder of courseObj.children) { @@ -93,39 +93,18 @@ export const getCourse = async (req, res, next) => { } logger.metric?.("course_opened", { - value: 1, - dimensions: { + value: 1, + dimensions: { courseCode: courseObj.code, userEmail: req.user ? req.user.email : "guest", department: req.user ? req.user.department : "unknown", - semester: req.user ? req.user.semester : 0 - } + semester: req.user ? req.user.semester : 0, + }, }); return res.json({ found: true, ...courseObj }); }; -export const deleteCourseByCode = async (req, res, next) => { - const { code } = req.params; - const normalizedCode = normalizeCourseCode(code); - if (!normalizedCode) throw new AppError(400, "Missing Course Id"); - - const courseCodeRegex = getCourseCodeCaseInsensitiveRegex(normalizedCode); - const courseFolders = await FolderModel.find({ courses: courseCodeRegex }); - for (const folder of courseFolders) { - if (folder.courses.length > 1) { - await FolderModel.updateOne({ _id: folder._id }, { $pull: { courses: normalizedCode } }); - } else { - await FolderModel.deleteOne({ _id: folder._id }); - } - } - await FileModel.deleteMany({ - course: { $regex: `^${normalizedCode}\\s-\\s`, $options: "i" }, - }); - await CourseModel.deleteOne({ code: courseCodeRegex }); - res.sendStatus(200); -}; - export const getAllCourses = async (req, res, next) => { const allCourse = await CourseModel.find().select("_id name code"); @@ -137,9 +116,7 @@ export const isCourseUpdated = async (req, res, next) => { if (!clientOn) return next(new AppError(500, "Invalid data provided!")); let outdatedOnClient = []; - const courses = req.user.courses - .map((c) => normalizeCourseCode(c.code)) - .filter(Boolean); + const courses = req.user.courses.map((c) => normalizeCourseCode(c.code)).filter(Boolean); const searchResults = await SearchResults.find({ code: { $in: courses.map(getCourseCodeCaseInsensitiveRegex) }, }); diff --git a/server/modules/course/course.routes.js b/server/modules/course/course.routes.js index 06b1f4ae..862997f1 100644 --- a/server/modules/course/course.routes.js +++ b/server/modules/course/course.routes.js @@ -1,10 +1,5 @@ import express from "express"; -import { - deleteCourseByCode, - getAllCourses, - getCourse, - isCourseUpdated, -} from "./course.controller.js"; +import { getAllCourses, getCourse, isCourseUpdated } from "./course.controller.js"; import CourseModel from "./course.model.js"; import { normalizeCourseCode, getCourseCodeCaseInsensitiveRegex } from "../../utils/course.js"; import logger from "../../utils/logger.js"; @@ -41,12 +36,19 @@ router.post("/create/:code", async (req, res) => { return res.status(201).json({ message: "Course created successfully", course: newCourse }); } catch (error) { - logger.error("Course creation failed", { error, attributes: { dependency: "mongodb", operation: "create-course", outcome: "failure", retryable: false } }); + logger.error("Course creation failed", { + error, + attributes: { + dependency: "mongodb", + operation: "create-course", + outcome: "failure", + retryable: false, + }, + }); res.status(500).json({ message: "Server Error" }); } }); router.get("/", catchAsync(getAllCourses)); -router.get("/delete/:code", catchAsync(deleteCourseByCode)); router.post("/isUpdated", isAuthenticated, catchAsync(isCourseUpdated)); router.get("/:code", catchAsync(getCourse)); diff --git a/server/modules/onedrive/onedrive.controller.js b/server/modules/onedrive/onedrive.controller.js index e8a903d9..9aa5a6cf 100644 --- a/server/modules/onedrive/onedrive.controller.js +++ b/server/modules/onedrive/onedrive.controller.js @@ -4,44 +4,17 @@ import AppError from "../../utils/appError.js"; import settings from "../../config/onedrive.js"; import fs from "fs"; import { extractGraphErrorDetails, formatGraphErrorMessage } from "../../utils/graphError.js"; -import { normalizeCourseCode, getCourseCodeCaseInsensitiveRegex } from "../../utils/course.js"; import { uploadThumbnail, isImageKitUrl } from "../../services/imagekit.js"; -import CourseModel, { FolderModel, FileModel } from "../course/course.model.js"; -import SearchResults from "../search/search.model.js"; - -const coursehub_id = process.env.ONEDRIVE_FOLDER_ID; - -const getCourseCodeFromFolderName = (name) => normalizeCourseCode(name?.split("-")[0]); -const getCourseNameFromFolderName = (name) => name?.split("-")[1]?.trim() || ""; - -export async function makeAllCourses(req, res) { - await visitAllFiles(); - return res.sendStatus(200); -} - -export async function makeCourseById(req, res) { - await visitCourseById(req.params.id); - return res.sendStatus(200); -} - -export async function getCourseIds(req, res) { - const data = await getAllCourseIds(); - return res.send(data); -} +import { FileModel } from "../course/course.model.js"; export async function thumbnail(req, res) { const fileId = req.body.fileId; - // 1. Already a permanent ImageKit URL in DB — return immediately. - // .lean() returns the raw stored value so legacy string thumbnails stay - // strings (a hydrated doc wraps the nested `thumbnail` path as an object, - // which breaks the `typeof === "string"` check below). + // 1. Already a permanent ImageKit URL in DB - return immediately. const file = await FileModel.findOne({ fileId }).select("thumbnail").lean(); const storedThumbnailUrl = - typeof file?.thumbnail === "string" - ? file.thumbnail - : file?.thumbnail?.url; + typeof file?.thumbnail === "string" ? file.thumbnail : file?.thumbnail?.url; if (storedThumbnailUrl && isImageKitUrl(storedThumbnailUrl)) { return res.status(200).json(storedThumbnailUrl); @@ -51,14 +24,18 @@ export async function thumbnail(req, res) { const access_token = await getAccessToken(); const thumbnaildata = await axios.get( `https://graph.microsoft.com/v1.0/me/drive/items/${fileId}/thumbnails`, - { headers: { Authorization: `Bearer ${access_token}` } } + { headers: { Authorization: `Bearer ${access_token}` } }, ); const thumbnailurl = thumbnaildata.data.value?.[0]?.medium?.url; if (!thumbnailurl) throw new AppError(404, "Thumbnail not found"); // 3. Download raw image bytes and upload to ImageKit as WebP permanently const imgResponse = await axios.get(thumbnailurl, { responseType: "arraybuffer" }); - const { url: permanentUrl, fileId: imagekitFileId, path: imagekitPath } = await uploadThumbnail(fileId, Buffer.from(imgResponse.data)); + const { + url: permanentUrl, + fileId: imagekitFileId, + path: imagekitPath, + } = await uploadThumbnail(fileId, Buffer.from(imgResponse.data)); // 4. Persist permanent URL to DB so this file never hits Graph API again await FileModel.updateOne( @@ -71,17 +48,12 @@ export async function thumbnail(req, res) { path: imagekitPath, }, }, - } + }, ); return res.status(200).json(permanentUrl); } -export async function getFile(req, res) { - const resp = await getFileDownloadLink(req.params.id); - return res.json({ url: resp }); -} - export async function getFilePreview(req, res) { const { fileID } = req.params; const resp = await getFileWebUrl(fileID); @@ -118,167 +90,11 @@ async function getFileWebUrl(file_id) { return data.link.webUrl; } -export async function getAllCourseIds() { - const access_token = await getAccessToken(); - const headers = { - Authorization: `Bearer ${access_token}`, - Host: "graph.microsoft.com", - }; - const url = `https://graph.microsoft.com/v1.0/me/drive/items/${coursehub_id}/children`; - const data = await getRequest(url, headers); - const children = data.value; - const resp = []; - children.map((child) => { - resp.push({ name: child.name, id: child.id }); - }); - return resp; -} - -async function visitAllFiles() { - const access_token = await getAccessToken(); - const headers = { - Authorization: `Bearer ${access_token}`, - Host: "graph.microsoft.com", - }; - const url = `https://graph.microsoft.com/v1.0/me/drive/items/${coursehub_id}/children`; - const data = await getRequest(url, headers); - const children = data.value; - const folders = children.map(async (child) => { - const folder_data = await visitFolder(child, child.name); - return folder_data; - }); - const resolved_folders = await Promise.all(folders); - await Promise.all(resolved_folders.map(async (folder) => { - const courseCode = getCourseCodeFromFolderName(folder.name); - const courseName = getCourseNameFromFolderName(folder.name); - await CourseModel.create({ - name: courseName, - code: courseCode, - children: folder.children, - }); - const searchDocument = await SearchResults.findOne({ - code: getCourseCodeCaseInsensitiveRegex(courseCode), - }); - if (!searchDocument) { - await SearchResults.create({ - name: courseName, - code: courseCode, - isAvailable: true, - }); - } else { - await SearchResults.updateOne( - { code: getCourseCodeCaseInsensitiveRegex(courseCode) }, - { - isAvailable: true, - } - ); - } - })); - return "ok"; -} - -export async function visitCourseById(id) { - const access_token = await getAccessToken(); - const headers = { - Authorization: `Bearer ${access_token}`, - Host: "graph.microsoft.com", - }; - const url = `https://graph.microsoft.com/v1.0/me/drive/items/${coursehub_id}/children`; - const data = await getRequest(url, headers); - const children = data.value; - const required_course = children.find((course) => course.id === id); - if (!required_course) throw new AppError(404, "Not Found!"); - const folder_data = await visitFolder(required_course, required_course.name); - const courseCode = getCourseCodeFromFolderName(required_course.name); - const courseName = getCourseNameFromFolderName(required_course.name); - - await CourseModel.create({ - name: courseName, - code: courseCode, - children: folder_data.children, - }); - const searchDocument = await SearchResults.findOne({ - code: getCourseCodeCaseInsensitiveRegex(courseCode), - }); - if (!searchDocument) { - await SearchResults.create({ - name: courseName, - code: courseCode, - isAvailable: true, - }); - } else { - await SearchResults.updateOne( - { code: getCourseCodeCaseInsensitiveRegex(courseCode) }, - { - isAvailable: true, - } - ); - } - - return "ok"; -} - -async function visitFolder(folder, currCourse, prevFolder) { - const access_token = await getAccessToken(); - const headers = { - Authorization: `Bearer ${access_token}`, - Host: "graph.microsoft.com", - }; - const url = `https://graph.microsoft.com/v1.0/me/drive/items/${folder.id}/children?$expand=thumbnails`; - const data = await getRequest(url, headers); - const children = data.value; - const normalizedCourseCode = getCourseCodeFromFolderName(currCourse); - let childType = "File"; - - const folders = children.map(async function (child) { - if (child.folder) { - const prevFolderName = prevFolder ? `${prevFolder}/` : ""; - const passName = prevFolderName + folder.name; - childType = "Folder"; - const nestedData = await visitFolder(child, normalizedCourseCode, passName); - return nestedData; - } - - const fileData = await visitFile(child, normalizedCourseCode); - return fileData; - }); - - const res = await Promise.all(folders); - const prevFolderName = prevFolder ? `${prevFolder}/` : "root/"; - const NewFolder = await FolderModel.create({ - courses: [normalizedCourseCode], - name: folder.name, - childType, - children: res, - path: prevFolderName, - id: folder.id, - }); - return NewFolder; -} - -async function visitFile(file, currCourse) { - const NewFile = await FileModel.create({ - course: normalizeCourseCode(currCourse), - name: file.name, - id: file.id, - size: file.size * 0.000001, - thumbnail: { - url: file?.thumbnails?.[0]?.medium?.url || "null", - }, - }); - return NewFile._id; -} - - let cachedAccessToken = null; let tokenExpiry = 0; let refreshPromise = null; export async function getAccessToken() { - - if ( - cachedAccessToken && - Date.now() < tokenExpiry - ) { + if (cachedAccessToken && Date.now() < tokenExpiry) { return cachedAccessToken; } if (refreshPromise) { @@ -295,9 +111,7 @@ export async function getAccessToken() { cachedAccessToken = data.access_token; - tokenExpiry = - Date.now() + - (data.expires_in - 60) * 1000; + tokenExpiry = Date.now() + (data.expires_in - 60) * 1000; return cachedAccessToken; })(); @@ -306,7 +120,6 @@ export async function getAccessToken() { } finally { refreshPromise = null; } - } export function clearAccessTokenCache() { @@ -365,7 +178,7 @@ export async function getRequest(url, headers) { const details = extractGraphErrorDetails(error); const appError = new AppError( details.status || 502, - formatGraphErrorMessage(details, "Microsoft Graph GET request failed") + formatGraphErrorMessage(details, "Microsoft Graph GET request failed"), ); appError.graphDetails = details; throw appError; @@ -393,7 +206,7 @@ export async function postRequest(url, headers, params) { const details = extractGraphErrorDetails(error); const appError = new AppError( details.status || 502, - formatGraphErrorMessage(details, "Microsoft Graph POST request failed") + formatGraphErrorMessage(details, "Microsoft Graph POST request failed"), ); appError.graphDetails = details; throw appError; diff --git a/server/modules/onedrive/onedrive.routes.js b/server/modules/onedrive/onedrive.routes.js index b5087d30..98afe990 100644 --- a/server/modules/onedrive/onedrive.routes.js +++ b/server/modules/onedrive/onedrive.routes.js @@ -1,22 +1,10 @@ import express from "express"; import catchAsync from "../../utils/catchAsync.js"; -import { - makeAllCourses, - makeCourseById, - getCourseIds, - thumbnail, - getFile, - getFilePreview, - getFileDownload, -} from "./onedrive.controller.js"; +import { thumbnail, getFilePreview, getFileDownload } from "./onedrive.controller.js"; const router = express.Router(); -router.get("/makeAllCourses", catchAsync(makeAllCourses)); -router.get("/makeCourseById/:id", catchAsync(makeCourseById)); -router.get("/getCourseIds", catchAsync(getCourseIds)); router.post("/thumbnail", catchAsync(thumbnail)); -router.get("/:id", catchAsync(getFile)); router.get("/preview/:fileID", catchAsync(getFilePreview)); router.get("/download/:fileID", catchAsync(getFileDownload)); diff --git a/server/modules/student/student.controller.js b/server/modules/student/student.controller.js index 879e941e..0a2a4f00 100644 --- a/server/modules/student/student.controller.js +++ b/server/modules/student/student.controller.js @@ -1,7 +1,7 @@ import User from "../user/user.model.js"; import UserUpdate from "../user/userUpdate.model.js"; import logger from "../../utils/logger.js"; -import BR from "../br/br.model.js" +import BR from "../br/br.model.js"; // GET /api/student/all?isBR=true // Returns every student sorted by rollNumber descending. // Pass isBR=true to only return Branch Representatives. @@ -12,7 +12,15 @@ const getAllStudents = async (req, res) => { const students = await User.find(filter).sort({ rollNumber: -1 }); res.status(200).json({ students }); } catch (error) { - logger.error("Student query failed", { error, attributes: { dependency: "mongodb", operation: "query-students", outcome: "failure", retryable: false } }); + logger.error("Student query failed", { + error, + attributes: { + dependency: "mongodb", + operation: "query-students", + outcome: "failure", + retryable: false, + }, + }); res.status(500).json({ error: "Internal Server Error" }); } }; @@ -44,10 +52,20 @@ const searchStudents = async (req, res) => { }, ]; - const students = await User.find({ ...brFilter, $or: orConditions }).sort({ rollNumber: -1 }); + const students = await User.find({ ...brFilter, $or: orConditions }).sort({ + rollNumber: -1, + }); res.status(200).json({ students }); } catch (error) { - logger.error("Student search failed", { error, attributes: { dependency: "mongodb", operation: "search-students", outcome: "failure", retryable: false } }); + logger.error("Student search failed", { + error, + attributes: { + dependency: "mongodb", + operation: "search-students", + outcome: "failure", + retryable: false, + }, + }); res.status(500).json({ error: "Internal Server Error" }); } }; @@ -55,7 +73,7 @@ const searchStudents = async (req, res) => { // PUT /api/student/refresh/:id // Deletes the student's UserUpdate record so their courses are safely // re-fetched from the academic portal on their next login. -// (Per issue #144 — directly calling fetchCoursesForBr would block the +// (Per issue #144 - directly calling fetchCoursesForBr would block the // server for 30-40s and only update previousCourses, not current courses.) const refreshStudentCourses = async (req, res) => { try { @@ -66,10 +84,19 @@ const refreshStudentCourses = async (req, res) => { await UserUpdate.deleteOne({ rollNumber: student.rollNumber }); res.status(200).json({ - message: "Student update record reset successfully. Courses will be re-fetched on next login.", + message: + "Student update record reset successfully. Courses will be re-fetched on next login.", }); } catch (error) { - logger.error("Student refresh failed", { error, attributes: { dependency: "mongodb", operation: "refresh-student", outcome: "failure", retryable: false } }); + logger.error("Student refresh failed", { + error, + attributes: { + dependency: "mongodb", + operation: "refresh-student", + outcome: "failure", + retryable: false, + }, + }); res.status(500).json({ error: "Internal Server Error" }); } }; @@ -81,10 +108,18 @@ const deleteStudent = async (req, res) => { const { id } = req.params; const student = await User.findByIdAndDelete(id); if (!student) return res.status(404).json({ error: "Student not found" }); - await UserUpdate.deleteOne({rollNumber:student.rollNumber}); + await UserUpdate.deleteOne({ rollNumber: student.rollNumber }); res.status(200).json({ message: "Student deleted successfully" }); } catch (error) { - logger.error("Student deletion failed", { error, attributes: { dependency: "mongodb", operation: "delete-student", outcome: "failure", retryable: false } }); + logger.error("Student deletion failed", { + error, + attributes: { + dependency: "mongodb", + operation: "delete-student", + outcome: "failure", + retryable: false, + }, + }); res.status(500).json({ error: "Internal Server Error" }); } }; @@ -101,11 +136,20 @@ const semesterReset = async (req, res) => { const result = await User.updateMany({}, { $set: { courses: [] } }); res.status(200).json({ - message: "Semester reset successful. All student updates cleared and course lists reset.", + message: + "Semester reset successful. All student updates cleared and course lists reset.", modifiedCount: result.modifiedCount, }); } catch (error) { - logger.error("Semester reset failed", { error, attributes: { dependency: "mongodb", operation: "semester-reset", outcome: "failure", retryable: false } }); + logger.error("Semester reset failed", { + error, + attributes: { + dependency: "mongodb", + operation: "semester-reset", + outcome: "failure", + retryable: false, + }, + }); res.status(500).json({ error: "Internal Server Error" }); } }; diff --git a/server/package.json b/server/package.json index a6b8b98d..4ceb93c6 100644 --- a/server/package.json +++ b/server/package.json @@ -8,8 +8,9 @@ "start": "node index.js", "dev": "nodemon index.js", "preflight": "node scripts/preflight.js", - "destroyDB": "node scripts/deleteAllCourses.js && node scripts/deleteAllFolders.js && node scripts/deleteAllFiles.js && node scripts/deleteAllSearchResults.js", - "seed:app": "node scripts/seed_app.js", + "test": "node --test test/*.test.js", + "test:db": "node --test test/integration/*.test.js", + "admin": "node scripts/provisionAdmin.js", "sync-cache": "node scripts/syncCoursesCache.js" }, "keywords": [], diff --git a/server/scripts/provisionAdmin.js b/server/scripts/provisionAdmin.js new file mode 100644 index 00000000..66f63715 --- /dev/null +++ b/server/scripts/provisionAdmin.js @@ -0,0 +1,42 @@ +import "dotenv/config"; +import mongoose from "mongoose"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + AdminProvisioningError, + provisionAdmin, + validateAdminCredentials, +} from "../modules/admin/admin.provisioning.js"; + +export async function main() { + try { + // Validate before connecting, so missing credentials cannot trigger writes + const credentials = validateAdminCredentials({ + userId: process.env.ADMIN_USER_ID, + password: process.env.ADMIN_PASSWORD, + }); + const mongoUri = process.env.MONGO_URI || process.env.MONGODB_URI; + if (!mongoUri) { + throw new AdminProvisioningError( + "MISSING_DATABASE", + "Set MONGO_URI to the intended database before provisioning.", + ); + } + await mongoose.connect(mongoUri, { serverSelectionTimeoutMS: 10000 }); + const admin = await provisionAdmin(credentials); + console.log(`Created administrator: ${admin.userId}`); + } catch (error) { + console.error( + error instanceof AdminProvisioningError + ? error.message + : "Administrator provisioning failed. Check database availability and the unique userId index.", + ); + process.exitCode = 1; + } finally { + await mongoose.disconnect(); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await main(); +} diff --git a/server/scripts/seed_app.js b/server/scripts/seed_app.js deleted file mode 100644 index 2c2f448e..00000000 --- a/server/scripts/seed_app.js +++ /dev/null @@ -1,104 +0,0 @@ -import dotenv from "dotenv"; -import mongoose from "mongoose"; -import path from "path"; -import { fileURLToPath } from "url"; -import config from "../config/default.js"; -import EventModel from "../modules/event/event.model.js"; -import BR from "../modules/br/br.model.js"; -import Admin from "../modules/admin/admin.model.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -dotenv.config({ path: path.resolve(__dirname, "../.env") }); - -const addDaysAtUtcMidnight = (baseDate, daysToAdd) => { - const date = new Date( - Date.UTC( - baseDate.getUTCFullYear(), - baseDate.getUTCMonth(), - baseDate.getUTCDate() - ) - ); - date.setUTCDate(date.getUTCDate() + daysToAdd); - return date; -}; - -const buildExamDateSeed = (today = new Date()) => { - return { - firstYearDates: { - midSem: addDaysAtUtcMidnight(today, 45), - endSem: addDaysAtUtcMidnight(today, 110), - }, - otherDates: { - midSem: addDaysAtUtcMidnight(today, 42), - endSem: addDaysAtUtcMidnight(today, 105), - }, - }; -}; - -const seedBrFromEnv = async () => { - const emailId = (process.env.EMAIL_ID || "").trim().toLowerCase(); - - if (!emailId) { - throw new Error("EMAIL_ID is missing in environment"); - } - - await BR.updateOne({ email: emailId }, { $set: { email: emailId } }, { upsert: true }); - - console.log(`Added BR email to list: ${emailId}`); -}; - -const seedDefaultAdmin = async () => { - const userId = "admin"; - const password = "admin"; - - let admin = await Admin.findOne({ userId }); - - if (!admin) { - await Admin.create({ userId, password }); - console.log("Added default admin: admin"); - return; - } - - admin.password = password; - await admin.save(); - console.log("Updated default admin credentials for: admin"); -}; - -const seedExamDates = async () => { - try { - const mongoUri = config.mongoURI || process.env.MONGODB_URI; - - if (!mongoUri) { - throw new Error("MONGO_URI (or MONGODB_URI) is missing in environment"); - } - - console.log("Connecting to MongoDB..."); - await mongoose.connect(mongoUri); - console.log("Connected."); - - console.log("Clearing existing exam date documents..."); - await EventModel.deleteMany({}); - - const examDateSeed = buildExamDateSeed(); - - console.log("Seeding exam dates..."); - const created = await EventModel.create(examDateSeed); - - console.log("Seeding BR from EMAIL_ID..."); - await seedBrFromEnv(); - - console.log("Seeding default admin account..."); - await seedDefaultAdmin(); - - console.log("Seed completed successfully."); - console.log(created); - } catch (error) { - console.error("Failed to seed exam dates:", error); - process.exitCode = 1; - } finally { - await mongoose.connection.close(); - } -}; - -await seedExamDates(); \ No newline at end of file diff --git a/server/services/UploadFile.js b/server/services/UploadFile.js index b3d65671..85a964aa 100644 --- a/server/services/UploadFile.js +++ b/server/services/UploadFile.js @@ -36,13 +36,13 @@ async function CreateFolder(contributionId) { Authorization: `Bearer ${access_token}`, }, }; - + const _data = { name: contributionId, folder: {}, "@microsoft.graph.conflictBehavior": "fail", }; - + try { const { data } = await axios.post(url, _data, config); return data.id; @@ -78,7 +78,14 @@ async function UploadFile(contributionId, filePath, fileName) { const folderId = parent_item_id; const session = await createUploadSession(folderId, fileName); if (!session?.url) { - logger.error("File upload failed", { attributes: { dependency: "microsoft-graph", operation: "upload-file", outcome: "failure", retryable: true } }); + logger.error("File upload failed", { + attributes: { + dependency: "microsoft-graph", + operation: "upload-file", + outcome: "failure", + retryable: true, + }, + }); return null; } const { url, access_token } = session; @@ -104,7 +111,7 @@ async function UploadFile(contributionId, filePath, fileName) { Authorization: `Bearer ${access_token}`, "Content-Type": "application/json", }, - } + }, ), axios.get(thumbnaillink, { headers: { Authorization: `Bearer ${access_token}` }, @@ -126,12 +133,18 @@ async function UploadFile(contributionId, filePath, fileName) { await fileData.save(); logger.info("File saved"); - // Upload thumbnail to ImageKit in the background — don't block the response + // Upload thumbnail to ImageKit in the background - don't block the response if (tempThumbnailUrl) { (async () => { try { - const imgResponse = await axios.get(tempThumbnailUrl, { responseType: "arraybuffer" }); - const { url: permanentUrl, fileId: imagekitFileId, path: imagekitPath } = await uploadThumbnail(data.id, Buffer.from(imgResponse.data)); + const imgResponse = await axios.get(tempThumbnailUrl, { + responseType: "arraybuffer", + }); + const { + url: permanentUrl, + fileId: imagekitFileId, + path: imagekitPath, + } = await uploadThumbnail(data.id, Buffer.from(imgResponse.data)); await FileModel.updateOne( { fileId: data.id }, { @@ -140,11 +153,25 @@ async function UploadFile(contributionId, filePath, fileName) { fileId: imagekitFileId, path: imagekitPath, }, - } + }, ); - logger.info("ImageKit thumbnail stored", { attributes: { dependency: "imagekit", operation: "store-thumbnail", outcome: "success" } }); + logger.info("ImageKit thumbnail stored", { + attributes: { + dependency: "imagekit", + operation: "store-thumbnail", + outcome: "success", + }, + }); } catch (thumbErr) { - logger.warn("ImageKit thumbnail upload failed", { error: thumbErr, attributes: { dependency: "imagekit", operation: "store-thumbnail", outcome: "failure", retryable: true } }); + logger.warn("ImageKit thumbnail upload failed", { + error: thumbErr, + attributes: { + dependency: "imagekit", + operation: "store-thumbnail", + outcome: "failure", + retryable: true, + }, + }); } })(); } @@ -159,15 +186,26 @@ async function UploadFile(contributionId, filePath, fileName) { async function DeleteFile(fileId) { const access_token = await getAccessToken(); - // Delete ImageKit thumbnail in the background — don't block the response - FileModel.findOne({ fileId }).lean().then((fileDoc) => { - const imagekitId = fileDoc?.thumbnail?.fileId || fileDoc?.imagekitFileId; - if (imagekitId) { - deleteThumbnail(imagekitId).catch((ikErr) => { - logger.warn("ImageKit thumbnail deletion failed", { error: ikErr, attributes: { dependency: "imagekit", operation: "delete-thumbnail", outcome: "failure", retryable: true } }); - }); - } - }).catch(() => {}); + // Delete ImageKit thumbnail in the background - don't block the response + FileModel.findOne({ fileId }) + .lean() + .then((fileDoc) => { + const imagekitId = fileDoc?.thumbnail?.fileId || fileDoc?.imagekitFileId; + if (imagekitId) { + deleteThumbnail(imagekitId).catch((ikErr) => { + logger.warn("ImageKit thumbnail deletion failed", { + error: ikErr, + attributes: { + dependency: "imagekit", + operation: "delete-thumbnail", + outcome: "failure", + retryable: true, + }, + }); + }); + } + }) + .catch(() => {}); try { //obtain parent folder onedrive id @@ -177,7 +215,7 @@ async function DeleteFile(fileId) { headers: { Authorization: `Bearer ${access_token}`, }, - } + }, ); const folderId = data?.parentReference?.id; @@ -208,7 +246,7 @@ async function isFolderEmpty(folderId, access_token) { headers: { Authorization: `Bearer ${access_token}`, }, - } + }, ); if (data.value.length === 1) return true; diff --git a/server/services/imagekit.js b/server/services/imagekit.js index 2c8b8a5b..02c2bbce 100644 --- a/server/services/imagekit.js +++ b/server/services/imagekit.js @@ -1,11 +1,17 @@ import ImageKit, { toFile } from "@imagekit/nodejs"; -// Lazy singleton — deferred until first use so env vars are loaded +// Lazy singleton - deferred until first use so env vars are loaded let _ik = null; function getClient() { if (!_ik) { - if (!process.env.IMAGEKIT_PUBLIC_KEY || !process.env.IMAGEKIT_PRIVATE_KEY || !process.env.IMAGEKIT_URL_ENDPOINT) { - throw new Error("IMAGEKIT_PUBLIC_KEY, IMAGEKIT_PRIVATE_KEY and IMAGEKIT_URL_ENDPOINT must be set in .env"); + if ( + !process.env.IMAGEKIT_PUBLIC_KEY || + !process.env.IMAGEKIT_PRIVATE_KEY || + !process.env.IMAGEKIT_URL_ENDPOINT + ) { + throw new Error( + "IMAGEKIT_PUBLIC_KEY, IMAGEKIT_PRIVATE_KEY and IMAGEKIT_URL_ENDPOINT must be set in .env", + ); } _ik = new ImageKit({ publicKey: process.env.IMAGEKIT_PUBLIC_KEY, @@ -19,7 +25,7 @@ function getClient() { /** * Upload a raw image buffer to ImageKit as WebP and return the permanent URL. * Uses fileId as the filename so re-uploads overwrite the existing file. - * ImageKit handles WebP conversion at the CDN edge — no sharp needed. + * ImageKit handles WebP conversion at the CDN edge - no sharp needed. */ export async function uploadThumbnail(fileId, imageBuffer) { const fileObject = await toFile(imageBuffer, `${fileId}.webp`, { type: "image/webp" }); @@ -27,9 +33,9 @@ export async function uploadThumbnail(fileId, imageBuffer) { file: fileObject, fileName: `${fileId}.webp`, folder: "/thumbnails", - useUniqueFileName: false, // overwrite same fileId on re-upload + useUniqueFileName: false, // overwrite same fileId on re-upload transformation: { - pre: "f-webp,q-80", // convert to WebP quality 80 at edge + pre: "f-webp,q-80", // convert to WebP quality 80 at edge }, }); return { @@ -50,7 +56,11 @@ export async function deleteThumbnail(imagekitFileId) { * Returns true if the URL is already a permanent ImageKit URL. */ export function isImageKitUrl(url) { - return Boolean(url && process.env.IMAGEKIT_URL_ENDPOINT && url.startsWith(process.env.IMAGEKIT_URL_ENDPOINT)); + return Boolean( + url && + process.env.IMAGEKIT_URL_ENDPOINT && + url.startsWith(process.env.IMAGEKIT_URL_ENDPOINT), + ); } export default getClient; diff --git a/server/test/admin-provisioning.test.js b/server/test/admin-provisioning.test.js new file mode 100644 index 00000000..b838bcc6 --- /dev/null +++ b/server/test/admin-provisioning.test.js @@ -0,0 +1,156 @@ +import "./support/environment.js"; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import Admin from "../modules/admin/admin.model.js"; +import { provisionAdmin, validateAdminCredentials } from "../modules/admin/admin.provisioning.js"; + +const credentials = { userId: "fixture-admin", password: "fixture-only-password-2026" }; + +for (const [name, input, code] of [ + ["missing credentials", undefined, "INVALID_USER_ID"], + ["blank user ID", { ...credentials, userId: " " }, "INVALID_USER_ID"], + ["control characters in user ID", { ...credentials, userId: "bad\nID" }, "INVALID_USER_ID"], + ["missing password", { userId: credentials.userId }, "INVALID_PASSWORD"], + ["short password", { ...credentials, password: "short" }, "INVALID_PASSWORD"], + ["blank password", { ...credentials, password: " ".repeat(12) }, "INVALID_PASSWORD"], + [ + "password longer than bcrypt input", + { ...credentials, password: "x".repeat(73) }, + "INVALID_PASSWORD", + ], + [ + "UTF-8 password exceeding bcrypt byte limit", + { ...credentials, password: "🔒".repeat(19) }, + "INVALID_PASSWORD", + ], +]) { + test(`${name} fails before database work`, async (t) => { + const index = t.mock.method(Admin, "createIndexes", async () => + assert.fail("Unexpected index work"), + ); + const lookup = t.mock.method(Admin, "exists", async () => assert.fail("Unexpected lookup")); + const create = t.mock.method(Admin, "create", async () => + assert.fail("Unexpected creation"), + ); + await assert.rejects(provisionAdmin(input), { code }); + assert.equal(index.mock.callCount() + lookup.mock.callCount() + create.mock.callCount(), 0); + }); +} + +test("user IDs are trimmed, passwords retain their exact whitespace, and the 72-byte boundary is accepted", () => { + const input = { userId: " fixture-admin ", password: " leading and trailing spaces " }; + assert.deepEqual(validateAdminCredentials(input), { ...input, userId: "fixture-admin" }); + assert.equal( + validateAdminCredentials({ ...credentials, password: "🔒".repeat(18) }).password, + "🔒".repeat(18), + ); +}); + +test("an existing administrator cannot be reset by provisioning", async (t) => { + const existing = Object.freeze({ + _id: "existing-id", + userId: credentials.userId, + password: "original-hash", + }); + t.mock.method(Admin, "createIndexes", async () => {}); + t.mock.method(Admin, "exists", async (filter) => { + assert.deepEqual(filter, { userId: credentials.userId }); + return { _id: existing._id }; + }); + const create = t.mock.method(Admin, "create", async () => + assert.fail("Existing administrators must not be saved"), + ); + await assert.rejects(provisionAdmin(credentials), { code: "ADMIN_EXISTS" }); + assert.equal(create.mock.callCount(), 0); + assert.equal(existing.password, "original-hash"); +}); + +test("new credentials pass through the real model hashing hook and no hash is returned", async (t) => { + t.mock.method(Admin, "createIndexes", async () => {}); + t.mock.method(Admin, "exists", async () => null); + let inserted; + t.mock.method(Admin.collection, "insertOne", (document, options, callback) => { + inserted = document; + callback(null, { acknowledged: true, insertedId: document._id }); + }); + const result = await provisionAdmin(credentials); + assert.deepEqual(Object.keys(result).sort(), ["id", "userId"]); + assert.equal(result.id, inserted._id.toString()); + assert.equal(result.userId, credentials.userId); + assert.notEqual(inserted.password, credentials.password); + assert.equal(await new Admin(inserted).comparePassword(credentials.password), true); + assert.equal(await new Admin(inserted).comparePassword("another-fixture-password"), false); +}); + +test("a concurrent duplicate is reported without retrying as an update", async (t) => { + t.mock.method(Admin, "createIndexes", async () => {}); + t.mock.method(Admin, "exists", async () => null); + const create = t.mock.method(Admin, "create", async () => { + throw Object.assign(new Error("duplicate"), { code: 11000 }); + }); + const update = t.mock.method(Admin, "updateOne", async () => + assert.fail("No update fallback is permitted"), + ); + await assert.rejects(provisionAdmin(credentials), { code: "ADMIN_EXISTS" }); + assert.equal(create.mock.callCount(), 1); + assert.equal(update.mock.callCount(), 0); +}); + +test("an index failure prevents account creation", async (t) => { + const failure = new Error("Index cannot be created"); + t.mock.method(Admin, "createIndexes", async () => { + throw failure; + }); + const create = t.mock.method(Admin, "create", async () => assert.fail("Unexpected write")); + await assert.rejects(provisionAdmin(credentials), failure); + assert.equal(create.mock.callCount(), 0); +}); + +function runProvisioning(env = {}) { + return spawnSync( + process.execPath, + [fileURLToPath(new URL("../scripts/provisionAdmin.js", import.meta.url))], + { + encoding: "utf8", + timeout: 3000, + env: { + ...process.env, + ADMIN_USER_ID: "", + ADMIN_PASSWORD: "", + MONGO_URI: "", + MONGODB_URI: "", + ...env, + }, + }, + ); +} + +test("provisioning requires explicit credentials and database configuration", () => { + const missingCredentials = runProvisioning(); + assert.equal(missingCredentials.status, 1); + assert.match(missingCredentials.stderr, /ADMIN_USER_ID/); + const missingDatabase = runProvisioning({ + ADMIN_USER_ID: credentials.userId, + ADMIN_PASSWORD: credentials.password, + }); + assert.equal(missingDatabase.status, 1); + assert.match(missingDatabase.stderr, /Set MONGO_URI/); +}); + +test("CLI connection errors do not expose passwords, database URIs or stacks", () => { + const uri = "invalid-uri-with-fixture-secret"; + const result = runProvisioning({ + ADMIN_USER_ID: credentials.userId, + ADMIN_PASSWORD: credentials.password, + MONGO_URI: uri, + }); + assert.equal(result.error, undefined); + assert.equal(result.status, 1); + assert.match(result.stderr, /Administrator provisioning failed/); + assert.equal(result.stdout, ""); + assert.equal(result.stderr.includes(credentials.password), false); + assert.equal(result.stderr.includes(uri), false); + assert.doesNotMatch(result.stderr, /\n\s+at /); +}); diff --git a/server/test/integration/admin-provisioning.test.js b/server/test/integration/admin-provisioning.test.js new file mode 100644 index 00000000..abd466a6 --- /dev/null +++ b/server/test/integration/admin-provisioning.test.js @@ -0,0 +1,199 @@ +import "../support/environment.js"; +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { randomUUID } from "node:crypto"; +import { once } from "node:events"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import mongoose from "mongoose"; +import { app } from "../../index.js"; +import Admin from "../../modules/admin/admin.model.js"; +import { provisionAdmin } from "../../modules/admin/admin.provisioning.js"; + +const owner = randomUUID(); +const password = "database-fixture-password-only"; +let ownsDatabase = false; +let listener; +let origin; + +before(async () => { + const uri = process.env.TEST_MONGO_URI; + assert.ok(uri, "Set TEST_MONGO_URI to an empty isolated coursehub_test_ database"); + const target = new URL(uri); + assert.match( + decodeURIComponent(target.pathname), + /^\/coursehub_test_[a-zA-Z0-9_-]+$/, + "Refusing a database outside the coursehub_test_ namespace", + ); + await mongoose.connect(uri, { + serverSelectionTimeoutMS: 5000, + autoIndex: false, + autoCreate: false, + }); + const db = mongoose.connection.db; + assert.deepEqual( + await db.listCollections({}, { nameOnly: true }).toArray(), + [], + "Refusing a nonempty test database", + ); + await db.collection("_testRun").insertOne({ _id: "owner", token: owner }); + ownsDatabase = true; + + listener = app.listen(0, "127.0.0.1"); + await once(listener, "listening"); + origin = `http://127.0.0.1:${listener.address().port}`; +}); + +after(async () => { + try { + if (listener) { + const closed = new Promise((resolve, reject) => + listener.close((error) => (error ? reject(error) : resolve())), + ); + listener.closeAllConnections(); + await closed; + } + if (ownsDatabase) { + const marker = await mongoose.connection.db + .collection("_testRun") + .findOne({ _id: "owner" }); + assert.equal( + marker?.token, + owner, + "Refusing cleanup without this run's ownership marker", + ); + assert.match(mongoose.connection.name, /^coursehub_test_[a-zA-Z0-9_-]+$/); + await mongoose.connection.db.dropDatabase(); + } + } finally { + await mongoose.disconnect(); + } +}); + +test("provisioned password hashes work with the existing administrator login", async () => { + const result = await provisionAdmin({ userId: "login-fixture", password }); + const stored = await Admin.findById(result.id); + assert.notEqual(stored.password, password); + assert.equal(await stored.comparePassword(password), true); + assert.equal(await stored.comparePassword("wrong-fixture-password"), false); + const response = await fetch(origin + "/api/admin/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ userId: "login-fixture", password }), + }); + assert.equal(response.status, 200); + assert.equal((await response.json()).success, true); + assert.match(response.headers.get("set-cookie"), /adminToken=/); + + const headers = { cookie: response.headers.get("set-cookie").split(";")[0] }; + const session = await fetch(origin + "/api/admin/", { headers }); + assert.equal(session.status, 200); + assert.deepEqual(await session.json(), { user: { userId: "login-fixture" } }); + const courses = await fetch(origin + "/api/admin/dbcourses", { headers }); + assert.equal(courses.status, 200); + assert.deepEqual(await courses.json(), []); +}); + +test("repeat provisioning cannot replace an existing hash or identity", async () => { + const first = await provisionAdmin({ userId: "existing-fixture", password }); + const original = await Admin.findById(first.id).lean(); + await assert.rejects( + provisionAdmin({ userId: " existing-fixture ", password: "different-fixture-password" }), + { code: "ADMIN_EXISTS" }, + ); + assert.deepEqual(await Admin.findById(first.id).lean(), original); + assert.equal(await Admin.countDocuments({ userId: "existing-fixture" }), 1); +}); + +test("concurrent provisioning creates one administrator with a unique indexed ID", async () => { + const outcomes = await Promise.allSettled([ + provisionAdmin({ userId: "concurrent-fixture", password }), + provisionAdmin({ userId: "concurrent-fixture", password }), + ]); + assert.equal(outcomes.filter((o) => o.status === "fulfilled").length, 1); + assert.equal(outcomes.find((o) => o.status === "rejected").reason.code, "ADMIN_EXISTS"); + assert.equal(await Admin.countDocuments({ userId: "concurrent-fixture" }), 1); + const index = (await Admin.collection.indexes()).find((i) => i.key.userId === 1); + assert.equal(index.unique, true); +}); + +test("the provisioning CLI creates an account and refuses a subsequent reset", async () => { + const run = (candidate) => + spawnSync( + process.execPath, + [fileURLToPath(new URL("../../scripts/provisionAdmin.js", import.meta.url))], + { + encoding: "utf8", + timeout: 5000, + env: { + ...process.env, + MONGO_URI: process.env.TEST_MONGO_URI, + ADMIN_USER_ID: "cli-fixture", + ADMIN_PASSWORD: candidate, + }, + }, + ); + const created = run(password); + assert.equal(created.error, undefined); + assert.equal(created.status, 0, created.stderr); + assert.match(created.stdout, /Created administrator: cli-fixture/); + assert.equal(created.stdout.includes(password), false); + const saved = await Admin.findOne({ userId: "cli-fixture" }); + assert.equal(await saved.comparePassword(password), true); + const original = saved.toObject(); + const repeated = run("different-cli-fixture-password"); + assert.equal(repeated.error, undefined); + assert.equal(repeated.status, 1); + assert.match(repeated.stderr, /already exists/); + assert.deepEqual(await Admin.findById(saved._id).lean(), original); +}); + +function probeTarget(uri) { + // Select only the login test so this nested run cannot recurse into + // these safeguards. Its before hook must refuse the target before fixtures. + const env = { ...process.env, TEST_MONGO_URI: uri }; + delete env.NODE_TEST_CONTEXT; + return spawnSync( + process.execPath, + [ + "--test", + "--test-name-pattern=provisioned password hashes", + fileURLToPath(import.meta.url), + ], + { + encoding: "utf8", + timeout: 10000, + env, + }, + ); +} + +test("the database suite refuses an application database name before connecting", () => { + const result = probeTarget("mongodb://127.0.0.1:1/coursehub"); + assert.equal(result.error, undefined); + assert.equal(result.status, 1); + assert.match( + result.stdout + result.stderr, + /Refusing a database outside the coursehub_test_ namespace/, + ); +}); + +test("the database suite leaves a nonempty test target intact", async () => { + const beforeCollections = await mongoose.connection.db + .listCollections({}, { nameOnly: true }) + .toArray(); + const beforeAdmins = JSON.stringify(await Admin.find({}).sort({ _id: 1 }).lean()); + const result = probeTarget(process.env.TEST_MONGO_URI); + assert.equal(result.error, undefined); + assert.equal(result.status, 1); + assert.match(result.stdout + result.stderr, /Refusing a nonempty test database/); + assert.deepEqual( + await mongoose.connection.db.listCollections({}, { nameOnly: true }).toArray(), + beforeCollections, + ); + assert.equal(JSON.stringify(await Admin.find({}).sort({ _id: 1 }).lean()), beforeAdmins); + assert.equal( + (await mongoose.connection.db.collection("_testRun").findOne({ _id: "owner" })).token, + owner, + ); +}); diff --git a/server/test/support/environment.js b/server/test/support/environment.js new file mode 100644 index 00000000..923f1e11 --- /dev/null +++ b/server/test/support/environment.js @@ -0,0 +1,8 @@ +import { devNull } from "node:os"; + +process.env.DOTENV_CONFIG_PATH = devNull; +process.env.NODE_ENV = "test"; +process.env.OPS_LOGGING_ENABLED = "false"; +process.env.MONGO_URI = "mongodb://127.0.0.1:1/coursehub_test_no_connection"; +process.env.JWT_SECRET = "coursehub-student-unit-test-key"; +process.env.ADMIN_JWT_SECRET = "coursehub-admin-unit-test-key"; diff --git a/server/utils/course.js b/server/utils/course.js index fcb0ca07..b177a091 100644 --- a/server/utils/course.js +++ b/server/utils/course.js @@ -16,14 +16,17 @@ const __dirname = path.dirname(__filename); // Load list.json once at server startup let listJsonMap = {}; try { - const listRaw = fs.readFileSync(path.resolve(__dirname, "../modules/course/list.json"), "utf-8"); + const listRaw = fs.readFileSync( + path.resolve(__dirname, "../modules/course/list.json"), + "utf-8", + ); const parsed = JSON.parse(listRaw); for (const [k, v] of Object.entries(parsed)) { if (!k) continue; listJsonMap[k.toString().toUpperCase().replace(/\s+/g, "")] = v.trim(); } } catch (e) { - // ignore — listJsonMap stays empty, legacy courselist still works + // ignore - listJsonMap stays empty, legacy courselist still works } /** @@ -58,25 +61,25 @@ export const getCourseCodeCaseInsensitiveRegex = (code) => { /** * Parses course allotments from the IITG Academic Portal HTML. * Supports parsing a specific student's courses or all student allotments at once. - * + * * @param {string} htmlData - Raw HTML string from the academic portal * @param {string|number} [rollNumber] - Optional. If provided, returns only this student's courses. - * @returns {Array<{original: string, normalized: string}>|Object} + * @returns {Array<{original: string, normalized: string}>|Object} * If rollNumber is provided, returns Array of course objects. * If no rollNumber, returns object mapping student roll to array of normalized course codes. */ export const parseCourseAllotmentsFromHtml = (htmlData, rollNumber = null) => { const $ = cheerio.load(htmlData); - + if (rollNumber !== null && rollNumber !== undefined) { const targetRollStr = rollNumber.toString().trim(); const courseCodes = []; - + $("tr").each((i, elem) => { const details = $(elem).find("td"); const studentRollNo = details.eq(2).text().trim(); const rawCode = details.eq(3).text().trim(); - + if (rawCode && studentRollNo === targetRollStr && !rawCode.includes("SA")) { const normalizedCode = normalizeCourseCode(rawCode); courseCodes.push({ @@ -85,11 +88,11 @@ export const parseCourseAllotmentsFromHtml = (htmlData, rollNumber = null) => { }); } }); - + return courseCodes; } else { const allotments = {}; - + $("tr").each((i, elem) => { const details = $(elem).find("td"); const rollStr = details.eq(2).text().trim(); @@ -99,14 +102,14 @@ export const parseCourseAllotmentsFromHtml = (htmlData, rollNumber = null) => { const roll = parseInt(rollStr); if (isNaN(roll)) return; const normalizedCode = normalizeCourseCode(rawCode); - + if (!allotments[roll]) { allotments[roll] = new Set(); } allotments[roll].add(normalizedCode); } }); - + // Convert Sets to Arrays for the returned structure const result = {}; for (const [roll, codeSet] of Object.entries(allotments)) { From 670f991d1945a108197fe5bbdc30747dd71493cf Mon Sep 17 00:00:00 2001 From: maydayv7 Date: Wed, 9 Sep 2026 01:53:46 +0530 Subject: [PATCH 02/20] fix: Require sign-in --- client/src/App.jsx | 6 +- client/src/api/Course.js | 20 +- client/src/api/File.js | 5 +- client/src/api/Search.js | 7 - client/src/api/User.js | 3 +- client/src/router_utils/PrivateRoutes.jsx | 62 +++- client/src/router_utils/PrivateRoutes.scss | 31 ++ .../browse/components/folder-info/index.jsx | 1 + client/src/screens/browse/index.jsx | 38 +- client/src/screens/contributions/index.jsx | 1 + .../components/addcoursemodal/styles.scss | 32 +- .../dashboard/components/coursecard/index.jsx | 1 - .../components/searchcoursebtn/index.jsx | 19 - .../components/searchcoursebtn/searchicon.svg | 14 - .../components/searchcoursebtn/styles.scss | 50 --- .../components/result/index.jsx | 30 -- .../components/result/styles.scss | 18 - .../components/sectionC/index.jsx | 16 - .../components/sectionC/styles.scss | 20 -- .../components/wrapper/index.jsx | 9 - .../components/wrapper/styles.scss | 55 --- .../components/searchcoursemodal/index.jsx | 102 ------ .../components/searchcoursemodal/styles.scss | 79 ----- client/src/screens/landing/index.jsx | 8 - server/index.js | 14 +- server/middleware/isAdmin.js | 28 +- server/middleware/isAuthenticated.js | 15 +- server/middleware/isBR.js | 2 +- server/middleware/isLibraryAuthenticated.js | 3 + server/middleware/sessionAuthentication.js | 58 ++++ server/modules/admin/admin.routes.js | 23 +- server/modules/auth/auth.controller.js | 134 ++++--- server/modules/auth/auth.routes.js | 31 +- server/modules/br/br.routes.js | 24 +- .../contribution/contribution.controller.js | 82 ++--- .../contribution/contribution.routes.js | 22 +- server/modules/course/course.controller.js | 77 +---- server/modules/course/course.routes.js | 9 +- server/modules/event/event.controller.js | 8 +- server/modules/event/event.routes.js | 4 +- server/modules/file/file.routes.js | 23 +- server/modules/folder/folder.routes.js | 13 +- server/modules/onedrive/onedrive.routes.js | 2 + server/modules/search/search.routes.js | 67 +--- server/modules/student/student.routes.js | 15 +- server/modules/user/user.model.js | 52 +-- server/modules/user/user.routes.js | 27 +- server/modules/year/year.routes.js | 11 +- server/package.json | 3 +- server/test/browser/library-access.test.js | 231 +++++++++++++ server/test/fixtures/library.js | 47 +++ ...in-provisioning.test.js => server.test.js} | 172 ++++++++- server/test/library-authentication.test.js | 326 ++++++++++++++++++ server/test/support/environment.js | 2 + server/test/support/provider-guards.js | 28 ++ 55 files changed, 1279 insertions(+), 901 deletions(-) create mode 100644 client/src/router_utils/PrivateRoutes.scss delete mode 100644 client/src/screens/landing/components/searchcoursebtn/index.jsx delete mode 100644 client/src/screens/landing/components/searchcoursebtn/searchicon.svg delete mode 100644 client/src/screens/landing/components/searchcoursebtn/styles.scss delete mode 100644 client/src/screens/landing/components/searchcoursemodal/components/result/index.jsx delete mode 100644 client/src/screens/landing/components/searchcoursemodal/components/result/styles.scss delete mode 100644 client/src/screens/landing/components/searchcoursemodal/components/sectionC/index.jsx delete mode 100644 client/src/screens/landing/components/searchcoursemodal/components/sectionC/styles.scss delete mode 100644 client/src/screens/landing/components/searchcoursemodal/components/wrapper/index.jsx delete mode 100644 client/src/screens/landing/components/searchcoursemodal/components/wrapper/styles.scss delete mode 100644 client/src/screens/landing/components/searchcoursemodal/index.jsx delete mode 100644 client/src/screens/landing/components/searchcoursemodal/styles.scss create mode 100644 server/middleware/isLibraryAuthenticated.js create mode 100644 server/middleware/sessionAuthentication.js create mode 100644 server/test/browser/library-access.test.js create mode 100644 server/test/fixtures/library.js rename server/test/integration/{admin-provisioning.test.js => server.test.js} (50%) create mode 100644 server/test/library-authentication.test.js create mode 100644 server/test/support/provider-guards.js diff --git a/client/src/App.jsx b/client/src/App.jsx index aa1930fe..5e4f27de 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -84,10 +84,10 @@ const App = () => { }> } path="dashboard" exact /> } path="profile" exact /> + } path="browse" /> + } path="browse/:code" /> + } path="browse/:code/:folderId" /> - } path="browse" /> - } path="browse/:code" /> - } path="browse/:code/:folderId" /> } path="/" /> } path="*" /> diff --git a/client/src/api/Course.js b/client/src/api/Course.js index 9779f481..2ffd9f74 100644 --- a/client/src/api/Course.js +++ b/client/src/api/Course.js @@ -7,27 +7,11 @@ export const getCourse = async (code) => { return resp; }; -export const getUserCourses = async (courses) => { - try { - const resp = await axios.get(`${root}/api/course/getUserCourses`, { - params: { - courses: courses, - }, - }); - return resp.data; - } catch (err) { - } -}; - export const fetchUserCoursesData = async (user) => { const [coursesRes, prevCoursesRes] = await Promise.all([ - axios.post(`${root}/api/auth/fetchCourses`, { - rollNumber: user.rollNumber, - }), + axios.post(`${root}/api/auth/fetchCourses`, {}), user.isBR - ? axios.post(`${root}/api/auth/fetchCoursesForBr`, { - rollNumber: user.rollNumber, - }) + ? axios.post(`${root}/api/auth/fetchCoursesForBr`, {}) : Promise.resolve({ data: { courses: [] } }), ]); diff --git a/client/src/api/File.js b/client/src/api/File.js index ed729791..4bd282a1 100644 --- a/client/src/api/File.js +++ b/client/src/api/File.js @@ -18,10 +18,6 @@ export const previewFile = async (fileId) => { const { data } = await API.get(`/file/preview/${fileId}`); return data; }; -export const fetchAllFiles = async () => { - const { data } = await API.get("/file/all"); - return data; -}; export const verifyFile = async (fileId) => { const { data } = await API.put(`/files/verify/${fileId}`); return data; @@ -44,6 +40,7 @@ export const getThumbnail = async (fileId) => { export const getFileDownloadLink = async (fileId) => { const response = await fetch(serverRoot + "/api/files/download", { method: "POST", + credentials: "include", headers: { "Content-Type": "application/json", }, diff --git a/client/src/api/Search.js b/client/src/api/Search.js index 99286fd0..677f72df 100644 --- a/client/src/api/Search.js +++ b/client/src/api/Search.js @@ -7,10 +7,3 @@ export const GetSearchResult = async (wordArr) => { }); return fetched; }; - -export const IsCourseAvailable = async (code) => { - const fetched = await axios.post(`${serverRoot}/api/search/isavailable`, { - code: code, - }); - return fetched; -}; diff --git a/client/src/api/User.js b/client/src/api/User.js index 7b73acca..059a19c4 100644 --- a/client/src/api/User.js +++ b/client/src/api/User.js @@ -5,9 +5,10 @@ let redirectUri = "https://www.coursehubiitg.in/api/auth/login/redirect"; axios.defaults.withCredentials = true; -export const getUser = async () => { +export const getUser = async (signal) => { const resp = await axios.get(`${serverRoot}/api/user`, { withCredentials: true, + signal, }); return resp; }; diff --git a/client/src/router_utils/PrivateRoutes.jsx b/client/src/router_utils/PrivateRoutes.jsx index a00dd450..b38243cd 100644 --- a/client/src/router_utils/PrivateRoutes.jsx +++ b/client/src/router_utils/PrivateRoutes.jsx @@ -1,8 +1,64 @@ +import { useEffect, useState } from "react"; import { Outlet, Navigate } from "react-router-dom"; -import { useSelector } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; +import { getUser } from "../api/User"; +import { LoginUser, LogoutUser } from "../actions/user_actions"; +import Loader from "../components/Loader"; +import "./PrivateRoutes.scss"; + const PrivateRoutes = () => { - const user = useSelector((state) => state.user); - return user.loggedIn ? : ; + const loggedIn = useSelector((state) => state.user.loggedIn); + const dispatch = useDispatch(); + const [status, setStatus] = useState("checking"); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + if (loggedIn) return; + const controller = new AbortController(); + setStatus("checking"); + getUser(controller.signal) + .then(({ data }) => { + if (controller.signal.aborted) return; + if (data.needsCourseSync) return setStatus("sync"); + dispatch(LoginUser(data)); + setStatus("ready"); + }) + .catch((error) => { + if (controller.signal.aborted) return; + if (error.response?.status === 401) { + dispatch(LogoutUser()); + setStatus("signed-out"); + } else { + setStatus("error"); + } + }); + return () => controller.abort(); + }, [loggedIn, dispatch, attempt]); + + if (loggedIn) return ; + if (status === "signed-out") return ; + if (status === "sync") return ; + if (status === "error") { + return ( +
+
+

We couldn’t check your session. Please try again.

+ +
+
+ ); + } + return ( +
+ +
+ ); }; export default PrivateRoutes; diff --git a/client/src/router_utils/PrivateRoutes.scss b/client/src/router_utils/PrivateRoutes.scss new file mode 100644 index 00000000..4341dbc3 --- /dev/null +++ b/client/src/router_utils/PrivateRoutes.scss @@ -0,0 +1,31 @@ +.session-gate { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: #f5f5f5; + color: #111; + text-align: center; +} + +.session-gate-message { + max-width: 420px; + line-height: 1.5; +} + +.session-gate-retry { + margin-top: 16px; + min-height: 44px; + padding: 10px 24px; + border: 2px solid #111; + border-radius: 8px; + background: #ffd700; + color: #111; + font: inherit; + cursor: pointer; + + &:focus-visible { + outline: 3px solid #111; + outline-offset: 3px; + } +} diff --git a/client/src/screens/browse/components/folder-info/index.jsx b/client/src/screens/browse/components/folder-info/index.jsx index 5a7fb89a..2f5cb1d6 100644 --- a/client/src/screens/browse/components/folder-info/index.jsx +++ b/client/src/screens/browse/components/folder-info/index.jsx @@ -138,6 +138,7 @@ const FolderInfo = ({ try { const fileResponse = await fetch(`${server}/api/files/download`, { method: "POST", + credentials: "include", headers: { "Content-Type": "application/json", }, diff --git a/client/src/screens/browse/index.jsx b/client/src/screens/browse/index.jsx index e1728869..bec3de4a 100644 --- a/client/src/screens/browse/index.jsx +++ b/client/src/screens/browse/index.jsx @@ -23,8 +23,7 @@ import { ClearFolderHistory, } from "../../actions/filebrowser_actions"; import { getColors } from "../../utils/colors"; -import { AddNewCourseLocal, LoginUser, LogoutUser } from "../../actions/user_actions"; -import { getUser } from "../../api/User"; +import { AddNewCourseLocal } from "../../actions/user_actions"; import { useParams } from "react-router-dom"; import { getCourse } from "../../api/Course"; import { fetchFolder } from "../../api/Folder"; @@ -74,7 +73,6 @@ const BrowseScreen = () => { contributionSection.classList.add("show"); }; const dispatch = useDispatch(); - const [loading, setLoading] = useState(true); const { code, folderId } = useParams(); const fb = useSelector((state) => state.fileBrowser); @@ -86,37 +84,7 @@ const BrowseScreen = () => { }, []); useEffect(() => { - async function getAuth() { - try { - const { data } = await getUser(); - if (!data) { - dispatch(LogoutUser()); - setLoading(false); - return; - } - if (data.needsCourseSync) { - setLoading(false); - return navigate("/loading"); - } - dispatch(LoginUser(data)); - setLoading(false); - } catch (error) { - dispatch(LogoutUser()); - setLoading(false); - navigate("/"); - } - } - - if (!user?.loggedIn) { - getAuth(); - } else { - setLoading(false); - } - }, []); - - - useEffect(() => { - if (loading || !code) { + if (!code) { return; } const run = async () => { @@ -223,7 +191,7 @@ const BrowseScreen = () => { } }; run(); - }, [loading, code]); + }, [code]); useEffect(() => { if (!code || !currCourse || !Array.isArray(currCourse) || currCourse.length === 0) { diff --git a/client/src/screens/contributions/index.jsx b/client/src/screens/contributions/index.jsx index 1239d96e..d06d4a4c 100644 --- a/client/src/screens/contributions/index.jsx +++ b/client/src/screens/contributions/index.jsx @@ -99,6 +99,7 @@ const Contributions = () => { server={{ url: `${server}/api/contribution/upload`, process: { + withCredentials: true, headers: { "contribution-id": contributionId, username: userName, diff --git a/client/src/screens/dashboard/components/addcoursemodal/styles.scss b/client/src/screens/dashboard/components/addcoursemodal/styles.scss index b7305541..97425693 100644 --- a/client/src/screens/dashboard/components/addcoursemodal/styles.scss +++ b/client/src/screens/dashboard/components/addcoursemodal/styles.scss @@ -60,12 +60,36 @@ left: 0; right: 0; bottom: 0; - background-color: rgba(0, 0, 0, 0.5); - z-index: 2; - display: none; - + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(5px); + -webkit-backdrop-filter: blur(5px); + z-index: 999; + display: flex; justify-content: center; align-items: center; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1), + visibility 0.25s cubic-bezier(0.16, 1, 0.3, 1), + backdrop-filter 0.25s ease; + + .wrapper { + transform: scale(0.93) translateY(16px); + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), + box-shadow 0.3s ease; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); + } + + &.show { + opacity: 1; + visibility: visible; + pointer-events: auto; + + .wrapper { + transform: scale(1) translateY(0); + } + } } .show { diff --git a/client/src/screens/dashboard/components/coursecard/index.jsx b/client/src/screens/dashboard/components/coursecard/index.jsx index 2c619317..051db15c 100644 --- a/client/src/screens/dashboard/components/coursecard/index.jsx +++ b/client/src/screens/dashboard/components/coursecard/index.jsx @@ -2,7 +2,6 @@ import formatLongText from "../../../../utils/formatLongText"; import { capitalise } from "../../../../utils/capitalise"; import "./styles.scss"; import { useEffect, useState } from "react"; -import { IsCourseAvailable } from "../../../../api/Search"; import { DeleteCourseAPI } from "../../../../api/User"; import { toast } from "react-toastify"; import { ConfirmDialog } from "./ConfirmDialog"; diff --git a/client/src/screens/landing/components/searchcoursebtn/index.jsx b/client/src/screens/landing/components/searchcoursebtn/index.jsx deleted file mode 100644 index 6b421ee9..00000000 --- a/client/src/screens/landing/components/searchcoursebtn/index.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import "./styles.scss"; -const SearchCourseButton = ({ searchCourseShowModalHandler }) => { - return ( -
{ - e.preventDefault(); - if (!e.target[0].value) return; - window.location = "/browse/" + e.target[0].value; - }} - onClick={searchCourseShowModalHandler} - > -
- -
- ); -}; - -export default SearchCourseButton; diff --git a/client/src/screens/landing/components/searchcoursebtn/searchicon.svg b/client/src/screens/landing/components/searchcoursebtn/searchicon.svg deleted file mode 100644 index a0832c9d..00000000 --- a/client/src/screens/landing/components/searchcoursebtn/searchicon.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/client/src/screens/landing/components/searchcoursebtn/styles.scss b/client/src/screens/landing/components/searchcoursebtn/styles.scss deleted file mode 100644 index a28a82dc..00000000 --- a/client/src/screens/landing/components/searchcoursebtn/styles.scss +++ /dev/null @@ -1,50 +0,0 @@ -.search-course-btn{ - width: 280px; - height: 50px; - .search-icon{ - width: 30px; - height: 30px; - position: absolute; - background-color: red; - margin: 10px; - background: url(./searchicon.svg), none; - background-repeat: no-repeat; - background-position: center; - background-size: contain; - } - input{ - width: 100%; - height: 100%; - border: 1.5px solid #000; - padding: 0px 50px; - background-color: rgba(255, 255, 255, 0); - font-family: 'Bold'; - font-size: 1rem; - &::placeholder{ - font-family: 'Bold'; - color: #000; - font-size: 1rem; - } - &:focus{ - outline-width: 0; - } - } - @media screen and (max-width: 750px) { - width: 200px; - height: 40px; - - .search-icon{ - width: 20px; - height: 20px; - } - input{ - font-size: 0.8rem; - padding: 0px 0px 0px 50px; - &::placeholder{ - font-family: 'Bold'; - color: #000; - font-size: 0.8rem; - } - } - } -} \ No newline at end of file diff --git a/client/src/screens/landing/components/searchcoursemodal/components/result/index.jsx b/client/src/screens/landing/components/searchcoursemodal/components/result/index.jsx deleted file mode 100644 index c597a0d6..00000000 --- a/client/src/screens/landing/components/searchcoursemodal/components/result/index.jsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; -import "./styles.scss"; -import formatLongText from "../../../../../../utils/formatLongText"; -import { capitalise } from "../../../../../../utils/capitalise"; -import { getRandomColor } from "../../../../../../utils/colors"; - -const Result = ({ _id, code, name, handleModalClose, isAvailable }) => { - function handleClick(code) { - if (isAvailable) window.location = "/browse/" + code; - } - return ( -
{ - if (isAvailable) { - handleClick(code); - handleModalClose(); - } - }} - > - {code.toUpperCase()} - - {capitalise(formatLongText(name, 40))} - {!isAvailable && "UNAVAILABLE"} - -
- ); -}; - -export default Result; diff --git a/client/src/screens/landing/components/searchcoursemodal/components/result/styles.scss b/client/src/screens/landing/components/searchcoursemodal/components/result/styles.scss deleted file mode 100644 index 61622d58..00000000 --- a/client/src/screens/landing/components/searchcoursemodal/components/result/styles.scss +++ /dev/null @@ -1,18 +0,0 @@ -.result{ - &.false{ - background-color: #636363; - cursor: not-allowed; - } - color: #fff; - background-color: #000; - padding: 20px 15px; - cursor: pointer; - .code{ - margin-right: 1rem; - font-family: 'Bold'; - } - .info{ - font-family: "Bold"; - margin-left: 2rem; - } -} \ No newline at end of file diff --git a/client/src/screens/landing/components/searchcoursemodal/components/sectionC/index.jsx b/client/src/screens/landing/components/searchcoursemodal/components/sectionC/index.jsx deleted file mode 100644 index b59ca590..00000000 --- a/client/src/screens/landing/components/searchcoursemodal/components/sectionC/index.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import "./styles.scss"; -const SectionC = (props) => { - const offHandler = (event) => { - if (event.target.id === "add_modal") { - const collection = document.getElementsByClassName("add_modal"); - const contributionSection = collection[0]; - contributionSection.classList.remove("show"); - } - }; - return ( -
- {props.children} -
- ); -}; -export default SectionC; diff --git a/client/src/screens/landing/components/searchcoursemodal/components/sectionC/styles.scss b/client/src/screens/landing/components/searchcoursemodal/components/sectionC/styles.scss deleted file mode 100644 index 4a3b822e..00000000 --- a/client/src/screens/landing/components/searchcoursemodal/components/sectionC/styles.scss +++ /dev/null @@ -1,20 +0,0 @@ -.add_modal { - position: fixed; - width: 100vw; - height: 100vh; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.5); - z-index: 2; - display: none; - - justify-content: center; - align-items: center; -} - -.show { - display: block; - display: flex; -} \ No newline at end of file diff --git a/client/src/screens/landing/components/searchcoursemodal/components/wrapper/index.jsx b/client/src/screens/landing/components/searchcoursemodal/components/wrapper/index.jsx deleted file mode 100644 index 2a0c0d80..00000000 --- a/client/src/screens/landing/components/searchcoursemodal/components/wrapper/index.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import "./styles.scss"; -const Wrapper = (props) => { - return ( -
- {props.children} -
- ); -}; -export default Wrapper; diff --git a/client/src/screens/landing/components/searchcoursemodal/components/wrapper/styles.scss b/client/src/screens/landing/components/searchcoursemodal/components/wrapper/styles.scss deleted file mode 100644 index 522520b5..00000000 --- a/client/src/screens/landing/components/searchcoursemodal/components/wrapper/styles.scss +++ /dev/null @@ -1,55 +0,0 @@ -.wrapper { - margin: auto; - width: 40%; - padding: 3rem 4rem; - background-color: white; - height: auto; - position: absolute; - - z-index: 200; - opacity: 1; - - div { - margin-bottom: 1rem; - } -} - -@media screen and (max-width:1270px) { - .wrapper { - width: 50%; - } -} - -@media screen and (max-width:1024px) { - .wrapper { - width: 55%; - } -} - -@media screen and (max-width:960px) { - .wrapper { - width: 65%; - padding: 2rem 3rem; - } -} - -@media screen and (max-width:760px) { - .wrapper { - width: 65%; - padding: 2rem 3rem; - } -} - -@media screen and (max-width:720px) { - .wrapper { - width: 70%; - padding: 1rem 2rem; - } -} - -@media screen and (max-width:400px) { - .wrapper { - width: 90%; - padding: 1rem 2rem; - } -} \ No newline at end of file diff --git a/client/src/screens/landing/components/searchcoursemodal/index.jsx b/client/src/screens/landing/components/searchcoursemodal/index.jsx deleted file mode 100644 index 06af8a42..00000000 --- a/client/src/screens/landing/components/searchcoursemodal/index.jsx +++ /dev/null @@ -1,102 +0,0 @@ -import Wrapper from "./components/wrapper"; -import SectionC from "./components/sectionC"; -import axios from "axios"; - -import "./styles.scss"; -import Space from "../../../../components/space"; -import { useState } from "react"; -import { useEffect } from "react"; -import { GetSearchResult } from "../../../../api/Search"; -import Result from "./components/result"; -import SmallLoader from "../../../../components/SmallLoader"; -const SearchCourseModal = ({ handleAddCourse }) => { - const [code, setCode] = useState(""); - const [btnState, setBtnState] = useState("disabled"); - const [err, setErr] = useState(null); - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); - - useEffect(() => { - if (code.length > 2) { - if (btnState !== "") setBtnState(""); - } else { - if (btnState !== "disabled") setBtnState("disabled"); - } - }, [code]); - - async function handleSearch() { - if (btnState === "disabled") return; - try { - setLoading(true); - setErr(null); - const { data } = await GetSearchResult(code.split(" ")); - if (data?.found === true) { - setResults(data.results); - setLoading(false); - setErr(null); - } else { - setErr("No results found!"); - setLoading(false); - setResults([]); - } - setCode(""); - } catch (error) { - setCode(""); - setErr("Server Error! Please contact admin."); - } - } - const handleModalClose = (event) => { - const collection = document.getElementsByClassName("add_modal"); - const contributionSection = collection[0]; - contributionSection.classList.remove("show"); - }; - return ( - - -
Search course
-
e.preventDefault()}> -
- - { - setCode(e.target.value); - if (results.length > 0) setResults([]); - }} - value={code} - > -
-
- {err === null ? ( - loading ? ( - - ) : ( - results.map((course) => ( - - )) - ) - ) : ( - err - )} - - -
- SEARCH -
-
-
- ); -}; -export default SearchCourseModal; diff --git a/client/src/screens/landing/components/searchcoursemodal/styles.scss b/client/src/screens/landing/components/searchcoursemodal/styles.scss deleted file mode 100644 index 136621c6..00000000 --- a/client/src/screens/landing/components/searchcoursemodal/styles.scss +++ /dev/null @@ -1,79 +0,0 @@ -.head { - width: 100%; - text-align: center; - font-family: 'bold'; - font-size: 2rem; - -} - -.course { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 2rem; - - .label_course { - font-family: 'Bold'; - font-size: 1.2rem; - } - - .input_course { - width: 60%; - padding: 0.5rem; - font-size: 1rem; - font-weight: 400; - } -} - - - -.button { - text-align: center; - background-color: #FECF6F; - padding: 1rem; - font-family: 'bold'; - cursor: pointer; - &.disabled{ - background-color: grey; - cursor: not-allowed; - } -} -.add_modal { - position: fixed; - width: 100vw; - height: 100vh; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(5px); - -webkit-backdrop-filter: blur(5px); - z-index: 999; - display: flex; - justify-content: center; - align-items: center; - opacity: 0; - visibility: hidden; - pointer-events: none; - transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1), - visibility 0.25s cubic-bezier(0.16, 1, 0.3, 1), - backdrop-filter 0.25s ease; - - .wrapper { - transform: scale(0.93) translateY(16px); - transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), - box-shadow 0.3s ease; - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); - } - - &.show { - opacity: 1; - visibility: visible; - pointer-events: auto; - - .wrapper { - transform: scale(1) translateY(0); - } - } -} \ No newline at end of file diff --git a/client/src/screens/landing/index.jsx b/client/src/screens/landing/index.jsx index 142e4b82..413921d4 100644 --- a/client/src/screens/landing/index.jsx +++ b/client/src/screens/landing/index.jsx @@ -1,5 +1,4 @@ import MicrosoftSignIn from "./components/microsoftbutton"; -import SearchCourseButton from "./components/searchcoursebtn"; import "./styles.scss"; import { useDispatch } from "react-redux"; import { useEffect } from "react"; @@ -7,7 +6,6 @@ import { LoginUser, LogoutUser } from "../../actions/user_actions"; import { useNavigate } from "react-router-dom"; import { useState } from "react"; import { getUser, handleLogin } from "../../api/User"; -import AddCourseModal from "./components/searchcoursemodal"; import Loader from "../../components/Loader"; import { clearLegacySessionLocalCoursesCache } from "../../utils/frontendCache"; @@ -20,11 +18,6 @@ const LandingPage = () => { clearLegacySessionLocalCoursesCache(); }, []); - const searchCourseShowModalHandler = (event) => { - const collection = document.getElementsByClassName("add_modal"); - const contributionSection = collection[0]; - contributionSection.classList.add("show"); - }; useEffect(() => { async function getAuth() { try { @@ -83,7 +76,6 @@ const LandingPage = () => { - ); }; diff --git a/server/index.js b/server/index.js index 9b998c5d..9d2c90e1 100644 --- a/server/index.js +++ b/server/index.js @@ -11,8 +11,6 @@ import config from "./config/default.js"; import { flushLogging, lifecycleLogger, logger, opsHttpMiddleware } from "./utils/logger.js"; import { initScheduler } from "./config/cron.js"; import connectDatabase from "./services/connectDB.js"; -import catchAsync from "./utils/catchAsync.js"; -import User from "./modules/user/user.model.js"; import authRoutes from "./modules/auth/auth.routes.js"; import userRoutes from "./modules/user/user.routes.js"; import onedriveRoutes from "./modules/onedrive/onedrive.routes.js"; @@ -44,10 +42,10 @@ app.use( credentials: true, }), ); -app.use(express.static("static")); app.use(express.json()); app.use(cookieParser()); app.use(ua.express()); +app.get("/api/health", (req, res) => res.json({ status: "ok" })); app.use("/api/auth", authRoutes); app.use("/api/user", userRoutes); app.use("/api/file", onedriveRoutes); @@ -61,13 +59,8 @@ app.use("/api/files", fileRoutes); app.use("/api/folder", folderRoutes); app.use("/api/year", yearRoutes); app.use("/api/student", studentRoutes); -app.use( - "/homepage", - catchAsync(async (req, res) => { - const user = await User.findByJWT(req.cookies.token); - if (!user) return res.redirect(config.clientURL); - return res.json(user); - }), +app.use("/api", (req, res) => + res.status(404).json({ error: true, message: "API endpoint not found" }), ); app.use((error, req, res, next) => { @@ -83,6 +76,7 @@ app.use((error, req, res, next) => { const { status = 500, message = "Something went wrong!" } = error; return res.status(status).json({ error: true, message }); }); +app.use(express.static("static")); app.get("*", (req, res) => res.sendFile(path.resolve(__dirname, "static", "index.html"))); async function closeServer() { diff --git a/server/middleware/isAdmin.js b/server/middleware/isAdmin.js index dcecabd0..66af9bb9 100644 --- a/server/middleware/isAdmin.js +++ b/server/middleware/isAdmin.js @@ -1,27 +1,3 @@ -import Admin from "../modules/admin/admin.model.js"; -import { verifyAdminJWT } from "../modules/auth-admin/auth-admin.services.js"; -import AppError from "../utils/appError.js"; +import { requireSession } from "./sessionAuthentication.js"; -async function isAdmin(req, res, next) { - try { - const tokenFromCookie = req.cookies?.adminToken; - const tokenFromHeader = req.headers?.authorization?.startsWith("Bearer ") - ? req.headers.authorization.split(" ")[1] - : null; - const token = tokenFromCookie || tokenFromHeader; - if (!token) return next(new AppError(403, "Not Authorized!")); - - const decoded = await verifyAdminJWT(token); - if (!decoded) return next(new AppError(403, "Not Authorized!")); - - const admin = await Admin.findById(decoded); - if (!admin) return next(new AppError(403, "Not Authorized!")); - - req.admin = admin; - return next(); - } catch (err) { - return next(new AppError(403, "Not Authorized!")); - } -} - -export default isAdmin; +export default requireSession("admin"); diff --git a/server/middleware/isAuthenticated.js b/server/middleware/isAuthenticated.js index 09aa856c..0378a85f 100644 --- a/server/middleware/isAuthenticated.js +++ b/server/middleware/isAuthenticated.js @@ -1,14 +1,3 @@ -import User from "../modules/user/user.model.js"; -import AppError from "../utils/appError.js"; +import { requireSession } from "./sessionAuthentication.js"; -const isAuthenticated = async function (req, res, next) { - let token = req.cookies.token; - if (!token) token = req.headers?.authorization?.split(" ")[1]; - if (!token) return next(new AppError(403, "Invalid token")); - const user = await User.findByJWT(token); - if (!user) return next(new AppError(403, "Not Authenticated")); - req.user = user; - return next(); -}; - -export default isAuthenticated; +export default requireSession("student"); diff --git a/server/middleware/isBR.js b/server/middleware/isBR.js index 3c38d57b..0f5f038c 100644 --- a/server/middleware/isBR.js +++ b/server/middleware/isBR.js @@ -1,5 +1,5 @@ export const isBR = (req, res, next) => { - if (req.user && req.user.isBR === true) { + if (req.admin || req.user?.isBR === true) { next(); } else { res.status(403).json({ message: "Not authorized as BR" }); diff --git a/server/middleware/isLibraryAuthenticated.js b/server/middleware/isLibraryAuthenticated.js new file mode 100644 index 00000000..7066ee07 --- /dev/null +++ b/server/middleware/isLibraryAuthenticated.js @@ -0,0 +1,3 @@ +import { requireSession } from "./sessionAuthentication.js"; + +export default requireSession("student", "admin"); diff --git a/server/middleware/sessionAuthentication.js b/server/middleware/sessionAuthentication.js new file mode 100644 index 00000000..77c06776 --- /dev/null +++ b/server/middleware/sessionAuthentication.js @@ -0,0 +1,58 @@ +import User from "../modules/user/user.model.js"; +import Admin from "../modules/admin/admin.model.js"; +import { verifyAdminJWT } from "../modules/auth-admin/auth-admin.services.js"; +import AppError from "../utils/appError.js"; + +async function findAdmin(token) { + const id = await verifyAdminJWT(token); + if (typeof id !== "string" || !/^[a-f0-9]{24}$/i.test(id)) return null; + return Admin.findById(id); +} + +export function requireSession(...roles) { + return async (req, res, next) => { + try { + const bearer = req.headers.authorization?.match(/^Bearer\s+(\S+)$/i)?.[1]; + const candidates = [ + { + role: "student", + key: "user", + token: req.cookies?.token || bearer, + find: (token) => User.findByJWT(token), + }, + { + role: "admin", + key: "admin", + token: req.cookies?.adminToken || bearer, + find: findAdmin, + }, + ].sort((a, b) => Number(roles.includes(b.role)) - Number(roles.includes(a.role))); + let authenticated = false; + for (const candidate of candidates) { + const actor = + req[candidate.key] || + (candidate.token && (await candidate.find(candidate.token))); + if (!actor) continue; + // A shared account cannot establish an individual student session + if ( + candidate.role === "student" && + actor.email?.toLowerCase() === "guest@coursehubiitg.in" + ) + continue; + req[candidate.key] = actor; + authenticated = true; + if (roles.includes(candidate.role)) return next(); + } + return next( + new AppError( + authenticated ? 403 : 401, + authenticated + ? "You do not have permission for this action" + : "Sign in to continue", + ), + ); + } catch (error) { + return next(error); + } + }; +} diff --git a/server/modules/admin/admin.routes.js b/server/modules/admin/admin.routes.js index a1316d79..8e7a03b5 100644 --- a/server/modules/admin/admin.routes.js +++ b/server/modules/admin/admin.routes.js @@ -22,18 +22,19 @@ const upload = multer({ dest: "uploads/" }); // Admin auth router.post("/auth/login", catchAsync(adminLogin)); router.post("/auth/logout", catchAsync(adminLogout)); +router.use(isAdmin); -router.get("/course/:code/dashboard", isAdmin, catchAsync(getCourseDashboardData)); -router.post("/contribution/action", isAdmin, catchAsync(handleContribution)); -router.delete("/node/:type/:id", isAdmin, catchAsync(deleteNode)); -router.get("/", isAdmin, catchAsync(getAdmin)); -router.get("/dbcourses", isAdmin, catchAsync(getDBCourses)); +router.get("/course/:code/dashboard", catchAsync(getCourseDashboardData)); +router.post("/contribution/action", catchAsync(handleContribution)); +router.delete("/node/:type/:id", catchAsync(deleteNode)); +router.get("/", catchAsync(getAdmin)); +router.get("/dbcourses", catchAsync(getDBCourses)); -router.post("/courses/upload", isAdmin, upload.single("file"), uploadCourses); -router.post("/courses/bulk-link", isAdmin, upload.single("file"), bulkLinkCourses); -router.patch("/course/:code", isAdmin, renameCourse); -router.post("/course/:code/link", isAdmin, linkLegacyCourse); -router.delete("/course/:code/delete", isAdmin, catchAsync(deleteCourse)); -router.post("/sync-courses-cache", isAdmin, catchAsync(syncCoursesCacheController)); +router.post("/courses/upload", upload.single("file"), uploadCourses); +router.post("/courses/bulk-link", upload.single("file"), bulkLinkCourses); +router.patch("/course/:code", renameCourse); +router.post("/course/:code/link", linkLegacyCourse); +router.delete("/course/:code/delete", catchAsync(deleteCourse)); +router.post("/sync-courses-cache", catchAsync(syncCoursesCacheController)); export default router; diff --git a/server/modules/auth/auth.controller.js b/server/modules/auth/auth.controller.js index 684701e1..fbc34151 100644 --- a/server/modules/auth/auth.controller.js +++ b/server/modules/auth/auth.controller.js @@ -15,7 +15,11 @@ import User from "../user/user.model.js"; import academic from "../../config/academic.js"; import courselist from "../course/course.list.js"; -import { getCourseCodeCaseInsensitiveRegex, normalizeCourseCode, parseCourseAllotmentsFromHtml } from "../../utils/course.js"; +import { + getCourseCodeCaseInsensitiveRegex, + normalizeCourseCode, + parseCourseAllotmentsFromHtml, +} from "../../utils/course.js"; import { getRandomColor } from "../../utils/generateRandomColor.js"; import UserUpdate from "../user/userUpdate.model.js"; @@ -27,48 +31,39 @@ const normalizeEmail = (email) => email?.toString().trim().toLowerCase(); export const loginHandler = (req, res) => { res.redirect( - `https://login.microsoftonline.com/850aa78d-94e1-4bc6-9cf3-8c11b530701c/oauth2/v2.0/authorize?client_id=${clientid}&response_type=code&redirect_uri=${redirect_uri}&scope=user.read%20offline_access&state=12345` + `https://login.microsoftonline.com/850aa78d-94e1-4bc6-9cf3-8c11b530701c/oauth2/v2.0/authorize?client_id=${clientid}&response_type=code&redirect_uri=${redirect_uri}&scope=user.read%20offline_access&state=12345`, ); }; -export const guestLoginHanlder = async (req, res, next) => { - const guest = await User.findOne({ email: "guest@coursehubiitg.in" }); - if (!guest) return next(new AppError(500, "Something went wrong.")); - const token = guest.generateJWT(); - res.json({ token }); -}; - - - // Helper function to get course names from database and courselist async function resolveCourseNames(courseCodes, dbCourses) { const courses = []; const seenCodes = new Set(); - + for (const { original, normalized } of courseCodes) { if (seenCodes.has(normalized)) continue; seenCodes.add(normalized); - + const dbCourse = dbCourses.find( (course) => normalizeCourseCode(course.code) === normalized || - normalizeCourseCode(course.code) === normalizeCourseCode(original) + normalizeCourseCode(course.code) === normalizeCourseCode(original), ); - + const name = dbCourse?.name || courselist[normalized] || courselist[original]; - + courses.push({ name, code: normalized, }); } - + return courses; } export const fetchCourses = async (rollNumber) => { const roll = parseInt(rollNumber); - + // 1. Try to find the cached allotments const cached = await CourseAllotment.findOne({ rollNumber: roll, @@ -80,8 +75,8 @@ export const fetchCourses = async (rollNumber) => { const CourseModel = (await import("../course/course.model.js")).default; const allCodes = cached.courses.map(getCourseCodeCaseInsensitiveRegex); const dbCourses = await CourseModel.find({ code: { $in: allCodes } }); - - const courseCodes = cached.courses.map(code => ({ original: code, normalized: code })); + + const courseCodes = cached.courses.map((code) => ({ original: code, normalized: code })); const courses = await resolveCourseNames(courseCodes, dbCourses); await User.updateOne({ rollNumber }, { $set: { courses } }); @@ -101,21 +96,21 @@ export const fetchCourses = async (rollNumber) => { yr: academic.currentYear, }), }; - + const response = await axios.post(config.url, config.data, { headers: config.headers, }); - + if (!response.data) { throw new AppError(500, "Something went wrong"); } - + const courseCodes = parseCourseAllotmentsFromHtml(response.data, rollNumber); - + if (courseCodes.length === 0) { throw new AppError(404, "No courses found for this roll number"); } - + const CourseModel = (await import("../course/course.model.js")).default; const allCodes = [ ...courseCodes.map((c) => normalizeCourseCode(c.normalized)), @@ -124,23 +119,31 @@ export const fetchCourses = async (rollNumber) => { .filter(Boolean) .map(getCourseCodeCaseInsensitiveRegex); const dbCourses = await CourseModel.find({ code: { $in: allCodes } }); - + const courses = await resolveCourseNames(courseCodes, dbCourses); - + await User.updateOne({ rollNumber }, { $set: { courses } }); // Cache the result for next time try { - const rawCodes = courseCodes.map(c => normalizeCourseCode(c.normalized)).filter(Boolean); + const rawCodes = courseCodes.map((c) => normalizeCourseCode(c.normalized)).filter(Boolean); await CourseAllotment.updateOne( { rollNumber: roll, session: academic.session, year: academic.currentYear }, { $set: { courses: rawCodes } }, - { upsert: true } + { upsert: true }, ); } catch (err) { - logger.error("Course allotment cache failed", { error: err, attributes: { dependency: "mongodb", operation: "cache-course-allotments", outcome: "failure", retryable: true } }); + logger.error("Course allotment cache failed", { + error: err, + attributes: { + dependency: "mongodb", + operation: "cache-course-allotments", + outcome: "failure", + retryable: true, + }, + }); } - + return courses; }; @@ -150,7 +153,10 @@ function calculateCourseSemesterNumber(rollNumber, courseYear, courseSession) { let sem = 1; if (courseSession.toLowerCase() === "july-nov") { sem = 2 * diff + 1; - } else if (courseSession.toLowerCase() === "jan-may" || courseSession.toLowerCase() === "jan - may") { + } else if ( + courseSession.toLowerCase() === "jan-may" || + courseSession.toLowerCase() === "jan - may" + ) { sem = 2 * diff; } return Math.max(1, sem); @@ -164,7 +170,7 @@ export const fetchCoursesForBr = async (rollNumber) => { const previousCourses = []; const configs = []; - + // Find all semesters cached for this student to minimize network calls const cachedSemesters = await CourseAllotment.find({ rollNumber: roll }); const CourseModel = (await import("../course/course.model.js")).default; @@ -172,14 +178,17 @@ export const fetchCoursesForBr = async (rollNumber) => { for (let yr = startYear; yr <= currentYear; yr++) { if (yr > startYear && (yr !== currentYear || academic.session !== "Jan-May")) { - const cacheHit = cachedSemesters.find(c => c.session === "Jan-May" && c.year === yr); + const cacheHit = cachedSemesters.find((c) => c.session === "Jan-May" && c.year === yr); if (cacheHit) { - const courseCodes = cacheHit.courses.map(code => ({ original: code, normalized: code })); + const courseCodes = cacheHit.courses.map((code) => ({ + original: code, + normalized: code, + })); const semesterCourses = await resolveCourseNames(courseCodes, dbCourses); previousCourses.push({ semester: calculateCourseSemesterNumber(rollNumber, yr, "Jan-May"), year: yr, - courses: semesterCourses + courses: semesterCourses, }); } else { configs.push({ @@ -189,22 +198,27 @@ export const fetchCoursesForBr = async (rollNumber) => { method: "post", url: "https://academic.iitg.ac.in/sso/gen/student1.jsp", headers: { "Content-Type": "application/x-www-form-urlencoded" }, - data: qs.stringify({ cid: "All", sess: "Jan-May", yr: yr }) - } + data: qs.stringify({ cid: "All", sess: "Jan-May", yr: yr }), + }, }); } } - if ((yr < currentYear || (yr === currentYear && academic.session === "July-Nov")) && - (yr !== currentYear || academic.session !== "July-Nov")) { - const cacheHit = cachedSemesters.find(c => c.session === "July-Nov" && c.year === yr); + if ( + (yr < currentYear || (yr === currentYear && academic.session === "July-Nov")) && + (yr !== currentYear || academic.session !== "July-Nov") + ) { + const cacheHit = cachedSemesters.find((c) => c.session === "July-Nov" && c.year === yr); if (cacheHit) { - const courseCodes = cacheHit.courses.map(code => ({ original: code, normalized: code })); + const courseCodes = cacheHit.courses.map((code) => ({ + original: code, + normalized: code, + })); const semesterCourses = await resolveCourseNames(courseCodes, dbCourses); previousCourses.push({ semester: calculateCourseSemesterNumber(rollNumber, yr, "July-Nov"), year: yr, - courses: semesterCourses + courses: semesterCourses, }); } else { configs.push({ @@ -214,8 +228,8 @@ export const fetchCoursesForBr = async (rollNumber) => { method: "post", url: "https://academic.iitg.ac.in/sso/gen/student1.jsp", headers: { "Content-Type": "application/x-www-form-urlencoded" }, - data: qs.stringify({ cid: "All", sess: "July-Nov", yr: yr }) - } + data: qs.stringify({ cid: "All", sess: "July-Nov", yr: yr }), + }, }); } } @@ -226,7 +240,7 @@ export const fetchCoursesForBr = async (rollNumber) => { configs.map(async (c) => { const res = await axios.post(c.req.url, c.req.data, { headers: c.req.headers }); return { data: res.data, sess: c.sess, yr: c.yr }; - }) + }), ); responses.forEach((res) => { @@ -240,19 +254,29 @@ export const fetchCoursesForBr = async (rollNumber) => { previousCourses.push({ semester: calculateCourseSemesterNumber(rollNumber, resObj.yr, resObj.sess), year: resObj.yr, - courses: semesterCourses + courses: semesterCourses, }); // Cache newly fetched historical allotments try { - const rawCodes = codes.map(c => normalizeCourseCode(c.normalized)).filter(Boolean); + const rawCodes = codes + .map((c) => normalizeCourseCode(c.normalized)) + .filter(Boolean); await CourseAllotment.updateOne( { rollNumber: roll, session: resObj.sess, year: resObj.yr }, { $set: { courses: rawCodes } }, - { upsert: true } + { upsert: true }, ); } catch (err) { - logger.error("BR course cache failed", { error: err, attributes: { dependency: "mongodb", operation: "cache-br-courses", outcome: "failure", retryable: true } }); + logger.error("BR course cache failed", { + error: err, + attributes: { + dependency: "mongodb", + operation: "cache-br-courses", + outcome: "failure", + retryable: true, + }, + }); } } } @@ -409,13 +433,13 @@ export const redirectHandler = async (req, res, next) => { const token = existingUser.generateJWT(); logger.metric?.("user_login", { - value: 1, - dimensions: { - userEmail: existingUser.email, + value: 1, + dimensions: { + userEmail: existingUser.email, isBR: existingUser.isBR || false, department: existingUser.department, - semester: existingUser.semester - } + semester: existingUser.semester, + }, }); res.cookie("token", token, { diff --git a/server/modules/auth/auth.routes.js b/server/modules/auth/auth.routes.js index d2a3f87c..b1424b06 100644 --- a/server/modules/auth/auth.routes.js +++ b/server/modules/auth/auth.routes.js @@ -1,22 +1,28 @@ import express from "express"; const router = express.Router(); +import isAuthenticated from "../../middleware/isAuthenticated.js"; import catchAsync from "../../utils/catchAsync.js"; import { redirectHandler, loginHandler, logoutHandler, - guestLoginHanlder, fetchCourses, - fetchCoursesForBr + fetchCoursesForBr, } from "./auth.controller.js"; router.get("/login", loginHandler); -router.get("/login/guest", guestLoginHanlder); -router.post("/fetchCourses", async (req, res, next) => { +router.post("/fetchCourses", isAuthenticated, async (req, res, next) => { try { - const { rollNumber } = req.body; - if (!rollNumber) return res.status(400).json({ error: "rollNumber required" }); + const rollNumber = req.user.rollNumber; + if ( + req.body.rollNumber !== undefined && + String(req.body.rollNumber) !== String(rollNumber) + ) { + return res + .status(403) + .json({ error: true, message: "You may only refresh your own courses" }); + } const courses = await fetchCourses(rollNumber); res.json({ courses }); } catch (err) { @@ -24,10 +30,17 @@ router.post("/fetchCourses", async (req, res, next) => { } }); -router.post("/fetchCoursesForBr", async (req, res, next) => { +router.post("/fetchCoursesForBr", isAuthenticated, async (req, res, next) => { try { - const { rollNumber } = req.body; - if (!rollNumber) return res.status(400).json({ error: "rollNumber required" }); + const rollNumber = req.user.rollNumber; + if ( + req.body.rollNumber !== undefined && + String(req.body.rollNumber) !== String(rollNumber) + ) { + return res + .status(403) + .json({ error: true, message: "You may only refresh your own courses" }); + } const courses = await fetchCoursesForBr(rollNumber); res.json({ courses }); } catch (error) { diff --git a/server/modules/br/br.routes.js b/server/modules/br/br.routes.js index 0b1954f9..780dfd3f 100644 --- a/server/modules/br/br.routes.js +++ b/server/modules/br/br.routes.js @@ -1,13 +1,21 @@ +import isAdmin from "../../middleware/isAdmin.js"; import express from "express"; -import { updateBRs, createBR, getAll, deleteBR, getBRs, getCoursesWithoutBR } from "./br.controller.js"; -import isAdmin from "../../middleware/isAdmin.js" +import { + updateBRs, + createBR, + getAll, + deleteBR, + getBRs, + getCoursesWithoutBR, +} from "./br.controller.js"; const router = express.Router(); +router.use(isAdmin); -router.post("/updateList",isAdmin, updateBRs); -router.post("/create", isAdmin,createBR); -router.get("/all",isAdmin, getAll); -router.get("/allBRs",isAdmin, getBRs); -router.get("/coursesWithoutBR",isAdmin, getCoursesWithoutBR); -router.delete("/delete",isAdmin, deleteBR); +router.post("/updateList", updateBRs); +router.post("/create", createBR); +router.get("/all", getAll); +router.get("/allBRs", getBRs); +router.get("/coursesWithoutBR", getCoursesWithoutBR); +router.delete("/delete", deleteBR); export default router; diff --git a/server/modules/contribution/contribution.controller.js b/server/modules/contribution/contribution.controller.js index 593c60df..265a45ce 100644 --- a/server/modules/contribution/contribution.controller.js +++ b/server/modules/contribution/contribution.controller.js @@ -22,7 +22,7 @@ async function ContributionCreation(contributionId, data) { const updatedContribution = await Contribution.findOneAndUpdate( { contributionId }, { ...data }, - { new: true } + { new: true }, ); return updatedContribution; } @@ -49,11 +49,6 @@ async function HandleFileToDB(contributionId, fileId) { return existingContribution; } -async function GetAllContributions(req, res, next) { - const allContributions = await Contribution.find({}); - res.json(allContributions); -} - async function HandleFileUpload(req, res, next) { logger.info("Handling File Upload"); const contributionId = req.headers["contribution-id"]; @@ -87,7 +82,15 @@ async function HandleFileUpload(req, res, next) { try { fileId = await UploadFile(contributionId, finalDirPath, finalFileName); } catch (uploadError) { - logger.error("Contribution upload failed", { error: uploadError, attributes: { dependency: "microsoft-graph", operation: "upload-contribution", outcome: "failure", retryable: true } }); + logger.error("Contribution upload failed", { + error: uploadError, + attributes: { + dependency: "microsoft-graph", + operation: "upload-contribution", + outcome: "failure", + retryable: true, + }, + }); } if (fileId) { @@ -100,7 +103,14 @@ async function HandleFileUpload(req, res, next) { await fs.promises.unlink(renamedPath).catch(() => {}); } } catch (err) { - logger.error("Contribution file processing failed", { error: err, attributes: { operation: "process-contribution", outcome: "failure", retryable: false } }); + logger.error("Contribution file processing failed", { + error: err, + attributes: { + operation: "process-contribution", + outcome: "failure", + retryable: false, + }, + }); } } @@ -130,7 +140,7 @@ async function CreateNewContribution(req, res, next) { } const newContribution = await ContributionCreation(data.contributionId, data); - + const uploader = await User.findById(data.uploadedBy); logger.metric?.("contribution_created", { @@ -139,8 +149,8 @@ async function CreateNewContribution(req, res, next) { courseCode: data.courseCode, userId: data.uploadedBy, department: uploader ? uploader.department : "unknown", - semester: uploader ? uploader.semester : 0 - } + semester: uploader ? uploader.semester : 0, + }, }); return res.json({ @@ -156,25 +166,6 @@ async function GetMyContributions(req, res, next) { res.json(myContributions); } -async function DeleteContribution(req, res, next) { - const { contributionId } = req.params; - await Contribution.deleteOne({ contributionId }); - res.json({ deleted: true }); -} - -// date format : YYYY-MM-DD -async function GetContributionsUpdatedSince(req, res, next) { - const { date } = req.body; - if (!date) return next(new AppError(400, "Invalid date")); - const d = new Date(date); - const contributions = await Contribution.find({ updatedAt: { $gte: d } }); - let codeSet = new Set(); - contributions.map((c) => codeSet.add(normalizeCourseCode(c.courseCode))); - let codes = []; - codeSet.forEach((c) => codes.push(c)); - return res.json({ codes, contributions }); -} - async function GetBrContribution(req, res, next) { try { const { courses } = req.body; @@ -188,7 +179,9 @@ async function GetBrContribution(req, res, next) { }).populate({ path: "files", }); - const unverifiedContributions = contributions.filter(c => c.files.some(f => f.isVerified === false)); + const unverifiedContributions = contributions.filter((c) => + c.files.some((f) => f.isVerified === false), + ); res.json({ unverifiedContributions }); } catch (error) { @@ -203,7 +196,7 @@ async function viewFile(req, res, next) { if (!file) { return res.status(404).json({ message: "File not found" }); } - if (file.isVerified === false && req.user.isBR === false) { + if (file.isVerified === false && !req.admin && req.user.isBR === false) { return res.status(403).json({ message: "File is not verified" }); } const getResponse = async () => { @@ -211,19 +204,19 @@ async function viewFile(req, res, next) { if (!accessToken) { return res.status(500).json({ message: "Access token not found" }); } - const response = await axios.get(`https://graph.microsoft.com/v1.0/me/drive/items/${file.fileId}/content`, { - headers: { - Authorization: `Bearer ${accessToken}` + const response = await axios.get( + `https://graph.microsoft.com/v1.0/me/drive/items/${file.fileId}/content`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + responseType: "stream", }, - responseType: "stream" - }); - - res.setHeader("Content-Type", response.headers["content-type"]) - res.setHeader( - "Content-Length", - response.headers["content-length"] ); + res.setHeader("Content-Type", response.headers["content-type"]); + res.setHeader("Content-Length", response.headers["content-length"]); + const downloadStream = response.data; res.on("close", () => { downloadStream.destroy(); @@ -245,12 +238,9 @@ async function viewFile(req, res, next) { } export default { - GetAllContributions, CreateNewContribution, HandleFileUpload, GetMyContributions, - DeleteContribution, - GetContributionsUpdatedSince, GetBrContribution, - viewFile + viewFile, }; diff --git a/server/modules/contribution/contribution.routes.js b/server/modules/contribution/contribution.routes.js index 233280a7..f4e4a2fe 100644 --- a/server/modules/contribution/contribution.routes.js +++ b/server/modules/contribution/contribution.routes.js @@ -1,18 +1,22 @@ +import isLibraryAuthenticated from "../../middleware/isLibraryAuthenticated.js"; import express from "express"; const router = express.Router(); +router.use(isLibraryAuthenticated); import ContributionController from "./contribution.controller.js"; import catchAsync from "../../utils/catchAsync.js"; import isAuthenticated from "../../middleware/isAuthenticated.js"; +import { isBR } from "../../middleware/isBR.js"; import multer from "multer"; export const upload = multer({ dest: "external/uploads" }); -router.get("/", isAuthenticated, ContributionController.GetMyContributions); -router.get("/all", ContributionController.GetAllContributions); -router.delete("/:contributionId", ContributionController.DeleteContribution); -router.post("/", catchAsync(ContributionController.CreateNewContribution)); -router.post("/upload", upload.array("file"), catchAsync(ContributionController.HandleFileUpload)); -// router.get("/:id", catchAsync(ContributionController.CreateNewContribution)); -router.post("/updated", catchAsync(ContributionController.GetContributionsUpdatedSince)); -router.post("/br",isAuthenticated, ContributionController.GetBrContribution) -router.get('/view/:id', isAuthenticated, catchAsync(ContributionController.viewFile)) +router.get("/", isAuthenticated, catchAsync(ContributionController.GetMyContributions)); +router.post("/", isAuthenticated, catchAsync(ContributionController.CreateNewContribution)); +router.post( + "/upload", + isAuthenticated, + upload.array("file"), + catchAsync(ContributionController.HandleFileUpload), +); +router.post("/br", isBR, catchAsync(ContributionController.GetBrContribution)); +router.get("/view/:id", catchAsync(ContributionController.viewFile)); export default router; diff --git a/server/modules/course/course.controller.js b/server/modules/course/course.controller.js index 469a9a27..cff7dc19 100644 --- a/server/modules/course/course.controller.js +++ b/server/modules/course/course.controller.js @@ -1,10 +1,6 @@ import AppError from "../../utils/appError.js"; -import User from "../user/user.model.js"; import CourseModel from "./course.model.js"; import logger from "../../utils/logger.js"; -import SearchResults from "../search/search.model.js"; -import courselist from "./course.list.js"; -import { bootstrapCourseFolders } from "./course.service.js"; import { normalizeCourseCode, getCourseCodeCaseInsensitiveRegex } from "../../utils/course.js"; import { computePopulatedFolderSubtreeCount } from "../../utils/folder.js"; @@ -27,28 +23,6 @@ const buildChildrenPopulate = (depth) => { return populate; }; -const createCourse = async (code) => { - const normalizedCode = normalizeCourseCode(code); - const courseLookupKey = `${normalizedCode.slice(0, 2)} ${normalizedCode.slice(-3)}`; - await CourseModel.create({ - code: normalizedCode, - name: courselist[courseLookupKey] || "Name Unavailable", - children: [], - books: [], - }); - - // Bootstrap folders - await bootstrapCourseFolders(normalizedCode); - - const createdCourse = await CourseModel.findOne({ - code: getCourseCodeCaseInsensitiveRegex(normalizedCode), - }) - .populate(buildChildrenPopulate(COURSE_CHILDREN_POPULATE_DEPTH)) - .select("-__v"); - - return createdCourse; -}; - export const getCourse = async (req, res, next) => { const { code } = req.params; logger.info("Course requested"); @@ -57,14 +31,11 @@ export const getCourse = async (req, res, next) => { if (!normalizedCode) throw new AppError(400, "Missing Course Id"); const courseCodeRegex = getCourseCodeCaseInsensitiveRegex(normalizedCode); - let courseDoc = await CourseModel.findOne({ code: courseCodeRegex }) + const courseDoc = await CourseModel.findOne({ code: courseCodeRegex }) .populate(buildChildrenPopulate(COURSE_CHILDREN_POPULATE_DEPTH)) .select("-__v"); - if (!courseDoc) { - courseDoc = await createCourse(normalizedCode); - } - if (!courseDoc) throw new AppError(500, "Failed to create course"); + if (!courseDoc) throw new AppError(404, "Course not found"); // Convert to plain object to avoid issues with Mongoose internal state const courseObj = courseDoc.toObject(); @@ -84,19 +55,11 @@ export const getCourse = async (req, res, next) => { } } - if (!req.user) { - let token = req.cookies?.token || req.headers?.authorization?.split(" ")[1]; - if (token) { - const user = await User.findByJWT(token); - if (user) req.user = user; - } - } - logger.metric?.("course_opened", { value: 1, dimensions: { courseCode: courseObj.code, - userEmail: req.user ? req.user.email : "guest", + userEmail: req.user?.email || req.admin?.userId, department: req.user ? req.user.department : "unknown", semester: req.user ? req.user.semester : 0, }, @@ -110,37 +73,3 @@ export const getAllCourses = async (req, res, next) => { res.json(allCourse); }; - -export const isCourseUpdated = async (req, res, next) => { - let { clientOn } = req.body; - if (!clientOn) return next(new AppError(500, "Invalid data provided!")); - let outdatedOnClient = []; - - const courses = req.user.courses.map((c) => normalizeCourseCode(c.code)).filter(Boolean); - const searchResults = await SearchResults.find({ - code: { $in: courses.map(getCourseCodeCaseInsensitiveRegex) }, - }); - const availableCourses = searchResults.filter((s) => s.isAvailable === true); - const availableCourseCodes = [ - ...new Set(availableCourses.map((c) => normalizeCourseCode(c.code)).filter(Boolean)), - ]; - const allOutdatedCourses = await CourseModel.find({ - $and: [ - { code: { $in: availableCourseCodes.map(getCourseCodeCaseInsensitiveRegex) } }, - { $or: [{ createdAt: { $gt: clientOn } }, { updatedAt: { $gt: clientOn } }] }, - ], - }); - - allOutdatedCourses.map((course) => { - outdatedOnClient.push(course.code); - }); - - if (!allOutdatedCourses.length > 0) { - return res.json({ updated: false, subscribedCourses: req.user.courses }); - } - return res.json({ - updated: true, - updatedCourses: outdatedOnClient, - subscribedCourses: req.user.courses, - }); -}; diff --git a/server/modules/course/course.routes.js b/server/modules/course/course.routes.js index 862997f1..b02fed60 100644 --- a/server/modules/course/course.routes.js +++ b/server/modules/course/course.routes.js @@ -1,13 +1,15 @@ +import isLibraryAuthenticated from "../../middleware/isLibraryAuthenticated.js"; import express from "express"; -import { getAllCourses, getCourse, isCourseUpdated } from "./course.controller.js"; +import { getAllCourses, getCourse } from "./course.controller.js"; import CourseModel from "./course.model.js"; import { normalizeCourseCode, getCourseCodeCaseInsensitiveRegex } from "../../utils/course.js"; +import isAdmin from "../../middleware/isAdmin.js"; import logger from "../../utils/logger.js"; const router = express.Router(); +router.use(isLibraryAuthenticated); import catchAsync from "../../utils/catchAsync.js"; -import isAuthenticated from "../../middleware/isAuthenticated.js"; -router.post("/create/:code", async (req, res) => { +router.post("/create/:code", isAdmin, async (req, res) => { try { const { code } = req.params; const { name } = req.body; @@ -49,7 +51,6 @@ router.post("/create/:code", async (req, res) => { } }); router.get("/", catchAsync(getAllCourses)); -router.post("/isUpdated", isAuthenticated, catchAsync(isCourseUpdated)); router.get("/:code", catchAsync(getCourse)); export default router; diff --git a/server/modules/event/event.controller.js b/server/modules/event/event.controller.js index 3ab896cf..1e7e07d2 100644 --- a/server/modules/event/event.controller.js +++ b/server/modules/event/event.controller.js @@ -9,10 +9,4 @@ async function GetExamDates(req, res, next) { return res.json({ dates: examDates.otherDates }); } -async function CreateEvent(req, res) { - const content = req.body; - const event = await EventModel.create(content); - return res.status(201).json({ event }); -} - -export default { GetExamDates, CreateEvent }; +export default { GetExamDates }; diff --git a/server/modules/event/event.routes.js b/server/modules/event/event.routes.js index 011fcc79..25358a09 100644 --- a/server/modules/event/event.routes.js +++ b/server/modules/event/event.routes.js @@ -5,6 +5,6 @@ import isAuthenticated from "../../middleware/isAuthenticated.js"; const router = Router(); -router.get("/examdates", isAuthenticated, catchAsync(EventController.GetExamDates)); -router.post("/create", catchAsync(EventController.CreateEvent)); +router.use(isAuthenticated); +router.get("/examdates", catchAsync(EventController.GetExamDates)); export default router; diff --git a/server/modules/file/file.routes.js b/server/modules/file/file.routes.js index 7bcbe70d..33d86b19 100644 --- a/server/modules/file/file.routes.js +++ b/server/modules/file/file.routes.js @@ -1,18 +1,23 @@ +import isLibraryAuthenticated from "../../middleware/isLibraryAuthenticated.js"; import express from "express"; -import { getAllFiles, verifyFile, unverifyFile, getFileLink, renameFile } from "./file.controller.js"; -import isAuthenticated from "../../middleware/isAuthenticated.js"; -import { isBR } from "../../middleware/isBR.js"; // if it's a named export +import { + getAllFiles, + verifyFile, + unverifyFile, + getFileLink, + renameFile, +} from "./file.controller.js"; +import { isBR } from "../../middleware/isBR.js"; import { downloadFiles } from "../../scripts/downloadFile.js"; const router = express.Router(); +router.use(isLibraryAuthenticated); -router.get("/all", isAuthenticated, getAllFiles); -router.put("/verify/:id", isAuthenticated, isBR, verifyFile); -// router.delete("/unverify/:id", isAuthenticated, isBR, unverifyFile); -router.delete("/unverify/:id", unverifyFile); -// route to download file +router.get("/all", getAllFiles); +router.put("/verify/:id", isBR, verifyFile); +router.delete("/unverify/:id", isBR, unverifyFile); router.post("/download", downloadFiles); router.get("/link/:id", getFileLink); -router.put("/rename/:id", isAuthenticated, isBR, renameFile); +router.put("/rename/:id", isBR, renameFile); export default router; diff --git a/server/modules/folder/folder.routes.js b/server/modules/folder/folder.routes.js index fb9da52a..1246bce2 100644 --- a/server/modules/folder/folder.routes.js +++ b/server/modules/folder/folder.routes.js @@ -1,13 +1,14 @@ +import isLibraryAuthenticated from "../../middleware/isLibraryAuthenticated.js"; import express from "express"; -import { createFolder,deleteFolder,getFolderContent, renameFolder } from "./folder.controller.js"; -import isAuthenticated from "../../middleware/isAuthenticated.js"; -import {isBR} from "../../middleware/isBR.js"; // if it's a named export +import { createFolder, deleteFolder, getFolderContent, renameFolder } from "./folder.controller.js"; +import { isBR } from "../../middleware/isBR.js"; // if it's a named export const router = express.Router(); +router.use(isLibraryAuthenticated); -router.post("/create", isAuthenticated, isBR, createFolder); -router.delete("/delete", isAuthenticated, isBR, deleteFolder); +router.post("/create", isBR, createFolder); +router.delete("/delete", isBR, deleteFolder); router.get("/content/:folderId", getFolderContent); -router.post("/rename", isAuthenticated, isBR, renameFolder) +router.post("/rename", isBR, renameFolder); export default router; diff --git a/server/modules/onedrive/onedrive.routes.js b/server/modules/onedrive/onedrive.routes.js index 98afe990..9f8d9f6e 100644 --- a/server/modules/onedrive/onedrive.routes.js +++ b/server/modules/onedrive/onedrive.routes.js @@ -1,8 +1,10 @@ +import isLibraryAuthenticated from "../../middleware/isLibraryAuthenticated.js"; import express from "express"; import catchAsync from "../../utils/catchAsync.js"; import { thumbnail, getFilePreview, getFileDownload } from "./onedrive.controller.js"; const router = express.Router(); +router.use(isLibraryAuthenticated); router.post("/thumbnail", catchAsync(thumbnail)); router.get("/preview/:fileID", catchAsync(getFilePreview)); diff --git a/server/modules/search/search.routes.js b/server/modules/search/search.routes.js index 4c90f7f5..615cbb47 100644 --- a/server/modules/search/search.routes.js +++ b/server/modules/search/search.routes.js @@ -1,33 +1,12 @@ import { Router } from "express"; import catchAsync from "../../utils/catchAsync.js"; -import SearchResult from "./search.model.js"; -import isAuthenticated from "../../middleware/isAuthenticated.js"; +import isLibraryAuthenticated from "../../middleware/isLibraryAuthenticated.js"; const router = Router(); +router.use(isLibraryAuthenticated); import CourseModel from "../course/course.model.js"; - - -// router.post( -// "/", -// catchAsync(async (req, res, next) => { -// let { code } = req.body; -// code = code.toLowerCase(); -// if (!code) return res.sendStatus(400); -// const fetched = await SearchResult.findOne({ code: code }); -// if (!fetched) return res.status(200).json({ found: false }); -// return res.status(200).json({ -// found: true, -// id: fetched._id, -// name: fetched.name, -// code: fetched.code, -// isAvailable: fetched.isAvailable, -// }); -// }) -// ); - router.post( "/", - // isAuthenticated, catchAsync(async (req, res, next) => { let { words } = req.body; const searchWords = words.map((w) => w.toLowerCase()); @@ -50,47 +29,7 @@ router.post( .sort((a, b) => b.matchCount - a.matchCount); return res.status(200).json({ found: payload.length > 0, results: payload }); - - // const agg = await SearchResult.getSearchResults(words); - // const payload = []; - // agg.forEach((item) => { - // if (item.numberOfWordsMatched > 0 || item.codeMatch) payload.push(item); - // }); - // return res.status(200).json({ found: payload.length > 0 ? true : false, results: payload }); - }) -); - -router.post( - "/feed", - // isAuthenticated, - catchAsync(async (req, res, next) => { - const { name, code } = req.body; - const savedDocument = await SearchResult.create({ - name: name.toLowerCase(), - code: code.toLowerCase(), - }); - res.json(savedDocument); - }) -); - -router.get( - "/available", - // isAuthenticated, - catchAsync(async (req, res, next) => { - const availableCourses = await SearchResult.find({ isAvailable: true }); - return res.json(availableCourses); - }) -); - -router.post( - "/isavailable", - // isAuthenticated, - catchAsync(async (req, res, next) => { - const { code } = req.body; - const course = await SearchResult.findOne({ code: code?.toLowerCase() }); - if (!course) return res.json({ isAvailable: false }); - return res.json({ isAvailable: course.isAvailable }); - }) + }), ); export default router; diff --git a/server/modules/student/student.routes.js b/server/modules/student/student.routes.js index 456b9732..9ee17226 100644 --- a/server/modules/student/student.routes.js +++ b/server/modules/student/student.routes.js @@ -1,6 +1,6 @@ +import isAdmin from "../../middleware/isAdmin.js"; import express from "express"; import catchAsync from "../../utils/catchAsync.js"; -import isAdmin from "../../middleware/isAdmin.js"; import { getAllStudents, searchStudents, @@ -10,11 +10,12 @@ import { } from "./student.controller.js"; const router = express.Router(); +router.use(isAdmin); -router.get("/all", isAdmin, catchAsync(getAllStudents)); -router.get("/search", isAdmin, catchAsync(searchStudents)); -router.put("/refresh/:id", isAdmin, catchAsync(refreshStudentCourses)); -router.post("/semester-reset", isAdmin, catchAsync(semesterReset)); -router.delete("/:id", isAdmin, catchAsync(deleteStudent)); +router.get("/all", catchAsync(getAllStudents)); +router.get("/search", catchAsync(searchStudents)); +router.put("/refresh/:id", catchAsync(refreshStudentCourses)); +router.post("/semester-reset", catchAsync(semesterReset)); +router.delete("/:id", catchAsync(deleteStudent)); -export default router \ No newline at end of file +export default router; diff --git a/server/modules/user/user.model.js b/server/modules/user/user.model.js index 1217ab3b..ab669761 100644 --- a/server/modules/user/user.model.js +++ b/server/modules/user/user.model.js @@ -15,7 +15,7 @@ const userSchema = Schema({ semester: { type: Number, reqiured: true }, degree: { type: String, required: true }, courses: { type: Array, default: [], required: true }, - readOnly: {type: Array, default: []}, + readOnly: { type: Array, default: [] }, isBR: { type: Boolean }, previousCourses: { type: Array, default: [] }, department: { type: String, required: true }, //dup @@ -40,7 +40,7 @@ userSchema.pre("save", function (next) { const courseCodes = new Set([ ...user.courses.map((c) => normalizeCourseCode(c.code)), ...(user.previousCourses?.flatMap((sem) => - sem.courses.map((c) => normalizeCourseCode(c.code)) + sem.courses.map((c) => normalizeCourseCode(c.code)), ) || []), ]); user.readOnly = user.readOnly.filter((c) => !courseCodes.has(normalizeCourseCode(c.code))); @@ -50,13 +50,9 @@ userSchema.pre("save", function (next) { userSchema.methods.generateJWT = function () { var user = this; - var token = jwt.sign( - { user: user._id, isBR: user.isBR }, - config.jwtSecret, - { - expiresIn: "24d", - } - ); + var token = jwt.sign({ user: user._id, isBR: user.isBR }, config.jwtSecret, { + expiresIn: "24d", + }); return token; }; @@ -65,6 +61,7 @@ userSchema.statics.findByJWT = async function (token) { var user = this; var decoded = jwt.verify(token, config.jwtSecret); const id = decoded.user; + if (typeof id !== "string" || !/^[a-f0-9]{24}$/i.test(id)) return false; const fetchedUser = await user.findOne({ _id: id }); if (!fetchedUser) return false; return fetchedUser; @@ -86,13 +83,15 @@ export const validateUser = function (obj) { degree: Joi.string().required(), courses: Joi.array().required(), isBR: Joi.boolean().optional(), - previousCourses: Joi.array().items( - Joi.object({ - semester: Joi.number().required(), - year: Joi.number().required(), - courses: Joi.array().required() - }) - ).required(), + previousCourses: Joi.array() + .items( + Joi.object({ + semester: Joi.number().required(), + year: Joi.number().required(), + courses: Joi.array().required(), + }), + ) + .required(), department: Joi.string().required(), readOnly: Joi.array().required(), }); @@ -101,7 +100,14 @@ export const validateUser = function (obj) { export const updateUserData = async (userId, userData) => { User.findOne({ _id: userId }, async (err, doc) => { if (err) { - logger.error("User update failed", { attributes: { dependency: "mongodb", operation: "update-user", outcome: "failure", retryable: false } }); + logger.error("User update failed", { + attributes: { + dependency: "mongodb", + operation: "update-user", + outcome: "failure", + retryable: false, + }, + }); } if (userData.newUserData.newUserName) { doc.name = userData.newUserData.newUserName; @@ -174,7 +180,7 @@ export const AddNewCourse = async (userid, code, name) => { // Remove from readOnly if present UserData.readOnly = UserData.readOnly.filter( - (course) => normalizeCourseCode(course.code) !== normalizedCode + (course) => normalizeCourseCode(course.code) !== normalizedCode, ); UserData.courses.push({ @@ -195,13 +201,13 @@ export const AddReadOnlyCourse = async (userid, code, name) => { // Check if in courses const inCourses = UserData.courses.some( - (course) => normalizeCourseCode(course.code) === normalizedCode + (course) => normalizeCourseCode(course.code) === normalizedCode, ); if (inCourses) return UserData; // Check if in previousCourses const inPrevious = UserData.previousCourses?.some((sem) => - sem.courses.some((course) => normalizeCourseCode(course.code) === normalizedCode) + sem.courses.some((course) => normalizeCourseCode(course.code) === normalizedCode), ); if (inPrevious) return UserData; @@ -219,7 +225,7 @@ export const RemoveCourse = async (userid, code) => { const UserData = await User.findById(userid); const normalizedCode = normalizeCourseCode(code); let filtered = UserData.courses.filter( - (course) => normalizeCourseCode(course.code) !== normalizedCode + (course) => normalizeCourseCode(course.code) !== normalizedCode, ); UserData.courses = filtered; const updatedUser = await UserData.save(); @@ -230,7 +236,7 @@ export const RemoveReadOnly = async (userid, code) => { const UserData = await User.findById(userid); const normalizedCode = normalizeCourseCode(code); let filtered = UserData.readOnly.filter( - (course) => normalizeCourseCode(course.code) !== normalizedCode + (course) => normalizeCourseCode(course.code) !== normalizedCode, ); UserData.readOnly = filtered; const updatedUser = await UserData.save(); @@ -241,7 +247,7 @@ export const removeFromFavourites = async (userid, fileid) => { const resp = await User.findOneAndUpdate( { _id: userid }, { $pull: { favourites: { _id: fileid } } }, - { new: true } + { new: true }, ); return resp; }; diff --git a/server/modules/user/user.routes.js b/server/modules/user/user.routes.js index 6eda3113..92d410a6 100644 --- a/server/modules/user/user.routes.js +++ b/server/modules/user/user.routes.js @@ -1,3 +1,4 @@ +import isAuthenticated from "../../middleware/isAuthenticated.js"; import { Router } from "express"; import { getUser, @@ -9,24 +10,22 @@ import { updateDeviceToken, getFavouritesController, addReadOnly, - deleteReadOnly - + deleteReadOnly, } from "./user.controller.js"; import catchAsync from "../../utils/catchAsync.js"; const router = Router(); +router.use(isAuthenticated); -import isAuthenticated from "../../middleware/isAuthenticated.js"; - -router.get("/", isAuthenticated, catchAsync(getUser)); -router.put("/update", isAuthenticated, catchAsync(updateUserController)); +router.get("/", catchAsync(getUser)); +router.put("/update", catchAsync(updateUserController)); -router.get("/favourites", isAuthenticated, catchAsync(getFavouritesController)); -router.post("/favourites", isAuthenticated, catchAsync(addToFavouriteController)); +router.get("/favourites", catchAsync(getFavouritesController)); +router.post("/favourites", catchAsync(addToFavouriteController)); -router.delete("/favourites/:id", isAuthenticated, catchAsync(removeFromFavouritesController)); -router.post("/course", isAuthenticated, catchAsync(addNewCourse)); -router.post("/readonly", isAuthenticated, catchAsync(addReadOnly)) -router.delete("/course/:code", isAuthenticated, catchAsync(deleteCourse)); -router.delete("/readonly/:code", isAuthenticated, catchAsync(deleteReadOnly)); -router.put("/devicetoken", isAuthenticated, catchAsync(updateDeviceToken)); +router.delete("/favourites/:id", catchAsync(removeFromFavouritesController)); +router.post("/course", catchAsync(addNewCourse)); +router.post("/readonly", catchAsync(addReadOnly)); +router.delete("/course/:code", catchAsync(deleteCourse)); +router.delete("/readonly/:code", catchAsync(deleteReadOnly)); +router.put("/devicetoken", catchAsync(updateDeviceToken)); export default router; diff --git a/server/modules/year/year.routes.js b/server/modules/year/year.routes.js index 72dd3358..4ccf47ac 100644 --- a/server/modules/year/year.routes.js +++ b/server/modules/year/year.routes.js @@ -1,11 +1,12 @@ +import isLibraryAuthenticated from "../../middleware/isLibraryAuthenticated.js"; import express from "express"; -import {addYear,deleteYear} from "./year.controller.js"; -import isAuthenticated from "../../middleware/isAuthenticated.js"; -import {isBR} from "../../middleware/isBR.js"; +import { addYear, deleteYear } from "./year.controller.js"; +import { isBR } from "../../middleware/isBR.js"; const router = express.Router(); +router.use(isLibraryAuthenticated); -router.post("", isAuthenticated, isBR, addYear); -router.delete("/delete",isAuthenticated,isBR,deleteYear); +router.post("", isBR, addYear); +router.delete("/delete", isBR, deleteYear); export default router; diff --git a/server/package.json b/server/package.json index 4ceb93c6..94096a49 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,8 @@ "test": "node --test test/*.test.js", "test:db": "node --test test/integration/*.test.js", "admin": "node scripts/provisionAdmin.js", - "sync-cache": "node scripts/syncCoursesCache.js" + "sync-cache": "node scripts/syncCoursesCache.js", + "test:browser": "node --test test/browser/*.test.js" }, "keywords": [], "author": "", diff --git a/server/test/browser/library-access.test.js b/server/test/browser/library-access.test.js new file mode 100644 index 00000000..a6853652 --- /dev/null +++ b/server/test/browser/library-access.test.js @@ -0,0 +1,231 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { before, after, test } from "node:test"; +import { createRequire } from "node:module"; +import { student, course, folder, libraryFile } from "../fixtures/library.js"; + +// Use the installed browser-test toolchain; no browser or provider is downloaded at runtime. +const { chromium } = createRequire(import.meta.url)(process.env.PLAYWRIGHT_MODULE || "playwright"); +const frontend = process.env.BROWSER_CLIENT_ORIGIN || "http://127.0.0.1:4183"; +const api = process.env.BROWSER_API_ORIGIN || "http://127.0.0.1:4185"; +const cookieValue = "synthetic-browser-session"; +let browser; +before(async () => { + assert.notEqual(frontend, api, "Use distinct frontend/API origins to test credentials"); + browser = await chromium.launch({ + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined, + }); +}); +after(async () => browser?.close()); + +async function openPage(t, { width = 1440, signedIn = true, sessionStatus, holdSession } = {}) { + const context = await browser.newContext({ + viewport: { width, height: 900 }, + locale: "en-US", + timezoneId: "Asia/Kolkata", + serviceWorkers: "block", + }); + if (signedIn) + await context.addCookies([ + { name: "token", value: cookieValue, url: api, httpOnly: true, sameSite: "Lax" }, + ]); + const page = await context.newPage(); + const errors = []; + const requests = []; + page.on("pageerror", (error) => errors.push(error.message)); + t.after(async () => { + if (process.env.BROWSER_ARTIFACT_DIR) { + fs.mkdirSync(process.env.BROWSER_ARTIFACT_DIR, { recursive: true }); + const name = t.name.replace(/[^a-z0-9-]+/gi, "-"); + await page.screenshot({ + path: path.join(process.env.BROWSER_ARTIFACT_DIR, name + ".png"), + fullPage: true, + }); + fs.writeFileSync( + path.join(process.env.BROWSER_ARTIFACT_DIR, name + ".json"), + JSON.stringify( + { requests, errors, text: await page.locator("body").innerText() }, + null, + 2, + ), + ); + } + await context.close(); + assert.deepEqual(errors, []); + }); + await context.route("**/*", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin === frontend && !url.pathname.startsWith("/api/")) return route.continue(); + if (url.href === libraryFile.thumbnail.url) + return route.fulfill({ + contentType: "image/gif", + body: Buffer.from( + "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + "base64", + ), + }); + if (url.href === "https://download.example.test/notes") { + assert.equal((await request.allHeaders()).cookie, undefined); + return route.fulfill({ + contentType: "application/pdf", + body: "%PDF-1.4\nSynthetic notes\n%%EOF", + }); + } + if (url.origin !== api) return route.abort(); + const headers = await request.allHeaders(); + const hasSession = headers.cookie?.includes(`token=${cookieValue}`); + requests.push({ + path: url.pathname, + method: request.method(), + hasSession: Boolean(hasSession), + headers, + body: request.postData(), + }); + let status = 200; + let data; + if (url.pathname === "/api/user") { + if (holdSession) await holdSession; + status = sessionStatus ? sessionStatus() : hasSession ? 200 : 401; + data = status === 200 ? student : { message: "Unable to restore session" }; + } else if (!hasSession) { + status = 401; + data = { message: "Sign in to continue" }; + } else if (url.pathname === "/api/course/CS101") data = { found: true, ...course }; + else if (url.pathname.startsWith("/api/folder/content/")) data = folder; + else if (url.pathname === "/api/contribution/") { + data = + request.method() === "POST" + ? { created: true, data: JSON.parse(request.postData()) } + : []; + } else if (url.pathname === "/api/contribution/upload") data = libraryFile._id; + else if (url.pathname === "/api/files/download") + data = { downloadLink: "https://download.example.test/notes" }; + else if (url.pathname === "/api/event/examdates") + data = { dates: { midSem: "2026-09-15", endSem: "2026-11-25" } }; + else if (url.pathname === "/api/search") data = { found: true, results: [course] }; + else if (url.pathname === "/api/user/favourites") data = []; + else { + status = 404; + data = { message: "Unexpected fixture endpoint" }; + } + return route + .fulfill({ + status, + headers: { + "access-control-allow-origin": frontend, + "access-control-allow-credentials": "true", + }, + contentType: typeof data === "string" ? "text/plain" : "application/json", + body: typeof data === "string" ? data : JSON.stringify(data), + }) + .catch(() => {}); + }); + return { page, requests }; +} + +for (const width of [1440, 390]) { + test(`signed-out and expired sessions return to sign-in without fetching content at ${width}px`, async (t) => { + for (const signedIn of [false, true]) { + const { page, requests } = await openPage(t, { + width, + signedIn, + sessionStatus: () => 401, + }); + await page.goto(`${frontend}/browse/CS101/${folder._id}`); + await page.waitForURL(frontend + "/"); + await page.getByText("Sign in with Microsoft", { exact: false }).waitFor(); + assert.ok(requests.length > 0); + assert.ok(requests.every((request) => request.path === "/api/user")); + } + }); + test(`a student session restores the requested folder at ${width}px`, async (t) => { + const { page, requests } = await openPage(t, { width }); + await page.goto(`${frontend}/browse/CS101/${folder._id}`); + await page.getByTitle("Lecture notes.pdf", { exact: true }).first().waitFor(); + assert.equal(new URL(page.url()).pathname, `/browse/CS101/${folder._id}`); + assert.equal(requests[0].path, "/api/user"); + assert.ok(requests.every((request) => request.hasSession)); + }); +} + +test("library content waits for session confirmation", async (t) => { + let release; + const holdSession = new Promise((resolve) => { + release = resolve; + }); + const { page, requests } = await openPage(t, { holdSession }); + await page.goto(`${frontend}/browse/CS101`, { waitUntil: "domcontentloaded" }); + await page.getByText("Checking your session...").waitFor(); + assert.ok(requests.every((request) => request.path === "/api/user")); + assert.equal(await page.locator(".browse-folder").count(), 0); + release(); + await page.getByText("Lecture Notes", { exact: true }).first().waitFor(); +}); + +test("a failed session check can be retried without losing the requested folder", async (t) => { + let attempts = 0; + const { page } = await openPage(t, { sessionStatus: () => (++attempts === 1 ? 503 : 200) }); + await page.goto(`${frontend}/browse/CS101/${folder._id}`); + await page + .getByRole("alert") + .getByText("We couldn’t check your session.", { exact: false }) + .waitFor(); + const retry = page.getByRole("button", { name: "Try again" }); + await page.keyboard.press("Tab"); + assert.equal(await retry.evaluate(button => button === document.activeElement), true); + await page.keyboard.press("Enter"); + await page.getByTitle("Lecture notes.pdf", { exact: true }).first().waitFor(); + assert.equal(new URL(page.url()).pathname, `/browse/CS101/${folder._id}`); +}); + +test("FilePond sends credentials and the contribution association across origins", async (t) => { + const { page, requests } = await openPage(t); + await page.goto(`${frontend}/browse/CS101/${folder._id}`); + await page.getByRole("button", { name: "Contribute", exact: true }).click(); + await page.locator(".filepond--browser").setInputFiles({ + name: "synthetic-notes.pdf", + mimeType: "application/pdf", + buffer: Buffer.from("%PDF-1.4\nTest notes\n%%EOF"), + }); + await page.locator(".contri .button").click(); + await page.getByText("Files uploaded successfully!", { exact: true }).waitFor(); + const created = requests.find( + (request) => request.path === "/api/contribution/" && request.method === "POST", + ); + const uploaded = requests.find((request) => request.path === "/api/contribution/upload"); + assert.ok(created?.hasSession && uploaded?.hasSession); + const manifest = JSON.parse(created.body); + assert.equal(uploaded.headers["contribution-id"], manifest.contributionId); + assert.equal(manifest.uploadedBy, student._id); + assert.ok(uploaded.headers["content-type"].startsWith("multipart/form-data")); + assert.ok(uploaded.body.includes("Test notes")); +}); + +test("individual and ZIP download requests include the student cookie only on API requests", async (t) => { + const { page, requests } = await openPage(t); + await page.goto(`${frontend}/browse/CS101/${folder._id}`); + await page.getByTitle("Lecture notes.pdf", { exact: true }).first().waitFor(); + const link = await page.evaluate(async () => { + const { getFileDownloadLink } = await import("/src/api/File.js"); + return getFileDownloadLink("https://example.test/notes"); + }); + assert.equal(link, "https://download.example.test/notes"); + const saved = page.waitForEvent("download"); + await page.getByTitle("Download entire folder as ZIP", { exact: true }).click(); + assert.match((await saved).suggestedFilename(), /\.zip$/); + const downloads = requests.filter((request) => request.path === "/api/files/download"); + assert.ok(downloads.length >= 2); + assert.ok(downloads.every((request) => request.hasSession)); +}); + +for (const width of [1440, 390]) { + test(`a signed-in direct profile link retains its destination at ${width}px`, async (t) => { + const { page } = await openPage(t, { width }); + await page.goto(`${frontend}/profile`); + await page.getByText(student.name, { exact: true }).first().waitFor(); + assert.equal(new URL(page.url()).pathname, "/profile"); + }); +} diff --git a/server/test/fixtures/library.js b/server/test/fixtures/library.js new file mode 100644 index 00000000..874a26ce --- /dev/null +++ b/server/test/fixtures/library.js @@ -0,0 +1,47 @@ +export const student = { + _id: "507f1f77bcf86cd799439011", + name: "Library Test Student", + email: "library-student@example.test", + rollNumber: 240101001, + degree: "B.Tech", + department: "Computer Science and Engineering", + semester: 5, + isBR: false, + courses: [{ code: "CS101", name: "Introduction to Computer Science" }], + previousCourses: [], + readOnly: [], + favourites: [], +}; +export const administrator = { _id: "507f1f77bcf86cd799439012", userId: "library-test-admin" }; +export const libraryFile = { + _id: "507f1f77bcf86cd799439021", + fileId: "test-drive-file", + name: "Lecture notes.pdf", + size: "128", + isVerified: true, + webUrl: "https://example.test/notes", + downloadUrl: "https://example.test/notes", + thumbnail: { url: "https://ik.imagekit.io/coursehub-test/notes.webp" }, +}; +export const folder = { + _id: "507f1f77bcf86cd799439031", + name: "Lecture Notes", + courses: ["CS101"], + childType: "File", + children: [libraryFile], + totalFileCount: 1, +}; +export const year = { + _id: "507f1f77bcf86cd799439041", + name: "2026", + courses: ["CS101"], + childType: "Folder", + children: [folder], + totalFileCount: 1, +}; +export const course = { + _id: "507f1f77bcf86cd799439051", + code: "CS101", + name: "Introduction to Computer Science", + children: [year], +}; diff --git a/server/test/integration/admin-provisioning.test.js b/server/test/integration/server.test.js similarity index 50% rename from server/test/integration/admin-provisioning.test.js rename to server/test/integration/server.test.js index abd466a6..8800bbcf 100644 --- a/server/test/integration/admin-provisioning.test.js +++ b/server/test/integration/server.test.js @@ -1,15 +1,28 @@ import "../support/environment.js"; import assert from "node:assert/strict"; -import { after, before, test } from "node:test"; +import { after, before, beforeEach, test } from "node:test"; import { randomUUID } from "node:crypto"; import { once } from "node:events"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import mongoose from "mongoose"; +import fs from "node:fs"; +import path from "node:path"; +import axios from "axios"; +import User from "../../modules/user/user.model.js"; +import UserUpdate from "../../modules/user/userUpdate.model.js"; +import Course, { FolderModel, FileModel } from "../../modules/course/course.model.js"; +import Contribution from "../../modules/contribution/contribution.model.js"; +import { upload } from "../../modules/contribution/contribution.routes.js"; +import { clearAccessTokenCache } from "../../modules/onedrive/onedrive.controller.js"; +import { guardExternalServices } from "../support/provider-guards.js"; +import { student, course, year, folder } from "../fixtures/library.js"; import { app } from "../../index.js"; import Admin from "../../modules/admin/admin.model.js"; import { provisionAdmin } from "../../modules/admin/admin.provisioning.js"; +beforeEach(guardExternalServices); + const owner = randomUUID(); const password = "database-fixture-password-only"; let ownsDatabase = false; @@ -197,3 +210,160 @@ test("the database suite leaves a nonempty test target intact", async () => { owner, ); }); + +test("authenticated browsing and multipart upload persist content with mocked Graph storage", async (t) => { + const person = await User.create(student); + await UserUpdate.create({ rollNumber: person.rollNumber }); + await FolderModel.create({ ...folder, children: [] }); + await FolderModel.create({ ...year, children: [folder._id] }); + await Course.create({ ...course, children: [year._id] }); + const headers = { cookie: `token=${person.generateJWT()}` }; + for (const route of [ + "/api/user", + "/api/course/CS101", + `/api/folder/content/${folder._id}?courseCode=CS101`, + ]) { + const response = await fetch(origin + route, { headers }); + assert.equal(response.status, 200, route); + await response.json(); + } + const absent = await fetch(origin + "/api/course/MISSING", { headers }); + assert.equal(absent.status, 404); + assert.equal(await Course.countDocuments(), 1); + const unsigned = await fetch(origin + "/api/course/CS101"); + assert.equal(unsigned.status, 401); + + const contributionId = randomUUID(); + const created = await fetch(origin + "/api/contribution", { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ + contributionId, + uploadedBy: person.id, + courseCode: "CS101", + parentFolder: folder._id, + approved: false, + description: "Test upload", + }), + }); + assert.equal(created.status, 200); + assert.equal((await created.json()).created, true); + + clearAccessTokenCache(); + t.after(clearAccessTokenCache); + for (const method of ["existsSync", "readFileSync", "writeFileSync"]) { + const original = fs[method]; + t.mock.method(fs, method, (file, ...rest) => { + if (!String(file).endsWith(".token")) return original(file, ...rest); + if (method === "existsSync") return true; + if (method === "readFileSync") return "test-refresh-token"; + }); + } + const calls = []; + t.mock.method(axios, "post", async (url) => { + calls.push(url); + if (url.startsWith("https://login.microsoftonline.com/")) + return { + data: { + access_token: "test-access", + expires_in: 3600, + refresh_token: "test-refresh", + }, + }; + if (url.endsWith("/createUploadSession")) { + assert.ok(url.includes("/test-storage-root:/")); + return { data: { uploadUrl: "https://upload.example.test/session" } }; + } + if (url.endsWith("/createLink")) + return { data: { link: { webUrl: "https://example.test/notes" } } }; + assert.fail(`Unexpected mock Graph POST: ${url}`); + }); + const contents = Buffer.from("%PDF-1.4\nSynthetic upload test\n%%EOF\n"); + t.mock.method(axios, "put", async (url, bytes, config) => { + calls.push(url); + assert.equal(url, "https://upload.example.test/session"); + assert.deepEqual(bytes, contents); + assert.equal( + config.headers["Content-Range"], + `bytes 0-${contents.length - 1}/${contents.length}`, + ); + return { data: { id: "test-uploaded-drive-file", size: contents.length } }; + }); + t.mock.method(axios, "get", async (url) => { + calls.push(url); + if (url.endsWith("/thumbnails")) return { data: { value: [] } }; + if (url.endsWith("/test-uploaded-drive-file")) + return { + data: { "@microsoft.graph.downloadUrl": "https://download.example.test/file" }, + }; + assert.fail(`Unexpected mock Graph GET: ${url}`); + }); + const temporaryPaths = []; + const handleFile = upload.storage._handleFile; + t.mock.method(upload.storage, "_handleFile", function (req, file, callback) { + handleFile.call(this, req, file, (error, info) => { + if (info?.path) temporaryPaths.push(info.path); + callback(error, info); + }); + }); + const filename = `test-${randomUUID()}.pdf`; + const renamedPath = path.join("external/uploads", filename.replace(".pdf", "~TestStudent.pdf")); + t.after(async () => { + for (const file of [...temporaryPaths, renamedPath]) + await fs.promises.rm(file, { force: true }); + }); + const form = new FormData(); + form.append("file", new Blob([contents], { type: "application/pdf" }), filename); + const uploaded = await fetch(origin + "/api/contribution/upload", { + method: "POST", + headers: { ...headers, "contribution-id": contributionId, username: "TestStudent" }, + body: form, + }); + assert.equal(uploaded.status, 200, await uploaded.clone().text()); + const id = await uploaded.text(); + const saved = await FileModel.findById(id); + assert.equal(saved.fileId, "test-uploaded-drive-file"); + assert.equal(saved.isVerified, false); + assert.equal(Number(saved.size), contents.length); + const savedContribution = await Contribution.findOne({ contributionId }); + assert.equal(savedContribution.uploadedBy, person.id); + assert.ok(savedContribution.files.some((file) => file.toString() === id)); + assert.ok( + (await FolderModel.findById(folder._id)).children.some((file) => file.toString() === id), + ); + assert.equal(temporaryPaths.length, 1); + assert.ok(temporaryPaths.every((file) => !fs.existsSync(file))); + assert.equal(fs.existsSync(renamedPath), false); + const download = await fetch(origin + "/api/file/download/test-uploaded-drive-file", { + headers, + }); + assert.equal(download.status, 200); + assert.deepEqual(await download.json(), { url: "https://download.example.test/file" }); + assert.equal(calls.filter((url) => url === "https://upload.example.test/session").length, 1); +}); + +test("an administrator session can explicitly create a course", async () => { + await provisionAdmin({ userId: "course-manager-fixture", password }); + const login = await fetch(origin + "/api/admin/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ userId: "course-manager-fixture", password }), + }); + assert.equal(login.status, 200); + const headers = { + cookie: login.headers.get("set-cookie").split(";")[0], + "content-type": "application/json", + }; + const created = await fetch(origin + "/api/course/create/QA202", { + method: "POST", + headers, + body: JSON.stringify({ name: "Administrator-created test course" }), + }); + assert.equal(created.status, 201); + const { course: saved } = await created.json(); + assert.equal(saved.code, "QA202"); + assert.equal((await Course.findById(saved._id)).name, "Administrator-created test course"); + const response = await fetch(origin + "/api/course/QA202", { headers }); + assert.equal(response.status, 200); + assert.deepEqual((await response.json()).children, []); +}); diff --git a/server/test/library-authentication.test.js b/server/test/library-authentication.test.js new file mode 100644 index 00000000..6ff03000 --- /dev/null +++ b/server/test/library-authentication.test.js @@ -0,0 +1,326 @@ +import "./support/environment.js"; +import assert from "node:assert/strict"; +import { before, beforeEach, after, test } from "node:test"; +import { once } from "node:events"; +import fs from "node:fs"; +import mongoose from "mongoose"; +import jwt from "jsonwebtoken"; +import axios from "axios"; +import { guardExternalServices } from "./support/provider-guards.js"; +import { app } from "../index.js"; +import User from "../modules/user/user.model.js"; +import Admin from "../modules/admin/admin.model.js"; +import Course, { FileModel } from "../modules/course/course.model.js"; +import CourseAllotment from "../modules/course/courseAllotment.model.js"; +import Contribution from "../modules/contribution/contribution.model.js"; +import { upload } from "../modules/contribution/contribution.routes.js"; +import { student, administrator, course, libraryFile } from "./fixtures/library.js"; + +beforeEach(guardExternalServices); + +let listener; +let origin; +before(async () => { + listener = app.listen(0, "127.0.0.1"); + await once(listener, "listening"); + origin = `http://127.0.0.1:${listener.address().port}`; +}); +after(async () => { + listener.closeAllConnections(); + await new Promise((resolve) => listener.close(resolve)); +}); + +const studentToken = (options = {}) => + jwt.sign({ user: student._id }, process.env.JWT_SECRET, options); +const adminToken = () => jwt.sign(administrator._id, process.env.ADMIN_JWT_SECRET); +const query = (value) => ({ + select() { + return this; + }, + populate() { + return this; + }, + lean() { + return this; + }, + then(resolve, reject) { + return Promise.resolve(value).then(resolve, reject); + }, +}); +function signedIn(t, actor = student) { + t.mock.method(User, "findOne", async ({ _id }) => (_id === actor._id ? actor : null)); + t.mock.method(Admin, "findById", async (id) => + id === administrator._id ? administrator : null, + ); +} +function blockIO(t) { + const attempts = []; + const reject = (name) => () => { + attempts.push(name); + throw new Error(`Unexpected ${name}`); + }; + for (const method of [ + "find", + "findOne", + "findById", + "create", + "updateOne", + "deleteOne", + "deleteMany", + "findOneAndUpdate", + "aggregate", + "countDocuments", + ]) { + t.mock.method(mongoose.Model, method, reject(`database.${method}`)); + } + for (const method of ["get", "post", "put", "patch", "delete", "request"]) { + t.mock.method(axios, method, reject(`provider.${method}`)); + } + const originalFetch = globalThis.fetch; + t.mock.method(globalThis, "fetch", (url, ...rest) => { + if (!String(url).startsWith(origin + "/")) return reject("provider.fetch")(); + return originalFetch(url, ...rest); + }); + const existsSync = fs.existsSync; + t.mock.method(fs, "existsSync", (path) => + String(path).endsWith(".token") ? reject("token file")() : existsSync(path), + ); + t.mock.method(Object.getPrototypeOf(upload.storage), "_handleFile", (req, file, callback) => { + attempts.push("multipart storage"); + callback(new Error("Unexpected multipart storage")); + }); + return attempts; +} + +const publicActions = new Set([ + "GET /api/auth/login", + "GET /api/auth/login/redirect", + "GET /api/auth/logout", + "POST /api/admin/auth/login", + "POST /api/admin/auth/logout", +]); +const protectedRoutes = []; +for (const [prefix, module] of [ + ["auth", "auth"], + ["user", "user"], + ["course", "course"], + ["search", "search"], + ["event", "event"], + ["contribution", "contribution"], + ["admin", "admin"], + ["br", "br"], + ["files", "file"], + ["file", "onedrive"], + ["folder", "folder"], + ["year", "year"], + ["student", "student"], +]) { + const { default: router } = await import(`../modules/${module}/${module}.routes.js`); + for (const { route } of router.stack) { + if (!route) continue; + for (const method of Object.keys(route.methods)) { + const path = `/api/${prefix}${route.path}`.replace(/\/$/, ""); + if (publicActions.has(`${method.toUpperCase()} ${path}`)) continue; + const concrete = path.replace(/:([a-zA-Z]+)/g, (_, name) => + name === "code" ? "CS101" : name === "type" ? "file" : libraryFile._id, + ); + protectedRoutes.push([method.toUpperCase(), concrete]); + if (method === "get") protectedRoutes.push(["HEAD", concrete]); + } + } +} + +for (const [label, headers] of [ + ["missing session", {}], + ["invalid bearer", { authorization: "Bearer invalid-signature" }], + ["expired student session", { cookie: `token=${studentToken({ expiresIn: -1 })}` }], + [ + "expired administrator session", + { + cookie: `adminToken=${jwt.sign({ sub: administrator._id }, process.env.ADMIN_JWT_SECRET, { expiresIn: -1 })}`, + }, + ], +]) { + test(`${label}: every protected route rejects before database, multipart or provider work`, async (t) => { + const attempts = blockIO(t); + for (const [method, path] of protectedRoutes) { + const multipart = path.endsWith("/upload") || path.endsWith("/bulk-link"); + const body = ["GET", "HEAD"].includes(method) + ? undefined + : multipart + ? '--fixture\r\nContent-Disposition: form-data; name="file"; filename="test.pdf"\r\nContent-Type: application/pdf\r\n\r\n%PDF-test\r\n--fixture--\r\n' + : JSON.stringify({ code: "CS101", words: ["CS101"], rollNumber: 999999999 }); + const response = await fetch(origin + path, { + method, + headers: { + ...headers, + "content-type": multipart + ? "multipart/form-data; boundary=fixture" + : "application/json", + }, + body, + signal: AbortSignal.timeout(3000), + }); + assert.equal(response.status, 401, `${method} ${path}`); + assert.match(response.headers.get("content-type"), /application\/json/); + await response.arrayBuffer(); + } + assert.deepEqual(attempts, []); + t.diagnostic(`${protectedRoutes.length} current route/method combinations checked`); + }); +} + +test("public authentication entry and minimal health work; unknown API requests remain JSON", async (t) => { + const attempts = blockIO(t); + const health = await fetch(origin + "/api/health"); + assert.equal(health.status, 200); + assert.deepEqual(await health.json(), { status: "ok" }); + const login = await fetch(origin + "/api/auth/login", { redirect: "manual" }); + assert.equal(login.status, 302); + assert.equal(new URL(login.headers.get("location")).hostname, "login.microsoftonline.com"); + for (const method of ["GET", "HEAD", "POST", "DELETE"]) { + const response = await fetch(origin + "/api/not-a-resource", { method }); + assert.equal(response.status, 404); + assert.match(response.headers.get("content-type"), /application\/json/); + } + assert.deepEqual(attempts, []); +}); + +test("student and administrator cookie/bearer sessions can read courses", async (t) => { + signedIn(t); + t.mock.method(Course, "find", () => query([course])); + t.mock.method(Course, "findOne", () => query({ toObject: () => structuredClone(course) })); + for (const headers of [ + { cookie: `token=${studentToken()}` }, + { authorization: `Bearer ${studentToken()}` }, + { cookie: `adminToken=${adminToken()}` }, + { authorization: `Bearer ${adminToken()}` }, + { cookie: `token=expired; adminToken=${adminToken()}` }, + ]) { + for (const path of ["/api/course", "/api/course/CS101"]) { + const response = await fetch(origin + path, { headers }); + assert.equal(response.status, 200); + const data = await response.json(); + assert.equal((Array.isArray(data) ? data[0] : data).code, "CS101"); + } + } +}); + +test("missing-course reads return 404 without creating content", async (t) => { + signedIn(t); + t.mock.method(Course, "findOne", () => query(null)); + const create = t.mock.method(Course, "create", () => + assert.fail("GET must not create a course"), + ); + const response = await fetch(origin + "/api/course/MISSING", { + headers: { cookie: `token=${studentToken()}` }, + }); + assert.equal(response.status, 404); + assert.equal(create.mock.callCount(), 0); +}); + +test("students cannot create courses or perform BR/administrator actions", async (t) => { + signedIn(t); + const create = t.mock.method(Course, "create", () => assert.fail("Denied creation")); + for (const [method, path] of [ + ["POST", "/api/course/create/CS101"], + ["GET", "/api/admin/dbcourses"], + ["DELETE", `/api/files/unverify/${libraryFile._id}`], + ["POST", "/api/folder/create"], + ["POST", "/api/contribution/br"], + ]) { + const response = await fetch(origin + path, { + method, + headers: { cookie: `token=${studentToken()}` }, + }); + assert.equal(response.status, 403, path); + } + assert.equal(create.mock.callCount(), 0); +}); + +test("removed accounts and shared-account credentials cannot establish a student session", async (t) => { + t.mock.method(User, "findOne", async () => null); + let response = await fetch(origin + "/api/course", { + headers: { cookie: `token=${studentToken()}` }, + }); + assert.equal(response.status, 401); + t.mock.method(User, "findOne", async () => ({ ...student, email: "guest@coursehubiitg.in" })); + response = await fetch(origin + "/api/course", { + headers: { cookie: `token=${studentToken()}` }, + }); + assert.equal(response.status, 401); +}); + +test("malformed signed identities do not reach a database query", async (t) => { + const attempts = blockIO(t); + for (const token of [ + jwt.sign({ user: { $ne: null } }, process.env.JWT_SECRET), + jwt.sign({ user: "invalid-id" }, process.env.JWT_SECRET), + jwt.sign({ user: student._id }, process.env.ADMIN_JWT_SECRET), + ]) { + const response = await fetch(origin + "/api/course", { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(response.status, 401); + } + assert.deepEqual(attempts, []); +}); + +test("signed-in search, thumbnail and contribution listing still work", async (t) => { + signedIn(t); + t.mock.method(Course, "find", () => query([course])); + t.mock.method(FileModel, "findOne", () => query(libraryFile)); + t.mock.method(Contribution, "find", () => query([])); + const headers = { cookie: `token=${studentToken()}`, "content-type": "application/json" }; + for (const [path, body] of [ + ["/api/search", { words: ["CS101"] }], + ["/api/file/thumbnail", { fileId: libraryFile.fileId }], + ]) { + const response = await fetch(origin + path, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + assert.equal(response.status, 200, path); + } + const response = await fetch(origin + "/api/contribution", { headers }); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), []); +}); + +test("course refresh derives its target from the authenticated student", async (t) => { + signedIn(t); + const lookups = []; + t.mock.method(CourseAllotment, "findOne", async (filter) => { + lookups.push(filter); + return { courses: ["CS101"] }; + }); + t.mock.method(Course, "find", () => query([course])); + const updates = []; + t.mock.method(User, "updateOne", async (filter) => { + updates.push(filter); + return { modifiedCount: 1 }; + }); + const headers = { cookie: `token=${studentToken()}`, "content-type": "application/json" }; + for (const body of [{}, { rollNumber: student.rollNumber }]) { + const response = await fetch(origin + "/api/auth/fetchCourses", { + method: "POST", + headers, + body: JSON.stringify(body), + }); + assert.equal(response.status, 200); + assert.equal((await response.json()).courses[0].code, "CS101"); + } + assert.ok(lookups.every((filter) => filter.rollNumber === student.rollNumber)); + assert.ok(updates.every((filter) => filter.rollNumber === student.rollNumber)); + const before = lookups.length; + for (const path of ["/api/auth/fetchCourses", "/api/auth/fetchCoursesForBr"]) { + const response = await fetch(origin + path, { + method: "POST", + headers, + body: JSON.stringify({ rollNumber: 999999999 }), + }); + assert.equal(response.status, 403); + } + assert.equal(lookups.length, before); +}); diff --git a/server/test/support/environment.js b/server/test/support/environment.js index 923f1e11..ca6bc805 100644 --- a/server/test/support/environment.js +++ b/server/test/support/environment.js @@ -6,3 +6,5 @@ process.env.OPS_LOGGING_ENABLED = "false"; process.env.MONGO_URI = "mongodb://127.0.0.1:1/coursehub_test_no_connection"; process.env.JWT_SECRET = "coursehub-student-unit-test-key"; process.env.ADMIN_JWT_SECRET = "coursehub-admin-unit-test-key"; +process.env.IMAGEKIT_URL_ENDPOINT = "https://ik.imagekit.io/coursehub-test/"; +process.env.ONEDRIVE_FOLDER_ID = "test-storage-root"; diff --git a/server/test/support/provider-guards.js b/server/test/support/provider-guards.js new file mode 100644 index 00000000..870c1ce7 --- /dev/null +++ b/server/test/support/provider-guards.js @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import axios from "axios"; + +export function guardExternalServices(t) { + const attempts = []; + const deny = (name) => { + attempts.push(name); + throw new Error(`External services are blocked in tests: ${name}`); + }; + for (const method of ["get", "post", "put", "patch", "delete", "request"]) { + t.mock.method(axios, method, () => deny(`axios.${method}`)); + } + const originalFetch = globalThis.fetch; + t.mock.method(globalThis, "fetch", (input, ...rest) => { + const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url); + if (!["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)) return deny("fetch"); + return originalFetch(input, ...rest); + }); + for (const method of ["existsSync", "readFileSync", "writeFileSync"]) { + const original = fs[method]; + t.mock.method(fs, method, (file, ...rest) => { + if (String(file).endsWith(".token")) return deny(`token ${method}`); + return original(file, ...rest); + }); + } + t.after(() => assert.deepEqual(attempts, [], "Unexpected provider or token-file access")); +} From 2f62049f8b8714fdacd1a8430d9ce4d0b0c59c13 Mon Sep 17 00:00:00 2001 From: maydayv7 Date: Wed, 9 Sep 2026 03:22:57 +0530 Subject: [PATCH 03/20] fix: Enforce course-level permissions --- admin/src/apis/auth.js | 2 - admin/src/apis/br.js | 3 +- admin/src/apis/courses.js | 55 +- admin/src/pages/CourseDashboard.jsx | 11 +- admin/src/pages/CourseLinking.jsx | 6 +- client/src/api/Contribution.js | 4 +- client/src/api/File.js | 38 +- client/src/api/Folder.js | 8 +- client/src/api/User.js | 2 + client/src/api/Year.js | 4 +- client/src/reducers/filebrowser_reducer.js | 12 +- client/src/reducers/user_reducer.js | 7 +- .../components/browsefolder/confirmDialog.jsx | 6 +- .../components/browsefolder/folderRename.jsx | 5 +- .../browse/components/browsefolder/index.jsx | 25 +- .../components/file-controller/index.jsx | 10 +- .../file-display/components/ConfirmDialog.jsx | 9 +- .../file-display/components/FileRename.jsx | 5 +- .../browse/components/file-display/index.jsx | 65 +- .../browse/components/folder-info/index.jsx | 26 +- .../components/year-info/confirmDelDialog.jsx | 7 +- .../browse/components/year-info/index.jsx | 22 +- client/src/screens/contributions/index.jsx | 25 +- .../ContributionCard/ConfirmDialog.jsx | 9 +- .../Contri_section/ContributionCard/index.jsx | 10 +- .../components/Contri_section/index.jsx | 8 +- client/src/utils/capabilities.js | 3 + client/src/utils/frontendCache.js | 2 +- server/.env.example | 4 +- server/.prettierrc | 2 +- server/config/academic.js | 6 +- server/index.js | 3 +- server/middleware/isBR.js | 12 +- server/middleware/sessionAuthentication.js | 5 +- server/modules/admin/admin.model.js | 2 +- .../admin/adminDashboard.controller.js | 355 +++------- server/modules/admin/auth.controller.js | 1 + .../modules/auth-admin/auth-admin.services.js | 2 +- server/modules/br/br.controller.js | 84 ++- .../contribution/contribution.controller.js | 343 ++++----- .../contribution/contribution.model.js | 19 +- .../contribution/contribution.routes.js | 6 +- server/modules/course/course.controller.js | 77 +- server/modules/course/course.list.js | 4 +- server/modules/course/course.model.js | 2 +- server/modules/course/course.service.js | 14 +- .../modules/course/courseAllotment.model.js | 2 +- server/modules/course/list.json | 2 +- server/modules/file/file.controller.js | 200 ++---- server/modules/file/file.routes.js | 19 +- server/modules/folder/folder.controller.js | 190 +---- server/modules/folder/folder.routes.js | 9 +- .../modules/onedrive/onedrive.controller.js | 140 ---- server/modules/onedrive/onedrive.routes.js | 13 - server/modules/user/user.controller.js | 116 ++- server/modules/user/user.model.js | 40 -- server/modules/user/user.routes.js | 4 - server/modules/year/year.controller.js | 110 +-- server/modules/year/year.routes.js | 5 +- server/scripts/downloadFile.js | 96 --- server/scripts/generateOneDriveToken.js | 16 +- .../migrate_folders_to_multi_course.js | 15 +- server/scripts/preflight.js | 3 +- server/scripts/recalculateFolderCounts.js | 3 +- ...everse_migrate_folders_to_single_course.js | 15 +- server/scripts/syncCoursesCache.js | 45 +- server/scripts/uppercaseCourseCodes.js | 17 +- server/services/UploadFile.js | 137 +--- server/services/authorization.js | 303 ++++++++ server/services/connectDB.js | 18 +- server/services/email.js | 14 +- server/services/fileDelivery.js | 80 +++ server/services/resourceRemoval.js | 74 ++ server/static/index.html | 20 +- server/test/browser/library-access.test.js | 223 +++++- server/test/fixtures/library.js | 2 + server/test/fixtures/permissions.js | 101 +++ server/test/integration/server.test.js | 66 +- server/test/library-authentication.test.js | 41 +- server/test/support/course-permissions.js | 659 ++++++++++++++++++ server/utils/appError.js | 10 +- server/utils/catchAsync.js | 6 +- server/utils/folder.js | 72 +- server/utils/graphError.js | 9 +- server/utils/logger.js | 7 +- 85 files changed, 2346 insertions(+), 1886 deletions(-) create mode 100644 client/src/utils/capabilities.js delete mode 100644 server/modules/onedrive/onedrive.routes.js delete mode 100644 server/scripts/downloadFile.js create mode 100644 server/services/authorization.js create mode 100644 server/services/fileDelivery.js create mode 100644 server/services/resourceRemoval.js create mode 100644 server/test/fixtures/permissions.js create mode 100644 server/test/support/course-permissions.js diff --git a/admin/src/apis/auth.js b/admin/src/apis/auth.js index 33041c85..5629c8ee 100644 --- a/admin/src/apis/auth.js +++ b/admin/src/apis/auth.js @@ -23,8 +23,6 @@ export async function adminLogout() { export async function checkAdminSession() { const res = await fetch(`${API_BASE_URL}api/admin/`, { credentials: "include", - headers: { Authorization: "Bearer admin-coursehub-cc23-golang" }, }); - // Even though server uses middleware isAdmin with JWT, some older routes may still accept header; cookie is primary. return res.ok; } diff --git a/admin/src/apis/br.js b/admin/src/apis/br.js index 156ffd44..18272d78 100644 --- a/admin/src/apis/br.js +++ b/admin/src/apis/br.js @@ -102,8 +102,7 @@ export const deleteBR = async (email) => { method: "DELETE", credentials: "include", headers:{ - "Content-Type":"application/json", - Authorization:"Bearer admin-coursehub-cc23-golang"}, + "Content-Type":"application/json"}, body: JSON.stringify({email:email}), }); const result = await response.json(); diff --git a/admin/src/apis/courses.js b/admin/src/apis/courses.js index 2308f46c..a439087f 100644 --- a/admin/src/apis/courses.js +++ b/admin/src/apis/courses.js @@ -5,9 +5,6 @@ export const fetchCourses = async () => { try { const response = await fetch(`${API_BASE_URL}api/admin/dbcourses`, { credentials: "include", - headers: { - Authorization: "Bearer admin-coursehub-cc23-golang", - }, }); return await response.json(); } catch (error) { @@ -24,7 +21,6 @@ export const updateCourseName = async (code, newName, newCode) => { method: "PATCH", headers: { "Content-Type": "application/json", - Authorization: "Bearer admin-coursehub-cc23-golang", }, body: JSON.stringify({ name: newName, newCode }), credentials: "include", @@ -43,7 +39,6 @@ export const createCourse = async (code, name) => { method: "POST", headers: { "Content-Type": "application/json", - Authorization: "Bearer admin-coursehub-cc23-golang", }, body: JSON.stringify({ name }), credentials: "include", @@ -78,7 +73,7 @@ export const bulkSyncCourses = async (courses, analysis, onProgress = null) => { ...analysis.nameConflicts.map((conflict) => ({ code: conflict.code, name: conflict.csvName, - })) + })), ); } @@ -137,7 +132,6 @@ export const linkLegacyCourse = async (targetCode, legacyCode) => { method: "POST", headers: { "Content-Type": "application/json", - Authorization: "Bearer admin-coursehub-cc23-golang", }, body: JSON.stringify({ legacyCode }), credentials: "include", @@ -163,7 +157,6 @@ export const deleteCourse = async (code) => { method: "DELETE", headers: { "Content-Type": "application/json", - Authorization: "Bearer admin-coursehub-cc23-golang", }, credentials: "include", }); @@ -180,13 +173,10 @@ export const deleteCourse = async (code) => { } }; -export const fetchCourseDashboardData = async(code)=> -{ - try - { +export const fetchCourseDashboardData = async (code) => { + try { const safeCode = code.toLowerCase().trim(); const response = await fetch(`${API_BASE_URL}api/admin/course/${safeCode}/dashboard`, { - headers: {Authorization: "Bearer admin-coursehub-cc23-golang"}, credentials: "include", }); @@ -198,51 +188,44 @@ export const fetchCourseDashboardData = async(code)=> console.error("Error fetching dashboard:", error); throw error; } -} +}; -export const handleContribution = async (contributionId, action) => { - try - { +export const handleContribution = async (contributionId, action, courseCode) => { + try { const response = await fetch(`${API_BASE_URL}api/admin/contribution/action`, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: "Bearer admin-coursehub-cc23-golang", }, - body: JSON.stringify({ contributionId, action}), + body: JSON.stringify({ contributionId, action, courseCode }), credentials: "include", }); - if (!response.ok) - { + if (!response.ok) { throw new Error(`Failed to ${action} contribution`); } return await response.json(); } catch (error) { + console.error("Error handling contribution:", error); throw error; } }; -export const deleteNode = async(type,id) => -{ - try - { +export const deleteNode = async (type, id, courseCode) => { + try { const response = await fetch(`${API_BASE_URL}api/admin/node/${type}/${id}`, { - method : "DELETE", + method: "DELETE", headers: { "Content-Type": "application/json", - Authorization: "Bearer admin-coursehub-cc23-golang", - }, - credentials : "include", + }, + body: JSON.stringify({ courseCode }), + credentials: "include", }); - if(!response.ok) - { + if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.message || "Failed to delete Item"); } - } - catch(error) - { + } catch (error) { + console.error("Error deleting node:", error); throw error; } -} - +}; diff --git a/admin/src/pages/CourseDashboard.jsx b/admin/src/pages/CourseDashboard.jsx index c45885c6..ed87cb86 100644 --- a/admin/src/pages/CourseDashboard.jsx +++ b/admin/src/pages/CourseDashboard.jsx @@ -50,7 +50,7 @@ function LoadStructure({ node, onDelete, depth = 0 }) { type="button" title={`Delete ${node.name}`} aria-label={`Delete ${node.name}`} - onClick={() => onDelete(nodeType, node._id, node.name)} + onClick={() => onDelete(nodeType, node._id, node.name, node.affectedCourses)} className="grid h-7 w-7 flex-shrink-0 place-items-center rounded-md text-red-600 opacity-0 hover:bg-red-50 group-hover:opacity-100" > @@ -106,7 +106,7 @@ export default function CourseDashboard() { try { - await handleContribution(contributionId,action); + await handleContribution(contributionId,action,code); alert(`Contribution ${isApprove ? 'Approved' : 'Rejected'} succesfully!`); loadData(); } @@ -117,15 +117,16 @@ export default function CourseDashboard() { } } - const handleDelete = async (type, id, name) => + const handleDelete = async (type, id, name, affectedCourses = []) => { - if (!window.confirm(`Are you SURE you want to permanently delete the ${type} "${name}"? This cannot be undone.`)) + const impact = affectedCourses.length > 1 ? (type === "file" ? ` This shared file will be removed from ${affectedCourses.join(", ")}.` : ` This folder will be unlinked from ${code}; other linked courses keep it.`) : ""; + if (!window.confirm(`Are you SURE you want to permanently delete the ${type} "${name}"?${impact} This cannot be undone.`)) { return; } try { - await deleteNode(type, id); + await deleteNode(type, id, code); loadData(); } catch (error) diff --git a/admin/src/pages/CourseLinking.jsx b/admin/src/pages/CourseLinking.jsx index 4ed797eb..52b2e583 100644 --- a/admin/src/pages/CourseLinking.jsx +++ b/admin/src/pages/CourseLinking.jsx @@ -48,9 +48,6 @@ const CourseLinking = () => { try { const response = await fetch(`${API_BASE_URL}api/admin/courses/bulk-link`, { method: "POST", - headers: { - Authorization: "Bearer admin-coursehub-cc23-golang", - }, body: formData, credentials: "include", }); @@ -93,7 +90,6 @@ const CourseLinking = () => { method: "POST", headers: { "Content-Type": "application/json", - Authorization: "Bearer admin-coursehub-cc23-golang", }, body: JSON.stringify({ legacyCode: manualOldCode.toUpperCase().trim() }), credentials: "include", @@ -322,4 +318,4 @@ const CourseLinking = () => { ); }; -export default CourseLinking; \ No newline at end of file +export default CourseLinking; diff --git a/client/src/api/Contribution.js b/client/src/api/Contribution.js index a3473a2c..91114b3e 100644 --- a/client/src/api/Contribution.js +++ b/client/src/api/Contribution.js @@ -11,7 +11,7 @@ export const GetMyContributions = async () => { return resp; }; -export const GetBrContribution = async (courses) => { - const resp = await axios.post(`${root}/api/contribution/br`, { courses }); +export const GetBrContribution = async () => { + const resp = await axios.post(`${root}/api/contribution/br`, {}); return resp; } diff --git a/client/src/api/File.js b/client/src/api/File.js index 4bd282a1..81b6f442 100644 --- a/client/src/api/File.js +++ b/client/src/api/File.js @@ -5,46 +5,30 @@ const API = axios.create({ baseURL: `${serverRoot}/api`, withCredentials: true, }); -API.interceptors.request.use((req) => { - const user = JSON.parse(localStorage.getItem("profile")); - if (user) req.headers.Authorization = `Bearer ${user.token}`; - return req; -}); -export const downloadFile = async (fileId) => { - const { data } = await API.get(`/file/download/${fileId}`); - return data; -}; export const previewFile = async (fileId) => { - const { data } = await API.get(`/file/preview/${fileId}`); - return data; + const { data } = await API.get(`/files/link/${fileId}`); + return { url: new URL(data.file.webUrl, serverRoot).href }; }; -export const verifyFile = async (fileId) => { - const { data } = await API.put(`/files/verify/${fileId}`); +export const verifyFile = async (fileId, courseCode) => { + const { data } = await API.put(`/files/verify/${fileId}`, { courseCode }); return data; }; -export const unverifyFile = async (fileId, oneDriveId, folderId) => { +export const unverifyFile = async (fileId, courseCode) => { await API.delete(`/files/unverify/${fileId}`, { data: { - oneDriveId, - folderId, + courseCode, }, }); }; -export const getThumbnail = async (fileId) => { - const resp = await axios.post(`${serverRoot}/api/file/thumbnail`, { - fileId: fileId, - }); -}; - -export const getFileDownloadLink = async (fileId) => { +export const getFileDownloadLink = async (fileId, courseCode) => { const response = await fetch(serverRoot + "/api/files/download", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ url: fileId }), + body: JSON.stringify({ fileId, courseCode }), }); if (!response.ok) { @@ -52,10 +36,10 @@ export const getFileDownloadLink = async (fileId) => { } const data = await response.json(); - return data.downloadLink; + return new URL(data.downloadLink, serverRoot).href; }; -export const renameFile = async (fileId, newName) => { - const { data } = await API.put(`/files/rename/${fileId}`, { newName }); +export const renameFile = async (fileId, newName, courseCode) => { + const { data } = await API.put(`/files/rename/${fileId}`, { newName, courseCode }); return data; }; diff --git a/client/src/api/Folder.js b/client/src/api/Folder.js index 6dda4e48..4b77ace4 100644 --- a/client/src/api/Folder.js +++ b/client/src/api/Folder.js @@ -21,9 +21,9 @@ export const createFolder = async ({ name, course, parentFolder, childType }) => return data; }; -export const deleteFolder = async ({ folder, parentFolderId, courseCode }) => { +export const deleteFolder = async ({ folderId, courseCode }) => { const { data } = await API.delete(`/folder/delete`, { - data: { folder, parentFolderId, courseCode }, + data: { folderId, courseCode }, }); return data; }; @@ -40,9 +40,9 @@ export const fetchFolder = async (folderId, courseCode) => { return data; }; -export const renameFolder = async (folderId, newName) => { +export const renameFolder = async (folderId, newName, courseCode) => { const response = await API.post("/folder/rename", { - data: { folderId, newName }, + folderId, newName, courseCode, }); if (response.status !== 200) { throw new Error("Failed to rename folder"); diff --git a/client/src/api/User.js b/client/src/api/User.js index 059a19c4..1775a9dc 100644 --- a/client/src/api/User.js +++ b/client/src/api/User.js @@ -1,11 +1,13 @@ import axios from "axios"; import serverRoot from "./server"; +import { clearAllCoursesCache } from "../utils/frontendCache"; let redirectUri = "https://www.coursehubiitg.in/api/auth/login/redirect"; axios.defaults.withCredentials = true; export const getUser = async (signal) => { + clearAllCoursesCache(); const resp = await axios.get(`${serverRoot}/api/user`, { withCredentials: true, signal, diff --git a/client/src/api/Year.js b/client/src/api/Year.js index 593b7e2d..1ac9140e 100644 --- a/client/src/api/Year.js +++ b/client/src/api/Year.js @@ -21,9 +21,9 @@ export const addYear = async ({ name, course }) => { return data; }; -export const deleteYear = async ({ folder, courseCode }) => { +export const deleteYear = async ({ folderId, courseCode }) => { const { data } = await API.delete("/year/delete", { - data: { folder, courseCode }, + data: { folderId, courseCode }, }); return data; }; diff --git a/client/src/reducers/filebrowser_reducer.js b/client/src/reducers/filebrowser_reducer.js index 66c7765a..a0eac806 100644 --- a/client/src/reducers/filebrowser_reducer.js +++ b/client/src/reducers/filebrowser_reducer.js @@ -96,6 +96,10 @@ const FileBrowserReducer = ( action ) => { switch (action.type) { + case "LOG_IN": + case "LOG_OUT": + return { currentCourse: null, currentCourseCode: null, currentFolder: null, + currentYear: null, currentYearFolderStructure: [], allCourseData: [], folderHistory: [] }; case "LOAD_COURSES": return { ...state, allCourseData: action.payload.allCourseData }; case "CHANGE_CURRENT_COURSE": @@ -113,13 +117,7 @@ const FileBrowserReducer = ( return state; } - const existingCourse = arr.find((course) => course.code?.toLowerCase() === incomingCode); - const existingHasTree = - Array.isArray(existingCourse?.children) && existingCourse.children.length > 0; - const incomingHasTree = - Array.isArray(incomingCourse?.children) && incomingCourse.children.length > 0; - - const nextCourse = existingHasTree && !incomingHasTree ? existingCourse : incomingCourse; + const nextCourse = incomingCourse; arr = arr.filter((course) => course.code?.toLowerCase() !== incomingCode); arr.push(nextCourse); diff --git a/client/src/reducers/user_reducer.js b/client/src/reducers/user_reducer.js index 29dced07..230f6ab8 100644 --- a/client/src/reducers/user_reducer.js +++ b/client/src/reducers/user_reducer.js @@ -27,7 +27,7 @@ const UserReducer = ( }, }; case "LOG_OUT": - return { ...state, loggedIn: false }; + return { ...state, loggedIn: false, user: {}, favourites: [], localCourses: [] }; case "UPDATE_FAVOURITES": return { ...state, favourites: action.payload.favourites }; case "ADD_COURSE_LOCAL": { @@ -41,8 +41,9 @@ const UserReducer = ( if (state.user?.readOnly?.some(matchesIncoming)) return state; if (state.localCourses?.some(matchesIncoming)) return state; - upsertLocalCourseCache(action.payload.course); - return { ...state, localCourses: [...state.localCourses, action.payload.course] }; + const shortcut = { code: action.payload.course.code, name: action.payload.course.name, color: action.payload.course.color }; + upsertLocalCourseCache(shortcut); + return { ...state, localCourses: [...state.localCourses, shortcut] }; } case "LOAD_LOCAL_COURSES": { const existingCodes = new Set( diff --git a/client/src/screens/browse/components/browsefolder/confirmDialog.jsx b/client/src/screens/browse/components/browsefolder/confirmDialog.jsx index 053a89e6..33ef2a13 100644 --- a/client/src/screens/browse/components/browsefolder/confirmDialog.jsx +++ b/client/src/screens/browse/components/browsefolder/confirmDialog.jsx @@ -57,7 +57,7 @@ const styles = { }; -const ConfirmDialog = ({ isOpen, onConfirm, onCancel, isLoading = false }) => { +const ConfirmDialog = ({ isOpen, onConfirm, onCancel, isLoading = false, affectedCourses = [], courseCode }) => { if (!isOpen) return null; return ( @@ -71,7 +71,7 @@ const ConfirmDialog = ({ isOpen, onConfirm, onCancel, isLoading = false }) => { Delete

Are you sure?

- Do you want to permanently delete this folder? This action cannot be undone. + {affectedCourses.length > 1 ? `Remove this folder from ${courseCode}? It remains available to the other linked courses: ${affectedCourses.filter(code => code !== courseCode).join(", ")}.` : "Do you want to permanently delete this folder? This action cannot be undone."}

diff --git a/client/src/screens/browse/components/browsefolder/folderRename.jsx b/client/src/screens/browse/components/browsefolder/folderRename.jsx index 3e343e3d..f712c8bf 100644 --- a/client/src/screens/browse/components/browsefolder/folderRename.jsx +++ b/client/src/screens/browse/components/browsefolder/folderRename.jsx @@ -1,6 +1,6 @@ import {useState, useEffect, useRef} from "react"; -export function FolderRename({initialName="", onCancel, onSave}) { +export function FolderRename({initialName="", onCancel, onSave, affectedCourses = []}) { const [name, setName] = useState(initialName); const inputRef = useRef(); @@ -20,6 +20,8 @@ export function FolderRename({initialName="", onCancel, onSave}) { } return ( + <> + {affectedCourses.length > 1 &&

Renaming changes this folder in {affectedCourses.join(", ")}.

} + ) } \ No newline at end of file diff --git a/client/src/screens/browse/components/browsefolder/index.jsx b/client/src/screens/browse/components/browsefolder/index.jsx index 2fead7ef..46538bcb 100644 --- a/client/src/screens/browse/components/browsefolder/index.jsx +++ b/client/src/screens/browse/components/browsefolder/index.jsx @@ -22,27 +22,17 @@ const BrowseFolder = ({ const dispatch = useDispatch(); const navigate = useNavigate(); const currentFolder = useSelector((state) => state.fileBrowser.currentFolder); - const isBR = useSelector((state) => state.user.user.isBR); const [showConfirm, setShowConfirm] = useState(false); - const user = useSelector((state) => state.user.user); const courseCode = subject || (folderData?.courses ? folderData.courses[0] : folderData?.course); const fileCount = getSubtreeFileCount(folderData); - const isReadOnlyCourse = - user?.readOnly?.some((c) => c.code.toLowerCase() === courseCode?.toLowerCase()) && - !user?.courses?.some((c) => c.code.toLowerCase() === courseCode?.toLowerCase()) && - !( - user?.isBR && - user?.previousCourses?.some((sem) => - sem.courses.some((c) => c.code.toLowerCase() === courseCode?.toLowerCase()) - ) - ); + const canManage = folderData?.capabilities?.canManage === true; const [isEditing, setIsEditing] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const setFolderName = async (newName) => { try { - const renamedFolder = await renameFolder(folderData._id, newName); + const renamedFolder = await renameFolder(folderData._id, newName, courseCode); toast.success("Folder renamed successfully!"); dispatch( ChangeFolder({ @@ -71,7 +61,7 @@ const BrowseFolder = ({ if (isDeleting) return; try { setIsDeleting(true); - await deleteFolder({ folder: folderData, parentFolderId: parentFolder._id, courseCode }); + await deleteFolder({ folderId: folderData._id, courseCode }); toast.success("Folder deleted successfully!"); dispatch( ChangeFolder({ @@ -108,7 +98,7 @@ const BrowseFolder = ({
{name ? name : "Name"} - {!isMobileView && isBR && !isReadOnlyCourse && ( + {!isMobileView && canManage && (
{ @@ -124,6 +114,7 @@ const BrowseFolder = ({
) : ( setIsEditing(false)} onSave={(newName) => { @@ -132,7 +123,7 @@ const BrowseFolder = ({ }} /> )} - {!isMobileView && isBR && !isReadOnlyCourse && ( + {!isMobileView && canManage && ( { @@ -150,8 +141,10 @@ const BrowseFolder = ({
- {!isMobileView && isBR && !isReadOnlyCourse && ( + {!isMobileView && canManage && ( { - const user = useSelector((state) => state.user.user); if (!files) return null; - const visibleFiles = files.filter((file) => { - if (user?.isBR) return true; - return file.isVerified; - }); - - return visibleFiles.map((file, idx) => ( + return files.map((file, idx) => ( )); }; diff --git a/client/src/screens/browse/components/file-display/components/ConfirmDialog.jsx b/client/src/screens/browse/components/file-display/components/ConfirmDialog.jsx index 199a604a..3565ee6f 100644 --- a/client/src/screens/browse/components/file-display/components/ConfirmDialog.jsx +++ b/client/src/screens/browse/components/file-display/components/ConfirmDialog.jsx @@ -1,4 +1,5 @@ import React from "react"; +import { createPortal } from "react-dom"; import tick from "../assets/tick.svg"; import cross from "../assets/cross.svg"; @@ -104,7 +105,7 @@ const styles = { }, }; -const ConfirmDialog = ({ isOpen, type, onConfirm, onCancel, isLoading = false }) => { +const ConfirmDialog = ({ isOpen, type, onConfirm, onCancel, isLoading = false, affectedCourses = [] }) => { if (!isOpen) return null; const isDelete = type === "delete"; @@ -125,7 +126,7 @@ const ConfirmDialog = ({ isOpen, type, onConfirm, onCancel, isLoading = false }) return isDelete ? "Delete" : "Verify"; }; - return ( + return createPortal(

Are you sure?

{message}

+ {affectedCourses.length > 1 &&

This shared file is used by {affectedCourses.join(", ")}. {isDelete ? "Deleting it removes it from every listed course." : "Verification publishes it in every listed course."}

}
-
+ , + document.body ); }; diff --git a/client/src/screens/browse/components/file-display/components/FileRename.jsx b/client/src/screens/browse/components/file-display/components/FileRename.jsx index 6269a725..a158d533 100644 --- a/client/src/screens/browse/components/file-display/components/FileRename.jsx +++ b/client/src/screens/browse/components/file-display/components/FileRename.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from "react"; -export function FileRename({ initialName = "", onCancel, onSave }) { +export function FileRename({ initialName = "", onCancel, onSave, affectedCourses = [] }) { const [name, setName] = useState(initialName); const inputRef = useRef(); const isFinishedRef = useRef(false); @@ -42,6 +42,8 @@ export function FileRename({ initialName = "", onCancel, onSave }) { }; return ( + <> + {affectedCourses.length > 1 &&

Renaming changes this file in {affectedCourses.join(", ")}.

} + ); } diff --git a/client/src/screens/browse/components/file-display/index.jsx b/client/src/screens/browse/components/file-display/index.jsx index a2d74b4b..51854e09 100644 --- a/client/src/screens/browse/components/file-display/index.jsx +++ b/client/src/screens/browse/components/file-display/index.jsx @@ -4,7 +4,6 @@ import { formatFileName, formatFileSize, formatFileType } from "../../../../util import { toast } from "react-toastify"; import { useDispatch, useSelector } from "react-redux"; import { ChangeFolder } from "../../../../actions/filebrowser_actions.js"; -import { getThumbnail } from "../../../../api/File"; import clientRoot from "../../../../api/server"; import capitalise from "../../../../utils/capitalise.js"; import Share from "../../../share"; @@ -17,11 +16,9 @@ import { import ConfirmDialog from "./components/ConfirmDialog.jsx"; import FileRename from "./components/FileRename.jsx"; import { getFileDownloadLink } from "../../../../api/File"; -import { fetchFolder } from "../../../../api/Folder.js"; const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { - const user = useSelector((state) => state.user?.user); const fileSize = formatFileSize(file.size); const fileType = formatFileType(file.name); const [showDialog, setShowDialog] = useState(false); @@ -55,31 +52,15 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { const isLoggedIn = useSelector((state) => state.user?.loggedIn); const currCourseCode = useSelector((state) => state.fileBrowser?.currentCourseCode); const currFolderId = useSelector((state) => state.fileBrowser?.currentFolder?._id); - const currentUser = useSelector((state) => state.user.user); - const isReadOnlyCourse = - currentUser?.readOnly?.some( - (c) => c.code.toLowerCase() === currCourseCode?.toLowerCase() - ) && - !currentUser?.courses?.some( - (c) => c.code.toLowerCase() === currCourseCode?.toLowerCase() - ) && - !( - currentUser?.isBR && - currentUser?.previousCourses?.some((sem) => - sem.courses.some((c) => c.code.toLowerCase() === currCourseCode?.toLowerCase()) - ) - ); + const canManage = file.capabilities?.canManage === true; const currentFolder = useSelector((state) => state.fileBrowser?.currentFolder); const [isEditing, setIsEditing] = useState(false); const dispatch = useDispatch(); - if (!file.isVerified && !currentUser?.isBR) { - return null; - } - const preview_url = file.webUrl; + const thumbnailUrl = - typeof file.thumbnail === "string" ? file.thumbnail : file.thumbnail?.url; + file.thumbnail?.url ? new URL(file.thumbnail.url, API_BASE_URL).href : undefined; const handleRename = async (newName) => { const trimmed = newName?.trim(); @@ -98,7 +79,7 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { } try { - const responseData = await renameFile(file._id, trimmed); + const responseData = await renameFile(file._id, trimmed, currCourseCode); toast.success("File renamed successfully!"); dispatch( ChangeFolder({ @@ -120,7 +101,7 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { return; } - const downloadLink = await getFileDownloadLink(file.webUrl); + const downloadLink = await getFileDownloadLink(file._id, currCourseCode); if (!downloadLink) { toast.error("Failed to generate download link."); @@ -142,16 +123,7 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { return; } - const isPdf = file.name.toLowerCase().endsWith(".pdf"); - if (isPdf) { - window.open( - `${API_BASE_URL}/api/contribution/view/${file._id}`, - "_blank" - ); - } else { - window.open(preview_url, "_blank"); - } - + window.open(`${API_BASE_URL}/api/files/preview/${file._id}`, "_blank", "noopener"); }; @@ -162,7 +134,7 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { try { setIsProcessing(true); - await verifyFile(file._id); + await verifyFile(file._id, currCourseCode); toast.success("File verified!"); dispatch(UpdateFileVerificationStatus(file._id, true)); } catch (err) { @@ -183,7 +155,7 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { try { setIsProcessing(true); - await unverifyFile(file._id, file.fileId, currFolderId); + await unverifyFile(file._id, currCourseCode); toast.success("File deleted!"); dispatch(RemoveFileFromFolder(file._id)); } catch (err) { @@ -199,22 +171,10 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { return (
- { - async function thumbnailrefresh() { - await getThumbnail(file.fileId); - const updatedFolder = await fetchFolder(currFolderId, currCourseCode); - dispatch(ChangeFolder(updatedFolder)); - } - thumbnailrefresh(); - }} - />
{ }} >
- {!isMobileView && user?.isBR && !isReadOnlyCourse && ( + {!isMobileView && canManage && ( <> {!file.isVerified ? ( @@ -242,6 +202,7 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => {
{isEditing ? ( setIsEditing(false)} onSave={(newName) => { @@ -252,7 +213,7 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { ) : (

{file?.name ? _dispName : "Quiz 1 Answer Key"} - {!isMobileView && user?.isBR && !isReadOnlyCourse && ( + {!isMobileView && canManage && ( { @@ -268,12 +229,14 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => {

{fileType.toUpperCase()} {fileSize}

+ {!file.isVerified &&

Pending approval

}

{capitalise(contributor)}

{!isMobileView && ( state.user.user); - const isReadOnlyCourse = - user?.readOnly?.some((c) => c.code.toLowerCase() === courseCode?.toLowerCase()) && - !user?.courses?.some((c) => c.code.toLowerCase() === courseCode?.toLowerCase()) && - !( - user?.isBR && - user?.previousCourses?.some((sem) => - sem.courses.some((c) => c.code.toLowerCase() === courseCode?.toLowerCase()) - ) - ); + const canManage = currentFolder?.capabilities?.canManage === true; + const canContribute = currentFolder?.capabilities?.canContribute === true; const handleCreateFolder = () => { setNewFolderName(""); @@ -142,7 +133,7 @@ const FolderInfo = ({ headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ url: child.webUrl }), + body: JSON.stringify({ fileId: child._id, courseCode }), }); if (!fileResponse.ok) { @@ -151,9 +142,10 @@ const FolderInfo = ({ } const fileData = await fileResponse.json(); - const downloadLink = fileData.downloadLink; + const downloadLink = new URL(fileData.downloadLink, server).href; - const curfile = await fetch(downloadLink); + const curfile = await fetch(downloadLink, { credentials: "include" }); + if (!curfile.ok) throw new Error("File is no longer accessible"); const fileBlob = await curfile.blob(); const filePath = folderPath ? `${folderPath}/${child.name}` : child.name; @@ -242,14 +234,14 @@ const FolderInfo = ({ {isDownloading ? "Download" : "Download"} - {!isReadOnlyCourse && canDownload && ( + {canContribute && canDownload && ( )} - {!isReadOnlyCourse && isBR && !canDownload && ( + {canManage && !canDownload && (
diff --git a/client/src/screens/browse/components/year-info/index.jsx b/client/src/screens/browse/components/year-info/index.jsx index abc42103..1159dbe3 100644 --- a/client/src/screens/browse/components/year-info/index.jsx +++ b/client/src/screens/browse/components/year-info/index.jsx @@ -1,3 +1,4 @@ +import { canManageCourse } from "../../../../utils/capabilities"; import { toast } from "react-toastify"; import { useState } from "react"; import { useSelector } from "react-redux"; @@ -19,7 +20,6 @@ import { getSubtreeFileCount } from "../../../../utils/folderUtils"; import { useNavigate } from "react-router-dom"; const YearInfo = ({ - isBR, courseCode, course, // years list currYear, @@ -32,15 +32,7 @@ const YearInfo = ({ const [isAddingYear, setIsAddingYear] = useState(false); const [isDeletingYear, setIsDeletingYear] = useState(false); const user = useSelector((state) => state.user.user); - const isReadOnlyCourse = - user?.readOnly?.some((c) => c.code.toLowerCase() === courseCode?.toLowerCase()) && - !user?.courses?.some((c) => c.code.toLowerCase() === courseCode?.toLowerCase()) && - !( - user?.isBR && - user?.previousCourses?.some((sem) => - sem.courses.some((c) => c.code.toLowerCase() === courseCode?.toLowerCase()) - ) - ); + const canManage = canManageCourse(user, courseCode); const handleAddYear = () => { setNewYearName(""); @@ -132,7 +124,7 @@ const YearInfo = ({ try { setIsDeletingYear(true); await deleteYear({ - folder: course[currYear], + folderId: course[currYear]._id, courseCode: courseCode, }); @@ -220,7 +212,7 @@ const YearInfo = ({ )} - {isBR && !isReadOnlyCourse ? ( + {canManage ? (
) : null} - {isBR && !isReadOnlyCourse ? ( + {canManage ? ( - {isBR && !isReadOnlyCourse ? ( + {canManage ? (
{course && (
diff --git a/client/src/screens/contributions/index.jsx b/client/src/screens/contributions/index.jsx index d06d4a4c..e6fc4ced 100644 --- a/client/src/screens/contributions/index.jsx +++ b/client/src/screens/contributions/index.jsx @@ -2,9 +2,8 @@ import Wrapper from "./components/wrapper"; import SectionC from "./components/sectionC"; import { FilePond } from "react-filepond"; import "filepond/dist/filepond.min.css"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import "./styles.scss"; -import { v4 as uuidv4 } from "uuid"; import { CreateNewContribution } from "../../api/Contribution"; import { useSelector } from "react-redux"; import { toast } from "react-toastify"; @@ -14,16 +13,11 @@ import { ChangeFolder, RefreshCurrentFolder } from "../../actions/filebrowser_ac import { fetchFolder } from "../../api/Folder"; const Contributions = () => { - const uploadedBy = useSelector((state) => state.user.user._id); - const userName = useSelector((state) => state.user.user.name); - const isBR = useSelector((state) => state.user.user.isBR); const currentFolder = useSelector((state) => state.fileBrowser.currentFolder); const currentCourseCode = useSelector((state) => state.fileBrowser.currentCourseCode); - const [contributionId, setContributionId] = useState(""); + const contributionId = useRef(""); + const isBR = currentFolder?.capabilities?.canManage === true; const dispatch = useDispatch(); - useEffect(() => { - setContributionId(uuidv4()); - }, []); const [submitEnabled, setSubmitEnabled] = useState(false); const [isUploading, setIsUploading] = useState(false); @@ -50,19 +44,17 @@ const Contributions = () => { try { setIsUploading(true); setSubmitEnabled(false); - await CreateNewContribution({ + const response = await CreateNewContribution({ parentFolder: currentFolder._id, courseCode: currentCourseCode || (currentFolder.courses ? currentFolder.courses[0] : currentFolder.course), description: "default", - approved: false, - contributionId, - uploadedBy, }); + contributionId.current = response.data.data.contributionId; await pond.current.processFiles(); pond.current.removeFiles(); contributionSection.classList.remove("show"); toast.success("Files uploaded successfully!"); - setContributionId(uuidv4()); + contributionId.current = ""; setSubmitEnabled(true); } catch (error) { setSubmitEnabled(true); @@ -100,10 +92,7 @@ const Contributions = () => { url: `${server}/api/contribution/upload`, process: { withCredentials: true, - headers: { - "contribution-id": contributionId, - username: userName, - }, + headers: () => ({ "contribution-id": contributionId.current }), }, }} instantUpload={false} diff --git a/client/src/screens/profile.js/components/Contri_section/ContributionCard/ConfirmDialog.jsx b/client/src/screens/profile.js/components/Contri_section/ContributionCard/ConfirmDialog.jsx index 87efba2e..83de712b 100644 --- a/client/src/screens/profile.js/components/Contri_section/ContributionCard/ConfirmDialog.jsx +++ b/client/src/screens/profile.js/components/Contri_section/ContributionCard/ConfirmDialog.jsx @@ -1,4 +1,5 @@ import React from "react"; +import { createPortal } from "react-dom"; import tick from "./assets/tick.svg"; import cross from "./assets/cross.svg"; @@ -104,7 +105,7 @@ const styles = { }, }; -const ConfirmDialog = ({ isOpen, type, onConfirm, onCancel, isLoading = false }) => { +const ConfirmDialog = ({ isOpen, type, onConfirm, onCancel, isLoading = false, affectedCourses = [] }) => { if (!isOpen) return null; const isDelete = type === "delete"; @@ -125,7 +126,7 @@ const ConfirmDialog = ({ isOpen, type, onConfirm, onCancel, isLoading = false }) return isDelete ? "Delete" : "Verify"; }; - return ( + return createPortal(

Are you sure?

{message}

+ {affectedCourses.length > 1 &&

This shared file is used by {affectedCourses.join(", ")}. {isDelete ? "Deleting it removes it from every listed course." : "Verification publishes it in every listed course."}

}
-
+
, + document.body ); }; diff --git a/client/src/screens/profile.js/components/Contri_section/ContributionCard/index.jsx b/client/src/screens/profile.js/components/Contri_section/ContributionCard/index.jsx index 93a650b3..3b023c6d 100644 --- a/client/src/screens/profile.js/components/Contri_section/ContributionCard/index.jsx +++ b/client/src/screens/profile.js/components/Contri_section/ContributionCard/index.jsx @@ -1,4 +1,5 @@ import "./styles.scss"; +import server from "../../../../../api/server"; import Button from "./Buttons"; import { toast } from "react-toastify"; import date from "date-and-time"; @@ -18,7 +19,7 @@ export default function ContributionCard(props) { if (isProcessing) return; try { setIsProcessing(true); - await verifyFile(props?.file?._id); + await verifyFile(props?.file?._id, props.managementCourseCode || props.courseCode); props.verify(); toast.success("File verified!"); setShowDialog(false); @@ -38,7 +39,7 @@ export default function ContributionCard(props) { if (isProcessing) return; try { setIsProcessing(true); - await unverifyFile(props?.file?._id, props?.file?.fileId, props.parentFolder); + await unverifyFile(props?.file?._id, props.managementCourseCode || props.courseCode); props.unverify(); toast.success("File deleted!"); setShowDialog(false); @@ -62,13 +63,13 @@ export default function ContributionCard(props) {

{props.courseCode}

- + {props?.file?.name}

- {props.isBR ? ( + {props.file.capabilities?.canManage ? (
{ useEffect(() => { const callBack = async () => { - const courses = [ - ...user.user.courses, - ...(user.user.previousCourses?.flatMap(sem => sem.courses) || []) - ]; - - const resp = await GetBrContribution(courses); + const resp = await GetBrContribution(); setBrContributions((prev) => [...resp.data.unverifiedContributions]); setIsLoading(false); }; @@ -76,6 +71,7 @@ const Contrisection = () => { ( code?.replace(/\s+/g, "").toUpperCase(); +export const canManageCourse = (user, code) => + Boolean(code && user?.capabilities?.canManageCourses?.includes(normalized(code))); diff --git a/client/src/utils/frontendCache.js b/client/src/utils/frontendCache.js index 3247b29e..11250825 100644 --- a/client/src/utils/frontendCache.js +++ b/client/src/utils/frontendCache.js @@ -10,7 +10,7 @@ const sanitizeLocalCourses = (courses) => { const byCode = new Map(); for (const course of courses) { if (!course || typeof course !== "object" || !course.code) continue; - byCode.set(normalizeCourseCode(course.code), course); + byCode.set(normalizeCourseCode(course.code), { code: course.code, name: course.name, color: course.color }); } return Array.from(byCode.values()); }; diff --git a/server/.env.example b/server/.env.example index 4aab568f..e2d409f8 100644 --- a/server/.env.example +++ b/server/.env.example @@ -6,9 +6,9 @@ MONGO_URI= JWT_SECRET= ADMIN_JWT_SECRET= -# Administrator provisioning: npm run provision +# Administrator provisioning: npm run admin ADMIN_USER_ID= # 1-128 characters -ADMIN_PASSWORD= # >12 characters +ADMIN_PASSWORD= # >=12 characters # Azure AD / Microsoft Graph auth AZURE_CLIENT_ID= diff --git a/server/.prettierrc b/server/.prettierrc index a26f0b9c..fca9decb 100644 --- a/server/.prettierrc +++ b/server/.prettierrc @@ -2,4 +2,4 @@ "tabWidth": 4, "singleQuote": false, "printWidth": 100 -} \ No newline at end of file +} diff --git a/server/config/academic.js b/server/config/academic.js index c896614b..4d1c1a7b 100644 --- a/server/config/academic.js +++ b/server/config/academic.js @@ -1,12 +1,12 @@ const currdate = new Date(); -const currdatenumber=currdate.getDate(); +const currdatenumber = currdate.getDate(); const currentYear = currdate.getFullYear().toString(); const currentMonth = currdate.getMonth(); var session; //session='Jan-May'; -if(currentMonth<=5||(currentMonth==6&&currdatenumber<=23)) session='Jan-May'; -else session='July-Nov'; +if (currentMonth <= 5 || (currentMonth == 6 && currdatenumber <= 23)) session = "Jan-May"; +else session = "July-Nov"; export default { currentYear, diff --git a/server/index.js b/server/index.js index 9d2c90e1..5a2871b4 100644 --- a/server/index.js +++ b/server/index.js @@ -13,7 +13,6 @@ import { initScheduler } from "./config/cron.js"; import connectDatabase from "./services/connectDB.js"; import authRoutes from "./modules/auth/auth.routes.js"; import userRoutes from "./modules/user/user.routes.js"; -import onedriveRoutes from "./modules/onedrive/onedrive.routes.js"; import courseRoutes from "./modules/course/course.routes.js"; import searchRoutes from "./modules/search/search.routes.js"; import eventRoutes from "./modules/event/event.routes.js"; @@ -48,7 +47,6 @@ app.use(ua.express()); app.get("/api/health", (req, res) => res.json({ status: "ok" })); app.use("/api/auth", authRoutes); app.use("/api/user", userRoutes); -app.use("/api/file", onedriveRoutes); app.use("/api/course", courseRoutes); app.use("/api/search", searchRoutes); app.use("/api/event", eventRoutes); @@ -64,6 +62,7 @@ app.use("/api", (req, res) => ); app.use((error, req, res, next) => { + if (res.headersSent) return next(error); logger.error("Unhandled request error", { error, attributes: { diff --git a/server/middleware/isBR.js b/server/middleware/isBR.js index 0f5f038c..175208d2 100644 --- a/server/middleware/isBR.js +++ b/server/middleware/isBR.js @@ -1,7 +1,11 @@ -export const isBR = (req, res, next) => { - if (req.admin || req.user?.isBR === true) { +import { actorFor } from "../services/authorization.js"; +import AppError from "../utils/appError.js"; +export const isBR = async (req, res, next) => { + try { + const actor = await actorFor(req); + if (!actor.admin && !actor.isBR) throw new AppError(403, "Not authorized as BR"); next(); - } else { - res.status(403).json({ message: "Not authorized as BR" }); + } catch (error) { + next(error); } }; diff --git a/server/middleware/sessionAuthentication.js b/server/middleware/sessionAuthentication.js index 77c06776..f706d826 100644 --- a/server/middleware/sessionAuthentication.js +++ b/server/middleware/sessionAuthentication.js @@ -41,7 +41,10 @@ export function requireSession(...roles) { continue; req[candidate.key] = actor; authenticated = true; - if (roles.includes(candidate.role)) return next(); + if (roles.includes(candidate.role)) { + res.setHeader("Cache-Control", "private, no-store"); + return next(); + } } return next( new AppError( diff --git a/server/modules/admin/admin.model.js b/server/modules/admin/admin.model.js index e0635a1a..a4553c0e 100644 --- a/server/modules/admin/admin.model.js +++ b/server/modules/admin/admin.model.js @@ -6,7 +6,7 @@ const AdminSchema = new mongoose.Schema( userId: { type: String, required: true, unique: true, trim: true }, password: { type: String, required: true }, }, - { timestamps: true } + { timestamps: true }, ); AdminSchema.pre("save", async function (next) { diff --git a/server/modules/admin/adminDashboard.controller.js b/server/modules/admin/adminDashboard.controller.js index 981b7040..59ffc96b 100644 --- a/server/modules/admin/adminDashboard.controller.js +++ b/server/modules/admin/adminDashboard.controller.js @@ -1,3 +1,12 @@ +import { + presentCourse, + requireFile, + requireFolder, + courseContext, + libraryGraph, +} from "../../services/authorization.js"; +import { removeFolderFromCourse, removeFile } from "../../services/resourceRemoval.js"; +import { presentContribution } from "../contribution/contribution.controller.js"; import AppError from "../../utils/appError.js"; import CourseModel, { FileModel, FolderModel } from "../course/course.model.js"; import fs from "fs"; @@ -6,11 +15,12 @@ import User from "../user/user.model.js"; import UserUpdate from "../user/userUpdate.model.js"; import SearchResults from "../search/search.model.js"; import Contribution from "../contribution/contribution.model.js"; -import { normalizeCourseCode, getCourseCodeCaseInsensitiveRegex, getCourseTitle } from "../../utils/course.js"; +import { + normalizeCourseCode, + getCourseCodeCaseInsensitiveRegex, + getCourseTitle, +} from "../../utils/course.js"; import { runSync } from "../../scripts/syncCoursesCache.js"; -import mongoose from "mongoose"; -import {deleteFile} from "../file/file.controller.js"; -import {DeleteFile} from "../../services/UploadFile.js"; import logger from "../../utils/logger.js"; // Get all courses from DB @@ -46,7 +56,7 @@ export async function uploadCourses(req, res, next) { course.name = name; await course.save(); } - }) + }), ); fs.unlinkSync(req.file.path); // Fetch and return the full course list @@ -59,217 +69,55 @@ export async function uploadCourses(req, res, next) { }); } -const buildChildren = (depth = 8) => -{ - if(depth <= 0) - { - return undefined; - } - const populate = { strictPopulate: false, path: "children"}; - const nested = buildChildren(depth - 1); - - if (nested) - { - populate.populate = nested; - } - return populate; -}; - -export async function getCourseDashboardData(req,res,next) -{ - - const {code} = req.params; - const codeUpper = normalizeCourseCode(code); - const codeRegex = getCourseCodeCaseInsensitiveRegex(codeUpper); - - const course = await CourseModel.findOne({ code: codeRegex }).populate(buildChildren()); - - const studentCount = await User.countDocuments({"courses.code": { $regex: `^${codeUpper}$`, $options: "i" }}); - const contributions = await Contribution.find({ courseCode: codeRegex }).sort({ createdAt: -1 }).populate("files"); - - res.json({course,studentCount,contributions}); -} - - -const delete_concurrency = 5; - -async function runWithConcurrency(items,limit,worker) -{ - for(let i = 0; i < items.length; i += limit) - { - await Promise.allSettled(items.slice(i, i + limit).map(worker)); - } +export async function getCourseDashboardData(req, res) { + const course = await presentCourse(req, req.params.code); + const codeRegex = getCourseCodeCaseInsensitiveRegex(course.code); + const studentCount = await User.countDocuments({ "courses.code": codeRegex }); + const graph = await libraryGraph(req); + const folderIds = [...graph.folderCourses] + .filter(([, codes]) => codes.has(normalizeCourseCode(course.code))) + .map(([id]) => id); + const records = await Contribution.find({ parentFolder: { $in: folderIds } }) + .sort({ createdAt: -1 }) + .populate("files"); + const contributions = await Promise.all( + records.map((c) => presentContribution(req, c, normalizeCourseCode(course.code))), + ); + res.json({ course, studentCount, contributions }); } - -async function collectFolderTree(rootFolderId) -{ - const folderIds = []; - const fileIds = []; - const seen = new Set(); - let level = [rootFolderId]; - - while(level.length > 0) - { - const unseen = level.filter((id)=> id && !seen.has(id.toString())); - unseen.forEach((id) => seen.add(id.toString())); - - if(unseen.length === 0) - break; - - const folders = await FolderModel.find({ _id: { $in: unseen } }) - .select("_id children childType") - .lean(); - - const nextLevel = []; - - for (const folder of folders) - { - folderIds.push(folder._id); - const children = Array.isArray(folder.children) ? folder.children : []; - - if (folder.childType === "Folder") - nextLevel.push(...children); - - else if (folder.childType === "File") - fileIds.push(...children); - } - level = nextLevel; - } - return {folderIds, fileIds}; +export async function deleteNode(req, res) { + const { type, id } = req.params; + if (type === "file") { + const { file } = await requireFile(req, id, req.body.courseCode, "canManage"); + await removeFile(file); + return res.json({ success: true }); + } + if (type !== "folder") throw new AppError(400, "Invalid node type"); + res.json(await removeFolderFromCourse(req, id, req.body.courseCode)); } - -async function deleteFolderTree(folderId) -{ - - const { folderIds, fileIds } = await collectFolderTree(folderId); - if (folderIds.length === 0) return; - - if (fileIds.length > 0) - { - const files = await FileModel.find({ _id: { $in: fileIds } }).select("_id fileId").lean(); - - await runWithConcurrency(files.filter((f) => f.fileId),delete_concurrency,(f) => DeleteFile(f.fileId)); - - await FileModel.deleteMany({ _id: { $in: fileIds } }); - await Contribution.updateMany({ files: { $in: fileIds } },{ $pull: { files: { $in: fileIds } } }); - await Contribution.deleteMany({ files: { $size: 0 } }); - } - - await FolderModel.deleteMany({ _id: { $in: folderIds } }); - - const deletedIds = [...folderIds, ...fileIds]; - await CourseModel.updateMany({ children: { $in: deletedIds } },{ $pull: { children: { $in: deletedIds } } }); - await FolderModel.updateMany({ children: { $in: deletedIds } },{ $pull: { children: { $in: deletedIds } } }); -} - - -export const deleteNode = async (req, res, next) => -{ - const {type,id} = req.params; - - if(type !== "file" && type !== "folder") - { - return next(new AppError(400, "Invalid node type")); - } - - if(!mongoose.Types.ObjectId.isValid(id)) - { - return next (new AppError(400, "Invalid node ID")); - } - - const objectId = new mongoose.Types.ObjectId(id); - - if(type === "file") - { - const file = await FileModel.findById(objectId); - if(!file) - { - return next(new AppError(404, "File not found")); - } - - await CourseModel.updateMany({children : objectId}, {$pull: {children: objectId}}); - await FolderModel.updateMany({children : objectId}, {$pull: {children: objectId}}); - - await Contribution.updateMany({files: objectId}, {$pull: {files: objectId}}); - await Contribution.deleteMany({files: {$size: 0}}); - await deleteFile(file); - } - else - { - const folder = await FolderModel.findById(objectId); - if(!folder) - { - return next(new AppError(404, "Folder not found")); - } - - await CourseModel.updateMany({children : objectId}, {$pull: {children: objectId}}); - await FolderModel.updateMany({children : objectId}, {$pull: {children: objectId}}); - - await deleteFolderTree(objectId); - - } - - return res.json({success:true, message: `${type} deleted successfully`}); -}; - - -export async function handleContribution(req,res,next) -{ - const {contributionId, action} = req.body; - - if(!contributionId) - { - return next (new AppError(400, "Contribution ID required")); - } - - if(action !== "approve" && action !== "reject") - { - return next (new AppError(400, "Invalid action. Must be 'approve' or 'reject'")); - } - - const contribution = await Contribution.findOne({contributionId:contributionId}).populate("files"); - - if(!contribution) - { - return next (new AppError(404, "Contribution not found")); - } - - if(action === "approve") - { +export async function handleContribution(req, res) { + const { contributionId, action } = req.body; + if (typeof contributionId !== "string" || !["approve", "reject"].includes(action)) + throw new AppError(400, "Invalid contribution action"); + const code = courseContext(req.body.courseCode); + const contribution = await Contribution.findOne({ contributionId }).populate("files"); + if (!contribution) throw new AppError(404, "Contribution not found"); + await requireFolder(req, String(contribution.parentFolder), code, "canModerate"); + // Validate the entire stored manifest before the first side effect. + for (const file of contribution.files) + await requireFile(req, String(file._id), code, "canModerate"); + if (action === "approve") { + await FileModel.updateMany( + { _id: { $in: contribution.files.map((f) => f._id) } }, + { $set: { isVerified: true } }, + ); contribution.approved = true; await contribution.save(); - - for (const file of contribution.files) - { - await FileModel.findByIdAndUpdate(file._id, { isVerified: true }); - } - return res.json({ success: true, message: "Contribution approved and files published." }); - } - - else - { - const parentFolder = await FolderModel.findById(contribution.parentFolder); - - for (const file of contribution.files) - { - if(parentFolder) - { - parentFolder.children = parentFolder.children.filter( - childId => childId.toString() !== file._id.toString() - ); - } - - await deleteFile(file); - } - - if(parentFolder) - { - await parentFolder.save(); - } - await Contribution.findByIdAndDelete(contribution._id); - return res.json({ success: true, message: "Contribution denied files rejected."}); + } else { + for (const file of contribution.files) await removeFile(file); + await Contribution.deleteOne({ _id: contribution._id }); } - + res.json({ success: true }); } export async function renameCourse(req, res, next) { @@ -302,7 +150,7 @@ export async function renameCourse(req, res, next) { const foldersToUpdate = await FolderModel.find({ courses: codeRegex }); const folderUpdateResult = await FolderModel.updateMany( { courses: codeRegex }, - { $set: { "courses.$": newCodeUpper } } + { $set: { "courses.$": newCodeUpper } }, ); // 2. Update all users' courses that have the old course code @@ -311,7 +159,7 @@ export async function renameCourse(req, res, next) { }); const courseUpdateResult = await User.updateMany( { "courses.code": { $regex: `^${codeUpper}$`, $options: "i" } }, - { $set: { "courses.$.code": newCodeUpper } } + { $set: { "courses.$.code": newCodeUpper } }, ); const usersWithPreviousCourses = await User.find({ @@ -319,7 +167,7 @@ export async function renameCourse(req, res, next) { }); const previousCourseUpdateResult = await User.updateMany( { "previousCourses.code": { $regex: `^${codeUpper}$`, $options: "i" } }, - { $set: { "previousCourses.$.code": newCodeUpper } } + { $set: { "previousCourses.$.code": newCodeUpper } }, ); const usersWithReadOnly = await User.find({ @@ -327,7 +175,7 @@ export async function renameCourse(req, res, next) { }); const readOnlyUpdateResult = await User.updateMany( { "readOnly.code": { $regex: `^${codeUpper}$`, $options: "i" } }, - { $set: { "readOnly.$.code": newCodeUpper } } + { $set: { "readOnly.$.code": newCodeUpper } }, ); // 3. Update all contributions with the old course code @@ -336,7 +184,7 @@ export async function renameCourse(req, res, next) { }); const contributionUpdateResult = await Contribution.updateMany( { courseCode: getCourseCodeCaseInsensitiveRegex(codeUpper) }, - { courseCode: newCodeUpper } + { courseCode: newCodeUpper }, ); } } @@ -344,7 +192,7 @@ export async function renameCourse(req, res, next) { const course = await CourseModel.findOneAndUpdate( { code: codeRegex }, { name, code: newCode ? normalizeCourseCode(newCode) : codeUpper }, - { new: true } + { new: true }, ); if (!course) { return next(new AppError(404, "Course not found")); @@ -423,7 +271,11 @@ export async function deleteCourse(req, res, next) { const users = await User.find({ $or: [ { courses: { $elemMatch: { code: { $regex: `^${codeUpper}$`, $options: "i" } } } }, - { previousCourses: { $elemMatch: { code: { $regex: `^${codeUpper}$`, $options: "i" } } } }, + { + previousCourses: { + $elemMatch: { code: { $regex: `^${codeUpper}$`, $options: "i" } }, + }, + }, { readOnly: { $elemMatch: { code: { $regex: `^${codeUpper}$`, $options: "i" } } } }, ], }); @@ -435,7 +287,7 @@ export async function deleteCourse(req, res, next) { // Remove from previousCourses array user.previousCourses = user.previousCourses.filter( - (c) => normalizeCourseCode(c.code) !== codeUpper + (c) => normalizeCourseCode(c.code) !== codeUpper, ); // Remove from readOnly array @@ -473,7 +325,15 @@ export async function deleteCourse(req, res, next) { } } } catch (fileError) { - logger.error("Admin file deletion failed", { error: fileError, attributes: { dependency: "microsoft-graph", operation: "delete-file", outcome: "failure", retryable: true } }); + logger.error("Admin file deletion failed", { + error: fileError, + attributes: { + dependency: "microsoft-graph", + operation: "delete-file", + outcome: "failure", + retryable: true, + }, + }); // Continue with other files even if one fails } } @@ -483,10 +343,7 @@ export async function deleteCourse(req, res, next) { // Now handle folders: if shared, just pull the code; if not, delete. for (const folder of courseFolders) { if (folder.courses.length > 1) { - await FolderModel.updateOne( - { _id: folder._id }, - { $pull: { courses: codeUpper } } - ); + await FolderModel.updateOne({ _id: folder._id }, { $pull: { courses: codeUpper } }); } else { await FolderModel.deleteOne({ _id: folder._id }); } @@ -529,7 +386,9 @@ async function performLinkWithLock(targetCodeRaw, sourceCodeRaw) { } let resolver; - const lockPromise = new Promise((resolve) => { resolver = resolve; }); + const lockPromise = new Promise((resolve) => { + resolver = resolve; + }); activeLinkLocks.set(lockKey, lockPromise); try { @@ -568,7 +427,7 @@ async function addCourseToFolderTree(startFolderId, codeToAdd) { if (folderIds.length > 0) { await FolderModel.updateMany( { _id: { $in: folderIds } }, - { $addToSet: { courses: codeToAdd } } + { $addToSet: { courses: codeToAdd } }, ); } } @@ -599,7 +458,7 @@ async function removeCourseFromFolderTree(startFolderId, codeToRemove) { for (const f of fileFolders) { for (const fileId of f.children) { try { - await deleteFile(fileId); + await removeFile(fileId); } catch (e) { // ignore individual file deletion errors } @@ -611,7 +470,7 @@ async function removeCourseFromFolderTree(startFolderId, codeToRemove) { if (foldersToUpdate.length > 0) { await FolderModel.updateMany( { _id: { $in: foldersToUpdate } }, - { $pull: { courses: codeToRemove } } + { $pull: { courses: codeToRemove } }, ); } } @@ -699,7 +558,7 @@ async function performLink(targetCodeRaw, sourceCodeRaw) { if (d._id.toString() !== chosen._id.toString()) { await removeCourseFromFolderTree(d._id, targetCode); updatedTargetChildIds = updatedTargetChildIds.filter( - (id) => id !== d._id.toString() + (id) => id !== d._id.toString(), ); } } @@ -713,7 +572,7 @@ async function performLink(targetCodeRaw, sourceCodeRaw) { const normName = sourceYearDoc.name.trim().toLowerCase(); const matchingTargetDoc = (targetYearsByName[normName] || []).find((d) => - updatedTargetChildIds.includes(d._id.toString()) + updatedTargetChildIds.includes(d._id.toString()), ); if (matchingTargetDoc) { @@ -728,7 +587,7 @@ async function performLink(targetCodeRaw, sourceCodeRaw) { if (targetIsEmpty) { // Target year folder is empty. Replace it with source year folder. updatedTargetChildIds = updatedTargetChildIds.filter( - (id) => id !== matchingTargetDoc._id.toString() + (id) => id !== matchingTargetDoc._id.toString(), ); if (!updatedTargetChildIds.includes(sourceYearDoc._id.toString())) { updatedTargetChildIds.push(sourceYearDoc._id.toString()); @@ -740,7 +599,9 @@ async function performLink(targetCodeRaw, sourceCodeRaw) { // Add target code to source folder tree await addCourseToFolderTree(sourceYearDoc._id, targetCode); } else { - logger.info("Existing target year retained", { attributes: { operation: "merge-year", outcome: "retained" } }); + logger.info("Existing target year retained", { + attributes: { operation: "merge-year", outcome: "retained" }, + }); } } else { // Year folder only exists in source. Add to target. @@ -776,7 +637,7 @@ export async function linkLegacyCourse(req, res, next) { export async function bulkLinkCourses(req, res, next) { if (!req.file) return next(new AppError(400, "No file uploaded")); - + const results = []; const filePath = req.file.path; @@ -794,10 +655,26 @@ export async function bulkLinkCourses(req, res, next) { if (!val) return true; const norm = normalizeCourseCode(val); const headerTerms = [ - "OLDCODE", "LEGACYCODE", "OLD", "LEGACY", "SOURCECODE", "SOURCE", - "OLDCOURSECODE", "OLDCOURSE", "LEGACYCOURSECODE", "LEGACYCOURSE", - "NEWCODE", "TARGETCODE", "NEW", "TARGET", "TARGETCOURSECODE", - "NEWCOURSECODE", "NEWCOURSE", "TARGETCOURSE", "CODE", "COURSE" + "OLDCODE", + "LEGACYCODE", + "OLD", + "LEGACY", + "SOURCECODE", + "SOURCE", + "OLDCOURSECODE", + "OLDCOURSE", + "LEGACYCOURSECODE", + "LEGACYCOURSE", + "NEWCODE", + "TARGETCODE", + "NEW", + "TARGET", + "TARGETCOURSECODE", + "NEWCOURSECODE", + "NEWCOURSE", + "TARGETCOURSE", + "CODE", + "COURSE", ]; return headerTerms.includes(norm); }; @@ -826,7 +703,7 @@ export async function bulkLinkCourses(req, res, next) { rawOld = row[keys[0]]; rawNew = row[keys[1]]; } else if (keys.length === 1 && typeof row[keys[0]] === "string") { - const parts = row[keys[0]].split(",").map(s => s.trim()); + const parts = row[keys[0]].split(",").map((s) => s.trim()); if (parts.length >= 2) { rawOld = parts[0]; rawNew = parts[1]; @@ -852,7 +729,7 @@ export async function bulkLinkCourses(req, res, next) { summary.errors.push({ oldCode, newCode, error: err.message }); } } - + cleanupFile(); res.json({ message: "Bulk linking completed", summary }); } catch (err) { diff --git a/server/modules/admin/auth.controller.js b/server/modules/admin/auth.controller.js index d1ad3f3b..6260f494 100644 --- a/server/modules/admin/auth.controller.js +++ b/server/modules/admin/auth.controller.js @@ -33,6 +33,7 @@ export const getAdmin = async (req, res, next) => { return res.json({ user: { userId: admin.userId, + capabilities: { canManageAllCourses: true, canModerate: true }, }, }); }; diff --git a/server/modules/auth-admin/auth-admin.services.js b/server/modules/auth-admin/auth-admin.services.js index 62a111ca..87ea3ded 100644 --- a/server/modules/auth-admin/auth-admin.services.js +++ b/server/modules/auth-admin/auth-admin.services.js @@ -46,7 +46,7 @@ export async function verifyOTP(email, otp) { sendEmail( email, "[COURSEHUB] Login attempt", - "Login attempt using your credentials but wrong OTP" + "Login attempt using your credentials but wrong OTP", ); await OTP.deleteMany({ email: email }); return false; diff --git a/server/modules/br/br.controller.js b/server/modules/br/br.controller.js index 1ace4650..45b612d6 100644 --- a/server/modules/br/br.controller.js +++ b/server/modules/br/br.controller.js @@ -36,26 +36,44 @@ const updateBRs = async (req, res) => { if (user) { if (!user.isBR) { user.isBR = true; - fetchCoursesForBr(user.rollNumber).catch((error) => logger.error("BR course refresh failed", { error, attributes: { dependency: "academic-portal", operation: "refresh-br-courses", outcome: "failure", retryable: true } })); + fetchCoursesForBr(user.rollNumber).catch((error) => + logger.error("BR course refresh failed", { + error, + attributes: { + dependency: "academic-portal", + operation: "refresh-br-courses", + outcome: "failure", + retryable: true, + }, + }), + ); await user.save(); } await BR.updateOne( { email: normalizedEmail }, { $set: { email: normalizedEmail } }, - { upsert: true } + { upsert: true }, ); } else { await BR.updateOne( { email: normalizedEmail }, { $set: { email: normalizedEmail } }, - { upsert: true } + { upsert: true }, ); } } res.status(201).json({ message: "BRs updated successfully" }); } catch (error) { - logger.error("BR update failed", { error, attributes: { dependency: "mongodb", operation: "update-br", outcome: "failure", retryable: false } }); + logger.error("BR update failed", { + error, + attributes: { + dependency: "mongodb", + operation: "update-br", + outcome: "failure", + retryable: false, + }, + }); res.status(500).json({ error: "Internal Server Error" }); } }; @@ -75,13 +93,31 @@ const createBR = async (req, res) => { user.isBR = true; await user.save(); } - fetchCoursesForBr(user.rollNumber).catch((error) => logger.error("BR course refresh failed", { error, attributes: { dependency: "academic-portal", operation: "refresh-br-courses", outcome: "failure", retryable: true } })); + fetchCoursesForBr(user.rollNumber).catch((error) => + logger.error("BR course refresh failed", { + error, + attributes: { + dependency: "academic-portal", + operation: "refresh-br-courses", + outcome: "failure", + retryable: true, + }, + }), + ); } const br = await BR.create({ email: normalizedEmail }); res.status(201).json({ message: "BR added", br }); } catch (error) { - logger.error("BR creation failed", { error, attributes: { dependency: "mongodb", operation: "create-br", outcome: "failure", retryable: false } }); + logger.error("BR creation failed", { + error, + attributes: { + dependency: "mongodb", + operation: "create-br", + outcome: "failure", + retryable: false, + }, + }); res.status(500).json({ error: "Internal Server Error" }); } }; @@ -105,12 +141,20 @@ const deleteBR = async (req, res) => { await User.updateOne( { email: normalizedEmail }, { $set: { isBR: false } }, - { collation: { locale: "en", strength: 2 } } + { collation: { locale: "en", strength: 2 } }, ); res.status(200).json({ message: "BR deleted successfully" }); } catch (error) { - logger.error("BR deletion failed", { error, attributes: { dependency: "mongodb", operation: "delete-br", outcome: "failure", retryable: false } }); + logger.error("BR deletion failed", { + error, + attributes: { + dependency: "mongodb", + operation: "delete-br", + outcome: "failure", + retryable: false, + }, + }); res.status(500).json({ error: "Internal Server Error" }); } }; @@ -123,7 +167,7 @@ const getBRs = async (req, res) => { // Fetch user details for those who have registered const users = await User.find({ email: { $in: brEmails } }); - + // Map users by email for quick lookup const userMap = {}; for (const user of users) { @@ -144,7 +188,7 @@ const getBRs = async (req, res) => { rollNumber: user.rollNumber, isBR: true, courses: user.courses || [], - } + }; } return { email: br.email, @@ -161,7 +205,15 @@ const getBRs = async (req, res) => { res.status(200).json({ brs }); } catch (error) { - logger.error("BR query failed", { error, attributes: { dependency: "mongodb", operation: "query-br", outcome: "failure", retryable: false } }); + logger.error("BR query failed", { + error, + attributes: { + dependency: "mongodb", + operation: "query-br", + outcome: "failure", + retryable: false, + }, + }); res.status(500).json({ error: "Internal Server Error" }); } }; @@ -202,7 +254,15 @@ const getCoursesWithoutBR = async (req, res) => { res.status(200).json({ coursesWithoutBR }); } catch (error) { - logger.error("Unassigned course query failed", { error, attributes: { dependency: "mongodb", operation: "query-unassigned-courses", outcome: "failure", retryable: false } }); + logger.error("Unassigned course query failed", { + error, + attributes: { + dependency: "mongodb", + operation: "query-unassigned-courses", + outcome: "failure", + retryable: false, + }, + }); res.status(500).json({ error: "Internal Server Error" }); } }; diff --git a/server/modules/contribution/contribution.controller.js b/server/modules/contribution/contribution.controller.js index 265a45ce..0cea6ae7 100644 --- a/server/modules/contribution/contribution.controller.js +++ b/server/modules/contribution/contribution.controller.js @@ -1,246 +1,143 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; import Contribution from "./contribution.model.js"; -import User from "../user/user.model.js"; -import Joi from "joi"; -import AppError from "../../utils/appError.js"; -import validatePayload from "../../utils/validate.js"; -import UploadFile from "../../services/UploadFile.js"; -import fs from "fs"; -import path from "path"; import { FolderModel, FileModel } from "../course/course.model.js"; +import UploadFile from "../../services/UploadFile.js"; +import AppError from "../../utils/appError.js"; +import { + actorFor, + requireFolder, + requireFile, + presentFile, + libraryGraph, +} from "../../services/authorization.js"; +import { normalizeCourseCode } from "../../utils/course.js"; import logger from "../../utils/logger.js"; -import { normalizeCourseCode, getCourseCodeCaseInsensitiveRegex } from "../../utils/course.js"; -import { recalculateParentFolderCounts } from "../../utils/folder.js"; -import { getAccessToken, clearAccessTokenCache } from "../onedrive/onedrive.controller.js"; -import axios from "axios"; - -async function ContributionCreation(contributionId, data) { - const existingContribution = await Contribution.findOne({ contributionId }); - if (!existingContribution) { - const newContribution = await Contribution.create({ ...data, contributionId }); - return newContribution; - } - const updatedContribution = await Contribution.findOneAndUpdate( - { contributionId }, - { ...data }, - { new: true }, - ); - return updatedContribution; -} - -async function HandleFileToDB(contributionId, fileId) { - const existingContribution = await Contribution.findOne({ contributionId }); - - if (!existingContribution) { - const newContribution = await Contribution.create({ contributionId, files: [fileId] }); - return newContribution; - } - - const parentFolder = existingContribution.parentFolder - ? await FolderModel.findOne({ _id: existingContribution.parentFolder }) - : null; - - existingContribution.files.push(fileId); - if (parentFolder) { - parentFolder.children.push(fileId); - await parentFolder.save(); - await recalculateParentFolderCounts(parentFolder._id); - } - await existingContribution.save(); - return existingContribution; -} - -async function HandleFileUpload(req, res, next) { - logger.info("Handling File Upload"); - const contributionId = req.headers["contribution-id"]; - const username = req.headers.username || "user"; - const files = req.files; - if (!files || files.length === 0) { - return res.status(400).json({ error: "No files were uploaded" }); - } - - const uploadedFiles = []; - - for (const file of files) { - try { - let initialPath = file.path; - let originalFilename = file.originalname; - - let wordArr = originalFilename.split("."); - let fileExtension = wordArr.length > 1 ? wordArr.pop() : ""; - let baseName = wordArr.join("."); - let finalFileName = `${baseName}~${username}${fileExtension ? "." + fileExtension : ""}`; - - const dirName = path.dirname(initialPath); - const renamedPath = path.join(dirName, finalFileName); - - await fs.promises.rename(initialPath, renamedPath); - - // UploadFile expects directory path ending with slash/backslash - const finalDirPath = dirName.endsWith(path.sep) ? dirName : `${dirName}${path.sep}`; - let fileId = null; - - try { - fileId = await UploadFile(contributionId, finalDirPath, finalFileName); - } catch (uploadError) { - logger.error("Contribution upload failed", { - error: uploadError, - attributes: { - dependency: "microsoft-graph", - operation: "upload-contribution", - outcome: "failure", - retryable: true, - }, - }); - } - - if (fileId) { - await HandleFileToDB(contributionId, fileId); - uploadedFiles.push(fileId.toString()); - } - - // Cleanup local temp file - if (fs.existsSync(renamedPath)) { - await fs.promises.unlink(renamedPath).catch(() => {}); - } - } catch (err) { - logger.error("Contribution file processing failed", { - error: err, - attributes: { - operation: "process-contribution", - outcome: "failure", - retryable: false, - }, - }); - } - } - - if (uploadedFiles.length === 0) { - return res.status(500).json({ error: "File upload failed" }); - } - - // Return the primary file ID string or response for FilePond - return res.status(200).send(uploadedFiles[0]); -} - -async function CreateNewContribution(req, res, next) { - const payloadSchema = { - contributionId: Joi.string().required(), - uploadedBy: Joi.string().required(), - courseCode: Joi.string().required(), - parentFolder: Joi.string().required(), - approved: Joi.bool(), - description: Joi.string().required(), - }; - const data = req.body; - data.courseCode = normalizeCourseCode(data.courseCode); - - const valid = validatePayload(payloadSchema, data); - if (valid.error) { - return next(new AppError(400, valid.error)); - } - - const newContribution = await ContributionCreation(data.contributionId, data); - - const uploader = await User.findById(data.uploadedBy); +async function CreateNewContribution(req, res) { + const allowed = ["parentFolder", "courseCode", "description"]; + if (Object.keys(req.body).some((key) => !allowed.includes(key))) + throw new AppError(400, "Unexpected contribution fields"); + const { parentFolder, courseCode, description = "" } = req.body; + if (typeof description !== "string" || description.length > 2000) + throw new AppError(400, "Invalid description"); + const context = await requireFolder(req, parentFolder, courseCode, "canContribute"); + if (context.folder.childType !== "File") throw new AppError(400, "Choose a file folder"); + const contribution = await Contribution.create({ + contributionId: randomUUID(), + uploadedBy: context.actor.id, + parentFolder, + courseCode: context.code, + description, + approved: context.capabilities.canManage, + }); logger.metric?.("contribution_created", { value: 1, dimensions: { - courseCode: data.courseCode, - userId: data.uploadedBy, - department: uploader ? uploader.department : "unknown", - semester: uploader ? uploader.semester : 0, + courseCode: context.code, + userId: context.actor.id, + department: req.user?.department || "administration", + semester: req.user?.semester || 0, }, }); - - return res.json({ - created: true, - data: newContribution, - }); + res.json({ created: true, data: contribution }); } -async function GetMyContributions(req, res, next) { - const myContributions = await Contribution.find({ uploadedBy: req.user._id }).populate({ - path: "files", - }); - res.json(myContributions); -} - -async function GetBrContribution(req, res, next) { +async function HandleFileUpload(req, res) { + const files = req.files || []; + if (!files.length) throw new AppError(400, "No files were uploaded"); + const uploaded = []; + const actor = await actorFor(req); try { - const { courses } = req.body; - - if (!courses || !Array.isArray(courses)) { - return res.status(400).json({ error: "courses array required" }); + for (const file of files) { + const name = file.originalname; + if (!name || /[\\/\x00-\x1f]/.test(name)) throw new AppError(400, "Invalid filename"); + const dot = name.lastIndexOf("."); + const base = dot === -1 ? name : name.slice(0, dot); + const extension = dot === -1 ? "" : name.slice(dot); + const contributor = (req.admin?.userId || req.user.name) + .replace(/[\\/:*?"<>|~\x00-\x1f]/g, "") + .slice(0, 80); + const fileId = await UploadFile( + file.path, + base + "~" + contributor + extension, + req.contributionApproved, + ); + if (!fileId) throw new AppError(502, "File upload failed"); + await Contribution.updateOne( + { _id: req.contribution._id, uploadedBy: actor.id }, + { $addToSet: { files: fileId }, $set: { approved: req.contributionApproved } }, + ); + await FolderModel.updateOne( + { _id: req.contribution.parentFolder }, + { $addToSet: { children: fileId } }, + ); + uploaded.push(String(fileId)); } - const codes = courses.map((course) => normalizeCourseCode(course.code)).filter(Boolean); - const contributions = await Contribution.find({ - courseCode: { $in: codes.map(getCourseCodeCaseInsensitiveRegex) }, - }).populate({ - path: "files", - }); - const unverifiedContributions = contributions.filter((c) => - c.files.some((f) => f.isVerified === false), - ); - - res.json({ unverifiedContributions }); - } catch (error) { - next(error); + res.send(uploaded[0]); + } finally { + await Promise.all(files.map((file) => fs.unlink(file.path).catch(() => {}))); } } -async function viewFile(req, res, next) { - try { - const { id } = req.params; - const file = await FileModel.findById(id); - if (!file) { - return res.status(404).json({ message: "File not found" }); - } - if (file.isVerified === false && !req.admin && req.user.isBR === false) { - return res.status(403).json({ message: "File is not verified" }); +export async function presentContribution( + req, + contribution, + contextCode = contribution.courseCode, +) { + const files = []; + for (const file of contribution.files) { + try { + await requireFile(req, String(file._id), contextCode); + files.push(await presentFile(req, file, contextCode)); + } catch (error) { + if (error.status !== 404) throw error; } - const getResponse = async () => { - const accessToken = await getAccessToken(); - if (!accessToken) { - return res.status(500).json({ message: "Access token not found" }); - } - const response = await axios.get( - `https://graph.microsoft.com/v1.0/me/drive/items/${file.fileId}/content`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - responseType: "stream", - }, - ); + } + return { ...contribution.toObject(), files, managementCourseCode: contextCode }; +} - res.setHeader("Content-Type", response.headers["content-type"]); - res.setHeader("Content-Length", response.headers["content-length"]); +async function GetMyContributions(req, res) { + const contributions = await Contribution.find({ uploadedBy: String(req.user._id) }).populate( + "files", + ); + res.json(await Promise.all(contributions.map((c) => presentContribution(req, c)))); +} - const downloadStream = response.data; - res.on("close", () => { - downloadStream.destroy(); - }); - downloadStream.pipe(res); - }; +async function GetBrContribution(req, res) { + const actor = await actorFor(req); + // Course filters are optional narrowing only. They cannot expand a BR's scope. + const requested = req.body.courses; + if (requested !== undefined && !Array.isArray(requested)) + throw new AppError(400, "Invalid course filter"); + const courses = requested?.map((c) => + typeof c?.code === "string" ? normalizeCourseCode(c.code) : "", + ); + if (courses?.some((code) => !code || (!actor.admin && !actor.managed.includes(code)))) + throw new AppError(403, "You do not have permission for this course"); + const graph = await libraryGraph(req); + const allowed = new Set(courses || (actor.admin ? [...graph.courses.keys()] : actor.managed)); + const folders = new Map( + [...graph.folderCourses] + .map(([id, codes]) => [id, [...codes].filter((code) => allowed.has(code))]) + .filter(([, codes]) => codes.length), + ); + const contributions = await Contribution.find({ + parentFolder: { $in: [...folders.keys()] }, + }).populate("files"); + const visible = []; + for (const contribution of contributions) { try { - await getResponse(); - } catch (err) { - if (err.response?.status === 401) { - clearAccessTokenCache(); - return await getResponse(); - } - throw err; + const contexts = folders.get(String(contribution.parentFolder)); + const submittedCode = normalizeCourseCode(contribution.courseCode); + const contextCode = contexts.includes(submittedCode) ? submittedCode : contexts[0]; + await requireFolder(req, String(contribution.parentFolder), contextCode, "canModerate"); + const data = await presentContribution(req, contribution, contextCode); + if (data.files.some((file) => !file.isVerified)) visible.push(data); + } catch (error) { + if (error.status !== 404) throw error; } - } catch (error) { - next(error); } + res.json({ unverifiedContributions: visible }); } - -export default { - CreateNewContribution, - HandleFileUpload, - GetMyContributions, - GetBrContribution, - viewFile, -}; +export default { CreateNewContribution, HandleFileUpload, GetMyContributions, GetBrContribution }; diff --git a/server/modules/contribution/contribution.model.js b/server/modules/contribution/contribution.model.js index 0b95f352..a361ebe6 100644 --- a/server/modules/contribution/contribution.model.js +++ b/server/modules/contribution/contribution.model.js @@ -1,5 +1,4 @@ -import mongoose, { model, mongo, Schema } from "mongoose"; -import User from "../user/user.model.js"; +import { model, Schema } from "mongoose"; const ContributionSchema = Schema( { @@ -11,23 +10,9 @@ const ContributionSchema = Schema( approved: { type: Boolean, default: false }, description: { type: String }, }, - { timestamps: true } + { timestamps: true }, ); -ContributionSchema.pre("save", async function (next) { - try { - if (this.uploadedBy) { - const user = await User.findById(this.uploadedBy); - if (user && user.isBR) { - this.approved = true; - } - } - next(); - } catch (err) { - next(err); - } -}); - const Contribution = model("Contribution", ContributionSchema); export default Contribution; diff --git a/server/modules/contribution/contribution.routes.js b/server/modules/contribution/contribution.routes.js index f4e4a2fe..e886646d 100644 --- a/server/modules/contribution/contribution.routes.js +++ b/server/modules/contribution/contribution.routes.js @@ -7,16 +7,16 @@ import catchAsync from "../../utils/catchAsync.js"; import isAuthenticated from "../../middleware/isAuthenticated.js"; import { isBR } from "../../middleware/isBR.js"; import multer from "multer"; +import { authorizeContributionUpload } from "../../services/authorization.js"; export const upload = multer({ dest: "external/uploads" }); router.get("/", isAuthenticated, catchAsync(ContributionController.GetMyContributions)); -router.post("/", isAuthenticated, catchAsync(ContributionController.CreateNewContribution)); +router.post("/", catchAsync(ContributionController.CreateNewContribution)); router.post( "/upload", - isAuthenticated, + authorizeContributionUpload, upload.array("file"), catchAsync(ContributionController.HandleFileUpload), ); router.post("/br", isBR, catchAsync(ContributionController.GetBrContribution)); -router.get("/view/:id", catchAsync(ContributionController.viewFile)); export default router; diff --git a/server/modules/course/course.controller.js b/server/modules/course/course.controller.js index cff7dc19..280611ae 100644 --- a/server/modules/course/course.controller.js +++ b/server/modules/course/course.controller.js @@ -1,75 +1,18 @@ -import AppError from "../../utils/appError.js"; -import CourseModel from "./course.model.js"; +import Course from "./course.model.js"; +import { presentCourse } from "../../services/authorization.js"; import logger from "../../utils/logger.js"; -import { normalizeCourseCode, getCourseCodeCaseInsensitiveRegex } from "../../utils/course.js"; -import { computePopulatedFolderSubtreeCount } from "../../utils/folder.js"; - -const COURSE_CHILDREN_POPULATE_DEPTH = 5; - -const buildChildrenPopulate = (depth) => { - if (depth <= 0) return undefined; - - const populate = { - strictPopulate: false, - path: "children", - select: "-__v", - }; - - const nestedPopulate = buildChildrenPopulate(depth - 1); - if (nestedPopulate) { - populate.populate = nestedPopulate; - } - - return populate; -}; - -export const getCourse = async (req, res, next) => { - const { code } = req.params; - logger.info("Course requested"); - - const normalizedCode = normalizeCourseCode(code); - if (!normalizedCode) throw new AppError(400, "Missing Course Id"); - const courseCodeRegex = getCourseCodeCaseInsensitiveRegex(normalizedCode); - - const courseDoc = await CourseModel.findOne({ code: courseCodeRegex }) - .populate(buildChildrenPopulate(COURSE_CHILDREN_POPULATE_DEPTH)) - .select("-__v"); - - if (!courseDoc) throw new AppError(404, "Course not found"); - - // Convert to plain object to avoid issues with Mongoose internal state - const courseObj = courseDoc.toObject(); - - const sortYear = (a, b) => { - if (a?.name > b?.name) return 1; - else if (a?.name < b?.name) return -1; - else return 1; - }; - - if (courseObj?.children && courseObj.children.length > 0) { - for (const childFolder of courseObj.children) { - computePopulatedFolderSubtreeCount(childFolder, normalizedCode); - } - if (courseObj.children.length > 1) { - courseObj.children.sort(sortYear); - } - } - +export const getCourse = async (req, res) => { + const course = await presentCourse(req, req.params.code); logger.metric?.("course_opened", { value: 1, dimensions: { - courseCode: courseObj.code, + courseCode: course.code, userEmail: req.user?.email || req.admin?.userId, - department: req.user ? req.user.department : "unknown", - semester: req.user ? req.user.semester : 0, + department: req.user?.department || "unknown", + semester: req.user?.semester || 0, }, }); - - return res.json({ found: true, ...courseObj }); -}; - -export const getAllCourses = async (req, res, next) => { - const allCourse = await CourseModel.find().select("_id name code"); - - res.json(allCourse); + res.json({ found: true, ...course }); }; +export const getAllCourses = async (req, res) => + res.json(await Course.find().select("_id name code")); diff --git a/server/modules/course/course.list.js b/server/modules/course/course.list.js index 6a135498..ab450957 100644 --- a/server/modules/course/course.list.js +++ b/server/modules/course/course.list.js @@ -1186,7 +1186,7 @@ const courselist = { "SP 503": "Polymer Synthesis and Characterization", "SP 511": "Polymer Processing and Rheology Laboratory", "SP 512": "Polymer Synthesis and Characterization Laboratory", - "SP 698": "Project -II" + "SP 698": "Project -II", }; -export default courselist; \ No newline at end of file +export default courselist; diff --git a/server/modules/course/course.model.js b/server/modules/course/course.model.js index 1b4c80fe..33495e0b 100644 --- a/server/modules/course/course.model.js +++ b/server/modules/course/course.model.js @@ -33,7 +33,7 @@ const CourseSchema = Schema( children: { type: [{ type: Schema.Types.ObjectId, ref: "Folder" }], default: [] }, books: [{ type: String }], }, - { timestamps: true } + { timestamps: true }, ); const CourseModel = model("Course", CourseSchema); diff --git a/server/modules/course/course.service.js b/server/modules/course/course.service.js index 463f94d8..4cc77f15 100644 --- a/server/modules/course/course.service.js +++ b/server/modules/course/course.service.js @@ -11,8 +11,8 @@ export const createYearFolderWithDefaultStructure = async (yearName, courseCode) courses: [courseCode], childType: "File", children: [], - }) - ) + }), + ), ); // 2. Create Exams Folder with sub-folders @@ -32,8 +32,8 @@ export const createYearFolderWithDefaultStructure = async (yearName, courseCode) courses: [courseCode], childType: "File", children: [], - }) - ) + }), + ), ); // 4. Create Year Folder @@ -52,7 +52,9 @@ export const bootstrapCourseFolders = async (courseCode) => { const targetYears = Array.from({ length: 5 }, (_, i) => (currentYear - i).toString()); // Fetch existing course with children names to avoid duplicates (case-insensitive) - const course = await CourseModel.findOne({ code: new RegExp('^' + courseCode + '$', 'i') }).populate("children", "name"); + const course = await CourseModel.findOne({ + code: new RegExp("^" + courseCode + "$", "i"), + }).populate("children", "name"); if (!course) return []; const actualCourseCode = course.code; @@ -71,7 +73,7 @@ export const bootstrapCourseFolders = async (courseCode) => { // Update Course with ONLY the newly created year folders await CourseModel.findOneAndUpdate( { _id: course._id }, - { $push: { children: { $each: newYearFolderIds } } } + { $push: { children: { $each: newYearFolderIds } } }, ); return newYearFolderIds; diff --git a/server/modules/course/courseAllotment.model.js b/server/modules/course/courseAllotment.model.js index dad65fdb..be825d21 100644 --- a/server/modules/course/courseAllotment.model.js +++ b/server/modules/course/courseAllotment.model.js @@ -20,7 +20,7 @@ const CourseAllotmentSchema = new mongoose.Schema( required: true, }, }, - { timestamps: true } + { timestamps: true }, ); CourseAllotmentSchema.index({ rollNumber: 1, session: 1, year: 1 }, { unique: true }); diff --git a/server/modules/course/list.json b/server/modules/course/list.json index 7259c003..c212d561 100644 --- a/server/modules/course/list.json +++ b/server/modules/course/list.json @@ -727,4 +727,4 @@ "MA4024": "Matrix Analysis and Applications", "PH4650": "Optoelectronics", "CS3001": "OOPs and Data Structures" -} \ No newline at end of file +} diff --git a/server/modules/file/file.controller.js b/server/modules/file/file.controller.js index 866e29fe..01bfaa2f 100644 --- a/server/modules/file/file.controller.js +++ b/server/modules/file/file.controller.js @@ -1,151 +1,53 @@ -import { FileModel, FolderModel } from "../course/course.model.js"; -import { DeleteFile, RenameOneDriveFile } from "../../services/UploadFile.js"; -import logger from "../../utils/logger.js"; -import { isValidObjectId } from "mongoose"; -import { recalculateParentFolderCounts } from "../../utils/folder.js"; - -export const verifyFile = async (req, res) => { - try { - const { id } = req.params; - - if (!id || !isValidObjectId(id)) { - return res.status(400).json({ message: "Invalid file ID" }); - } - - const file = await FileModel.findById(id); - if (!file) return res.status(404).json({ message: "File not found" }); - - file.isVerified = true; - await file.save(); - - res.status(200).json({ message: "File verified successfully", file }); - } catch (err) { - logger.error("File verification failed", { error: err, attributes: { dependency: "mongodb", operation: "verify-file", outcome: "failure", retryable: false } }); - res.status(500).json({ message: "Server error", error: err.message }); +import Contribution from "../contribution/contribution.model.js"; +import { RenameOneDriveFile } from "../../services/UploadFile.js"; +import { requireFile, presentFile, visibleFiles } from "../../services/authorization.js"; +import { removeFile } from "../../services/resourceRemoval.js"; +import AppError from "../../utils/appError.js"; + +export async function verifyFile(req, res) { + const { file, code } = await requireFile( + req, + req.params.id, + req.body.courseCode, + "canModerate", + ); + file.isVerified = true; + await file.save(); + const contributions = await Contribution.find({ files: file._id }).populate("files"); + for (const contribution of contributions) { + contribution.approved = + contribution.files.length > 0 && contribution.files.every((f) => f.isVerified); + await contribution.save(); } -}; - -export const unverifyFile = async (req, res) => { - try { - const { id } = req.params; - const { folderId, oneDriveId } = req.body; - - if (!folderId || !oneDriveId) { - return res.status(400).json({ message: "folderId and oneDriveId required" }); - } - - await FolderModel.findByIdAndUpdate(folderId, { $pull: { children: id } }); - await recalculateParentFolderCounts(folderId); - const file = await FileModel.findByIdAndDelete(id); - if (!file) return res.status(404).json({ message: "File not found" }); - - await DeleteFile(oneDriveId); - - res.status(200).json({ message: "File deleted (unverified) successfully" }); - } catch (err) { - logger.error("File removal failed", { error: err, attributes: { dependency: "microsoft-graph", operation: "remove-file", outcome: "failure", retryable: true } }); - res.status(500).json({ message: "Server error", error: err.message }); - } -}; - -export const deleteFile = async (file) => { - await FileModel.findByIdAndDelete(file._id); - await DeleteFile(file.fileId); + res.json({ message: "File verified successfully", file: await presentFile(req, file, code) }); +} +export async function unverifyFile(req, res) { + const { file } = await requireFile(req, req.params.id, req.body.courseCode, "canManage"); + await removeFile(file); + res.json({ message: "File deleted successfully" }); +} +export async function getAllFiles(req, res) { + res.json(await visibleFiles(req)); +} +export async function getFileLink(req, res) { + const { file } = await requireFile(req, req.params.id, req.query.courseCode); + res.json({ file: await presentFile(req, file, req.query.courseCode) }); +} +export async function downloadFiles(req, res) { + const { file } = await requireFile(req, req.body.fileId, req.body.courseCode); + res.json({ downloadLink: `/api/files/content/${file._id}?download=1` }); +} +export async function renameFile(req, res) { + const { file, code } = await requireFile(req, req.params.id, req.body.courseCode, "canManage"); + const newName = typeof req.body.newName === "string" ? req.body.newName.trim() : ""; + if (!newName || newName.length > 200 || /[\\/:*?"<>|]/.test(newName)) + throw new AppError(400, "A valid file name is required"); + const dot = file.name.lastIndexOf("."), + tilde = file.name.lastIndexOf("~"); + const suffix = tilde !== -1 ? file.name.slice(tilde) : dot !== -1 ? file.name.slice(dot) : ""; + const name = newName + suffix; + await RenameOneDriveFile(file.fileId, name); + file.name = name; + await file.save(); + res.json({ message: "File renamed successfully", file: await presentFile(req, file, code) }); } - -export const getAllFiles = async (req, res) => { - try { - let files; - - if (req.user.isBR === true) { - files = await FileModel.find().sort({ uploadedAt: -1 }); - } else { - // Regular users get only verified files - files = await FileModel.find({ isVerified: true }).sort({ uploadedAt: -1 }); - } - - res.status(200).json(files); - } catch (err) { - logger.error("File query failed", { error: err, attributes: { dependency: "mongodb", operation: "query-files", outcome: "failure", retryable: false } }); - res.status(500).json({ message: "Server error", error: err.message }); - } -}; - - -export const getFileLink = async (req, res) => { - try { - const fileId = req.params.id; - if (!fileId || !isValidObjectId(fileId)) { - return res.status(400).json({ message: "Invalid file ID" }); - } - - const file = await FileModel.findById(fileId).select("webUrl downloadUrl name"); - - if (!file) { - return res.status(404).json({ message: "File not found" }); - } - - return res.status(200).json({ file }); - - } catch (error) { - logger.error("File link query failed", { error, attributes: { dependency: "mongodb", operation: "query-file-link", outcome: "failure", retryable: false } }); - return res.status(500).json({ message: "Internal server error" }); - } -}; - -export const renameFile = async (req, res) => { - try { - const { id } = req.params; - const { newName } = req.body; - - if (!id || !isValidObjectId(id)) { - return res.status(400).json({ message: "Invalid file ID" }); - } - - const trimmedNewName = newName?.trim(); - if (!trimmedNewName) { - return res.status(400).json({ message: "New name is required and cannot be empty" }); - } - - if (trimmedNewName.length > 200) { - return res.status(400).json({ message: "New name is too long (maximum 200 characters)" }); - } - - // Validate illegal OneDrive characters: \ / : * ? " < > | - const illegalChars = /[\\/:*?"<>|]/; - if (illegalChars.test(trimmedNewName)) { - return res.status(400).json({ - message: "New name contains forbidden characters (\\, /, :, *, ?, \", <, >, |)" - }); - } - - const file = await FileModel.findById(id); - if (!file) { - return res.status(404).json({ message: "File not found" }); - } - - const originalName = file.name; - const lastTildeIndex = originalName.lastIndexOf("~"); - const dotIndex = originalName.lastIndexOf("."); - const ext = dotIndex !== -1 ? originalName.slice(dotIndex) : ""; - - let contributor = ""; - if (lastTildeIndex !== -1) { - contributor = originalName.slice(lastTildeIndex, dotIndex !== -1 ? dotIndex : undefined); - } - - const finalNewName = `${trimmedNewName}${contributor}${ext}`; - - // Rename on OneDrive using the file's fileId - await RenameOneDriveFile(file.fileId, finalNewName); - - // Update name in MongoDB - file.name = finalNewName; - await file.save(); - - res.status(200).json({ message: "File renamed successfully", file }); - } catch (err) { - logger.error("File rename failed", { error: err, attributes: { dependency: "microsoft-graph", operation: "rename-file", outcome: "failure", retryable: true } }); - res.status(500).json({ message: "Server error", error: err.message }); - } -}; diff --git a/server/modules/file/file.routes.js b/server/modules/file/file.routes.js index 33d86b19..270ba158 100644 --- a/server/modules/file/file.routes.js +++ b/server/modules/file/file.routes.js @@ -6,18 +6,23 @@ import { unverifyFile, getFileLink, renameFile, + downloadFiles, } from "./file.controller.js"; import { isBR } from "../../middleware/isBR.js"; -import { downloadFiles } from "../../scripts/downloadFile.js"; +import catchAsync from "../../utils/catchAsync.js"; +import { fileContent, fileThumbnail, filePreview } from "../../services/fileDelivery.js"; const router = express.Router(); router.use(isLibraryAuthenticated); -router.get("/all", getAllFiles); -router.put("/verify/:id", isBR, verifyFile); -router.delete("/unverify/:id", isBR, unverifyFile); -router.post("/download", downloadFiles); -router.get("/link/:id", getFileLink); -router.put("/rename/:id", isBR, renameFile); +router.get("/all", catchAsync(getAllFiles)); +router.put("/verify/:id", isBR, catchAsync(verifyFile)); +router.delete("/unverify/:id", isBR, catchAsync(unverifyFile)); +router.post("/download", catchAsync(downloadFiles)); +router.get("/link/:id", catchAsync(getFileLink)); +router.put("/rename/:id", isBR, catchAsync(renameFile)); +router.get("/content/:id", catchAsync(fileContent)); +router.get("/preview/:id", catchAsync(filePreview)); +router.get("/thumbnail/:id", catchAsync(fileThumbnail)); export default router; diff --git a/server/modules/folder/folder.controller.js b/server/modules/folder/folder.controller.js index 21be66f7..5be90e8d 100644 --- a/server/modules/folder/folder.controller.js +++ b/server/modules/folder/folder.controller.js @@ -1,169 +1,41 @@ import { FolderModel } from "../course/course.model.js"; -import { deleteFile } from "../file/file.controller.js"; -import { normalizeCourseCode } from "../../utils/course.js"; -import { recalculateParentFolderCounts, computePopulatedFolderSubtreeCount } from "../../utils/folder.js"; +import { requireFolder, presentFolder } from "../../services/authorization.js"; +import { removeFolderFromCourse } from "../../services/resourceRemoval.js"; +import AppError from "../../utils/appError.js"; -const COURSE_CHILDREN_POPULATE_DEPTH = 5; - -const buildChildrenPopulate = (depth = COURSE_CHILDREN_POPULATE_DEPTH) => { - if (depth <= 0) return undefined; - - const populate = { - strictPopulate: false, - path: "children", - select: "-__v", - }; - - const nestedPopulate = buildChildrenPopulate(depth - 1); - if (nestedPopulate) { - populate.populate = nestedPopulate; - } - return populate; -}; - -async function createFolder(req, res) { +export async function createFolder(req, res) { const { name, course, parentFolder, childType } = req.body; - const newFolder = await FolderModel.create({ - name, - courses: [normalizeCourseCode(course)], + const context = await requireFolder(req, parentFolder, course, "canManage"); + if ( + context.folder.childType !== "Folder" || + !["File", "Folder"].includes(childType) || + typeof name !== "string" || + !name.trim() + ) + throw new AppError(400, "A name and a valid parent folder are required"); + const folder = await FolderModel.create({ + name: name.trim(), + courses: context.affectedCourses, childType, children: [], - totalFileCount: 0, }); - - if (parentFolder) { - const parent = await FolderModel.findById(parentFolder); - parent.children.push(newFolder._id); - await parent.save(); - await recalculateParentFolderCounts(parentFolder); - } - - return res.json(newFolder); -} - -async function deleteFolder(req, res) { - const { folder, parentFolderId, courseCode } = req.body; - const folderId = folder._id; - const normalizedCode = courseCode ? normalizeCourseCode(courseCode) : null; - - try { - const folderDoc = await FolderModel.findById(folderId); - - // If the folder is shared with multiple courses and a specific course requested deletion - if (folderDoc && folderDoc.courses && folderDoc.courses.length > 1 && normalizedCode) { - // Option B: Safe Unlinking. Just remove this course's reference from the folder and its descendants - await removeCourseFromFolderRecursive(folderId, normalizedCode); - // We DO NOT pull it from parentFolderId.children, because other courses still need it. - // It will be hidden from the UI via getFolderContent filtering. - } else { - // Standard Deletion: It's only used by one course, so safely delete the document entirely - if (parentFolderId) { - await FolderModel.findByIdAndUpdate(parentFolderId, { - $pull: { children: folderId }, - }); - } - await recursiveDelete(folder); - } - - if (parentFolderId) { - await recalculateParentFolderCounts(parentFolderId); - } - - return res.json({ success: true, folderId }); - } catch (err) { - return res.status(500).json({ success: false, error: err.message }); - } + await FolderModel.updateOne({ _id: parentFolder }, { $addToSet: { children: folder._id } }); + req.authorizationGraph = undefined; + res.json(await presentFolder(req, folder._id, context.code)); } - -async function removeCourseFromFolderRecursive(folderId, codeToRemove) { - const folder = await FolderModel.findById(folderId); - if (!folder) return; - - await FolderModel.updateOne( - { _id: folderId }, - { $pull: { courses: codeToRemove } } - ); - - if (folder.childType === "Folder") { - for (const childId of folder.children) { - await removeCourseFromFolderRecursive(childId, codeToRemove); - } - } +export async function deleteFolder(req, res) { + res.json(await removeFolderFromCourse(req, req.body.folderId, req.body.courseCode)); } - -async function recursiveDelete(folder) { - if (!folder.children) { - await FolderModel.findByIdAndDelete(folder._id); - return; - } - if (folder.childType === "Folder") { - for (const subfolder of folder.children) { - await recursiveDelete(subfolder); - } - await FolderModel.findByIdAndDelete(folder._id); - } - else if (folder.childType === "File") { - for (const file of folder.children) { - await deleteFile(file); - } - await FolderModel.findByIdAndDelete(folder._id); - } +export async function getFolderContent(req, res) { + const context = await requireFolder(req, req.params.folderId, req.query.courseCode); + res.json(await presentFolder(req, context.folder._id, context.code)); } - -async function getFolderContent(req, res) { - const { folderId } = req.params; - const { courseCode } = req.query; - try { - const folderDoc = await FolderModel.findById(folderId); - if (!folderDoc) { - return res.status(404).json({ message: "Folder not found" }); - } - - let populatedDoc; - if (folderDoc.childType === "File") { - populatedDoc = await folderDoc.populate({ - path: "children", - model: "File", - select: "-__v" - }); - } else { - populatedDoc = await folderDoc.populate(buildChildrenPopulate()); - } - - // Convert to plain object to avoid Mongoose internal setters during filtering - const folderObj = populatedDoc.toObject(); - - // Filter out shared folders that have been "unlinked" by this specific course - if (courseCode) { - const normalizedCode = normalizeCourseCode(courseCode); - folderObj.children = folderObj.children.filter(child => { - if (child.childType === "Folder") { - return child.courses && child.courses.includes(normalizedCode); - } - return true; - }); - } - - computePopulatedFolderSubtreeCount(folderObj, courseCode); - - return res.json(folderObj); - } catch (err) { - return res.status(500).json({ error: err.message }); - } +export async function renameFolder(req, res) { + const { folderId, newName, courseCode } = req.body; + const context = await requireFolder(req, folderId, courseCode, "canManage"); + if (typeof newName !== "string" || !newName.trim() || newName.length > 200) + throw new AppError(400, "A valid folder name is required"); + await FolderModel.updateOne({ _id: folderId }, { $set: { name: newName.trim() } }); + req.authorizationGraph = undefined; + res.json(await presentFolder(req, context.folder._id, context.code)); } - -async function renameFolder(req, res) { - const { folderId, newName } = req.body.data; - try { - const folder = await FolderModel.findByIdAndUpdate(folderId, { $set: { name: newName } }, { new: true }) - if (!folder) { - return res.status(404).json({ message: "Folder not found" }); - } - return res.json(folder); - } - catch(err){ - return res.status(500).json({ error: err.message }); - } -} - -export { createFolder, deleteFolder, getFolderContent, renameFolder }; diff --git a/server/modules/folder/folder.routes.js b/server/modules/folder/folder.routes.js index 1246bce2..9080b420 100644 --- a/server/modules/folder/folder.routes.js +++ b/server/modules/folder/folder.routes.js @@ -3,12 +3,13 @@ import express from "express"; import { createFolder, deleteFolder, getFolderContent, renameFolder } from "./folder.controller.js"; import { isBR } from "../../middleware/isBR.js"; // if it's a named export +import catchAsync from "../../utils/catchAsync.js"; const router = express.Router(); router.use(isLibraryAuthenticated); -router.post("/create", isBR, createFolder); -router.delete("/delete", isBR, deleteFolder); -router.get("/content/:folderId", getFolderContent); -router.post("/rename", isBR, renameFolder); +router.post("/create", isBR, catchAsync(createFolder)); +router.delete("/delete", isBR, catchAsync(deleteFolder)); +router.get("/content/:folderId", catchAsync(getFolderContent)); +router.post("/rename", isBR, catchAsync(renameFolder)); export default router; diff --git a/server/modules/onedrive/onedrive.controller.js b/server/modules/onedrive/onedrive.controller.js index 9aa5a6cf..fe044757 100644 --- a/server/modules/onedrive/onedrive.controller.js +++ b/server/modules/onedrive/onedrive.controller.js @@ -3,92 +3,6 @@ import qs from "querystring"; import AppError from "../../utils/appError.js"; import settings from "../../config/onedrive.js"; import fs from "fs"; -import { extractGraphErrorDetails, formatGraphErrorMessage } from "../../utils/graphError.js"; -import { uploadThumbnail, isImageKitUrl } from "../../services/imagekit.js"; - -import { FileModel } from "../course/course.model.js"; - -export async function thumbnail(req, res) { - const fileId = req.body.fileId; - - // 1. Already a permanent ImageKit URL in DB - return immediately. - const file = await FileModel.findOne({ fileId }).select("thumbnail").lean(); - const storedThumbnailUrl = - typeof file?.thumbnail === "string" ? file.thumbnail : file?.thumbnail?.url; - - if (storedThumbnailUrl && isImageKitUrl(storedThumbnailUrl)) { - return res.status(200).json(storedThumbnailUrl); - } - - // 2. Fetch a fresh temporary URL from Graph API (legacy files with expired OneDrive URLs) - const access_token = await getAccessToken(); - const thumbnaildata = await axios.get( - `https://graph.microsoft.com/v1.0/me/drive/items/${fileId}/thumbnails`, - { headers: { Authorization: `Bearer ${access_token}` } }, - ); - const thumbnailurl = thumbnaildata.data.value?.[0]?.medium?.url; - if (!thumbnailurl) throw new AppError(404, "Thumbnail not found"); - - // 3. Download raw image bytes and upload to ImageKit as WebP permanently - const imgResponse = await axios.get(thumbnailurl, { responseType: "arraybuffer" }); - const { - url: permanentUrl, - fileId: imagekitFileId, - path: imagekitPath, - } = await uploadThumbnail(fileId, Buffer.from(imgResponse.data)); - - // 4. Persist permanent URL to DB so this file never hits Graph API again - await FileModel.updateOne( - { fileId }, - { - $set: { - thumbnail: { - url: permanentUrl, - fileId: imagekitFileId, - path: imagekitPath, - }, - }, - }, - ); - - return res.status(200).json(permanentUrl); -} - -export async function getFilePreview(req, res) { - const { fileID } = req.params; - const resp = await getFileWebUrl(fileID); - return res.json({ url: resp }); -} - -export async function getFileDownload(req, res) { - const { fileID } = req.params; - const resp = await getFileDownloadLink(fileID); - return res.json({ url: resp }); -} - -async function getFileDownloadLink(file_id) { - const access_token = await getAccessToken(); - const headers = { - Authorization: `Bearer ${access_token}`, - Host: "graph.microsoft.com", - }; - const url = `https://graph.microsoft.com/v1.0/me/drive/items/${file_id}`; - const data = await getRequest(url, headers); - return data["@microsoft.graph.downloadUrl"]; -} - -async function getFileWebUrl(file_id) { - const access_token = await getAccessToken(); - const headers = { - Authorization: `Bearer ${access_token}`, - Host: "graph.microsoft.com", - }; - - const url = `https://graph.microsoft.com/v1.0/me/drive/items/${file_id}/createLink`; - - const data = await postRequest(url, headers); - return data.link.webUrl; -} let cachedAccessToken = null; let tokenExpiry = 0; @@ -158,57 +72,3 @@ async function refreshAccessToken() { return response.data; } - -export async function getRequest(url, headers) { - const config = { - method: "get", - url, - headers, - }; - - try { - const response = await axios.get(config.url, { - headers: config.headers, - }); - - if (!response.data) throw new AppError(500, "Something went wrong"); - - return response.data; - } catch (error) { - const details = extractGraphErrorDetails(error); - const appError = new AppError( - details.status || 502, - formatGraphErrorMessage(details, "Microsoft Graph GET request failed"), - ); - appError.graphDetails = details; - throw appError; - } -} - -export async function postRequest(url, headers, params) { - const data = qs.stringify(params); - const config = { - method: "post", - url, - headers, - data, - }; - - try { - const response = await axios.post(config.url, config.data, { - headers: config.headers, - }); - - if (!response.data) throw new AppError(500, "Something went wrong"); - - return response.data; - } catch (error) { - const details = extractGraphErrorDetails(error); - const appError = new AppError( - details.status || 502, - formatGraphErrorMessage(details, "Microsoft Graph POST request failed"), - ); - appError.graphDetails = details; - throw appError; - } -} diff --git a/server/modules/onedrive/onedrive.routes.js b/server/modules/onedrive/onedrive.routes.js deleted file mode 100644 index 9f8d9f6e..00000000 --- a/server/modules/onedrive/onedrive.routes.js +++ /dev/null @@ -1,13 +0,0 @@ -import isLibraryAuthenticated from "../../middleware/isLibraryAuthenticated.js"; -import express from "express"; -import catchAsync from "../../utils/catchAsync.js"; -import { thumbnail, getFilePreview, getFileDownload } from "./onedrive.controller.js"; - -const router = express.Router(); -router.use(isLibraryAuthenticated); - -router.post("/thumbnail", catchAsync(thumbnail)); -router.get("/preview/:fileID", catchAsync(getFilePreview)); -router.get("/download/:fileID", catchAsync(getFileDownload)); - -export default router; diff --git a/server/modules/user/user.controller.js b/server/modules/user/user.controller.js index 0e468cd9..cbd69d16 100644 --- a/server/modules/user/user.controller.js +++ b/server/modules/user/user.controller.js @@ -1,15 +1,43 @@ import AppError from "../../utils/appError.js"; import logger from "../../utils/logger.js"; -import User, { RemoveCourse } from "./user.model.js"; -import { addToFavourites, removeFromFavourites, AddNewCourse , AddReadOnlyCourse, RemoveReadOnly } from "./user.model.js"; +import User from "./user.model.js"; +import { + addToFavourites, + removeFromFavourites, + AddReadOnlyCourse, + RemoveReadOnly, +} from "./user.model.js"; import { updateUserData } from "./user.model.js"; import UserUpdate from "./userUpdate.model.js"; -import BR from "../br/br.model.js"; +import { actorFor, requireFile } from "../../services/authorization.js"; import { normalizeCourseCode } from "../../utils/course.js"; -const normalizeEmail = (email) => email?.toString().trim().toLowerCase(); +async function visibleFavourites(req, user) { + const favourites = []; + for (const favourite of user.favourites || []) { + try { + const { file } = await requireFile(req, favourite.id, favourite.code); + favourites.push({ ...(favourite.toObject?.() || favourite), name: file.name }); + } catch (error) { + if (error.status !== 404) throw error; + } + } + return favourites; +} +async function userResult(req, user) { + const actor = await actorFor(req); + return { + ...user.toObject(), + isBR: actor.isBR, + capabilities: { + canManageCourses: actor.managed, + canContributeCourses: [...new Set([...actor.current, ...actor.managed])], + }, + favourites: await visibleFavourites(req, user), + }; +} -let currentDay = new Date().toISOString().split('T')[0]; +let currentDay = new Date().toISOString().split("T")[0]; let activeUsersToday = new Set(); let currentHour = new Date().toISOString().substring(0, 13); @@ -19,7 +47,7 @@ export const getUser = async (req, res, next) => { const user = req.user; const now = new Date(); - const today = now.toISOString().split('T')[0]; + const today = now.toISOString().split("T")[0]; const thisHour = now.toISOString().substring(0, 13); // 1. Daily Cache @@ -27,18 +55,18 @@ export const getUser = async (req, res, next) => { activeUsersToday.clear(); currentDay = today; } - + // 2. Hourly Cache if (thisHour !== currentHour) { activeUsersThisHour.clear(); currentHour = thisHour; } - + if (user && user.email) { const dimensions = { userEmail: user.email, department: user.department, - semester: user.semester + semester: user.semester, }; if (!activeUsersToday.has(user.email)) { @@ -64,32 +92,8 @@ export const getUser = async (req, res, next) => { return res.status(401).json({ error: "User update required, please log in again" }); } - const normalizedEmail = normalizeEmail(user.email); - const brDoc = normalizedEmail - ? await BR.findOne({ email: normalizedEmail }).collation({ - locale: "en", - strength: 2, - }) - : null; - - if (brDoc && !user.isBR) { - user.isBR = true; - await user.save(); - - res.cookie("token", "loggedout", { - maxAge: 0, - sameSite: "lax", - secure: false, - expires: new Date(Date.now()), - httpOnly: true, - }); - - return res - .status(401) - .json({ error: "BR access updated. Please log in again.", forceLogout: true }); - } - - const isBranchRep = !!user.isBR || !!brDoc; + const actor = await actorFor(req); + const isBranchRep = actor.isBR; const previousCourses = Array.isArray(user.previousCourses) ? user.previousCourses : []; const needsCourseSync = isBranchRep && previousCourses.length === 0; @@ -103,9 +107,13 @@ export const getUser = async (req, res, next) => { degree: user.degree, courses: user.courses, department: user.department, - favourites: user.favourites, + favourites: await visibleFavourites(req, user), deviceToken: user.deviceToken, isBR: isBranchRep, + capabilities: { + canManageCourses: actor.managed, + canContributeCourses: [...new Set([...actor.current, ...actor.managed])], + }, readOnly: user.readOnly, needsCourseSync, }; @@ -124,24 +132,16 @@ export const updateUserController = async (req, res) => { export const addToFavouriteController = async (req, res, next) => { const data = req.body; if (!data.id || !data.name || !data.path || !data.code) return res.sendStatus(400); - //validate + await requireFile(req, data.id, data.code); const updatedUser = await addToFavourites( req.user._id, data.name, data.id, data.path, - normalizeCourseCode(data.code) + normalizeCourseCode(data.code), ); - return res.status(200).json(updatedUser); -}; -export const addNewCourse = async (req, res, next) => { - const data = req.body; - if (!data.code || !data.name) return res.sendStatus(400); - - const updatedUser = await AddNewCourse(req.user._id, normalizeCourseCode(data.code), data.name); - return res.status(200).json(updatedUser); + return res.status(200).json(await userResult(req, updatedUser)); }; - export const addReadOnly = async (req, res, next) => { const data = req.body; if (!data.code || !data.name) return res.sendStatus(400); @@ -149,17 +149,9 @@ export const addReadOnly = async (req, res, next) => { const updatedUser = await AddReadOnlyCourse( req.user._id, normalizeCourseCode(data.code), - data.name + data.name, ); - return res.status(200).json(updatedUser); -}; - -export const deleteCourse = async (req, res, next) => { - const { code } = req.params; - if (!code) return res.sendStatus(400); - const updatedUser = await RemoveCourse(req.user._id, normalizeCourseCode(code)); - - return res.status(200).json(updatedUser); + return res.status(200).json(await userResult(req, updatedUser)); }; export const deleteReadOnly = async (req, res, next) => { @@ -167,7 +159,7 @@ export const deleteReadOnly = async (req, res, next) => { if (!code) return res.sendStatus(400); const updatedUser = await RemoveReadOnly(req.user._id, normalizeCourseCode(code)); - return res.status(200).json(updatedUser); + return res.status(200).json(await userResult(req, updatedUser)); }; export const removeFromFavouritesController = async (req, res, next) => { @@ -175,7 +167,7 @@ export const removeFromFavouritesController = async (req, res, next) => { if (!id) return res.sendStatus(400); //validate const updatedUser = await removeFromFavourites(req.user._id, id); - return res.status(200).json(updatedUser); + return res.status(200).json(await userResult(req, updatedUser)); }; export const updateDeviceToken = async (req, res, next) => { const user = req.user; @@ -186,14 +178,12 @@ export const updateDeviceToken = async (req, res, next) => { }; export const getFavouritesController = async (req, res, next) => { - const user = req.user; const foundUser = await User.findById(user._id); if (!foundUser) { - return res.status(404).json({ message: 'User not found' }); + return res.status(404).json({ message: "User not found" }); } - return res.status(200).json({ favourites: foundUser.favourites }); - + return res.status(200).json({ favourites: await visibleFavourites(req, foundUser) }); }; diff --git a/server/modules/user/user.model.js b/server/modules/user/user.model.js index ab669761..b5c6376f 100644 --- a/server/modules/user/user.model.js +++ b/server/modules/user/user.model.js @@ -138,12 +138,6 @@ export const getUserFromToken = async function (access_token) { } }; -// export const findUserWithRollNumber = async function (rollNumber) { -// const user = await User.findOne({ rollNumber: rollNumber }); -// if (!user) return false; -// return user; -// }; - export const findUserWithEmail = async function (email) { const normalizedEmail = email?.toString().trim().toLowerCase(); if (!normalizedEmail) return false; @@ -169,29 +163,6 @@ export const addToFavourites = async (userid, name, id, path, code) => { const updatedUser = await UserData.save(); return updatedUser; }; -export const AddNewCourse = async (userid, code, name) => { - const UserData = await User.findById(userid); - const normalizedCode = normalizeCourseCode(code); - - if (UserData.courses.some((c) => normalizeCourseCode(c.code) === normalizedCode)) - return UserData; - - const color = getRandomColor(); - - // Remove from readOnly if present - UserData.readOnly = UserData.readOnly.filter( - (course) => normalizeCourseCode(course.code) !== normalizedCode, - ); - - UserData.courses.push({ - code: normalizedCode, - name, - color, - }); - const updatedUser = await UserData.save(); - return updatedUser; -}; - export const AddReadOnlyCourse = async (userid, code, name) => { const UserData = await User.findById(userid); const normalizedCode = normalizeCourseCode(code); @@ -221,17 +192,6 @@ export const AddReadOnlyCourse = async (userid, code, name) => { return updatedUser; }; -export const RemoveCourse = async (userid, code) => { - const UserData = await User.findById(userid); - const normalizedCode = normalizeCourseCode(code); - let filtered = UserData.courses.filter( - (course) => normalizeCourseCode(course.code) !== normalizedCode, - ); - UserData.courses = filtered; - const updatedUser = await UserData.save(); - return updatedUser; -}; - export const RemoveReadOnly = async (userid, code) => { const UserData = await User.findById(userid); const normalizedCode = normalizeCourseCode(code); diff --git a/server/modules/user/user.routes.js b/server/modules/user/user.routes.js index 92d410a6..cad82f6e 100644 --- a/server/modules/user/user.routes.js +++ b/server/modules/user/user.routes.js @@ -5,8 +5,6 @@ import { addToFavouriteController, removeFromFavouritesController, updateUserController, - addNewCourse, - deleteCourse, updateDeviceToken, getFavouritesController, addReadOnly, @@ -23,9 +21,7 @@ router.get("/favourites", catchAsync(getFavouritesController)); router.post("/favourites", catchAsync(addToFavouriteController)); router.delete("/favourites/:id", catchAsync(removeFromFavouritesController)); -router.post("/course", catchAsync(addNewCourse)); router.post("/readonly", catchAsync(addReadOnly)); -router.delete("/course/:code", catchAsync(deleteCourse)); router.delete("/readonly/:code", catchAsync(deleteReadOnly)); router.put("/devicetoken", catchAsync(updateDeviceToken)); export default router; diff --git a/server/modules/year/year.controller.js b/server/modules/year/year.controller.js index 6332268a..f1c26500 100644 --- a/server/modules/year/year.controller.js +++ b/server/modules/year/year.controller.js @@ -1,96 +1,20 @@ -import { FolderModel } from "../course/course.model.js"; -import CourseModel from "../course/course.model.js"; -import { deleteFile } from "../file/file.controller.js"; -import { normalizeCourseCode, getCourseCodeCaseInsensitiveRegex } from "../../utils/course.js"; +import Course from "../course/course.model.js"; +import { requireCourse, requireFolder, presentFolder } from "../../services/authorization.js"; +import { removeFolderFromCourse } from "../../services/resourceRemoval.js"; import { createYearFolderWithDefaultStructure } from "../course/course.service.js"; -import logger from "../../utils/logger.js"; - -async function addYear(req, res) { - const { name, course } = req.body; - const normalizedCourseCode = normalizeCourseCode(course); - const courseCode = normalizedCourseCode || course; - - const newYear = await createYearFolderWithDefaultStructure(name, courseCode); - - if (normalizedCourseCode) { - const parent = await CourseModel.findOne({ - code: getCourseCodeCaseInsensitiveRegex(normalizedCourseCode), - }); - if (!parent) { - return res.status(404).json({ message: "Course not found" }); - } - parent.children.push(newYear._id); - await parent.save(); - } - - return res.json(newYear); +import AppError from "../../utils/appError.js"; +export async function addYear(req, res) { + const context = await requireCourse(req, req.body.course, "canManage"); + if (typeof req.body.name !== "string" || !req.body.name.trim()) + throw new AppError(400, "A year name is required"); + const year = await createYearFolderWithDefaultStructure(req.body.name, context.code); + await Course.updateOne({ _id: context.course._id }, { $addToSet: { children: year._id } }); + req.authorizationGraph = undefined; + res.json(await presentFolder(req, year._id, context.code)); } - -async function deleteYear(req, res) { - const { folder, courseCode } = req.body; - const folderId = folder._id; - const normalizedCourseCode = normalizeCourseCode(courseCode); - - try { - if (normalizedCourseCode) { - await CourseModel.findOneAndUpdate( - { code: getCourseCodeCaseInsensitiveRegex(normalizedCourseCode) }, - {$pull: { children: folderId }} - ); - } - - const folderDoc = await FolderModel.findById(folderId); - if (folderDoc) { - if (folderDoc.courses.length > 1) { - // Remove this course from the shared folder and its descendants - await removeCourseFromFolderRecursive(folderId, normalizedCourseCode); - } else { - // Last course using this folder, delete it - await recursiveDelete(folder); - } - } - - return res.json({ success: true, folderId }); - } catch (err) { - logger.error("Year deletion failed", { error: err, attributes: { dependency: "mongodb", operation: "delete-year", outcome: "failure", retryable: false } }); - return res.status(500).json({ success: false, error: err.message }); - } +export async function deleteYear(req, res) { + const context = await requireFolder(req, req.body.folderId, req.body.courseCode, "canManage"); + if (!context.course.children.some((id) => String(id) === String(context.folder._id))) + throw new AppError(404, "Year not found"); + res.json(await removeFolderFromCourse(req, req.body.folderId, context.code)); } - -async function removeCourseFromFolderRecursive(folderId, codeToRemove) { - const folder = await FolderModel.findById(folderId); - if (!folder) return; - - await FolderModel.updateOne( - { _id: folderId }, - { $pull: { courses: codeToRemove } } - ); - - if (folder.childType === "Folder") { - for (const childId of folder.children) { - await removeCourseFromFolderRecursive(childId, codeToRemove); - } - } -} - -async function recursiveDelete(folder){ - if(!folder.children) { - await FolderModel.findByIdAndDelete(folder._id); - return; - } - if (folder.childType === "Folder"){ - for(const subfolder of folder.children){ - await recursiveDelete(subfolder); - } - await FolderModel.findByIdAndDelete(folder._id); - } - else if(folder.childType === "File"){ - for(const file of folder.children){ - logger.debug("Year file inspected", { attributes: { dependency: "mongodb", operation: "inspect-year-file", outcome: "success" } }); - await deleteFile(file); - } - await FolderModel.findByIdAndDelete(folder._id); - } -} - -export {addYear,deleteYear} diff --git a/server/modules/year/year.routes.js b/server/modules/year/year.routes.js index 4ccf47ac..2452c4b7 100644 --- a/server/modules/year/year.routes.js +++ b/server/modules/year/year.routes.js @@ -3,10 +3,11 @@ import express from "express"; import { addYear, deleteYear } from "./year.controller.js"; import { isBR } from "../../middleware/isBR.js"; +import catchAsync from "../../utils/catchAsync.js"; const router = express.Router(); router.use(isLibraryAuthenticated); -router.post("", isBR, addYear); -router.delete("/delete", isBR, deleteYear); +router.post("", isBR, catchAsync(addYear)); +router.delete("/delete", isBR, catchAsync(deleteYear)); export default router; diff --git a/server/scripts/downloadFile.js b/server/scripts/downloadFile.js deleted file mode 100644 index efb67704..00000000 --- a/server/scripts/downloadFile.js +++ /dev/null @@ -1,96 +0,0 @@ -import { getAccessToken } from "../modules/onedrive/onedrive.controller.js"; -import logger from "../utils/logger.js"; -import { extractGraphErrorDetails } from "../utils/graphError.js"; - -const encodeGraphShareUrl = (shareUrl) => { - let base64; - if (typeof Buffer !== "undefined") { - base64 = Buffer.from(shareUrl, "utf8").toString("base64"); - } else { - base64 = btoa(shareUrl); - } - - return `u!${base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`; -}; - -export const downloadFiles = async (req, res) => { - const access = await getAccessToken(); - const endpoint = "https://graph.microsoft.com/v1.0/shares/{encoded}/driveItem"; - - try { - const inputUrl = req.body?.url; - if (!inputUrl) { - return res - .status(400) - .json({ error: "Please provide a SharePoint/OneDrive share URL in request body as `url`." }); - } - - const encoded = encodeGraphShareUrl(inputUrl); - const resolvedEndpoint = endpoint.replace("{encoded}", encoded); - - const response = await fetch(resolvedEndpoint, { - method: "GET", - headers: { - Authorization: `Bearer ${access}`, - }, - }); - - if (!response.ok) { - const text = await response.text().catch(() => ""); - let parsedPayload = text; - try { - parsedPayload = text ? JSON.parse(text) : text; - } catch { - parsedPayload = text; - } - - const details = extractGraphErrorDetails({ - config: { method: "get", url: resolvedEndpoint }, - response: { - status: response.status, - headers: Object.fromEntries(response.headers.entries()), - data: parsedPayload, - }, - }); - - logger.error("Graph share lookup failed", { error: new Error("Graph share lookup failed"), attributes: { dependency: "microsoft-graph", operation: "lookup-share", outcome: "failure", retryable: true } }); - return res.status(502).json({ - error: "Failed to fetch file details from Microsoft Graph", - status: details.status, - code: details.code, - detail: details.message, - requestId: details.requestId, - }); - } - - const data = await response.json(); - - if (data.error) { - const details = extractGraphErrorDetails({ - config: { method: "get", url: resolvedEndpoint }, - response: { status: 502, data }, - }); - logger.error("Graph share lookup failed", { error: new Error("Graph returned an error response"), attributes: { dependency: "microsoft-graph", operation: "lookup-share", outcome: "failure", retryable: true } }); - - return res.status(502).json({ - error: "Microsoft Graph responded with an error", - detail: details.message, - code: details.code, - requestId: details.requestId, - }); - } - - const downloadLink = data["@microsoft.graph.downloadUrl"]; - if (!downloadLink) { - logger.error("Graph response missing download URL", { attributes: { dependency: "microsoft-graph", operation: "lookup-share", outcome: "failure", retryable: false } }); - - return res.status(500).json({ error: "No download link found in Graph response." }); - } - - return res.status(200).json({ downloadLink }); - } catch (err) { - const details = extractGraphErrorDetails(err); - logger.error("Graph download failed", { error: err, attributes: { dependency: "microsoft-graph", operation: "download-file", outcome: "failure", retryable: true } }); - return res.status(500).json({ error: "Internal server error", detail: details.message }); - } -}; diff --git a/server/scripts/generateOneDriveToken.js b/server/scripts/generateOneDriveToken.js index 83930c4d..69644938 100644 --- a/server/scripts/generateOneDriveToken.js +++ b/server/scripts/generateOneDriveToken.js @@ -27,7 +27,7 @@ if (!clientId || !clientSecret) { const scopes = "user.read offline_access files.readwrite"; const authUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?client_id=${clientId}&response_type=code&redirect_uri=${encodeURIComponent( - redirectUri + redirectUri, )}&scope=${encodeURIComponent(scopes)}&state=12345`; console.log("\n========================================================================"); @@ -36,7 +36,9 @@ console.log("=================================================================== console.log("1. Open the following URL in your browser:\n"); console.log(` ${authUrl}\n`); console.log("2. Sign in with the Coding Club (OneDrive owner) Microsoft account."); -console.log("3. Grant consent for requested permissions (user.read, offline_access, files.readwrite).\n"); +console.log( + "3. Grant consent for requested permissions (user.read, offline_access, files.readwrite).\n", +); const exchangeCodeForTokens = async (code) => { try { @@ -58,7 +60,7 @@ const exchangeCodeForTokens = async (code) => { headers: { "Content-Type": "application/x-www-form-urlencoded", }, - } + }, ); if (!response.data || !response.data.refresh_token) { @@ -122,8 +124,12 @@ const server = http.createServer(async (req, res) => { server.on("error", (err) => { if (err.code === "EADDRINUSE") { - console.log(`ℹ️ Note: Port ${port} is currently in use (e.g. CourseHub dev server is running).`); - console.log(" After signing in, copy the 'code' parameter from the redirected browser URL bar.\n"); + console.log( + `ℹ️ Note: Port ${port} is currently in use (e.g. CourseHub dev server is running).`, + ); + console.log( + " After signing in, copy the 'code' parameter from the redirected browser URL bar.\n", + ); const rl = readline.createInterface({ input: process.stdin, diff --git a/server/scripts/migrate_folders_to_multi_course.js b/server/scripts/migrate_folders_to_multi_course.js index d4e9ae7f..73b08e04 100644 --- a/server/scripts/migrate_folders_to_multi_course.js +++ b/server/scripts/migrate_folders_to_multi_course.js @@ -12,15 +12,14 @@ async function migrate() { // Using direct mongo update for efficiency // This converts the 'course' field (string) to 'courses' field (array with that string) - const result = await FolderModel.updateMany( - { course: { $exists: true } }, - [ - { $set: { courses: ["$course"] } }, - { $unset: "course" } - ] - ); + const result = await FolderModel.updateMany({ course: { $exists: true } }, [ + { $set: { courses: ["$course"] } }, + { $unset: "course" }, + ]); - console.log(`Migration completed. Matched ${result.matchedCount} documents and modified ${result.modifiedCount} documents.`); + console.log( + `Migration completed. Matched ${result.matchedCount} documents and modified ${result.modifiedCount} documents.`, + ); process.exit(0); } catch (error) { diff --git a/server/scripts/preflight.js b/server/scripts/preflight.js index fc9012d5..77a63fdb 100644 --- a/server/scripts/preflight.js +++ b/server/scripts/preflight.js @@ -1,2 +1,3 @@ const major = Number(process.versions.node.split(".")[0]); -if (major < 22) throw new Error(`CourseHub server requires Node 22+, found ${process.versions.node}`); +if (major < 22) + throw new Error(`CourseHub server requires Node 22+, found ${process.versions.node}`); diff --git a/server/scripts/recalculateFolderCounts.js b/server/scripts/recalculateFolderCounts.js index 9a465691..e92574af 100644 --- a/server/scripts/recalculateFolderCounts.js +++ b/server/scripts/recalculateFolderCounts.js @@ -5,7 +5,8 @@ import { calculateFolderSubtreeCount } from "../utils/folder.js"; dotenv.config(); -const envUri = process.env.MONGODB_URI || process.env.MONGO_URI || "mongodb://localhost:27017/coursehub"; +const envUri = + process.env.MONGODB_URI || process.env.MONGO_URI || "mongodb://localhost:27017/coursehub"; // Prepare fallback URIs if host.docker.internal cannot be resolved outside container const candidateUris = [ envUri, diff --git a/server/scripts/reverse_migrate_folders_to_single_course.js b/server/scripts/reverse_migrate_folders_to_single_course.js index 752f0b87..18433803 100644 --- a/server/scripts/reverse_migrate_folders_to_single_course.js +++ b/server/scripts/reverse_migrate_folders_to_single_course.js @@ -11,15 +11,14 @@ async function reverseMigrate() { console.log("Starting reversal of migration..."); // This converts the 'courses' field (array) back to 'course' field (first element of array) - const result = await FolderModel.updateMany( - { courses: { $exists: true, $ne: [] } }, - [ - { $set: { course: { $arrayElemAt: ["$courses", 0] } } }, - { $unset: "courses" } - ] - ); + const result = await FolderModel.updateMany({ courses: { $exists: true, $ne: [] } }, [ + { $set: { course: { $arrayElemAt: ["$courses", 0] } } }, + { $unset: "courses" }, + ]); - console.log(`Reversal completed. Matched ${result.matchedCount} documents and modified ${result.modifiedCount} documents.`); + console.log( + `Reversal completed. Matched ${result.matchedCount} documents and modified ${result.modifiedCount} documents.`, + ); process.exit(0); } catch (error) { diff --git a/server/scripts/syncCoursesCache.js b/server/scripts/syncCoursesCache.js index 97775988..4db6adb6 100644 --- a/server/scripts/syncCoursesCache.js +++ b/server/scripts/syncCoursesCache.js @@ -15,30 +15,63 @@ const __filename = fileURLToPath(import.meta.url); dotenv.config({ path: path.join(path.dirname(__filename), "../.env") }); export async function runSync({ correlationId = getCorrelationId() || randomUUID() } = {}) { - lifecycleLogger.info("Course cache job started", { correlationId, attributes: { jobName: "course-cache", operation: "course-cache-sync", outcome: "started" } }); + lifecycleLogger.info("Course cache job started", { + correlationId, + attributes: { jobName: "course-cache", operation: "course-cache-sync", outcome: "started" }, + }); try { const response = await axios.post( "https://academic.iitg.ac.in/sso/gen/student1.jsp", qs.stringify({ cid: "All", sess: academic.session, yr: academic.currentYear }), - { headers: { "Content-Type": "application/x-www-form-urlencoded" }, timeout: 60000 } + { headers: { "Content-Type": "application/x-www-form-urlencoded" }, timeout: 60000 }, ); if (!response.data) throw new Error("Academic portal returned no data"); const allotments = parseCourseAllotmentsFromHtml(response.data); const bulkOps = Object.keys(allotments).map((roll) => ({ updateOne: { - filter: { rollNumber: Number.parseInt(roll), session: academic.session, year: academic.currentYear }, + filter: { + rollNumber: Number.parseInt(roll), + session: academic.session, + year: academic.currentYear, + }, update: { $set: { courses: allotments[roll] } }, upsert: true, }, })); if (bulkOps.length) { await CourseAllotment.bulkWrite(bulkOps); - lifecycleLogger.info("Course cache job completed", { correlationId, attributes: { dependency: "mongodb", jobName: "course-cache", operation: "course-cache-sync", outcome: "success" } }); + lifecycleLogger.info("Course cache job completed", { + correlationId, + attributes: { + dependency: "mongodb", + jobName: "course-cache", + operation: "course-cache-sync", + outcome: "success", + }, + }); } else { - lifecycleLogger.warn("Course cache job produced no updates", { correlationId, attributes: { dependency: "academic-portal", jobName: "course-cache", operation: "course-cache-sync", outcome: "empty" } }); + lifecycleLogger.warn("Course cache job produced no updates", { + correlationId, + attributes: { + dependency: "academic-portal", + jobName: "course-cache", + operation: "course-cache-sync", + outcome: "empty", + }, + }); } } catch (error) { - lifecycleLogger.error("Course cache job failed", { error, correlationId, attributes: { dependency: "academic-portal", jobName: "course-cache", operation: "course-cache-sync", outcome: "failure", retryable: true } }); + lifecycleLogger.error("Course cache job failed", { + error, + correlationId, + attributes: { + dependency: "academic-portal", + jobName: "course-cache", + operation: "course-cache-sync", + outcome: "failure", + retryable: true, + }, + }); throw error; } } diff --git a/server/scripts/uppercaseCourseCodes.js b/server/scripts/uppercaseCourseCodes.js index e2b00481..7387df26 100644 --- a/server/scripts/uppercaseCourseCodes.js +++ b/server/scripts/uppercaseCourseCodes.js @@ -145,7 +145,7 @@ const normalizeFolderCourses = async () => { for (const folder of folders) { if (!folder.courses || !folder.courses.length) continue; let needsUpdate = false; - const normalizedCourses = folder.courses.map(code => { + const normalizedCourses = folder.courses.map((code) => { const normalizedCourseCode = normalizeCourseCode(code); if (normalizedCourseCode && normalizedCourseCode !== code) { needsUpdate = true; @@ -177,7 +177,7 @@ const normalizeFileCourseReferences = async () => { await filesCollection.updateOne( { _id: file._id }, - { $set: { course: normalizedCourseReference } } + { $set: { course: normalizedCourseReference } }, ); updatedCount += 1; } @@ -258,7 +258,7 @@ const normalizeUserCourseCodes = async () => { const codeChanged = !lengthChanged && originalCourses.some( - (entry, index) => entry?.code !== normalizedCourses[index]?.code + (entry, index) => entry?.code !== normalizedCourses[index]?.code, ); if (lengthChanged || codeChanged) { @@ -288,7 +288,7 @@ export async function migrateToUppercase() { const courseSummary = await mergeCourseDocuments(); console.log( - `Normalized CourseModel: updated=${courseSummary.updatedCount}, deletedDuplicates=${courseSummary.deletedCount}` + `Normalized CourseModel: updated=${courseSummary.updatedCount}, deletedDuplicates=${courseSummary.deletedCount}`, ); const updatedFolders = await normalizeFolderCourses(); @@ -299,7 +299,7 @@ export async function migrateToUppercase() { const searchSummary = await mergeSearchResultsDocuments(); console.log( - `Normalized SearchResults: updated=${searchSummary.updatedCount}, deletedDuplicates=${searchSummary.deletedCount}` + `Normalized SearchResults: updated=${searchSummary.updatedCount}, deletedDuplicates=${searchSummary.deletedCount}`, ); const updatedContributions = await normalizeContributionCourseCodes(); @@ -307,7 +307,7 @@ export async function migrateToUppercase() { const updatedUsers = await normalizeUserCourseCodes(); console.log( - `Normalized user course arrays (courses/readOnly/previousCourses/favourites): updated=${updatedUsers}` + `Normalized user course arrays (courses/readOnly/previousCourses/favourites): updated=${updatedUsers}`, ); console.log("Migration to uppercase normalization completed successfully"); @@ -316,10 +316,7 @@ export async function migrateToUppercase() { } } -if ( - process.argv[1] && - import.meta.url === pathToFileURL(process.argv[1]).href -) { +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { migrateToUppercase() .then(() => process.exit(0)) .catch((error) => { diff --git a/server/services/UploadFile.js b/server/services/UploadFile.js index 85a964aa..0e569821 100644 --- a/server/services/UploadFile.js +++ b/server/services/UploadFile.js @@ -1,64 +1,18 @@ import fs from "fs"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; import { getAccessToken } from "../modules/onedrive/onedrive.controller.js"; import axios from "axios"; import { FileModel } from "../modules/course/course.model.js"; -import Contribution from "../modules/contribution/contribution.model.js"; import logger from "../utils/logger.js"; import { logGraphError } from "../utils/graphError.js"; -import { uploadThumbnail, deleteThumbnail } from "./imagekit.js"; +import { uploadThumbnail } from "./imagekit.js"; const parent_item_id = process.env.ONEDRIVE_FOLDER_ID; -async function GetFolderId(contributionId) { - const access_token = await getAccessToken(); - const url = `https://graph.microsoft.com/v1.0/me/drive/items/${parent_item_id}/children`; - const config = { - headers: { - Authorization: `Bearer ${access_token}`, - }, - }; - - try { - const { data } = await axios.get(url, config); - const foundFolder = data?.value?.find((folder) => folder.name === contributionId); - if (foundFolder) return foundFolder?.id; - return false; - } catch (error) { - return false; - } -} - -async function CreateFolder(contributionId) { - const access_token = await getAccessToken(); - const url = `https://graph.microsoft.com/v1.0/me/drive/items/${parent_item_id}/children`; - const config = { - headers: { - Authorization: `Bearer ${access_token}`, - }, - }; - - const _data = { - name: contributionId, - folder: {}, - "@microsoft.graph.conflictBehavior": "fail", - }; - - try { - const { data } = await axios.post(url, _data, config); - return data.id; - } catch (error) { - if (error?.response?.status === 409) { - const folderId = await GetFolderId(contributionId); - return folderId; - } else { - return false; - } - } -} - async function createUploadSession(folderId, fileName) { const access_token = await getAccessToken(); - const url = `https://graph.microsoft.com/v1.0/me/drive/items/${folderId}:/${fileName}:/createUploadSession`; + const url = `https://graph.microsoft.com/v1.0/me/drive/items/${folderId}:/${encodeURIComponent(fileName)}:/createUploadSession`; const config = { headers: { Authorization: `Bearer ${access_token}`, @@ -74,9 +28,9 @@ async function createUploadSession(folderId, fileName) { } } -async function UploadFile(contributionId, filePath, fileName) { +async function UploadFile(filePath, fileName, approved) { const folderId = parent_item_id; - const session = await createUploadSession(folderId, fileName); + const session = await createUploadSession(folderId, randomUUID() + path.extname(fileName)); if (!session?.url) { logger.error("File upload failed", { attributes: { @@ -89,8 +43,7 @@ async function UploadFile(contributionId, filePath, fileName) { return null; } const { url, access_token } = session; - const existingContribution = await Contribution.findOne({ contributionId }); - const file = fs.readFileSync(`${filePath}${fileName}`); + const file = fs.readFileSync(filePath); const config = { headers: { "Content-Range": `bytes 0-${file.length - 1}/${file.length}`, @@ -122,7 +75,7 @@ async function UploadFile(contributionId, filePath, fileName) { const webUrl = urldata?.data?.link?.webUrl; const fileData = new FileModel({ - isVerified: !!existingContribution?.approved, + isVerified: approved === true, fileId: data.id, size: data.size, thumbnail: tempThumbnailUrl ? { url: tempThumbnailUrl } : undefined, @@ -184,73 +137,15 @@ async function UploadFile(contributionId, filePath, fileName) { } async function DeleteFile(fileId) { + if (!fileId || fileId === parent_item_id) throw new Error("Storage root cannot be deleted"); const access_token = await getAccessToken(); - - // Delete ImageKit thumbnail in the background - don't block the response - FileModel.findOne({ fileId }) - .lean() - .then((fileDoc) => { - const imagekitId = fileDoc?.thumbnail?.fileId || fileDoc?.imagekitFileId; - if (imagekitId) { - deleteThumbnail(imagekitId).catch((ikErr) => { - logger.warn("ImageKit thumbnail deletion failed", { - error: ikErr, - attributes: { - dependency: "imagekit", - operation: "delete-thumbnail", - outcome: "failure", - retryable: true, - }, - }); - }); - } + await axios + .delete(`https://graph.microsoft.com/v1.0/me/drive/items/${encodeURIComponent(fileId)}`, { + headers: { Authorization: `Bearer ${access_token}` }, }) - .catch(() => {}); - - try { - //obtain parent folder onedrive id - const { data } = await axios.get( - `https://graph.microsoft.com/v1.0/me/drive/items/${fileId}`, - { - headers: { - Authorization: `Bearer ${access_token}`, - }, - }, - ); - const folderId = data?.parentReference?.id; - - //delete entire folder if it is the only file or delete only the file - const empty = await isFolderEmpty(folderId, access_token); - if (empty) { - await axios.delete(`https://graph.microsoft.com/v1.0/me/drive/items/${folderId}`, { - headers: { - Authorization: `Bearer ${access_token}`, - }, - }); - } else { - await axios.delete(`https://graph.microsoft.com/v1.0/me/drive/items/${fileId}`, { - headers: { - Authorization: `Bearer ${access_token}`, - }, - }); - } - } catch (err) { - logGraphError(logger, err, "Failed to delete file from Microsoft Graph"); - } -} - -async function isFolderEmpty(folderId, access_token) { - const { data } = await axios.get( - `https://graph.microsoft.com/v1.0/me/drive/items/${folderId}/children`, - { - headers: { - Authorization: `Bearer ${access_token}`, - }, - }, - ); - - if (data.value.length === 1) return true; - else return false; + .catch((error) => { + if (error.response?.status !== 404) throw error; + }); } async function RenameOneDriveFile(fileId, newName) { @@ -263,7 +158,7 @@ async function RenameOneDriveFile(fileId, newName) { }, }; const _data = { - name: newName, + name: randomUUID() + path.extname(newName), }; try { const { data } = await axios.patch(url, _data, config); diff --git a/server/services/authorization.js b/server/services/authorization.js new file mode 100644 index 00000000..023e1849 --- /dev/null +++ b/server/services/authorization.js @@ -0,0 +1,303 @@ +import Course, { FolderModel, FileModel } from "../modules/course/course.model.js"; +import CourseAllotment from "../modules/course/courseAllotment.model.js"; +import BR from "../modules/br/br.model.js"; +import Contribution from "../modules/contribution/contribution.model.js"; +import AppError from "../utils/appError.js"; +import { normalizeCourseCode } from "../utils/course.js"; + +const idOf = (value) => String(value?._id || value); +const codesOf = (values = []) => [...new Set(values.map(normalizeCourseCode).filter(Boolean))]; +export function resourceId(value) { + if (typeof value !== "string" || !/^[a-f0-9]{24}$/i.test(value)) + throw new AppError(404, "Resource not found"); + return value.toLowerCase(); +} + +export function courseContext(value) { + if (typeof value !== "string" || !normalizeCourseCode(value)) + throw new AppError(400, "Course context is required"); + return normalizeCourseCode(value); +} + +// Evaluate the existing academic cutoff on each request, including after semester rollover +export function academicPeriod(now = new Date()) { + const parts = Object.fromEntries( + new Intl.DateTimeFormat("en-US", { + timeZone: "Asia/Kolkata", + year: "numeric", + month: "numeric", + day: "numeric", + }) + .formatToParts(now) + .map(({ type, value }) => [type, Number(value)]), + ); + return { + year: parts.year, + session: parts.month < 7 || (parts.month === 7 && parts.day <= 23) ? "Jan-May" : "July-Nov", + }; +} + +export async function actorFor(req) { + if (!req.authorizationActor) + req.authorizationActor = (async () => { + if (req.admin) + return { admin: true, id: idOf(req.admin), isBR: false, current: [], managed: [] }; + if (!req.user) throw new AppError(401, "Sign in to continue"); + const email = req.user.email?.trim().toLowerCase(); + const [br, allotments] = await Promise.all([ + email + ? BR.findOne({ email }).collation({ locale: "en", strength: 2 }).lean() + : null, + CourseAllotment.find({ rollNumber: req.user.rollNumber }).lean(), + ]); + const period = academicPeriod(); + const pastOrCurrent = allotments.filter( + (a) => + ["Jan-May", "July-Nov"].includes(a.session) && + (a.year < period.year || + (a.year === period.year && + (a.session === "Jan-May" || period.session === "July-Nov"))), + ); + return { + admin: false, + id: idOf(req.user), + isBR: Boolean(br), + current: codesOf( + pastOrCurrent + .filter((a) => a.year === period.year && a.session === period.session) + .flatMap((a) => a.courses), + ), + managed: br ? codesOf(pastOrCurrent.flatMap((a) => a.courses)) : [], + }; + })(); + return req.authorizationActor; +} + +export function capabilities(actor, code) { + const canManage = actor.admin || actor.managed.includes(normalizeCourseCode(code)); + return { + canManage, + canModerate: canManage, + canContribute: canManage || actor.current.includes(normalizeCourseCode(code)), + }; +} + +export async function requireCourse(req, value, action = "read") { + const code = courseContext(value); + const actor = await actorFor(req); + const permitted = capabilities(actor, code); + if (action !== "read" && !permitted[action]) + throw new AppError(403, "You do not have permission for this course"); + const graph = await libraryGraph(req); + const course = graph.courses.get(code); + if (!course) throw new AppError(404, "Course not found"); + return { course, code, actor, capabilities: permitted }; +} + +// Membership requires a path from a real course root through folders still linked to that course +export async function libraryGraph(req) { + if (!req.authorizationGraph) + req.authorizationGraph = (async () => { + const [courses, folders] = await Promise.all([ + Course.find().select("_id code name children books createdAt updatedAt").lean(), + FolderModel.find().select("_id name courses childType children").lean(), + ]); + const graph = { + courses: new Map(courses.map((c) => [normalizeCourseCode(c.code), c])), + folders: new Map(folders.map((f) => [idOf(f), f])), + folderCourses: new Map(), + fileCourses: new Map(), + }; + const add = (map, id, code) => { + if (!map.has(id)) map.set(id, new Set()); + map.get(id).add(code); + }; + for (const [code, course] of graph.courses) { + const visited = new Set(); + const walk = (id, ancestors = new Set()) => { + id = idOf(id); + if (ancestors.has(id) || ancestors.size > 64) + throw new AppError(409, "Course tree requires repair"); + if (visited.has(id)) return; + const folder = graph.folders.get(id); + if (!folder || !codesOf(folder.courses).includes(code)) return; + visited.add(id); + add(graph.folderCourses, id, code); + if (folder.childType === "File") + for (const file of folder.children) + add(graph.fileCourses, idOf(file), code); + else + for (const child of folder.children) + walk(child, new Set([...ancestors, id])); + }; + for (const root of course.children) walk(root); + } + return graph; + })(); + return req.authorizationGraph; +} + +export async function requireFolder(req, id, value, action = "read") { + id = resourceId(id); + const graph = await libraryGraph(req); + const affectedCourses = [...(graph.folderCourses.get(id) || [])].sort(); + const code = value + ? courseContext(value) + : action === "read" + ? affectedCourses[0] + : courseContext(value); + if (!code || !affectedCourses.includes(code)) throw new AppError(404, "Folder not found"); + const course = await requireCourse(req, code, action); + return { ...course, folder: graph.folders.get(id), affectedCourses }; +} + +async function ownedFiles(req) { + if (!req.authorizationOwnedFiles) + req.authorizationOwnedFiles = (async () => { + const actor = await actorFor(req); + if (!actor.id) return new Set(); + const contributions = await Contribution.find({ uploadedBy: actor.id }) + .select("files") + .lean(); + return new Set(contributions.flatMap((c) => c.files.map(idOf))); + })(); + return req.authorizationOwnedFiles; +} + +export async function canReadFile(req, file) { + const [actor, graph] = await Promise.all([actorFor(req), libraryGraph(req)]); + const courses = [...(graph.fileCourses.get(idOf(file)) || [])]; + if (!courses.length) return false; + return ( + file.isVerified === true || + actor.admin || + courses.some((c) => actor.managed.includes(c)) || + (await ownedFiles(req)).has(idOf(file)) + ); +} + +export async function requireFile(req, id, value, action = "read") { + id = resourceId(id); + const [file, graph, actor] = await Promise.all([ + FileModel.findById(id), + libraryGraph(req), + actorFor(req), + ]); + const affectedCourses = [...(graph.fileCourses.get(id) || [])].sort(); + if (!file || !affectedCourses.length) throw new AppError(404, "File not found"); + const code = value + ? courseContext(value) + : action === "read" + ? undefined + : courseContext(value); + if (code && !affectedCourses.includes(code)) throw new AppError(404, "File not found"); + if (action !== "read") await requireCourse(req, code, action); + else if (!(await canReadFile(req, file))) throw new AppError(404, "File not found"); + return { file, affectedCourses, code, actor }; +} + +export async function presentFile(req, file, code) { + const [actor, graph] = await Promise.all([actorFor(req), libraryGraph(req)]); + const id = idOf(file), + affectedCourses = [...(graph.fileCourses.get(id) || [])].sort(); + return { + _id: id, + name: file.name, + size: file.size, + isVerified: file.isVerified === true, + webUrl: `/api/files/preview/${id}`, + downloadUrl: `/api/files/content/${id}?download=1`, + thumbnail: { url: `/api/files/thumbnail/${id}` }, + affectedCourses, + capabilities: capabilities( + actor, + code || affectedCourses.find((c) => capabilities(actor, c).canManage), + ), + }; +} + +async function visibleFileMap(req) { + if (!req.authorizationVisibleFiles) + req.authorizationVisibleFiles = (async () => { + const graph = await libraryGraph(req); + const files = await FileModel.find({ + _id: { $in: [...graph.fileCourses.keys()] }, + }).lean(); + const visible = await Promise.all( + files.map(async (file) => ((await canReadFile(req, file)) ? file : null)), + ); + return new Map(visible.filter(Boolean).map((file) => [idOf(file), file])); + })(); + return req.authorizationVisibleFiles; +} + +export async function presentFolder(req, id, code) { + const [graph, files, actor] = await Promise.all([ + libraryGraph(req), + visibleFileMap(req), + actorFor(req), + ]); + const folder = graph.folders.get(idOf(id)); + if (!folder || !graph.folderCourses.get(idOf(id))?.has(code)) return null; + const children = ( + await Promise.all( + folder.children.map(async (child) => + folder.childType === "Folder" + ? presentFolder(req, child, code) + : files.has(idOf(child)) + ? presentFile(req, files.get(idOf(child)), code) + : null, + ), + ) + ).filter(Boolean); + return { + ...folder, + children, + totalFileCount: + folder.childType === "File" + ? children.length + : children.reduce((sum, child) => sum + child.totalFileCount, 0), + capabilities: capabilities(actor, code), + affectedCourses: [...graph.folderCourses.get(idOf(id))].sort(), + }; +} + +export async function presentCourse(req, value) { + const { course, code, capabilities: permitted } = await requireCourse(req, value); + const children = ( + await Promise.all(course.children.map((id) => presentFolder(req, id, code))) + ).filter(Boolean); + children.sort((a, b) => a.name.localeCompare(b.name)); + return { ...course, children, capabilities: permitted }; +} + +export async function visibleFiles(req) { + return Promise.all( + [...(await visibleFileMap(req)).values()].map((file) => presentFile(req, file)), + ); +} + +export async function authorizeContributionUpload(req, res, next) { + try { + const id = req.headers["contribution-id"]; + if (typeof id !== "string" || !id || id.length > 100) + throw new AppError(404, "Contribution not found"); + const contribution = await Contribution.findOne({ + contributionId: id, + uploadedBy: (await actorFor(req)).id, + }); + if (!contribution) throw new AppError(404, "Contribution not found"); + const context = await requireFolder( + req, + idOf(contribution.parentFolder), + contribution.courseCode, + "canContribute", + ); + if (context.folder.childType !== "File") throw new AppError(400, "Choose a file folder"); + req.contribution = contribution; + req.contributionApproved = context.capabilities.canManage; + next(); + } catch (error) { + next(error); + } +} diff --git a/server/services/connectDB.js b/server/services/connectDB.js index a7e241a0..7b43d3ae 100644 --- a/server/services/connectDB.js +++ b/server/services/connectDB.js @@ -3,12 +3,24 @@ import config from "../config/default.js"; import { lifecycleLogger } from "../utils/logger.js"; const connectDatabase = async () => { - lifecycleLogger.info("Database connection starting", { attributes: { dependency: "mongodb", operation: "connect", outcome: "started" } }); + lifecycleLogger.info("Database connection starting", { + attributes: { dependency: "mongodb", operation: "connect", outcome: "started" }, + }); try { await mongoose.connect(config.mongoURI); - lifecycleLogger.info("Database connection established", { attributes: { dependency: "mongodb", operation: "connect", outcome: "success" } }); + lifecycleLogger.info("Database connection established", { + attributes: { dependency: "mongodb", operation: "connect", outcome: "success" }, + }); } catch (error) { - lifecycleLogger.error("Database connection failed", { error, attributes: { dependency: "mongodb", operation: "connect", outcome: "failure", retryable: true } }); + lifecycleLogger.error("Database connection failed", { + error, + attributes: { + dependency: "mongodb", + operation: "connect", + outcome: "failure", + retryable: true, + }, + }); throw error; } }; diff --git a/server/services/email.js b/server/services/email.js index 80392a42..34235d73 100644 --- a/server/services/email.js +++ b/server/services/email.js @@ -19,10 +19,20 @@ function sendEmail(to, subject, msg) { }; transporter.sendMail(options, function (err, info) { if (err) { - logger.error("Email delivery failed", { error: err, attributes: { dependency: "outlook", operation: "send-email", outcome: "failure", retryable: true } }); + logger.error("Email delivery failed", { + error: err, + attributes: { + dependency: "outlook", + operation: "send-email", + outcome: "failure", + retryable: true, + }, + }); return; } - logger.info("Email delivered", { attributes: { dependency: "outlook", operation: "send-email", outcome: "success" } }); + logger.info("Email delivered", { + attributes: { dependency: "outlook", operation: "send-email", outcome: "success" }, + }); }); } diff --git a/server/services/fileDelivery.js b/server/services/fileDelivery.js new file mode 100644 index 00000000..cabd5c24 --- /dev/null +++ b/server/services/fileDelivery.js @@ -0,0 +1,80 @@ +import axios from "axios"; +import { pipeline } from "node:stream/promises"; +import { requireFile } from "./authorization.js"; +import { getAccessToken } from "../modules/onedrive/onedrive.controller.js"; +import AppError from "../utils/appError.js"; +import { isImageKitUrl } from "./imagekit.js"; + +export async function filePreview(req, res) { + const { file } = await requireFile(req, req.params.id, req.query.courseCode); + // Keep Office's viewer for existing provider links after the visibility check. + if (!/\.(pdf|png|jpe?g|webp|gif)$/i.test(file.name)) { + let url; + try { + url = new URL(file.webUrl); + } catch { + /* Use authenticated content instead. */ + } + if ( + url?.protocol === "https:" && + /(^|\.)(sharepoint\.com|onedrive\.live\.com|1drv\.ms)$/i.test(url.hostname) + ) + return res.redirect(url.href); + } + return fileContent(req, res); +} + +export async function fileContent(req, res) { + const { file } = await requireFile(req, req.params.id, req.query.courseCode); + const token = await getAccessToken(); + const response = await axios.get( + `https://graph.microsoft.com/v1.0/me/drive/items/${encodeURIComponent(file.fileId)}/content`, + { + headers: { Authorization: `Bearer ${token}` }, + responseType: "stream", + }, + ); + res.setHeader("Cache-Control", "private, no-store"); + res.setHeader("X-Content-Type-Options", "nosniff"); + const contentType = response.headers["content-type"] || "application/octet-stream"; + res.setHeader("Content-Type", contentType); + // Inline only passive document/image formats + const inline = + req.query.download !== "1" && + /^(application\/pdf|image\/(png|jpeg|webp|gif))(;|$)/i.test(contentType); + res.setHeader( + "Content-Disposition", + `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(file.name)}`, + ); + if (response.headers["content-length"]) + res.setHeader("Content-Length", response.headers["content-length"]); + res.on("close", () => response.data.destroy()); + await pipeline(response.data, res); +} + +export async function fileThumbnail(req, res) { + const { file } = await requireFile(req, req.params.id, req.query.courseCode); + let url = typeof file.thumbnail === "string" ? file.thumbnail : file.thumbnail?.url; + if (!url || !isImageKitUrl(url)) { + const token = await getAccessToken(); + const { data } = await axios.get( + `https://graph.microsoft.com/v1.0/me/drive/items/${encodeURIComponent(file.fileId)}/thumbnails`, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ); + url = data.value?.[0]?.medium?.url; + } + if (!url) throw new AppError(404, "Thumbnail not found"); + const response = await axios.get(url, { responseType: "stream" }); + const type = response.headers["content-type"] || ""; + if (!/^image\/(png|jpeg|webp|gif)(;|$)/i.test(type)) { + response.data.destroy(); + throw new AppError(502, "Thumbnail unavailable"); + } + res.setHeader("Content-Type", type); + res.setHeader("Cache-Control", "private, no-store"); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.on("close", () => response.data.destroy()); + await pipeline(response.data, res); +} diff --git a/server/services/resourceRemoval.js b/server/services/resourceRemoval.js new file mode 100644 index 00000000..58cd4097 --- /dev/null +++ b/server/services/resourceRemoval.js @@ -0,0 +1,74 @@ +import { normalizeCourseCode } from "../utils/course.js"; +import Course, { FileModel, FolderModel } from "../modules/course/course.model.js"; +import Contribution from "../modules/contribution/contribution.model.js"; +import { DeleteFile } from "./UploadFile.js"; +import { deleteThumbnail } from "./imagekit.js"; +import { libraryGraph, requireFolder } from "./authorization.js"; + +export async function removeFile(file) { + // Use only the provider identity persisted on this authorized resource + await DeleteFile(file.fileId); + if (file.thumbnail?.fileId) { + await deleteThumbnail(file.thumbnail.fileId).catch((error) => { + if (error.status !== 404) throw error; + }); + } + await FolderModel.updateMany({ children: file._id }, { $pull: { children: file._id } }); + await Contribution.updateMany({ files: file._id }, { $pull: { files: file._id } }); + await FileModel.deleteOne({ _id: file._id }); +} + +export async function removeFolderFromCourse(req, id, code) { + const context = await requireFolder(req, id, code, "canManage"); + id = String(context.folder._id); + const graph = await libraryGraph(req); + const affected = new Set(); + const collect = (folderId) => { + folderId = String(folderId); + if (affected.has(folderId) || !graph.folderCourses.get(folderId)?.has(context.code)) return; + affected.add(folderId); + const folder = graph.folders.get(folderId); + if (folder.childType === "Folder") folder.children.forEach(collect); + }; + collect(id); + const removed = [...affected].filter( + (folderId) => + graph.folderCourses.get(folderId).size === 1 && + graph.folders + .get(folderId) + .courses.every((c) => normalizeCourseCode(c) === context.code), + ); + const candidateFiles = new Set( + removed.flatMap((folderId) => { + const folder = graph.folders.get(folderId); + return folder.childType === "File" ? folder.children.map(String) : []; + }), + ); + // Files referenced by a surviving folder must remain available there + for (const [folderId, folder] of graph.folders) { + if (!removed.includes(folderId) && folder.childType === "File") + folder.children.forEach((fileId) => candidateFiles.delete(String(fileId))); + } + const files = await FileModel.find({ _id: { $in: [...candidateFiles] } }); + for (const file of files) await removeFile(file); + for (const folderId of affected) { + const folder = graph.folders.get(folderId); + await FolderModel.updateOne( + { _id: folderId }, + { + $set: { + courses: folder.courses.filter((c) => normalizeCourseCode(c) !== context.code), + }, + }, + ); + } + await Course.updateOne({ _id: context.course._id }, { $pull: { children: id } }); + if (removed.length) { + await FolderModel.deleteMany({ _id: { $in: removed } }); + await FolderModel.updateMany( + { children: { $in: removed } }, + { $pull: { children: { $in: removed } } }, + ); + } + return { success: true, folderId: id, unlinked: context.affectedCourses.length > 1 }; +} diff --git a/server/static/index.html b/server/static/index.html index f5530ddb..b22e57f2 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -1,11 +1,11 @@ - + - - - - Document - - - Server is running - - \ No newline at end of file + + + + Document + + + Server is running + + diff --git a/server/test/browser/library-access.test.js b/server/test/browser/library-access.test.js index a6853652..b794b3be 100644 --- a/server/test/browser/library-access.test.js +++ b/server/test/browser/library-access.test.js @@ -20,7 +20,18 @@ before(async () => { }); after(async () => browser?.close()); -async function openPage(t, { width = 1440, signedIn = true, sessionStatus, holdSession } = {}) { +async function openPage( + t, + { + width = 1440, + signedIn = true, + sessionStatus, + holdSession, + actor = student, + folderData = folder, + moderationQueue = [], + } = {}, +) { const context = await browser.newContext({ viewport: { width, height: 900 }, locale: "en-US", @@ -89,21 +100,61 @@ async function openPage(t, { width = 1440, signedIn = true, sessionStatus, holdS if (url.pathname === "/api/user") { if (holdSession) await holdSession; status = sessionStatus ? sessionStatus() : hasSession ? 200 : 401; - data = status === 200 ? student : { message: "Unable to restore session" }; + data = status === 200 ? actor : { message: "Unable to restore session" }; } else if (!hasSession) { status = 401; data = { message: "Sign in to continue" }; - } else if (url.pathname === "/api/course/CS101") data = { found: true, ...course }; - else if (url.pathname.startsWith("/api/folder/content/")) data = folder; + } else if (url.pathname === "/api/course/CS101") + data = { + found: true, + ...course, + children: [ + { + ...course.children[0], + children: [folderData], + totalFileCount: folderData.children.length, + }, + ], + }; + else if (url.pathname.startsWith("/api/folder/content/")) data = folderData; + else if (url.pathname === `/api/files/thumbnail/${libraryFile._id}`) { + assert.ok(hasSession); + return route.fulfill({ + contentType: "image/gif", + body: Buffer.from( + "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + "base64", + ), + }); + } else if (url.pathname === "/api/contribution/br") + data = { unverifiedContributions: moderationQueue }; + else if (url.pathname.startsWith("/api/files/verify/")) + data = { file: { ...libraryFile, isVerified: true } }; else if (url.pathname === "/api/contribution/") { data = request.method() === "POST" - ? { created: true, data: JSON.parse(request.postData()) } + ? { + created: true, + data: { + ...JSON.parse(request.postData()), + contributionId: "server-issued-contribution", + }, + } : []; } else if (url.pathname === "/api/contribution/upload") data = libraryFile._id; else if (url.pathname === "/api/files/download") - data = { downloadLink: "https://download.example.test/notes" }; - else if (url.pathname === "/api/event/examdates") + data = { downloadLink: `/api/files/content/${libraryFile._id}?download=1` }; + else if (url.pathname === `/api/files/content/${libraryFile._id}`) { + assert.ok(hasSession); + return route.fulfill({ + contentType: "application/pdf", + body: "%PDF-1.4\nNotes\n%%EOF", + headers: { + "access-control-allow-origin": frontend, + "access-control-allow-credentials": "true", + }, + }); + } else if (url.pathname === "/api/event/examdates") data = { dates: { midSem: "2026-09-15", endSem: "2026-11-25" } }; else if (url.pathname === "/api/search") data = { found: true, results: [course] }; else if (url.pathname === "/api/user/favourites") data = []; @@ -175,7 +226,7 @@ test("a failed session check can be retried without losing the requested folder" .waitFor(); const retry = page.getByRole("button", { name: "Try again" }); await page.keyboard.press("Tab"); - assert.equal(await retry.evaluate(button => button === document.activeElement), true); + assert.equal(await retry.evaluate((button) => button === document.activeElement), true); await page.keyboard.press("Enter"); await page.getByTitle("Lecture notes.pdf", { exact: true }).first().waitFor(); assert.equal(new URL(page.url()).pathname, `/browse/CS101/${folder._id}`); @@ -198,21 +249,24 @@ test("FilePond sends credentials and the contribution association across origins const uploaded = requests.find((request) => request.path === "/api/contribution/upload"); assert.ok(created?.hasSession && uploaded?.hasSession); const manifest = JSON.parse(created.body); - assert.equal(uploaded.headers["contribution-id"], manifest.contributionId); - assert.equal(manifest.uploadedBy, student._id); + assert.equal(uploaded.headers["contribution-id"], "server-issued-contribution"); + assert.equal(manifest.contributionId, undefined); + assert.equal(manifest.uploadedBy, undefined); + assert.equal(manifest.approved, undefined); + assert.equal(uploaded.headers.username, undefined); assert.ok(uploaded.headers["content-type"].startsWith("multipart/form-data")); assert.ok(uploaded.body.includes("Test notes")); }); -test("individual and ZIP download requests include the student cookie only on API requests", async (t) => { +test("individual and ZIP download requests use authorized resource IDs and credentials", async (t) => { const { page, requests } = await openPage(t); await page.goto(`${frontend}/browse/CS101/${folder._id}`); await page.getByTitle("Lecture notes.pdf", { exact: true }).first().waitFor(); const link = await page.evaluate(async () => { const { getFileDownloadLink } = await import("/src/api/File.js"); - return getFileDownloadLink("https://example.test/notes"); + return getFileDownloadLink("507f1f77bcf86cd799439021", "CS101"); }); - assert.equal(link, "https://download.example.test/notes"); + assert.equal(link, `${api}/api/files/content/${libraryFile._id}?download=1`); const saved = page.waitForEvent("download"); await page.getByTitle("Download entire folder as ZIP", { exact: true }).click(); assert.match((await saved).suggestedFilename(), /\.zip$/); @@ -229,3 +283,146 @@ for (const width of [1440, 390]) { assert.equal(new URL(page.url()).pathname, "/profile"); }); } + +for (const width of [1440, 390]) { + test(`owners can see their pending files with an approval label at ${width}px`, async (t) => { + const pending = { + ...libraryFile, + _id: "507f1f77bcf86cd799439022", + name: "Awaiting review.pdf", + isVerified: false, + capabilities: { canManage: false }, + }; + const { page } = await openPage(t, { + width, + folderData: { ...folder, children: [libraryFile, pending], totalFileCount: 2 }, + }); + await page.goto(frontend + `/browse/CS101/${folder._id}`); + await page.getByTitle("Awaiting review.pdf", { exact: true }).first().waitFor(); + await page.getByText("Pending approval", { exact: true }).waitFor(); + assert.equal(await page.locator(".file-display .unverify").count(), 0); + }); +} +test("resource capabilities hide management controls despite a stale BR flag and editable course list", async (t) => { + const actor = { + ...student, + isBR: true, + capabilities: { canManageCourses: [], canContributeCourses: [] }, + }; + const { page } = await openPage(t, { + actor, + folderData: { ...folder, capabilities: { canManage: false, canContribute: false } }, + }); + await page.goto(frontend + `/browse/CS101/${folder._id}`); + await page.getByTitle(libraryFile.name, { exact: true }).first().waitFor(); + assert.equal( + await page + .locator(".file-display .unverify, .file-display .verify, .file-display .rename-tick") + .count(), + 0, + ); + assert.equal(await page.getByRole("button", { name: /Contribute|Add File/ }).count(), 0); +}); +test("shared file deletion identifies every affected course before confirmation", async (t) => { + const file = { + ...libraryFile, + capabilities: { canManage: true }, + affectedCourses: ["CS101", "MA101"], + }; + const { page, requests } = await openPage(t, { + actor: { ...student, isBR: true }, + folderData: { + ...folder, + children: [file], + capabilities: { canManage: true, canContribute: true }, + }, + }); + await page.goto(frontend + `/browse/CS101/${folder._id}`); + await page.getByTitle("Delete", { exact: true }).click(); + await page + .getByText("Deleting it removes it from every listed course.", { exact: false }) + .waitFor(); + assert.ok((await page.locator("body").innerText()).includes("CS101, MA101")); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + assert.ok(requests.every((request) => request.method !== "DELETE")); +}); +test("restoring a session discards stored file trees from an earlier actor", async (t) => { + const { page, requests } = await openPage(t); + await page.addInitScript( + (tree) => sessionStorage.setItem("AllCourses", JSON.stringify([tree])), + { + ...course, + children: [ + { + ...course.children[0], + children: [ + { + ...folder, + children: [ + { + ...libraryFile, + name: "Private cached file.pdf", + isVerified: false, + }, + ], + }, + ], + }, + ], + }, + ); + await page.goto(frontend + `/browse/CS101/${folder._id}`); + await page.getByTitle(libraryFile.name, { exact: true }).first().waitFor(); + assert.equal(await page.getByTitle("Private cached file.pdf", { exact: true }).count(), 0); + assert.ok(requests.some((request) => request.path === "/api/course/CS101")); +}); + +test("moderation uses the authorized shared-course context returned by the server", async (t) => { + const pending = { + ...libraryFile, + isVerified: false, + capabilities: { canManage: true }, + affectedCourses: ["CS101", "MA101"], + }; + const { page, requests } = await openPage(t, { + actor: { + ...student, + isBR: true, + capabilities: { canManageCourses: ["CS101"], canContributeCourses: ["CS101"] }, + }, + moderationQueue: [ + { + contributionId: "shared-review", + courseCode: "MA101", + managementCourseCode: "CS101", + parentFolder: folder._id, + updatedAt: "2026-09-08T06:30:00Z", + files: [pending], + }, + ], + }); + await page.goto(frontend + "/profile"); + await page.getByText("APPROVE", { exact: true }).click(); + await page.getByRole("button", { name: "Verify", exact: true }).click(); + await page.getByText("NO PENDING CONTRIBUTIONS", { exact: true }).waitFor(); + const action = requests.find((request) => request.path.startsWith("/api/files/verify/")); + assert.equal(action.method, "PUT"); + assert.equal(JSON.parse(action.body).courseCode, "CS101"); + assert.ok(action.hasSession); +}); + +test("thumbnail backgrounds send the session cookie to the resource-ID endpoint", async (t) => { + const thumbnailPath = `/api/files/thumbnail/${libraryFile._id}`; + const { page, requests } = await openPage(t, { + folderData: { + ...folder, + children: [{ ...libraryFile, thumbnail: { url: thumbnailPath } }], + }, + }); + const loaded = page.waitForResponse( + (response) => new URL(response.url()).pathname === thumbnailPath, + ); + await page.goto(frontend + `/browse/CS101/${folder._id}`); + assert.equal((await loaded).status(), 200); + assert.ok(requests.find((request) => request.path === thumbnailPath)?.hasSession); +}); diff --git a/server/test/fixtures/library.js b/server/test/fixtures/library.js index 874a26ce..78a34811 100644 --- a/server/test/fixtures/library.js +++ b/server/test/fixtures/library.js @@ -7,6 +7,7 @@ export const student = { department: "Computer Science and Engineering", semester: 5, isBR: false, + capabilities: { canManageCourses: [], canContributeCourses: ["CS101"] }, courses: [{ code: "CS101", name: "Introduction to Computer Science" }], previousCourses: [], readOnly: [], @@ -24,6 +25,7 @@ export const libraryFile = { thumbnail: { url: "https://ik.imagekit.io/coursehub-test/notes.webp" }, }; export const folder = { + capabilities: { canManage: false, canContribute: true }, _id: "507f1f77bcf86cd799439031", name: "Lecture Notes", courses: ["CS101"], diff --git a/server/test/fixtures/permissions.js b/server/test/fixtures/permissions.js new file mode 100644 index 00000000..8900f0ad --- /dev/null +++ b/server/test/fixtures/permissions.js @@ -0,0 +1,101 @@ +import User from "../../modules/user/user.model.js"; +import UserUpdate from "../../modules/user/userUpdate.model.js"; +import BR from "../../modules/br/br.model.js"; +import CourseAllotment from "../../modules/course/courseAllotment.model.js"; +import Course, { FolderModel, FileModel } from "../../modules/course/course.model.js"; +import Contribution from "../../modules/contribution/contribution.model.js"; +import { academicPeriod } from "../../services/authorization.js"; +import { student } from "./library.js"; + +export async function permissionFixtures() { + const period = academicPeriod(); + const actors = {}; + for (const [index, role] of [ + "owner", + "student", + "currentBR", + "historicalBR", + "unrelatedBR", + "revokedBR", + "unallottedBR", + ].entries()) { + const { _id, capabilities, ...base } = student; + const person = await User.create({ + ...base, + name: `Permission ${role}`, + email: `${role.toLowerCase()}@permissions.example.test`, + rollNumber: 299100000 + index, + isBR: role === "revokedBR" || role === "unrelatedBR", + courses: [{ code: "AUTH101", name: "Editable course list grants no permission" }], + previousCourses: [ + { semester: 1, year: period.year - 1, courses: [{ code: "AUTH101" }] }, + ], + readOnly: [{ code: "AUTH301", name: "Others course" }], + }); + await UserUpdate.create({ rollNumber: person.rollNumber }); + if (["currentBR", "historicalBR", "unrelatedBR", "unallottedBR"].includes(role)) + await BR.create({ email: person.email.toUpperCase() }); + if (role !== "unallottedBR") + await CourseAllotment.create({ + rollNumber: person.rollNumber, + ...period, + year: role === "historicalBR" ? period.year - 1 : period.year, + courses: [role === "unrelatedBR" ? "AUTH201" : " auth101 "], + }); + actors[role] = { + person, + headers: { + cookie: `token=${person.generateJWT()}`, + "content-type": "application/json", + }, + }; + } + const file = async (name, isVerified = false) => + FileModel.create({ + name, + fileId: `provider-${name}`, + size: "128", + isVerified, + webUrl: "https://provider.example.test/private", + downloadUrl: "https://provider.example.test/private?download=1", + thumbnail: { url: "https://ik.imagekit.io/coursehub-test/private.webp" }, + }); + const approved = await file("approved.pdf", true), + pending = await file("pending.pdf"), + foreign = await file("foreign.pdf"); + const leaf = await FolderModel.create({ + name: "Shared notes", + courses: ["AUTH101", "AUTH301"], + childType: "File", + children: [approved._id, pending._id], + totalFileCount: 999, + }); + const root = await FolderModel.create({ + name: "2026", + courses: ["AUTH101", "AUTH301"], + childType: "Folder", + children: [leaf._id], + totalFileCount: 999, + }); + const foreignLeaf = await FolderModel.create({ + name: "Unrelated notes", + courses: ["AUTH201"], + childType: "File", + children: [foreign._id], + }); + for (const code of ["AUTH101", "AUTH301", "AUTH201"]) + await Course.create({ + code, + name: `Permission fixture ${code}`, + children: [code === "AUTH201" ? foreignLeaf._id : root._id], + }); + const contribution = await Contribution.create({ + contributionId: "permission-owned", + uploadedBy: actors.owner.person.id, + courseCode: "AUTH101", + parentFolder: leaf._id, + files: [pending._id], + approved: false, + }); + return { actors, approved, pending, foreign, leaf, root, foreignLeaf, contribution }; +} diff --git a/server/test/integration/server.test.js b/server/test/integration/server.test.js index 8800bbcf..40f9953c 100644 --- a/server/test/integration/server.test.js +++ b/server/test/integration/server.test.js @@ -10,6 +10,9 @@ import fs from "node:fs"; import path from "node:path"; import axios from "axios"; import User from "../../modules/user/user.model.js"; +import { Readable } from "node:stream"; +import CourseAllotment from "../../modules/course/courseAllotment.model.js"; +import { academicPeriod } from "../../services/authorization.js"; import UserUpdate from "../../modules/user/userUpdate.model.js"; import Course, { FolderModel, FileModel } from "../../modules/course/course.model.js"; import Contribution from "../../modules/contribution/contribution.model.js"; @@ -101,7 +104,12 @@ test("provisioned password hashes work with the existing administrator login", a const headers = { cookie: response.headers.get("set-cookie").split(";")[0] }; const session = await fetch(origin + "/api/admin/", { headers }); assert.equal(session.status, 200); - assert.deepEqual(await session.json(), { user: { userId: "login-fixture" } }); + assert.deepEqual(await session.json(), { + user: { + userId: "login-fixture", + capabilities: { canManageAllCourses: true, canModerate: true }, + }, + }); const courses = await fetch(origin + "/api/admin/dbcourses", { headers }); assert.equal(courses.status, 200); assert.deepEqual(await courses.json(), []); @@ -213,6 +221,11 @@ test("the database suite leaves a nonempty test target intact", async () => { test("authenticated browsing and multipart upload persist content with mocked Graph storage", async (t) => { const person = await User.create(student); + await CourseAllotment.create({ + rollNumber: person.rollNumber, + ...academicPeriod(), + courses: ["CS101"], + }); await UserUpdate.create({ rollNumber: person.rollNumber }); await FolderModel.create({ ...folder, children: [] }); await FolderModel.create({ ...year, children: [folder._id] }); @@ -233,21 +246,19 @@ test("authenticated browsing and multipart upload persist content with mocked Gr const unsigned = await fetch(origin + "/api/course/CS101"); assert.equal(unsigned.status, 401); - const contributionId = randomUUID(); const created = await fetch(origin + "/api/contribution", { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify({ - contributionId, - uploadedBy: person.id, courseCode: "CS101", parentFolder: folder._id, - approved: false, description: "Test upload", }), }); assert.equal(created.status, 200); - assert.equal((await created.json()).created, true); + const createdData = await created.json(); + assert.equal(createdData.created, true); + const contributionId = createdData.data.contributionId; clearAccessTokenCache(); t.after(clearAccessTokenCache); @@ -279,6 +290,7 @@ test("authenticated browsing and multipart upload persist content with mocked Gr assert.fail(`Unexpected mock Graph POST: ${url}`); }); const contents = Buffer.from("%PDF-1.4\nSynthetic upload test\n%%EOF\n"); + let uploadedCount = 0; t.mock.method(axios, "put", async (url, bytes, config) => { calls.push(url); assert.equal(url, "https://upload.example.test/session"); @@ -287,14 +299,20 @@ test("authenticated browsing and multipart upload persist content with mocked Gr config.headers["Content-Range"], `bytes 0-${contents.length - 1}/${contents.length}`, ); - return { data: { id: "test-uploaded-drive-file", size: contents.length } }; + return { + data: { + id: uploadedCount++ ? "second-uploaded-drive-file" : "test-uploaded-drive-file", + size: contents.length, + }, + }; }); t.mock.method(axios, "get", async (url) => { calls.push(url); if (url.endsWith("/thumbnails")) return { data: { value: [] } }; - if (url.endsWith("/test-uploaded-drive-file")) + if (url.endsWith("/test-uploaded-drive-file/content")) return { - data: { "@microsoft.graph.downloadUrl": "https://download.example.test/file" }, + data: Readable.from([contents]), + headers: { "content-type": "application/pdf" }, }; assert.fail(`Unexpected mock Graph GET: ${url}`); }); @@ -334,12 +352,32 @@ test("authenticated browsing and multipart upload persist content with mocked Gr assert.equal(temporaryPaths.length, 1); assert.ok(temporaryPaths.every((file) => !fs.existsSync(file))); assert.equal(fs.existsSync(renamedPath), false); - const download = await fetch(origin + "/api/file/download/test-uploaded-drive-file", { + const download = await fetch(origin + `/api/files/content/${id}`, { headers, }); assert.equal(download.status, 200); - assert.deepEqual(await download.json(), { url: "https://download.example.test/file" }); - assert.equal(calls.filter((url) => url === "https://upload.example.test/session").length, 1); + assert.deepEqual(Buffer.from(await download.arrayBuffer()), contents); + assert.equal(saved.name, filename.replace(".pdf", "~Library Test Student.pdf")); + const secondForm = new FormData(); + secondForm.append("file", new Blob([contents], { type: "application/pdf" }), filename); + const secondUpload = await fetch(origin + "/api/contribution/upload", { + method: "POST", + headers: { ...headers, "contribution-id": contributionId }, + body: secondForm, + }); + assert.equal(secondUpload.status, 200); + const second = await FileModel.findById(await secondUpload.text()); + assert.equal(second.name, saved.name); + assert.notEqual(second.fileId, saved.fileId); + const sessions = calls.filter((url) => url.endsWith("/createUploadSession")); + assert.equal(sessions.length, 2); + assert.equal( + new Set(sessions).size, + 2, + "Equal display names must target different storage paths", + ); + assert.ok(temporaryPaths.every((file) => !fs.existsSync(file))); + assert.equal(calls.filter((url) => url === "https://upload.example.test/session").length, 2); }); test("an administrator session can explicitly create a course", async () => { @@ -367,3 +405,7 @@ test("an administrator session can explicitly create a course", async () => { assert.equal(response.status, 200); assert.deepEqual((await response.json()).children, []); }); + +import { exerciseCoursePermissions } from "../support/course-permissions.js"; +test("course permission and moderation boundaries", async (t) => + exerciseCoursePermissions(t, origin)); diff --git a/server/test/library-authentication.test.js b/server/test/library-authentication.test.js index 6ff03000..4b28a679 100644 --- a/server/test/library-authentication.test.js +++ b/server/test/library-authentication.test.js @@ -10,11 +10,13 @@ import { guardExternalServices } from "./support/provider-guards.js"; import { app } from "../index.js"; import User from "../modules/user/user.model.js"; import Admin from "../modules/admin/admin.model.js"; -import Course, { FileModel } from "../modules/course/course.model.js"; +import Course, { FileModel, FolderModel } from "../modules/course/course.model.js"; import CourseAllotment from "../modules/course/courseAllotment.model.js"; +import BR from "../modules/br/br.model.js"; +import { academicPeriod } from "../services/authorization.js"; import Contribution from "../modules/contribution/contribution.model.js"; import { upload } from "../modules/contribution/contribution.routes.js"; -import { student, administrator, course, libraryFile } from "./fixtures/library.js"; +import { student, administrator, course, year, folder, libraryFile } from "./fixtures/library.js"; beforeEach(guardExternalServices); @@ -34,6 +36,9 @@ const studentToken = (options = {}) => jwt.sign({ user: student._id }, process.env.JWT_SECRET, options); const adminToken = () => jwt.sign(administrator._id, process.env.ADMIN_JWT_SECRET); const query = (value) => ({ + collation() { + return this; + }, select() { return this; }, @@ -48,6 +53,15 @@ const query = (value) => ({ }, }); function signedIn(t, actor = student) { + t.mock.method(BR, "findOne", () => query(null)); + t.mock.method(CourseAllotment, "find", () => + query([{ ...academicPeriod(), courses: ["CS101"] }]), + ); + t.mock.method(FolderModel, "find", () => query([year, folder])); + t.mock.method(FileModel, "find", () => query([libraryFile])); + t.mock.method(FileModel, "findById", async () => libraryFile); + t.mock.method(Contribution, "find", () => query([])); + t.mock.method(Course, "find", () => query([course])); t.mock.method(User, "findOne", async ({ _id }) => (_id === actor._id ? actor : null)); t.mock.method(Admin, "findById", async (id) => id === administrator._id ? administrator : null, @@ -110,7 +124,6 @@ for (const [prefix, module] of [ ["admin", "admin"], ["br", "br"], ["files", "file"], - ["file", "onedrive"], ["folder", "folder"], ["year", "year"], ["student", "student"], @@ -266,16 +279,13 @@ test("malformed signed identities do not reach a database query", async (t) => { assert.deepEqual(attempts, []); }); -test("signed-in search, thumbnail and contribution listing still work", async (t) => { +test("signed-in search and contribution listing still work", async (t) => { signedIn(t); t.mock.method(Course, "find", () => query([course])); t.mock.method(FileModel, "findOne", () => query(libraryFile)); t.mock.method(Contribution, "find", () => query([])); const headers = { cookie: `token=${studentToken()}`, "content-type": "application/json" }; - for (const [path, body] of [ - ["/api/search", { words: ["CS101"] }], - ["/api/file/thumbnail", { fileId: libraryFile.fileId }], - ]) { + for (const [path, body] of [["/api/search", { words: ["CS101"] }]]) { const response = await fetch(origin + path, { method: "POST", headers, @@ -324,3 +334,18 @@ test("course refresh derives its target from the authenticated student", async ( } assert.equal(lookups.length, before); }); + +test("academic permissions use the current India calendar at the semester boundary", () => { + assert.deepEqual(academicPeriod(new Date("2026-07-23T18:29:59Z")), { + year: 2026, + session: "Jan-May", + }); + assert.deepEqual(academicPeriod(new Date("2026-07-23T18:30:00Z")), { + year: 2026, + session: "July-Nov", + }); + assert.deepEqual(academicPeriod(new Date("2026-12-31T18:30:00Z")), { + year: 2027, + session: "Jan-May", + }); +}); diff --git a/server/test/support/course-permissions.js b/server/test/support/course-permissions.js new file mode 100644 index 00000000..8a7159d0 --- /dev/null +++ b/server/test/support/course-permissions.js @@ -0,0 +1,659 @@ +import getImageKit from "../../services/imagekit.js"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { Readable } from "node:stream"; +import axios from "axios"; +import jwt from "jsonwebtoken"; +import Course, { FolderModel, FileModel } from "../../modules/course/course.model.js"; +import BR from "../../modules/br/br.model.js"; +import CourseAllotment from "../../modules/course/courseAllotment.model.js"; +import Contribution from "../../modules/contribution/contribution.model.js"; +import Admin from "../../modules/admin/admin.model.js"; +import { upload } from "../../modules/contribution/contribution.routes.js"; +import { permissionFixtures } from "../fixtures/permissions.js"; +import { clearAccessTokenCache } from "../../modules/onedrive/onedrive.controller.js"; +import { academicPeriod } from "../../services/authorization.js"; + +export async function exerciseCoursePermissions(t, origin) { + const f = await permissionFixtures(); + const admin = await Admin.findOne(); + f.actors.admin = { + person: admin, + headers: { + cookie: `adminToken=${jwt.sign(admin.id, process.env.ADMIN_JWT_SECRET)}`, + "content-type": "application/json", + }, + }; + const request = async (actor, method, route, body, status = 200) => { + const response = await fetch(origin + route, { + method, + headers: f.actors[actor].headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(5000), + }); + assert.equal( + response.status, + status, + `${actor} ${method} ${route}: ${await response.clone().text()}`, + ); + return response; + }; + const providerCalls = []; + const deletedThumbnails = []; + process.env.IMAGEKIT_PUBLIC_KEY = "fake-test-public-key"; + process.env.IMAGEKIT_PRIVATE_KEY = "fake-test-private-key"; + clearAccessTokenCache(); + t.after(clearAccessTokenCache); + t.beforeEach((sub) => { + sub.mock.method(getImageKit().files, "delete", async (id) => { + deletedThumbnails.push(id); + }); + for (const method of ["existsSync", "readFileSync", "writeFileSync"]) { + const original = fs[method]; + sub.mock.method(fs, method, (name, ...args) => + String(name).endsWith(".token") + ? method === "existsSync" + ? true + : method === "readFileSync" + ? "fake-refresh" + : undefined + : original(name, ...args), + ); + } + sub.mock.method(axios, "post", async (url) => { + providerCalls.push(url); + assert.ok(url.startsWith("https://login.microsoftonline.com/")); + return { data: { access_token: "fake-access", expires_in: 3600 } }; + }); + sub.mock.method(axios, "get", async (url) => { + providerCalls.push(url); + assert.ok( + url.startsWith("https://graph.microsoft.com/v1.0/me/drive/items/") || + url === f.pending.thumbnail.url, + ); + return { + data: Readable.from([ + url === f.pending.thumbnail.url + ? "image-fixture" + : "%PDF-1.4 permission document", + ]), + headers: { + "content-type": + url === f.pending.thumbnail.url ? "image/webp" : "application/pdf", + }, + }; + }); + sub.mock.method(axios, "delete", async (url) => { + providerCalls.push(url); + return { status: 204 }; + }); + sub.mock.method(axios, "patch", async (url, body) => { + providerCalls.push(url); + return { data: { name: body.name } }; + }); + }); + + for (const role of Object.keys(f.actors)) + await t.test( + `${role}: pending visibility across trees, counts, metadata, content, thumbnails and archive inputs`, + async () => { + const seesPending = ["owner", "currentBR", "historicalBR", "admin"].includes(role); + const canManage = ["currentBR", "historicalBR", "admin"].includes(role); + const tree = await (await request(role, "GET", "/api/course/AUTH101")).json(); + const content = await ( + await request( + role, + "GET", + `/api/folder/content/${f.leaf.id}?courseCode=AUTH101`, + ) + ).json(); + assert.equal(tree.capabilities.canManage, canManage); + assert.equal(content.totalFileCount, seesPending ? 2 : 1); + assert.equal(tree.children[0].totalFileCount, seesPending ? 2 : 1); + assert.deepEqual( + content.children.map((file) => file._id).sort(), + [f.approved.id, ...(seesPending ? [f.pending.id] : [])].sort(), + ); + assert.equal(JSON.stringify(tree).includes("provider.example.test"), false); + assert.equal(JSON.stringify(tree).includes("private.webp"), false); + assert.equal(JSON.stringify(tree).includes(f.pending.fileId), false); + const all = await (await request(role, "GET", "/api/files/all")).json(); + assert.equal( + all.some((file) => file._id === f.pending.id), + seesPending, + ); + for (const [method, path, body] of [ + ["GET", `/api/files/link/${f.pending.id}`], + [ + "POST", + "/api/files/download", + { fileId: f.pending.id, courseCode: "AUTH101" }, + ], + ["GET", `/api/files/content/${f.pending.id}`], + ["GET", `/api/files/preview/${f.pending.id}`], + ["GET", `/api/files/thumbnail/${f.pending.id}`], + ["HEAD", `/api/files/content/${f.pending.id}`], + ["HEAD", `/api/files/thumbnail/${f.pending.id}`], + ]) { + const before = providerCalls.length; + const response = await request( + role, + method, + path, + body, + seesPending ? 200 : 404, + ); + await response.arrayBuffer(); + if (!seesPending) assert.equal(providerCalls.length, before); + } + // Approved files remain available even outside registration. + await request(role, "GET", `/api/files/link/${f.approved.id}`); + }, + ); + await t.test( + "BR status comes from the registry and registered permissions come from allotments", + async () => { + for (const role of Object.keys(f.actors).filter((r) => r !== "admin")) { + const user = await (await request(role, "GET", "/api/user")).json(); + assert.equal( + user.isBR, + ["currentBR", "historicalBR", "unrelatedBR", "unallottedBR"].includes(role), + ); + assert.equal( + user.capabilities.canManageCourses.includes("AUTH101"), + ["currentBR", "historicalBR"].includes(role), + ); + assert.equal(user.capabilities.canManageCourses.includes("AUTH301"), false); + } + // A future allotment cannot give management access now. + await CourseAllotment.create({ + rollNumber: f.actors.unallottedBR.person.rollNumber, + ...academicPeriod(), + year: academicPeriod().year + 1, + courses: ["AUTH101"], + }); + const user = await (await request("unallottedBR", "GET", "/api/user")).json(); + assert.deepEqual(user.capabilities.canManageCourses, []); + }, + ); + await t.test( + "ordinary, unrelated, revoked and unallotted actors cannot mutate course resources", + async () => { + const originalFile = JSON.stringify(await FileModel.findById(f.pending.id).lean()); + const originalFolder = JSON.stringify(await FolderModel.findById(f.leaf.id).lean()); + const mutations = [ + ["PUT", `/api/files/verify/${f.pending.id}`, { courseCode: "AUTH101" }], + [ + "DELETE", + `/api/files/unverify/${f.pending.id}`, + { courseCode: "AUTH101", oneDriveId: f.foreign.fileId }, + ], + [ + "PUT", + `/api/files/rename/${f.pending.id}`, + { courseCode: "AUTH101", newName: "forged" }, + ], + [ + "POST", + "/api/folder/create", + { + course: "AUTH101", + parentFolder: f.root.id, + name: "forged", + childType: "File", + }, + ], + [ + "POST", + "/api/folder/rename", + { courseCode: "AUTH101", folderId: f.leaf.id, newName: "forged" }, + ], + ["DELETE", "/api/folder/delete", { courseCode: "AUTH101", folderId: f.leaf.id }], + ["POST", "/api/year", { course: "AUTH101", name: "2030" }], + ["DELETE", "/api/year/delete", { courseCode: "AUTH101", folderId: f.root.id }], + ]; + const before = providerCalls.length; + for (const role of ["owner", "student", "unrelatedBR", "revokedBR", "unallottedBR"]) + for (const [method, route, body] of mutations) + await request(role, method, route, body, 403); + assert.equal(providerCalls.length, before); + assert.equal( + JSON.stringify(await FileModel.findById(f.pending.id).lean()), + originalFile, + ); + assert.equal( + JSON.stringify(await FolderModel.findById(f.leaf.id).lean()), + originalFolder, + ); + }, + ); + await t.test( + "forged resource IDs and foreign contexts fail before provider or database mutations", + async () => { + const before = providerCalls.length; + for (const role of ["currentBR", "historicalBR", "admin"]) { + await request( + role, + "PUT", + `/api/files/verify/${f.foreign.id}`, + { courseCode: "AUTH101" }, + 404, + ); + await request( + role, + "PUT", + `/api/files/rename/${f.foreign.id}`, + { courseCode: "AUTH101", newName: "forged" }, + 404, + ); + await request( + role, + "DELETE", + `/api/files/unverify/${f.foreign.id}`, + { courseCode: "AUTH101" }, + 404, + ); + await request( + role, + "POST", + "/api/folder/create", + { + course: "AUTH101", + parentFolder: f.foreignLeaf.id, + name: "forged", + childType: "File", + }, + 404, + ); + await request( + role, + "POST", + "/api/folder/rename", + { folderId: f.foreignLeaf.id, courseCode: "AUTH101", newName: "forged" }, + 404, + ); + await request( + role, + "DELETE", + "/api/year/delete", + { folderId: f.foreignLeaf.id, courseCode: "AUTH101" }, + 404, + ); + await request( + role, + "DELETE", + "/api/folder/delete", + { folderId: { $ne: null }, courseCode: "AUTH101" }, + 404, + ); + await request( + role, + "GET", + `/api/folder/content/${f.foreignLeaf.id}?courseCode=AUTH101`, + undefined, + 404, + ); + await request( + role, + "POST", + "/api/files/download", + { fileId: { $ne: null }, courseCode: "AUTH101" }, + 404, + ); + } + assert.equal(providerCalls.length, before); + assert.equal((await FileModel.findById(f.foreign.id)).isVerified, false); + }, + ); + await t.test( + "contribution identity and approval are generated by the server; Others grants no upload permission", + async () => { + const input = { + parentFolder: f.leaf.id, + courseCode: "AUTH101", + description: "matrix upload", + }; + for (const [key, value] of [ + ["uploadedBy", f.actors.student.person.id], + ["approved", true], + ["contributionId", f.contribution.contributionId], + ]) + await request( + "owner", + "POST", + "/api/contribution", + { ...input, [key]: value }, + 400, + ); + for (const role of [ + "owner", + "student", + "currentBR", + "historicalBR", + "revokedBR", + "admin", + ]) { + const { data } = await ( + await request(role, "POST", "/api/contribution", input) + ).json(); + assert.equal(data.uploadedBy, f.actors[role].person.id); + assert.match(data.contributionId, /^[0-9a-f-]{36}$/); + assert.equal(data.approved, ["currentBR", "historicalBR", "admin"].includes(role)); + } + for (const role of ["unrelatedBR", "unallottedBR"]) + await request(role, "POST", "/api/contribution", input, 403); + await request( + "currentBR", + "POST", + "/api/contribution", + { ...input, courseCode: "AUTH301" }, + 403, + ); + await request( + "owner", + "POST", + "/api/contribution", + { ...input, parentFolder: f.foreignLeaf.id }, + 404, + ); + await request( + "owner", + "POST", + "/api/contribution", + { ...input, parentFolder: f.root.id }, + 400, + ); + // A historical student without BR status cannot contribute. + await BR.deleteOne({ email: f.actors.historicalBR.person.email.toUpperCase() }); + await request("historicalBR", "POST", "/api/contribution", input, 403); + await BR.create({ email: f.actors.historicalBR.person.email }); + }, + ); + await t.test("uploads must own a persisted contribution before multipart writes", async () => { + const storage = t.mock.method(upload.storage, "_handleFile", () => + assert.fail("Forbidden multipart storage"), + ); + for (const [role, id] of [ + ["student", f.contribution.contributionId], + ["owner", "unknown-id"], + ]) { + const form = new FormData(); + form.append("file", new Blob(["forged"]), "notes.pdf"); + const response = await fetch(origin + "/api/contribution/upload", { + method: "POST", + headers: { + cookie: f.actors[role].headers.cookie, + "contribution-id": id, + username: "forged-uploader", + }, + body: form, + }); + assert.equal(response.status, 404); + } + assert.equal(storage.mock.callCount(), 0); + storage.mock.restore(); + }); + await t.test( + "moderation queues cannot be widened by editable course lists or foreign course filters", + async () => { + for (const role of ["currentBR", "historicalBR", "admin"]) { + const data = await (await request(role, "POST", "/api/contribution/br", {})).json(); + assert.ok( + data.unverifiedContributions.some( + (c) => c.contributionId === f.contribution.contributionId, + ), + ); + } + const unrelated = await ( + await request("unrelatedBR", "POST", "/api/contribution/br", {}) + ).json(); + assert.deepEqual(unrelated.unverifiedContributions, []); + await request( + "unrelatedBR", + "POST", + "/api/contribution/br", + { courses: [{ code: "AUTH101" }] }, + 403, + ); + for (const role of ["owner", "student", "revokedBR"]) + await request(role, "POST", "/api/contribution/br", {}, 403); + const own = await (await request("owner", "GET", "/api/contribution")).json(); + assert.ok(own.some((c) => c.files.some((file) => file._id === f.pending.id))); + const other = await (await request("student", "GET", "/api/contribution")).json(); + assert.ok(other.every((c) => c.files.every((file) => file._id !== f.pending.id))); + }, + ); + await t.test("shared submissions appear in each authorized moderation context", async () => { + const shared = await Contribution.create({ + contributionId: "shared-context-review", + uploadedBy: f.actors.owner.person.id, + courseCode: "AUTH301", + parentFolder: f.leaf.id, + files: [f.pending._id], + approved: false, + }); + const queue = await (await request("currentBR", "POST", "/api/contribution/br", {})).json(); + const item = queue.unverifiedContributions.find( + (c) => c.contributionId === shared.contributionId, + ); + assert.equal(item.courseCode, "AUTH301"); + assert.equal(item.managementCourseCode, "AUTH101"); + assert.equal(item.files[0].capabilities.canManage, true); + const dashboard = await ( + await request("admin", "GET", "/api/admin/course/AUTH101/dashboard") + ).json(); + assert.ok(dashboard.contributions.some((c) => c.contributionId === shared.contributionId)); + await request( + "admin", + "POST", + "/api/admin/contribution/action", + { contributionId: shared.contributionId, courseCode: "AUTH201", action: "approve" }, + 404, + ); + await request("admin", "POST", "/api/admin/contribution/action", { + contributionId: shared.contributionId, + courseCode: "AUTH101", + action: "approve", + }); + assert.equal((await FileModel.findById(f.pending.id)).isVerified, true); + await Contribution.deleteOne({ _id: shared._id }); + }); + await t.test( + "authorized BRs retain rename, moderation, year and folder management; shared descendants inherit membership", + async () => { + await request("historicalBR", "PUT", `/api/files/rename/${f.pending.id}`, { + courseCode: "AUTH101", + newName: "Renamed pending", + }); + assert.equal((await FileModel.findById(f.pending.id)).name, "Renamed pending.pdf"); + await request("currentBR", "PUT", `/api/files/verify/${f.pending.id}`, { + courseCode: "AUTH101", + }); + assert.equal((await Contribution.findById(f.contribution.id)).approved, true); + const created = await ( + await request("currentBR", "POST", "/api/folder/create", { + course: "AUTH101", + parentFolder: f.root.id, + name: "Shared descendant", + childType: "File", + }) + ).json(); + assert.deepEqual(created.courses.sort(), ["AUTH101", "AUTH301"]); + await request("currentBR", "POST", "/api/folder/rename", { + courseCode: "AUTH101", + folderId: created._id, + newName: "Renamed descendant", + }); + const otherCourse = await ( + await request("student", "GET", "/api/course/AUTH301") + ).json(); + assert.ok( + otherCourse.children[0].children.some((c) => c.name === "Renamed descendant"), + ); + const year = await ( + await request("historicalBR", "POST", "/api/year", { + course: "AUTH101", + name: "2030", + }) + ).json(); + await request("historicalBR", "DELETE", "/api/year/delete", { + courseCode: "AUTH101", + folderId: year._id, + }); + assert.equal(await FolderModel.findById(year._id), null); + }, + ); + await t.test("revocation takes effect on the next request with the same session", async () => { + await BR.deleteOne({ email: f.actors.currentBR.person.email.toUpperCase() }); + await request( + "currentBR", + "POST", + "/api/folder/rename", + { courseCode: "AUTH101", folderId: f.leaf.id, newName: "Denied" }, + 403, + ); + const user = await (await request("currentBR", "GET", "/api/user")).json(); + assert.equal(user.isBR, false); + assert.deepEqual(user.capabilities.canManageCourses, []); + await BR.create({ email: f.actors.currentBR.person.email }); + }); + await t.test( + "Office previews authorize before redirecting an existing provider link", + async () => { + const office = await FileModel.create({ + name: "Review.docx", + fileId: "provider-office", + size: "10", + isVerified: false, + webUrl: "https://tenant.sharepoint.com/:w:/fixture", + downloadUrl: "https://tenant.sharepoint.com/:w:/fixture", + }); + await FolderModel.updateOne({ _id: f.leaf.id }, { $push: { children: office._id } }); + const denied = await fetch(origin + `/api/files/preview/${office.id}`, { + headers: f.actors.student.headers, + redirect: "manual", + }); + assert.equal(denied.status, 404); + assert.equal(denied.headers.get("location"), null); + const allowed = await fetch(origin + `/api/files/preview/${office.id}`, { + headers: f.actors.currentBR.headers, + redirect: "manual", + }); + assert.equal(allowed.status, 302); + assert.equal(allowed.headers.get("location"), office.webUrl); + await FolderModel.updateOne({ _id: f.leaf.id }, { $pull: { children: office._id } }); + await FileModel.deleteOne({ _id: office._id }); + }, + ); + await t.test( + "uploading after BR revocation rechecks approval and ignores claimed uploader headers", + async (sub) => { + const { data } = await ( + await request("currentBR", "POST", "/api/contribution", { + courseCode: "AUTH101", + parentFolder: f.leaf.id, + description: "revocation test", + }) + ).json(); + assert.equal(data.approved, true); + await BR.deleteOne({ email: f.actors.currentBR.person.email }); + sub.after(async () => { + await BR.updateOne( + { email: f.actors.currentBR.person.email }, + { $setOnInsert: { email: f.actors.currentBR.person.email } }, + { upsert: true }, + ); + }); + sub.mock.method(axios, "post", async (url) => { + if (url.endsWith("/createUploadSession")) + return { data: { uploadUrl: "https://upload.example.test/revoked" } }; + if (url.endsWith("/createLink")) + return { data: { link: { webUrl: "https://tenant.sharepoint.com/revoked" } } }; + assert.fail("Unexpected upload provider POST"); + }); + sub.mock.method(axios, "put", async (url, bytes) => { + assert.equal(url, "https://upload.example.test/revoked"); + return { data: { id: "provider-revoked-upload", size: bytes.length } }; + }); + sub.mock.method(axios, "get", async (url) => { + assert.ok(url.endsWith("/thumbnails")); + return { data: { value: [] } }; + }); + const form = new FormData(); + form.append("file", new Blob(["revoked upload"]), "notes.pdf"); + const response = await fetch(origin + "/api/contribution/upload", { + method: "POST", + headers: { + cookie: f.actors.currentBR.headers.cookie, + "contribution-id": data.contributionId, + username: "Administrator", + approved: "true", + }, + body: form, + }); + assert.equal(response.status, 200, await response.clone().text()); + const fileId = await response.text(); + const file = await FileModel.findById(fileId); + assert.equal(file.isVerified, false); + assert.equal(file.name, "notes~Permission currentBR.pdf"); + assert.equal( + (await Contribution.findOne({ contributionId: data.contributionId })).approved, + false, + ); + await FolderModel.updateOne({ _id: f.leaf.id }, { $pull: { children: file._id } }); + await FileModel.deleteOne({ _id: file._id }); + await Contribution.deleteOne({ contributionId: data.contributionId }); + }, + ); + await t.test( + "shared removal unlinks only the active course and ignores forged descendants", + async () => { + await request("currentBR", "DELETE", "/api/folder/delete", { + courseCode: "AUTH101", + folderId: f.leaf.id, + folder: { _id: f.foreignLeaf.id, children: [f.foreign] }, + }); + assert.ok(await FileModel.findById(f.pending.id)); + await FileModel.updateOne( + { _id: f.pending.id }, + { $set: { "thumbnail.fileId": "owned-thumbnail" } }, + ); + assert.ok(await FileModel.findById(f.foreign.id)); + assert.equal( + (await (await request("student", "GET", "/api/course/AUTH101")).json()).children[0] + .totalFileCount, + 0, + ); + assert.equal( + (await (await request("student", "GET", "/api/course/AUTH301")).json()).children[0] + .totalFileCount, + 2, + ); + await request( + "currentBR", + "PUT", + `/api/files/verify/${f.pending.id}`, + { courseCode: "AUTH101" }, + 404, + ); + await request( + "admin", + "DELETE", + `/api/admin/node/file/${f.pending.id}`, + { courseCode: "AUTH101" }, + 404, + ); + await request("admin", "DELETE", `/api/admin/node/file/${f.pending.id}`, { + courseCode: "AUTH301", + }); + assert.equal(await FileModel.findById(f.pending.id), null); + assert.deepEqual(deletedThumbnails, ["owned-thumbnail"]); + assert.ok( + providerCalls.some((url) => + url.endsWith("/" + encodeURIComponent(f.pending.fileId)), + ), + ); + assert.ok(providerCalls.every((url) => !url.endsWith("/test-storage-root"))); + }, + ); +} diff --git a/server/utils/appError.js b/server/utils/appError.js index 43aa6a02..56a98df9 100644 --- a/server/utils/appError.js +++ b/server/utils/appError.js @@ -1,8 +1,8 @@ class AppError extends Error { - constructor(status, message) { - super(); - this.message = message; - this.status = status; - } + constructor(status, message) { + super(); + this.message = message; + this.status = status; + } } export default AppError; diff --git a/server/utils/catchAsync.js b/server/utils/catchAsync.js index 697fb046..ee44bdbb 100644 --- a/server/utils/catchAsync.js +++ b/server/utils/catchAsync.js @@ -1,7 +1,7 @@ function catchAsync(fn) { - return function (req, res, next) { - fn(req, res, next).catch((e) => next(e)); - }; + return function (req, res, next) { + fn(req, res, next).catch((e) => next(e)); + }; } export default catchAsync; diff --git a/server/utils/folder.js b/server/utils/folder.js index 80fb9f48..053011ab 100644 --- a/server/utils/folder.js +++ b/server/utils/folder.js @@ -1,5 +1,4 @@ import { FolderModel } from "../modules/course/course.model.js"; -import { normalizeCourseCode } from "./course.js"; import logger from "./logger.js"; /** @@ -29,68 +28,15 @@ export async function calculateFolderSubtreeCount(folderId) { await FolderModel.updateOne({ _id: folderId }, { $set: { totalFileCount: count } }); return count; } catch (err) { - logger.error("Folder subtree calculation failed", { error: err, attributes: { dependency: "mongodb", operation: "calculate-folder-subtree", outcome: "failure", retryable: false } }); + logger.error("Folder subtree calculation failed", { + error: err, + attributes: { + dependency: "mongodb", + operation: "calculate-folder-subtree", + outcome: "failure", + retryable: false, + }, + }); return 0; } } - -/** - * Recalculates subtree count for a parent folder and bubbles up to root folders. - */ -export async function recalculateParentFolderCounts(parentFolderId) { - if (!parentFolderId) return; - - try { - await calculateFolderSubtreeCount(parentFolderId); - - // Find any parent folder that has parentFolderId in its children - const parents = await FolderModel.find({ children: parentFolderId }); - for (const parent of parents) { - await recalculateParentFolderCounts(parent._id); - } - } catch (err) { - logger.error("Parent folder recalculation failed", { error: err, attributes: { dependency: "mongodb", operation: "recalculate-folder-counts", outcome: "failure", retryable: false } }); - } -} - -/** - * Attaches/computes totalFileCount on populated folder objects for API responses, - * optionally filtering child folders by courseCode. - */ -export function computePopulatedFolderSubtreeCount(folderObj, courseCodeFilter = null) { - if (!folderObj) return 0; - - const normalizedCode = courseCodeFilter ? normalizeCourseCode(courseCodeFilter) : null; - - if (folderObj.childType === "File") { - const count = Array.isArray(folderObj.children) ? folderObj.children.length : 0; - folderObj.totalFileCount = count; - return count; - } - - if (folderObj.childType === "Folder") { - let total = 0; - if (Array.isArray(folderObj.children)) { - // Filter children if courseCodeFilter is active - if (normalizedCode) { - folderObj.children = folderObj.children.filter((child) => { - if (child && child.childType === "Folder") { - return child.courses && child.courses.includes(normalizedCode); - } - return true; - }); - } - - for (const child of folderObj.children) { - if (child && typeof child === "object") { - total += computePopulatedFolderSubtreeCount(child, courseCodeFilter); - } - } - } - folderObj.totalFileCount = total; - return total; - } - - folderObj.totalFileCount = folderObj.totalFileCount || 0; - return folderObj.totalFileCount; -} diff --git a/server/utils/graphError.js b/server/utils/graphError.js index dccf0960..b4908a5b 100644 --- a/server/utils/graphError.js +++ b/server/utils/graphError.js @@ -30,7 +30,7 @@ export function extractGraphErrorDetails(error) { payload?.message || response?.data?.error_description || error?.message || - "Microsoft Graph request failed" + "Microsoft Graph request failed", ); const requestId = innerError?.["request-id"] || @@ -68,7 +68,12 @@ export function logGraphError(logger, error, context = "Microsoft Graph request const details = error?.graphDetails || extractGraphErrorDetails(error); logger.error("Microsoft Graph request failed", { error, - attributes: { dependency: "microsoft-graph", operation: "request", outcome: "failure", retryable: true }, + attributes: { + dependency: "microsoft-graph", + operation: "request", + outcome: "failure", + retryable: true, + }, }); return details; } diff --git a/server/utils/logger.js b/server/utils/logger.js index fa52cdda..4e69eeba 100644 --- a/server/utils/logger.js +++ b/server/utils/logger.js @@ -18,9 +18,12 @@ export function readLoggingConfig(env = process.env) { const secret = env.OPS_LOG_INGEST_SECRET || PLACEHOLDER_SECRET; if (enabled && production) { const url = new URL(ingestionUrl); - if (url.protocol !== "https:") throw new TypeError("OPS_LOG_INGEST_URL must use HTTPS in production"); + if (url.protocol !== "https:") + throw new TypeError("OPS_LOG_INGEST_URL must use HTTPS in production"); if (secret.length < 32 || /placeholder|change[-_ ]?me|disabled/i.test(secret)) { - throw new TypeError("OPS_LOG_INGEST_SECRET must be a non-placeholder secret of at least 32 characters"); + throw new TypeError( + "OPS_LOG_INGEST_SECRET must be a non-placeholder secret of at least 32 characters", + ); } } return { enabled, ingestionUrl, secret }; From fdc95297288d1e89fe0cbc2ad8a54709655ae624 Mon Sep 17 00:00:00 2001 From: maydayv7 Date: Wed, 9 Sep 2026 04:12:36 +0530 Subject: [PATCH 04/20] fix: Handle request failures and harden auth sessions --- README.md | 1 + admin/src/apis/auth.js | 27 +- admin/src/apis/br.js | 11 +- admin/src/apis/courses.js | 17 +- admin/src/apis/http.js | 66 +++ admin/src/apis/student.js | 11 +- admin/src/components/Sidebar.jsx | 4 +- admin/src/pages/CourseLinking.jsx | 5 +- admin/src/pages/Login.jsx | 10 +- admin/src/router_utils/PrivateRoutes.jsx | 5 +- client/src/api/Contribution.js | 3 +- client/src/api/Course.js | 3 +- client/src/api/File.js | 21 +- client/src/api/Folder.js | 11 +- client/src/api/Search.js | 2 +- client/src/api/User.js | 15 +- client/src/api/Year.js | 12 +- client/src/api/csrf.js | 34 ++ client/src/api/http.js | 27 ++ client/src/components/navbar/index.jsx | 12 +- client/src/loading.jsx | 3 +- client/src/main.jsx | 8 +- client/src/router_utils/PrivateRoutes.jsx | 8 +- .../browse/components/folder-info/index.jsx | 18 +- .../browse/components/navbar/index.jsx | 12 +- client/src/screens/contributions/index.jsx | 5 +- client/src/screens/landing/index.jsx | 5 +- .../components/FrontBanner/SemCard/index.jsx | 46 -- .../components/FrontBanner/index.jsx | 60 ++- .../components/FrontBanner/styles.scss | 12 + client/src/utils/loginDestination.js | 17 + docs/authentication.md | 40 ++ server/.env.example | 22 +- server/config/security.js | 56 +++ server/index.js | 32 +- server/middleware/authThrottle.js | 62 +++ server/middleware/csrf.js | 32 ++ server/middleware/requestErrors.js | 96 ++++ server/middleware/sessionAuthentication.js | 70 ++- server/modules/admin/admin.routes.js | 19 +- .../admin/adminDashboard.controller.js | 183 +++----- server/modules/admin/auth.controller.js | 34 +- .../modules/auth-admin/auth-admin.services.js | 57 --- server/modules/auth-admin/otp.model.js | 16 - server/modules/auth/auth.controller.js | 57 ++- server/modules/auth/auth.routes.js | 19 +- server/modules/br/br.routes.js | 13 +- server/modules/session/session.model.js | 15 + server/modules/user/user.controller.js | 21 +- server/modules/user/user.model.js | 94 ++-- server/scripts/generateOneDriveToken.js | 287 ++++++------ server/services/UploadFile.js | 38 +- server/services/oauth.js | 97 ++++ server/services/sessions.js | 73 +++ server/test/browser/library-access.test.js | 16 +- server/test/browser/session-profile.test.js | 295 +++++++++++++ server/test/fixtures/permissions.js | 3 +- server/test/fixtures/sessions.js | 14 + server/test/integration/server.test.js | 17 +- server/test/library-authentication.test.js | 92 +++- server/test/oauth-provisioning.test.js | 100 +++++ server/test/request-errors.test.js | 162 +++++++ server/test/support/course-permissions.js | 16 +- server/test/support/environment.js | 4 + server/test/support/session-security.js | 417 ++++++++++++++++++ server/utils/appError.js | 7 +- server/utils/catchAsync.js | 4 +- server/utils/oauthProof.js | 37 ++ server/utils/uploadedCsv.js | 28 ++ 69 files changed, 2360 insertions(+), 776 deletions(-) create mode 100644 admin/src/apis/http.js create mode 100644 client/src/api/csrf.js create mode 100644 client/src/api/http.js create mode 100644 client/src/utils/loginDestination.js create mode 100644 docs/authentication.md create mode 100644 server/config/security.js create mode 100644 server/middleware/authThrottle.js create mode 100644 server/middleware/csrf.js create mode 100644 server/middleware/requestErrors.js delete mode 100644 server/modules/auth-admin/auth-admin.services.js delete mode 100644 server/modules/auth-admin/otp.model.js create mode 100644 server/modules/session/session.model.js create mode 100644 server/services/oauth.js create mode 100644 server/services/sessions.js create mode 100644 server/test/browser/session-profile.test.js create mode 100644 server/test/fixtures/sessions.js create mode 100644 server/test/oauth-provisioning.test.js create mode 100644 server/test/request-errors.test.js create mode 100644 server/test/support/session-security.js create mode 100644 server/utils/oauthProof.js create mode 100644 server/utils/uploadedCsv.js diff --git a/README.md b/README.md index 201f201a..aca2dbf0 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,7 @@ npm run build ## Further Reading - [Usage Guide](https://codingclub.in/blog/meet-coursehub-find-share-and-organise-course-material) +- [Authentication and session configuration](docs/authentication.md) - [Course linking and shared-folder model](./docs/course_link_logic.md) - [Frontend caching](./docs/frontend-caching.md) diff --git a/admin/src/apis/auth.js b/admin/src/apis/auth.js index 5629c8ee..e85f5ca3 100644 --- a/admin/src/apis/auth.js +++ b/admin/src/apis/auth.js @@ -1,28 +1,35 @@ +import { apiFetch, setCsrfToken, clearCsrfToken, responseError } from "./http"; import { API_BASE_URL } from "./server.js"; export async function adminLogin({ userId, password }) { - const res = await fetch(`${API_BASE_URL}api/admin/auth/login`, { + const res = await apiFetch(`${API_BASE_URL}api/admin/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ userId, password }), }); - if (!res.ok) throw new Error("Login failed"); - return res.json(); + if (!res.ok) throw await responseError(res, "Login failed"); + const data = await res.json(); + setCsrfToken(data.csrfToken); + return data; } export async function adminLogout() { - const res = await fetch(`${API_BASE_URL}api/admin/auth/logout`, { - method: "POST", - credentials: "include", - }); - if (!res.ok) throw new Error("Logout failed"); - return res.json(); + try { + const res = await apiFetch(`${API_BASE_URL}api/admin/auth/logout`, { + method: "POST", + credentials: "include", + }); + if (!res.ok && res.status !== 401) throw await responseError(res, "Logout failed"); + return res.json(); + } catch (error) { if (error.status !== 401) throw error; } + finally { clearCsrfToken(); } } export async function checkAdminSession() { - const res = await fetch(`${API_BASE_URL}api/admin/`, { + const res = await apiFetch(`${API_BASE_URL}api/admin/`, { credentials: "include", }); + if (res.ok) setCsrfToken((await res.json()).csrfToken); return res.ok; } diff --git a/admin/src/apis/br.js b/admin/src/apis/br.js index 18272d78..f850c06f 100644 --- a/admin/src/apis/br.js +++ b/admin/src/apis/br.js @@ -1,9 +1,10 @@ +import { apiFetch } from "./http"; import { API_BASE_URL } from "./server.js"; // Fetch all branch representatives export const fetchBRs = async () => { try { - const response = await fetch(`${API_BASE_URL}api/br/allBRs`, { + const response = await apiFetch(`${API_BASE_URL}api/br/allBRs`, { credentials: "include", }); return await response.json(); @@ -16,7 +17,7 @@ export const fetchBRs = async () => { // Fetch all courses that don't have a branch representative export const fetchCoursesWithoutBR = async () => { try { - const response = await fetch(`${API_BASE_URL}api/br/coursesWithoutBR`, { + const response = await apiFetch(`${API_BASE_URL}api/br/coursesWithoutBR`, { credentials: "include", }); return await response.json(); @@ -29,7 +30,7 @@ export const fetchCoursesWithoutBR = async () => { // Create a single BR export const createBR = async (email) => { try { - const response = await fetch(`${API_BASE_URL}api/br/create`, { + const response = await apiFetch(`${API_BASE_URL}api/br/create`, { method: "POST", credentials: "include", headers: { @@ -73,7 +74,7 @@ export const uploadBRs = async (file) => { } // Call backend API - const response = await fetch(`${API_BASE_URL}api/br/updateList`, { + const response = await apiFetch(`${API_BASE_URL}api/br/updateList`, { method: "POST", credentials: "include", headers: { @@ -98,7 +99,7 @@ export const uploadBRs = async (file) => { export const deleteBR = async (email) => { try { const response = await - fetch(`${API_BASE_URL}api/br/delete`,{ + apiFetch(`${API_BASE_URL}api/br/delete`,{ method: "DELETE", credentials: "include", headers:{ diff --git a/admin/src/apis/courses.js b/admin/src/apis/courses.js index a439087f..bdcaa355 100644 --- a/admin/src/apis/courses.js +++ b/admin/src/apis/courses.js @@ -1,9 +1,10 @@ +import { apiFetch } from "./http"; import { API_BASE_URL } from "./server.js"; // Fetch all courses export const fetchCourses = async () => { try { - const response = await fetch(`${API_BASE_URL}api/admin/dbcourses`, { + const response = await apiFetch(`${API_BASE_URL}api/admin/dbcourses`, { credentials: "include", }); return await response.json(); @@ -17,7 +18,7 @@ export const fetchCourses = async () => { export const updateCourseName = async (code, newName, newCode) => { try { const safeCode = code.toLowerCase().trim(); - const response = await fetch(`${API_BASE_URL}api/admin/course/${safeCode}`, { + const response = await apiFetch(`${API_BASE_URL}api/admin/course/${safeCode}`, { method: "PATCH", headers: { "Content-Type": "application/json", @@ -35,7 +36,7 @@ export const updateCourseName = async (code, newName, newCode) => { // Create a new course export const createCourse = async (code, name) => { try { - const response = await fetch(`${API_BASE_URL}api/course/create/${code}`, { + const response = await apiFetch(`${API_BASE_URL}api/course/create/${code}`, { method: "POST", headers: { "Content-Type": "application/json", @@ -128,7 +129,7 @@ export const bulkSyncCourses = async (courses, analysis, onProgress = null) => { export const linkLegacyCourse = async (targetCode, legacyCode) => { try { const safeCode = targetCode.toLowerCase().trim(); - const response = await fetch(`${API_BASE_URL}api/admin/course/${safeCode}/link`, { + const response = await apiFetch(`${API_BASE_URL}api/admin/course/${safeCode}/link`, { method: "POST", headers: { "Content-Type": "application/json", @@ -153,7 +154,7 @@ export const linkLegacyCourse = async (targetCode, legacyCode) => { export const deleteCourse = async (code) => { try { const safeCode = code.toLowerCase().trim(); - const response = await fetch(`${API_BASE_URL}api/admin/course/${safeCode}/delete`, { + const response = await apiFetch(`${API_BASE_URL}api/admin/course/${safeCode}/delete`, { method: "DELETE", headers: { "Content-Type": "application/json", @@ -176,7 +177,7 @@ export const deleteCourse = async (code) => { export const fetchCourseDashboardData = async (code) => { try { const safeCode = code.toLowerCase().trim(); - const response = await fetch(`${API_BASE_URL}api/admin/course/${safeCode}/dashboard`, { + const response = await apiFetch(`${API_BASE_URL}api/admin/course/${safeCode}/dashboard`, { credentials: "include", }); @@ -192,7 +193,7 @@ export const fetchCourseDashboardData = async (code) => { export const handleContribution = async (contributionId, action, courseCode) => { try { - const response = await fetch(`${API_BASE_URL}api/admin/contribution/action`, { + const response = await apiFetch(`${API_BASE_URL}api/admin/contribution/action`, { method: "POST", headers: { "Content-Type": "application/json", @@ -212,7 +213,7 @@ export const handleContribution = async (contributionId, action, courseCode) => export const deleteNode = async (type, id, courseCode) => { try { - const response = await fetch(`${API_BASE_URL}api/admin/node/${type}/${id}`, { + const response = await apiFetch(`${API_BASE_URL}api/admin/node/${type}/${id}`, { method: "DELETE", headers: { "Content-Type": "application/json", diff --git a/admin/src/apis/http.js b/admin/src/apis/http.js new file mode 100644 index 00000000..73c3ac3c --- /dev/null +++ b/admin/src/apis/http.js @@ -0,0 +1,66 @@ +import { API_BASE_URL } from "./server"; + +let csrfToken; +let pending; +export const clearCsrfToken = () => { + csrfToken = undefined; +}; +export const setCsrfToken = (token) => { + csrfToken = token; +}; +async function getCsrfToken() { + if (csrfToken) return csrfToken; + if (!pending) + pending = (async () => { + const response = await fetch(`${API_BASE_URL}api/auth/csrf?role=admin`, { + credentials: "include", + headers: { "X-Session-Role": "admin" }, + }); + const data = await response.json(); + if (!response.ok) { + const error = new Error(data.message || "Unable to verify your session"); + error.status = response.status; + throw error; + } + if (typeof data.csrfToken !== "string") + throw new Error("Unable to verify your session"); + csrfToken = data.csrfToken; + return csrfToken; + })().finally(() => { + pending = undefined; + }); + return pending; +} + +export async function apiFetch(url, options = {}) { + const mutation = !["GET", "HEAD", "OPTIONS"].includes((options.method || "GET").toUpperCase()); + const login = new URL(url, window.location.origin).pathname === "/api/admin/auth/login"; + for (let attempt = 0; attempt < 2; attempt++) { + const headers = new Headers(options.headers); + headers.set("X-Session-Role", "admin"); + if (mutation && !login) headers.set("X-CSRF-Token", await getCsrfToken()); + const response = await fetch(url, { ...options, headers, credentials: "include" }); + if (response.status === 401) clearCsrfToken(); + if ( + response.status === 403 && + !attempt && + ( + await response + .clone() + .json() + .catch(() => ({})) + ).code === "CSRF_INVALID" + ) { + clearCsrfToken(); + continue; + } + return response; + } +} + +export async function responseError(response, fallback) { + const data = await response.json().catch(() => ({})); + const error = new Error(data.message || fallback); + error.status = response.status; + return error; +} diff --git a/admin/src/apis/student.js b/admin/src/apis/student.js index 764670e8..7816c9ff 100644 --- a/admin/src/apis/student.js +++ b/admin/src/apis/student.js @@ -1,3 +1,4 @@ +import { apiFetch } from "./http"; import { API_BASE_URL } from "./server.js"; // Fetch all students sorted by rollNumber descending. @@ -5,7 +6,7 @@ import { API_BASE_URL } from "./server.js"; export const fetchStudents = async (brOnly = false) => { try { const url = brOnly ? `${API_BASE_URL}api/br/allBRs` : `${API_BASE_URL}api/student/all`; - const response = await fetch(url, { credentials: "include" }); + const response = await apiFetch(url, { credentials: "include" }); const data = await response.json(); return { students: data.students || data.brs || [] }; } catch (error) { @@ -20,7 +21,7 @@ export const searchStudents = async (query, brOnly = false) => { try { const params = new URLSearchParams({ q: query }); if (brOnly) params.set("isBR", "true"); - const response = await fetch(`${API_BASE_URL}api/student/search?${params.toString()}`, { + const response = await apiFetch(`${API_BASE_URL}api/student/search?${params.toString()}`, { credentials: "include", }); return await response.json(); @@ -34,7 +35,7 @@ export const searchStudents = async (query, brOnly = false) => { // Courses will be re-fetched when the student next logs in. export const refreshStudentCourses = async (id) => { try { - const response = await fetch(`${API_BASE_URL}api/student/refresh/${id}`, { + const response = await apiFetch(`${API_BASE_URL}api/student/refresh/${id}`, { method: "PUT", credentials: "include", }); @@ -51,7 +52,7 @@ export const refreshStudentCourses = async (id) => { // Delete a single student permanently. export const deleteStudent = async (id) => { try { - const response = await fetch(`${API_BASE_URL}api/student/${id}`, { + const response = await apiFetch(`${API_BASE_URL}api/student/${id}`, { method: "DELETE", credentials: "include", }); @@ -68,7 +69,7 @@ export const deleteStudent = async (id) => { // Semester reset - deletes all UserUpdate records and clears courses for every student. export const semesterReset = async () => { try { - const response = await fetch(`${API_BASE_URL}api/student/semester-reset`, { + const response = await apiFetch(`${API_BASE_URL}api/student/semester-reset`, { method: "POST", credentials: "include", }); diff --git a/admin/src/components/Sidebar.jsx b/admin/src/components/Sidebar.jsx index 40b06645..3df765f2 100644 --- a/admin/src/components/Sidebar.jsx +++ b/admin/src/components/Sidebar.jsx @@ -1,4 +1,5 @@ import React from "react"; +import { toast } from "react-toastify"; import { Link, useLocation } from "react-router-dom"; import { FaBook, FaUsers, FaLayerGroup, FaLink, FaUserGraduate, FaExclamationTriangle } from "react-icons/fa"; import { adminLogout } from "@/apis/auth"; @@ -17,7 +18,8 @@ const Sidebar = () => { try { await adminLogout(); } catch (err) { - console.error("Logout failed", err); + toast.error("Could not log out. Please try again."); + return; } window.location.href = "/admin/login"; }; diff --git a/admin/src/pages/CourseLinking.jsx b/admin/src/pages/CourseLinking.jsx index 52b2e583..97cba5e5 100644 --- a/admin/src/pages/CourseLinking.jsx +++ b/admin/src/pages/CourseLinking.jsx @@ -1,3 +1,4 @@ +import { apiFetch } from "@/apis/http"; import React, { useState } from "react"; import { FaLink, @@ -46,7 +47,7 @@ const CourseLinking = () => { formData.append("file", file); try { - const response = await fetch(`${API_BASE_URL}api/admin/courses/bulk-link`, { + const response = await apiFetch(`${API_BASE_URL}api/admin/courses/bulk-link`, { method: "POST", body: formData, credentials: "include", @@ -86,7 +87,7 @@ const CourseLinking = () => { setManualSuccess(null); try { - const response = await fetch(`${API_BASE_URL}api/admin/course/${manualNewCode.toLowerCase().trim()}/link`, { + const response = await apiFetch(`${API_BASE_URL}api/admin/course/${manualNewCode.toLowerCase().trim()}/link`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/admin/src/pages/Login.jsx b/admin/src/pages/Login.jsx index c42dcab1..b3ba6b32 100644 --- a/admin/src/pages/Login.jsx +++ b/admin/src/pages/Login.jsx @@ -15,9 +15,11 @@ export default function Login() { setLoading(true); try { await adminLogin({ userId, password }); - window.location.href = "/admin/"; // redirect to admin home - } catch { - setError("Invalid credentials"); + const requested = new URLSearchParams(window.location.search).get("returnTo"); + const target = new URL(requested || "/admin/", window.location.origin); + window.location.href = target.origin === window.location.origin && target.pathname.startsWith("/admin/") && target.pathname !== "/admin/login" ? target.pathname + target.search + target.hash : "/admin/"; + } catch (error) { + setError(error.message || "Unable to sign in. Please try again."); } finally { setLoading(false); } @@ -30,7 +32,7 @@ export default function Login() {

Enter your credentials to access the admin panel.

- {error &&
{error}
} + {error &&
{error}
}
diff --git a/admin/src/router_utils/PrivateRoutes.jsx b/admin/src/router_utils/PrivateRoutes.jsx index 7a1f2a56..a4ea95ad 100644 --- a/admin/src/router_utils/PrivateRoutes.jsx +++ b/admin/src/router_utils/PrivateRoutes.jsx @@ -1,8 +1,9 @@ import React, { useEffect, useState } from "react"; -import { Navigate } from "react-router-dom"; +import { Navigate, useLocation } from "react-router-dom"; import { checkAdminSession } from "@/apis/auth"; export default function PrivateRoute({ children }) { + const location = useLocation(); const [loading, setLoading] = useState(true); const [ok, setOk] = useState(false); @@ -24,6 +25,6 @@ export default function PrivateRoute({ children }) { }, []); if (loading) return null; - if (!ok) return ; + if (!ok) return ; return children; } diff --git a/client/src/api/Contribution.js b/client/src/api/Contribution.js index 91114b3e..16dc2555 100644 --- a/client/src/api/Contribution.js +++ b/client/src/api/Contribution.js @@ -1,5 +1,4 @@ -import axios from "axios"; -axios.defaults.withCredentials = true; +import axios from "./http"; import root from "./server"; export const CreateNewContribution = async (data) => { diff --git a/client/src/api/Course.js b/client/src/api/Course.js index 2ffd9f74..ff6f5e36 100644 --- a/client/src/api/Course.js +++ b/client/src/api/Course.js @@ -1,5 +1,4 @@ -import axios from "axios"; -axios.defaults.withCredentials = true; +import axios from "./http"; import root from "./server"; export const getCourse = async (code) => { diff --git a/client/src/api/File.js b/client/src/api/File.js index 81b6f442..848947f3 100644 --- a/client/src/api/File.js +++ b/client/src/api/File.js @@ -1,10 +1,6 @@ -import axios from "axios"; +import API from "./http"; import serverRoot from "./server"; -const API = axios.create({ - baseURL: `${serverRoot}/api`, - withCredentials: true, -}); export const previewFile = async (fileId) => { const { data } = await API.get(`/files/link/${fileId}`); return { url: new URL(data.file.webUrl, serverRoot).href }; @@ -22,20 +18,7 @@ export const unverifyFile = async (fileId, courseCode) => { }; export const getFileDownloadLink = async (fileId, courseCode) => { - const response = await fetch(serverRoot + "/api/files/download", { - method: "POST", - credentials: "include", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ fileId, courseCode }), - }); - - if (!response.ok) { - throw new Error(`Error fetching download link: ${response.statusText}`); - } - - const data = await response.json(); + const { data } = await API.post("/files/download", { fileId, courseCode }); return new URL(data.downloadLink, serverRoot).href; }; diff --git a/client/src/api/Folder.js b/client/src/api/Folder.js index 4b77ace4..d03b810a 100644 --- a/client/src/api/Folder.js +++ b/client/src/api/Folder.js @@ -1,15 +1,6 @@ -import axios from "axios"; +import API from "./http"; import serverRoot from "./server"; -const API = axios.create({ - baseURL: `${serverRoot}/api`, - withCredentials: true, -}); -API.interceptors.request.use((req) => { - const user = JSON.parse(localStorage.getItem("profile")); - if (user) req.headers.Authorization = `Bearer ${user.token}`; - return req; -}); export const createFolder = async ({ name, course, parentFolder, childType }) => { const { data } = await API.post("/folder/create", { diff --git a/client/src/api/Search.js b/client/src/api/Search.js index 677f72df..cfd64bcc 100644 --- a/client/src/api/Search.js +++ b/client/src/api/Search.js @@ -1,4 +1,4 @@ -import axios from "axios"; +import axios from "./http"; import serverRoot from "./server"; export const GetSearchResult = async (wordArr) => { diff --git a/client/src/api/User.js b/client/src/api/User.js index 1775a9dc..f3e88d49 100644 --- a/client/src/api/User.js +++ b/client/src/api/User.js @@ -1,10 +1,10 @@ -import axios from "axios"; +import axios from "./http"; import serverRoot from "./server"; import { clearAllCoursesCache } from "../utils/frontendCache"; -let redirectUri = "https://www.coursehubiitg.in/api/auth/login/redirect"; +import { clearCsrfToken } from "./csrf"; +import { loginDestination } from "../utils/loginDestination"; -axios.defaults.withCredentials = true; export const getUser = async (signal) => { clearAllCoursesCache(); @@ -20,7 +20,8 @@ export const updateUser = async (newUserData) => { }; export const handleLogin = () => { - window.location.href = `${serverRoot}/api/auth/login`; + const destination = loginDestination(new URLSearchParams(window.location.search).get("returnTo")); + window.location.href = `${serverRoot}/api/auth/login?returnTo=${encodeURIComponent(destination)}`; }; export const AddNewCourseAPI = async (code, name) => { const resp = await axios.post(`${serverRoot}/api/user/readonly`, { code, name }); @@ -49,3 +50,9 @@ export const GetExamDates = async () => { const resp = await axios.get(`${serverRoot}/api/event/examdates`); return resp; }; + +export const logoutUser = async () => { + try { await axios.post(`${serverRoot}/api/auth/logout`); } + catch (error) { if (error.response?.status !== 401) throw error; } + clearCsrfToken(); +}; diff --git a/client/src/api/Year.js b/client/src/api/Year.js index 1ac9140e..66118a5f 100644 --- a/client/src/api/Year.js +++ b/client/src/api/Year.js @@ -1,15 +1,5 @@ -import axios from "axios"; +import API from "./http"; import serverRoot from "./server"; -import { Children } from "react"; -const API = axios.create({ - baseURL: `${serverRoot}/api`, - withCredentials: true, -}); -API.interceptors.request.use((req) => { - const user = JSON.parse(localStorage.getItem("profile")); - if (user) req.headers.Authorization = `Bearer ${user.token}`; - return req; -}); export const addYear = async ({ name, course }) => { const { data } = await API.post("/year", { diff --git a/client/src/api/csrf.js b/client/src/api/csrf.js new file mode 100644 index 00000000..8b696f94 --- /dev/null +++ b/client/src/api/csrf.js @@ -0,0 +1,34 @@ +import server from "./server"; + +let token; +let pending; +export function setCsrfToken(value) { + token = value; +} +export function clearCsrfToken() { + token = undefined; +} +export async function getCsrfToken(refresh = false) { + if (refresh) token = undefined; + if (token) return token; + if (!pending) + pending = (async () => { + const response = await fetch(`${server}/api/auth/csrf?role=student`, { + credentials: "include", + headers: { "X-Session-Role": "student" }, + }); + const data = await response.json(); + if (!response.ok) { + const error = new Error(data.message || "Unable to verify your session"); + error.response = { status: response.status, data }; + throw error; + } + if (typeof data.csrfToken !== "string") + throw new Error("Unable to verify your session"); + token = data.csrfToken; + return token; + })().finally(() => { + pending = undefined; + }); + return pending; +} diff --git a/client/src/api/http.js b/client/src/api/http.js new file mode 100644 index 00000000..acbb8bbc --- /dev/null +++ b/client/src/api/http.js @@ -0,0 +1,27 @@ +import axios from "axios"; +import server from "./server"; +import { getCsrfToken, setCsrfToken, clearCsrfToken } from "./csrf"; + +const http = axios.create({ baseURL: `${server}/api`, withCredentials: true }); +http.interceptors.request.use(async (request) => { + request.headers["X-Session-Role"] = "student"; + if (!["get", "head", "options"].includes(request.method)) + request.headers["X-CSRF-Token"] = await getCsrfToken(); + return request; +}); +http.interceptors.response.use( + (response) => { + if (response.data?.csrfToken) setCsrfToken(response.data.csrfToken); + return response; + }, + async (error) => { + if (error.response?.status === 401) clearCsrfToken(); + if (error.response?.data?.code === "CSRF_INVALID" && !error.config._csrfRetried) { + error.config._csrfRetried = true; + await getCsrfToken(true); + return http(error.config); + } + throw error; + }, +); +export default http; diff --git a/client/src/components/navbar/index.jsx b/client/src/components/navbar/index.jsx index 456e3f7d..7fb33b80 100644 --- a/client/src/components/navbar/index.jsx +++ b/client/src/components/navbar/index.jsx @@ -6,7 +6,8 @@ import SearchBar from "./components/searchbar"; import { useDispatch } from "react-redux"; import { useNavigate } from "react-router-dom"; -import server from "../../api/server"; +import { logoutUser } from "../../api/User"; +import { toast } from "react-toastify"; import { LogoutUser } from "../../actions/user_actions"; @@ -17,9 +18,12 @@ const NavBar = () => { const mobileMenuRef = useRef(null); const toggleButtonRef = useRef(null); - const handleLogout = () => { - dispatch(LogoutUser()); - window.location = `${server}/api/auth/logout`; + const handleLogout = async () => { + try { + await logoutUser(); + dispatch(LogoutUser()); + window.location.href = "/"; + } catch { toast.error("Could not log out. Please try again."); } }; const toggleMobileMenu = (e) => { diff --git a/client/src/loading.jsx b/client/src/loading.jsx index 016e51b3..798134f5 100644 --- a/client/src/loading.jsx +++ b/client/src/loading.jsx @@ -6,6 +6,7 @@ import { toast } from "react-toastify"; import "./loading.css"; import { LoginUser } from "./actions/user_actions"; import { useDispatch } from "react-redux"; +import { loginDestination } from "./utils/loginDestination"; const LoadingPage = () => { const navigate = useNavigate(); @@ -42,7 +43,7 @@ const LoadingPage = () => { } dispatch(LoginUser(user)); - navigate("/dashboard"); + navigate(loginDestination(new URLSearchParams(window.location.search).get("returnTo")), { replace: true }); } loadData(); diff --git a/client/src/main.jsx b/client/src/main.jsx index 5914983e..c2c9d1a6 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -13,11 +13,9 @@ import { createStore } from "redux"; import reducers from "./reducers"; const store = createStore(reducers); import { Provider } from "react-redux"; -import axios from "axios"; -axios.defaults.withCredentials = true; ReactDOM.createRoot(document.getElementById("root")).render( - - - + + + , ); diff --git a/client/src/router_utils/PrivateRoutes.jsx b/client/src/router_utils/PrivateRoutes.jsx index b38243cd..8492f3f4 100644 --- a/client/src/router_utils/PrivateRoutes.jsx +++ b/client/src/router_utils/PrivateRoutes.jsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Outlet, Navigate } from "react-router-dom"; +import { Outlet, Navigate, useLocation } from "react-router-dom"; import { useDispatch, useSelector } from "react-redux"; import { getUser } from "../api/User"; import { LoginUser, LogoutUser } from "../actions/user_actions"; @@ -7,6 +7,8 @@ import Loader from "../components/Loader"; import "./PrivateRoutes.scss"; const PrivateRoutes = () => { + const location = useLocation(); + const destination = encodeURIComponent(location.pathname + location.search + location.hash); const loggedIn = useSelector((state) => state.user.loggedIn); const dispatch = useDispatch(); const [status, setStatus] = useState("checking"); @@ -36,8 +38,8 @@ const PrivateRoutes = () => { }, [loggedIn, dispatch, attempt]); if (loggedIn) return ; - if (status === "signed-out") return ; - if (status === "sync") return ; + if (status === "signed-out") return ; + if (status === "sync") return ; if (status === "error") { return (
diff --git a/client/src/screens/browse/components/folder-info/index.jsx b/client/src/screens/browse/components/folder-info/index.jsx index 2d66a999..2188ba0a 100644 --- a/client/src/screens/browse/components/folder-info/index.jsx +++ b/client/src/screens/browse/components/folder-info/index.jsx @@ -1,3 +1,4 @@ +import { getFileDownloadLink } from "../../../../api/File"; import "./styles.scss"; import { toast } from "react-toastify"; import clientRoot from "../../../../api/server"; @@ -127,22 +128,7 @@ const FolderInfo = ({ } } else { try { - const fileResponse = await fetch(`${server}/api/files/download`, { - method: "POST", - credentials: "include", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ fileId: child._id, courseCode }), - }); - - if (!fileResponse.ok) { - toast.error(`Failed to download file: ${child.name}`); - continue; - } - - const fileData = await fileResponse.json(); - const downloadLink = new URL(fileData.downloadLink, server).href; + const downloadLink = await getFileDownloadLink(child._id, courseCode); const curfile = await fetch(downloadLink, { credentials: "include" }); if (!curfile.ok) throw new Error("File is no longer accessible"); diff --git a/client/src/screens/browse/components/navbar/index.jsx b/client/src/screens/browse/components/navbar/index.jsx index f75cee0b..e8637737 100644 --- a/client/src/screens/browse/components/navbar/index.jsx +++ b/client/src/screens/browse/components/navbar/index.jsx @@ -6,7 +6,8 @@ import SearchBar from "../../../../components/navbar/components/searchbar"; import { useNavigate } from "react-router-dom"; import { useDispatch } from "react-redux"; import { LogoutUser } from "../../../../actions/user_actions"; -import server from "../../../../api/server"; +import { logoutUser } from "../../../../api/User"; +import { toast } from "react-toastify"; const NavBarBrowseScreen = () => { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); @@ -16,9 +17,12 @@ const NavBarBrowseScreen = () => { const navigate = useNavigate(); const dispatch = useDispatch(); - const handleLogout = () => { - dispatch(LogoutUser()); - window.location = `${server}/api/auth/logout`; + const handleLogout = async () => { + try { + await logoutUser(); + dispatch(LogoutUser()); + window.location.href = "/"; + } catch { toast.error("Could not log out. Please try again."); } }; const toggleMobileMenu = (e) => { diff --git a/client/src/screens/contributions/index.jsx b/client/src/screens/contributions/index.jsx index e6fc4ced..73b82d7a 100644 --- a/client/src/screens/contributions/index.jsx +++ b/client/src/screens/contributions/index.jsx @@ -11,11 +11,13 @@ import { useDispatch } from "react-redux"; import server from "../../api/server"; import { ChangeFolder, RefreshCurrentFolder } from "../../actions/filebrowser_actions"; import { fetchFolder } from "../../api/Folder"; +import { getCsrfToken } from "../../api/csrf"; const Contributions = () => { const currentFolder = useSelector((state) => state.fileBrowser.currentFolder); const currentCourseCode = useSelector((state) => state.fileBrowser.currentCourseCode); const contributionId = useRef(""); + const csrfToken = useRef(""); const isBR = currentFolder?.capabilities?.canManage === true; const dispatch = useDispatch(); @@ -50,6 +52,7 @@ const Contributions = () => { description: "default", }); contributionId.current = response.data.data.contributionId; + csrfToken.current = await getCsrfToken(true); await pond.current.processFiles(); pond.current.removeFiles(); contributionSection.classList.remove("show"); @@ -92,7 +95,7 @@ const Contributions = () => { url: `${server}/api/contribution/upload`, process: { withCredentials: true, - headers: () => ({ "contribution-id": contributionId.current }), + headers: () => ({ "contribution-id": contributionId.current, "X-CSRF-Token": csrfToken.current, "X-Session-Role": "student" }), }, }} instantUpload={false} diff --git a/client/src/screens/landing/index.jsx b/client/src/screens/landing/index.jsx index 413921d4..2efd32e4 100644 --- a/client/src/screens/landing/index.jsx +++ b/client/src/screens/landing/index.jsx @@ -1,3 +1,4 @@ +import { loginDestination } from "../../utils/loginDestination"; import MicrosoftSignIn from "./components/microsoftbutton"; import "./styles.scss"; import { useDispatch } from "react-redux"; @@ -29,11 +30,11 @@ const LandingPage = () => { } if (data.needsCourseSync) { setLoading(false); - return navigate("/loading"); + return navigate(`/loading${window.location.search}`); } dispatch(LoginUser(data)); setLoading(false); - navigate(`/dashboard`); + navigate(loginDestination(new URLSearchParams(window.location.search).get("returnTo")), { replace: true }); } catch (error) { dispatch(LogoutUser()); setLoading(false); diff --git a/client/src/screens/profile.js/components/FrontBanner/SemCard/index.jsx b/client/src/screens/profile.js/components/FrontBanner/SemCard/index.jsx index 1533d902..f5e68abd 100644 --- a/client/src/screens/profile.js/components/FrontBanner/SemCard/index.jsx +++ b/client/src/screens/profile.js/components/FrontBanner/SemCard/index.jsx @@ -1,51 +1,5 @@ -import React from "react"; -import { useState } from "react"; -import { toast } from "react-toastify"; -import { updateUser } from "../../../../../api/User"; -import { UpdateUserAction } from "../../../../../actions/user_actions"; -import { useDispatch, useSelector } from "react-redux"; -import "react-toastify/dist/ReactToastify.css"; import "./styles.scss"; function SemCard(props) { - const dispatch = useDispatch(); - const user = useSelector((state) => state.user); - const [isEditSem, setIsEditSem] = useState(false); - const [userSem, setUserSem] = useState(user.user.semester); - - function editSemHandler(newSem) { - setIsEditSem((prev) => !prev); - } - async function submitSemHandler() { - const newUserData = { - newUserSem: userSem, - }; - if (!userSem || userSem > 8 || userSem < 1) { - toast.error("Incorrect Semester field", { - position: "top-right", - autoClose: 3000, - hideProgressBar: false, - closeOnClick: true, - pauseOnHover: true, - draggable: true, - progress: undefined, - theme: "light", - }); - return; - } - setIsEditSem((prev) => !prev); - dispatch(UpdateUserAction(newUserData)); - toast.success("Succesfully Updated", { - position: "top-right", - autoClose: 3000, - hideProgressBar: false, - closeOnClick: true, - pauseOnHover: true, - draggable: true, - progress: undefined, - theme: "light", - }); - await updateUser(newUserData); - } return (
diff --git a/client/src/screens/profile.js/components/FrontBanner/index.jsx b/client/src/screens/profile.js/components/FrontBanner/index.jsx index 84ac277d..8444ea30 100644 --- a/client/src/screens/profile.js/components/FrontBanner/index.jsx +++ b/client/src/screens/profile.js/components/FrontBanner/index.jsx @@ -16,40 +16,30 @@ const FrontBanner = () => { const [isNameEdit, setIsNameEdit] = useState(false); const user = useSelector((state) => state.user); const [userName, setUserName] = useState(formatName(user?.user?.name)); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(""); - function editNameHandler(newName) { + function editNameHandler() { + setUserName(user.user.name); + setSaveError(""); setIsNameEdit(true); } async function submitNameHandler() { - const newUserData = { - newUserName: userName, - }; - if (!userName) { - toast.error("Username cannot be empty", { - position: "top-right", - autoClose: 3000, - hideProgressBar: false, - closeOnClick: true, - pauseOnHover: true, - draggable: true, - progress: undefined, - theme: "light", - }); + if (saving) return; + if (!userName.trim()) { + setSaveError("Name cannot be empty."); return; } - setIsNameEdit((prev) => !prev); - dispatch(UpdateUserAction(newUserData)); - toast.success("Succesfully Updated", { - position: "top-right", - autoClose: 3000, - hideProgressBar: false, - closeOnClick: true, - pauseOnHover: true, - draggable: true, - progress: undefined, - theme: "light", - }); - const resp = await updateUser(newUserData); + setSaving(true); + setSaveError(""); + try { + const { data } = await updateUser({ newUserName: userName.trim() }); + dispatch(UpdateUserAction({ newUserName: data.name })); + setIsNameEdit(false); + toast.success("Profile updated"); + } catch (error) { + setSaveError(error.response?.data?.message || "Could not save your name. Please try again."); + } finally { setSaving(false); } } return ( @@ -62,8 +52,14 @@ const FrontBanner = () => {
{isNameEdit ? ( { if (event.key === "Enter") submitNameHandler(); }} className="inputName" value={userName} onChange={(event) => { @@ -77,11 +73,13 @@ const FrontBanner = () => { formatName(user?.user?.name) )} {isNameEdit ? ( -
+
+ {saving &&

Saving…

} + {saveError && }
{formatBranch(user?.user?.degree, user?.user?.department)}
diff --git a/client/src/screens/profile.js/components/FrontBanner/styles.scss b/client/src/screens/profile.js/components/FrontBanner/styles.scss index 8cf56fcf..4d73c6ec 100644 --- a/client/src/screens/profile.js/components/FrontBanner/styles.scss +++ b/client/src/screens/profile.js/components/FrontBanner/styles.scss @@ -39,6 +39,16 @@ display: flex; align-items: center; + button { + background-color: transparent; + border: 0; + padding: 0; + cursor: pointer; + flex-shrink: 0; + } + button:focus-visible { outline: 2px solid #f8d648; outline-offset: 4px; } + button:disabled { opacity: 0.5; cursor: wait; } + .inputName { background: none; border: none; @@ -75,6 +85,8 @@ } } } + + .profile-save-error { color: #ffd167; max-width: 32rem; font-size: 1rem; } } @media screen and (max-width: 1182px) { diff --git a/client/src/utils/loginDestination.js b/client/src/utils/loginDestination.js new file mode 100644 index 00000000..f400d9f3 --- /dev/null +++ b/client/src/utils/loginDestination.js @@ -0,0 +1,17 @@ +export function loginDestination(value) { + if ( + typeof value !== "string" || + !value.startsWith("/") || + value.startsWith("//") || + /[\\\r\n]/.test(value) + ) + return "/dashboard"; + const url = new URL(value, window.location.origin); + if ( + url.origin !== window.location.origin || + url.pathname.startsWith("/api/") || + ["/", "/loading"].includes(url.pathname) + ) + return "/dashboard"; + return url.pathname + url.search + url.hash; +} diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 00000000..5834cc40 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,40 @@ +# Authentication and sessions + +The API owns both student and administrator sessions. Student sign-in uses Microsoft's authorization-code flow with a browser-bound, single-use state and an S256 PKCE verifier. Administrator sign-in uses the explicitly provisioned account credentials. + +Sessions last 24 days for students and 7 days for administrators. Each signed token identifies a MongoDB session record. The API checks expiry and revocation on every request and still checks that the account exists. Logout revokes the current session before clearing its cookie. Other devices keep their own sessions. + +When introducing these sessions, previously issued tokens require a fresh sign-in because they do not have a persisted session record. + +## Configuration + +Configure the variables in `server/.env.example`: + +- `NODE_ENV=production` enables Secure cookies. Serve the API over HTTPS in production. +- `ALLOWED_ORIGINS` contains exact origins for both frontends and any API-hosted frontend, separated by commas. Include scheme and port; omit paths and wildcards. For example: `https://coursehub.example,https://admin.coursehub.example`. +- `COOKIE_SAME_SITE` defaults to `lax`. If the API and frontend must operate on different sites, use `none` with production HTTPS. Browser third-party-cookie restrictions can still prevent that topology; using the same site avoids that dependency. +- `TRUST_PROXY_HOPS` defaults to `0`. Set the exact number of trusted reverse-proxy hops for the deployment. Restrict direct access to the application port when trusting a proxy. +- `AUTH_RATE_LIMIT` defaults to `20` attempts per `AUTH_RATE_WINDOW_SECONDS` window (default `900`). Authentication counters are stored in MongoDB by IP and, for administrator login, account. Throttled requests return `429` and `Retry-After`. +- `CLIENT_URL`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID` and `REDIRECT_URI` configure Microsoft sign-in. Register the exact redirect URI in Azure. JWT signing keys and Azure secrets remain server-only. + +The API creates TTL indexes for `sessions`, `oauthattempts` and `authratelimits` during startup. These are additive collections and work without multi-document transactions. + +## Browser requests + +Both frontends send cookies and an `X-Session-Role` hint: the hint only selects which authenticated session to use when both cookies exist. It does not grant a role. The user/admin bootstrap returns the session's `csrfToken`, also available from `GET /api/auth/csrf?role=student` or `role=admin` after authentication. + +Cookie-authenticated mutations require an allowed Origin (or Referer when Origin is absent) and `X-CSRF-Token`. Authentication login has an explicit origin check; OAuth callbacks instead validate the browser-bound state. FilePond uses the same cookies and CSRF token. A request rejected with `CSRF_INVALID` may refresh the token and retry once, since rejection happens before the handler or multipart storage runs. Tokens are kept in memory, never local storage. + +Sign-in preserves the requested pathname, query and hash. The destination must be a local frontend path, and resource permissions are checked again after sign-in. A missing academic synchronization record produces `needsCourseSync`; it does not invalidate an otherwise valid session. + +## Errors and profile saves + +API errors contain `error`, a stable `code`, a safe `message`, optional `fieldErrors`, and `requestId`. The same ID appears in `X-Request-Id` and the request's logging correlation ID. Provider credentials, stack traces and database details are excluded from responses. + +Profile updates validate permitted fields, await persistence and return the saved name and semester. The editor reports success only after confirmation and keeps failed edits available for retry. + +## OneDrive credential provisioning + +Run `node scripts/generateOneDriveToken.js` from `server/` only when intentionally provisioning the selected storage account. `ONEDRIVE_REDIRECT_URI` optionally selects a separate Azure-registered callback, otherwise the script uses `REDIRECT_URI`. + +A local HTTP callback listens only on loopback. For a remote callback or an occupied local port, paste the complete redirected URL, including state, into the terminal. Each attempt expires in 10 minutes, verifies state and destination, uses PKCE, and can be consumed once. The script saves tokens with owner-only permissions and does not print them. Routine tests mock both the token provider and token-file writes. diff --git a/server/.env.example b/server/.env.example index e2d409f8..d9193a70 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,3 +1,5 @@ +NODE_ENV=development + # App server and database PORT= MONGO_URI= @@ -6,9 +8,17 @@ MONGO_URI= JWT_SECRET= ADMIN_JWT_SECRET= -# Administrator provisioning: npm run admin -ADMIN_USER_ID= # 1-128 characters -ADMIN_PASSWORD= # >=12 characters +# Exact frontend/API origins +ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174 + +# Sessions +COOKIE_SAME_SITE=lax +# Number of trusted reverse-proxy hops +TRUST_PROXY_HOPS=0 + +# Persistent auth throttling, per IP and admin account +AUTH_RATE_WINDOW_SECONDS=900 +AUTH_RATE_LIMIT=20 # Azure AD / Microsoft Graph auth AZURE_CLIENT_ID= @@ -22,6 +32,8 @@ API_BASE_URL= # OneDrive storage ONEDRIVE_FOLDER_ID= +# Optional Azure-registered callback for provisioning +ONEDRIVE_REDIRECT_URI= # ImageKit - get from ImageKit dashboard > Developer Options IMAGEKIT_PUBLIC_KEY= @@ -36,3 +48,7 @@ MAIL_PASSWORD= OPS_LOGGING_ENABLED=false OPS_LOG_INGEST_URL= OPS_LOG_INGEST_SECRET= + +# Administrator provisioning: npm run admin +ADMIN_USER_ID= # 1-128 characters +ADMIN_PASSWORD= # >=12 characters diff --git a/server/config/security.js b/server/config/security.js new file mode 100644 index 00000000..e085bb81 --- /dev/null +++ b/server/config/security.js @@ -0,0 +1,56 @@ +import config from "./default.js"; + +export const sessionSeconds = { student: 24 * 24 * 60 * 60, admin: 7 * 24 * 60 * 60 }; +export const cookieNames = { student: "token", admin: "adminToken" }; +export const cookieOptions = () => ({ + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.COOKIE_SAME_SITE || "lax", + path: "/", +}); + +export function allowedOrigins() { + const values = process.env.ALLOWED_ORIGINS?.split(",") || [ + config.clientURL, + config.localApiBaseUrl, + ...(process.env.NODE_ENV === "production" + ? [] + : [ + "http://localhost:5173", + "http://localhost:5174", + "http://127.0.0.1:4183", + "http://127.0.0.1:4184", + ]), + ]; + return new Set( + values + .filter((value) => value?.trim()) + .map((value) => { + const url = new URL(value.trim()); + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) + throw new Error("ALLOWED_ORIGINS must contain HTTP(S) origins without paths"); + return url.origin; + }), + ); +} + +export function validateSecuritySettings() { + if (!allowedOrigins().size) throw new Error("Configure ALLOWED_ORIGINS"); + const options = cookieOptions(); + if ( + !["lax", "strict", "none"].includes(options.sameSite) || + (options.sameSite === "none" && !options.secure) + ) + throw new Error("COOKIE_SAME_SITE must be lax, strict, or none with production HTTPS"); + const hops = Number(process.env.TRUST_PROXY_HOPS || 0); + if (!Number.isInteger(hops) || hops < 0 || hops > 10) + throw new Error("TRUST_PROXY_HOPS must be an integer from 0 to 10"); + return hops; +} diff --git a/server/index.js b/server/index.js index 5a2871b4..594192d0 100644 --- a/server/index.js +++ b/server/index.js @@ -8,7 +8,7 @@ import express from "express"; import cookieParser from "cookie-parser"; import ua from "express-useragent"; import config from "./config/default.js"; -import { flushLogging, lifecycleLogger, logger, opsHttpMiddleware } from "./utils/logger.js"; +import { flushLogging, lifecycleLogger, opsHttpMiddleware } from "./utils/logger.js"; import { initScheduler } from "./config/cron.js"; import connectDatabase from "./services/connectDB.js"; import authRoutes from "./modules/auth/auth.routes.js"; @@ -23,6 +23,11 @@ import fileRoutes from "./modules/file/file.routes.js"; import folderRoutes from "./modules/folder/folder.routes.js"; import yearRoutes from "./modules/year/year.routes.js"; import studentRoutes from "./modules/student/student.routes.js"; +import { allowedOrigins, validateSecuritySettings } from "./config/security.js"; +import { requestContext, requestErrorHandler } from "./middleware/requestErrors.js"; +import Session from "./modules/session/session.model.js"; +import { OAuthAttempt } from "./services/oauth.js"; +import { AuthRateLimit } from "./middleware/authThrottle.js"; const app = express(); const server = http.createServer(app); @@ -30,15 +35,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); let scheduler; let shutdownPromise; +app.set("trust proxy", validateSecuritySettings()); app.use(opsHttpMiddleware); +app.use(requestContext); app.use( cors({ - origin: [ - "http://localhost:5174", - "http://localhost:5173", - "https://coursehub.codingclub.in", - ], + origin: (origin, callback) => callback(null, !!origin && allowedOrigins().has(origin)), credentials: true, + exposedHeaders: ["X-Request-Id"], }), ); app.use(express.json()); @@ -61,20 +65,7 @@ app.use("/api", (req, res) => res.status(404).json({ error: true, message: "API endpoint not found" }), ); -app.use((error, req, res, next) => { - if (res.headersSent) return next(error); - logger.error("Unhandled request error", { - error, - attributes: { - component: "express-error-handler", - operation: "request", - outcome: "failure", - retryable: false, - }, - }); - const { status = 500, message = "Something went wrong!" } = error; - return res.status(status).json({ error: true, message }); -}); +app.use(requestErrorHandler); app.use(express.static("static")); app.get("*", (req, res) => res.sendFile(path.resolve(__dirname, "static", "index.html"))); @@ -124,6 +115,7 @@ process.once("unhandledRejection", (error) => void terminate({ error, exitCode: export async function start() { await connectDatabase(); + await Promise.all([Session, OAuthAttempt, AuthRateLimit].map((model) => model.createIndexes())); scheduler = initScheduler(); await new Promise((resolve, reject) => { server.once("error", reject); diff --git a/server/middleware/authThrottle.js b/server/middleware/authThrottle.js new file mode 100644 index 00000000..0448ac44 --- /dev/null +++ b/server/middleware/authThrottle.js @@ -0,0 +1,62 @@ +import { createHash } from "node:crypto"; +import { model, Schema } from "mongoose"; +import AppError from "../utils/appError.js"; +import catchAsync from "../utils/catchAsync.js"; + +export const AuthRateLimit = model( + "AuthRateLimit", + new Schema({ + _id: String, + count: Number, + expiresAt: { type: Date, expires: 0 }, + }), +); + +export function authThrottle(action) { + return catchAsync(async (req, res, next) => { + const seconds = Number(process.env.AUTH_RATE_WINDOW_SECONDS || 900); + const limit = Number(process.env.AUTH_RATE_LIMIT || 20); + if (!Number.isInteger(seconds) || seconds < 1 || !Number.isInteger(limit) || limit < 1) + throw new Error("Invalid authentication throttling configuration"); + const window = Math.floor(Date.now() / (seconds * 1000)); + const expiresAt = new Date((window + 1) * seconds * 1000); + const identities = [`ip:${req.ip}`]; + if (typeof req.body?.userId === "string") + identities.push(`account:${req.body.userId.trim()}`); + for (const identity of identities) { + const _id = createHash("sha256") + .update(`${action}:${window}:${identity}`) + .digest("hex"); + let entry; + try { + entry = await AuthRateLimit.findOneAndUpdate( + { _id }, + { + $inc: { count: 1 }, + $setOnInsert: { expiresAt }, + }, + { upsert: true, new: true }, + ); + } catch (error) { + if (error.code !== 11000) throw error; + entry = await AuthRateLimit.findOneAndUpdate( + { _id }, + { $inc: { count: 1 } }, + { new: true }, + ); + } + if (entry.count > limit) { + res.setHeader( + "Retry-After", + Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000)), + ); + throw new AppError( + 429, + "Too many sign-in attempts. Try again later.", + "AUTH_THROTTLED", + ); + } + } + next(); + }); +} diff --git a/server/middleware/csrf.js b/server/middleware/csrf.js new file mode 100644 index 00000000..3cc7c537 --- /dev/null +++ b/server/middleware/csrf.js @@ -0,0 +1,32 @@ +import { timingSafeEqual } from "node:crypto"; +import { allowedOrigins } from "../config/security.js"; +import AppError from "../utils/appError.js"; + +export const isMutation = (req) => !["GET", "HEAD", "OPTIONS"].includes(req.method); +export function trustedOrigin(req) { + if (req.headers.origin) return allowedOrigins().has(req.headers.origin); + try { + return allowedOrigins().has(new URL(req.headers.referer).origin); + } catch { + return false; + } +} +export function requireTrustedOrigin(req, res, next) { + if (!trustedOrigin(req)) + return next(new AppError(403, "Request origin is not allowed", "ORIGIN_DENIED")); + next(); +} +export function checkCsrf(req, session, usesCookie) { + if (!isMutation(req) || !usesCookie) return; + if (!trustedOrigin(req)) + throw new AppError(403, "Request origin is not allowed", "ORIGIN_DENIED"); + const supplied = req.headers["x-csrf-token"]; + const expected = session.csrfToken; + if ( + typeof supplied !== "string" || + !/^[A-Za-z0-9_-]{43}$/.test(supplied) || + supplied.length !== expected.length || + !timingSafeEqual(Buffer.from(supplied), Buffer.from(expected)) + ) + throw new AppError(403, "Refresh your session and try again", "CSRF_INVALID"); +} diff --git a/server/middleware/requestErrors.js b/server/middleware/requestErrors.js new file mode 100644 index 00000000..a6ea8a91 --- /dev/null +++ b/server/middleware/requestErrors.js @@ -0,0 +1,96 @@ +import { randomUUID } from "node:crypto"; +import AppError from "../utils/appError.js"; +import logger, { getCorrelationId } from "../utils/logger.js"; + +const defaults = { + 400: ["INVALID_REQUEST", "Invalid request"], + 401: ["AUTH_REQUIRED", "Sign in to continue"], + 403: ["ACCESS_DENIED", "You do not have permission for this action"], + 404: ["NOT_FOUND", "Resource not found"], + 409: ["CONFLICT", "The request conflicts with existing data"], + 413: ["UPLOAD_TOO_LARGE", "The upload exceeds the permitted limit"], + 429: ["RATE_LIMITED", "Too many requests. Try again later."], + 500: ["INTERNAL_ERROR", "Something went wrong. Please try again."], + 502: [ + "PROVIDER_UNAVAILABLE", + "The storage or sign-in service is unavailable. Try again later.", + ], + 503: ["SERVICE_UNAVAILABLE", "The service is temporarily unavailable. Try again later."], + 504: ["PROVIDER_TIMEOUT", "The external service took too long. Try again later."], +}; + +export function safeError(error) { + let status = error instanceof AppError ? error.status : 500; + if (error?.isAxiosError) + status = ["ECONNABORTED", "ETIMEDOUT"].includes(error.code) ? 504 : 502; + else if ( + ["MongoServerSelectionError", "MongooseServerSelectionError", "MongoNetworkError"].includes( + error?.name, + ) + ) + status = 503; + else if (error?.code === 11000) status = 409; + else if ( + ["ValidationError", "CastError"].includes(error?.name) || + error?.type === "entity.parse.failed" + ) + status = 400; + else if (error?.type === "entity.too.large" || error?.code === "LIMIT_FILE_SIZE") status = 413; + else if (error?.name === "MulterError") status = 400; + if (!Number.isInteger(status) || status < 400 || status > 599) status = 500; + const [code, message] = defaults[status] || [ + `HTTP_${status}`, + "The request could not be completed", + ]; + const expose = error instanceof AppError && status < 500; + return { + status, + code: (expose && error.code) || code, + message: expose ? error.message : message, + ...(expose && error.fieldErrors ? { fieldErrors: error.fieldErrors } : {}), + }; +} + +export function requestContext(req, res, next) { + req.requestId = getCorrelationId() || randomUUID(); + res.setHeader("X-Request-Id", req.requestId); + const json = res.json.bind(res); + res.json = (body) => { + if (res.statusCode >= 400) { + const supplied = + typeof body?.message === "string" + ? body.message + : typeof body?.error === "string" + ? body.error + : undefined; + const details = safeError( + new AppError( + res.statusCode, + supplied || defaults[res.statusCode]?.[1], + body?.code, + body?.fieldErrors, + ), + ); + const { status, ...data } = details; + return json({ error: true, ...data, requestId: req.requestId }); + } + return json(body); + }; + res.sendStatus = (status) => res.status(status).json({}); + next(); +} + +export function requestErrorHandler(error, req, res, next) { + if (res.headersSent) return next(error); + logger.error("Request failed", { + error, + attributes: { + component: "express-error-handler", + operation: "request", + outcome: "failure", + requestId: req.requestId, + }, + }); + const { status, ...data } = safeError(error); + return res.status(status).json(data); +} diff --git a/server/middleware/sessionAuthentication.js b/server/middleware/sessionAuthentication.js index f706d826..210018fb 100644 --- a/server/middleware/sessionAuthentication.js +++ b/server/middleware/sessionAuthentication.js @@ -1,61 +1,55 @@ import User from "../modules/user/user.model.js"; import Admin from "../modules/admin/admin.model.js"; -import { verifyAdminJWT } from "../modules/auth-admin/auth-admin.services.js"; +import { readSession } from "../services/sessions.js"; +import { cookieNames } from "../config/security.js"; +import { checkCsrf } from "./csrf.js"; import AppError from "../utils/appError.js"; -async function findAdmin(token) { - const id = await verifyAdminJWT(token); - if (typeof id !== "string" || !/^[a-f0-9]{24}$/i.test(id)) return null; - return Admin.findById(id); -} - export function requireSession(...roles) { return async (req, res, next) => { try { const bearer = req.headers.authorization?.match(/^Bearer\s+(\S+)$/i)?.[1]; - const candidates = [ - { - role: "student", - key: "user", - token: req.cookies?.token || bearer, - find: (token) => User.findByJWT(token), - }, - { - role: "admin", - key: "admin", - token: req.cookies?.adminToken || bearer, - find: findAdmin, - }, - ].sort((a, b) => Number(roles.includes(b.role)) - Number(roles.includes(a.role))); + const preferred = roles.includes(req.headers["x-session-role"]) + ? req.headers["x-session-role"] + : roles[0]; + const candidates = ["student", "admin"].sort( + (a, b) => Number(b === preferred) - Number(a === preferred), + ); let authenticated = false; - for (const candidate of candidates) { + req.sessions ||= {}; + for (const role of candidates) { + const cookie = req.cookies?.[cookieNames[role]]; + const token = cookie || bearer; + if (!token) continue; + const session = req.sessions[role] || (await readSession(token, role)); + if (!session) continue; + const key = role === "student" ? "user" : "admin"; const actor = - req[candidate.key] || - (candidate.token && (await candidate.find(candidate.token))); - if (!actor) continue; - // A shared account cannot establish an individual student session + req[key] || + (await (role === "student" ? User : Admin).findById(session.actorId)); if ( - candidate.role === "student" && - actor.email?.toLowerCase() === "guest@coursehubiitg.in" + !actor || + (role === "student" && actor.email?.toLowerCase() === "guest@coursehubiitg.in") ) continue; - req[candidate.key] = actor; authenticated = true; - if (roles.includes(candidate.role)) { + req[key] = actor; + req.sessions[role] = session; + if (roles.includes(role)) { + checkCsrf(req, session, !!cookie); + req.session = session; res.setHeader("Cache-Control", "private, no-store"); return next(); } } - return next( - new AppError( - authenticated ? 403 : 401, - authenticated - ? "You do not have permission for this action" - : "Sign in to continue", - ), + throw new AppError( + authenticated ? 403 : 401, + authenticated + ? "You do not have permission for this action" + : "Sign in to continue", ); } catch (error) { - return next(error); + next(error); } }; } diff --git a/server/modules/admin/admin.routes.js b/server/modules/admin/admin.routes.js index 8e7a03b5..5a68fac6 100644 --- a/server/modules/admin/admin.routes.js +++ b/server/modules/admin/admin.routes.js @@ -2,6 +2,8 @@ import express from "express"; import catchAsync from "../../utils/catchAsync.js"; import isAdmin from "../../middleware/isAdmin.js"; import multer from "multer"; +import { requireTrustedOrigin } from "../../middleware/csrf.js"; +import { authThrottle } from "../../middleware/authThrottle.js"; import { adminLogin, adminLogout, getAdmin } from "./auth.controller.js"; import { getDBCourses, @@ -20,8 +22,13 @@ const router = express.Router(); const upload = multer({ dest: "uploads/" }); // Admin auth -router.post("/auth/login", catchAsync(adminLogin)); -router.post("/auth/logout", catchAsync(adminLogout)); +router.post( + "/auth/login", + requireTrustedOrigin, + authThrottle("admin-login"), + catchAsync(adminLogin), +); +router.post("/auth/logout", isAdmin, catchAsync(adminLogout)); router.use(isAdmin); router.get("/course/:code/dashboard", catchAsync(getCourseDashboardData)); @@ -30,10 +37,10 @@ router.delete("/node/:type/:id", catchAsync(deleteNode)); router.get("/", catchAsync(getAdmin)); router.get("/dbcourses", catchAsync(getDBCourses)); -router.post("/courses/upload", upload.single("file"), uploadCourses); -router.post("/courses/bulk-link", upload.single("file"), bulkLinkCourses); -router.patch("/course/:code", renameCourse); -router.post("/course/:code/link", linkLegacyCourse); +router.post("/courses/upload", upload.single("file"), catchAsync(uploadCourses)); +router.post("/courses/bulk-link", upload.single("file"), catchAsync(bulkLinkCourses)); +router.patch("/course/:code", catchAsync(renameCourse)); +router.post("/course/:code/link", catchAsync(linkLegacyCourse)); router.delete("/course/:code/delete", catchAsync(deleteCourse)); router.post("/sync-courses-cache", catchAsync(syncCoursesCacheController)); diff --git a/server/modules/admin/adminDashboard.controller.js b/server/modules/admin/adminDashboard.controller.js index 59ffc96b..1e2f0f3d 100644 --- a/server/modules/admin/adminDashboard.controller.js +++ b/server/modules/admin/adminDashboard.controller.js @@ -9,8 +9,8 @@ import { removeFolderFromCourse, removeFile } from "../../services/resourceRemov import { presentContribution } from "../contribution/contribution.controller.js"; import AppError from "../../utils/appError.js"; import CourseModel, { FileModel, FolderModel } from "../course/course.model.js"; -import fs from "fs"; -import csv from "csv-parser"; +import { processUploadedCsv } from "../../utils/uploadedCsv.js"; +import { safeError } from "../../middleware/requestErrors.js"; import User from "../user/user.model.js"; import UserUpdate from "../user/userUpdate.model.js"; import SearchResults from "../search/search.model.js"; @@ -34,39 +34,24 @@ export async function getDBCourses(req, res, next) { } // Upload courses via CSV file (comma-separated) -export async function uploadCourses(req, res, next) { - if (!req.file) return next(new AppError(400, "No file uploaded")); - const results = []; - fs.createReadStream(req.file.path) - .pipe(csv(["code", "name"])) - .on("data", (data) => results.push(data)) - .on("end", async () => { - try { - await Promise.all( - results.map(async ({ code, name }) => { - if (!code || !name) return null; - const codeUpper = normalizeCourseCode(code); - let course = await CourseModel.findOne({ - code: getCourseCodeCaseInsensitiveRegex(codeUpper), - }); - if (!course) { - course = await CourseModel.create({ code: codeUpper, name }); - } else { - course.code = codeUpper; - course.name = name; - await course.save(); - } - }), - ); - fs.unlinkSync(req.file.path); - // Fetch and return the full course list - const allCourses = await CourseModel.find({}); - res.json(allCourses); - } catch (err) { - fs.unlinkSync(req.file.path); - next(new AppError(500, "Failed to process CSV")); +export async function uploadCourses(req, res) { + const allCourses = await processUploadedCsv(req.file, ["code", "name"], async (results) => { + for (const { code, name } of results) { + if (!code || !name) continue; + const codeUpper = normalizeCourseCode(code); + const course = await CourseModel.findOne({ + code: getCourseCodeCaseInsensitiveRegex(codeUpper), + }); + if (!course) await CourseModel.create({ code: codeUpper, name }); + else { + course.code = codeUpper; + course.name = name; + await course.save(); } - }); + } + return CourseModel.find({}); + }); + res.json(allCourses); } export async function getCourseDashboardData(req, res) { @@ -315,14 +300,7 @@ export async function deleteCourse(req, res, next) { // UNLESS it's a shared folder. // If it's a shared folder, we should ONLY delete files if we are deleting the last reference. if (folder.courses.length <= 1) { - // Remove file from database - await FileModel.findByIdAndDelete(file._id); - - // Delete file from OneDrive if fileId exists - if (file.fileId) { - const { DeleteFile } = await import("../../services/UploadFile.js"); - await DeleteFile(file.fileId); - } + await removeFile(file); } } catch (fileError) { logger.error("Admin file deletion failed", { @@ -334,7 +312,7 @@ export async function deleteCourse(req, res, next) { retryable: true, }, }); - // Continue with other files even if one fails + throw fileError; } } } @@ -368,7 +346,7 @@ export async function deleteCourse(req, res, next) { deletedContributions: contributionsDeleted.deletedCount, }); } catch (error) { - return next(new AppError(500, "Failed to delete course: " + error.message)); + return next(error); } } @@ -457,11 +435,7 @@ async function removeCourseFromFolderTree(startFolderId, codeToRemove) { for (const f of fileFolders) { for (const fileId of f.children) { - try { - await removeFile(fileId); - } catch (e) { - // ignore individual file deletion errors - } + await removeFile(fileId); } } await FolderModel.deleteMany({ _id: { $in: foldersToDelete } }); @@ -631,26 +605,11 @@ export async function linkLegacyCourse(req, res, next) { const course = await performLinkWithLock(code, legacyCode); res.json({ message: "Legacy course linked successfully", course }); } catch (error) { - return next(new AppError(500, error.message)); + return next(error); } } -export async function bulkLinkCourses(req, res, next) { - if (!req.file) return next(new AppError(400, "No file uploaded")); - - const results = []; - const filePath = req.file.path; - - const cleanupFile = () => { - if (fs.existsSync(filePath)) { - try { - fs.unlinkSync(filePath); - } catch (e) { - // ignore unlink errors - } - } - }; - +export async function bulkLinkCourses(req, res) { const isHeaderValue = (val) => { if (!val) return true; const norm = normalizeCourseCode(val); @@ -679,64 +638,52 @@ export async function bulkLinkCourses(req, res, next) { return headerTerms.includes(norm); }; - fs.createReadStream(filePath) - .pipe(csv({ headers: false })) - .on("data", (data) => results.push(data)) - .on("error", (err) => { - cleanupFile(); - return next(new AppError(400, "Failed to parse CSV file: " + err.message)); - }) - .on("end", async () => { - const summary = { success: 0, failed: 0, errors: [] }; - try { - for (const row of results) { - const keys = Object.keys(row); - if (keys.length === 0) continue; - - let rawOld = ""; - let rawNew = ""; - - if (row.oldCode || row.legacyCode || row.old || row.sourceCode) { - rawOld = row.oldCode || row.legacyCode || row.old || row.sourceCode; - rawNew = row.newCode || row.targetCode || row.new || row.target; - } else if (keys.length >= 2) { - rawOld = row[keys[0]]; - rawNew = row[keys[1]]; - } else if (keys.length === 1 && typeof row[keys[0]] === "string") { - const parts = row[keys[0]].split(",").map((s) => s.trim()); - if (parts.length >= 2) { - rawOld = parts[0]; - rawNew = parts[1]; - } - } + const summary = await processUploadedCsv(req.file, { headers: false }, async (results) => { + const summary = { success: 0, failed: 0, errors: [] }; + for (const row of results) { + const keys = Object.keys(row); + if (keys.length === 0) continue; + + let rawOld = ""; + let rawNew = ""; + + if (row.oldCode || row.legacyCode || row.old || row.sourceCode) { + rawOld = row.oldCode || row.legacyCode || row.old || row.sourceCode; + rawNew = row.newCode || row.targetCode || row.new || row.target; + } else if (keys.length >= 2) { + rawOld = row[keys[0]]; + rawNew = row[keys[1]]; + } else if (keys.length === 1 && typeof row[keys[0]] === "string") { + const parts = row[keys[0]].split(",").map((s) => s.trim()); + if (parts.length >= 2) { + rawOld = parts[0]; + rawNew = parts[1]; + } + } - if (!rawOld || !rawNew) continue; + if (!rawOld || !rawNew) continue; - if (isHeaderValue(rawOld) && isHeaderValue(rawNew)) { - continue; - } + if (isHeaderValue(rawOld) && isHeaderValue(rawNew)) { + continue; + } - const oldCode = normalizeCourseCode(rawOld); - const newCode = normalizeCourseCode(rawNew); + const oldCode = normalizeCourseCode(rawOld); + const newCode = normalizeCourseCode(rawNew); - if (!oldCode || !newCode) continue; + if (!oldCode || !newCode) continue; - try { - await performLinkWithLock(newCode, oldCode); - summary.success++; - } catch (err) { - summary.failed++; - summary.errors.push({ oldCode, newCode, error: err.message }); - } - } - - cleanupFile(); - res.json({ message: "Bulk linking completed", summary }); + try { + await performLinkWithLock(newCode, oldCode); + summary.success++; } catch (err) { - cleanupFile(); - next(new AppError(500, `Failed to process bulk linking CSV: ${err.message}`)); + summary.failed++; + summary.errors.push({ oldCode, newCode, error: safeError(err).message }); } - }); + } + + return summary; + }); + res.json({ message: "Bulk linking completed", summary }); } export async function syncCoursesCacheController(req, res, next) { @@ -744,6 +691,6 @@ export async function syncCoursesCacheController(req, res, next) { await runSync(); res.json({ success: true, message: "Course cache synchronized successfully." }); } catch (err) { - next(new AppError(500, `Cache sync failed: ${err.message}`)); + next(err); } } diff --git a/server/modules/admin/auth.controller.js b/server/modules/admin/auth.controller.js index 6260f494..5dd08b43 100644 --- a/server/modules/admin/auth.controller.js +++ b/server/modules/admin/auth.controller.js @@ -1,29 +1,38 @@ import Admin from "./admin.model.js"; import AppError from "../../utils/appError.js"; -import { signAdminJWT } from "../auth-admin/auth-admin.services.js"; +import { + createSession, + setSessionCookie, + revokeSession, + clearSessionCookie, +} from "../../services/sessions.js"; export const adminLogin = async (req, res, next) => { const { userId, password } = req.body; - if (!userId || !password) return next(new AppError(400, "userId and password required")); + if ( + typeof userId !== "string" || + !userId.trim() || + userId.length > 128 || + typeof password !== "string" || + !password || + password.length > 1024 + ) + return next(new AppError(400, "userId and password required")); - const admin = await Admin.findOne({ userId }); + const admin = await Admin.findOne({ userId: userId.trim() }); if (!admin) return next(new AppError(401, "Invalid credentials")); const ok = await admin.comparePassword(password); if (!ok) return next(new AppError(401, "Invalid credentials")); - const token = await signAdminJWT(admin._id.toString()); - res.cookie("adminToken", token, { - httpOnly: true, - sameSite: "lax", - secure: false, - maxAge: 1000 * 60 * 60 * 24 * 7, - }); - return res.json({ success: true }); + const { token, session } = await createSession(admin._id, "admin"); + setSessionCookie(res, "admin", token); + return res.json({ success: true, csrfToken: session.csrfToken }); }; export const adminLogout = async (req, res) => { - res.clearCookie("adminToken"); + await revokeSession(req.session); + clearSessionCookie(res, "admin"); return res.json({ success: true }); }; @@ -31,6 +40,7 @@ export const getAdmin = async (req, res, next) => { const admin = req.admin; if (!admin) return next(new AppError(500, "Something went wrong!")); return res.json({ + csrfToken: req.session.csrfToken, user: { userId: admin.userId, capabilities: { canManageAllCourses: true, canModerate: true }, diff --git a/server/modules/auth-admin/auth-admin.services.js b/server/modules/auth-admin/auth-admin.services.js deleted file mode 100644 index 87ea3ded..00000000 --- a/server/modules/auth-admin/auth-admin.services.js +++ /dev/null @@ -1,57 +0,0 @@ -import config from "../../config/default.js"; -import jwt from "jsonwebtoken"; -import bcrypt from "bcrypt"; -import OTP from "./otp.model.js"; -import sendEmail from "../../services/email.js"; - -const adminJwtSecret = config.adminJwtSecret; - -export async function signAdminJWT(_id) { - const signed = jwt.sign(_id, adminJwtSecret); - return signed; -} - -export async function verifyAdminJWT(token) { - try { - const decoded = jwt.verify(token, adminJwtSecret); - return decoded; - } catch (error) { - return false; - } -} - -export async function tryAuthenticate(candidatePassword, hashedPassword) { - const match = await bcrypt.compare(candidatePassword, hashedPassword); - if (!match) return false; - return true; -} - -export async function generateOTP(username, email) { - await OTP.deleteMany({ email: email }); - const newOtp = Math.floor(100000 + Math.random() * 900000); - const otp = await OTP.create({ - username: username, - email: email, - otp: newOtp, - }); - // send email - sendEmail(email, "[COURSEHUB] CourseHub Admin Login OTP", `Your OTP is ${newOtp}`); - return otp; -} - -export async function verifyOTP(email, otp) { - const otpFromDB = await OTP.findOne({ email: email }); - if (!otpFromDB) return false; - if (otp !== otpFromDB.otp) { - sendEmail( - email, - "[COURSEHUB] Login attempt", - "Login attempt using your credentials but wrong OTP", - ); - await OTP.deleteMany({ email: email }); - return false; - } - await OTP.deleteMany({ email: email }); - sendEmail(email, "[COURSEHUB] Login successful", "Loggedin Successfully"); - return true; -} diff --git a/server/modules/auth-admin/otp.model.js b/server/modules/auth-admin/otp.model.js deleted file mode 100644 index c16bfc48..00000000 --- a/server/modules/auth-admin/otp.model.js +++ /dev/null @@ -1,16 +0,0 @@ -import mongoose from "mongoose"; -import joi from "joi"; - -// export const otpValidationSchema = joi.object({ -// username: joi.string().required(), -// otp: joi.number().required(), -// }); - -const otpSchema = new mongoose.Schema({ - username: { type: String, required: true }, - email: { type: String, required: true }, - otp: { type: Number, required: true }, -}); - -const OTP = mongoose.model("otp", otpSchema); -export default OTP; diff --git a/server/modules/auth/auth.controller.js b/server/modules/auth/auth.controller.js index fbc34151..a97296d6 100644 --- a/server/modules/auth/auth.controller.js +++ b/server/modules/auth/auth.controller.js @@ -1,7 +1,13 @@ import axios from "axios"; import qs from "querystring"; import AppError from "../../utils/appError.js"; -import catchAsync from "../../utils/catchAsync.js"; +import { beginOAuth, consumeOAuth, oauthEndpoint } from "../../services/oauth.js"; +import { + createSession, + setSessionCookie, + clearSessionCookie, + revokeSession, +} from "../../services/sessions.js"; import appConfig from "../../config/default.js"; @@ -21,7 +27,6 @@ import { parseCourseAllotmentsFromHtml, } from "../../utils/course.js"; -import { getRandomColor } from "../../utils/generateRandomColor.js"; import UserUpdate from "../user/userUpdate.model.js"; import BR from "../br/br.model.js"; import CourseAllotment from "../course/courseAllotment.model.js"; @@ -29,11 +34,7 @@ import logger from "../../utils/logger.js"; const normalizeEmail = (email) => email?.toString().trim().toLowerCase(); -export const loginHandler = (req, res) => { - res.redirect( - `https://login.microsoftonline.com/850aa78d-94e1-4bc6-9cf3-8c11b530701c/oauth2/v2.0/authorize?client_id=${clientid}&response_type=code&redirect_uri=${redirect_uri}&scope=user.read%20offline_access&state=12345`, - ); -}; +export const loginHandler = beginOAuth; // Helper function to get course names from database and courselist async function resolveCourseNames(courseCodes, dbCourses) { @@ -99,6 +100,7 @@ export const fetchCourses = async (rollNumber) => { const response = await axios.post(config.url, config.data, { headers: config.headers, + timeout: 30000, }); if (!response.data) { @@ -321,6 +323,7 @@ const getDepartment = async (access_token, roll) => { }; const response = await axios.get(config.url, { headers: config.headers, + timeout: 30000, }); return response.data.positions[0].detail.company.department; }; @@ -336,6 +339,7 @@ function calculateSemester(rollNumber) { } export const redirectHandler = async (req, res, next) => { + const attempt = await consumeOAuth(req, res); const { code } = req.query; const data = qs.stringify({ @@ -345,22 +349,23 @@ export const redirectHandler = async (req, res, next) => { scope: "user.read", grant_type: "authorization_code", code: code, + code_verifier: attempt.verifier, }); let newUser = false; const config = { method: "post", - url: `https://login.microsoftonline.com/850aa78d-94e1-4bc6-9cf3-8c11b530701c/oauth2/v2.0/token`, + url: oauthEndpoint("token"), headers: { "Content-Type": "application/x-www-form-urlencoded", - client_secret: clientSecret, }, data: data, }; const response = await axios.post(config.url, config.data, { headers: config.headers, + timeout: 30000, }); if (!response.data) { @@ -368,7 +373,6 @@ export const redirectHandler = async (req, res, next) => { } const AccessToken = response.data.access_token; - const RefreshToken = response.data.refresh_token; const userFromToken = await getUserFromToken(AccessToken); @@ -430,7 +434,7 @@ export const redirectHandler = async (req, res, next) => { await newUpdation.save(); } - const token = existingUser.generateJWT(); + const { token } = await createSession(existingUser._id, "student"); logger.metric?.("user_login", { value: 1, @@ -442,16 +446,12 @@ export const redirectHandler = async (req, res, next) => { }, }); - res.cookie("token", token, { - maxAge: 2073600000, - sameSite: "lax", - secure: false, - expires: new Date(Date.now() + 2073600000), - httpOnly: true, - }); + setSessionCookie(res, "student", token); if (newUser || (existingUser && !userUpdated)) { - return res.redirect(`${appConfig.clientURL}/loading`); + return res.redirect( + `${appConfig.clientURL}/loading?returnTo=${encodeURIComponent(attempt.returnTo)}`, + ); } const needsCourseSync = @@ -459,19 +459,16 @@ export const redirectHandler = async (req, res, next) => { (!Array.isArray(existingUser.previousCourses) || existingUser.previousCourses.length === 0); if (needsCourseSync) { - return res.redirect(`${appConfig.clientURL}/loading`); + return res.redirect( + `${appConfig.clientURL}/loading?returnTo=${encodeURIComponent(attempt.returnTo)}`, + ); } - res.redirect(`${appConfig.clientURL}/dashboard`); + res.redirect(new URL(attempt.returnTo, appConfig.clientURL).href); }; -export const logoutHandler = (req, res, next) => { - res.cookie("token", "loggedout", { - maxAge: 0, - sameSite: "lax", - secure: false, - expires: new Date(Date.now()), - httpOnly: true, - }); - res.redirect(appConfig.clientURL); +export const logoutHandler = async (req, res) => { + await revokeSession(req.session); + clearSessionCookie(res, "student"); + res.json({ success: true }); }; diff --git a/server/modules/auth/auth.routes.js b/server/modules/auth/auth.routes.js index b1424b06..c525681c 100644 --- a/server/modules/auth/auth.routes.js +++ b/server/modules/auth/auth.routes.js @@ -2,6 +2,9 @@ import express from "express"; const router = express.Router(); import isAuthenticated from "../../middleware/isAuthenticated.js"; import catchAsync from "../../utils/catchAsync.js"; +import { authThrottle } from "../../middleware/authThrottle.js"; +import { requireSession } from "../../middleware/sessionAuthentication.js"; +import AppError from "../../utils/appError.js"; import { redirectHandler, loginHandler, @@ -10,7 +13,17 @@ import { fetchCoursesForBr, } from "./auth.controller.js"; -router.get("/login", loginHandler); +router.get("/login", authThrottle("student-login"), catchAsync(loginHandler)); +router.get( + "/csrf", + (req, res, next) => { + const role = req.query.role; + if (!["student", "admin"].includes(role)) + return next(new AppError(400, "Invalid session role")); + requireSession(role)(req, res, next); + }, + (req, res) => res.json({ csrfToken: req.session.csrfToken }), +); router.post("/fetchCourses", isAuthenticated, async (req, res, next) => { try { @@ -48,8 +61,8 @@ router.post("/fetchCoursesForBr", isAuthenticated, async (req, res, next) => { } }); -router.get("/login/redirect", catchAsync(redirectHandler)); +router.get("/login/redirect", authThrottle("student-callback"), catchAsync(redirectHandler)); -router.get("/logout", logoutHandler); +router.post("/logout", isAuthenticated, catchAsync(logoutHandler)); export default router; diff --git a/server/modules/br/br.routes.js b/server/modules/br/br.routes.js index 780dfd3f..1fb92c8e 100644 --- a/server/modules/br/br.routes.js +++ b/server/modules/br/br.routes.js @@ -1,3 +1,4 @@ +import catchAsync from "../../utils/catchAsync.js"; import isAdmin from "../../middleware/isAdmin.js"; import express from "express"; import { @@ -11,11 +12,11 @@ import { const router = express.Router(); router.use(isAdmin); -router.post("/updateList", updateBRs); -router.post("/create", createBR); -router.get("/all", getAll); -router.get("/allBRs", getBRs); -router.get("/coursesWithoutBR", getCoursesWithoutBR); -router.delete("/delete", deleteBR); +router.post("/updateList", catchAsync(updateBRs)); +router.post("/create", catchAsync(createBR)); +router.get("/all", catchAsync(getAll)); +router.get("/allBRs", catchAsync(getBRs)); +router.get("/coursesWithoutBR", catchAsync(getCoursesWithoutBR)); +router.delete("/delete", catchAsync(deleteBR)); export default router; diff --git a/server/modules/session/session.model.js b/server/modules/session/session.model.js new file mode 100644 index 00000000..c424982b --- /dev/null +++ b/server/modules/session/session.model.js @@ -0,0 +1,15 @@ +import { model, Schema } from "mongoose"; + +const sessionSchema = new Schema( + { + _id: String, + actorId: { type: Schema.Types.ObjectId, required: true }, + role: { type: String, enum: ["student", "admin"], required: true }, + csrfToken: { type: String, required: true }, + expiresAt: { type: Date, required: true, expires: 0 }, + revokedAt: { type: Date, default: null }, + }, + { timestamps: true }, +); + +export default model("Session", sessionSchema); diff --git a/server/modules/user/user.controller.js b/server/modules/user/user.controller.js index cbd69d16..887fb0b0 100644 --- a/server/modules/user/user.controller.js +++ b/server/modules/user/user.controller.js @@ -81,24 +81,15 @@ export const getUser = async (req, res, next) => { } const userUpdated = await UserUpdate.findOne({ rollNumber: user.rollNumber }); - if (!userUpdated) { - res.cookie("token", "loggedout", { - maxAge: 0, - sameSite: "lax", - secure: false, - expires: new Date(Date.now()), - httpOnly: true, - }); - return res.status(401).json({ error: "User update required, please log in again" }); - } const actor = await actorFor(req); const isBranchRep = actor.isBR; const previousCourses = Array.isArray(user.previousCourses) ? user.previousCourses : []; - const needsCourseSync = isBranchRep && previousCourses.length === 0; + const needsCourseSync = !userUpdated || (isBranchRep && previousCourses.length === 0); const responseUser = { + csrfToken: req.session.csrfToken, _id: user._id, name: user.name, email: user.email, @@ -126,8 +117,10 @@ export const getUser = async (req, res, next) => { }; export const updateUserController = async (req, res) => { - const data = req.body; - updateUserData(req.user._id, data); + if (Object.keys(req.body).some((key) => key !== "newUserData")) + throw new AppError(400, "Unexpected profile fields", "VALIDATION_FAILED"); + const saved = await updateUserData(req.user._id, req.body.newUserData); + res.json(saved); }; export const addToFavouriteController = async (req, res, next) => { const data = req.body; @@ -172,7 +165,7 @@ export const removeFromFavouritesController = async (req, res, next) => { export const updateDeviceToken = async (req, res, next) => { const user = req.user; const { deviceToken } = req.body; - if (!deviceToken) return next(new AppError("Invalid device token")); + if (!deviceToken) return next(new AppError(400, "Invalid device token")); await User.findByIdAndUpdate(user._id, { deviceToken: deviceToken }); return res.json({ status: 200 }); }; diff --git a/server/modules/user/user.model.js b/server/modules/user/user.model.js index b5c6376f..bde94405 100644 --- a/server/modules/user/user.model.js +++ b/server/modules/user/user.model.js @@ -1,9 +1,7 @@ import { model, Schema } from "mongoose"; import Joi from "joi"; +import AppError from "../../utils/appError.js"; import axios from "axios"; -import jwt from "jsonwebtoken"; -import config from "../../config/default.js"; -import logger from "../../utils/logger.js"; import { getRandomColor } from "../../utils/generateRandomColor.js"; import { normalizeCourseCode } from "../../utils/course.js"; @@ -48,28 +46,6 @@ userSchema.pre("save", function (next) { next(); }); -userSchema.methods.generateJWT = function () { - var user = this; - var token = jwt.sign({ user: user._id, isBR: user.isBR }, config.jwtSecret, { - expiresIn: "24d", - }); - return token; -}; - -userSchema.statics.findByJWT = async function (token) { - try { - var user = this; - var decoded = jwt.verify(token, config.jwtSecret); - const id = decoded.user; - if (typeof id !== "string" || !/^[a-f0-9]{24}$/i.test(id)) return false; - const fetchedUser = await user.findOne({ _id: id }); - if (!fetchedUser) return false; - return fetchedUser; - } catch (error) { - return false; - } -}; - const User = model("User", userSchema); export default User; @@ -98,45 +74,39 @@ export const validateUser = function (obj) { return joiSchema.validate(obj); }; export const updateUserData = async (userId, userData) => { - User.findOne({ _id: userId }, async (err, doc) => { - if (err) { - logger.error("User update failed", { - attributes: { - dependency: "mongodb", - operation: "update-user", - outcome: "failure", - retryable: false, - }, - }); - } - if (userData.newUserData.newUserName) { - doc.name = userData.newUserData.newUserName; - await doc.save(); - } else if (userData.newUserData.newUserSem) { - doc.semester = userData.newUserData.newUserSem; - await doc.save(); - } - }); + const schema = Joi.object({ + newUserName: Joi.string().trim().min(1).max(120), + newUserSem: Joi.number().integer().min(1).max(12), + }) + .min(1) + .required(); + const { value, error } = schema.validate(userData, { abortEarly: false }); + if (error) + throw new AppError( + 400, + "Check the profile fields", + "VALIDATION_FAILED", + Object.fromEntries( + error.details.map((detail) => [detail.path.join("."), detail.message]), + ), + ); + const updates = {}; + if (value.newUserName !== undefined) updates.name = value.newUserName; + if (value.newUserSem !== undefined) updates.semester = value.newUserSem; + const saved = await User.findByIdAndUpdate( + userId, + { $set: updates }, + { new: true, runValidators: true }, + ); + if (!saved) throw new AppError(404, "Profile not found"); + return { name: saved.name, semester: saved.semester }; }; -export const getUserFromToken = async function (access_token) { - try { - var config = { - method: "get", - url: "https://graph.microsoft.com/v1.0/me", - headers: { - Authorization: `Bearer ${access_token}`, - }, - }; - const response = await axios.get(config.url, { - headers: config.headers, - }); - - return response; - } catch (error) { - return false; - } -}; +export const getUserFromToken = (accessToken) => + axios.get("https://graph.microsoft.com/v1.0/me", { + headers: { Authorization: `Bearer ${accessToken}` }, + timeout: 30000, + }); export const findUserWithEmail = async function (email) { const normalizedEmail = email?.toString().trim().toLowerCase(); diff --git a/server/scripts/generateOneDriveToken.js b/server/scripts/generateOneDriveToken.js index 69644938..96fb1a9e 100644 --- a/server/scripts/generateOneDriveToken.js +++ b/server/scripts/generateOneDriveToken.js @@ -1,151 +1,158 @@ -import { fileURLToPath } from "url"; +import { fileURLToPath } from "node:url"; import dotenv from "dotenv"; -import http from "http"; -import fs from "fs"; -import path from "path"; -import readline from "readline"; +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import readline from "node:readline/promises"; import axios from "axios"; -import qs from "querystring"; - -// Resolve paths relative to server/ directory (where .env and token files live) -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const serverDir = path.resolve(__dirname, ".."); - -dotenv.config({ path: path.join(serverDir, ".env") }); - -const clientId = process.env.AZURE_CLIENT_ID; -const clientSecret = process.env.AZURE_CLIENT_SECRET; -const tenantId = process.env.AZURE_TENANT_ID || "850aa78d-94e1-4bc6-9cf3-8c11b530701c"; -const redirectUri = process.env.REDIRECT_URI || "http://localhost:8080/api/auth/login/redirect"; - -if (!clientId || !clientSecret) { - console.error("❌ Missing AZURE_CLIENT_ID or AZURE_CLIENT_SECRET in server/.env file."); - process.exit(1); -} - -const scopes = "user.read offline_access files.readwrite"; - -const authUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?client_id=${clientId}&response_type=code&redirect_uri=${encodeURIComponent( - redirectUri, -)}&scope=${encodeURIComponent(scopes)}&state=12345`; - -console.log("\n========================================================================"); -console.log("🔑 COURSEHUB ONEDRIVE REFRESH TOKEN GENERATOR"); -console.log("========================================================================\n"); -console.log("1. Open the following URL in your browser:\n"); -console.log(` ${authUrl}\n`); -console.log("2. Sign in with the Coding Club (OneDrive owner) Microsoft account."); -console.log( - "3. Grant consent for requested permissions (user.read, offline_access, files.readwrite).\n", -); - -const exchangeCodeForTokens = async (code) => { - try { - console.log("⏳ Exchanging authorization code for tokens..."); - - const data = qs.stringify({ - client_id: clientId, - client_secret: clientSecret, - grant_type: "authorization_code", - code: code.trim(), - redirect_uri: redirectUri, - scope: scopes, - }); - +import { createOAuthProof, createLocalOAuthCallback } from "../utils/oauthProof.js"; + +const serverDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export async function provisionOneDriveToken() { + dotenv.config({ path: path.join(serverDir, ".env") }); + const clientId = process.env.AZURE_CLIENT_ID; + const clientSecret = process.env.AZURE_CLIENT_SECRET; + const tenantId = process.env.AZURE_TENANT_ID || "850aa78d-94e1-4bc6-9cf3-8c11b530701c"; + const redirectUri = + process.env.ONEDRIVE_REDIRECT_URI || + process.env.REDIRECT_URI || + "http://localhost:8080/api/auth/login/redirect"; + if (!clientId || !clientSecret) + throw new Error("Configure AZURE_CLIENT_ID and AZURE_CLIENT_SECRET in server/.env."); + const redirect = new URL(redirectUri); + const scope = "user.read offline_access files.readwrite"; + const proof = createOAuthProof(); + const consume = createLocalOAuthCallback(redirectUri, proof.state); + const endpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0`; + const url = new URL(`${endpoint}/authorize`); + url.search = new URLSearchParams({ + client_id: clientId, + response_type: "code", + redirect_uri: redirectUri, + scope, + state: proof.state, + code_challenge: proof.challenge, + code_challenge_method: "S256", + }).toString(); + console.log("Open this URL and sign in with the configured OneDrive owner account:"); + console.log(url.href); + console.log("This sign-in attempt expires in ten minutes."); + + const exchange = async (callbackUrl) => { + const code = consume(callbackUrl); const response = await axios.post( - `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, - data, - { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - }, + `${endpoint}/token`, + new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + scope, + code_verifier: proof.verifier, + }).toString(), + { headers: { "Content-Type": "application/x-www-form-urlencoded" }, timeout: 30000 }, ); - - if (!response.data || !response.data.refresh_token) { - throw new Error("No refresh_token returned in response from Microsoft"); + if (!response.data?.refresh_token) + throw new Error("Microsoft did not return a refresh token."); + for (const [name, value] of [ + ["onedrive-refresh-token.token", response.data.refresh_token], + ["onedrive-access-token.token", response.data.access_token], + ]) { + if (!value) continue; + const target = path.join(serverDir, name); + if (fs.existsSync(target)) fs.chmodSync(target, 0o600); + fs.writeFileSync(target, value, { encoding: "utf8", mode: 0o600 }); } - - const refreshToken = response.data.refresh_token; - const accessToken = response.data.access_token; - - const refreshTokenPath = path.join(serverDir, "onedrive-refresh-token.token"); - const accessTokenPath = path.join(serverDir, "onedrive-access-token.token"); - - fs.writeFileSync(refreshTokenPath, refreshToken, "utf-8"); - if (accessToken) { - fs.writeFileSync(accessTokenPath, accessToken, "utf-8"); + console.log("OneDrive tokens saved in the server directory with owner-only permissions."); + }; + const manual = async () => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 600000); + try { + const callbackUrl = await rl.question( + "Paste the complete redirected URL (including code and state): ", + { signal: controller.signal }, + ); + await exchange(callbackUrl.trim()); + } finally { + clearTimeout(timer); + rl.close(); } - - console.log("\n========================================================================"); - console.log("✅ REFRESH TOKEN SUCCESSFULLY GENERATED & SAVED!"); - console.log("========================================================================"); - console.log(`📁 File written: ${refreshTokenPath}`); - console.log("\nRefresh Token:\n"); - console.log(refreshToken); - console.log("\n========================================================================\n"); - - return true; - } catch (err) { - const errorData = err?.response?.data; - console.error("\n❌ Failed to exchange code for token:"); - console.error(errorData?.error_description || errorData?.error || err.message); - return false; + }; + if ( + redirect.protocol !== "http:" || + !["localhost", "127.0.0.1", "[::1]"].includes(redirect.hostname) + ) + return manual(); + const port = Number(redirect.port || 80); + const listener = http.createServer(); + try { + await new Promise((resolve, reject) => { + listener.once("error", reject); + listener.listen(port, redirect.hostname === "[::1]" ? "::1" : "127.0.0.1", resolve); + }); + } catch (error) { + if (error.code === "EADDRINUSE") return manual(); + throw error; } -}; - -const parsedUrl = new URL(redirectUri); -const port = parseInt(parsedUrl.port || "8080", 10); - -const server = http.createServer(async (req, res) => { - const reqUrl = new URL(req.url, `http://${req.headers.host}`); - if (reqUrl.pathname === parsedUrl.pathname || reqUrl.pathname === "/api/auth/login/redirect") { - const code = reqUrl.searchParams.get("code"); - if (code) { - res.writeHead(200, { "Content-Type": "text/html" }); - res.end(` -
-

✅ OneDrive Authorization Received!

-

Generating and saving refresh token in terminal...

-

You can close this window now.

-
- `); - const success = await exchangeCodeForTokens(code); - server.close(() => { - process.exit(success ? 0 : 1); + console.log( + `Listening on the configured loopback callback: ${redirect.origin}${redirect.pathname}`, + ); + try { + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("Sign-in timed out. Start again.")), + 600000, + ); + let processing = false; + listener.on("request", (req, res) => { + const callback = new URL(req.url, redirect.origin); + if (callback.pathname !== redirect.pathname) { + res.writeHead(404).end(); + return; + } + if (processing) { + res.writeHead(409).end("Sign-in is already processing."); + return; + } + processing = true; + exchange(callback.href) + .then(() => { + res.writeHead(200, { + "Content-Type": "text/plain", + "Cache-Control": "no-store", + }).end("OneDrive tokens saved. You can close this window."); + clearTimeout(timer); + resolve(); + }) + .catch(() => { + res.writeHead(400, { + "Content-Type": "text/plain", + "Cache-Control": "no-store", + }).end("Sign-in could not be completed. Start again from the terminal."); + clearTimeout(timer); + reject( + new Error( + "OneDrive sign-in failed. Check the callback configuration and start again.", + ), + ); + }); }); - return; - } - } - res.writeHead(404); - res.end(); -}); - -server.on("error", (err) => { - if (err.code === "EADDRINUSE") { - console.log( - `ℹ️ Note: Port ${port} is currently in use (e.g. CourseHub dev server is running).`, - ); - console.log( - " After signing in, copy the 'code' parameter from the redirected browser URL bar.\n", - ); - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, }); - - rl.question("Paste the 'code' parameter here: ", async (inputCode) => { - rl.close(); - const success = await exchangeCodeForTokens(inputCode); - process.exit(success ? 0 : 1); - }); - } else { - console.error("Server error:", err); + } finally { + listener.closeAllConnections(); + await new Promise((resolve) => listener.close(resolve)); } -}); +} -server.listen(port, () => { - console.log(`🌐 Listening on http://localhost:${port} for automated callback redirect...\n`); -}); +if (process.argv[1] === fileURLToPath(import.meta.url)) { + provisionOneDriveToken().catch(() => { + console.error( + "OneDrive sign-in failed. Check the Azure credentials and registered redirect URI, then start again.", + ); + process.exitCode = 1; + }); +} diff --git a/server/services/UploadFile.js b/server/services/UploadFile.js index 0e569821..0cd44dbe 100644 --- a/server/services/UploadFile.js +++ b/server/services/UploadFile.js @@ -11,15 +11,15 @@ import { uploadThumbnail } from "./imagekit.js"; const parent_item_id = process.env.ONEDRIVE_FOLDER_ID; async function createUploadSession(folderId, fileName) { - const access_token = await getAccessToken(); - const url = `https://graph.microsoft.com/v1.0/me/drive/items/${folderId}:/${encodeURIComponent(fileName)}:/createUploadSession`; - const config = { - headers: { - Authorization: `Bearer ${access_token}`, - }, - }; - try { + const access_token = await getAccessToken(); + const url = `https://graph.microsoft.com/v1.0/me/drive/items/${folderId}:/${encodeURIComponent(fileName)}:/createUploadSession`; + const config = { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }; + const { data } = await axios.post(url, {}, config); return { url: data?.uploadUrl, access_token }; } catch (error) { @@ -149,18 +149,18 @@ async function DeleteFile(fileId) { } async function RenameOneDriveFile(fileId, newName) { - const access_token = await getAccessToken(); - const url = `https://graph.microsoft.com/v1.0/me/drive/items/${fileId}`; - const config = { - headers: { - Authorization: `Bearer ${access_token}`, - "Content-Type": "application/json", - }, - }; - const _data = { - name: randomUUID() + path.extname(newName), - }; try { + const access_token = await getAccessToken(); + const url = `https://graph.microsoft.com/v1.0/me/drive/items/${fileId}`; + const config = { + headers: { + Authorization: `Bearer ${access_token}`, + "Content-Type": "application/json", + }, + }; + const _data = { + name: randomUUID() + path.extname(newName), + }; const { data } = await axios.patch(url, _data, config); return data; } catch (err) { diff --git a/server/services/oauth.js b/server/services/oauth.js new file mode 100644 index 00000000..e87e4118 --- /dev/null +++ b/server/services/oauth.js @@ -0,0 +1,97 @@ +import { randomBytes } from "node:crypto"; +import { model, Schema } from "mongoose"; +import { cookieOptions } from "../config/security.js"; +import config from "../config/default.js"; +import AppError from "../utils/appError.js"; +import { createOAuthProof, hashOAuthValue as hash } from "../utils/oauthProof.js"; + +export const OAuthAttempt = model( + "OAuthAttempt", + new Schema({ + _id: String, + bindingHash: String, + verifier: String, + returnTo: String, + expiresAt: { type: Date, expires: 0 }, + }), +); +const flowCookie = "coursehubOAuth"; +const flowOptions = () => ({ ...cookieOptions(), sameSite: "lax", path: "/api/auth" }); +export const oauthEndpoint = (part) => + `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID || "850aa78d-94e1-4bc6-9cf3-8c11b530701c"}/oauth2/v2.0/${part}`; + +export function safeReturnTo(value) { + if ( + typeof value !== "string" || + value.length > 2048 || + !value.startsWith("/") || + value.startsWith("//") || + /[\\\r\n]/.test(value) + ) + return "/dashboard"; + const base = new URL(config.clientURL); + const target = new URL(value, base); + if (target.origin !== base.origin || target.pathname.startsWith("/api/")) return "/dashboard"; + return target.pathname + target.search + target.hash; +} + +export async function beginOAuth(req, res) { + const { state, verifier, challenge } = createOAuthProof(); + const binding = randomBytes(32).toString("base64url"); + await OAuthAttempt.create({ + _id: hash(state), + bindingHash: hash(binding), + verifier, + returnTo: safeReturnTo(req.query.returnTo), + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + }); + res.cookie(flowCookie, binding, { ...flowOptions(), maxAge: 10 * 60 * 1000 }); + const url = new URL(oauthEndpoint("authorize")); + url.search = new URLSearchParams({ + client_id: process.env.AZURE_CLIENT_ID, + response_type: "code", + redirect_uri: process.env.REDIRECT_URI, + scope: "user.read", + state, + code_challenge: challenge, + code_challenge_method: "S256", + }).toString(); + res.setHeader("Cache-Control", "no-store"); + res.redirect(url.href); +} + +export async function consumeOAuth(req, res) { + res.clearCookie(flowCookie, flowOptions()); + const { state } = req.query; + const binding = req.cookies?.[flowCookie]; + if ( + typeof state !== "string" || + !/^[\w-]{43}$/.test(state) || + typeof binding !== "string" || + !/^[\w-]{43}$/.test(binding) + ) + throw new AppError( + 400, + "Sign-in expired or could not be verified. Start again.", + "OAUTH_STATE_INVALID", + ); + const attempt = await OAuthAttempt.findOneAndDelete({ + _id: hash(state), + bindingHash: hash(binding), + expiresAt: { $gt: new Date() }, + }); + if (!attempt) + throw new AppError( + 400, + "Sign-in expired or could not be verified. Start again.", + "OAUTH_STATE_INVALID", + ); + if ( + req.query.error || + typeof req.query.code !== "string" || + !req.query.code || + req.query.code.length > 2048 + ) + throw new AppError(400, "Sign-in was not completed. Start again.", "OAUTH_CANCELLED"); + return attempt; +} diff --git a/server/services/sessions.js b/server/services/sessions.js new file mode 100644 index 00000000..b70301d6 --- /dev/null +++ b/server/services/sessions.js @@ -0,0 +1,73 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import jwt from "jsonwebtoken"; +import config from "../config/default.js"; +import { cookieNames, cookieOptions, sessionSeconds } from "../config/security.js"; +import Session from "../modules/session/session.model.js"; + +const signingKey = (role) => (role === "admin" ? config.adminJwtSecret : config.jwtSecret); +const claimsOptions = { issuer: "coursehub", audience: "coursehub-api", algorithms: ["HS256"] }; + +export async function createSession(actorId, role) { + const id = String(actorId); + if (!/^[a-f0-9]{24}$/i.test(id) || !Object.hasOwn(sessionSeconds, role)) + throw new Error("Invalid session identity"); + const jti = randomUUID(); + const expiresIn = sessionSeconds[role]; + const token = jwt.sign({ sub: id, role }, signingKey(role), { + algorithm: "HS256", + issuer: claimsOptions.issuer, + audience: claimsOptions.audience, + jwtid: jti, + expiresIn, + }); + const session = await Session.create({ + _id: jti, + actorId: id, + role, + csrfToken: randomBytes(32).toString("base64url"), + expiresAt: new Date(jwt.decode(token).exp * 1000), + }); + return { token, session }; +} + +export async function readSession(token, role) { + let claims; + try { + claims = jwt.verify(token, signingKey(role), claimsOptions); + } catch { + return null; + } + if ( + claims.role !== role || + typeof claims.sub !== "string" || + !/^[a-f0-9]{24}$/i.test(claims.sub) || + typeof claims.jti !== "string" || + !/^[a-f0-9-]{36}$/i.test(claims.jti) || + !Number.isInteger(claims.exp) + ) + return null; + // Database failures must propagate as request errors + return Session.findOne({ + _id: claims.jti, + actorId: claims.sub, + role, + revokedAt: null, + expiresAt: { $gt: new Date() }, + }); +} + +export function setSessionCookie(res, role, token) { + res.cookie(cookieNames[role], token, { + ...cookieOptions(), + maxAge: sessionSeconds[role] * 1000, + }); +} +export function clearSessionCookie(res, role) { + res.clearCookie(cookieNames[role], cookieOptions()); +} +export async function revokeSession(session) { + await Session.updateOne( + { _id: session._id, revokedAt: null }, + { $set: { revokedAt: new Date() } }, + ); +} diff --git a/server/test/browser/library-access.test.js b/server/test/browser/library-access.test.js index b794b3be..67d49444 100644 --- a/server/test/browser/library-access.test.js +++ b/server/test/browser/library-access.test.js @@ -100,11 +100,15 @@ async function openPage( if (url.pathname === "/api/user") { if (holdSession) await holdSession; status = sessionStatus ? sessionStatus() : hasSession ? 200 : 401; - data = status === 200 ? actor : { message: "Unable to restore session" }; + data = + status === 200 + ? { ...actor, csrfToken: "c".repeat(43) } + : { message: "Unable to restore session" }; } else if (!hasSession) { status = 401; data = { message: "Sign in to continue" }; - } else if (url.pathname === "/api/course/CS101") + } else if (url.pathname === "/api/auth/csrf") data = { csrfToken: "c".repeat(43) }; + else if (url.pathname === "/api/course/CS101") data = { found: true, ...course, @@ -186,7 +190,11 @@ for (const width of [1440, 390]) { sessionStatus: () => 401, }); await page.goto(`${frontend}/browse/CS101/${folder._id}`); - await page.waitForURL(frontend + "/"); + await page.waitForURL( + (url) => + url.pathname === "/" && + url.searchParams.get("returnTo") === `/browse/CS101/${folder._id}`, + ); await page.getByText("Sign in with Microsoft", { exact: false }).waitFor(); assert.ok(requests.length > 0); assert.ok(requests.every((request) => request.path === "/api/user")); @@ -254,6 +262,8 @@ test("FilePond sends credentials and the contribution association across origins assert.equal(manifest.uploadedBy, undefined); assert.equal(manifest.approved, undefined); assert.equal(uploaded.headers.username, undefined); + assert.equal(uploaded.headers["x-csrf-token"], "c".repeat(43)); + assert.equal(created.headers["x-csrf-token"], "c".repeat(43)); assert.ok(uploaded.headers["content-type"].startsWith("multipart/form-data")); assert.ok(uploaded.body.includes("Test notes")); }); diff --git a/server/test/browser/session-profile.test.js b/server/test/browser/session-profile.test.js new file mode 100644 index 00000000..7ac33c3b --- /dev/null +++ b/server/test/browser/session-profile.test.js @@ -0,0 +1,295 @@ +import assert from "node:assert/strict"; +import { test, before, after } from "node:test"; +import { createRequire } from "node:module"; +import { student } from "../fixtures/library.js"; +const { chromium } = createRequire(import.meta.url)(process.env.PLAYWRIGHT_MODULE || "playwright"); +const frontend = process.env.BROWSER_CLIENT_ORIGIN || "http://127.0.0.1:4186"; +const api = process.env.BROWSER_API_ORIGIN || "http://127.0.0.1:4185"; +const adminOrigin = process.env.BROWSER_ADMIN_ORIGIN || "http://127.0.0.1:4184"; +const csrf = "c".repeat(43); +let browser; +before(async () => { + browser = await chromium.launch({ + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined, + }); +}); +after(async () => browser?.close()); + +async function studentPage( + t, + { width = 1440, signedIn = true, save, logout, csrfMismatch = false } = {}, +) { + const context = await browser.newContext({ + viewport: { width, height: 900 }, + locale: "en-US", + timezoneId: "Asia/Kolkata", + }); + const page = await context.newPage(); + const errors = [], + requests = []; + let authenticated = signedIn; + let name = student.name; + if (signedIn) + await context.addCookies([ + { + name: "token", + value: "synthetic-session", + url: api, + httpOnly: true, + sameSite: "Lax", + }, + ]); + page.on("pageerror", (e) => errors.push(e.message)); + t.after(async () => { + await context.close(); + assert.deepEqual(errors, []); + }); + await context.route("**/*", async (route) => { + const request = route.request(), + url = new URL(request.url()); + if (url.origin === frontend) return route.continue(); + if (url.origin !== api) return route.abort(); + const headers = await request.allHeaders(); + requests.push({ + path: url.pathname, + method: request.method(), + headers, + body: request.postData(), + }); + let status = 200, + data = {}; + if (url.pathname === "/api/user") { + status = authenticated ? 200 : 401; + data = authenticated + ? { ...student, name, csrfToken: csrfMismatch ? "s".repeat(43) : csrf } + : { message: "Sign in to continue" }; + } else if (url.pathname === "/api/auth/csrf") data = { csrfToken: csrf }; + else if (url.pathname === "/api/contribution/") data = []; + else if (url.pathname === "/api/user/update") { + assert.ok(headers.cookie?.includes("token=synthetic-session")); + assert.equal(headers.authorization, undefined); + assert.equal(headers["x-session-role"], "student"); + if (headers["x-csrf-token"] !== csrf) { + status = 403; + data = { code: "CSRF_INVALID", message: "Refresh your session" }; + } else { + const result = await save?.(JSON.parse(request.postData())); + status = result?.status || 200; + data = result?.data || { + name: JSON.parse(request.postData()).newUserData.newUserName, + semester: student.semester, + }; + if (status === 200) name = data.name; + } + } else if (url.pathname === "/api/auth/logout") { + assert.equal(request.method(), "POST"); + assert.equal(headers["x-csrf-token"], csrf); + const result = await logout?.(); + status = result?.status || 200; + data = status === 200 ? { success: true } : { message: "Unavailable" }; + if (status === 200) authenticated = false; + } else if (url.pathname === "/api/auth/login") { + authenticated = true; + await context.addCookies([ + { + name: "token", + value: "synthetic-session", + url: api, + httpOnly: true, + sameSite: "Lax", + }, + ]); + return route.fulfill({ + status: 302, + headers: { location: frontend + url.searchParams.get("returnTo") }, + }); + } else if (url.pathname === "/api/event/examdates") + data = { dates: { midSem: "2026-09-15", endSem: "2026-11-25" } }; + else return route.fulfill({ status: 404, contentType: "application/json", body: "{}" }); + await route + .fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(data), + headers: { + "access-control-allow-origin": frontend, + "access-control-allow-credentials": "true", + }, + }) + .catch(() => {}); + }); + return { page, requests }; +} + +for (const width of [1440, 390]) { + test(`profile waits for confirmed persistence and uses the server-saved name at ${width}px`, async (t) => { + let release; + const waiting = new Promise((resolve) => { + release = resolve; + }); + const { page, requests } = await studentPage(t, { + width, + save: async () => { + await waiting; + return { data: { name: "Confirmed Student", semester: student.semester } }; + }, + }); + await page.goto(frontend + "/profile"); + await page.getByRole("button", { name: "Edit name", exact: true }).click(); + await page.getByRole("textbox", { name: "Name", exact: true }).fill("Requested Student"); + await page.getByRole("button", { name: "Save name", exact: true }).click(); + await page.getByRole("status").filter({ hasText: "Saving…" }).waitFor(); + assert.equal( + await page.getByRole("textbox", { name: "Name", exact: true }).inputValue(), + "Requested Student", + ); + assert.ok(await page.getByRole("button", { name: "Save name", exact: true }).isDisabled()); + assert.equal(await page.getByText("Profile updated", { exact: true }).count(), 0); + release(); + await page.getByText("Profile updated", { exact: true }).waitFor(); + await page + .locator(".front_banner header") + .filter({ hasText: "Confirmed Student" }) + .waitFor(); + assert.equal(requests.filter((r) => r.path === "/api/user/update").length, 1); + }); + test(`failed profile save retains edits and supports keyboard retry at ${width}px`, async (t) => { + let attempts = 0; + const { page } = await studentPage(t, { + width, + save: () => + ++attempts === 1 + ? { status: 503, data: { message: "The service is temporarily unavailable." } } + : undefined, + }); + await page.goto(frontend + "/profile"); + await page.getByRole("button", { name: "Edit name", exact: true }).click(); + const input = page.getByRole("textbox", { name: "Name", exact: true }); + await input.fill("Retained Student"); + await input.press("Enter"); + await page + .getByRole("alert") + .filter({ hasText: "The service is temporarily unavailable." }) + .waitFor(); + assert.equal(await input.inputValue(), "Retained Student"); + assert.equal(await page.getByText("Profile updated", { exact: true }).count(), 0); + await input.press("Enter"); + await page.getByText("Profile updated", { exact: true }).waitFor(); + assert.equal(attempts, 2); + }); + test(`sign-in preserves a profile destination with query and hash at ${width}px`, async (t) => { + const { page } = await studentPage(t, { width, signedIn: false }); + const destination = "/profile?tab=contributions#pending"; + await page.goto(frontend + destination); + await page.getByText("Sign in with Microsoft", { exact: false }).waitFor(); + assert.equal(new URL(page.url()).searchParams.get("returnTo"), destination); + await page.getByText("Sign in with Microsoft", { exact: false }).click(); + await page.getByRole("button", { name: "Edit name", exact: true }).waitFor(); + assert.equal(page.url(), frontend + destination); + }); +} + +test("a CSRF token from a previous session is refreshed after rejection before retrying the save", async (t) => { + const { page, requests } = await studentPage(t, { csrfMismatch: true }); + await page.goto(frontend + "/profile"); + await page.getByRole("button", { name: "Edit name", exact: true }).click(); + await page.getByRole("textbox", { name: "Name", exact: true }).fill("Fresh Session"); + await page.getByRole("button", { name: "Save name", exact: true }).click(); + await page.getByText("Profile updated", { exact: true }).waitFor(); + const saves = requests.filter((r) => r.path === "/api/user/update"); + assert.equal(saves.length, 2); + assert.equal(saves[0].headers["x-csrf-token"], "s".repeat(43)); + assert.equal(saves[1].headers["x-csrf-token"], csrf); +}); + +test("logout failure keeps the student signed in until a successful POST response", async (t) => { + let attempts = 0; + const { page } = await studentPage(t, { + logout: () => ({ status: ++attempts === 1 ? 503 : 200 }), + }); + await page.goto(frontend + "/profile"); + await page.getByText("Log Out", { exact: true }).first().click(); + await page.getByText("Could not log out. Please try again.", { exact: true }).waitFor(); + assert.equal(new URL(page.url()).pathname, "/profile"); + await page.getByText("Log Out", { exact: true }).first().click(); + await page.getByText("Sign in with Microsoft", { exact: false }).waitFor(); + assert.equal(new URL(page.url()).pathname, "/"); +}); + +for (const width of [1440, 390]) + test(`administrator login preserves destination and submits CSRF-protected linking at ${width}px`, async (t) => { + const context = await browser.newContext({ viewport: { width, height: 900 } }); + const page = await context.newPage(); + let authenticated = false; + const requests = [], + errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + t.after(async () => { + await context.close(); + assert.deepEqual(errors, []); + }); + await context.route("**/*", async (route) => { + const request = route.request(), + url = new URL(request.url()); + if (url.origin !== adminOrigin) return route.abort(); + if (!url.pathname.startsWith("/api/")) return route.continue(); + const headers = await request.allHeaders(); + requests.push({ path: url.pathname, method: request.method(), headers }); + let status = 200, + data = {}; + if (url.pathname === "/api/admin/") { + status = authenticated ? 200 : 401; + data = { csrfToken: csrf, user: { userId: "synthetic-admin" } }; + } else if (url.pathname === "/api/admin/auth/login") { + authenticated = true; + await context.addCookies([ + { + name: "adminToken", + value: "synthetic-admin", + url: adminOrigin, + httpOnly: true, + sameSite: "Lax", + }, + ]); + data = { success: true, csrfToken: csrf }; + } else if (url.pathname === "/api/auth/csrf") data = { csrfToken: csrf }; + else if (url.pathname.endsWith("/link") || url.pathname.endsWith("/bulk-link")) { + assert.ok(headers.cookie?.includes("adminToken=synthetic-admin")); + assert.equal(headers["x-csrf-token"], csrf); + assert.equal(headers.authorization, undefined); + data = { summary: { success: 1, failed: 0, errors: [] } }; + if (url.pathname.endsWith("/bulk-link")) + assert.match(headers["content-type"], /^multipart\/form-data; boundary=/); + } else { + status = 404; + } + return route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(data), + }); + }); + const destination = "/admin/course-linking?mode=manual#courses"; + await page.goto(adminOrigin + destination); + await page.getByRole("button", { name: "Sign In", exact: true }).waitFor(); + assert.equal(new URL(page.url()).searchParams.get("returnTo"), destination); + await page.getByPlaceholder("admin id").fill("synthetic-admin"); + await page.locator("input[type=password]").fill("synthetic-password"); + await page.getByRole("button", { name: "Sign In", exact: true }).click(); + await page.getByRole("heading", { name: "Course Linking", exact: true }).waitFor(); + assert.equal(page.url(), adminOrigin + destination); + await page.getByPlaceholder("e.g., CS101", { exact: true }).fill("CS101"); + await page.getByPlaceholder("e.g., CSN101", { exact: true }).fill("CSN101"); + await page.getByRole("button", { name: "Link Course", exact: true }).click(); + await page.getByText("Successfully linked CS101 to CSN101", { exact: true }).waitFor(); + await page.getByRole("button", { name: "Bulk Link (CSV)", exact: true }).click(); + await page.locator("#csv-upload").setInputFiles({ + name: "links.csv", + mimeType: "text/csv", + buffer: Buffer.from("CS101,CSN101\n"), + }); + await page.getByRole("button", { name: "Upload and Link", exact: true }).click(); + await page.getByRole("heading", { name: "Linking Summary", exact: true }).waitFor(); + assert.ok(requests.some((r) => r.path.endsWith("/bulk-link"))); + }); diff --git a/server/test/fixtures/permissions.js b/server/test/fixtures/permissions.js index 8900f0ad..4a054dd8 100644 --- a/server/test/fixtures/permissions.js +++ b/server/test/fixtures/permissions.js @@ -6,6 +6,7 @@ import Course, { FolderModel, FileModel } from "../../modules/course/course.mode import Contribution from "../../modules/contribution/contribution.model.js"; import { academicPeriod } from "../../services/authorization.js"; import { student } from "./library.js"; +import { sessionHeaders } from "./sessions.js"; export async function permissionFixtures() { const period = academicPeriod(); @@ -45,7 +46,7 @@ export async function permissionFixtures() { actors[role] = { person, headers: { - cookie: `token=${person.generateJWT()}`, + ...(await sessionHeaders(person.id)), "content-type": "application/json", }, }; diff --git a/server/test/fixtures/sessions.js b/server/test/fixtures/sessions.js new file mode 100644 index 00000000..2967d3f5 --- /dev/null +++ b/server/test/fixtures/sessions.js @@ -0,0 +1,14 @@ +import { createSession } from "../../services/sessions.js"; +import { cookieNames } from "../../config/security.js"; + +export const testOrigin = "http://client.coursehub.test"; +export const testCsrfToken = "c".repeat(43); +export async function sessionHeaders(actorId, role = "student") { + const { token, session } = await createSession(actorId, role); + return { + cookie: `${cookieNames[role]}=${token}`, + origin: testOrigin, + "x-csrf-token": session.csrfToken, + "x-session-role": role, + }; +} diff --git a/server/test/integration/server.test.js b/server/test/integration/server.test.js index 40f9953c..63187650 100644 --- a/server/test/integration/server.test.js +++ b/server/test/integration/server.test.js @@ -1,4 +1,5 @@ import "../support/environment.js"; +import { sessionHeaders, testOrigin } from "../fixtures/sessions.js"; import assert from "node:assert/strict"; import { after, before, beforeEach, test } from "node:test"; import { randomUUID } from "node:crypto"; @@ -94,17 +95,19 @@ test("provisioned password hashes work with the existing administrator login", a assert.equal(await stored.comparePassword("wrong-fixture-password"), false); const response = await fetch(origin + "/api/admin/auth/login", { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", origin: testOrigin }, body: JSON.stringify({ userId: "login-fixture", password }), }); assert.equal(response.status, 200); - assert.equal((await response.json()).success, true); + const loginData = await response.json(); + assert.equal(loginData.success, true); assert.match(response.headers.get("set-cookie"), /adminToken=/); const headers = { cookie: response.headers.get("set-cookie").split(";")[0] }; const session = await fetch(origin + "/api/admin/", { headers }); assert.equal(session.status, 200); assert.deepEqual(await session.json(), { + csrfToken: loginData.csrfToken, user: { userId: "login-fixture", capabilities: { canManageAllCourses: true, canModerate: true }, @@ -230,7 +233,7 @@ test("authenticated browsing and multipart upload persist content with mocked Gr await FolderModel.create({ ...folder, children: [] }); await FolderModel.create({ ...year, children: [folder._id] }); await Course.create({ ...course, children: [year._id] }); - const headers = { cookie: `token=${person.generateJWT()}` }; + const headers = await sessionHeaders(person.id); for (const route of [ "/api/user", "/api/course/CS101", @@ -384,12 +387,14 @@ test("an administrator session can explicitly create a course", async () => { await provisionAdmin({ userId: "course-manager-fixture", password }); const login = await fetch(origin + "/api/admin/auth/login", { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", origin: testOrigin }, body: JSON.stringify({ userId: "course-manager-fixture", password }), }); assert.equal(login.status, 200); const headers = { cookie: login.headers.get("set-cookie").split(";")[0], + origin: testOrigin, + "x-csrf-token": (await login.json()).csrfToken, "content-type": "application/json", }; const created = await fetch(origin + "/api/course/create/QA202", { @@ -409,3 +414,7 @@ test("an administrator session can explicitly create a course", async () => { import { exerciseCoursePermissions } from "../support/course-permissions.js"; test("course permission and moderation boundaries", async (t) => exerciseCoursePermissions(t, origin)); + +import { exerciseSessionSecurity } from "../support/session-security.js"; +test("session, OAuth, CSRF and profile boundaries", async (t) => + exerciseSessionSecurity(t, origin)); diff --git a/server/test/library-authentication.test.js b/server/test/library-authentication.test.js index 4b28a679..843ac692 100644 --- a/server/test/library-authentication.test.js +++ b/server/test/library-authentication.test.js @@ -1,4 +1,9 @@ import "./support/environment.js"; +import { randomUUID } from "node:crypto"; +import Session from "../modules/session/session.model.js"; +import { OAuthAttempt } from "../services/oauth.js"; +import { AuthRateLimit } from "../middleware/authThrottle.js"; +import { testOrigin, testCsrfToken } from "./fixtures/sessions.js"; import assert from "node:assert/strict"; import { before, beforeEach, after, test } from "node:test"; import { once } from "node:events"; @@ -32,9 +37,20 @@ after(async () => { await new Promise((resolve) => listener.close(resolve)); }); -const studentToken = (options = {}) => - jwt.sign({ user: student._id }, process.env.JWT_SECRET, options); -const adminToken = () => jwt.sign(administrator._id, process.env.ADMIN_JWT_SECRET); +const signedToken = (role, id, options = {}) => + jwt.sign( + { sub: id, role }, + role === "student" ? process.env.JWT_SECRET : process.env.ADMIN_JWT_SECRET, + { + issuer: "coursehub", + audience: "coursehub-api", + jwtid: randomUUID(), + expiresIn: role === "student" ? "24d" : "7d", + ...options, + }, + ); +const studentToken = (options = {}) => signedToken("student", student._id, options); +const adminToken = () => signedToken("admin", administrator._id); const query = (value) => ({ collation() { return this; @@ -53,6 +69,14 @@ const query = (value) => ({ }, }); function signedIn(t, actor = student) { + t.mock.method(Session, "findOne", (filter) => + query({ + _id: filter._id, + actorId: filter.actorId, + role: filter.role, + csrfToken: testCsrfToken, + }), + ); t.mock.method(BR, "findOne", () => query(null)); t.mock.method(CourseAllotment, "find", () => query([{ ...academicPeriod(), courses: ["CS101"] }]), @@ -62,7 +86,7 @@ function signedIn(t, actor = student) { t.mock.method(FileModel, "findById", async () => libraryFile); t.mock.method(Contribution, "find", () => query([])); t.mock.method(Course, "find", () => query([course])); - t.mock.method(User, "findOne", async ({ _id }) => (_id === actor._id ? actor : null)); + t.mock.method(User, "findById", async (id) => (String(id) === actor._id ? actor : null)); t.mock.method(Admin, "findById", async (id) => id === administrator._id ? administrator : null, ); @@ -109,9 +133,7 @@ function blockIO(t) { const publicActions = new Set([ "GET /api/auth/login", "GET /api/auth/login/redirect", - "GET /api/auth/logout", "POST /api/admin/auth/login", - "POST /api/admin/auth/logout", ]); const protectedRoutes = []; for (const [prefix, module] of [ @@ -137,8 +159,15 @@ for (const [prefix, module] of [ const concrete = path.replace(/:([a-zA-Z]+)/g, (_, name) => name === "code" ? "CS101" : name === "type" ? "file" : libraryFile._id, ); - protectedRoutes.push([method.toUpperCase(), concrete]); - if (method === "get") protectedRoutes.push(["HEAD", concrete]); + protectedRoutes.push([ + method.toUpperCase(), + concrete + (route.path === "/csrf" ? "?role=student" : ""), + ]); + if (method === "get") + protectedRoutes.push([ + "HEAD", + concrete + (route.path === "/csrf" ? "?role=student" : ""), + ]); } } } @@ -188,6 +217,8 @@ test("public authentication entry and minimal health work; unknown API requests const health = await fetch(origin + "/api/health"); assert.equal(health.status, 200); assert.deepEqual(await health.json(), { status: "ok" }); + t.mock.method(AuthRateLimit, "findOneAndUpdate", () => query({ count: 1 })); + t.mock.method(OAuthAttempt, "create", async (data) => data); const login = await fetch(origin + "/api/auth/login", { redirect: "manual" }); assert.equal(login.status, 302); assert.equal(new URL(login.headers.get("location")).hostname, "login.microsoftonline.com"); @@ -204,7 +235,7 @@ test("student and administrator cookie/bearer sessions can read courses", async t.mock.method(Course, "find", () => query([course])); t.mock.method(Course, "findOne", () => query({ toObject: () => structuredClone(course) })); for (const headers of [ - { cookie: `token=${studentToken()}` }, + { cookie: `token=${studentToken()}`, origin: testOrigin, "x-csrf-token": testCsrfToken }, { authorization: `Bearer ${studentToken()}` }, { cookie: `adminToken=${adminToken()}` }, { authorization: `Bearer ${adminToken()}` }, @@ -226,7 +257,11 @@ test("missing-course reads return 404 without creating content", async (t) => { assert.fail("GET must not create a course"), ); const response = await fetch(origin + "/api/course/MISSING", { - headers: { cookie: `token=${studentToken()}` }, + headers: { + cookie: `token=${studentToken()}`, + origin: testOrigin, + "x-csrf-token": testCsrfToken, + }, }); assert.equal(response.status, 404); assert.equal(create.mock.callCount(), 0); @@ -244,7 +279,11 @@ test("students cannot create courses or perform BR/administrator actions", async ]) { const response = await fetch(origin + path, { method, - headers: { cookie: `token=${studentToken()}` }, + headers: { + cookie: `token=${studentToken()}`, + origin: testOrigin, + "x-csrf-token": testCsrfToken, + }, }); assert.equal(response.status, 403, path); } @@ -252,14 +291,23 @@ test("students cannot create courses or perform BR/administrator actions", async }); test("removed accounts and shared-account credentials cannot establish a student session", async (t) => { - t.mock.method(User, "findOne", async () => null); + signedIn(t); + t.mock.method(User, "findById", async () => null); let response = await fetch(origin + "/api/course", { - headers: { cookie: `token=${studentToken()}` }, + headers: { + cookie: `token=${studentToken()}`, + origin: testOrigin, + "x-csrf-token": testCsrfToken, + }, }); assert.equal(response.status, 401); - t.mock.method(User, "findOne", async () => ({ ...student, email: "guest@coursehubiitg.in" })); + t.mock.method(User, "findById", async () => ({ ...student, email: "guest@coursehubiitg.in" })); response = await fetch(origin + "/api/course", { - headers: { cookie: `token=${studentToken()}` }, + headers: { + cookie: `token=${studentToken()}`, + origin: testOrigin, + "x-csrf-token": testCsrfToken, + }, }); assert.equal(response.status, 401); }); @@ -284,7 +332,12 @@ test("signed-in search and contribution listing still work", async (t) => { t.mock.method(Course, "find", () => query([course])); t.mock.method(FileModel, "findOne", () => query(libraryFile)); t.mock.method(Contribution, "find", () => query([])); - const headers = { cookie: `token=${studentToken()}`, "content-type": "application/json" }; + const headers = { + cookie: `token=${studentToken()}`, + origin: testOrigin, + "x-csrf-token": testCsrfToken, + "content-type": "application/json", + }; for (const [path, body] of [["/api/search", { words: ["CS101"] }]]) { const response = await fetch(origin + path, { method: "POST", @@ -311,7 +364,12 @@ test("course refresh derives its target from the authenticated student", async ( updates.push(filter); return { modifiedCount: 1 }; }); - const headers = { cookie: `token=${studentToken()}`, "content-type": "application/json" }; + const headers = { + cookie: `token=${studentToken()}`, + origin: testOrigin, + "x-csrf-token": testCsrfToken, + "content-type": "application/json", + }; for (const body of [{}, { rollNumber: student.rollNumber }]) { const response = await fetch(origin + "/api/auth/fetchCourses", { method: "POST", diff --git a/server/test/oauth-provisioning.test.js b/server/test/oauth-provisioning.test.js new file mode 100644 index 00000000..56d7dbf1 --- /dev/null +++ b/server/test/oauth-provisioning.test.js @@ -0,0 +1,100 @@ +import "./support/environment.js"; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { once } from "node:events"; +import http from "node:http"; +import fs from "node:fs"; +import dotenv from "dotenv"; +import axios from "axios"; +import { createOAuthProof, createLocalOAuthCallback, hashOAuthValue } from "../utils/oauthProof.js"; +import { provisionOneDriveToken } from "../scripts/generateOneDriveToken.js"; + +test("local OAuth callbacks verify destination, expiration and state and can be consumed only once", () => { + const proof = createOAuthProof(); + const redirect = "http://127.0.0.1:8080/callback"; + const callback = `${redirect}?state=${proof.state}&code=synthetic-code`; + assert.equal(proof.challenge, hashOAuthValue(proof.verifier)); + assert.notEqual(createOAuthProof().state, proof.state); + const consume = createLocalOAuthCallback(redirect, proof.state); + for (const invalid of [ + callback.replace("127.0.0.1", "attacker.example"), + callback.replace("/callback", "/wrong"), + callback.replace(proof.state, "x".repeat(43)), + callback + "&state=duplicate", + ]) + assert.throws(() => consume(invalid), /could not be verified/); + assert.equal(consume(callback), "synthetic-code"); + assert.throws(() => consume(callback), /could not be verified/); + assert.throws( + () => createLocalOAuthCallback(redirect, proof.state, 0)(callback), + /could not be verified/, + ); +}); + +test("the OneDrive CLI exchanges PKCE through a loopback callback and saves tokens without printing them", async (t) => { + t.mock.method(dotenv, "config", () => ({})); + const prior = { + secret: process.env.AZURE_CLIENT_SECRET, + redirect: process.env.ONEDRIVE_REDIRECT_URI, + }; + process.env.AZURE_CLIENT_SECRET = "synthetic-client-secret"; + process.env.ONEDRIVE_REDIRECT_URI = "http://127.0.0.1:0/callback"; + t.after(() => { + for (const [key, value] of [ + ["AZURE_CLIENT_SECRET", prior.secret], + ["ONEDRIVE_REDIRECT_URI", prior.redirect], + ]) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + const messages = [], + saved = []; + t.mock.method(console, "log", (value) => messages.push(value)); + const originalExists = fs.existsSync; + t.mock.method(fs, "existsSync", (file) => + String(file).endsWith(".token") ? false : originalExists(file), + ); + t.mock.method(fs, "writeFileSync", (file, value, options) => + saved.push({ file, value, options }), + ); + t.mock.method(axios, "post", async (url, data) => { + const params = new URLSearchParams(data); + const authorize = new URL( + messages.find((message) => String(message).startsWith("https://")), + ); + assert.equal( + hashOAuthValue(params.get("code_verifier")), + authorize.searchParams.get("code_challenge"), + ); + assert.equal(params.get("code"), "test-code"); + return { + data: { + refresh_token: "synthetic-refresh-secret", + access_token: "synthetic-access-secret", + }, + }; + }); + let listener; + const createServer = http.createServer; + t.mock.method(http, "createServer", (...args) => { + listener = createServer(...args); + return listener; + }); + const running = provisionOneDriveToken(); + await once(listener, "listening"); + assert.equal(listener.address().address, "127.0.0.1"); + const authorize = new URL(messages.find((message) => String(message).startsWith("https://"))); + const response = await fetch( + `http://127.0.0.1:${listener.address().port}/callback?code=test-code&state=${authorize.searchParams.get("state")}`, + ); + assert.equal(response.status, 200); + assert.match(await response.text(), /tokens saved/); + await running; + assert.equal(saved.length, 2); + assert.ok(saved.every((file) => file.options.mode === 0o600)); + assert.doesNotMatch( + messages.join("\n"), + /synthetic-refresh-secret|synthetic-access-secret|synthetic-client-secret/, + ); +}); diff --git a/server/test/request-errors.test.js b/server/test/request-errors.test.js new file mode 100644 index 00000000..98634fef --- /dev/null +++ b/server/test/request-errors.test.js @@ -0,0 +1,162 @@ +import "./support/environment.js"; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { tmpdir } from "node:os"; +import { once } from "node:events"; +import express from "express"; +import catchAsync from "../utils/catchAsync.js"; +import AppError from "../utils/appError.js"; +import { requestContext, requestErrorHandler } from "../middleware/requestErrors.js"; +import { uploadCourses } from "../modules/admin/adminDashboard.controller.js"; +import Course from "../modules/course/course.model.js"; +import { cookieOptions, validateSecuritySettings } from "../config/security.js"; +import { setSessionCookie, clearSessionCookie } from "../services/sessions.js"; + +async function serve(t, configure) { + const app = express(); + app.use(requestContext); + app.use(express.json()); + configure(app); + app.use(requestErrorHandler); + const listener = app.listen(0, "127.0.0.1"); + await once(listener, "listening"); + t.after(async () => { + listener.closeAllConnections(); + await new Promise((resolve) => listener.close(resolve)); + }); + return `http://127.0.0.1:${listener.address().port}`; +} + +test("sync and async handler failures return one safe JSON response and the process serves the next request", async (t) => { + const origin = await serve(t, (app) => { + app.get( + "/sync", + catchAsync(() => { + throw new Error("secret-db-host"); + }), + ); + app.get( + "/async", + catchAsync(async () => { + await Promise.resolve(); + throw Object.assign(new Error("graph-provider-secret"), { isAxiosError: true }); + }), + ); + app.get( + "/invalid", + catchAsync(async () => { + throw new AppError(403, "Permission denied", "ACCESS_DENIED"); + }), + ); + app.get("/health", (req, res) => res.json({ ok: true })); + }); + for (const [route, status, code] of [ + ["sync", 500, "INTERNAL_ERROR"], + ["async", 502, "PROVIDER_UNAVAILABLE"], + ["invalid", 403, "ACCESS_DENIED"], + ]) { + const response = await fetch(`${origin}/${route}`); + assert.equal(response.status, status); + const data = await response.json(); + assert.equal(data.code, code); + assert.equal(data.requestId, response.headers.get("x-request-id")); + assert.doesNotMatch(JSON.stringify(data), /secret|stack/); + assert.equal((await fetch(origin + "/health")).status, 200); + } +}); + +test("invalid JSON and old direct-error responses follow the same safe error contract", async (t) => { + const origin = await serve(t, (app) => { + app.post("/json", (req, res) => res.json(req.body)); + app.get("/direct", (req, res) => + res.status(500).json({ error: "private provider detail", stack: "sensitive-stack" }), + ); + app.get("/bad", (req, res) => res.sendStatus(400)); + }); + for (const [route, options, status] of [ + [ + "json", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{invalid-secret", + }, + 400, + ], + ["direct", {}, 500], + ["bad", {}, 400], + ]) { + const response = await fetch(origin + "/" + route, options); + assert.equal(response.status, status); + const data = await response.json(); + assert.ok(data.code); + assert.ok(data.message); + assert.ok(data.requestId); + assert.doesNotMatch(JSON.stringify(data), /private|sensitive|invalid-secret/); + } +}); + +test("CSV stream and database failures finish the request and clean up the temporary upload", async (t) => { + const dir = await fs.mkdtemp(path.join(tmpdir(), "coursehub-csv-")); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + const file = path.join(dir, "upload.csv"); + await fs.writeFile(file, "QA101,Test Course\n"); + const mock = t.mock.method(Course, "findOne", async () => { + throw new Error("private database details"); + }); + const origin = await serve(t, (app) => { + app.post( + "/csv", + (req, res, next) => { + req.file = { path: file }; + next(); + }, + catchAsync(uploadCourses), + ); + }); + const failed = await fetch(origin + "/csv", { method: "POST" }); + assert.equal(failed.status, 500); + assert.doesNotMatch(await failed.text(), /private database/); + await assert.rejects(fs.stat(file), { code: "ENOENT" }); + mock.mock.restore(); + const missing = await fetch(origin + "/csv", { method: "POST" }); + assert.equal(missing.status, 500); + assert.doesNotMatch(await missing.text(), new RegExp(dir)); +}); + +test("production cookie creation and clearing use matching secure attributes", () => { + const previous = { node: process.env.NODE_ENV, sameSite: process.env.COOKIE_SAME_SITE }; + process.env.NODE_ENV = "production"; + process.env.COOKIE_SAME_SITE = "none"; + try { + assert.deepEqual(cookieOptions(), { + httpOnly: true, + secure: true, + sameSite: "none", + path: "/", + }); + const calls = []; + const res = { + cookie: (name, token, options) => calls.push({ name, options }), + clearCookie: (name, options) => calls.push({ name, options }), + }; + for (const role of ["student", "admin"]) { + setSessionCookie(res, role, "synthetic"); + clearSessionCookie(res, role); + } + for (const index of [0, 2]) { + const { maxAge, ...attributes } = calls[index].options; + assert.deepEqual(attributes, calls[index + 1].options); + assert.equal(calls[index].name, calls[index + 1].name); + assert.ok(maxAge > 0); + } + process.env.NODE_ENV = "development"; + assert.throws(validateSecuritySettings, /production HTTPS/); + } finally { + process.env.NODE_ENV = previous.node; + if (previous.sameSite === undefined) delete process.env.COOKIE_SAME_SITE; + else process.env.COOKIE_SAME_SITE = previous.sameSite; + } +}); diff --git a/server/test/support/course-permissions.js b/server/test/support/course-permissions.js index 8a7159d0..d30de229 100644 --- a/server/test/support/course-permissions.js +++ b/server/test/support/course-permissions.js @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import { Readable } from "node:stream"; import axios from "axios"; -import jwt from "jsonwebtoken"; +import { sessionHeaders } from "../fixtures/sessions.js"; import Course, { FolderModel, FileModel } from "../../modules/course/course.model.js"; import BR from "../../modules/br/br.model.js"; import CourseAllotment from "../../modules/course/courseAllotment.model.js"; @@ -20,7 +20,7 @@ export async function exerciseCoursePermissions(t, origin) { f.actors.admin = { person: admin, headers: { - cookie: `adminToken=${jwt.sign(admin.id, process.env.ADMIN_JWT_SECRET)}`, + ...(await sessionHeaders(admin.id, "admin")), "content-type": "application/json", }, }; @@ -382,7 +382,11 @@ export async function exerciseCoursePermissions(t, origin) { const response = await fetch(origin + "/api/contribution/upload", { method: "POST", headers: { - cookie: f.actors[role].headers.cookie, + ...Object.fromEntries( + Object.entries(f.actors[role].headers).filter( + ([name]) => name !== "content-type", + ), + ), "contribution-id": id, username: "forged-uploader", }, @@ -584,7 +588,11 @@ export async function exerciseCoursePermissions(t, origin) { const response = await fetch(origin + "/api/contribution/upload", { method: "POST", headers: { - cookie: f.actors.currentBR.headers.cookie, + ...Object.fromEntries( + Object.entries(f.actors.currentBR.headers).filter( + ([name]) => name !== "content-type", + ), + ), "contribution-id": data.contributionId, username: "Administrator", approved: "true", diff --git a/server/test/support/environment.js b/server/test/support/environment.js index ca6bc805..74109a3a 100644 --- a/server/test/support/environment.js +++ b/server/test/support/environment.js @@ -2,6 +2,10 @@ import { devNull } from "node:os"; process.env.DOTENV_CONFIG_PATH = devNull; process.env.NODE_ENV = "test"; +process.env.ALLOWED_ORIGINS = "http://client.coursehub.test"; +process.env.CLIENT_URL = "http://client.coursehub.test"; +process.env.AZURE_CLIENT_ID = "test-client-id"; +process.env.REDIRECT_URI = "http://api.coursehub.test/api/auth/login/redirect"; process.env.OPS_LOGGING_ENABLED = "false"; process.env.MONGO_URI = "mongodb://127.0.0.1:1/coursehub_test_no_connection"; process.env.JWT_SECRET = "coursehub-student-unit-test-key"; diff --git a/server/test/support/session-security.js b/server/test/support/session-security.js new file mode 100644 index 00000000..298b310c --- /dev/null +++ b/server/test/support/session-security.js @@ -0,0 +1,417 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import axios from "axios"; +import jwt from "jsonwebtoken"; +import User from "../../modules/user/user.model.js"; +import UserUpdate from "../../modules/user/userUpdate.model.js"; +import Admin from "../../modules/admin/admin.model.js"; +import Session from "../../modules/session/session.model.js"; +import { OAuthAttempt } from "../../services/oauth.js"; +import { AuthRateLimit } from "../../middleware/authThrottle.js"; +import { createSession, readSession } from "../../services/sessions.js"; +import { sessionHeaders, testOrigin } from "../fixtures/sessions.js"; +import { student } from "../fixtures/library.js"; + +export async function exerciseSessionSecurity(t, origin) { + const { _id, ...base } = student; + const person = await User.create({ + ...base, + name: "Session Student", + email: "session@example.test", + rollNumber: 298123456, + }); + await UserUpdate.create({ rollNumber: person.rollNumber }); + const admin = await Admin.create({ + userId: "session-admin", + password: "session-admin-test-password", + }); + const send = (url, options = {}) => + fetch(origin + url, { signal: AbortSignal.timeout(5000), redirect: "manual", ...options }); + const jsonHeaders = (headers) => ({ ...headers, "content-type": "application/json" }); + + await t.test( + "session claims and database expiration agree with 24-day student and seven-day admin cookies", + async () => { + for (const [actor, role, days] of [ + [person, "student", 24], + [admin, "admin", 7], + ]) { + const { token, session } = await createSession(actor.id, role); + const claims = jwt.decode(token); + assert.equal(claims.exp - claims.iat, days * 86400); + assert.equal(claims.exp * 1000, session.expiresAt.getTime()); + assert.equal(claims.role, role); + assert.equal(claims.sub, actor.id); + assert.equal(claims.jti, session.id); + assert.ok(await readSession(token, role)); + assert.equal( + await readSession(token, role === "student" ? "admin" : "student"), + null, + ); + } + await Session.createIndexes(); + assert.equal( + (await Session.collection.indexes()).find((i) => i.key.expiresAt) + .expireAfterSeconds, + 0, + ); + }, + ); + + await t.test( + "expired, revoked and missing persisted sessions are denied even while a signed JWT remains valid", + async () => { + for (const state of ["expired", "revoked", "missing"]) { + const { token, session } = await createSession(person.id, "student"); + if (state === "expired") + await Session.updateOne( + { _id: session.id }, + { $set: { expiresAt: new Date(0) } }, + ); + if (state === "revoked") + await Session.updateOne( + { _id: session.id }, + { $set: { revokedAt: new Date() } }, + ); + if (state === "missing") await Session.deleteOne({ _id: session.id }); + assert.equal( + (await send("/api/user", { headers: { cookie: `token=${token}` } })).status, + 401, + ); + } + }, + ); + + await t.test( + "student and admin logout revoke only the selected session and prevent cookie/bearer replay", + async () => { + for (const [actor, role, path, readPath] of [ + [person, "student", "/api/auth/logout", "/api/user"], + [admin, "admin", "/api/admin/auth/logout", "/api/admin/"], + ]) { + const headers = await sessionHeaders(actor.id, role); + const second = await sessionHeaders(actor.id, role); + const response = await send(path, { method: "POST", headers }); + assert.equal(response.status, 200); + assert.match( + response.headers.get("set-cookie"), + /Path=\/; Expires=Thu, 01 Jan 1970/, + ); + assert.match(response.headers.get("set-cookie"), /HttpOnly; SameSite=Lax/); + assert.equal((await send(readPath, { headers })).status, 401); + assert.equal( + ( + await send(readPath, { + headers: { authorization: `Bearer ${headers.cookie.split("=")[1]}` }, + }) + ).status, + 401, + ); + assert.equal((await send(readPath, { headers: second })).status, 200); + } + }, + ); + + await t.test( + "CSRF and exact origins reject cross-site writes before any profile persistence", + async () => { + const headers = await sessionHeaders(person.id); + const original = (await User.findById(person.id)).name; + for (const altered of [ + { "x-csrf-token": undefined }, + { "x-csrf-token": "x".repeat(43) }, + { "x-csrf-token": "é".repeat(43) }, + { origin: "https://attacker.example" }, + { origin: `${testOrigin}.attacker.example` }, + { origin: "null" }, + { origin: undefined }, + ]) { + const candidate = Object.fromEntries( + Object.entries({ ...headers, ...altered }).filter( + ([, value]) => value !== undefined, + ), + ); + const response = await send("/api/user/update", { + method: "PUT", + headers: jsonHeaders(candidate), + body: JSON.stringify({ newUserData: { newUserName: "Forged" } }), + }); + assert.equal(response.status, 403, JSON.stringify(altered)); + assert.equal((await User.findById(person.id)).name, original); + } + const csrf = await send("/api/auth/csrf?role=student", { headers }); + assert.equal((await csrf.json()).csrfToken, headers["x-csrf-token"]); + assert.match(csrf.headers.get("cache-control"), /no-store/); + const update = await send("/api/user/update", { + method: "PUT", + headers: jsonHeaders(headers), + body: JSON.stringify({ newUserData: { newUserName: "Saved Student" } }), + }); + assert.equal(update.status, 200); + assert.deepEqual(await update.json(), { + name: "Saved Student", + semester: person.semester, + }); + assert.equal((await User.findById(person.id)).name, "Saved Student"); + }, + ); + + await t.test( + "CSRF tokens cannot be transferred between sessions; cookie and bearer precedence cannot bypass CSRF", + async () => { + const studentHeaders = await sessionHeaders(person.id); + const adminHeaders = await sessionHeaders(admin.id, "admin"); + const response = await send("/api/user/update", { + method: "PUT", + headers: jsonHeaders({ + ...studentHeaders, + "x-csrf-token": adminHeaders["x-csrf-token"], + authorization: `Bearer ${adminHeaders.cookie.split("=")[1]}`, + }), + body: JSON.stringify({ newUserData: { newUserName: "Forged" } }), + }); + assert.equal(response.status, 403); + const combined = { + ...adminHeaders, + cookie: studentHeaders.cookie + "; " + adminHeaders.cookie, + }; + const created = await send("/api/course/create/SESSION101", { + method: "POST", + headers: jsonHeaders(combined), + body: JSON.stringify({ name: "Admin with two browser sessions" }), + }); + assert.equal(created.status, 201); + }, + ); + + await t.test( + "profile validation rejects permission fields and invalid edits without changing the document", + async () => { + const headers = jsonHeaders(await sessionHeaders(person.id)); + const before = await User.findById(person.id).lean(); + for (const newUserData of [ + { newUserName: " " }, + { newUserSem: 1.5 }, + { isBR: true }, + { courses: ["SESSION101"] }, + { newUserName: "N", uploadedBy: admin.id }, + {}, + ]) { + const response = await send("/api/user/update", { + method: "PUT", + headers, + body: JSON.stringify({ newUserData }), + }); + assert.equal(response.status, 400); + const body = await response.json(); + assert.equal(body.code, "VALIDATION_FAILED"); + assert.ok(body.fieldErrors); + } + assert.deepEqual(await User.findById(person.id).lean(), before); + }, + ); + + await t.test( + "database failures return a safe response with a matching request ID and permit a later successful save", + async (st) => { + const headers = jsonHeaders(await sessionHeaders(person.id)); + const mock = st.mock.method(User, "findByIdAndUpdate", async () => { + throw new Error("mongodb://private-password@internal-host provider diagnostic"); + }); + const response = await send("/api/user/update", { + method: "PUT", + headers, + body: JSON.stringify({ newUserData: { newUserName: "Retry Student" } }), + }); + assert.equal(response.status, 500); + const body = await response.json(); + assert.equal(body.code, "INTERNAL_ERROR"); + assert.equal(body.requestId, response.headers.get("x-request-id")); + assert.doesNotMatch( + JSON.stringify(body), + /private-password|internal-host|stack|diagnostic/, + ); + mock.mock.restore(); + const retry = await send("/api/user/update", { + method: "PUT", + headers, + body: JSON.stringify({ newUserData: { newUserName: "Retry Student" } }), + }); + assert.equal(retry.status, 200); + assert.equal((await retry.json()).name, "Retry Student"); + }, + ); + + await t.test( + "missing synchronization metadata leaves the session authenticated and reports synchronization needed", + async () => { + const headers = await sessionHeaders(person.id); + await UserUpdate.deleteOne({ rollNumber: person.rollNumber }); + const response = await send("/api/user", { headers }); + assert.equal(response.status, 200); + assert.equal((await response.json()).needsCourseSync, true); + assert.equal(response.headers.get("set-cookie"), null); + await UserUpdate.create({ rollNumber: person.rollNumber }); + }, + ); + + await t.test( + "admin login rejects forged origins and object credentials, and sets an expiring cookie", + async () => { + const body = JSON.stringify({ + userId: admin.userId, + password: "session-admin-test-password", + }); + for (const originHeader of [undefined, "https://attacker.example", "null"]) { + const response = await send("/api/admin/auth/login", { + method: "POST", + headers: { + "content-type": "application/json", + ...(originHeader ? { origin: originHeader } : {}), + }, + body, + }); + assert.equal(response.status, 403); + } + const invalid = await send("/api/admin/auth/login", { + method: "POST", + headers: { "content-type": "application/json", origin: testOrigin }, + body: JSON.stringify({ userId: { $ne: null }, password: "secret" }), + }); + assert.equal(invalid.status, 400); + const response = await send("/api/admin/auth/login", { + method: "POST", + headers: { "content-type": "application/json", origin: testOrigin }, + body, + }); + assert.equal(response.status, 200); + assert.match(response.headers.get("set-cookie"), /Max-Age=604800/); + assert.match(response.headers.get("set-cookie"), /HttpOnly; SameSite=Lax/); + assert.equal((await response.json()).csrfToken.length, 43); + }, + ); + + const begin = async (returnTo) => { + const response = await send( + "/api/auth/login?returnTo=" + + encodeURIComponent(returnTo || "/profile?tab=files#pending"), + ); + assert.equal(response.status, 302, await response.clone().text()); + const url = new URL(response.headers.get("location")); + return { + url, + state: url.searchParams.get("state"), + cookie: response.headers.get("set-cookie").split(";")[0], + }; + }; + const callback = (flow) => `/api/auth/login/redirect?code=fixture-code&state=${flow.state}`; + + await t.test( + "OAuth validates browser-bound state and S256 PKCE, preserves destinations and rejects callback replay", + async (st) => { + const flow = await begin(); + const other = await begin(); + assert.notEqual(flow.state, other.state); + assert.equal(flow.url.searchParams.get("code_challenge_method"), "S256"); + let exchanges = 0; + st.mock.method(axios, "post", async (url, body) => { + exchanges++; + const params = new URLSearchParams(body); + assert.equal(params.get("code"), "fixture-code"); + const verifier = params.get("code_verifier"); + assert.match(verifier, /^[A-Za-z0-9_-]{43,128}$/); + assert.equal( + createHash("sha256").update(verifier).digest("base64url"), + flow.url.searchParams.get("code_challenge"), + ); + return { data: { access_token: "test-graph-access" } }; + }); + st.mock.method(axios, "get", async () => ({ + data: { mail: person.email, surname: String(person.rollNumber) }, + })); + for (const headers of [{}, { cookie: other.cookie }]) + assert.equal((await send(callback(flow), { headers })).status, 400); + assert.equal(exchanges, 0); + const responses = await Promise.all([ + send(callback(flow), { headers: { cookie: flow.cookie } }), + send(callback(flow), { headers: { cookie: flow.cookie } }), + ]); + assert.deepEqual(responses.map((r) => r.status).sort(), [302, 400]); + assert.equal(exchanges, 1); + const success = responses.find((r) => r.status === 302); + assert.equal( + success.headers.get("location"), + testOrigin + "/profile?tab=files#pending", + ); + assert.ok( + success.headers + .getSetCookie() + .some((value) => /token=.*Max-Age=2073600/.test(value)), + ); + assert.equal( + (await send(callback(flow), { headers: { cookie: flow.cookie } })).status, + 400, + ); + }, + ); + + await t.test( + "expired OAuth attempts and unsafe destinations are rejected without provider exchange", + async () => { + const flow = await begin("//attacker.example/steal"); + const id = createHash("sha256").update(flow.state).digest("base64url"); + assert.equal((await OAuthAttempt.findById(id)).returnTo, "/dashboard"); + await OAuthAttempt.updateOne({ _id: id }, { $set: { expiresAt: new Date(0) } }); + assert.equal( + (await send(callback(flow), { headers: { cookie: flow.cookie } })).status, + 400, + ); + }, + ); + + await t.test("OAuth provider failures finish safely and consume the attempt", async (st) => { + const flow = await begin(); + st.mock.method(axios, "post", async () => { + throw Object.assign(new Error("provider-secret"), { + isAxiosError: true, + response: { status: 500, data: { secret: "provider-secret" } }, + }); + }); + const response = await send(callback(flow), { headers: { cookie: flow.cookie } }); + assert.equal(response.status, 502); + assert.doesNotMatch(await response.text(), /provider-secret/); + assert.equal( + (await send(callback(flow), { headers: { cookie: flow.cookie } })).status, + 400, + ); + }); + + await t.test( + "authentication throttling persists in MongoDB and reports a retry interval", + async () => { + await AuthRateLimit.deleteMany({}); + process.env.AUTH_RATE_LIMIT = "2"; + try { + for (const status of [401, 401, 429]) { + const response = await send("/api/admin/auth/login", { + method: "POST", + headers: { origin: testOrigin, "content-type": "application/json" }, + body: JSON.stringify({ + userId: "unknown-admin", + password: "wrong-password", + }), + }); + assert.equal(response.status, status); + if (status === 429) { + assert.ok(Number(response.headers.get("retry-after")) > 0); + assert.equal((await response.json()).code, "AUTH_THROTTLED"); + } + } + assert.ok(await AuthRateLimit.countDocuments({ count: { $gte: 3 } })); + } finally { + delete process.env.AUTH_RATE_LIMIT; + await AuthRateLimit.deleteMany({}); + } + }, + ); +} diff --git a/server/utils/appError.js b/server/utils/appError.js index 56a98df9..1512eba4 100644 --- a/server/utils/appError.js +++ b/server/utils/appError.js @@ -1,8 +1,9 @@ class AppError extends Error { - constructor(status, message) { - super(); - this.message = message; + constructor(status, message, code, fieldErrors) { + super(message); this.status = status; + this.code = code; + this.fieldErrors = fieldErrors; } } export default AppError; diff --git a/server/utils/catchAsync.js b/server/utils/catchAsync.js index ee44bdbb..939548ac 100644 --- a/server/utils/catchAsync.js +++ b/server/utils/catchAsync.js @@ -1,6 +1,8 @@ function catchAsync(fn) { return function (req, res, next) { - fn(req, res, next).catch((e) => next(e)); + return Promise.resolve() + .then(() => fn(req, res, next)) + .catch(next); }; } diff --git a/server/utils/oauthProof.js b/server/utils/oauthProof.js new file mode 100644 index 00000000..f2501683 --- /dev/null +++ b/server/utils/oauthProof.js @@ -0,0 +1,37 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +export const hashOAuthValue = (value) => createHash("sha256").update(value).digest("base64url"); +export function createOAuthProof() { + const state = randomBytes(32).toString("base64url"); + const verifier = randomBytes(64).toString("base64url"); + return { state, verifier, challenge: hashOAuthValue(verifier) }; +} +export function createLocalOAuthCallback(redirectUri, state, expiresAt = Date.now() + 600000) { + const redirect = new URL(redirectUri); + let consumed = false; + return (callbackUrl) => { + const url = new URL(callbackUrl); + const supplied = url.searchParams.get("state"); + const code = url.searchParams.get("code"); + if ( + consumed || + Date.now() >= expiresAt || + url.origin !== redirect.origin || + url.pathname !== redirect.pathname || + url.searchParams.getAll("state").length !== 1 || + typeof supplied !== "string" || + !/^[\w-]{43}$/.test(supplied) || + !timingSafeEqual(Buffer.from(state), Buffer.from(supplied)) + ) + throw new Error("Callback could not be verified. Start sign-in again."); + consumed = true; + if ( + url.searchParams.has("error") || + url.searchParams.getAll("code").length !== 1 || + !code || + code.length > 2048 + ) + throw new Error("Sign-in was not completed. Start again."); + return code; + }; +} diff --git a/server/utils/uploadedCsv.js b/server/utils/uploadedCsv.js new file mode 100644 index 00000000..21a03e55 --- /dev/null +++ b/server/utils/uploadedCsv.js @@ -0,0 +1,28 @@ +import fs from "node:fs"; +import { Writable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import csv from "csv-parser"; +import AppError from "./appError.js"; + +export async function processUploadedCsv(file, options, processRows) { + if (!file) throw new AppError(400, "No file uploaded"); + try { + const rows = []; + await pipeline( + fs.createReadStream(file.path), + csv(options), + new Writable({ + objectMode: true, + write(row, encoding, done) { + rows.push(row); + done(); + }, + }), + ); + return await processRows(rows); + } finally { + await fs.promises.unlink(file.path).catch((error) => { + if (error.code !== "ENOENT") throw error; + }); + } +} From b2119eb6770e1b599edce72e26c391cefa89376b Mon Sep 17 00:00:00 2001 From: maydayv7 Date: Wed, 9 Sep 2026 04:41:27 +0530 Subject: [PATCH 05/20] chore: Patch server dependencies --- server/.env.example | 4 - server/middleware/multipart.js | 25 + server/modules/admin/admin.routes.js | 11 +- .../contribution/contribution.routes.js | 5 +- server/package-lock.json | 10342 +++------------- server/package.json | 30 +- server/services/email.js | 39 - server/test/academic-html.test.js | 28 + server/test/admin-provisioning.test.js | 10 + server/test/fixtures/multipart-server.js | 76 + server/test/fixtures/password-hashes.js | 11 + server/test/integration/server.test.js | 40 + server/test/multipart.test.js | 200 + server/utils/validate.js | 8 - 14 files changed, 2376 insertions(+), 8453 deletions(-) create mode 100644 server/middleware/multipart.js delete mode 100644 server/services/email.js create mode 100644 server/test/academic-html.test.js create mode 100644 server/test/fixtures/multipart-server.js create mode 100644 server/test/fixtures/password-hashes.js create mode 100644 server/test/multipart.test.js delete mode 100644 server/utils/validate.js diff --git a/server/.env.example b/server/.env.example index d9193a70..cb4163f4 100644 --- a/server/.env.example +++ b/server/.env.example @@ -40,10 +40,6 @@ IMAGEKIT_PUBLIC_KEY= IMAGEKIT_PRIVATE_KEY= IMAGEKIT_URL_ENDPOINT= -# Outbound email -MAIL_USER= -MAIL_PASSWORD= - # Ops telemetry OPS_LOGGING_ENABLED=false OPS_LOG_INGEST_URL= diff --git a/server/middleware/multipart.js b/server/middleware/multipart.js new file mode 100644 index 00000000..ea2d2a3d --- /dev/null +++ b/server/middleware/multipart.js @@ -0,0 +1,25 @@ +import AppError from "../utils/appError.js"; + +export const multipartLimits = Object.freeze({ + fieldArrayIndexLimit: 0, + fieldNestingDepth: 0, +}); + +const invalidMultipart = new Set([ + "Multipart: Boundary not found", + "Malformed content type", + "Malformed part header", + "Unexpected end of form", + "Unexpected end of file", +]); + +export function parseMultipart(parser) { + return (req, res, next) => { + parser(req, res, (error) => { + if (error && !error.code && invalidMultipart.has(error.message)) { + return next(new AppError(400, "Invalid multipart upload", "INVALID_MULTIPART")); + } + next(error); + }); + }; +} diff --git a/server/modules/admin/admin.routes.js b/server/modules/admin/admin.routes.js index 5a68fac6..79114d2c 100644 --- a/server/modules/admin/admin.routes.js +++ b/server/modules/admin/admin.routes.js @@ -2,6 +2,7 @@ import express from "express"; import catchAsync from "../../utils/catchAsync.js"; import isAdmin from "../../middleware/isAdmin.js"; import multer from "multer"; +import { multipartLimits, parseMultipart } from "../../middleware/multipart.js"; import { requireTrustedOrigin } from "../../middleware/csrf.js"; import { authThrottle } from "../../middleware/authThrottle.js"; import { adminLogin, adminLogout, getAdmin } from "./auth.controller.js"; @@ -19,7 +20,7 @@ import { } from "./adminDashboard.controller.js"; const router = express.Router(); -const upload = multer({ dest: "uploads/" }); +const upload = multer({ dest: "uploads/", limits: multipartLimits }); // Admin auth router.post( @@ -37,8 +38,12 @@ router.delete("/node/:type/:id", catchAsync(deleteNode)); router.get("/", catchAsync(getAdmin)); router.get("/dbcourses", catchAsync(getDBCourses)); -router.post("/courses/upload", upload.single("file"), catchAsync(uploadCourses)); -router.post("/courses/bulk-link", upload.single("file"), catchAsync(bulkLinkCourses)); +router.post("/courses/upload", parseMultipart(upload.single("file")), catchAsync(uploadCourses)); +router.post( + "/courses/bulk-link", + parseMultipart(upload.single("file")), + catchAsync(bulkLinkCourses), +); router.patch("/course/:code", catchAsync(renameCourse)); router.post("/course/:code/link", catchAsync(linkLegacyCourse)); router.delete("/course/:code/delete", catchAsync(deleteCourse)); diff --git a/server/modules/contribution/contribution.routes.js b/server/modules/contribution/contribution.routes.js index e886646d..618cbe8e 100644 --- a/server/modules/contribution/contribution.routes.js +++ b/server/modules/contribution/contribution.routes.js @@ -7,15 +7,16 @@ import catchAsync from "../../utils/catchAsync.js"; import isAuthenticated from "../../middleware/isAuthenticated.js"; import { isBR } from "../../middleware/isBR.js"; import multer from "multer"; +import { multipartLimits, parseMultipart } from "../../middleware/multipart.js"; import { authorizeContributionUpload } from "../../services/authorization.js"; -export const upload = multer({ dest: "external/uploads" }); +export const upload = multer({ dest: "external/uploads", limits: multipartLimits }); router.get("/", isAuthenticated, catchAsync(ContributionController.GetMyContributions)); router.post("/", catchAsync(ContributionController.CreateNewContribution)); router.post( "/upload", authorizeContributionUpload, - upload.array("file"), + parseMultipart(upload.array("file")), catchAsync(ContributionController.HandleFileUpload), ); router.post("/br", isBR, catchAsync(ContributionController.GetBrContribution)); diff --git a/server/package-lock.json b/server/package-lock.json index aefeb8ac..13768260 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,7 +1,7 @@ { "name": "server", "version": "1.0.0", - "lockfileVersion": 2, + "lockfileVersion": 3, "requires": true, "packages": { "": { @@ -9,7813 +9,1484 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@azure/identity": "^3.0.0", "@coding-club-iitg/ops-logger": "^0.3.1", "@imagekit/nodejs": "^7.5.0", - "@microsoft/microsoft-graph-client": "^3.0.2", - "aes-js": "^3.1.2", - "axios": "^1.1.3", - "bcrypt": "^5.1.0", + "axios": "^1.20.0", + "bcrypt": "^6.0.0", "cheerio": "^1.0.0-rc.12", - "cookie-parser": "^1.4.6", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "csv-parser": "^3.2.0", "dotenv": "^16.6.1", - "express": "^4.18.2", + "express": "^4.22.2", "express-useragent": "^1.0.15", - "firebase-admin": "^13.4.0", - "formidable": "^2.1.1", - "isomorphic-fetch": "^3.0.0", - "joi": "^17.7.1", + "joi": "^17.13.7", "jsonwebtoken": "^9.0.3", - "mongoose": "^6.7.0", - "multer": "^1.4.5-lts.1", - "node-cron": "^4.6.0", - "nodemailer": "^6.9.1" + "mongoose": "^6.13.11", + "multer": "^2.3.0", + "node-cron": "^4.6.0" }, "devDependencies": { "@coding-club-iitg/ops-contract": "^0.2.0", - "nodemon": "^2.0.20" + "nodemon": "^3.1.14" }, "engines": { "node": ">=22" } }, - "node_modules/@aws-crypto/ie11-detection": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-2.0.2.tgz", - "integrity": "sha512-5XDMQY98gMAf/WRTic5G++jfmS/VLM0rwpiOpaainKi4L0nqWMSB1SzsrEG5rjFZGYN6ZAefO+/Yta2dFM0kMw==", - "optional": true, - "dependencies": { - "tslib": "^1.11.1" - } - }, - "node_modules/@aws-crypto/ie11-detection/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-2.0.0.tgz", - "integrity": "sha512-rYXOQ8BFOaqMEHJrLHul/25ckWH6GTJtdLSajhlqGMx0PmSueAuvboCuZCTqEKlxR8CQOwRarxYMZZSYlhRA1A==", - "optional": true, - "dependencies": { - "@aws-crypto/ie11-detection": "^2.0.0", - "@aws-crypto/sha256-js": "^2.0.0", - "@aws-crypto/supports-web-crypto": "^2.0.0", - "@aws-crypto/util": "^2.0.0", - "@aws-sdk/types": "^3.1.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@aws-sdk/util-utf8-browser": "^3.0.0", - "tslib": "^1.11.1" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-2.0.0.tgz", - "integrity": "sha512-VZY+mCY4Nmrs5WGfitmNqXzaE873fcIZDu54cbaDaaamsaTOP1DBImV9F4pICc3EHjQXujyE8jig+PFCaew9ig==", - "optional": true, - "dependencies": { - "@aws-crypto/util": "^2.0.0", - "@aws-sdk/types": "^3.1.0", - "tslib": "^1.11.1" - } - }, - "node_modules/@aws-crypto/sha256-js/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-2.0.2.tgz", - "integrity": "sha512-6mbSsLHwZ99CTOOswvCRP3C+VCWnzBf+1SnbWxzzJ9lR0mA0JnY2JEAhp8rqmTE0GPFy88rrM27ffgp62oErMQ==", - "optional": true, - "dependencies": { - "tslib": "^1.11.1" - } - }, - "node_modules/@aws-crypto/supports-web-crypto/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true - }, - "node_modules/@aws-crypto/util": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-2.0.2.tgz", - "integrity": "sha512-Lgu5v/0e/BcrZ5m/IWqzPUf3UYFTy/PpeED+uc9SWUR1iZQL8XXbGQg10UfllwwBryO3hFF5dizK+78aoXC1eA==", - "optional": true, - "dependencies": { - "@aws-sdk/types": "^3.110.0", - "@aws-sdk/util-utf8-browser": "^3.0.0", - "tslib": "^1.11.1" - } - }, - "node_modules/@aws-crypto/util/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true - }, - "node_modules/@aws-sdk/abort-controller": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/abort-controller/-/abort-controller-3.198.0.tgz", - "integrity": "sha512-kmK1fNJ5nkBH23wOrAdxWcVtG/NNCaX66cxr90jnbGvSAeNRi5nLLqlmQOyZ0RRg+tpNCec+N/qqfxAmmD3NdQ==", - "optional": true, - "dependencies": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@aws-sdk/client-cognito-identity": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.199.0.tgz", - "integrity": "sha512-F7VvJkaSYBBiAAtlrWO6Hq5GbqARMerihtlFvQra2Uy0bSX/okNgefurjjW1Gijh4xk6brWPO+46GD9OpZXtcQ==", - "optional": true, - "dependencies": { - "@aws-crypto/sha256-browser": "2.0.0", - "@aws-crypto/sha256-js": "2.0.0", - "@aws-sdk/client-sts": "3.199.0", - "@aws-sdk/config-resolver": "3.198.0", - "@aws-sdk/credential-provider-node": "3.199.0", - "@aws-sdk/fetch-http-handler": "3.199.0", - "@aws-sdk/hash-node": "3.198.0", - "@aws-sdk/invalid-dependency": "3.198.0", - "@aws-sdk/middleware-content-length": "3.199.0", - "@aws-sdk/middleware-endpoint": "3.198.0", - "@aws-sdk/middleware-host-header": "3.198.0", - "@aws-sdk/middleware-logger": "3.198.0", - "@aws-sdk/middleware-recursion-detection": "3.198.0", - "@aws-sdk/middleware-retry": "3.198.0", - "@aws-sdk/middleware-serde": "3.198.0", - "@aws-sdk/middleware-signing": "3.198.0", - "@aws-sdk/middleware-stack": "3.198.0", - "@aws-sdk/middleware-user-agent": "3.198.0", - "@aws-sdk/node-config-provider": "3.198.0", - "@aws-sdk/node-http-handler": "3.199.0", - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/smithy-client": "3.198.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/url-parser": "3.198.0", - "@aws-sdk/util-base64-browser": "3.188.0", - "@aws-sdk/util-base64-node": "3.188.0", - "@aws-sdk/util-body-length-browser": "3.188.0", - "@aws-sdk/util-body-length-node": "3.188.0", - "@aws-sdk/util-defaults-mode-browser": "3.198.0", - "@aws-sdk/util-defaults-mode-node": "3.198.0", - "@aws-sdk/util-endpoints": "3.198.0", - "@aws-sdk/util-user-agent-browser": "3.198.0", - "@aws-sdk/util-user-agent-node": "3.198.0", - "@aws-sdk/util-utf8-browser": "3.188.0", - "@aws-sdk/util-utf8-node": "3.199.0", - "tslib": "^2.3.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@aws-sdk/client-sso": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.199.0.tgz", - "integrity": "sha512-xRTI39cLcCU1lGlSaE2arCPzRwUY16Oq46fGoe+9pwalDL3oKeE6uq/idTxPzPZdZFhzpLUVc0QdfwGDYhJpXg==", - "optional": true, - "dependencies": { - "@aws-crypto/sha256-browser": "2.0.0", - "@aws-crypto/sha256-js": "2.0.0", - "@aws-sdk/config-resolver": "3.198.0", - "@aws-sdk/fetch-http-handler": "3.199.0", - "@aws-sdk/hash-node": "3.198.0", - "@aws-sdk/invalid-dependency": "3.198.0", - "@aws-sdk/middleware-content-length": "3.199.0", - "@aws-sdk/middleware-endpoint": "3.198.0", - "@aws-sdk/middleware-host-header": "3.198.0", - "@aws-sdk/middleware-logger": "3.198.0", - "@aws-sdk/middleware-recursion-detection": "3.198.0", - "@aws-sdk/middleware-retry": "3.198.0", - "@aws-sdk/middleware-serde": "3.198.0", - "@aws-sdk/middleware-stack": "3.198.0", - "@aws-sdk/middleware-user-agent": "3.198.0", - "@aws-sdk/node-config-provider": "3.198.0", - "@aws-sdk/node-http-handler": "3.199.0", - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/smithy-client": "3.198.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/url-parser": "3.198.0", - "@aws-sdk/util-base64-browser": "3.188.0", - "@aws-sdk/util-base64-node": "3.188.0", - "@aws-sdk/util-body-length-browser": "3.188.0", - "@aws-sdk/util-body-length-node": "3.188.0", - "@aws-sdk/util-defaults-mode-browser": "3.198.0", - "@aws-sdk/util-defaults-mode-node": "3.198.0", - "@aws-sdk/util-endpoints": "3.198.0", - "@aws-sdk/util-user-agent-browser": "3.198.0", - "@aws-sdk/util-user-agent-node": "3.198.0", - "@aws-sdk/util-utf8-browser": "3.188.0", - "@aws-sdk/util-utf8-node": "3.199.0", - "tslib": "^2.3.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@aws-sdk/client-sts": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.199.0.tgz", - "integrity": "sha512-ly7kLi/KFRr9RrvTVVlDAXlROkInTMRy4BL02Ugw7brSPysUJabzdlDB5gxC+jbeq8qpai/vCybePf6lgrcvFw==", + "node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-crypto/sha256-browser": "2.0.0", - "@aws-crypto/sha256-js": "2.0.0", - "@aws-sdk/config-resolver": "3.198.0", - "@aws-sdk/credential-provider-node": "3.199.0", - "@aws-sdk/fetch-http-handler": "3.199.0", - "@aws-sdk/hash-node": "3.198.0", - "@aws-sdk/invalid-dependency": "3.198.0", - "@aws-sdk/middleware-content-length": "3.199.0", - "@aws-sdk/middleware-endpoint": "3.198.0", - "@aws-sdk/middleware-host-header": "3.198.0", - "@aws-sdk/middleware-logger": "3.198.0", - "@aws-sdk/middleware-recursion-detection": "3.198.0", - "@aws-sdk/middleware-retry": "3.198.0", - "@aws-sdk/middleware-sdk-sts": "3.199.0", - "@aws-sdk/middleware-serde": "3.198.0", - "@aws-sdk/middleware-signing": "3.198.0", - "@aws-sdk/middleware-stack": "3.198.0", - "@aws-sdk/middleware-user-agent": "3.198.0", - "@aws-sdk/node-config-provider": "3.198.0", - "@aws-sdk/node-http-handler": "3.199.0", - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/smithy-client": "3.198.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/url-parser": "3.198.0", - "@aws-sdk/util-base64-browser": "3.188.0", - "@aws-sdk/util-base64-node": "3.188.0", - "@aws-sdk/util-body-length-browser": "3.188.0", - "@aws-sdk/util-body-length-node": "3.188.0", - "@aws-sdk/util-defaults-mode-browser": "3.198.0", - "@aws-sdk/util-defaults-mode-node": "3.198.0", - "@aws-sdk/util-endpoints": "3.198.0", - "@aws-sdk/util-user-agent-browser": "3.198.0", - "@aws-sdk/util-user-agent-node": "3.198.0", - "@aws-sdk/util-utf8-browser": "3.188.0", - "@aws-sdk/util-utf8-node": "3.199.0", - "fast-xml-parser": "4.0.11", - "tslib": "^2.3.1" + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=12.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/config-resolver": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/config-resolver/-/config-resolver-3.198.0.tgz", - "integrity": "sha512-CxpbkTOfOYZLWcNgcZqooSIlLnixzHVz6skDgxOfeN2vohNOgt8hwU0Dmif3sC4AeyeV0CBm7ew9tg/WzsBxhg==", + "node_modules/@aws-sdk/credential-provider-cognito-identity": { + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.69.tgz", + "integrity": "sha512-vpsh9VWmQVC/nsdzf72F2yUMBOFT1aq+hoLseYV03zjVXVHkjqSNJskFRKPFxc3MRFrHNbSSmOwsbYE15u9ecw==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/signature-v4": "3.198.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/util-config-provider": "3.188.0", - "@aws-sdk/util-middleware": "3.198.0", - "tslib": "^2.3.1" + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/credential-provider-cognito-identity": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.199.0.tgz", - "integrity": "sha512-vhdvaCq9Q/LPvAdTN9HFnsR713iDb1qI7yTmnGzJYym1J9a2pR4YQvZr1pRyXbn176ORkgEiPMNSoDw8CiCRlg==", + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/client-cognito-identity": "3.199.0", - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.198.0.tgz", - "integrity": "sha512-Psui5iNdbHrHNF14vejORMtSEaH7EOt51pQcfmP1jk8Tinf+KMMMdbOqyzL4LHYwLTLH9Cr6m6UGrJXdmFiIZA==", + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/credential-provider-imds": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.198.0.tgz", - "integrity": "sha512-p2xMCo3whCnXd5/dH738rAVURXhlppjRNDv0sCkDcVtr3exn4s5x5ednFM8K0zNo/hsqjqFbK3jT4W72bgHphw==", + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/node-config-provider": "3.198.0", - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/url-parser": "3.198.0", - "tslib": "^2.3.1" + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.199.0.tgz", - "integrity": "sha512-6m3WtK+oKGyo7iCHjORwmHN4wUp4F9j79dSedEf0EYxDbHpH9yMnEE6hYV11GdmM+/i8x8DYA45apAZo8gCIcA==", + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/credential-provider-env": "3.198.0", - "@aws-sdk/credential-provider-imds": "3.198.0", - "@aws-sdk/credential-provider-sso": "3.199.0", - "@aws-sdk/credential-provider-web-identity": "3.198.0", - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/shared-ini-file-loader": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.199.0.tgz", - "integrity": "sha512-pgJhOnTHj+ZZkcN9v7R+m2VnkQi4vvyA7N6k3EDWMQ8tdo48ofwObORkzA4gPX+TPuIjpYa1lU03dpY4zuzJKQ==", + "version": "3.972.82", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.82.tgz", + "integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/credential-provider-env": "3.198.0", - "@aws-sdk/credential-provider-imds": "3.198.0", - "@aws-sdk/credential-provider-ini": "3.199.0", - "@aws-sdk/credential-provider-process": "3.198.0", - "@aws-sdk/credential-provider-sso": "3.199.0", - "@aws-sdk/credential-provider-web-identity": "3.198.0", - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/shared-ini-file-loader": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">=12.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.198.0.tgz", - "integrity": "sha512-LWiwKDCui7ILr+6opBzLCCAho9ZOppuEthUdKZx6T7+yD2cQT0caN5PkVUBMtfTu9+DZnHD2bpIL1T2KEaqEUw==", + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/shared-ini-file-loader": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.199.0.tgz", - "integrity": "sha512-aMNZkEj/5RN6jSbfkVadjNblExJtHCJGvcnKnzIGfXLgqOFoWGzY+YIHmn/GTgCnpuMbN5hXYXV6w76HwIvWGw==", + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/client-sso": "3.199.0", - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/shared-ini-file-loader": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.198.0.tgz", - "integrity": "sha512-D+fhnmqN18P/Roq5oxVq53J3mqS9Oi9IJaIKdrbdK/FibqOyKmTERaLKWkONwG35qExSECOpoEGn7ioUMQgAgQ==", + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-providers": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.199.0.tgz", - "integrity": "sha512-PpOgvV7akew9cXrrEEF+h6H/JGURVCPw+UsvN0yjN8oSexwe0sUzKG1AVIrHYiAWkZKIohtsv7NNzBAKvJ4Qmw==", - "optional": true, - "dependencies": { - "@aws-sdk/client-cognito-identity": "3.199.0", - "@aws-sdk/client-sso": "3.199.0", - "@aws-sdk/client-sts": "3.199.0", - "@aws-sdk/credential-provider-cognito-identity": "3.199.0", - "@aws-sdk/credential-provider-env": "3.198.0", - "@aws-sdk/credential-provider-imds": "3.198.0", - "@aws-sdk/credential-provider-ini": "3.199.0", - "@aws-sdk/credential-provider-node": "3.199.0", - "@aws-sdk/credential-provider-process": "3.198.0", - "@aws-sdk/credential-provider-sso": "3.199.0", - "@aws-sdk/credential-provider-web-identity": "3.198.0", - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/shared-ini-file-loader": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@aws-sdk/fetch-http-handler": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/fetch-http-handler/-/fetch-http-handler-3.199.0.tgz", - "integrity": "sha512-o1jRUMoJR/HOhqA2euYIQxzKLkbqBGwMGH3ahm5eqEJ9kCp84c2KsxT/HOEqjvAQi3f3np8VlTPbuscvDKUN4w==", - "optional": true, - "dependencies": { - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/querystring-builder": "3.199.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/util-base64-browser": "3.188.0", - "tslib": "^2.3.1" - } - }, - "node_modules/@aws-sdk/hash-node": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/hash-node/-/hash-node-3.198.0.tgz", - "integrity": "sha512-+UTjEEQlvT4+IIwLpN36Qb1DOQHe3zHkvIVe6SjLln+Z/UEK6NhMI0tsJNbiW38WAfwOjJ+otrRBHuD93SBRxQ==", + "version": "3.1128.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1128.0.tgz", + "integrity": "sha512-yKAD0B1YcucqTPSJKWU1QH/MSk8NZAheqCoqmVetC1rlX9IYG10Vzb6pStkRNEDjpoOeQ97W3W/DAT1o12TtuQ==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/types": "3.198.0", - "@aws-sdk/util-buffer-from": "3.188.0", - "tslib": "^2.3.1" + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-cognito-identity": "^3.972.69", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@aws-sdk/invalid-dependency": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/invalid-dependency/-/invalid-dependency-3.198.0.tgz", - "integrity": "sha512-lbwS+H7WYk/g9/nHoTgt7xkrZCJ/OJuBfsx41RvMxW7zPxJeHYD/PvgPvYOB9lTUBkr7SDCeMoS5PtGdAwVOfg==", - "optional": true, - "dependencies": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/is-array-buffer": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/is-array-buffer/-/is-array-buffer-3.188.0.tgz", - "integrity": "sha512-n69N4zJZCNd87Rf4NzufPzhactUeM877Y0Tp/F3KiHqGeTnVjYUa4Lv1vLBjqtfjYb2HWT3NKlYn5yzrhaEwiQ==", + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "tslib": "^2.3.1" + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-content-length": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-content-length/-/middleware-content-length-3.199.0.tgz", - "integrity": "sha512-Q76J6xZl36tqS401TGs/NPIu8lYbNG1EtqxM88CWe/u0SH+D4LIR9AMXq9c4PH0ldIMWAGVGbRLLc0/H3rPyBg==", + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-endpoint": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint/-/middleware-endpoint-3.198.0.tgz", - "integrity": "sha512-J/rQkIXUbFJAlD6LSDVGU4bGbwD/2pvF5N39ePzvaJ8SwV9Y78XER/2fIAERhFNppuYinGdBdMLiPsC6qPT6ZA==", + "node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/middleware-serde": "3.198.0", - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/signature-v4": "3.198.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/url-parser": "3.198.0", - "@aws-sdk/util-config-provider": "3.188.0", - "@aws-sdk/util-middleware": "3.198.0", - "tslib": "^2.3.1" + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.198.0.tgz", - "integrity": "sha512-keHstrdw0bFzEkUrkMQ9+UxaKu5b1K87cH6guqLf4JBo04CT+2kPRlDSma65XCi2U81zfTnWApk+/SPPFN3otA==", + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.198.0.tgz", - "integrity": "sha512-IFvNO4MI80FyltPzrEpYHMG47EYXawcD5zTzcbimpeLTpyrLY/zkSJqh5cVFu+NcDWsuD6U1geuvfN+i+2Bg1Q==", + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.198.0.tgz", - "integrity": "sha512-VHyz5xBJOaLZwdL94XWB04XCA+pwbURy+4ESF66vIY1umWgfanbZPkvw1XlRaQJydOmyIDFqhNG2AzB28WN9iw==", + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", "optional": true, - "dependencies": { - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - }, "engines": { - "node": ">= 12.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-retry": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-retry/-/middleware-retry-3.198.0.tgz", - "integrity": "sha512-dwv5QJYTPNkmjKcQ0RtClkNumFomECzxjXvSiyjD9Ft6AWHcUeyqJfGKbmP5mFHpezWckK1qcT6cPMVrJilgjw==", - "optional": true, - "dependencies": { - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/service-error-classification": "3.198.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/util-middleware": "3.198.0", - "tslib": "^2.3.1", - "uuid": "^8.3.2" - }, + "node_modules/@coding-club-iitg/ops-contract": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@coding-club-iitg/ops-contract/-/ops-contract-0.2.0.tgz", + "integrity": "sha512-kTZmAnhdGf0tN6dcp/M2BNdyxO5bEjfvinQcpp157XVoZbXsxcp1AjAf/3LbNkdW6u1mh6/6h0APaDqo/pb02g==", + "dev": true, + "license": "UNLICENSED", "engines": { - "node": ">= 12.0.0" + "node": ">=18" } }, - "node_modules/@aws-sdk/middleware-sdk-sts": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sts/-/middleware-sdk-sts-3.199.0.tgz", - "integrity": "sha512-ISM3Lx1AM2IiHpcQPdwHHvnW/dRyC/jGy2fHQvmYxj8x2oIYnEXxk4vA7DFnrYupxwi2yTSp3k8On2+1VgMjiw==", - "optional": true, + "node_modules/@coding-club-iitg/ops-logger": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@coding-club-iitg/ops-logger/-/ops-logger-0.3.1.tgz", + "integrity": "sha512-OEWdQDEqh9P8TUZ8IBxtDe1PEjRhVgURI3qBUKrvIg7aYKXTgVdGLcDNgkDHrENOKsxP5Hh1aaw0cqf5N7YQ1Q==", + "license": "UNLICENSED", "dependencies": { - "@aws-sdk/middleware-signing": "3.198.0", - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/signature-v4": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "@coding-club-iitg/ops-contract": "^0.3.0" }, "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@aws-sdk/middleware-serde": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-serde/-/middleware-serde-3.198.0.tgz", - "integrity": "sha512-RlAua2691KCFabp3kjnsd5p+1nQbULTK1Ia/jvlTAyG4tGOeA0x1At6KZoI1LfkN+VjstV5/3b9aOCtcFuxkhA==", - "optional": true, - "dependencies": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "node": ">=18" }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@aws-sdk/middleware-signing": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.198.0.tgz", - "integrity": "sha512-HPY9d1c1CUiV6JBVxiiQQgYfmELl1cn6h0TI00EmOAM5/wxUoiYBX2cGWf2NRF9/iBTppZjxwAKMYPIqF5Tkvw==", - "optional": true, - "dependencies": { - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/signature-v4": "3.198.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/util-middleware": "3.198.0", - "tslib": "^2.3.1" + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/sdk-trace-base": "^2.0.0" }, - "engines": { - "node": ">= 12.0.0" + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + } } }, - "node_modules/@aws-sdk/middleware-stack": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-stack/-/middleware-stack-3.198.0.tgz", - "integrity": "sha512-5mHRjiJFHxziXOiChW3kttQHjgqH5qW9xRIDJepyf+NRJ60L8bZj0t8oGecqVqo27S02+UvrFgOzoRvBbATVFw==", - "optional": true, - "dependencies": { - "tslib": "^2.3.1" - }, + "node_modules/@coding-club-iitg/ops-logger/node_modules/@coding-club-iitg/ops-contract": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@coding-club-iitg/ops-contract/-/ops-contract-0.3.0.tgz", + "integrity": "sha512-cuF9HwuDskcpOqptHi3MlDUfgjukR1ISZQUuSTEbMCaB9KI9qMp/CNR5oW6VDXTIyZRhH6lxvlSA/zl8ICiXlg==", + "license": "UNLICENSED", "engines": { - "node": ">= 12.0.0" + "node": ">=18" } }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.198.0.tgz", - "integrity": "sha512-SMteVixqoSazxUN1ROMj+nSf/zgTMRVPaTCKU0iEAtrE7ilp9Xv6FEC7ffm1MM9xIoAZ2eY1eAtY3uN0yxBm4A==", - "optional": true, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", "dependencies": { - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - }, - "engines": { - "node": ">= 12.0.0" + "@hapi/hoek": "^9.0.0" } }, - "node_modules/@aws-sdk/node-config-provider": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/node-config-provider/-/node-config-provider-3.198.0.tgz", - "integrity": "sha512-W+msdp94ZjR8mJMtGPHjWHsIdsOu3HaVX4x+AQq9cj7+pg/D5CvWw7fnbkUQeG+V8Ia/aqzBNxlUpr/FAeQY/g==", - "optional": true, + "node_modules/@imagekit/nodejs": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@imagekit/nodejs/-/nodejs-7.11.0.tgz", + "integrity": "sha512-dTZKPWSeCQN/CrGwhcENB10EvTt/9iijTqBR6qQHns1DHt46v7SVdf6wB2eRhuh3XPgzKmF8DRFnvUF/MLGGLA==", + "license": "Apache-2.0", "dependencies": { - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/shared-ini-file-loader": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - }, - "engines": { - "node": ">= 12.0.0" + "standardwebhooks": "^1.0.0" } }, - "node_modules/@aws-sdk/node-http-handler": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/node-http-handler/-/node-http-handler-3.199.0.tgz", - "integrity": "sha512-F+6T7MHHm0b3+GVRxEnFCuIyqUQb0b8a3Hne3QFV4cxtnX58O/SSF8KlpuGZwobivdRC/AKDaTdI/eA0PQfegA==", + "node_modules/@mongodb-js/saslprep": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.5.2.tgz", + "integrity": "sha512-UBvCBdPHAmiiDNEpD2ORBGzna4qYr06fLELfGDPW3/KZ9uLK+cGT9npAc/x/6/8SLzdbILMuc3HWFQ0FvJMhCw==", + "license": "MIT", "optional": true, "dependencies": { - "@aws-sdk/abort-controller": "3.198.0", - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/querystring-builder": "3.199.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - }, - "engines": { - "node": ">= 12.0.0" + "sparse-bitfield": "^3.0.3" } }, - "node_modules/@aws-sdk/property-provider": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/property-provider/-/property-provider-3.198.0.tgz", - "integrity": "sha512-jnQeJgZlk+6YJS2/eFz6pm9+XHzvCB0jTxHBwt2zYwZfcJ98viRQWMYfkY1XsemuQb/uIoHRBRhFXaJSLpXVDQ==", - "optional": true, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", "dependencies": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - }, - "engines": { - "node": ">= 12.0.0" + "@hapi/hoek": "^9.0.0" } }, - "node_modules/@aws-sdk/protocol-http": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.198.0.tgz", - "integrity": "sha512-x+Qc+kwYqaZvLJ/820rxoFUIgSnSS/XlUHwmS+CTn7nJ68CeL3dzmae6TVOslpVBLCvoS2CbEpEoBbofOpsbGw==", + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/querystring-builder": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-builder/-/querystring-builder-3.199.0.tgz", - "integrity": "sha512-P4MWPzCqtH3sgI9iXXVdYirYVgggtg4uq5MVefnfHW+osZu8ZR+UKJw5ojAFfOCqcnKOU/xJjz185RroOjrzYQ==", + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/types": "3.198.0", - "@aws-sdk/util-uri-escape": "3.188.0", - "tslib": "^2.3.1" + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/querystring-parser": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-parser/-/querystring-parser-3.198.0.tgz", - "integrity": "sha512-oMybZYINxNiwSELR7tOwqu+1S7CeEC3g5L4IQXk2wvVx96HEf3sQgLr1wbmV1b7lEnTuH9OrgI5RgDUBVqipdw==", + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@aws-sdk/service-error-classification": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/service-error-classification/-/service-error-classification-3.198.0.tgz", - "integrity": "sha512-bVsWOIYuDtSmwJtPF1pU84x2TL20Pj02C0+/6ua4qLvRatVKFbj1wxWiU/nKvgjiGFX8VWuQUKMzXUYQfYn4nw==", - "optional": true, - "engines": { - "node": ">= 12.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/shared-ini-file-loader": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/shared-ini-file-loader/-/shared-ini-file-loader-3.198.0.tgz", - "integrity": "sha512-n3Ykuvtb6f+WQuhcMVumY9VxQwPp8+cMSc5s6YHptkvZkz/cd2wmPhO914gKE/i2MoC/zQsFCXT8Z1YnS7k8sA==", + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/signature-v4": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4/-/signature-v4-3.198.0.tgz", - "integrity": "sha512-8EIyEt7ElTK/tQamYyB16IGwc7EwtLlSVcksaiII780ZtYULnOjogi/UImCYqSejQw+EHhXfbj14HRQT56rqEQ==", + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/is-array-buffer": "3.188.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/util-hex-encoding": "3.188.0", - "@aws-sdk/util-middleware": "3.198.0", - "@aws-sdk/util-uri-escape": "3.188.0", - "tslib": "^2.3.1" + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/smithy-client": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.198.0.tgz", - "integrity": "sha512-IKUzJSoIkxYkYpRdlrh6REtDcW5c87FKeqtMC8VTpaTxrXwnJOqbenp7IwArwOnbXp4aIVmzdxT/nvQrftlgWg==", + "node_modules/@smithy/types": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/middleware-stack": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "tslib": "^2.6.2" }, "engines": { - "node": ">= 12.0.0" + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/types": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.198.0.tgz", - "integrity": "sha512-ljgY9Pgb2CSRrf4IeaNy5gkhTsBae9STKc/mqfScSzvZOvRHu+BOIAGM33fDoCwxD1viKNVJvU1KemiI57Gbvw==", - "optional": true, - "engines": { - "node": ">= 12.0.0" - } + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" }, - "node_modules/@aws-sdk/url-parser": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/url-parser/-/url-parser-3.198.0.tgz", - "integrity": "sha512-wm3+OTDWKsMEOlLGvJ+jxCcOXMjgd5qBDVbu2bTiyTahc2poNlM7kKhSwL4I8PkmGZVAqfAlHD4Wj38WecHQPw==", - "optional": true, + "node_modules/@types/node": { + "version": "26.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.0.tgz", + "integrity": "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==", + "license": "MIT", "dependencies": { - "@aws-sdk/querystring-parser": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" + "undici-types": "~8.9.0" } }, - "node_modules/@aws-sdk/util-base64-browser": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-base64-browser/-/util-base64-browser-3.188.0.tgz", - "integrity": "sha512-qlH+5NZBLiyKziL335BEPedYxX6j+p7KFRWXvDQox9S+s+gLCayednpK+fteOhBenCcR9fUZOVuAPScy1I8qCg==", - "optional": true, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", + "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", + "license": "MIT", "dependencies": { - "tslib": "^2.3.1" + "@types/node": "*", + "@types/webidl-conversions": "*" } }, - "node_modules/@aws-sdk/util-base64-node": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-base64-node/-/util-base64-node-3.188.0.tgz", - "integrity": "sha512-r1dccRsRjKq+OhVRUfqFiW3sGgZBjHbMeHLbrAs9jrOjU2PTQ8PSzAXLvX/9lmp7YjmX17Qvlsg0NCr1tbB9OA==", - "optional": true, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", "dependencies": { - "@aws-sdk/util-buffer-from": "3.188.0", - "tslib": "^2.3.1" + "mime-types": "~2.1.34", + "negotiator": "0.6.3" }, "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@aws-sdk/util-body-length-browser": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-body-length-browser/-/util-body-length-browser-3.188.0.tgz", - "integrity": "sha512-8VpnwFWXhnZ/iRSl9mTf+VKOX9wDE8QtN4bj9pBfxwf90H1X7E8T6NkiZD3k+HubYf2J94e7DbeHs7fuCPW5Qg==", - "optional": true, - "dependencies": { - "tslib": "^2.3.1" + "node": ">= 0.6" } }, - "node_modules/@aws-sdk/util-body-length-node": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-body-length-node/-/util-body-length-node-3.188.0.tgz", - "integrity": "sha512-XwqP3vxk60MKp4YDdvDeCD6BPOiG2e+/Ou4AofZOy5/toB6NKz2pFNibQIUg2+jc7mPMnGnvOW3MQEgSJ+gu/Q==", - "optional": true, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", "dependencies": { - "tslib": "^2.3.1" + "debug": "4" }, "engines": { - "node": ">= 12.0.0" + "node": ">= 6.0.0" } }, - "node_modules/@aws-sdk/util-buffer-from": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-buffer-from/-/util-buffer-from-3.188.0.tgz", - "integrity": "sha512-NX1WXZ8TH20IZb4jPFT2CnLKSqZWddGxtfiWxD9M47YOtq/SSQeR82fhqqVjJn4P8w2F5E28f+Du4ntg/sGcxA==", - "optional": true, - "dependencies": { - "@aws-sdk/is-array-buffer": "3.188.0", - "tslib": "^2.3.1" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@aws-sdk/util-config-provider": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-config-provider/-/util-config-provider-3.188.0.tgz", - "integrity": "sha512-LBA7tLbi7v4uvbOJhSnjJrxbcRifKK/1ZVK94JTV2MNSCCyNkFotyEI5UWDl10YKriTIUyf7o5cakpiDZ3O4xg==", - "optional": true, - "dependencies": { - "tslib": "^2.3.1" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@aws-sdk/util-defaults-mode-browser": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-browser/-/util-defaults-mode-browser-3.198.0.tgz", - "integrity": "sha512-IG4iVKQdFjdVFMH5KbSUY2l48wL9aCX/qzoCyTPjKkVumvmwnfkt5OCslkNcaqRdvp5o7QL7aHbq0EZ3K7Ya0A==", - "optional": true, - "dependencies": { - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/types": "3.198.0", - "bowser": "^2.11.0", - "tslib": "^2.3.1" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@aws-sdk/util-defaults-mode-node": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-node/-/util-defaults-mode-node-3.198.0.tgz", - "integrity": "sha512-LBKSKJjEs0D8tjalblDNmq9DWYTDQ1wVUksAIBO2gQU+EZHJwPb9qxyAk32gbnVTOYceZpJ5/vAGT7speDzEyw==", - "optional": true, - "dependencies": { - "@aws-sdk/config-resolver": "3.198.0", - "@aws-sdk/credential-provider-imds": "3.198.0", - "@aws-sdk/node-config-provider": "3.198.0", - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.198.0.tgz", - "integrity": "sha512-fpeJNVoe/QsIcGybgJ+D2jZcUFi7d37FlMiZd9eVnS5LyMGDNH8tVS7aPT7dgb0z30/FKMBIKKG6QxDGxFaqjQ==", - "optional": true, - "dependencies": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@aws-sdk/util-hex-encoding": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.188.0.tgz", - "integrity": "sha512-QyWovTtjQ2RYxqVM+STPh65owSqzuXURnfoof778spyX4iQ4z46wOge1YV2ZtwS8w5LWd9eeVvDrLu5POPYOnA==", - "optional": true, - "dependencies": { - "tslib": "^2.3.1" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.188.0.tgz", - "integrity": "sha512-SxobBVLZkkLSawTCfeQnhVX3Azm9O+C2dngZVe1+BqtF8+retUbVTs7OfYeWBlawVkULKF2e781lTzEHBBjCzw==", - "optional": true, - "dependencies": { - "tslib": "^2.3.1" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@aws-sdk/util-middleware": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-middleware/-/util-middleware-3.198.0.tgz", - "integrity": "sha512-hEdkuGRWhZdEb1plzkGCN2kT8SqiPrEQHngB+1q7pjFJcKWkYkmaLHGw2zhbg1EVNpcGmj5DzCSWzwoPkpDRsw==", - "optional": true, - "dependencies": { - "tslib": "^2.3.1" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@aws-sdk/util-uri-escape": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-uri-escape/-/util-uri-escape-3.188.0.tgz", - "integrity": "sha512-4Y6AYZMT483Tiuq8dxz5WHIiPNdSFPGrl6tRTo2Oi2FcwypwmFhqgEGcqxeXDUJktvaCBxeA08DLr/AemVhPCg==", - "optional": true, - "dependencies": { - "tslib": "^2.3.1" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.198.0.tgz", - "integrity": "sha512-XIwaaKtrEsxsayk1yUNjx15AZenP6YRaRDa3f6dhGO+D6OOXP+0S38O5lakyDDGW7nkwkmXa2NIv/OPHPYJ+jQ==", - "optional": true, - "dependencies": { - "@aws-sdk/types": "3.198.0", - "bowser": "^2.11.0", - "tslib": "^2.3.1" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.198.0.tgz", - "integrity": "sha512-21bi3pNO7jvo3l9LtMJyR48ERN69PuBqMnwnjsDVqyIFBbnZr/JR5rWQx7jdZ0iUt6mRlgZ17xHXlGUGMCxznA==", - "optional": true, - "dependencies": { - "@aws-sdk/node-config-provider": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, - "node_modules/@aws-sdk/util-utf8-browser": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.188.0.tgz", - "integrity": "sha512-jt627x0+jE+Ydr9NwkFstg3cUvgWh56qdaqAMDsqgRlKD21md/6G226z/Qxl7lb1VEW2LlmCx43ai/37Qwcj2Q==", - "optional": true, - "dependencies": { - "tslib": "^2.3.1" - } - }, - "node_modules/@aws-sdk/util-utf8-node": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-node/-/util-utf8-node-3.199.0.tgz", - "integrity": "sha512-Kk3qCdGbe5k0PUE8EBgMsRxNstvDCoWStYWjNwsHWuc/hJitSf44PColzXw6xxHqH1sY+6LcgIaMwJZ5C4bB6w==", - "optional": true, - "dependencies": { - "@aws-sdk/util-buffer-from": "3.188.0", - "tslib": "^2.3.1" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@azure/abort-controller": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz", - "integrity": "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==", - "dependencies": { - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@azure/core-auth": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.4.0.tgz", - "integrity": "sha512-HFrcTgmuSuukRf/EdPmqBrc5l6Q5Uu+2TbuhaKbgaCpP2TfAeiNaQPAadxO+CYBRHGUzIDteMAjFspFLDLnKVQ==", - "dependencies": { - "@azure/abort-controller": "^1.0.0", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@azure/core-client": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.6.1.tgz", - "integrity": "sha512-mZ1MSKhZBYoV8GAWceA+PEJFWV2VpdNSpxxcj1wjIAOi00ykRuIQChT99xlQGZWLY3/NApWhSImlFwsmCEs4vA==", - "dependencies": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-auth": "^1.4.0", - "@azure/core-rest-pipeline": "^1.9.1", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.0.0", - "@azure/logger": "^1.0.0", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@azure/core-rest-pipeline": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.9.2.tgz", - "integrity": "sha512-8rXI6ircjenaLp+PkOFpo37tQ1PQfztZkfVj97BIF3RPxHAsoVSgkJtu3IK/bUEWcb7HzXSoyBe06M7ODRkRyw==", - "dependencies": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-auth": "^1.4.0", - "@azure/core-tracing": "^1.0.1", - "@azure/core-util": "^1.0.0", - "@azure/logger": "^1.0.0", - "form-data": "^4.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "tslib": "^2.2.0", - "uuid": "^8.3.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@azure/core-tracing": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.0.1.tgz", - "integrity": "sha512-I5CGMoLtX+pI17ZdiFJZgxMJApsK6jjfm85hpgp3oazCdq5Wxgh4wMr7ge/TTWW1B5WBuvIOI1fMU/FrOAMKrw==", - "dependencies": { - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@azure/core-util": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.1.1.tgz", - "integrity": "sha512-A4TBYVQCtHOigFb2ETiiKFDocBoI1Zk2Ui1KpI42aJSIDexF7DHQFpnjonltXAIU/ceH+1fsZAWWgvX6/AKzog==", - "dependencies": { - "@azure/abort-controller": "^1.0.0", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@azure/identity": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-3.0.0.tgz", - "integrity": "sha512-MAwrefZE6T15wJe/tOA6dffdTNCh+S6DOQe2otO6drEEVCHDF0zb+GItlK6kQzD5hPm/YueFCW6sN1q6F4XYuQ==", - "dependencies": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-auth": "^1.3.0", - "@azure/core-client": "^1.4.0", - "@azure/core-rest-pipeline": "^1.1.0", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.0.0", - "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^2.26.0", - "@azure/msal-common": "^7.0.0", - "@azure/msal-node": "^1.10.0", - "events": "^3.0.0", - "jws": "^4.0.0", - "open": "^8.0.0", - "stoppable": "^1.1.0", - "tslib": "^2.2.0", - "uuid": "^8.3.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@azure/logger": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.0.3.tgz", - "integrity": "sha512-aK4s3Xxjrx3daZr3VylxejK3vG5ExXck5WOHDJ8in/k9AqlfIyFMMT1uG7u8mNjX+QRILTIn0/Xgschfh/dQ9g==", - "dependencies": { - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@azure/msal-browser": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-2.30.0.tgz", - "integrity": "sha512-4Y9+rjJiTFP7KEmuq1btmIrBgk0ImNyKsXj6A6NHZALd1X0M6W7L7kxpH6F+d1tEkMv8bYnZdn7IcauXbL8Llw==", - "dependencies": { - "@azure/msal-common": "^7.6.0" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/msal-common": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-7.6.0.tgz", - "integrity": "sha512-XqfbglUTVLdkHQ8F9UQJtKseRr3sSnr9ysboxtoswvaMVaEfvyLtMoHv9XdKUfOc0qKGzNgRFd9yRjIWVepl6Q==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/msal-node": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.14.2.tgz", - "integrity": "sha512-t3whVhhLdZVVeDEtUPD2Wqfa8BDi3EDMnpWp8dbuRW0GhUpikBfs4AQU0Fe6P9zS87n9LpmUTLrIcPEEuzkvfA==", - "dependencies": { - "@azure/msal-common": "^7.6.0", - "jsonwebtoken": "^8.5.1", - "uuid": "^8.3.0" - }, - "engines": { - "node": "10 || 12 || 14 || 16 || 18" - } - }, - "node_modules/@azure/msal-node/node_modules/jsonwebtoken": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", - "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", - "license": "MIT", - "dependencies": { - "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^5.6.0" - }, - "engines": { - "node": ">=4", - "npm": ">=1.4.28" - } - }, - "node_modules/@azure/msal-node/node_modules/jwa": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", - "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/@azure/msal-node/node_modules/jws": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", - "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "jwa": "^1.4.2", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/@azure/msal-node/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/@babel/runtime": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.0.tgz", - "integrity": "sha512-NDYdls71fTXoU8TZHfbBWg7DiZfNzClcKui/+kyi6ppD2L1qnWW3VV6CjtaBXSUGGhiTWJ6ereOIkUvenif66Q==", - "dependencies": { - "regenerator-runtime": "^0.13.10" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@coding-club-iitg/ops-contract": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@coding-club-iitg/ops-contract/-/ops-contract-0.2.0.tgz", - "integrity": "sha512-kTZmAnhdGf0tN6dcp/M2BNdyxO5bEjfvinQcpp157XVoZbXsxcp1AjAf/3LbNkdW6u1mh6/6h0APaDqo/pb02g==", - "dev": true, - "license": "UNLICENSED", - "engines": { - "node": ">=18" - } - }, - "node_modules/@coding-club-iitg/ops-logger": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@coding-club-iitg/ops-logger/-/ops-logger-0.3.1.tgz", - "integrity": "sha512-OEWdQDEqh9P8TUZ8IBxtDe1PEjRhVgURI3qBUKrvIg7aYKXTgVdGLcDNgkDHrENOKsxP5Hh1aaw0cqf5N7YQ1Q==", - "license": "UNLICENSED", - "dependencies": { - "@coding-club-iitg/ops-contract": "^0.3.0" + "ms": "^2.1.3" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/sdk-trace-base": "^2.0.0" + "node": ">=6.0" }, "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@opentelemetry/sdk-trace-base": { + "supports-color": { "optional": true } } }, - "node_modules/@coding-club-iitg/ops-logger/node_modules/@coding-club-iitg/ops-contract": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@coding-club-iitg/ops-contract/-/ops-contract-0.3.0.tgz", - "integrity": "sha512-cuF9HwuDskcpOqptHi3MlDUfgjukR1ISZQUuSTEbMCaB9KI9qMp/CNR5oW6VDXTIyZRhH6lxvlSA/zl8ICiXlg==", - "license": "UNLICENSED", - "engines": { - "node": ">=18" - } - }, - "node_modules/@fastify/busboy": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.1.1.tgz", - "integrity": "sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==" - }, - "node_modules/@firebase/app-check-interop-types": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", - "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==" - }, - "node_modules/@firebase/app-types": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", - "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==" - }, - "node_modules/@firebase/auth-interop-types": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", - "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==" - }, - "node_modules/@firebase/component": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.0.tgz", - "integrity": "sha512-wR9En2A+WESUHexjmRHkqtaVH94WLNKt6rmeqZhSLBybg4Wyf0Umk04SZsS6sBq4102ZsDBFwoqMqJYj2IoDSg==", - "dependencies": { - "@firebase/util": "1.13.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@firebase/database": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.0.tgz", - "integrity": "sha512-gM6MJFae3pTyNLoc9VcJNuaUDej0ctdjn3cVtILo3D5lpp0dmUHHLFN/pUKe7ImyeB1KAvRlEYxvIHNF04Filg==", - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.7.0", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.13.0", - "faye-websocket": "0.11.4", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@firebase/database-compat": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.0.tgz", - "integrity": "sha512-8nYc43RqxScsePVd1qe1xxvWNf0OBnbwHxmXJ7MHSuuTVYFO3eLyLW3PiCKJ9fHnmIz4p4LbieXwz+qtr9PZDg==", - "dependencies": { - "@firebase/component": "0.7.0", - "@firebase/database": "1.1.0", - "@firebase/database-types": "1.0.16", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.13.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@firebase/database-types": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.16.tgz", - "integrity": "sha512-xkQLQfU5De7+SPhEGAXFBnDryUWhhlFXelEg2YeZOQMCdoe7dL64DDAd77SQsR+6uoXIZY5MB4y/inCs4GTfcw==", - "dependencies": { - "@firebase/app-types": "0.9.3", - "@firebase/util": "1.13.0" - } - }, - "node_modules/@firebase/logger": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", - "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@firebase/util": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.13.0.tgz", - "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", - "hasInstallScript": true, - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@google-cloud/firestore": { - "version": "7.11.3", - "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.3.tgz", - "integrity": "sha512-qsM3/WHpawF07SRVvEJJVRwhYzM7o9qtuksyuqnrMig6fxIrwWnsezECWsG/D5TyYru51Fv5c/RTqNDQ2yU+4w==", - "optional": true, - "dependencies": { - "@opentelemetry/api": "^1.3.0", - "fast-deep-equal": "^3.1.1", - "functional-red-black-tree": "^1.0.1", - "google-gax": "^4.3.3", - "protobufjs": "^7.2.6" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@google-cloud/paginator": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", - "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", - "optional": true, - "dependencies": { - "arrify": "^2.0.0", - "extend": "^3.0.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@google-cloud/projectify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", - "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", - "optional": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@google-cloud/promisify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", - "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@google-cloud/storage": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.16.0.tgz", - "integrity": "sha512-7/5LRgykyOfQENcm6hDKP8SX/u9XxE5YOiWOkgkwcoO+cG8xT/cyOvp9wwN3IxfdYgpHs8CE7Nq2PKX2lNaEXw==", - "optional": true, - "dependencies": { - "@google-cloud/paginator": "^5.0.0", - "@google-cloud/projectify": "^4.0.0", - "@google-cloud/promisify": "<4.1.0", - "abort-controller": "^3.0.0", - "async-retry": "^1.3.3", - "duplexify": "^4.1.3", - "fast-xml-parser": "^4.4.1", - "gaxios": "^6.0.2", - "google-auth-library": "^9.6.3", - "html-entities": "^2.5.2", - "mime": "^3.0.0", - "p-limit": "^3.0.1", - "retry-request": "^7.0.0", - "teeny-request": "^9.0.0", - "uuid": "^8.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@google-cloud/storage/node_modules/fast-xml-parser": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", - "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "optional": true, - "dependencies": { - "strnum": "^1.1.1" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/@google-cloud/storage/node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "optional": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/@grpc/grpc-js": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", - "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==", - "optional": true, - "dependencies": { - "@grpc/proto-loader": "^0.7.13", - "@js-sdsl/ordered-map": "^4.4.2" - }, - "engines": { - "node": ">=12.10.0" - } - }, - "node_modules/@grpc/proto-loader": { - "version": "0.7.15", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", - "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", - "optional": true, - "dependencies": { - "lodash.camelcase": "^4.3.0", - "long": "^5.0.0", - "protobufjs": "^7.2.5", - "yargs": "^17.7.2" - }, - "bin": { - "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" - }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@imagekit/nodejs": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@imagekit/nodejs/-/nodejs-7.5.0.tgz", - "integrity": "sha512-o87dE4/4CtGomeMNGWmFxSKcQeUjb2VzY3k2L5peGgA3kpvQph3r5hBkvl5ZEOlHbIQvTFqf/wTPBRE5u2ixNg==", - "license": "Apache-2.0", - "dependencies": { - "standardwebhooks": "^1.0.0" - } - }, - "node_modules/@js-sdsl/ordered-map": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", - "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/js-sdsl" - } - }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", - "integrity": "sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA==", - "dependencies": { - "detect-libc": "^2.0.0", - "https-proxy-agent": "^5.0.0", - "make-dir": "^3.1.0", - "node-fetch": "^2.6.7", - "nopt": "^5.0.0", - "npmlog": "^5.0.1", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.11" - }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" - } - }, - "node_modules/@mapbox/node-pre-gyp/node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@microsoft/microsoft-graph-client": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-client/-/microsoft-graph-client-3.0.2.tgz", - "integrity": "sha512-eYDiApYmiGsm1s1jfAa/rhB2xQCsX4pWt0vCTd1LZmiApMQfT/c0hXj2hvpuGz5GrcLdugbu05xB79rIV57Pjw==", - "dependencies": { - "@babel/runtime": "^7.12.5", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependenciesMeta": { - "@azure/identity": { - "optional": true - }, - "@azure/msal-browser": { - "optional": true - }, - "buffer": { - "optional": true - }, - "stream-browserify": { - "optional": true - } - } - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "optional": true, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "optional": true - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "optional": true - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "optional": true - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "optional": true - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "optional": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "optional": true - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "optional": true - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "optional": true - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "optional": true - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "optional": true - }, - "node_modules/@sideway/address": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", - "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" - }, - "node_modules/@stablelib/base64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", - "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", - "license": "MIT" - }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "engines": { - "node": ">= 10" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/caseless": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", - "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", - "optional": true - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", - "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", - "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==" - }, - "node_modules/@types/jsonwebtoken": { - "version": "9.0.10", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", - "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", - "dependencies": { - "@types/ms": "*", - "@types/node": "*" - } - }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "optional": true - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" - }, - "node_modules/@types/node": { - "version": "22.17.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz", - "integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" - }, - "node_modules/@types/request": { - "version": "2.48.13", - "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", - "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", - "optional": true, - "dependencies": { - "@types/caseless": "*", - "@types/node": "*", - "@types/tough-cookie": "*", - "form-data": "^2.5.5" - } - }, - "node_modules/@types/request/node_modules/form-data": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", - "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", - "optional": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.35", - "safe-buffer": "^5.2.1" - }, - "engines": { - "node": ">= 0.12" - } - }, - "node_modules/@types/send": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", - "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", - "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, - "node_modules/@types/tough-cookie": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "optional": true - }, - "node_modules/@types/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==" - }, - "node_modules/@types/whatwg-url": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", - "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", - "dependencies": { - "@types/node": "*", - "@types/webidl-conversions": "*" - } - }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "optional": true, - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/aes-js": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz", - "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==" - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/agent-base/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/agent-base/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "optional": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", - "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/append-field": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" - }, - "node_modules/aproba": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", - "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" - }, - "node_modules/are-we-there-yet": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", - "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/are-we-there-yet/node_modules/readable-stream": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.1.tgz", - "integrity": "sha512-+rQmrWMYGA90yenhTYsLWAsLsqVC8osOw6PKE1HDYiO0gdPeKe/xDHNzIAIn4C91YQ6oenEhfYqqc1883qHbjQ==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, - "node_modules/arrify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", - "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" - }, - "node_modules/async-retry": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", - "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", - "optional": true, - "dependencies": { - "retry": "0.13.1" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/axios": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz", - "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/bcrypt": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.0.tgz", - "integrity": "sha512-RHBS7HI5N5tEnGTmtR/pppX0mmDSBpQ4aCBsj7CEQfYXDcO74A8sIBYcJMuCsis2E81zDxeENYhv66oZwLiA+Q==", - "hasInstallScript": true, - "dependencies": { - "@mapbox/node-pre-gyp": "^1.0.10", - "node-addon-api": "^5.0.0" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "engines": { - "node": "*" - } - }, - "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.1", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" - }, - "node_modules/bowser": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", - "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", - "optional": true - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "dependencies": { - "fill-range": "^7.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/bson": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.0.tgz", - "integrity": "sha512-VrlEE4vuiO1WTpfof4VmaVolCVYkYTgB9iWgYNOrVlnifpME/06fhFRmONgBhClD5pFC1t9ZWqFUQEQAzY43bA==", - "dependencies": { - "buffer": "^5.6.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/cheerio": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", - "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", - "dependencies": { - "cheerio-select": "^2.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "htmlparser2": "^8.0.1", - "parse5": "^7.0.0", - "parse5-htmlparser2-tree-adapter": "^7.0.0" - }, - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/cheeriojs/cheerio?sponsor=1" - } - }, - "node_modules/cheerio-select": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", - "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", - "dependencies": { - "boolbase": "^1.0.0", - "css-select": "^5.1.0", - "css-what": "^6.1.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "engines": { - "node": ">=10" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "optional": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "optional": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "optional": true - }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "bin": { - "color-support": "bin.js" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" - }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "engines": [ - "node >= 0.8" - ], - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/concat-stream/node_modules/readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/concat-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "node_modules/concat-stream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", - "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-parser": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", - "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", - "dependencies": { - "cookie": "0.4.1", - "cookie-signature": "1.0.6" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/cookie-parser/node_modules/cookie": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", - "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/css-select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/csv-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz", - "integrity": "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA==", - "license": "MIT", - "bin": { - "csv-parser": "bin/csv-parser" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "engines": { - "node": ">=8" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" - }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-libc": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", - "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==", - "engines": { - "node": ">=8" - } - }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ] - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz", - "integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.1" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/duplexify": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", - "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", - "optional": true, - "dependencies": { - "end-of-stream": "^1.4.1", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1", - "stream-shift": "^1.0.2" - } - }, - "node_modules/duplexify/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "optional": true, - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/entities": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz", - "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.1", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.5.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.11.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/express-useragent": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/express-useragent/-/express-useragent-1.0.15.tgz", - "integrity": "sha512-eq5xMiYCYwFPoekffMjvEIk+NWdlQY9Y38OsTyl13IvA728vKT+q/CSERYWzcw93HGBJcIqMIsZC5CZGARPVdg==", - "engines": { - "node": ">=4.5" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "node_modules/farmhash-modern": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", - "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "optional": true - }, - "node_modules/fast-sha256": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", - "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", - "license": "Unlicense" - }, - "node_modules/fast-xml-parser": { - "version": "4.0.11", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.0.11.tgz", - "integrity": "sha512-4aUg3aNRR/WjQAcpceODG1C3x3lFANXRo8+1biqfieHmg9pyMt7qB4lQV/Ta6sJCTbA5vfD8fnA8S54JATiFUA==", - "optional": true, - "dependencies": { - "strnum": "^1.0.5" - }, - "bin": { - "fxparser": "src/cli/cli.js" - }, - "funding": { - "type": "paypal", - "url": "https://paypal.me/naturalintelligence" - } - }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/firebase-admin": { - "version": "13.4.0", - "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.4.0.tgz", - "integrity": "sha512-Y8DcyKK+4pl4B93ooiy1G8qvdyRMkcNFfBSh+8rbVcw4cW8dgG0VXCCTp5NUwub8sn9vSPsOwpb9tE2OuFmcfQ==", - "dependencies": { - "@fastify/busboy": "^3.0.0", - "@firebase/database-compat": "^2.0.0", - "@firebase/database-types": "^1.0.6", - "@types/node": "^22.8.7", - "farmhash-modern": "^1.1.0", - "google-auth-library": "^9.14.2", - "jsonwebtoken": "^9.0.0", - "jwks-rsa": "^3.1.0", - "node-forge": "^1.3.1", - "uuid": "^11.0.2" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@google-cloud/firestore": "^7.11.0", - "@google-cloud/storage": "^7.14.0" - } - }, - "node_modules/firebase-admin/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/formidable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.1.tgz", - "integrity": "sha512-0EcS9wCFEzLvfiks7omJ+SiYJAiD+TzK4Pcw1UlUoGnhUxDcMKjt0P7x8wEb0u6OHu8Nb98WG3nxtlF5C7bvUQ==", - "dependencies": { - "dezalgo": "^1.0.4", - "hexoid": "^1.0.0", - "once": "^1.4.0", - "qs": "^6.11.0" - }, - "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", - "optional": true - }, - "node_modules/gauge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", - "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/gaxios": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", - "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/gaxios/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "engines": { - "node": ">= 14" - } - }, - "node_modules/gaxios/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/gaxios/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/gaxios/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/gaxios/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/gcp-metadata": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", - "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", - "dependencies": { - "gaxios": "^6.1.1", - "google-logging-utils": "^0.0.2", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "optional": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/google-auth-library": { - "version": "9.15.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", - "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^6.1.1", - "gcp-metadata": "^6.1.0", - "gtoken": "^7.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/google-gax": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", - "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", - "optional": true, - "dependencies": { - "@grpc/grpc-js": "^1.10.9", - "@grpc/proto-loader": "^0.7.13", - "@types/long": "^4.0.0", - "abort-controller": "^3.0.0", - "duplexify": "^4.0.0", - "google-auth-library": "^9.3.0", - "node-fetch": "^2.7.0", - "object-hash": "^3.0.0", - "proto3-json-serializer": "^2.0.2", - "protobufjs": "^7.3.2", - "retry-request": "^7.0.0", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/google-gax/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "optional": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/google-logging-utils": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", - "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", - "engines": { - "node": ">=14" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gtoken": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", - "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", - "dependencies": { - "gaxios": "^6.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hexoid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", - "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", - "engines": { - "node": ">=8" - } - }, - "node_modules/html-entities": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", - "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/mdevils" - }, - { - "type": "patreon", - "url": "https://patreon.com/mdevils" - } - ], - "optional": true - }, - "node_modules/htmlparser2": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", - "integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "entities": "^4.3.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==" - }, - "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/http-proxy-agent/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/http-proxy-agent/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/https-proxy-agent/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" - }, - "node_modules/isomorphic-fetch": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", - "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", - "dependencies": { - "node-fetch": "^2.6.1", - "whatwg-fetch": "^3.4.1" - } - }, - "node_modules/joi": { - "version": "17.7.1", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.7.1.tgz", - "integrity": "sha512-teoLhIvWE298R6AeJywcjR4sX2hHjB3/xJX4qPjg+gTg+c0mzUDsziYlqPmLomq9gVsfaMcgPaGc7VxtD/9StA==", - "dependencies": { - "@hapi/hoek": "^9.0.0", - "@hapi/topo": "^5.0.0", - "@sideway/address": "^4.1.3", - "@sideway/formula": "^3.0.1", - "@sideway/pinpoint": "^2.0.0" - } - }, - "node_modules/jose": { - "version": "4.15.9", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", - "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, - "node_modules/jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "license": "MIT", - "dependencies": { - "jws": "^4.0.1", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jsonwebtoken/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/jsonwebtoken/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jwks-rsa": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz", - "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==", - "dependencies": { - "@types/express": "^4.17.20", - "@types/jsonwebtoken": "^9.0.4", - "debug": "^4.3.4", - "jose": "^4.15.4", - "limiter": "^1.1.5", - "lru-memoizer": "^2.2.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/jwks-rsa/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/jwks-rsa/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/kareem": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.4.1.tgz", - "integrity": "sha512-aJ9opVoXroQUPfovYP5kaj2lM7Jn02Gw13bL0lg9v0V7SaUc0qavPs0Eue7d2DcC3NjqI6QAUElXNsuZSeM+EA==" - }, - "node_modules/limiter": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", - "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" - }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "optional": true - }, - "node_modules/lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" - }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "optional": true - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/lru-memoizer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", - "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", - "dependencies": { - "lodash.clonedeep": "^4.5.0", - "lru-cache": "6.0.0" - } - }, - "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/memory-pager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "optional": true - }, - "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", - "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.4.tgz", - "integrity": "sha512-lwycX3cBMTvcejsHITUgYj6Gy6A7Nh4Q6h9NP4sTHY1ccJlC7yKzDmiShEHsJ16Jf1nKGDEaiHxiltsJEvk0nQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/mongodb": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.11.0.tgz", - "integrity": "sha512-9l9n4Nk2BYZzljW3vHah3Z0rfS5npKw6ktnkmFgTcnzaXH1DRm3pDl6VMHu84EVb1lzmSaJC4OzWZqTkB5i2wg==", - "dependencies": { - "bson": "^4.7.0", - "denque": "^2.1.0", - "mongodb-connection-string-url": "^2.5.4", - "socks": "^2.7.1" - }, - "engines": { - "node": ">=12.9.0" - }, - "optionalDependencies": { - "@aws-sdk/credential-providers": "^3.186.0", - "saslprep": "^1.0.3" - } - }, - "node_modules/mongodb-connection-string-url": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.5.4.tgz", - "integrity": "sha512-SeAxuWs0ez3iI3vvmLk/j2y+zHwigTDKQhtdxTgt5ZCOQQS5+HW4g45/Xw5vzzbn7oQXCNQ24Z40AkJsizEy7w==", - "dependencies": { - "@types/whatwg-url": "^8.2.1", - "whatwg-url": "^11.0.0" - } - }, - "node_modules/mongoose": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.7.0.tgz", - "integrity": "sha512-Jt6NSiSpgcrSBzRb9+YwkpjjVuq4H532c4jbf+5Nu0wd/nIPHSOKhr8jnQZ8gQTdPjubF+szR5r6KMSqaY4/Wg==", - "dependencies": { - "bson": "^4.6.5", - "kareem": "2.4.1", - "mongodb": "4.11.0", - "mpath": "0.9.0", - "mquery": "4.0.3", - "ms": "2.1.3", - "sift": "16.0.0" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mongoose" - } - }, - "node_modules/mongoose/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/mpath": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", - "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mquery": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-4.0.3.tgz", - "integrity": "sha512-J5heI+P08I6VJ2Ky3+33IpCdAvlYGTSUjwTPxkAr8i8EoduPMBX2OY/wa3IKZIQl7MU4SbFk8ndgSKyB/cl1zA==", - "dependencies": { - "debug": "4.x" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/mquery/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/mquery/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/multer": { - "version": "1.4.5-lts.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", - "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", - "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.0.0", - "concat-stream": "^1.5.2", - "mkdirp": "^0.5.4", - "object-assign": "^4.1.1", - "type-is": "^1.6.4", - "xtend": "^4.0.0" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-addon-api": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", - "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" - }, - "node_modules/node-cron": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.6.0.tgz", - "integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==", - "license": "ISC", - "engines": { - "node": ">=20" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", - "engines": { - "node": ">= 6.13.0" - } - }, - "node_modules/nodemailer": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.1.tgz", - "integrity": "sha512-qHw7dOiU5UKNnQpXktdgQ1d3OFgRAekuvbJLcdG5dnEo/GtcTHRYM7+UfJARdOFU9WUQO8OiIamgWPmiSFHYAA==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/nodemon": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.20.tgz", - "integrity": "sha512-Km2mWHKKY5GzRg6i1j5OxOHQtuvVsgskLfigG25yTtbyfRGn/GNvIbRyOf1PSCKJ2aT/58TiuUsuOU5UToVViw==", - "dev": true, - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^3.2.7", - "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", - "pstree.remy": "^1.1.8", - "semver": "^5.7.1", - "simple-update-notifier": "^1.0.7", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=8.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/nodemon/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/nodemon/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/nopt": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", - "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", - "dev": true, - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "*" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "dependencies": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" - } - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "optional": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/object-inspect": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", - "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/open": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.0.tgz", - "integrity": "sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "optional": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse5": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.1.tgz", - "integrity": "sha512-kwpuwzB+px5WUg9pyK0IcK/shltJN5/OVhQagxhCQNtT9Y9QRZqNY2e1cmbu/paRh5LMnz/oVTVLBpjFmMZhSg==", - "dependencies": { - "entities": "^4.4.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", - "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", - "dependencies": { - "domhandler": "^5.0.2", - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" - }, - "node_modules/proto3-json-serializer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", - "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", - "optional": true, - "dependencies": { - "protobufjs": "^7.2.5" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/protobufjs": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", - "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", - "hasInstallScript": true, - "optional": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true - }, - "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.13.10", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz", - "integrity": "sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw==" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "optional": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/retry-request": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", - "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", - "optional": true, - "dependencies": { - "@types/request": "^2.48.8", - "extend": "^3.0.2", - "teeny-request": "^9.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/saslprep": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz", - "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==", - "optional": true, - "dependencies": { - "sparse-bitfield": "^3.0.3" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "dependencies": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/sift": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.0.tgz", - "integrity": "sha512-ILTjdP2Mv9V1kIxWMXeMTIRbOBrqKc4JAXmFMnFq3fKeyQ2Qwa3Dw1ubcye3vR+Y6ofA0b9gNDr/y2t6eUeIzQ==" - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" - }, - "node_modules/simple-update-notifier": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.0.7.tgz", - "integrity": "sha512-BBKgR84BJQJm6WjWFMHgLVuo61FBDSj1z/xSFUIozqO6wO7ii0JxCqlIud7Enr/+LhlbNI0whErq96P2qHNWew==", - "dev": true, - "dependencies": { - "semver": "~7.0.0" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", - "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", - "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", - "dependencies": { - "ip": "^2.0.0", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.13.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/sparse-bitfield": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", - "optional": true, - "dependencies": { - "memory-pager": "^1.0.2" - } - }, - "node_modules/standardwebhooks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", - "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", - "license": "MIT", - "dependencies": { - "@stablelib/base64": "^1.0.0", - "fast-sha256": "^1.3.0" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/stoppable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", - "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", - "engines": { - "node": ">=4", - "npm": ">=6" - } - }, - "node_modules/stream-events": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", - "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", - "optional": true, - "dependencies": { - "stubs": "^3.0.0" - } - }, - "node_modules/stream-shift": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", - "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", - "optional": true - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strnum": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", - "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "optional": true - }, - "node_modules/stubs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", - "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", - "optional": true - }, - "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/tar": { - "version": "6.1.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz", - "integrity": "sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^4.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/teeny-request": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", - "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", - "optional": true, - "dependencies": { - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "node-fetch": "^2.6.9", - "stream-events": "^1.0.5", - "uuid": "^9.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/teeny-request/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "optional": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/touch": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", - "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", - "dev": true, - "dependencies": { - "nopt": "~1.0.10" - }, - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, - "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "dependencies": { - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" - }, - "node_modules/undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "engines": { - "node": ">=12" - } - }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/whatwg-fetch": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz", - "integrity": "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==" - }, - "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "dependencies": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "optional": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "optional": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "optional": true, - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "optional": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - }, - "dependencies": { - "@aws-crypto/ie11-detection": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-2.0.2.tgz", - "integrity": "sha512-5XDMQY98gMAf/WRTic5G++jfmS/VLM0rwpiOpaainKi4L0nqWMSB1SzsrEG5rjFZGYN6ZAefO+/Yta2dFM0kMw==", - "optional": true, - "requires": { - "tslib": "^1.11.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true - } - } - }, - "@aws-crypto/sha256-browser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-2.0.0.tgz", - "integrity": "sha512-rYXOQ8BFOaqMEHJrLHul/25ckWH6GTJtdLSajhlqGMx0PmSueAuvboCuZCTqEKlxR8CQOwRarxYMZZSYlhRA1A==", - "optional": true, - "requires": { - "@aws-crypto/ie11-detection": "^2.0.0", - "@aws-crypto/sha256-js": "^2.0.0", - "@aws-crypto/supports-web-crypto": "^2.0.0", - "@aws-crypto/util": "^2.0.0", - "@aws-sdk/types": "^3.1.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@aws-sdk/util-utf8-browser": "^3.0.0", - "tslib": "^1.11.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true - } - } - }, - "@aws-crypto/sha256-js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-2.0.0.tgz", - "integrity": "sha512-VZY+mCY4Nmrs5WGfitmNqXzaE873fcIZDu54cbaDaaamsaTOP1DBImV9F4pICc3EHjQXujyE8jig+PFCaew9ig==", - "optional": true, - "requires": { - "@aws-crypto/util": "^2.0.0", - "@aws-sdk/types": "^3.1.0", - "tslib": "^1.11.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true - } - } - }, - "@aws-crypto/supports-web-crypto": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-2.0.2.tgz", - "integrity": "sha512-6mbSsLHwZ99CTOOswvCRP3C+VCWnzBf+1SnbWxzzJ9lR0mA0JnY2JEAhp8rqmTE0GPFy88rrM27ffgp62oErMQ==", - "optional": true, - "requires": { - "tslib": "^1.11.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true - } - } - }, - "@aws-crypto/util": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-2.0.2.tgz", - "integrity": "sha512-Lgu5v/0e/BcrZ5m/IWqzPUf3UYFTy/PpeED+uc9SWUR1iZQL8XXbGQg10UfllwwBryO3hFF5dizK+78aoXC1eA==", - "optional": true, - "requires": { - "@aws-sdk/types": "^3.110.0", - "@aws-sdk/util-utf8-browser": "^3.0.0", - "tslib": "^1.11.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true - } - } - }, - "@aws-sdk/abort-controller": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/abort-controller/-/abort-controller-3.198.0.tgz", - "integrity": "sha512-kmK1fNJ5nkBH23wOrAdxWcVtG/NNCaX66cxr90jnbGvSAeNRi5nLLqlmQOyZ0RRg+tpNCec+N/qqfxAmmD3NdQ==", - "optional": true, - "requires": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/client-cognito-identity": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.199.0.tgz", - "integrity": "sha512-F7VvJkaSYBBiAAtlrWO6Hq5GbqARMerihtlFvQra2Uy0bSX/okNgefurjjW1Gijh4xk6brWPO+46GD9OpZXtcQ==", - "optional": true, - "requires": { - "@aws-crypto/sha256-browser": "2.0.0", - "@aws-crypto/sha256-js": "2.0.0", - "@aws-sdk/client-sts": "3.199.0", - "@aws-sdk/config-resolver": "3.198.0", - "@aws-sdk/credential-provider-node": "3.199.0", - "@aws-sdk/fetch-http-handler": "3.199.0", - "@aws-sdk/hash-node": "3.198.0", - "@aws-sdk/invalid-dependency": "3.198.0", - "@aws-sdk/middleware-content-length": "3.199.0", - "@aws-sdk/middleware-endpoint": "3.198.0", - "@aws-sdk/middleware-host-header": "3.198.0", - "@aws-sdk/middleware-logger": "3.198.0", - "@aws-sdk/middleware-recursion-detection": "3.198.0", - "@aws-sdk/middleware-retry": "3.198.0", - "@aws-sdk/middleware-serde": "3.198.0", - "@aws-sdk/middleware-signing": "3.198.0", - "@aws-sdk/middleware-stack": "3.198.0", - "@aws-sdk/middleware-user-agent": "3.198.0", - "@aws-sdk/node-config-provider": "3.198.0", - "@aws-sdk/node-http-handler": "3.199.0", - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/smithy-client": "3.198.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/url-parser": "3.198.0", - "@aws-sdk/util-base64-browser": "3.188.0", - "@aws-sdk/util-base64-node": "3.188.0", - "@aws-sdk/util-body-length-browser": "3.188.0", - "@aws-sdk/util-body-length-node": "3.188.0", - "@aws-sdk/util-defaults-mode-browser": "3.198.0", - "@aws-sdk/util-defaults-mode-node": "3.198.0", - "@aws-sdk/util-endpoints": "3.198.0", - "@aws-sdk/util-user-agent-browser": "3.198.0", - "@aws-sdk/util-user-agent-node": "3.198.0", - "@aws-sdk/util-utf8-browser": "3.188.0", - "@aws-sdk/util-utf8-node": "3.199.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/client-sso": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.199.0.tgz", - "integrity": "sha512-xRTI39cLcCU1lGlSaE2arCPzRwUY16Oq46fGoe+9pwalDL3oKeE6uq/idTxPzPZdZFhzpLUVc0QdfwGDYhJpXg==", - "optional": true, - "requires": { - "@aws-crypto/sha256-browser": "2.0.0", - "@aws-crypto/sha256-js": "2.0.0", - "@aws-sdk/config-resolver": "3.198.0", - "@aws-sdk/fetch-http-handler": "3.199.0", - "@aws-sdk/hash-node": "3.198.0", - "@aws-sdk/invalid-dependency": "3.198.0", - "@aws-sdk/middleware-content-length": "3.199.0", - "@aws-sdk/middleware-endpoint": "3.198.0", - "@aws-sdk/middleware-host-header": "3.198.0", - "@aws-sdk/middleware-logger": "3.198.0", - "@aws-sdk/middleware-recursion-detection": "3.198.0", - "@aws-sdk/middleware-retry": "3.198.0", - "@aws-sdk/middleware-serde": "3.198.0", - "@aws-sdk/middleware-stack": "3.198.0", - "@aws-sdk/middleware-user-agent": "3.198.0", - "@aws-sdk/node-config-provider": "3.198.0", - "@aws-sdk/node-http-handler": "3.199.0", - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/smithy-client": "3.198.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/url-parser": "3.198.0", - "@aws-sdk/util-base64-browser": "3.188.0", - "@aws-sdk/util-base64-node": "3.188.0", - "@aws-sdk/util-body-length-browser": "3.188.0", - "@aws-sdk/util-body-length-node": "3.188.0", - "@aws-sdk/util-defaults-mode-browser": "3.198.0", - "@aws-sdk/util-defaults-mode-node": "3.198.0", - "@aws-sdk/util-endpoints": "3.198.0", - "@aws-sdk/util-user-agent-browser": "3.198.0", - "@aws-sdk/util-user-agent-node": "3.198.0", - "@aws-sdk/util-utf8-browser": "3.188.0", - "@aws-sdk/util-utf8-node": "3.199.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/client-sts": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.199.0.tgz", - "integrity": "sha512-ly7kLi/KFRr9RrvTVVlDAXlROkInTMRy4BL02Ugw7brSPysUJabzdlDB5gxC+jbeq8qpai/vCybePf6lgrcvFw==", - "optional": true, - "requires": { - "@aws-crypto/sha256-browser": "2.0.0", - "@aws-crypto/sha256-js": "2.0.0", - "@aws-sdk/config-resolver": "3.198.0", - "@aws-sdk/credential-provider-node": "3.199.0", - "@aws-sdk/fetch-http-handler": "3.199.0", - "@aws-sdk/hash-node": "3.198.0", - "@aws-sdk/invalid-dependency": "3.198.0", - "@aws-sdk/middleware-content-length": "3.199.0", - "@aws-sdk/middleware-endpoint": "3.198.0", - "@aws-sdk/middleware-host-header": "3.198.0", - "@aws-sdk/middleware-logger": "3.198.0", - "@aws-sdk/middleware-recursion-detection": "3.198.0", - "@aws-sdk/middleware-retry": "3.198.0", - "@aws-sdk/middleware-sdk-sts": "3.199.0", - "@aws-sdk/middleware-serde": "3.198.0", - "@aws-sdk/middleware-signing": "3.198.0", - "@aws-sdk/middleware-stack": "3.198.0", - "@aws-sdk/middleware-user-agent": "3.198.0", - "@aws-sdk/node-config-provider": "3.198.0", - "@aws-sdk/node-http-handler": "3.199.0", - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/smithy-client": "3.198.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/url-parser": "3.198.0", - "@aws-sdk/util-base64-browser": "3.188.0", - "@aws-sdk/util-base64-node": "3.188.0", - "@aws-sdk/util-body-length-browser": "3.188.0", - "@aws-sdk/util-body-length-node": "3.188.0", - "@aws-sdk/util-defaults-mode-browser": "3.198.0", - "@aws-sdk/util-defaults-mode-node": "3.198.0", - "@aws-sdk/util-endpoints": "3.198.0", - "@aws-sdk/util-user-agent-browser": "3.198.0", - "@aws-sdk/util-user-agent-node": "3.198.0", - "@aws-sdk/util-utf8-browser": "3.188.0", - "@aws-sdk/util-utf8-node": "3.199.0", - "fast-xml-parser": "4.0.11", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/config-resolver": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/config-resolver/-/config-resolver-3.198.0.tgz", - "integrity": "sha512-CxpbkTOfOYZLWcNgcZqooSIlLnixzHVz6skDgxOfeN2vohNOgt8hwU0Dmif3sC4AeyeV0CBm7ew9tg/WzsBxhg==", - "optional": true, - "requires": { - "@aws-sdk/signature-v4": "3.198.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/util-config-provider": "3.188.0", - "@aws-sdk/util-middleware": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/credential-provider-cognito-identity": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.199.0.tgz", - "integrity": "sha512-vhdvaCq9Q/LPvAdTN9HFnsR713iDb1qI7yTmnGzJYym1J9a2pR4YQvZr1pRyXbn176ORkgEiPMNSoDw8CiCRlg==", - "optional": true, - "requires": { - "@aws-sdk/client-cognito-identity": "3.199.0", - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/credential-provider-env": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.198.0.tgz", - "integrity": "sha512-Psui5iNdbHrHNF14vejORMtSEaH7EOt51pQcfmP1jk8Tinf+KMMMdbOqyzL4LHYwLTLH9Cr6m6UGrJXdmFiIZA==", - "optional": true, - "requires": { - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/credential-provider-imds": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.198.0.tgz", - "integrity": "sha512-p2xMCo3whCnXd5/dH738rAVURXhlppjRNDv0sCkDcVtr3exn4s5x5ednFM8K0zNo/hsqjqFbK3jT4W72bgHphw==", - "optional": true, - "requires": { - "@aws-sdk/node-config-provider": "3.198.0", - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/url-parser": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/credential-provider-ini": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.199.0.tgz", - "integrity": "sha512-6m3WtK+oKGyo7iCHjORwmHN4wUp4F9j79dSedEf0EYxDbHpH9yMnEE6hYV11GdmM+/i8x8DYA45apAZo8gCIcA==", - "optional": true, - "requires": { - "@aws-sdk/credential-provider-env": "3.198.0", - "@aws-sdk/credential-provider-imds": "3.198.0", - "@aws-sdk/credential-provider-sso": "3.199.0", - "@aws-sdk/credential-provider-web-identity": "3.198.0", - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/shared-ini-file-loader": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/credential-provider-node": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.199.0.tgz", - "integrity": "sha512-pgJhOnTHj+ZZkcN9v7R+m2VnkQi4vvyA7N6k3EDWMQ8tdo48ofwObORkzA4gPX+TPuIjpYa1lU03dpY4zuzJKQ==", - "optional": true, - "requires": { - "@aws-sdk/credential-provider-env": "3.198.0", - "@aws-sdk/credential-provider-imds": "3.198.0", - "@aws-sdk/credential-provider-ini": "3.199.0", - "@aws-sdk/credential-provider-process": "3.198.0", - "@aws-sdk/credential-provider-sso": "3.199.0", - "@aws-sdk/credential-provider-web-identity": "3.198.0", - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/shared-ini-file-loader": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/credential-provider-process": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.198.0.tgz", - "integrity": "sha512-LWiwKDCui7ILr+6opBzLCCAho9ZOppuEthUdKZx6T7+yD2cQT0caN5PkVUBMtfTu9+DZnHD2bpIL1T2KEaqEUw==", - "optional": true, - "requires": { - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/shared-ini-file-loader": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/credential-provider-sso": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.199.0.tgz", - "integrity": "sha512-aMNZkEj/5RN6jSbfkVadjNblExJtHCJGvcnKnzIGfXLgqOFoWGzY+YIHmn/GTgCnpuMbN5hXYXV6w76HwIvWGw==", - "optional": true, - "requires": { - "@aws-sdk/client-sso": "3.199.0", - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/shared-ini-file-loader": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/credential-provider-web-identity": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.198.0.tgz", - "integrity": "sha512-D+fhnmqN18P/Roq5oxVq53J3mqS9Oi9IJaIKdrbdK/FibqOyKmTERaLKWkONwG35qExSECOpoEGn7ioUMQgAgQ==", - "optional": true, - "requires": { - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/credential-providers": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.199.0.tgz", - "integrity": "sha512-PpOgvV7akew9cXrrEEF+h6H/JGURVCPw+UsvN0yjN8oSexwe0sUzKG1AVIrHYiAWkZKIohtsv7NNzBAKvJ4Qmw==", - "optional": true, - "requires": { - "@aws-sdk/client-cognito-identity": "3.199.0", - "@aws-sdk/client-sso": "3.199.0", - "@aws-sdk/client-sts": "3.199.0", - "@aws-sdk/credential-provider-cognito-identity": "3.199.0", - "@aws-sdk/credential-provider-env": "3.198.0", - "@aws-sdk/credential-provider-imds": "3.198.0", - "@aws-sdk/credential-provider-ini": "3.199.0", - "@aws-sdk/credential-provider-node": "3.199.0", - "@aws-sdk/credential-provider-process": "3.198.0", - "@aws-sdk/credential-provider-sso": "3.199.0", - "@aws-sdk/credential-provider-web-identity": "3.198.0", - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/shared-ini-file-loader": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/fetch-http-handler": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/fetch-http-handler/-/fetch-http-handler-3.199.0.tgz", - "integrity": "sha512-o1jRUMoJR/HOhqA2euYIQxzKLkbqBGwMGH3ahm5eqEJ9kCp84c2KsxT/HOEqjvAQi3f3np8VlTPbuscvDKUN4w==", - "optional": true, - "requires": { - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/querystring-builder": "3.199.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/util-base64-browser": "3.188.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/hash-node": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/hash-node/-/hash-node-3.198.0.tgz", - "integrity": "sha512-+UTjEEQlvT4+IIwLpN36Qb1DOQHe3zHkvIVe6SjLln+Z/UEK6NhMI0tsJNbiW38WAfwOjJ+otrRBHuD93SBRxQ==", - "optional": true, - "requires": { - "@aws-sdk/types": "3.198.0", - "@aws-sdk/util-buffer-from": "3.188.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/invalid-dependency": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/invalid-dependency/-/invalid-dependency-3.198.0.tgz", - "integrity": "sha512-lbwS+H7WYk/g9/nHoTgt7xkrZCJ/OJuBfsx41RvMxW7zPxJeHYD/PvgPvYOB9lTUBkr7SDCeMoS5PtGdAwVOfg==", - "optional": true, - "requires": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/is-array-buffer": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/is-array-buffer/-/is-array-buffer-3.188.0.tgz", - "integrity": "sha512-n69N4zJZCNd87Rf4NzufPzhactUeM877Y0Tp/F3KiHqGeTnVjYUa4Lv1vLBjqtfjYb2HWT3NKlYn5yzrhaEwiQ==", - "optional": true, - "requires": { - "tslib": "^2.3.1" - } - }, - "@aws-sdk/middleware-content-length": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-content-length/-/middleware-content-length-3.199.0.tgz", - "integrity": "sha512-Q76J6xZl36tqS401TGs/NPIu8lYbNG1EtqxM88CWe/u0SH+D4LIR9AMXq9c4PH0ldIMWAGVGbRLLc0/H3rPyBg==", - "optional": true, - "requires": { - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/middleware-endpoint": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint/-/middleware-endpoint-3.198.0.tgz", - "integrity": "sha512-J/rQkIXUbFJAlD6LSDVGU4bGbwD/2pvF5N39ePzvaJ8SwV9Y78XER/2fIAERhFNppuYinGdBdMLiPsC6qPT6ZA==", - "optional": true, - "requires": { - "@aws-sdk/middleware-serde": "3.198.0", - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/signature-v4": "3.198.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/url-parser": "3.198.0", - "@aws-sdk/util-config-provider": "3.188.0", - "@aws-sdk/util-middleware": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/middleware-host-header": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.198.0.tgz", - "integrity": "sha512-keHstrdw0bFzEkUrkMQ9+UxaKu5b1K87cH6guqLf4JBo04CT+2kPRlDSma65XCi2U81zfTnWApk+/SPPFN3otA==", - "optional": true, - "requires": { - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/middleware-logger": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.198.0.tgz", - "integrity": "sha512-IFvNO4MI80FyltPzrEpYHMG47EYXawcD5zTzcbimpeLTpyrLY/zkSJqh5cVFu+NcDWsuD6U1geuvfN+i+2Bg1Q==", - "optional": true, - "requires": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/middleware-recursion-detection": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.198.0.tgz", - "integrity": "sha512-VHyz5xBJOaLZwdL94XWB04XCA+pwbURy+4ESF66vIY1umWgfanbZPkvw1XlRaQJydOmyIDFqhNG2AzB28WN9iw==", - "optional": true, - "requires": { - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/middleware-retry": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-retry/-/middleware-retry-3.198.0.tgz", - "integrity": "sha512-dwv5QJYTPNkmjKcQ0RtClkNumFomECzxjXvSiyjD9Ft6AWHcUeyqJfGKbmP5mFHpezWckK1qcT6cPMVrJilgjw==", - "optional": true, - "requires": { - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/service-error-classification": "3.198.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/util-middleware": "3.198.0", - "tslib": "^2.3.1", - "uuid": "^8.3.2" - } - }, - "@aws-sdk/middleware-sdk-sts": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sts/-/middleware-sdk-sts-3.199.0.tgz", - "integrity": "sha512-ISM3Lx1AM2IiHpcQPdwHHvnW/dRyC/jGy2fHQvmYxj8x2oIYnEXxk4vA7DFnrYupxwi2yTSp3k8On2+1VgMjiw==", - "optional": true, - "requires": { - "@aws-sdk/middleware-signing": "3.198.0", - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/signature-v4": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/middleware-serde": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-serde/-/middleware-serde-3.198.0.tgz", - "integrity": "sha512-RlAua2691KCFabp3kjnsd5p+1nQbULTK1Ia/jvlTAyG4tGOeA0x1At6KZoI1LfkN+VjstV5/3b9aOCtcFuxkhA==", - "optional": true, - "requires": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/middleware-signing": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.198.0.tgz", - "integrity": "sha512-HPY9d1c1CUiV6JBVxiiQQgYfmELl1cn6h0TI00EmOAM5/wxUoiYBX2cGWf2NRF9/iBTppZjxwAKMYPIqF5Tkvw==", - "optional": true, - "requires": { - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/signature-v4": "3.198.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/util-middleware": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/middleware-stack": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-stack/-/middleware-stack-3.198.0.tgz", - "integrity": "sha512-5mHRjiJFHxziXOiChW3kttQHjgqH5qW9xRIDJepyf+NRJ60L8bZj0t8oGecqVqo27S02+UvrFgOzoRvBbATVFw==", - "optional": true, - "requires": { - "tslib": "^2.3.1" - } - }, - "@aws-sdk/middleware-user-agent": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.198.0.tgz", - "integrity": "sha512-SMteVixqoSazxUN1ROMj+nSf/zgTMRVPaTCKU0iEAtrE7ilp9Xv6FEC7ffm1MM9xIoAZ2eY1eAtY3uN0yxBm4A==", - "optional": true, - "requires": { - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/node-config-provider": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/node-config-provider/-/node-config-provider-3.198.0.tgz", - "integrity": "sha512-W+msdp94ZjR8mJMtGPHjWHsIdsOu3HaVX4x+AQq9cj7+pg/D5CvWw7fnbkUQeG+V8Ia/aqzBNxlUpr/FAeQY/g==", - "optional": true, - "requires": { - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/shared-ini-file-loader": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/node-http-handler": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/node-http-handler/-/node-http-handler-3.199.0.tgz", - "integrity": "sha512-F+6T7MHHm0b3+GVRxEnFCuIyqUQb0b8a3Hne3QFV4cxtnX58O/SSF8KlpuGZwobivdRC/AKDaTdI/eA0PQfegA==", - "optional": true, - "requires": { - "@aws-sdk/abort-controller": "3.198.0", - "@aws-sdk/protocol-http": "3.198.0", - "@aws-sdk/querystring-builder": "3.199.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/property-provider": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/property-provider/-/property-provider-3.198.0.tgz", - "integrity": "sha512-jnQeJgZlk+6YJS2/eFz6pm9+XHzvCB0jTxHBwt2zYwZfcJ98viRQWMYfkY1XsemuQb/uIoHRBRhFXaJSLpXVDQ==", - "optional": true, - "requires": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/protocol-http": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.198.0.tgz", - "integrity": "sha512-x+Qc+kwYqaZvLJ/820rxoFUIgSnSS/XlUHwmS+CTn7nJ68CeL3dzmae6TVOslpVBLCvoS2CbEpEoBbofOpsbGw==", - "optional": true, - "requires": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/querystring-builder": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-builder/-/querystring-builder-3.199.0.tgz", - "integrity": "sha512-P4MWPzCqtH3sgI9iXXVdYirYVgggtg4uq5MVefnfHW+osZu8ZR+UKJw5ojAFfOCqcnKOU/xJjz185RroOjrzYQ==", - "optional": true, - "requires": { - "@aws-sdk/types": "3.198.0", - "@aws-sdk/util-uri-escape": "3.188.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/querystring-parser": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-parser/-/querystring-parser-3.198.0.tgz", - "integrity": "sha512-oMybZYINxNiwSELR7tOwqu+1S7CeEC3g5L4IQXk2wvVx96HEf3sQgLr1wbmV1b7lEnTuH9OrgI5RgDUBVqipdw==", - "optional": true, - "requires": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/service-error-classification": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/service-error-classification/-/service-error-classification-3.198.0.tgz", - "integrity": "sha512-bVsWOIYuDtSmwJtPF1pU84x2TL20Pj02C0+/6ua4qLvRatVKFbj1wxWiU/nKvgjiGFX8VWuQUKMzXUYQfYn4nw==", - "optional": true - }, - "@aws-sdk/shared-ini-file-loader": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/shared-ini-file-loader/-/shared-ini-file-loader-3.198.0.tgz", - "integrity": "sha512-n3Ykuvtb6f+WQuhcMVumY9VxQwPp8+cMSc5s6YHptkvZkz/cd2wmPhO914gKE/i2MoC/zQsFCXT8Z1YnS7k8sA==", - "optional": true, - "requires": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/signature-v4": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4/-/signature-v4-3.198.0.tgz", - "integrity": "sha512-8EIyEt7ElTK/tQamYyB16IGwc7EwtLlSVcksaiII780ZtYULnOjogi/UImCYqSejQw+EHhXfbj14HRQT56rqEQ==", - "optional": true, - "requires": { - "@aws-sdk/is-array-buffer": "3.188.0", - "@aws-sdk/types": "3.198.0", - "@aws-sdk/util-hex-encoding": "3.188.0", - "@aws-sdk/util-middleware": "3.198.0", - "@aws-sdk/util-uri-escape": "3.188.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/smithy-client": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.198.0.tgz", - "integrity": "sha512-IKUzJSoIkxYkYpRdlrh6REtDcW5c87FKeqtMC8VTpaTxrXwnJOqbenp7IwArwOnbXp4aIVmzdxT/nvQrftlgWg==", - "optional": true, - "requires": { - "@aws-sdk/middleware-stack": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/types": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.198.0.tgz", - "integrity": "sha512-ljgY9Pgb2CSRrf4IeaNy5gkhTsBae9STKc/mqfScSzvZOvRHu+BOIAGM33fDoCwxD1viKNVJvU1KemiI57Gbvw==", - "optional": true - }, - "@aws-sdk/url-parser": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/url-parser/-/url-parser-3.198.0.tgz", - "integrity": "sha512-wm3+OTDWKsMEOlLGvJ+jxCcOXMjgd5qBDVbu2bTiyTahc2poNlM7kKhSwL4I8PkmGZVAqfAlHD4Wj38WecHQPw==", - "optional": true, - "requires": { - "@aws-sdk/querystring-parser": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/util-base64-browser": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-base64-browser/-/util-base64-browser-3.188.0.tgz", - "integrity": "sha512-qlH+5NZBLiyKziL335BEPedYxX6j+p7KFRWXvDQox9S+s+gLCayednpK+fteOhBenCcR9fUZOVuAPScy1I8qCg==", - "optional": true, - "requires": { - "tslib": "^2.3.1" - } - }, - "@aws-sdk/util-base64-node": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-base64-node/-/util-base64-node-3.188.0.tgz", - "integrity": "sha512-r1dccRsRjKq+OhVRUfqFiW3sGgZBjHbMeHLbrAs9jrOjU2PTQ8PSzAXLvX/9lmp7YjmX17Qvlsg0NCr1tbB9OA==", - "optional": true, - "requires": { - "@aws-sdk/util-buffer-from": "3.188.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/util-body-length-browser": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-body-length-browser/-/util-body-length-browser-3.188.0.tgz", - "integrity": "sha512-8VpnwFWXhnZ/iRSl9mTf+VKOX9wDE8QtN4bj9pBfxwf90H1X7E8T6NkiZD3k+HubYf2J94e7DbeHs7fuCPW5Qg==", - "optional": true, - "requires": { - "tslib": "^2.3.1" - } - }, - "@aws-sdk/util-body-length-node": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-body-length-node/-/util-body-length-node-3.188.0.tgz", - "integrity": "sha512-XwqP3vxk60MKp4YDdvDeCD6BPOiG2e+/Ou4AofZOy5/toB6NKz2pFNibQIUg2+jc7mPMnGnvOW3MQEgSJ+gu/Q==", - "optional": true, - "requires": { - "tslib": "^2.3.1" - } - }, - "@aws-sdk/util-buffer-from": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-buffer-from/-/util-buffer-from-3.188.0.tgz", - "integrity": "sha512-NX1WXZ8TH20IZb4jPFT2CnLKSqZWddGxtfiWxD9M47YOtq/SSQeR82fhqqVjJn4P8w2F5E28f+Du4ntg/sGcxA==", - "optional": true, - "requires": { - "@aws-sdk/is-array-buffer": "3.188.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/util-config-provider": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-config-provider/-/util-config-provider-3.188.0.tgz", - "integrity": "sha512-LBA7tLbi7v4uvbOJhSnjJrxbcRifKK/1ZVK94JTV2MNSCCyNkFotyEI5UWDl10YKriTIUyf7o5cakpiDZ3O4xg==", - "optional": true, - "requires": { - "tslib": "^2.3.1" - } - }, - "@aws-sdk/util-defaults-mode-browser": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-browser/-/util-defaults-mode-browser-3.198.0.tgz", - "integrity": "sha512-IG4iVKQdFjdVFMH5KbSUY2l48wL9aCX/qzoCyTPjKkVumvmwnfkt5OCslkNcaqRdvp5o7QL7aHbq0EZ3K7Ya0A==", - "optional": true, - "requires": { - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/types": "3.198.0", - "bowser": "^2.11.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/util-defaults-mode-node": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-node/-/util-defaults-mode-node-3.198.0.tgz", - "integrity": "sha512-LBKSKJjEs0D8tjalblDNmq9DWYTDQ1wVUksAIBO2gQU+EZHJwPb9qxyAk32gbnVTOYceZpJ5/vAGT7speDzEyw==", - "optional": true, - "requires": { - "@aws-sdk/config-resolver": "3.198.0", - "@aws-sdk/credential-provider-imds": "3.198.0", - "@aws-sdk/node-config-provider": "3.198.0", - "@aws-sdk/property-provider": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/util-endpoints": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.198.0.tgz", - "integrity": "sha512-fpeJNVoe/QsIcGybgJ+D2jZcUFi7d37FlMiZd9eVnS5LyMGDNH8tVS7aPT7dgb0z30/FKMBIKKG6QxDGxFaqjQ==", - "optional": true, - "requires": { - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/util-hex-encoding": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.188.0.tgz", - "integrity": "sha512-QyWovTtjQ2RYxqVM+STPh65owSqzuXURnfoof778spyX4iQ4z46wOge1YV2ZtwS8w5LWd9eeVvDrLu5POPYOnA==", - "optional": true, - "requires": { - "tslib": "^2.3.1" - } - }, - "@aws-sdk/util-locate-window": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.188.0.tgz", - "integrity": "sha512-SxobBVLZkkLSawTCfeQnhVX3Azm9O+C2dngZVe1+BqtF8+retUbVTs7OfYeWBlawVkULKF2e781lTzEHBBjCzw==", - "optional": true, - "requires": { - "tslib": "^2.3.1" - } - }, - "@aws-sdk/util-middleware": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-middleware/-/util-middleware-3.198.0.tgz", - "integrity": "sha512-hEdkuGRWhZdEb1plzkGCN2kT8SqiPrEQHngB+1q7pjFJcKWkYkmaLHGw2zhbg1EVNpcGmj5DzCSWzwoPkpDRsw==", - "optional": true, - "requires": { - "tslib": "^2.3.1" - } - }, - "@aws-sdk/util-uri-escape": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-uri-escape/-/util-uri-escape-3.188.0.tgz", - "integrity": "sha512-4Y6AYZMT483Tiuq8dxz5WHIiPNdSFPGrl6tRTo2Oi2FcwypwmFhqgEGcqxeXDUJktvaCBxeA08DLr/AemVhPCg==", - "optional": true, - "requires": { - "tslib": "^2.3.1" - } - }, - "@aws-sdk/util-user-agent-browser": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.198.0.tgz", - "integrity": "sha512-XIwaaKtrEsxsayk1yUNjx15AZenP6YRaRDa3f6dhGO+D6OOXP+0S38O5lakyDDGW7nkwkmXa2NIv/OPHPYJ+jQ==", - "optional": true, - "requires": { - "@aws-sdk/types": "3.198.0", - "bowser": "^2.11.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/util-user-agent-node": { - "version": "3.198.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.198.0.tgz", - "integrity": "sha512-21bi3pNO7jvo3l9LtMJyR48ERN69PuBqMnwnjsDVqyIFBbnZr/JR5rWQx7jdZ0iUt6mRlgZ17xHXlGUGMCxznA==", - "optional": true, - "requires": { - "@aws-sdk/node-config-provider": "3.198.0", - "@aws-sdk/types": "3.198.0", - "tslib": "^2.3.1" - } - }, - "@aws-sdk/util-utf8-browser": { - "version": "3.188.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.188.0.tgz", - "integrity": "sha512-jt627x0+jE+Ydr9NwkFstg3cUvgWh56qdaqAMDsqgRlKD21md/6G226z/Qxl7lb1VEW2LlmCx43ai/37Qwcj2Q==", - "optional": true, - "requires": { - "tslib": "^2.3.1" - } - }, - "@aws-sdk/util-utf8-node": { - "version": "3.199.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-node/-/util-utf8-node-3.199.0.tgz", - "integrity": "sha512-Kk3qCdGbe5k0PUE8EBgMsRxNstvDCoWStYWjNwsHWuc/hJitSf44PColzXw6xxHqH1sY+6LcgIaMwJZ5C4bB6w==", - "optional": true, - "requires": { - "@aws-sdk/util-buffer-from": "3.188.0", - "tslib": "^2.3.1" - } - }, - "@azure/abort-controller": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz", - "integrity": "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==", - "requires": { - "tslib": "^2.2.0" - } - }, - "@azure/core-auth": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.4.0.tgz", - "integrity": "sha512-HFrcTgmuSuukRf/EdPmqBrc5l6Q5Uu+2TbuhaKbgaCpP2TfAeiNaQPAadxO+CYBRHGUzIDteMAjFspFLDLnKVQ==", - "requires": { - "@azure/abort-controller": "^1.0.0", - "tslib": "^2.2.0" - } - }, - "@azure/core-client": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.6.1.tgz", - "integrity": "sha512-mZ1MSKhZBYoV8GAWceA+PEJFWV2VpdNSpxxcj1wjIAOi00ykRuIQChT99xlQGZWLY3/NApWhSImlFwsmCEs4vA==", - "requires": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-auth": "^1.4.0", - "@azure/core-rest-pipeline": "^1.9.1", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.0.0", - "@azure/logger": "^1.0.0", - "tslib": "^2.2.0" - } - }, - "@azure/core-rest-pipeline": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.9.2.tgz", - "integrity": "sha512-8rXI6ircjenaLp+PkOFpo37tQ1PQfztZkfVj97BIF3RPxHAsoVSgkJtu3IK/bUEWcb7HzXSoyBe06M7ODRkRyw==", - "requires": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-auth": "^1.4.0", - "@azure/core-tracing": "^1.0.1", - "@azure/core-util": "^1.0.0", - "@azure/logger": "^1.0.0", - "form-data": "^4.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "tslib": "^2.2.0", - "uuid": "^8.3.0" - } - }, - "@azure/core-tracing": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.0.1.tgz", - "integrity": "sha512-I5CGMoLtX+pI17ZdiFJZgxMJApsK6jjfm85hpgp3oazCdq5Wxgh4wMr7ge/TTWW1B5WBuvIOI1fMU/FrOAMKrw==", - "requires": { - "tslib": "^2.2.0" - } - }, - "@azure/core-util": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.1.1.tgz", - "integrity": "sha512-A4TBYVQCtHOigFb2ETiiKFDocBoI1Zk2Ui1KpI42aJSIDexF7DHQFpnjonltXAIU/ceH+1fsZAWWgvX6/AKzog==", - "requires": { - "@azure/abort-controller": "^1.0.0", - "tslib": "^2.2.0" - } - }, - "@azure/identity": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-3.0.0.tgz", - "integrity": "sha512-MAwrefZE6T15wJe/tOA6dffdTNCh+S6DOQe2otO6drEEVCHDF0zb+GItlK6kQzD5hPm/YueFCW6sN1q6F4XYuQ==", - "requires": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-auth": "^1.3.0", - "@azure/core-client": "^1.4.0", - "@azure/core-rest-pipeline": "^1.1.0", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.0.0", - "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^2.26.0", - "@azure/msal-common": "^7.0.0", - "@azure/msal-node": "^1.10.0", - "events": "^3.0.0", - "jws": "^4.0.0", - "open": "^8.0.0", - "stoppable": "^1.1.0", - "tslib": "^2.2.0", - "uuid": "^8.3.0" - } - }, - "@azure/logger": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.0.3.tgz", - "integrity": "sha512-aK4s3Xxjrx3daZr3VylxejK3vG5ExXck5WOHDJ8in/k9AqlfIyFMMT1uG7u8mNjX+QRILTIn0/Xgschfh/dQ9g==", - "requires": { - "tslib": "^2.2.0" - } - }, - "@azure/msal-browser": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-2.30.0.tgz", - "integrity": "sha512-4Y9+rjJiTFP7KEmuq1btmIrBgk0ImNyKsXj6A6NHZALd1X0M6W7L7kxpH6F+d1tEkMv8bYnZdn7IcauXbL8Llw==", - "requires": { - "@azure/msal-common": "^7.6.0" - } - }, - "@azure/msal-common": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-7.6.0.tgz", - "integrity": "sha512-XqfbglUTVLdkHQ8F9UQJtKseRr3sSnr9ysboxtoswvaMVaEfvyLtMoHv9XdKUfOc0qKGzNgRFd9yRjIWVepl6Q==" - }, - "@azure/msal-node": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.14.2.tgz", - "integrity": "sha512-t3whVhhLdZVVeDEtUPD2Wqfa8BDi3EDMnpWp8dbuRW0GhUpikBfs4AQU0Fe6P9zS87n9LpmUTLrIcPEEuzkvfA==", - "requires": { - "@azure/msal-common": "^7.6.0", - "jsonwebtoken": "^8.5.1", - "uuid": "^8.3.0" - }, - "dependencies": { - "jsonwebtoken": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", - "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", - "requires": { - "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^5.6.0" - } - }, - "jwa": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", - "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", - "requires": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "jws": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", - "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", - "requires": { - "jwa": "^1.4.2", - "safe-buffer": "^5.0.1" - } - }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - } - } - }, - "@babel/runtime": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.0.tgz", - "integrity": "sha512-NDYdls71fTXoU8TZHfbBWg7DiZfNzClcKui/+kyi6ppD2L1qnWW3VV6CjtaBXSUGGhiTWJ6ereOIkUvenif66Q==", - "requires": { - "regenerator-runtime": "^0.13.10" - } - }, - "@coding-club-iitg/ops-contract": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@coding-club-iitg/ops-contract/-/ops-contract-0.2.0.tgz", - "integrity": "sha512-kTZmAnhdGf0tN6dcp/M2BNdyxO5bEjfvinQcpp157XVoZbXsxcp1AjAf/3LbNkdW6u1mh6/6h0APaDqo/pb02g==", - "dev": true - }, - "@coding-club-iitg/ops-logger": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@coding-club-iitg/ops-logger/-/ops-logger-0.3.1.tgz", - "integrity": "sha512-OEWdQDEqh9P8TUZ8IBxtDe1PEjRhVgURI3qBUKrvIg7aYKXTgVdGLcDNgkDHrENOKsxP5Hh1aaw0cqf5N7YQ1Q==", - "requires": { - "@coding-club-iitg/ops-contract": "^0.3.0" - }, - "dependencies": { - "@coding-club-iitg/ops-contract": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@coding-club-iitg/ops-contract/-/ops-contract-0.3.0.tgz", - "integrity": "sha512-cuF9HwuDskcpOqptHi3MlDUfgjukR1ISZQUuSTEbMCaB9KI9qMp/CNR5oW6VDXTIyZRhH6lxvlSA/zl8ICiXlg==" - } - } - }, - "@fastify/busboy": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.1.1.tgz", - "integrity": "sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==" - }, - "@firebase/app-check-interop-types": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", - "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==" - }, - "@firebase/app-types": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", - "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==" - }, - "@firebase/auth-interop-types": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", - "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==" - }, - "@firebase/component": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.0.tgz", - "integrity": "sha512-wR9En2A+WESUHexjmRHkqtaVH94WLNKt6rmeqZhSLBybg4Wyf0Umk04SZsS6sBq4102ZsDBFwoqMqJYj2IoDSg==", - "requires": { - "@firebase/util": "1.13.0", - "tslib": "^2.1.0" - } - }, - "@firebase/database": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.0.tgz", - "integrity": "sha512-gM6MJFae3pTyNLoc9VcJNuaUDej0ctdjn3cVtILo3D5lpp0dmUHHLFN/pUKe7ImyeB1KAvRlEYxvIHNF04Filg==", - "requires": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.7.0", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.13.0", - "faye-websocket": "0.11.4", - "tslib": "^2.1.0" - } - }, - "@firebase/database-compat": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.0.tgz", - "integrity": "sha512-8nYc43RqxScsePVd1qe1xxvWNf0OBnbwHxmXJ7MHSuuTVYFO3eLyLW3PiCKJ9fHnmIz4p4LbieXwz+qtr9PZDg==", - "requires": { - "@firebase/component": "0.7.0", - "@firebase/database": "1.1.0", - "@firebase/database-types": "1.0.16", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.13.0", - "tslib": "^2.1.0" - } - }, - "@firebase/database-types": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.16.tgz", - "integrity": "sha512-xkQLQfU5De7+SPhEGAXFBnDryUWhhlFXelEg2YeZOQMCdoe7dL64DDAd77SQsR+6uoXIZY5MB4y/inCs4GTfcw==", - "requires": { - "@firebase/app-types": "0.9.3", - "@firebase/util": "1.13.0" - } - }, - "@firebase/logger": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", - "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", - "requires": { - "tslib": "^2.1.0" - } - }, - "@firebase/util": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.13.0.tgz", - "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", - "requires": { - "tslib": "^2.1.0" - } - }, - "@google-cloud/firestore": { - "version": "7.11.3", - "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.3.tgz", - "integrity": "sha512-qsM3/WHpawF07SRVvEJJVRwhYzM7o9qtuksyuqnrMig6fxIrwWnsezECWsG/D5TyYru51Fv5c/RTqNDQ2yU+4w==", - "optional": true, - "requires": { - "@opentelemetry/api": "^1.3.0", - "fast-deep-equal": "^3.1.1", - "functional-red-black-tree": "^1.0.1", - "google-gax": "^4.3.3", - "protobufjs": "^7.2.6" - } - }, - "@google-cloud/paginator": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", - "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", - "optional": true, - "requires": { - "arrify": "^2.0.0", - "extend": "^3.0.2" - } - }, - "@google-cloud/projectify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", - "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", - "optional": true - }, - "@google-cloud/promisify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", - "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", - "optional": true - }, - "@google-cloud/storage": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.16.0.tgz", - "integrity": "sha512-7/5LRgykyOfQENcm6hDKP8SX/u9XxE5YOiWOkgkwcoO+cG8xT/cyOvp9wwN3IxfdYgpHs8CE7Nq2PKX2lNaEXw==", - "optional": true, - "requires": { - "@google-cloud/paginator": "^5.0.0", - "@google-cloud/projectify": "^4.0.0", - "@google-cloud/promisify": "<4.1.0", - "abort-controller": "^3.0.0", - "async-retry": "^1.3.3", - "duplexify": "^4.1.3", - "fast-xml-parser": "^4.4.1", - "gaxios": "^6.0.2", - "google-auth-library": "^9.6.3", - "html-entities": "^2.5.2", - "mime": "^3.0.0", - "p-limit": "^3.0.1", - "retry-request": "^7.0.0", - "teeny-request": "^9.0.0", - "uuid": "^8.0.0" - }, - "dependencies": { - "fast-xml-parser": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", - "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", - "optional": true, - "requires": { - "strnum": "^1.1.1" - } - }, - "mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "optional": true - } - } - }, - "@grpc/grpc-js": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", - "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==", - "optional": true, - "requires": { - "@grpc/proto-loader": "^0.7.13", - "@js-sdsl/ordered-map": "^4.4.2" - } - }, - "@grpc/proto-loader": { - "version": "0.7.15", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", - "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", - "optional": true, - "requires": { - "lodash.camelcase": "^4.3.0", - "long": "^5.0.0", - "protobufjs": "^7.2.5", - "yargs": "^17.7.2" - } - }, - "@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" - }, - "@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "requires": { - "@hapi/hoek": "^9.0.0" - } - }, - "@imagekit/nodejs": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@imagekit/nodejs/-/nodejs-7.5.0.tgz", - "integrity": "sha512-o87dE4/4CtGomeMNGWmFxSKcQeUjb2VzY3k2L5peGgA3kpvQph3r5hBkvl5ZEOlHbIQvTFqf/wTPBRE5u2ixNg==", - "requires": { - "standardwebhooks": "^1.0.0" - } - }, - "@js-sdsl/ordered-map": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", - "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", - "optional": true - }, - "@mapbox/node-pre-gyp": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", - "integrity": "sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA==", - "requires": { - "detect-libc": "^2.0.0", - "https-proxy-agent": "^5.0.0", - "make-dir": "^3.1.0", - "node-fetch": "^2.6.7", - "nopt": "^5.0.0", - "npmlog": "^5.0.1", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.11" - }, - "dependencies": { - "nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "requires": { - "abbrev": "1" - } - }, - "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", - "requires": { - "lru-cache": "^6.0.0" - } - } - } - }, - "@microsoft/microsoft-graph-client": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-client/-/microsoft-graph-client-3.0.2.tgz", - "integrity": "sha512-eYDiApYmiGsm1s1jfAa/rhB2xQCsX4pWt0vCTd1LZmiApMQfT/c0hXj2hvpuGz5GrcLdugbu05xB79rIV57Pjw==", - "requires": { - "@babel/runtime": "^7.12.5", - "tslib": "^2.2.0" - } - }, - "@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "optional": true - }, - "@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "optional": true - }, - "@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "optional": true - }, - "@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "optional": true - }, - "@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "optional": true - }, - "@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "optional": true, - "requires": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "optional": true - }, - "@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "optional": true - }, - "@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "optional": true - }, - "@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "optional": true - }, - "@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "optional": true - }, - "@sideway/address": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", - "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", - "requires": { - "@hapi/hoek": "^9.0.0" - } - }, - "@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" - }, - "@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" - }, - "@stablelib/base64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", - "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==" - }, - "@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==" - }, - "@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "requires": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "@types/caseless": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", - "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", - "optional": true - }, - "@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "requires": { - "@types/node": "*" - } - }, - "@types/express": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", - "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", - "requires": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "@types/express-serve-static-core": { - "version": "4.19.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", - "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "requires": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==" - }, - "@types/jsonwebtoken": { - "version": "9.0.10", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", - "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", - "requires": { - "@types/ms": "*", - "@types/node": "*" - } - }, - "@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "optional": true - }, - "@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" - }, - "@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" - }, - "@types/node": { - "version": "22.17.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz", - "integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==", - "requires": { - "undici-types": "~6.21.0" - } - }, - "@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==" - }, - "@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" - }, - "@types/request": { - "version": "2.48.13", - "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", - "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", - "optional": true, - "requires": { - "@types/caseless": "*", - "@types/node": "*", - "@types/tough-cookie": "*", - "form-data": "^2.5.5" - }, - "dependencies": { - "form-data": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", - "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", - "optional": true, - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.35", - "safe-buffer": "^5.2.1" - } - } - } - }, - "@types/send": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", - "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "requires": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "@types/serve-static": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", - "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", - "requires": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, - "@types/tough-cookie": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "optional": true - }, - "@types/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==" - }, - "@types/whatwg-url": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", - "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", - "requires": { - "@types/node": "*", - "@types/webidl-conversions": "*" - } - }, - "abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" - }, - "abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "optional": true, - "requires": { - "event-target-shim": "^5.0.0" - } - }, - "accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "requires": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - } - }, - "aes-js": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz", - "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==" - }, - "agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "requires": { - "debug": "4" - }, - "dependencies": { - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "requires": { - "ms": "2.1.2" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - } - } - }, - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "optional": true, - "requires": { - "color-convert": "^2.0.1" - } + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, - "anymatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", - "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, - "requires": { + "license": "ISC", + "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" } }, - "append-field": { + "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" - }, - "aproba": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", - "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" - }, - "are-we-there-yet": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", - "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", - "requires": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "dependencies": { - "readable-stream": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.1.tgz", - "integrity": "sha512-+rQmrWMYGA90yenhTYsLWAsLsqVC8osOw6PKE1HDYiO0gdPeKe/xDHNzIAIn4C91YQ6oenEhfYqqc1883qHbjQ==", - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - } - } + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" }, - "array-flatten": { + "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, - "arrify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", - "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", - "optional": true - }, - "asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" - }, - "async-retry": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", - "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", - "optional": true, - "requires": { - "retry": "0.13.1" - } + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" }, - "asynckit": { + "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "axios": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz", - "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==", - "requires": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, - "base64-js": { + "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" }, - "bcrypt": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.0.tgz", - "integrity": "sha512-RHBS7HI5N5tEnGTmtR/pppX0mmDSBpQ4aCBsj7CEQfYXDcO74A8sIBYcJMuCsis2E81zDxeENYhv66oZwLiA+Q==", - "requires": { - "@mapbox/node-pre-gyp": "^1.0.10", - "node-addon-api": "^5.0.0" - } - }, - "bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==" - }, - "binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true - }, - "body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", - "requires": { - "bytes": "3.1.2", - "content-type": "~1.0.4", + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.1", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" } }, - "boolbase": { + "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" }, - "bowser": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", - "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT", "optional": true }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, - "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, - "requires": { - "fill-range": "^7.0.1" + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" } }, - "bson": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.0.tgz", - "integrity": "sha512-VrlEE4vuiO1WTpfof4VmaVolCVYkYTgB9iWgYNOrVlnifpME/06fhFRmONgBhClD5pFC1t9ZWqFUQEQAzY43bA==", - "requires": { + "node_modules/bson": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.2.tgz", + "integrity": "sha512-Ry9wCtIZ5kGqkJoi6aD8KjxFZEx78guTQDnpXWiNthsxzrxAK/i8E6pCHAIZTbaEFWcOCvbecMukfK7XUvyLpQ==", + "license": "Apache-2.0", + "dependencies": { "buffer": "^5.6.0" + }, + "engines": { + "node": ">=6.9.0" } }, - "buffer": { + "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "requires": { + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, - "buffer-equal-constant-time": { + "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" }, - "buffer-from": { + "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" }, - "busboy": { + "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "requires": { + "dependencies": { "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" } }, - "bytes": { + "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" - }, - "call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "requires": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" } }, - "call-bind-apply-helpers": { + "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "requires": { + "license": "MIT", + "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "cheerio": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", - "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", - "requires": { + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "htmlparser2": "^8.0.1", - "parse5": "^7.0.0", - "parse5-htmlparser2-tree-adapter": "^7.0.0" + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" } }, - "cheerio-select": { + "node_modules/cheerio-select": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", - "requires": { + "license": "BSD-2-Clause", + "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" } }, - "chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, - "requires": { + "license": "MIT", + "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", - "fsevents": "~2.3.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" } }, - "chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" - }, - "cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "optional": true, - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "optional": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "optional": true - }, - "color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==" - }, - "combined-stream": { + "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "requires": { + "license": "MIT", + "dependencies": { "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" } }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" - }, - "concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "requires": { + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", - "readable-stream": "^2.2.2", + "readable-stream": "^3.0.2", "typedarray": "^0.0.6" - }, - "dependencies": { - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "requires": { - "safe-buffer": "~5.1.0" - } - } } }, - "console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" - }, - "content-disposition": { + "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "requires": { + "license": "MIT", + "dependencies": { "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" } }, - "content-type": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", - "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" - }, - "cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" - }, - "cookie-parser": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", - "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", - "requires": { - "cookie": "0.4.1", + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", "cookie-signature": "1.0.6" }, - "dependencies": { - "cookie": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", - "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" - } + "engines": { + "node": ">= 0.8.0" } }, - "cookie-signature": { + "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" }, - "core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" - }, - "cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "requires": { + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { "object-assign": "^4", "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "css-select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", - "requires": { + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" } }, - "css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==" + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } }, - "csv-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz", - "integrity": "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA==" + "node_modules/csv-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.1.tgz", + "integrity": "sha512-v8RPMSglouR9od735SnwSxLBbCJqEPSbgm1R5qfr8yIiMUCEFjox56kRZid0SvgHJEkxeIEu3+a9QS3YRh7CuA==", + "license": "MIT", + "bin": { + "csv-parser": "bin/csv-parser" + }, + "engines": { + "node": ">= 10" + } }, - "debug": { + "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { + "license": "MIT", + "dependencies": { "ms": "2.0.0" } }, - "define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==" - }, - "delayed-stream": { + "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" - }, - "delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" - }, - "denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==" + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } }, - "depd": { + "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } }, - "destroy": { + "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" - }, - "detect-libc": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", - "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==" - }, - "dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "requires": { - "asap": "^2.0.0", - "wrappy": "1" + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" } }, - "dom-serializer": { + "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "requires": { + "license": "MIT", + "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, - "domelementtype": { + "node_modules/domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" }, - "domhandler": { + "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "requires": { + "license": "BSD-2-Clause", + "dependencies": { "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" } }, - "domutils": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz", - "integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==", - "requires": { + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", - "domhandler": "^5.0.1" + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "dotenv": { + "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==" + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } }, - "dunder-proto": { + "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "requires": { + "license": "MIT", + "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" - } - }, - "duplexify": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", - "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", - "optional": true, - "requires": { - "end-of-stream": "^1.4.1", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1", - "stream-shift": "^1.0.2" }, - "dependencies": { - "readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "optional": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - } + "engines": { + "node": ">= 0.4" } }, - "ecdsa-sig-formatter": { + "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "requires": { + "license": "Apache-2.0", + "dependencies": { "safe-buffer": "^5.0.1" } }, - "ee-first": { + "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" }, - "encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } }, - "end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "optional": true, - "requires": { - "once": "^1.4.0" + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" } }, - "entities": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz", - "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==" + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } }, - "es-define-property": { + "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } }, - "es-errors": { + "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } }, - "es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "requires": { + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" } }, - "es-set-tostringtag": { + "node_modules/es-set-tostringtag": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "requires": { + "license": "MIT", + "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" } }, - "escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "optional": true - }, - "escape-html": { + "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" }, - "etag": { + "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" - }, - "event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "optional": true - }, - "events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } }, - "express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", - "requires": { + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", - "content-disposition": "0.5.4", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "express-useragent": { + "node_modules/express-useragent": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/express-useragent/-/express-useragent-1.0.15.tgz", - "integrity": "sha512-eq5xMiYCYwFPoekffMjvEIk+NWdlQY9Y38OsTyl13IvA728vKT+q/CSERYWzcw93HGBJcIqMIsZC5CZGARPVdg==" - }, - "extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "farmhash-modern": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", - "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==" - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "optional": true + "integrity": "sha512-eq5xMiYCYwFPoekffMjvEIk+NWdlQY9Y38OsTyl13IvA728vKT+q/CSERYWzcw93HGBJcIqMIsZC5CZGARPVdg==", + "license": "MIT", + "engines": { + "node": ">=4.5" + } }, - "fast-sha256": { + "node_modules/fast-sha256": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", - "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==" - }, - "fast-xml-parser": { - "version": "4.0.11", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.0.11.tgz", - "integrity": "sha512-4aUg3aNRR/WjQAcpceODG1C3x3lFANXRo8+1biqfieHmg9pyMt7qB4lQV/Ta6sJCTbA5vfD8fnA8S54JATiFUA==", - "optional": true, - "requires": { - "strnum": "^1.0.5" - } - }, - "faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "requires": { - "websocket-driver": ">=0.5.1" - } + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" }, - "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, - "requires": { + "license": "MIT", + "dependencies": { "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" } }, - "finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "requires": { + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "statuses": "2.0.1", + "statuses": "~2.0.2", "unpipe": "~1.0.0" - } - }, - "firebase-admin": { - "version": "13.4.0", - "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.4.0.tgz", - "integrity": "sha512-Y8DcyKK+4pl4B93ooiy1G8qvdyRMkcNFfBSh+8rbVcw4cW8dgG0VXCCTp5NUwub8sn9vSPsOwpb9tE2OuFmcfQ==", - "requires": { - "@fastify/busboy": "^3.0.0", - "@firebase/database-compat": "^2.0.0", - "@firebase/database-types": "^1.0.6", - "@google-cloud/firestore": "^7.11.0", - "@google-cloud/storage": "^7.14.0", - "@types/node": "^22.8.7", - "farmhash-modern": "^1.1.0", - "google-auth-library": "^9.14.2", - "jsonwebtoken": "^9.0.0", - "jwks-rsa": "^3.1.0", - "node-forge": "^1.3.1", - "uuid": "^11.0.2" }, - "dependencies": { - "uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==" - } + "engines": { + "node": ">= 0.8" } }, - "follow-redirects": { + "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==" + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } }, - "form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "requires": { + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - } - }, - "formidable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.1.tgz", - "integrity": "sha512-0EcS9wCFEzLvfiks7omJ+SiYJAiD+TzK4Pcw1UlUoGnhUxDcMKjt0P7x8wEb0u6OHu8Nb98WG3nxtlF5C7bvUQ==", - "requires": { - "dezalgo": "^1.0.4", - "hexoid": "^1.0.0", - "once": "^1.4.0", - "qs": "^6.11.0" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" } }, - "forwarded": { + "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } }, - "fresh": { + "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" - }, - "fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "requires": { - "minipass": "^3.0.0" - }, - "dependencies": { - "minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "requires": { - "yallist": "^4.0.0" - } - } + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" } }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" - }, - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "optional": true + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } }, - "function-bind": { + "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" - }, - "functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", - "optional": true - }, - "gauge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", - "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", - "requires": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - } - }, - "gaxios": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", - "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", - "requires": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9", - "uuid": "^9.0.1" - }, - "dependencies": { - "agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" - }, - "debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "requires": { - "ms": "^2.1.3" - } - }, - "https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "requires": { - "agent-base": "^7.1.2", - "debug": "4" - } - }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" - } - } - }, - "gcp-metadata": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", - "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", - "requires": { - "gaxios": "^6.1.1", - "google-logging-utils": "^0.0.2", - "json-bigint": "^1.0.0" + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "optional": true - }, - "get-intrinsic": { + "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "requires": { + "license": "MIT", + "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", @@ -7826,346 +1497,316 @@ "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "get-proto": { + "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "requires": { + "license": "MIT", + "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" } }, - "glob-parent": { + "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, - "requires": { + "license": "ISC", + "dependencies": { "is-glob": "^4.0.1" - } - }, - "google-auth-library": { - "version": "9.15.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", - "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", - "requires": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^6.1.1", - "gcp-metadata": "^6.1.0", - "gtoken": "^7.0.0", - "jws": "^4.0.0" - } - }, - "google-gax": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", - "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", - "optional": true, - "requires": { - "@grpc/grpc-js": "^1.10.9", - "@grpc/proto-loader": "^0.7.13", - "@types/long": "^4.0.0", - "abort-controller": "^3.0.0", - "duplexify": "^4.0.0", - "google-auth-library": "^9.3.0", - "node-fetch": "^2.7.0", - "object-hash": "^3.0.0", - "proto3-json-serializer": "^2.0.2", - "protobufjs": "^7.3.2", - "retry-request": "^7.0.0", - "uuid": "^9.0.1" }, - "dependencies": { - "uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "optional": true - } + "engines": { + "node": ">= 6" } }, - "google-logging-utils": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", - "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==" - }, - "gopd": { + "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" - }, - "gtoken": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", - "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", - "requires": { - "gaxios": "^6.0.0", - "jws": "^4.0.0" + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "has-flag": { + "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } }, - "has-symbols": { + "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "has-tostringtag": { + "node_modules/has-tostringtag": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "requires": { + "license": "MIT", + "dependencies": { "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" - }, - "hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "requires": { + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" } }, - "hexoid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", - "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==" - }, - "html-entities": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", - "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", - "optional": true - }, - "htmlparser2": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", - "integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==", - "requires": { + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "entities": "^4.3.0" + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" } }, - "http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "requires": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==" - }, - "http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "requires": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", "dependencies": { - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "requires": { - "ms": "2.1.2" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - } + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "https-proxy-agent": { + "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "requires": { + "license": "MIT", + "dependencies": { "agent-base": "6", "debug": "4" }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "requires": { - "ms": "2.1.2" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true } } }, - "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "requires": { - "safer-buffer": ">= 2.1.2 < 3" + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" } }, - "ieee754": { + "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" }, - "ignore-by-default": { + "node_modules/ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } + "dev": true, + "license": "ISC" }, - "inherits": { + "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" }, - "ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==" + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } }, - "ipaddr.js": { + "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } }, - "is-binary-path": { + "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, - "requires": { + "license": "MIT", + "dependencies": { "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" } }, - "is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==" - }, - "is-extglob": { + "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, - "is-glob": { + "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, - "requires": { + "license": "MIT", + "dependencies": { "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" } }, - "is-number": { + "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true - }, - "is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" - }, - "is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "requires": { - "is-docker": "^2.0.0" + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" } }, - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" - }, - "isomorphic-fetch": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", - "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", - "requires": { - "node-fetch": "^2.6.1", - "whatwg-fetch": "^3.4.1" - } - }, - "joi": { - "version": "17.7.1", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.7.1.tgz", - "integrity": "sha512-teoLhIvWE298R6AeJywcjR4sX2hHjB3/xJX4qPjg+gTg+c0mzUDsziYlqPmLomq9gVsfaMcgPaGc7VxtD/9StA==", - "requires": { - "@hapi/hoek": "^9.0.0", - "@hapi/topo": "^5.0.0", - "@sideway/address": "^4.1.3", + "node_modules/joi": { + "version": "17.13.7", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.7.tgz", + "integrity": "sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, - "jose": { - "version": "4.15.9", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", - "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==" - }, - "json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "requires": { - "bignumber.js": "^9.0.0" - } - }, - "jsonwebtoken": { + "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "requires": { + "license": "MIT", + "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", @@ -8177,1139 +1818,1078 @@ "ms": "^2.1.1", "semver": "^7.5.4" }, - "dependencies": { - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==" - } + "engines": { + "node": ">=12", + "npm": ">=6" } }, - "jwa": { + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "requires": { + "license": "MIT", + "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, - "jwks-rsa": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz", - "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==", - "requires": { - "@types/express": "^4.17.20", - "@types/jsonwebtoken": "^9.0.4", - "debug": "^4.3.4", - "jose": "^4.15.4", - "limiter": "^1.1.5", - "lru-memoizer": "^2.2.0" - }, - "dependencies": { - "debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "requires": { - "ms": "^2.1.3" - } - }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - } - } - }, - "jws": { + "node_modules/jws": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "requires": { + "license": "MIT", + "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, - "kareem": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.4.1.tgz", - "integrity": "sha512-aJ9opVoXroQUPfovYP5kaj2lM7Jn02Gw13bL0lg9v0V7SaUc0qavPs0Eue7d2DcC3NjqI6QAUElXNsuZSeM+EA==" - }, - "limiter": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", - "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" - }, - "lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "optional": true - }, - "lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + "node_modules/kareem": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", + "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } }, - "lodash.includes": { + "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" }, - "lodash.isboolean": { + "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" }, - "lodash.isinteger": { + "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" }, - "lodash.isnumber": { + "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" }, - "lodash.isplainobject": { + "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" }, - "lodash.isstring": { + "node_modules/lodash.isstring": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" }, - "lodash.once": { + "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" - }, - "long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "optional": true - }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "requires": { - "yallist": "^4.0.0" - } - }, - "lru-memoizer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", - "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", - "requires": { - "lodash.clonedeep": "^4.5.0", - "lru-cache": "6.0.0" - } - }, - "make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "requires": { - "semver": "^6.0.0" - }, - "dependencies": { - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" - } - } + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" }, - "math-intrinsics": { + "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } }, - "media-typer": { + "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } }, - "memory-pager": { + "node_modules/memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT", "optional": true }, - "merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "methods": { + "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } }, - "mime": { + "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } }, - "mime-db": { + "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } }, - "mime-types": { + "node_modules/mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "requires": { + "license": "MIT", + "dependencies": { "mime-db": "1.52.0" - } - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", - "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==" - }, - "minipass": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.4.tgz", - "integrity": "sha512-lwycX3cBMTvcejsHITUgYj6Gy6A7Nh4Q6h9NP4sTHY1ccJlC7yKzDmiShEHsJ16Jf1nKGDEaiHxiltsJEvk0nQ==" - }, - "minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "requires": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" }, - "dependencies": { - "minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "requires": { - "yallist": "^4.0.0" - } - } + "engines": { + "node": ">= 0.6" } }, - "mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "requires": { - "minimist": "^1.2.6" + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "mongodb": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.11.0.tgz", - "integrity": "sha512-9l9n4Nk2BYZzljW3vHah3Z0rfS5npKw6ktnkmFgTcnzaXH1DRm3pDl6VMHu84EVb1lzmSaJC4OzWZqTkB5i2wg==", - "requires": { - "@aws-sdk/credential-providers": "^3.186.0", - "bson": "^4.7.0", - "denque": "^2.1.0", - "mongodb-connection-string-url": "^2.5.4", - "saslprep": "^1.0.3", + "node_modules/mongodb": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.17.2.tgz", + "integrity": "sha512-mLV7SEiov2LHleRJPMPrK2PMyhXFZt2UQLC4VD4pnth3jMjYKHhtqfwwkkvS/NXuo/Fp3vbhaNcXrIDaLRb9Tg==", + "license": "Apache-2.0", + "dependencies": { + "bson": "^4.7.2", + "mongodb-connection-string-url": "^2.6.0", "socks": "^2.7.1" + }, + "engines": { + "node": ">=12.9.0" + }, + "optionalDependencies": { + "@aws-sdk/credential-providers": "^3.186.0", + "@mongodb-js/saslprep": "^1.1.0" } }, - "mongodb-connection-string-url": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.5.4.tgz", - "integrity": "sha512-SeAxuWs0ez3iI3vvmLk/j2y+zHwigTDKQhtdxTgt5ZCOQQS5+HW4g45/Xw5vzzbn7oQXCNQ24Z40AkJsizEy7w==", - "requires": { + "node_modules/mongodb-connection-string-url": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", + "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", + "license": "Apache-2.0", + "dependencies": { "@types/whatwg-url": "^8.2.1", "whatwg-url": "^11.0.0" } }, - "mongoose": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.7.0.tgz", - "integrity": "sha512-Jt6NSiSpgcrSBzRb9+YwkpjjVuq4H532c4jbf+5Nu0wd/nIPHSOKhr8jnQZ8gQTdPjubF+szR5r6KMSqaY4/Wg==", - "requires": { - "bson": "^4.6.5", - "kareem": "2.4.1", - "mongodb": "4.11.0", + "node_modules/mongoose": { + "version": "6.13.11", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.13.11.tgz", + "integrity": "sha512-5KM1Oq106FCdiy4//geOam3mPFekkSY0a4L+zQQPrc6tB6AJtg772aMgeUHF2sTNSxFhJQsAOjpLJpK6Cz+hRw==", + "license": "MIT", + "dependencies": { + "bson": "^4.7.2", + "kareem": "2.5.1", + "mongodb": "4.17.2", "mpath": "0.9.0", "mquery": "4.0.3", "ms": "2.1.3", - "sift": "16.0.0" + "sift": "16.0.1" }, - "dependencies": { - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - } + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" } }, - "mpath": { + "node_modules/mongoose/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mpath": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", - "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==" + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } }, - "mquery": { + "node_modules/mquery": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/mquery/-/mquery-4.0.3.tgz", "integrity": "sha512-J5heI+P08I6VJ2Ky3+33IpCdAvlYGTSUjwTPxkAr8i8EoduPMBX2OY/wa3IKZIQl7MU4SbFk8ndgSKyB/cl1zA==", - "requires": { + "license": "MIT", + "dependencies": { "debug": "4.x" }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/mquery/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "requires": { - "ms": "2.1.2" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true } } }, - "ms": { + "node_modules/mquery/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" }, - "multer": { - "version": "1.4.5-lts.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", - "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", - "requires": { + "node_modules/multer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", + "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", + "license": "MIT", + "dependencies": { "append-field": "^1.0.0", - "busboy": "^1.0.0", - "concat-stream": "^1.5.2", - "mkdirp": "^0.5.4", - "object-assign": "^4.1.1", - "type-is": "^1.6.4", - "xtend": "^4.0.0" + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "negotiator": { + "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } }, - "node-addon-api": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", - "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } }, - "node-cron": { + "node_modules/node-cron": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.6.0.tgz", - "integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==" - }, - "node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "requires": { - "whatwg-url": "^5.0.0" - }, - "dependencies": { - "tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "requires": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - } + "integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==", + "license": "ISC", + "engines": { + "node": ">=20" } }, - "node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==" - }, - "nodemailer": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.1.tgz", - "integrity": "sha512-qHw7dOiU5UKNnQpXktdgQ1d3OFgRAekuvbJLcdG5dnEo/GtcTHRYM7+UfJARdOFU9WUQO8OiIamgWPmiSFHYAA==" + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } }, - "nodemon": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.20.tgz", - "integrity": "sha512-Km2mWHKKY5GzRg6i1j5OxOHQtuvVsgskLfigG25yTtbyfRGn/GNvIbRyOf1PSCKJ2aT/58TiuUsuOU5UToVViw==", + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", "dev": true, - "requires": { + "license": "MIT", + "dependencies": { "chokidar": "^3.5.2", - "debug": "^3.2.7", + "debug": "^4", "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", + "minimatch": "^10.2.1", "pstree.remy": "^1.1.8", - "semver": "^5.7.1", - "simple-update-notifier": "^1.0.7", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true } } }, - "nopt": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", - "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "requires": { - "abbrev": "1" - } + "license": "MIT" }, - "normalize-path": { + "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true - }, - "npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "requires": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, - "nth-check": { + "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "requires": { + "license": "BSD-2-Clause", + "dependencies": { "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "object-assign": { + "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" - }, - "object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "optional": true + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, - "object-inspect": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", - "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==" + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "on-finished": { + "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "requires": { + "license": "MIT", + "dependencies": { "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" } }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "requires": { - "wrappy": "1" - } - }, - "open": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.0.tgz", - "integrity": "sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==", - "requires": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "optional": true, - "requires": { - "yocto-queue": "^0.1.0" + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "parse5": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.1.tgz", - "integrity": "sha512-kwpuwzB+px5WUg9pyK0IcK/shltJN5/OVhQagxhCQNtT9Y9QRZqNY2e1cmbu/paRh5LMnz/oVTVLBpjFmMZhSg==", - "requires": { - "entities": "^4.4.0" + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "parse5-htmlparser2-tree-adapter": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", - "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", - "requires": { - "domhandler": "^5.0.2", - "parse5": "^7.0.0" + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "parseurl": { + "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" - }, - "path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" - }, - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } }, - "process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" }, - "proto3-json-serializer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", - "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", - "optional": true, - "requires": { - "protobufjs": "^7.2.5" + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "protobufjs": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", - "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", - "optional": true, - "requires": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - } - }, - "proxy-addr": { + "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "requires": { + "license": "MIT", + "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" } }, - "proxy-from-env": { + "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==" + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, - "pstree.remy": { + "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true + "dev": true, + "license": "MIT" }, - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } }, - "qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "requires": { - "side-channel": "^1.0.4" + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "range-parser": { + "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } }, - "raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", - "requires": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" } }, - "readdirp": { + "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, - "requires": { + "license": "MIT", + "dependencies": { "picomatch": "^2.2.1" - } - }, - "regenerator-runtime": { - "version": "0.13.10", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz", - "integrity": "sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw==" - }, - "require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "optional": true - }, - "retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "optional": true - }, - "retry-request": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", - "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", - "optional": true, - "requires": { - "@types/request": "^2.48.8", - "extend": "^3.0.2", - "teeny-request": "^9.0.0" - } - }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "requires": { - "glob": "^7.1.3" }, - "dependencies": { - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - } + "engines": { + "node": ">=8.10.0" } }, - "safe-buffer": { + "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" }, - "safer-buffer": { + "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" }, - "saslprep": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz", - "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==", - "optional": true, - "requires": { - "sparse-bitfield": "^3.0.3" + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" - }, - "send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "requires": { + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "range-parser": "~1.2.1", - "statuses": "2.0.1" + "statuses": "~2.0.2" }, - "dependencies": { - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - } + "engines": { + "node": ">= 0.8.0" } }, - "serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "requires": { - "encodeurl": "~1.0.2", + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" } }, - "set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" - }, - "setprototypeof": { + "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" }, - "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - } - }, - "sift": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.0.tgz", - "integrity": "sha512-ILTjdP2Mv9V1kIxWMXeMTIRbOBrqKc4JAXmFMnFq3fKeyQ2Qwa3Dw1ubcye3vR+Y6ofA0b9gNDr/y2t6eUeIzQ==" - }, - "signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" - }, - "simple-update-notifier": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.0.7.tgz", - "integrity": "sha512-BBKgR84BJQJm6WjWFMHgLVuo61FBDSj1z/xSFUIozqO6wO7ii0JxCqlIud7Enr/+LhlbNI0whErq96P2qHNWew==", - "dev": true, - "requires": { - "semver": "~7.0.0" + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", "dependencies": { - "semver": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", - "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", - "dev": true - } + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sift": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", + "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==", + "license": "MIT" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" } }, - "smart-buffer": { + "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==" - }, - "socks": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", - "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", - "requires": { - "ip": "^2.0.0", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" } }, - "sparse-bitfield": { + "node_modules/sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", "optional": true, - "requires": { + "dependencies": { "memory-pager": "^1.0.2" } }, - "standardwebhooks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", - "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", - "requires": { + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, - "statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" - }, - "stoppable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", - "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==" - }, - "stream-events": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", - "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", - "optional": true, - "requires": { - "stubs": "^3.0.0" + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" } }, - "stream-shift": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", - "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", - "optional": true - }, - "streamsearch": { + "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==" + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } }, - "string_decoder": { + "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "requires": { + "license": "MIT", + "dependencies": { "safe-buffer": "~5.2.0" } }, - "string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "strnum": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", - "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", - "optional": true - }, - "stubs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", - "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", - "optional": true - }, - "supports-color": { + "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - }, - "tar": { - "version": "6.1.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz", - "integrity": "sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==", - "requires": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^4.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, + "license": "MIT", "dependencies": { - "mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" - } - } - }, - "teeny-request": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", - "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", - "optional": true, - "requires": { - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "node-fetch": "^2.6.9", - "stream-events": "^1.0.5", - "uuid": "^9.0.0" + "has-flag": "^3.0.0" }, - "dependencies": { - "uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "optional": true - } + "engines": { + "node": ">=4" } }, - "to-regex-range": { + "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, - "requires": { + "license": "MIT", + "dependencies": { "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" } }, - "toidentifier": { + "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } }, - "touch": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", - "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", "dev": true, - "requires": { - "nopt": "~1.0.10" + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" } }, - "tr46": { + "node_modules/tr46": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "requires": { + "license": "MIT", + "dependencies": { "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" } }, - "tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true }, - "type-is": { + "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "requires": { + "license": "MIT", + "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" } }, - "typedarray": { + "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" }, - "undefsafe": { + "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } }, - "undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" }, - "unpipe": { + "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } }, - "util-deprecate": { + "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" }, - "utils-merge": { + "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" - }, - "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } }, - "vary": { + "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } }, - "webidl-conversions": { + "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" - }, - "websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "requires": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" } }, - "websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==" + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } }, - "whatwg-fetch": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz", - "integrity": "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==" + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, - "whatwg-url": { + "node_modules/whatwg-url": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "requires": { + "license": "MIT", + "dependencies": { "tr46": "^3.0.0", "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" } - }, - "wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "requires": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "optional": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" - }, - "y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "optional": true - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, - "yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "optional": true, - "requires": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - } - }, - "yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "optional": true - }, - "yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "optional": true } } } diff --git a/server/package.json b/server/package.json index 94096a49..78614e46 100644 --- a/server/package.json +++ b/server/package.json @@ -18,35 +18,33 @@ "author": "", "license": "ISC", "dependencies": { - "@azure/identity": "^3.0.0", "@coding-club-iitg/ops-logger": "^0.3.1", "@imagekit/nodejs": "^7.5.0", - "@microsoft/microsoft-graph-client": "^3.0.2", - "aes-js": "^3.1.2", - "axios": "^1.1.3", - "bcrypt": "^5.1.0", + "axios": "^1.20.0", + "bcrypt": "^6.0.0", "cheerio": "^1.0.0-rc.12", - "cookie-parser": "^1.4.6", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "csv-parser": "^3.2.0", "dotenv": "^16.6.1", - "express": "^4.18.2", + "express": "^4.22.2", "express-useragent": "^1.0.15", - "firebase-admin": "^13.4.0", - "formidable": "^2.1.1", - "isomorphic-fetch": "^3.0.0", - "joi": "^17.7.1", + "joi": "^17.13.7", "jsonwebtoken": "^9.0.3", - "mongoose": "^6.7.0", - "multer": "^1.4.5-lts.1", - "node-cron": "^4.6.0", - "nodemailer": "^6.9.1" + "mongoose": "^6.13.11", + "multer": "^2.3.0", + "node-cron": "^4.6.0" }, "devDependencies": { "@coding-club-iitg/ops-contract": "^0.2.0", - "nodemon": "^2.0.20" + "nodemon": "^3.1.14" }, "engines": { "node": ">=22" + }, + "overrides": { + "express": { + "qs": "6.16.0" + } } } diff --git a/server/services/email.js b/server/services/email.js deleted file mode 100644 index 34235d73..00000000 --- a/server/services/email.js +++ /dev/null @@ -1,39 +0,0 @@ -import nodemailer from "nodemailer"; -import logger from "../utils/logger.js"; - -const emailClient = { email: process.env.MAIL_USER, password: process.env.MAIL_PASSWORD }; - -function sendEmail(to, subject, msg) { - const transporter = nodemailer.createTransport({ - service: "outlook", - auth: { - user: emailClient.email, - pass: emailClient.password, - }, - }); - const options = { - from: emailClient.email, - to: to, - subject: subject, - text: msg, - }; - transporter.sendMail(options, function (err, info) { - if (err) { - logger.error("Email delivery failed", { - error: err, - attributes: { - dependency: "outlook", - operation: "send-email", - outcome: "failure", - retryable: true, - }, - }); - return; - } - logger.info("Email delivered", { - attributes: { dependency: "outlook", operation: "send-email", outcome: "success" }, - }); - }); -} - -export default sendEmail; diff --git a/server/test/academic-html.test.js b/server/test/academic-html.test.js new file mode 100644 index 00000000..48debebb --- /dev/null +++ b/server/test/academic-html.test.js @@ -0,0 +1,28 @@ +import "./support/environment.js"; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { parseCourseAllotmentsFromHtml } from "../utils/course.js"; + +// Synthetic academic-portal table +const html = ` + + + + + + + +
No.NameRollCourse
1Student A 260100001 CS 101
2Student A260100001 MA 102
3Student A260100001SA 101
4Student B260100002PH 103
5Student B260100002 PH 103
6Incomplete row
`; + +test("academic HTML parsing retains student isolation, normalization and exclusions", () => { + assert.deepEqual(parseCourseAllotmentsFromHtml(html, 260100001), [ + { original: "CS\u00a0101", normalized: "CS101" }, + { original: "MA 102", normalized: "MA102" }, + ]); + assert.deepEqual(parseCourseAllotmentsFromHtml(html), { + 260100001: ["CS101", "MA102"], + 260100002: ["PH103"], + }); + assert.deepEqual(parseCourseAllotmentsFromHtml(html, 260199999), []); + assert.deepEqual(parseCourseAllotmentsFromHtml("
"), {}); +}); diff --git a/server/test/admin-provisioning.test.js b/server/test/admin-provisioning.test.js index b838bcc6..8b54d307 100644 --- a/server/test/admin-provisioning.test.js +++ b/server/test/admin-provisioning.test.js @@ -1,4 +1,5 @@ import "./support/environment.js"; +import { storedPasswordHashes } from "./fixtures/password-hashes.js"; import assert from "node:assert/strict"; import { test } from "node:test"; import { spawnSync } from "node:child_process"; @@ -48,6 +49,15 @@ test("user IDs are trimmed, passwords retain their exact whitespace, and the 72- ); }); +test("persisted administrator hashes still authenticate without a password reset", async () => { + for (const { password, hash } of storedPasswordHashes) { + const admin = new Admin({ userId: "existing-admin", password: hash }); + assert.equal(await admin.comparePassword(password), true); + assert.equal(await admin.comparePassword("incorrect-password"), false); + assert.equal(admin.password, hash); + } +}); + test("an existing administrator cannot be reset by provisioning", async (t) => { const existing = Object.freeze({ _id: "existing-id", diff --git a/server/test/fixtures/multipart-server.js b/server/test/fixtures/multipart-server.js new file mode 100644 index 00000000..e6567349 --- /dev/null +++ b/server/test/fixtures/multipart-server.js @@ -0,0 +1,76 @@ +import "../support/environment.js"; +import fs from "node:fs"; +import path from "node:path"; +import { once } from "node:events"; +import express from "express"; +import multer from "multer"; +import { multipartLimits, parseMultipart } from "../../middleware/multipart.js"; +import { requestContext, requestErrorHandler } from "../../middleware/requestErrors.js"; +import catchAsync from "../../utils/catchAsync.js"; + +// A child process contains any parser crash or event-loop stall in hostile input tests. +if (!process.send) throw new Error("Start this fixture through the multipart test runner"); +const directory = process.argv[2]; +const activeWrites = new Set(); +const createWriteStream = fs.createWriteStream; +fs.createWriteStream = (...args) => { + const stream = createWriteStream(...args); + activeWrites.add(stream); + stream.once("close", () => activeWrites.delete(stream)); + return stream; +}; + +const limits = { ...multipartLimits, fileSize: 8, files: 2, fields: 4, fieldSize: 64 }; +const upload = multer({ dest: directory, limits }); +const interruptedUpload = multer({ + dest: directory, + limits: { ...limits, fileSize: 1024 * 1024 }, +}); +const failingUpload = multer({ + storage: multer.diskStorage({ + destination(req, file, callback) { + callback(Object.assign(new Error("private-storage-path"), { code: "EACCES" })); + }, + }), + limits, +}); +let completed = 0; +let errors = 0; +const app = express(); +app.use(requestContext); +const finish = catchAsync(async (req, res) => { + completed++; + const files = req.files || (req.file ? [req.file] : []); + const result = []; + for (const file of files) { + result.push({ + originalname: file.originalname, + filename: file.filename, + size: file.size, + content: await fs.promises.readFile(file.path, "utf8"), + contained: path.dirname(file.path) === directory, + }); + await fs.promises.unlink(file.path); + } + res.json({ files: result, fields: req.body }); +}); +app.post("/multiple", parseMultipart(upload.array("file")), finish); +app.post("/single", parseMultipart(upload.single("file")), finish); +app.post("/interrupt", parseMultipart(interruptedUpload.array("file")), finish); +app.post("/storage-failure", parseMultipart(failingUpload.single("file")), finish); +app.get("/health", async (req, res) => { + res.json({ + completed, + errors, + activeWrites: activeWrites.size, + files: await fs.promises.readdir(directory), + }); +}); +app.use((error, req, res, next) => { + errors++; + next(error); +}); +app.use(requestErrorHandler); +const listener = app.listen(0, "127.0.0.1"); +await once(listener, "listening"); +process.send({ origin: `http://127.0.0.1:${listener.address().port}` }); diff --git a/server/test/fixtures/password-hashes.js b/server/test/fixtures/password-hashes.js new file mode 100644 index 00000000..2164a8aa --- /dev/null +++ b/server/test/fixtures/password-hashes.js @@ -0,0 +1,11 @@ +// Synthetic persisted hashes produced with bcrypt 5.1.0 before the dependency upgrade. +export const storedPasswordHashes = [ + { + password: " CourseHub existing admin ", + hash: "$2b$10$maN8Zl8Loc2tqO6c7YkgSerIR3xetL0TrgThwiWXJQKp.pX9Fmx9y", + }, + { + password: "🔒🔒🔒🔒🔒🔒🔒🔒🔒🔒🔒🔒🔒🔒🔒🔒🔒🔒", + hash: "$2b$10$l6wUhH2uGN5WvWmjMJ6FTuPgGQU5OTXvj9qWSJGQyPazWvNHe2Ewq", + }, +]; diff --git a/server/test/integration/server.test.js b/server/test/integration/server.test.js index 63187650..1046853a 100644 --- a/server/test/integration/server.test.js +++ b/server/test/integration/server.test.js @@ -87,6 +87,46 @@ after(async () => { } }); +test("authenticated administrator CSV uploads persist courses and clean up parsed and rejected files", async (t) => { + const admin = await Admin.create({ userId: "multipart-admin", password }); + let importedCourse; + t.after(async () => { + if (importedCourse) await Course.deleteOne({ _id: importedCourse._id }); + await Admin.deleteOne({ _id: admin._id }); + }); + const headers = await sessionHeaders(admin.id, "admin"); + delete headers["content-type"]; + fs.mkdirSync("uploads", { recursive: true }); + const beforeFiles = fs.readdirSync("uploads").sort(); + const body = new FormData(); + body.append( + "file", + new Blob(["QA6001,Multipart Course\n"], { type: "text/csv" }), + "courses.csv", + ); + const response = await fetch(origin + "/api/admin/courses/upload", { + method: "POST", + headers, + body, + }); + assert.equal(response.status, 200); + assert.ok((await response.json()).some((course) => course.code === "QA6001")); + importedCourse = await Course.findOne({ code: "QA6001" }); + assert.equal(importedCourse.name, "Multipart Course"); + assert.deepEqual(fs.readdirSync("uploads").sort(), beforeFiles); + + for (const endpoint of ["/api/admin/courses/upload", "/api/admin/courses/bulk-link"]) { + const invalid = new FormData(); + invalid.append("file", new Blob(["QA6002,Must Not Import\n"]), "courses.csv"); + invalid.append("items[4294967294]", "invalid"); + const rejected = await fetch(origin + endpoint, { method: "POST", headers, body: invalid }); + assert.equal(rejected.status, 400); + await rejected.json(); + assert.equal(await Course.exists({ code: "QA6002" }), null); + assert.deepEqual(fs.readdirSync("uploads").sort(), beforeFiles); + } +}); + test("provisioned password hashes work with the existing administrator login", async () => { const result = await provisionAdmin({ userId: "login-fixture", password }); const stored = await Admin.findById(result.id); diff --git a/server/test/multipart.test.js b/server/test/multipart.test.js new file mode 100644 index 00000000..84afefcb --- /dev/null +++ b/server/test/multipart.test.js @@ -0,0 +1,200 @@ +import "./support/environment.js"; +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { fork } from "node:child_process"; +import { once } from "node:events"; +import fs from "node:fs/promises"; +import http from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +let child; +let directory; +let origin; +let output = ""; +before(async () => { + directory = await fs.mkdtemp(path.join(tmpdir(), "coursehub-multipart-")); + child = fork(new URL("./fixtures/multipart-server.js", import.meta.url), [directory], { + stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + for (const stream of [child.stdout, child.stderr]) { + stream.on("data", (data) => { + output = (output + data).slice(-12000); + }); + } + const [message] = await once(child, "message", { signal: AbortSignal.timeout(5000) }); + origin = message.origin; +}); +after(async () => { + if (child?.exitCode === null) { + const exited = once(child, "exit"); + child.kill("SIGKILL"); + await exited; + } + if (directory) await fs.rm(directory, { recursive: true, force: true }); +}); + +async function send(route, body, headers) { + assert.equal(child.exitCode, null, output); + return fetch(origin + route, { + method: "POST", + body, + headers, + signal: AbortSignal.timeout(3000), + }); +} +const health = async () => { + const response = await fetch(origin + "/health", { signal: AbortSignal.timeout(1500) }); + assert.equal(response.status, 200, output); + return response.json(); +}; +async function waitFor(predicate) { + for (let attempt = 0; attempt < 50; attempt++) { + const state = await health(); + if (predicate(state)) return state; + await delay(10); + } + assert.fail("Multipart work did not settle: " + JSON.stringify(await health())); +} +async function assertClean() { + await waitFor((state) => state.activeWrites === 0 && state.files.length === 0); +} +function files(contents, field = "file") { + const body = new FormData(); + for (const content of contents) { + body.append(field, new Blob([content], { type: "application/pdf" }), "notes.pdf"); + } + return body; +} +async function rejected(route, body, status, headers) { + const before = await health(); + const response = await send(route, body, headers); + assert.equal(response.status, status, output); + const error = await response.json(); + assert.ok(error.code); + assert.equal(error.requestId, response.headers.get("x-request-id")); + assert.doesNotMatch(JSON.stringify(error), /stack|private-storage|Unexpected end|Boundary/); + assert.equal( + (await health()).completed, + before.completed, + "Rejected input reached the handler", + ); + await assertClean(); +} + +test("FilePond flat metadata, repeated files and the exact byte limit retain disk-storage behavior", async () => { + const body = files(["12345678", "second"]); + body.append("file", JSON.stringify({ id: "pond-fixture" })); + const response = await send("/multiple", body); + assert.equal(response.status, 200); + const data = await response.json(); + assert.deepEqual(data.fields, { file: '{"id":"pond-fixture"}' }); + assert.deepEqual( + data.files.map((file) => file.content), + ["12345678", "second"], + ); + assert.deepEqual( + data.files.map((file) => file.size), + [8, 6], + ); + assert.equal(new Set(data.files.map((file) => file.filename)).size, 2); + for (const file of data.files) { + assert.equal(file.originalname, "notes.pdf"); + assert.match(file.filename, /^[a-f0-9]{32}$/); + assert.equal(file.contained, true); + } + await assertClean(); +}); + +test("single CSV uploads accept one file and reject unexpected fields or a second file", async () => { + const body = new FormData(); + body.append("file", new Blob(["A,Name\n"], { type: "text/csv" }), "courses.csv"); + const response = await send("/single", body); + assert.equal(response.status, 200); + assert.equal((await response.json()).files[0].content, "A,Name\n"); + await assertClean(); + await rejected("/single", files(["one", "two"]), 400); + await rejected("/multiple", files(["one"], "unexpected"), 400); +}); + +test("file byte/count and text field limits reject input and remove already-written files", async () => { + await rejected("/multiple", files(["valid", "123456789"]), 413); + await rejected("/multiple", files(["one", "two", "three"]), 400); + const body = files(["valid"]); + body.append("description", "a".repeat(65)); + await rejected("/multiple", body, 400); + const fields = new FormData(); + for (let index = 0; index < 5; index++) fields.append("field" + index, "text"); + await rejected("/multiple", fields, 400); +}); + +test("crafted array indexes, sparse-array growth and deep field names cannot crash or stall the process", async () => { + for (const names of [ + ["items[4294967294]", "items[]"], + ["items[4294967294]", "items[key]"], + ["items" + "[nested]".repeat(1000)], + ]) { + const body = new FormData(); + for (const name of names) body.append(name, "text"); + await rejected("/multiple", body, 400); + } + const response = await send("/multiple", files(["healthy"])); + assert.equal(response.status, 200); + await response.json(); + await assertClean(); +}); + +test("missing boundaries, malformed headers and truncated multipart bodies return safe client errors", async () => { + await rejected("/multiple", "invalid", 400, { "content-type": "multipart/form-data" }); + const headers = { "content-type": "multipart/form-data; boundary=fixture" }; + await rejected( + "/multiple", + "--fixture\r\ninvalid header\r\n\r\ndata\r\n--fixture--\r\n", + 400, + headers, + ); + await rejected( + "/multiple", + '--fixture\r\nContent-Disposition: form-data; name="file"; filename="notes.pdf"\r\n\r\ndata', + 400, + headers, + ); +}); + +test("aborted disk uploads release file descriptors and temporary files before the next upload", async () => { + for (let attempt = 0; attempt < 3; attempt++) { + const before = await health(); + const request = http.request(origin + "/interrupt", { + method: "POST", + headers: { + "content-type": "multipart/form-data; boundary=fixture", + "content-length": 1024 * 1024, + }, + }); + request.on("error", () => {}); + try { + request.write( + '--fixture\r\nContent-Disposition: form-data; name="file"; filename="notes.pdf"\r\n\r\n', + ); + request.write(Buffer.alloc(4096, "a")); + await waitFor((state) => state.activeWrites > 0 && state.files.length > 0); + } finally { + // Aborting intentionally emits ECONNRESET before close + const closed = new Promise((resolve) => request.once("close", resolve)); + request.destroy(); + await closed; + } + await waitFor((state) => state.errors > before.errors); + await assertClean(); + assert.equal((await health()).completed, before.completed); + } + const response = await send("/multiple", files(["healthy"])); + assert.equal(response.status, 200); + await response.json(); + await assertClean(); +}); + +test("disk failures remain server errors with safe responses", async () => { + await rejected("/storage-failure", files(["data"]), 500); +}); diff --git a/server/utils/validate.js b/server/utils/validate.js deleted file mode 100644 index e3510c8f..00000000 --- a/server/utils/validate.js +++ /dev/null @@ -1,8 +0,0 @@ -import Joi from "joi"; - -const validatePayload = (schema, obj) => { - const joiSchema = Joi.object(schema); - return joiSchema.validate(obj); -}; - -export default validatePayload; From c4aa45f55485c9457443f9b7004ab47fefa584b2 Mon Sep 17 00:00:00 2001 From: maydayv7 Date: Wed, 9 Sep 2026 11:22:02 +0530 Subject: [PATCH 06/20] fix: Centralize storage and recover upload and deletion operations - Centralized Graph access, token refresh, storage validation and thumbnails. - Added bounded uploads with progress, partial results, retries and cancellation. - Added recoverable deletion journals and administrator operation/retry controls. --- .gitignore | 9 +- README.md | 3 +- admin/src/App.jsx | 2 + admin/src/apis/courses.js | 17 +- admin/src/apis/operations.js | 28 + admin/src/components/OperationNotice.jsx | 31 + admin/src/components/Sidebar.jsx | 1 + admin/src/pages/CourseDashboard.jsx | 157 +++-- admin/src/pages/Operations.jsx | 217 ++++++ admin/src/router_utils/PrivateRoutes.jsx | 3 +- client/src/api/Contribution.js | 8 +- client/src/api/File.js | 7 +- client/src/api/Folder.js | 12 +- client/src/api/Operation.js | 35 + client/src/api/Year.js | 3 +- .../src/components/OperationNotice/index.jsx | 144 ++++ .../components/OperationNotice/styles.scss | 43 ++ client/src/router_utils/PrivateRoutes.jsx | 3 +- .../browse/components/file-display/index.jsx | 43 +- client/src/screens/browse/index.jsx | 2 +- .../components/sectionC/index.jsx | 2 +- client/src/screens/contributions/index.jsx | 542 ++++++++++++--- client/src/screens/contributions/styles.scss | 140 +++- .../Contri_section/ContributionCard/index.jsx | 12 +- docs/storage-operations.md | 35 + server/.env.example | 4 + server/.gitignore | 12 +- server/config/storage.js | 19 + server/index.js | 13 +- server/middleware/receiveUpload.js | 65 ++ .../admin/adminDashboard.controller.js | 291 ++------ server/modules/auth/auth.controller.js | 16 +- .../contribution/contribution.controller.js | 81 +-- .../contribution/contribution.model.js | 2 + .../contribution/contribution.routes.js | 14 +- server/modules/course/course.model.js | 11 +- server/modules/course/course.routes.js | 77 +-- server/modules/file/file.controller.js | 33 +- server/modules/folder/folder.controller.js | 29 +- .../modules/onedrive/onedrive.controller.js | 74 -- .../modules/operation/operation.controller.js | 156 +++++ server/modules/operation/operation.model.js | 76 +++ server/modules/operation/operation.routes.js | 16 + server/modules/user/user.model.js | 8 +- server/modules/year/year.controller.js | 26 +- server/scripts/generateOneDriveToken.js | 16 +- server/scripts/inventoryStorage.js | 55 ++ server/services/UploadFile.js | 173 ----- server/services/authorization.js | 67 +- server/services/contentMutation.js | 45 ++ server/services/courseLocks.js | 83 +++ server/services/deletions.js | 446 ++++++++++++ server/services/fileDelivery.js | 47 +- server/services/graphClient.js | 184 +++++ server/services/imagekit.js | 43 +- server/services/operationWorker.js | 200 ++++++ server/services/resourceRemoval.js | 74 -- server/services/storage.js | 244 +++++++ server/services/storageInventory.js | 69 ++ server/services/storageLeases.js | 25 + server/services/thumbnails.js | 41 ++ server/services/tokenStore.js | 190 ++++++ server/services/uploads.js | 611 +++++++++++++++++ server/test/browser/library-access.test.js | 50 +- server/test/browser/operations.test.js | 386 +++++++++++ server/test/browser/session-profile.test.js | 1 + server/test/fixtures/operations.js | 66 ++ server/test/integration/server.test.js | 203 +++--- server/test/library-authentication.test.js | 3 +- server/test/oauth-provisioning.test.js | 15 +- server/test/storage-inventory.test.js | 35 + server/test/storage.test.js | 358 ++++++++++ server/test/support/course-permissions.js | 172 ++--- server/test/support/environment.js | 1 + server/test/support/operations.js | 640 ++++++++++++++++++ server/test/support/provider-guards.js | 2 + server/test/support/session-security.js | 3 +- server/test/uploads.test.js | 68 ++ server/utils/graphError.js | 79 --- 79 files changed, 5879 insertions(+), 1338 deletions(-) create mode 100644 admin/src/apis/operations.js create mode 100644 admin/src/components/OperationNotice.jsx create mode 100644 admin/src/pages/Operations.jsx create mode 100644 client/src/api/Operation.js create mode 100644 client/src/components/OperationNotice/index.jsx create mode 100644 client/src/components/OperationNotice/styles.scss create mode 100644 docs/storage-operations.md create mode 100644 server/config/storage.js create mode 100644 server/middleware/receiveUpload.js delete mode 100644 server/modules/onedrive/onedrive.controller.js create mode 100644 server/modules/operation/operation.controller.js create mode 100644 server/modules/operation/operation.model.js create mode 100644 server/modules/operation/operation.routes.js create mode 100644 server/scripts/inventoryStorage.js delete mode 100644 server/services/UploadFile.js create mode 100644 server/services/contentMutation.js create mode 100644 server/services/courseLocks.js create mode 100644 server/services/deletions.js create mode 100644 server/services/graphClient.js create mode 100644 server/services/operationWorker.js delete mode 100644 server/services/resourceRemoval.js create mode 100644 server/services/storage.js create mode 100644 server/services/storageInventory.js create mode 100644 server/services/storageLeases.js create mode 100644 server/services/thumbnails.js create mode 100644 server/services/tokenStore.js create mode 100644 server/services/uploads.js create mode 100644 server/test/browser/operations.test.js create mode 100644 server/test/fixtures/operations.js create mode 100644 server/test/storage-inventory.test.js create mode 100644 server/test/storage.test.js create mode 100644 server/test/support/operations.js create mode 100644 server/test/uploads.test.js delete mode 100644 server/utils/graphError.js diff --git a/.gitignore b/.gitignore index a113c5ba..0b824bae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,9 @@ node_modules -file-server .env -access-token.token -refresh_token.token -onedrive-access-token.token -onedrive-refresh-token.token .DS_Store .worktrees/ + +*.token* +onedrive-access-token.json* +.onedrive-refresh.lock* diff --git a/README.md b/README.md index aca2dbf0..8829c00d 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ It supports student and BR management, course dashboards, bulk course imports, c The Node.js and Express API owns authentication, authorization, course and file metadata, contribution review, and administration workflows. MongoDB stores the application data, while Microsoft Graph and OneDrive provide file storage and delivery. -ImageKit stores permanent thumbnails instead of relying on expiring OneDrive preview URLs. +The API refreshes Graph thumbnails and continues serving existing ImageKit thumbnails after authorization. Course allotments are cached in MongoDB after they are resolved from IITG's academic data. A scheduled job synchronizes the shared course cache each month. @@ -262,6 +262,7 @@ npm run build - [Usage Guide](https://codingclub.in/blog/meet-coursehub-find-share-and-organise-course-material) - [Authentication and session configuration](docs/authentication.md) +- [Storage configuration, uploads, and operation recovery](docs/storage-operations.md) - [Course linking and shared-folder model](./docs/course_link_logic.md) - [Frontend caching](./docs/frontend-caching.md) diff --git a/admin/src/App.jsx b/admin/src/App.jsx index a2fb2f5d..29bd130f 100644 --- a/admin/src/App.jsx +++ b/admin/src/App.jsx @@ -9,6 +9,7 @@ import Login from "./pages/Login"; import { ToastContainer } from "react-toastify"; import 'react-toastify/dist/ReactToastify.css'; import CourseDashboard from "./pages/CourseDashboard"; +import Operations from "./pages/Operations"; function App() { return ( @@ -18,6 +19,7 @@ function App() {
+ } /> diff --git a/admin/src/apis/courses.js b/admin/src/apis/courses.js index bdcaa355..078c7b23 100644 --- a/admin/src/apis/courses.js +++ b/admin/src/apis/courses.js @@ -1,5 +1,6 @@ import { apiFetch } from "./http"; import { API_BASE_URL } from "./server.js"; +import { waitForOperation } from "./operations"; // Fetch all courses export const fetchCourses = async () => { @@ -167,7 +168,7 @@ export const deleteCourse = async (code) => { throw new Error(errorData.message || "Failed to delete course"); } - return await response.json(); + return await waitForOperation(await response.json()); } catch (error) { console.error("Error deleting course:", error); throw error; @@ -191,40 +192,42 @@ export const fetchCourseDashboardData = async (code) => { } }; -export const handleContribution = async (contributionId, action, courseCode) => { +export const handleContribution = async (contributionId, action, courseCode, affectedCourses) => { try { const response = await apiFetch(`${API_BASE_URL}api/admin/contribution/action`, { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ contributionId, action, courseCode }), + body: JSON.stringify({ contributionId, action, courseCode, affectedCourses }), credentials: "include", }); if (!response.ok) { - throw new Error(`Failed to ${action} contribution`); + const error = await response.json().catch(() => ({})); + throw new Error(error.message || `Failed to ${action} contribution`); } - return await response.json(); + return await waitForOperation(await response.json()); } catch (error) { console.error("Error handling contribution:", error); throw error; } }; -export const deleteNode = async (type, id, courseCode) => { +export const deleteNode = async (type, id, courseCode, affectedCourses) => { try { const response = await apiFetch(`${API_BASE_URL}api/admin/node/${type}/${id}`, { method: "DELETE", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ courseCode }), + body: JSON.stringify({ courseCode, affectedCourses }), credentials: "include", }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.message || "Failed to delete Item"); } + return await waitForOperation(await response.json()); } catch (error) { console.error("Error deleting node:", error); throw error; diff --git a/admin/src/apis/operations.js b/admin/src/apis/operations.js new file mode 100644 index 00000000..a866c69b --- /dev/null +++ b/admin/src/apis/operations.js @@ -0,0 +1,28 @@ +import { apiFetch, responseError } from "./http"; +import { API_BASE_URL } from "./server"; + +async function request(path = "", options) { + const response = await apiFetch(`${API_BASE_URL}api/operations${path}`, options); + if (!response.ok) throw await responseError(response, "Could not load operation status"); + return response.json(); +} +export const operationEvent = "coursehub-operation"; +export const getOperation = (id, signal) => request(`/${id}`, { signal }); +export const listOperations = (page, status, signal) => + request(`?page=${page}&pageSize=20${status ? `&status=${status}` : ""}`, { signal }); +export const retryOperation = (id) => request(`/${id}/retry`, { method: "POST" }); +export async function waitForOperation(accepted) { + if (!accepted?.operationId) return accepted; + window.dispatchEvent(new CustomEvent(operationEvent)); + for (;;) { + const operation = await getOperation(accepted.operationId); + window.dispatchEvent(new CustomEvent(operationEvent, { detail: operation })); + if (operation.status === "completed") return operation; + if (["failed", "cancelled"].includes(operation.status)) + throw new Error( + operation.error?.message || + "Cleanup could not finish. Open Operations to review and retry.", + ); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } +} diff --git a/admin/src/components/OperationNotice.jsx b/admin/src/components/OperationNotice.jsx new file mode 100644 index 00000000..11fd6dba --- /dev/null +++ b/admin/src/components/OperationNotice.jsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { operationEvent } from "@/apis/operations"; + +export default function OperationNotice() { + const [operation, setOperation] = useState(null); + useEffect(() => { + const update = (event) => setOperation(event.detail || { status: "queued" }); + window.addEventListener(operationEvent, update); + return () => window.removeEventListener(operationEvent, update); + }, []); + return ( +
+ {operation && ( + + {operation.status === "completed" + ? "Cleanup completed." + : operation.status === "failed" + ? "Cleanup needs attention." + : "Cleanup is in progress. You can leave this page."} + + )} + + Operations + +
+ ); +} diff --git a/admin/src/components/Sidebar.jsx b/admin/src/components/Sidebar.jsx index 3df765f2..bddd6cd4 100644 --- a/admin/src/components/Sidebar.jsx +++ b/admin/src/components/Sidebar.jsx @@ -5,6 +5,7 @@ import { FaBook, FaUsers, FaLayerGroup, FaLink, FaUserGraduate, FaExclamationTri import { adminLogout } from "@/apis/auth"; const navItems = [ + { label: "Operations", to: "/admin/operations", icon: FaLayerGroup }, { label: "Students", to: "/admin/students", icon: FaUserGraduate }, { label: "Courses", to: "/admin/courses", icon: FaBook }, { label: "Course Linking", to: "/admin/course-linking", icon: FaLink }, diff --git a/admin/src/pages/CourseDashboard.jsx b/admin/src/pages/CourseDashboard.jsx index ed87cb86..6f31ff3d 100644 --- a/admin/src/pages/CourseDashboard.jsx +++ b/admin/src/pages/CourseDashboard.jsx @@ -1,4 +1,4 @@ -import { useParams} from "react-router-dom"; +import { useParams } from "react-router-dom"; import React, { useEffect, useState } from "react"; import { fetchCourseDashboardData, deleteNode, handleContribution } from "@/apis/courses"; import { @@ -34,13 +34,15 @@ function LoadStructure({ node, onDelete, depth = 0 }) { aria-label={open ? "Collapse folder" : "Expand folder"} className="grid h-5 w-5 flex-shrink-0 place-items-center rounded text-slate-400 hover:bg-slate-200 hover:text-slate-700" > - {hasChildren ? (open ? : ) : null} + {hasChildren ? open ? : : null} ) : ( )} - + {isFolder ? : } @@ -57,7 +59,8 @@ function LoadStructure({ node, onDelete, depth = 0 }) {
- {isFolder && open && + {isFolder && + open && node.children?.map((child) => ( { - try - { + try { const getDashboard = await fetchCourseDashboardData(code); setData(getDashboard); - } - catch (error) - { + } catch (error) { console.log(error); - } - finally - { + } finally { setLoading(false); } }; - const handleContributionAction = async(contributionId, action) => - { + const handleContributionAction = async (contributionId, action) => { const isApprove = action === "approve"; - if(!window.confirm(`Are you sure you want to ${action} this contribution`)) - { + const contribution = data.contributions.find( + (item) => item.contributionId === contributionId, + ); + const affectedCourses = [ + ...new Set(contribution?.files.flatMap((file) => file.affectedCourses || [code])), + ].sort(); + const impact = + !isApprove && affectedCourses.length > 1 + ? ` Shared files will be deleted from every affected course: ${affectedCourses.join(", ")}.` + : ""; + if (!window.confirm(`Are you sure you want to ${action} this contribution?${impact}`)) { return; } - try - { - await handleContribution(contributionId,action,code); - alert(`Contribution ${isApprove ? 'Approved' : 'Rejected'} succesfully!`); + try { + await handleContribution(contributionId, action, code, affectedCourses); + alert(`Contribution ${isApprove ? "Approved" : "Rejected"} succesfully!`); loadData(); - } - catch(error) - { + } catch (error) { console.log(error); - alert("error"); - } - } + alert(error.message || "The contribution action could not finish."); + } + }; - const handleDelete = async (type, id, name, affectedCourses = []) => - { - const impact = affectedCourses.length > 1 ? (type === "file" ? ` This shared file will be removed from ${affectedCourses.join(", ")}.` : ` This folder will be unlinked from ${code}; other linked courses keep it.`) : ""; - if (!window.confirm(`Are you SURE you want to permanently delete the ${type} "${name}"?${impact} This cannot be undone.`)) - { + const handleDelete = async (type, id, name, affectedCourses = []) => { + const impact = + affectedCourses.length > 1 + ? type === "file" + ? ` This shared file will be removed from ${affectedCourses.join(", ")}.` + : ` This folder will be unlinked from ${code}; other linked courses keep it.` + : ""; + if ( + !window.confirm( + `Are you SURE you want to permanently delete the ${type} "${name}"?${impact} This cannot be undone.`, + ) + ) { return; } - try - { - await deleteNode(type, id, code); + try { + await deleteNode(type, id, code, affectedCourses); loadData(); - } - catch (error) - { + } catch (error) { console.error(error); - alert(`Failed to delete the ${type}. Check the console.`); + alert( + error.message || + `Could not finish deleting the ${type}. Open Operations for its status.`, + ); } }; - if (loading) - { + if (loading) { return (
@@ -147,20 +156,19 @@ export default function CourseDashboard() { } const studentCount = data?.studentCount || 0; - const approvedContributions = data?.contributions?.filter(c => c.approved) || []; - const verifiedFilesCount = approvedContributions.reduce((total,contribution) => - { + const approvedContributions = data?.contributions?.filter((c) => c.approved) || []; + const verifiedFilesCount = approvedContributions.reduce((total, contribution) => { return total + (contribution.files?.length || 0); - },0); + }, 0); - const pendingContributions = data?.contributions?.filter(c => !c.approved) || []; + const pendingContributions = data?.contributions?.filter((c) => !c.approved) || []; return (

{data?.course?.code} - · {data?.course?.name} + · {data?.course?.name}

@@ -171,7 +179,9 @@ export default function CourseDashboard() {
-
{studentCount}
+
+ {studentCount} +
Registered students
@@ -180,8 +190,12 @@ export default function CourseDashboard() {
-
{verifiedFilesCount}
-
Verified contributions
+
+ {verifiedFilesCount} +
+
+ Verified contributions +
@@ -193,15 +207,23 @@ export default function CourseDashboard() {
{data?.course?.children?.length ? ( data.course.children.map((child) => ( - + )) ) : (
-
No folders or files yet
-
Course content will appear here once added.
+
+ No folders or files yet +
+
+ Course content will appear here once added. +
)}
@@ -220,8 +242,12 @@ export default function CourseDashboard() { -
You're all caught up
-
New submissions will show up here for review.
+
+ You're all caught up +
+
+ New submissions will show up here for review. +
) : ( pendingContributions.map((contribution) => ( @@ -234,19 +260,32 @@ export default function CourseDashboard() {
- {contribution.files?.map(f => f.name).join(", ") || contribution.contributionId} + {contribution.files?.map((f) => f.name).join(", ") || + contribution.contributionId} +
+
+ Awaiting review
-
Awaiting review
); -} \ No newline at end of file +} diff --git a/admin/src/pages/Operations.jsx b/admin/src/pages/Operations.jsx new file mode 100644 index 00000000..342b9ece --- /dev/null +++ b/admin/src/pages/Operations.jsx @@ -0,0 +1,217 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { listOperations, retryOperation } from "@/apis/operations"; + +const active = ["planning", "queued", "running", "cancelling"]; +export default function Operations() { + const [page, setPage] = useState(1); + const [status, setStatus] = useState(""); + const [data, setData] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + const [revision, setRevision] = useState(0); + const [busy, setBusy] = useState(null); + useEffect(() => { + const controller = new AbortController(); + let timer; + const load = async () => { + try { + const result = await listOperations(page, status, controller.signal); + if (!Array.isArray(result.items)) + throw new Error("Could not load operation status"); + if (controller.signal.aborted) return; + setData(result); + setError(""); + if (result.items.some((item) => active.includes(item.status))) + timer = setTimeout(load, 3000); + } catch (failure) { + if (!controller.signal.aborted) setError(failure.message); + } finally { + if (!controller.signal.aborted) setLoading(false); + } + }; + setLoading(true); + load(); + return () => { + controller.abort(); + clearTimeout(timer); + }; + }, [page, status, revision]); + const retry = async (id) => { + setBusy(id); + try { + await retryOperation(id); + setRevision((value) => value + 1); + } catch (failure) { + setError(failure.message); + } finally { + setBusy(null); + } + }; + const button = + "rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-900 hover:bg-gray-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-600 disabled:opacity-50"; + return ( +
+
+
+

Operations

+

+ Upload results and recoverable content cleanup. +

+
+ + Back to courses + +
+
+ + +
+ {error && ( +

+ {error} Use Refresh to try again. +

+ )} + {loading && ( +

+ Updating operations… +

+ )} + {data?.items.length === 0 && ( +

+ No operations match this status. +

+ )} +
    + {data?.items.map((item) => ( +
  • +
    +

    + {item.name}{" "} + + · {item.courseCode} + +

    + + {item.status} + +
    +

    + {item.kind === "delete" + ? `Cleanup · ${item.completedSteps} ${item.completedSteps === 1 ? "step" : "steps"} completed` + : `${item.entries.filter((entry) => entry.state === "completed").length} of ${item.entries.length} files uploaded`} +

    + {item.affectedCourses?.length > 1 && ( +

    + Affected courses: {item.affectedCourses.join(", ")} +

    + )} + {item.error?.message && ( +

    {item.error.message}

    + )} + {item.kind === "delete" && item.status === "failed" && ( +

    + Content stays unavailable until cleanup finishes. Retry resumes the + saved steps. +

    + )} + {item.entries.length > 0 && ( +
      + {item.entries.map((entry) => ( +
    • + {entry.name} + — {entry.state} + {entry.error?.message && ( +

      {entry.error.message}

      + )} +
    • + ))} +
    + )} + {item.canRetry && ( + + )} +
  • + ))} +
+ {data && data.total > 20 && ( + + )} +
+ ); +} diff --git a/admin/src/router_utils/PrivateRoutes.jsx b/admin/src/router_utils/PrivateRoutes.jsx index a4ea95ad..8639379c 100644 --- a/admin/src/router_utils/PrivateRoutes.jsx +++ b/admin/src/router_utils/PrivateRoutes.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from "react"; import { Navigate, useLocation } from "react-router-dom"; import { checkAdminSession } from "@/apis/auth"; +import OperationNotice from "@/components/OperationNotice"; export default function PrivateRoute({ children }) { const location = useLocation(); @@ -26,5 +27,5 @@ export default function PrivateRoute({ children }) { if (loading) return null; if (!ok) return ; - return children; + return <>{children}; } diff --git a/client/src/api/Contribution.js b/client/src/api/Contribution.js index 16dc2555..7dea03e4 100644 --- a/client/src/api/Contribution.js +++ b/client/src/api/Contribution.js @@ -1,8 +1,10 @@ import axios from "./http"; import root from "./server"; -export const CreateNewContribution = async (data) => { - const resp = await axios.post(`${root}/api/contribution/`, data); +export const CreateNewContribution = async (data, key) => { + const resp = await axios.post(`${root}/api/contribution/`, data, { + headers: { "Idempotency-Key": key }, + }); return resp; }; export const GetMyContributions = async () => { @@ -13,4 +15,4 @@ export const GetMyContributions = async () => { export const GetBrContribution = async () => { const resp = await axios.post(`${root}/api/contribution/br`, {}); return resp; -} +}; diff --git a/client/src/api/File.js b/client/src/api/File.js index 848947f3..c6b697dd 100644 --- a/client/src/api/File.js +++ b/client/src/api/File.js @@ -1,5 +1,6 @@ import API from "./http"; import serverRoot from "./server"; +import { waitForOperation } from "./Operation"; export const previewFile = async (fileId) => { const { data } = await API.get(`/files/link/${fileId}`); @@ -9,12 +10,14 @@ export const verifyFile = async (fileId, courseCode) => { const { data } = await API.put(`/files/verify/${fileId}`, { courseCode }); return data; }; -export const unverifyFile = async (fileId, courseCode) => { - await API.delete(`/files/unverify/${fileId}`, { +export const unverifyFile = async (fileId, courseCode, affectedCourses) => { + const { data } = await API.delete(`/files/unverify/${fileId}`, { data: { courseCode, + affectedCourses, }, }); + return waitForOperation(data); }; export const getFileDownloadLink = async (fileId, courseCode) => { diff --git a/client/src/api/Folder.js b/client/src/api/Folder.js index d03b810a..b389f22f 100644 --- a/client/src/api/Folder.js +++ b/client/src/api/Folder.js @@ -1,6 +1,6 @@ import API from "./http"; import serverRoot from "./server"; - +import { waitForOperation } from "./Operation"; export const createFolder = async ({ name, course, parentFolder, childType }) => { const { data } = await API.post("/folder/create", { @@ -16,12 +16,12 @@ export const deleteFolder = async ({ folderId, courseCode }) => { const { data } = await API.delete(`/folder/delete`, { data: { folderId, courseCode }, }); - return data; + return waitForOperation(data); }; export const fetchFolder = async (folderId, courseCode) => { - const url = courseCode - ? `/folder/content/${folderId}?courseCode=${courseCode}` + const url = courseCode + ? `/folder/content/${folderId}?courseCode=${courseCode}` : `/folder/content/${folderId}`; const response = await API.get(url); if (response.status !== 200) { @@ -33,7 +33,9 @@ export const fetchFolder = async (folderId, courseCode) => { export const renameFolder = async (folderId, newName, courseCode) => { const response = await API.post("/folder/rename", { - folderId, newName, courseCode, + folderId, + newName, + courseCode, }); if (response.status !== 200) { throw new Error("Failed to rename folder"); diff --git a/client/src/api/Operation.js b/client/src/api/Operation.js new file mode 100644 index 00000000..bea47810 --- /dev/null +++ b/client/src/api/Operation.js @@ -0,0 +1,35 @@ +import API from "./http"; + +export const operationEvent = "coursehub-operation"; +export const getOperation = async (id, signal) => + (await API.get(`/operations/${id}`, { signal })).data; +export const listOperations = async (signal) => (await API.get("/operations", { signal })).data; +export const retryOperation = async (id) => (await API.post(`/operations/${id}/retry`, {})).data; +export const cancelOperation = async (id) => (await API.post(`/operations/${id}/cancel`, {})).data; +export const announceOperation = (operation) => + window.dispatchEvent(new CustomEvent(operationEvent, { detail: operation })); + +export async function waitForOperation(accepted) { + if (!accepted?.operationId) return accepted; + announceOperation({ + id: accepted.operationId, + kind: "delete", + status: "queued", + name: "Content deletion", + entries: [], + }); + for (;;) { + const operation = await getOperation(accepted.operationId); + announceOperation(operation); + if (operation.status === "completed") return operation; + if (["failed", "cancelled"].includes(operation.status)) { + const error = new Error( + operation.error?.message || + "The operation could not finish. Check its status to retry.", + ); + error.operationId = operation.id; + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } +} diff --git a/client/src/api/Year.js b/client/src/api/Year.js index 66118a5f..4c7f5bd2 100644 --- a/client/src/api/Year.js +++ b/client/src/api/Year.js @@ -1,5 +1,6 @@ import API from "./http"; import serverRoot from "./server"; +import { waitForOperation } from "./Operation"; export const addYear = async ({ name, course }) => { const { data } = await API.post("/year", { @@ -15,5 +16,5 @@ export const deleteYear = async ({ folderId, courseCode }) => { const { data } = await API.delete("/year/delete", { data: { folderId, courseCode }, }); - return data; + return waitForOperation(data); }; diff --git a/client/src/components/OperationNotice/index.jsx b/client/src/components/OperationNotice/index.jsx new file mode 100644 index 00000000..11aba728 --- /dev/null +++ b/client/src/components/OperationNotice/index.jsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { operationEvent, listOperations, getOperation, cancelOperation } from "../../api/Operation"; +import "./styles.scss"; + +const active = ["planning", "queued", "running", "cancelling"]; +const labels = { + planning: "Preparing", + awaiting: "Waiting for files", + queued: "Scheduled", + running: "In progress", + completed: "Completed", + failed: "Needs attention", + partial: "Some files failed", + cancelling: "Cancelling", + cancelled: "Cancelled", +}; +export default function OperationNotice() { + const [items, setItems] = useState([]); + const [error, setError] = useState(""); + const [attempt, setAttempt] = useState(0); + useEffect(() => { + const controller = new AbortController(); + listOperations(controller.signal) + .then((data) => { + if (!Array.isArray(data.items)) throw new Error("Invalid operation response"); + setItems( + data.items.filter((item) => !["completed", "cancelled"].includes(item.status)), + ); + setError(""); + }) + .catch(() => { + if (!controller.signal.aborted) setError("Operation status is unavailable."); + }); + const update = ({ detail }) => + setItems((current) => + [detail, ...current.filter((item) => item.id !== detail.id)].slice(0, 20), + ); + window.addEventListener(operationEvent, update); + return () => { + controller.abort(); + window.removeEventListener(operationEvent, update); + }; + }, [attempt]); + useEffect(() => { + const controller = new AbortController(); + const ids = items.filter((item) => active.includes(item.status)).map((item) => item.id); + if (!ids.length) return; + const timer = setTimeout(async () => { + try { + const updated = await Promise.all( + ids.map((id) => getOperation(id, controller.signal)), + ); + setItems((current) => + current.map((item) => updated.find((next) => next.id === item.id) || item), + ); + setError(""); + } catch { + if (!controller.signal.aborted) + setError("Status refresh failed. The operation may still be running."); + } + }, 2000); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [items, attempt]); + if (!items.length && !error) return null; + return ( + + ); +} diff --git a/client/src/components/OperationNotice/styles.scss b/client/src/components/OperationNotice/styles.scss new file mode 100644 index 00000000..be649074 --- /dev/null +++ b/client/src/components/OperationNotice/styles.scss @@ -0,0 +1,43 @@ +.operation-notice { + position: fixed; + inset: auto 1rem 1rem auto; + z-index: 900; + width: min(24rem, calc(100vw - 2rem)); + max-height: 40vh; + overflow: auto; + padding: 0.75rem 1rem; + border: 1px solid #d2bb63; + border-radius: 0.5rem; + background: #fff8dc; + color: #242015; + box-shadow: 0 3px 16px #0002; + font-size: 0.875rem; + div + div { + margin-top: 0.75rem; + border-top: 1px solid #d2bb63; + padding-top: 0.75rem; + } + p { + margin: 0.35rem 0; + } + li { + overflow-wrap: anywhere; + } + a, + button { + display: inline-block; + margin: 0.35rem 0.75rem 0 0; + color: #242015; + text-decoration: underline; + } + button { + background: transparent; + border: 0; + cursor: pointer; + padding: 0.25rem; + } + :focus-visible { + outline: 2px solid #242015; + outline-offset: 2px; + } +} diff --git a/client/src/router_utils/PrivateRoutes.jsx b/client/src/router_utils/PrivateRoutes.jsx index 8492f3f4..5e43f50f 100644 --- a/client/src/router_utils/PrivateRoutes.jsx +++ b/client/src/router_utils/PrivateRoutes.jsx @@ -4,6 +4,7 @@ import { useDispatch, useSelector } from "react-redux"; import { getUser } from "../api/User"; import { LoginUser, LogoutUser } from "../actions/user_actions"; import Loader from "../components/Loader"; +import OperationNotice from "../components/OperationNotice"; import "./PrivateRoutes.scss"; const PrivateRoutes = () => { @@ -37,7 +38,7 @@ const PrivateRoutes = () => { return () => controller.abort(); }, [loggedIn, dispatch, attempt]); - if (loggedIn) return ; + if (loggedIn) return <>; if (status === "signed-out") return ; if (status === "sync") return ; if (status === "error") { diff --git a/client/src/screens/browse/components/file-display/index.jsx b/client/src/screens/browse/components/file-display/index.jsx index 51854e09..17b87415 100644 --- a/client/src/screens/browse/components/file-display/index.jsx +++ b/client/src/screens/browse/components/file-display/index.jsx @@ -18,12 +18,11 @@ import FileRename from "./components/FileRename.jsx"; import { getFileDownloadLink } from "../../../../api/File"; const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { - - const fileSize = formatFileSize(file.size); + const fileSize = formatFileSize(file.sizeBytes ?? file.size); const fileType = formatFileType(file.name); const [showDialog, setShowDialog] = useState(false); const [dialogType, setDialogType] = useState("verify"); - const [onConfirmAction, setOnConfirmAction] = useState(() => () => { }); + const [onConfirmAction, setOnConfirmAction] = useState(() => () => {}); const [isProcessing, setIsProcessing] = useState(false); let name = file.name; @@ -32,7 +31,7 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { let untruncatedDispName = name; try { const lastTildeIndex = name.lastIndexOf("~"); - if (lastTildeIndex !== -1) { + if (lastTildeIndex !== -1 && !file.contributorName) { untruncatedDispName = name.slice(0, lastTildeIndex); _dispName = formatFileName(untruncatedDispName); let contributorPart = name.slice(lastTildeIndex + 1); @@ -42,7 +41,7 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { const dotIdx = name.lastIndexOf("."); untruncatedDispName = dotIdx !== -1 ? name.slice(0, dotIdx) : name; _dispName = formatFileName(untruncatedDispName); - contributor = "Anonymous"; + contributor = file.contributorName || "Anonymous"; } } catch (error) { _dispName = formatFileName(file.name); @@ -58,9 +57,9 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { const [isEditing, setIsEditing] = useState(false); const dispatch = useDispatch(); - - const thumbnailUrl = - file.thumbnail?.url ? new URL(file.thumbnail.url, API_BASE_URL).href : undefined; + const thumbnailUrl = file.thumbnail?.url + ? new URL(file.thumbnail.url, API_BASE_URL).href + : undefined; const handleRename = async (newName) => { const trimmed = newName?.trim(); @@ -69,7 +68,9 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { // Validation for illegal OneDrive characters: \ / : * ? " < > | const illegalChars = /[\\/:*?"<>|]/; if (illegalChars.test(trimmed)) { - toast.error("Filename cannot contain any of the following characters: \\ / : * ? \" < > |"); + toast.error( + 'Filename cannot contain any of the following characters: \\ / : * ? " < > |', + ); return; } @@ -85,9 +86,9 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { ChangeFolder({ ...currentFolder, children: (currentFolder?.children || []).map((child) => - child._id === file._id ? { ...child, name: responseData.file.name } : child + child._id === file._id ? { ...child, name: responseData.file.name } : child, ), - }) + }), ); } catch (err) { console.error("Error renaming file:", err); @@ -126,7 +127,6 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { window.open(`${API_BASE_URL}/api/files/preview/${file._id}`, "_blank", "noopener"); }; - const handleVerify = async () => { setDialogType("verify"); setOnConfirmAction(() => async () => { @@ -155,7 +155,7 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { try { setIsProcessing(true); - await unverifyFile(file._id, currCourseCode); + await unverifyFile(file._id, currCourseCode, file.affectedCourses); toast.success("File deleted!"); dispatch(RemoveFileFromFolder(file._id)); } catch (err) { @@ -171,8 +171,9 @@ const FileDisplay = ({ file, path, code, isMobileView = false, index = 0 }) => { return (
{ {!isMobileView && canManage && ( <> {!file.isVerified ? ( - + ) : ( <> )} - + )} diff --git a/client/src/screens/browse/index.jsx b/client/src/screens/browse/index.jsx index bec3de4a..74c865f3 100644 --- a/client/src/screens/browse/index.jsx +++ b/client/src/screens/browse/index.jsx @@ -572,7 +572,7 @@ const BrowseScreen = () => { )}
- {!isMobile && } + ); }; diff --git a/client/src/screens/contributions/components/sectionC/index.jsx b/client/src/screens/contributions/components/sectionC/index.jsx index 22a1c635..61a31057 100644 --- a/client/src/screens/contributions/components/sectionC/index.jsx +++ b/client/src/screens/contributions/components/sectionC/index.jsx @@ -1,7 +1,7 @@ import "./styles.scss"; const SectionC = (props) => { const offHandler = (event) => { - if (event.target.id === "contri") { + if (event.target.id === "contri" && !props.busy) { const collection = document.getElementsByClassName("contri"); const contributionSection = collection[0]; contributionSection.classList.remove("show"); diff --git a/client/src/screens/contributions/index.jsx b/client/src/screens/contributions/index.jsx index 73b82d7a..c7d66108 100644 --- a/client/src/screens/contributions/index.jsx +++ b/client/src/screens/contributions/index.jsx @@ -2,129 +2,485 @@ import Wrapper from "./components/wrapper"; import SectionC from "./components/sectionC"; import { FilePond } from "react-filepond"; import "filepond/dist/filepond.min.css"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { useLocation } from "react-router-dom"; import "./styles.scss"; import { CreateNewContribution } from "../../api/Contribution"; -import { useSelector } from "react-redux"; +import { useSelector, useDispatch } from "react-redux"; import { toast } from "react-toastify"; -import { useDispatch } from "react-redux"; import server from "../../api/server"; +import API from "../../api/http"; import { ChangeFolder, RefreshCurrentFolder } from "../../actions/filebrowser_actions"; import { fetchFolder } from "../../api/Folder"; import { getCsrfToken } from "../../api/csrf"; +import { + getOperation, + retryOperation, + cancelOperation, + announceOperation, +} from "../../api/Operation"; +const stateLabels = { + pending: "Ready to upload", + receiving: "Receiving", + queued: "Waiting for storage", + uploading: "Uploading", + publishing: "Saving", + cleanup: "Cleaning up unpublished file", + completed: "Uploaded", + failed: "Failed — retry this file", + cancelled: "Cancelled", +}; const Contributions = () => { const currentFolder = useSelector((state) => state.fileBrowser.currentFolder); const currentCourseCode = useSelector((state) => state.fileBrowser.currentCourseCode); - const contributionId = useRef(""); - const csrfToken = useRef(""); const isBR = currentFolder?.capabilities?.canManage === true; const dispatch = useDispatch(); + const location = useLocation(); + const pond = useRef(); + const operationRef = useRef(); + const requestKey = useRef(); + const csrfToken = useRef(""); + const fileIds = useRef(new Map()); + const transfers = useRef(new Map()); + const mounted = useRef(true); + const [sentBytes, setSentBytes] = useState({}); + useEffect(() => { + mounted.current = true; + const activeTransfers = transfers.current; + return () => { + mounted.current = false; + for (const stop of activeTransfers.values()) stop(); + }; + }, []); + const [limits, setLimits] = useState(); + const [operation, setOperation] = useState(); + const [fileCount, setFileCount] = useState(0); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [limitsAttempt, setLimitsAttempt] = useState(0); + const showOperation = (value) => { + if (!mounted.current) return; + operationRef.current = value; + setOperation(value); + announceOperation(value); + }; - const [submitEnabled, setSubmitEnabled] = useState(false); - const [isUploading, setIsUploading] = useState(false); - - let pond = useRef(); + useEffect(() => { + const controller = new AbortController(); + API.get("/contribution/limits", { signal: controller.signal }) + .then(({ data }) => { + if ( + ![data.fileBytes, data.batchBytes, data.files, data.concurrentFiles].every( + (value) => Number.isSafeInteger(value) && value > 0, + ) + ) + throw new Error("Invalid upload limits"); + setLimits(data); + setError(""); + }) + .catch(() => { + if (!controller.signal.aborted) + setError("Upload limits are unavailable. Please retry."); + }); + return () => controller.abort(); + }, [limitsAttempt]); + useEffect(() => { + const id = new URLSearchParams(location.search).get("upload"); + if (!id || !currentFolder?._id || !currentCourseCode) return; + const controller = new AbortController(); + getOperation(id, controller.signal) + .then((value) => { + if ( + value.kind !== "upload" || + value.folderId !== currentFolder?._id || + value.courseCode !== currentCourseCode + ) + throw new Error("Choose the folder belonging to this upload."); + showOperation(value); + document.querySelector(".contri")?.classList.add("show"); + }) + .catch(() => { + if (!controller.signal.aborted) setError("This upload could not be loaded."); + }); + return () => controller.abort(); + }, [location.search, currentFolder?._id, currentCourseCode]); + useEffect(() => { + if ( + !operation || + !["queued", "running", "cancelling", "planning"].includes(operation.status) + ) + return; + const controller = new AbortController(); + const timer = setTimeout( + () => + getOperation(operation.id, controller.signal) + .then(showOperation) + .catch(() => { + if (!controller.signal.aborted) + setError("Status refresh failed. Completed uploads are preserved."); + }), + 1500, + ); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [operation]); - const handleUpdateFiles = (fileItems) => { - if (fileItems.length > 0) setSubmitEnabled(true); - else setSubmitEnabled(false); - }; + function sendFile(file, id, op) { + return new Promise((resolve, reject) => { + const controller = new AbortController(); + const xhr = new XMLHttpRequest(); + let aborted = false; + const finish = (error) => { + transfers.current.delete(id); + if (error) reject(error); + else resolve(); + }; + transfers.current.set(id, () => { + aborted = true; + controller.abort(); + xhr.abort(); + finish( + new Error("The transfer was stopped. Check the batch status before retrying."), + ); + }); + xhr.open("POST", `${server}/api/contribution/upload`); + xhr.withCredentials = true; + xhr.timeout = 15 * 60 * 1000; + for (const [name, value] of Object.entries({ + "contribution-id": op.id, + "upload-file-id": id, + "X-CSRF-Token": csrfToken.current, + "X-Session-Role": "student", + })) + xhr.setRequestHeader(name, value); + xhr.upload.onprogress = (event) => + setSentBytes((current) => ({ + ...current, + [id]: Math.min(file.size, event.loaded), + })); + xhr.onerror = xhr.ontimeout = () => { + if (!aborted) finish(new Error("The connection failed. Retry this file.")); + }; + xhr.onload = async () => { + try { + const data = JSON.parse(xhr.responseText); + if (xhr.status !== 202) + throw new Error(data.message || "This file was not accepted"); + for (;;) { + const current = await getOperation(op.id, controller.signal); + if (aborted) return; + showOperation(current); + const entry = current.entries.find((item) => item.id === id); + if (entry?.state === "completed") return finish(); + if ( + ["failed", "cancelled"].includes(entry?.state) || + current.status === "failed" + ) + throw new Error( + entry?.error?.message || + current.error?.message || + "This file could not be uploaded", + ); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } catch (failure) { + if (!aborted) finish(failure); + } + }; + const body = new FormData(); + body.append("file", file, file.name); + xhr.send(body); + }); + } async function handleSubmit() { - if (isUploading) return; - - const files = pond.current ? pond.current.getFiles() : []; - if (!submitEnabled || !files.length) { - toast.error("Please upload a valid file"); + if (busy || !limits) return; + const files = pond.current?.getFiles() || []; + if (!files.length) { + setError("Choose the files to upload or retry."); return; } - - const collection = document.getElementsByClassName("contri"); - const contributionSection = collection[0]; - + if ( + files.length > limits.files || + files.some(({ file }) => file.size > limits.fileBytes) || + files.reduce((sum, item) => sum + item.file.size, 0) > limits.batchBytes + ) { + setError("Choose up to 40 files, no more than 100 MiB each and 1 GiB in total."); + return; + } + setBusy(true); + setError(""); try { - setIsUploading(true); - setSubmitEnabled(false); - const response = await CreateNewContribution({ - parentFolder: currentFolder._id, - courseCode: currentCourseCode || (currentFolder.courses ? currentFolder.courses[0] : currentFolder.course), - description: "default", - }); - contributionId.current = response.data.data.contributionId; + let current = operationRef.current; + if (!current) { + requestKey.current ||= crypto.randomUUID(); + const { data } = await CreateNewContribution( + { + parentFolder: currentFolder._id, + courseCode: currentCourseCode || currentFolder.courses?.[0], + description: "", + manifest: files.map(({ file }) => ({ name: file.name, size: file.size })), + }, + requestKey.current, + ); + current = data; + showOperation(current); + } else if (["failed", "partial"].includes(current.status)) { + current = await retryOperation(current.id); + showOperation(current); + } + for (const file of fileIds.current.keys()) + if (!files.some((item) => item.file === file)) fileIds.current.delete(file); + const assigned = new Set(fileIds.current.values()); + const available = current.entries.filter( + (entry) => entry.state !== "completed" && !assigned.has(entry.id), + ); + for (const item of files) { + if (fileIds.current.has(item.file)) continue; + const index = available.findIndex( + (entry) => entry.name === item.file.name && entry.size === item.file.size, + ); + if (index < 0) + throw new Error("Choose the original failed files to retry this batch."); + fileIds.current.set(item.file, available.splice(index, 1)[0].id); + } csrfToken.current = await getCsrfToken(true); - await pond.current.processFiles(); - pond.current.removeFiles(); - contributionSection.classList.remove("show"); - toast.success("Files uploaded successfully!"); - contributionId.current = ""; - setSubmitEnabled(true); - } catch (error) { - setSubmitEnabled(true); - contributionSection.classList.remove("show"); - toast.error("Upload failed. Please try again!"); + const queue = files.filter( + (item) => + current.entries.find((entry) => entry.id === fileIds.current.get(item.file)) + ?.state !== "completed", + ); + const failures = []; + await Promise.all( + Array.from({ length: limits.concurrentFiles }, async () => { + while (queue.length && !operationRef.current?.cancelRequested) { + const item = queue.shift(); + try { + await sendFile(item.file, fileIds.current.get(item.file), current); + } catch (failure) { + failures.push(failure); + } + } + }), + ); + if (failures.length && !operationRef.current?.cancelRequested) throw failures[0]; + let result = await getOperation(current.id); + while ( + ["queued", "running"].includes(result.status) && + result.entries.every((entry) => entry.state === "completed") + ) { + await new Promise((resolve) => setTimeout(resolve, 500)); + result = await getOperation(current.id); + } + showOperation(result); + if (result.status === "completed") toast.success("Files uploaded successfully!"); + } catch (failure) { + setError( + failure.response?.data?.message || + failure.message || + "Some files failed. Successful uploads are preserved; retry the failed files.", + ); + if (operationRef.current) { + try { + showOperation(await getOperation(operationRef.current.id)); + } catch { + /* Status retry remains available. */ + } + } } finally { - setIsUploading(false); + if (!mounted.current) return; + setBusy(false); + try { + const folder = await fetchFolder(currentFolder._id, currentCourseCode); + dispatch(ChangeFolder(folder)); + dispatch(RefreshCurrentFolder()); + } catch { + /* The operation result is retained when the folder cannot refresh. */ + } } - + } + async function handleCancel() { + if (!operationRef.current) return; try { - const updatedFolder = await fetchFolder(currentFolder._id, currentCourseCode); - dispatch(ChangeFolder(updatedFolder)); - dispatch(RefreshCurrentFolder()); - } catch (error) { - return null; + showOperation(await cancelOperation(operationRef.current.id)); + for (const stop of transfers.current.values()) stop(); + setBusy(false); + } catch { + setError("Cancellation could not be confirmed. Retry cancellation or check status."); } } - + const reset = () => { + pond.current?.removeFiles(); + operationRef.current = undefined; + requestKey.current = undefined; + fileIds.current.clear(); + setSentBytes({}); + setOperation(undefined); + setError(""); + }; + const closed = operation && ["completed", "cancelled"].includes(operation.status); + const canSend = + currentFolder?.capabilities?.canContribute === true && + (!operation || operation.canCancel === true); return ( - + -
{isBR ? "Upload Files" : "Share Your Files"}
-
- {isBR - ? "Upload files to this folder" - : "Your files will be added to this folder"} -
-
- ({ "contribution-id": contributionId.current, "X-CSRF-Token": csrfToken.current, "X-Session-Role": "student" }), - }, - }} - instantUpload={false} - allowProcess={false} - allowRevert={false} - ref={(ref) => { - pond.current = ref; - }} - /> -
-
-
-
Note:
-
Do not close this window while files are being uploaded.
-
- {!isBR ? ( -
-
Note:
-
- Files require approval from a Branch Representative before becoming - visible to other users. -
+
+
{isBR ? "Upload Files" : "Share Your Files"}
+

Upload to {currentFolder?.name || "this folder"}.

+

100 MiB per file · 40 files · 1 GiB per batch

+ {!limits && ( + + )} + {canSend && (!closed || busy) && ( +
+ setFileCount(files.length)} + allowDrop={!busy} + allowBrowse={!busy} + allowRemove={!busy} + instantUpload={false} + allowProcess={false} + allowRevert={false} + ref={(value) => { + pond.current = value; + }} + />
- ) : ( - <> )} -
-
- {isUploading ? "UPLOADING..." : "SUBMIT"} + {operation && ( +
+

+ { + operation.entries.filter((entry) => entry.state === "completed") + .length + }{" "} + of {operation.entries.length} files uploaded +

+
    + {operation.entries.map((entry) => ( +
  • + {entry.name} + + {busy && + entry.state === "pending" && + sentBytes[entry.id] !== undefined + ? "Sending" + : stateLabels[entry.state] || entry.state} + + {["failed", "cleanup"].includes(entry.state) && + entry.error?.message && ( + + {entry.error.message} + + )} + {((busy && sentBytes[entry.id] !== undefined) || + ["receiving", "uploading", "publishing"].includes( + entry.state, + )) && ( + + )} +
  • + ))} +
+
+ )} + {error && ( +

+ {error} +

+ )} + {operation && ( + + )} +

+ Completed files are kept when another file fails or the batch is cancelled. +

+ {operation && canSend && !closed && !fileCount && ( +

+ To retry after leaving this page, choose the original failed files + again. +

+ )} + {!isBR && ( +

+ Your files need approval before other students can see them. +

+ )} +
+ {!closed && canSend && ( + + )} + {operation?.canCancel && ( + + )} + {closed && currentFolder?.capabilities?.canContribute === true && ( + + )} + {!busy && ( + + )} +
diff --git a/client/src/screens/contributions/styles.scss b/client/src/screens/contributions/styles.scss index 533c3b3e..b260ac9f 100644 --- a/client/src/screens/contributions/styles.scss +++ b/client/src/screens/contributions/styles.scss @@ -1,9 +1,9 @@ .head { width: 100%; text-align: center; - font-family: 'bold'; + font-family: "bold"; font-size: 2rem; - color:black; + color: black; } .course { @@ -13,7 +13,7 @@ margin-top: 2rem; .label_course { - font-family: 'Bold'; + font-family: "Bold"; font-size: 1.2rem; } @@ -25,14 +25,13 @@ } } - .section { display: flex; align-items: center; justify-content: space-between; .label_section { - font-family: 'Bold'; + font-family: "Bold"; justify-content: start; font-size: 1.2rem; color: black; @@ -56,7 +55,7 @@ justify-content: space-between; .label_year { - font-family: 'Bold'; + font-family: "Bold"; font-size: 1.2rem; } @@ -86,7 +85,7 @@ margin-top: 2rem; .label_description { - font-family: 'Bold'; + font-family: "Bold"; font-size: 1.2rem; } @@ -107,12 +106,52 @@ justify-content: space-between; .label_file { - background-image: repeating-linear-gradient(0deg, #333333, #333333 11px, transparent 11px, transparent 18px, #333333 18px), repeating-linear-gradient(90deg, #333333, #333333 11px, transparent 11px, transparent 18px, #333333 18px), repeating-linear-gradient(180deg, #333333, #333333 11px, transparent 11px, transparent 18px, #333333 18px), repeating-linear-gradient(270deg, #333333, #333333 11px, transparent 11px, transparent 18px, #333333 18px); - background-size: 2px 100%, 100% 2px, 2px 100%, 100% 2px; - background-position: 0 0, 0 0, 100% 0, 0 100%; + background-image: + repeating-linear-gradient( + 0deg, + #333333, + #333333 11px, + transparent 11px, + transparent 18px, + #333333 18px + ), + repeating-linear-gradient( + 90deg, + #333333, + #333333 11px, + transparent 11px, + transparent 18px, + #333333 18px + ), + repeating-linear-gradient( + 180deg, + #333333, + #333333 11px, + transparent 11px, + transparent 18px, + #333333 18px + ), + repeating-linear-gradient( + 270deg, + #333333, + #333333 11px, + transparent 11px, + transparent 18px, + #333333 18px + ); + background-size: + 2px 100%, + 100% 2px, + 2px 100%, + 100% 2px; + background-position: + 0 0, + 0 0, + 100% 0, + 0 100%; background-repeat: no-repeat; - font-family: 'Bold'; + font-family: "Bold"; width: 100%; text-align: center; padding: 2rem; @@ -121,7 +160,6 @@ align-items: center; font-size: 1.2rem; - &:hover { cursor: pointer; } @@ -155,14 +193,15 @@ .button { text-align: center; - background-color: #FECF6F; + background-color: #fecf6f; padding: 1rem; margin-top: 1rem; - font-family: 'bold'; + font-family: "bold"; cursor: pointer; - transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), - box-shadow 0.2s ease, - background-color 0.2s ease; + transition: + transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 0.2s ease, + background-color 0.2s ease; &:hover:not(.false) { transform: translateY(-2px); @@ -174,7 +213,7 @@ } &.true { - background-color: #FECF6F; + background-color: #fecf6f; cursor: pointer; } @@ -186,4 +225,67 @@ .filepond--root { max-height: 20vh; -} \ No newline at end of file +} + +.contri .upload-content { + max-height: 75vh; + overflow: auto; + color: #242424; + .upload-limits, + .upload-hint { + font-size: 0.875rem; + line-height: 1.5; + color: #535353; + } + .upload-error { + color: #a21c1c; + line-height: 1.5; + } + .upload-results ul { + list-style: none; + padding: 0; + } + .upload-results li { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.6rem 0; + border-bottom: 1px solid #dedede; + overflow-wrap: anywhere; + } + .upload-results strong { + font-size: 0.8rem; + } + .upload-results progress { + width: 100%; + accent-color: #9b741b; + } + .upload-actions { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin-top: 1rem; + } + button { + font: inherit; + padding: 0.6rem 0.8rem; + border: 1px solid #8a8a8a; + border-radius: 0.25rem; + background: #fff; + color: #242424; + cursor: pointer; + } + button.button { + margin: 0; + background: #fecf6f; + border-color: #b8913f; + } + button:disabled { + opacity: 0.6; + cursor: not-allowed; + } + :focus-visible { + outline: 2px solid #242424; + outline-offset: 2px; + } +} diff --git a/client/src/screens/profile.js/components/Contri_section/ContributionCard/index.jsx b/client/src/screens/profile.js/components/Contri_section/ContributionCard/index.jsx index 3b023c6d..d3e30e72 100644 --- a/client/src/screens/profile.js/components/Contri_section/ContributionCard/index.jsx +++ b/client/src/screens/profile.js/components/Contri_section/ContributionCard/index.jsx @@ -39,7 +39,11 @@ export default function ContributionCard(props) { if (isProcessing) return; try { setIsProcessing(true); - await unverifyFile(props?.file?._id, props.managementCourseCode || props.courseCode); + await unverifyFile( + props?.file?._id, + props.managementCourseCode || props.courseCode, + props.file?.affectedCourses, + ); props.unverify(); toast.success("File deleted!"); setShowDialog(false); @@ -63,7 +67,11 @@ export default function ContributionCard(props) {

{props.courseCode}

- + {props?.file?.name}

diff --git a/docs/storage-operations.md b/docs/storage-operations.md new file mode 100644 index 00000000..0e1b37cd --- /dev/null +++ b/docs/storage-operations.md @@ -0,0 +1,35 @@ +# Storage, uploads, and recoverable cleanup + +The API owns Graph access and runs a MongoDB-backed operation worker. Graceful shutdown waits for the current operation. Interrupted operations resume from their saved state on the next start. + +## Persistent configuration + +Set `ONEDRIVE_FOLDER_ID` to the dedicated CourseHub storage root. Automatic cleanup may delete individual files underneath that root: it never deletes the root or a provider folder. + +Provision storage credentials with `node scripts/generateOneDriveToken.js` from `server`. Provisioning and refresh atomically write private files: `onedrive-refresh-token.token` and `onedrive-access-token.json`. + +## Upload contract + +1. Read authenticated `GET /api/contribution/limits`. Limits are **100 MiB per file**, **40 files per batch**, and **1 GiB per batch**. Empty files and unsupported path/control/device names are rejected. +2. `POST /api/contribution/` with an `Idempotency-Key` (16–100 letters, digits, or hyphens), `parentFolder`, `courseCode`, optional `description`, and `manifest: [{ name, size }]`. The server generates the contribution and file identities. Reusing a key with a different manifest returns `409`. +3. Send each file as multipart field `file` to `POST /api/contribution/upload`, with `contribution-id` set to the returned operation ID and `upload-file-id` set to its entry ID. Include session cookies, the session role, and CSRF token. Actual filename and byte count must match the manifest. The response is `202`, indicating receipt rather than publication. +4. Poll `GET /api/operations/:id`. Each entry reports progress, success, or failure. Successful files remain available if another file fails. Retry unfinished work with `POST /api/operations/:id/retry`: resend only failed files needing bytes, with the same identity and contents. A failed file whose storage ID is already recorded can finish without retransmission. +5. The owner may call `POST /api/operations/:id/cancel`. Cancellation keeps published files and cleans unpublished storage. It can remain in progress or need retry if a provider is unavailable. Closing the browser does not cancel server work. Reopen the upload from the activity notice; the browser may require the original failed files to be selected again. + +At most 2 files per batch are received concurrently. 8 journaled staging slots bound temporary disk usage across API processes to 800 MiB of file data; one elected worker uploads at most 2 files concurrently. Busy requests receive a retryable `409`. Incomplete multipart receives expire after 15 minutes. Temporary files are removed after processing or failure, and stale reservations are recovered after restart. + +Storage names are random and stable for retries. Display names and contributor names are separate metadata. PDF/image delivery and supported Office-to-PDF previews pass through authenticated resource-ID endpoints. Graph thumbnails are refreshed on expiry. + +## Deletion and recovery + +Inventory existing metadata with `node scripts/inventoryStorage.js --database coursehub --report ../~storage-inventory.json` from `server`. It lists stored sharing/thumbnail references and protected-root or malformed-ID records; sharing URLs are represented by their host and a fingerprint. + +File, folder/year, contribution rejection, and whole-course deletion return `202` with an `operationId` and status URL. The journal records affected IDs and provider cleanup IDs before marking content as deleting or contacting providers. Ordered course locks prevent conflicting edits during cleanup. A shared folder/year is unlinked from the active course; an individual shared file is removed from every referencing course, after confirmation includes all affected course codes. + +The worker persists completed cleanup steps, treats absent provider files/thumbnails as success, and retries temporary failures with bounded backoff. Content stays unavailable and its course locks remain held if deletion cannot finish. Administrators can inspect, filter, and retry operations at `/admin/operations`; upload owners and BRs can inspect their permitted operations. + +If cleanup fails, restore the missing provider/database configuration and use the administrator retry action. Retry preserves completed steps and the identifiers needed for remaining cleanup. A crash between completion and lock release is recovered automatically. + +## Verification + +Graph contracts: [upload sessions](https://learn.microsoft.com/en-us/graph/api/driveitem-createuploadsession?view=graph-rest-1.0), [pagination](https://learn.microsoft.com/en-us/graph/paging), [throttling](https://learn.microsoft.com/en-us/graph/throttling), and [format conversion](https://learn.microsoft.com/en-us/graph/api/driveitem-get-content-format?view=graph-rest-1.0). diff --git a/server/.env.example b/server/.env.example index cb4163f4..1bf17f67 100644 --- a/server/.env.example +++ b/server/.env.example @@ -32,6 +32,10 @@ API_BASE_URL= # OneDrive storage ONEDRIVE_FOLDER_ID= +# Directory for private refresh tokens and the access-token cache +ONEDRIVE_TOKEN_DIR= +# Upload staging directory, shared by API workers +UPLOAD_TEMP_DIR= # Optional Azure-registered callback for provisioning ONEDRIVE_REDIRECT_URI= diff --git a/server/.gitignore b/server/.gitignore index 78ca2ac3..814c5bf9 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -1,10 +1,2 @@ -node_modules -file-server -.env - -access-token.token -refresh_token.token -onedrive-access-token.token -onedrive-refresh-token.token -onedrive-device-code.token -.DS_Store +/uploads/ +/external/uploads/ diff --git a/server/config/storage.js b/server/config/storage.js new file mode 100644 index 00000000..2a829510 --- /dev/null +++ b/server/config/storage.js @@ -0,0 +1,19 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const serverDirectory = fileURLToPath(new URL("../", import.meta.url)); +export const uploadLimits = Object.freeze({ + fileBytes: 100 * 1024 * 1024, + files: 40, + batchBytes: 1024 * 1024 * 1024, + concurrentFiles: 2, +}); +export const graphChunkBytes = 10 * 320 * 1024; +export const tokenDirectory = () => path.resolve(process.env.ONEDRIVE_TOKEN_DIR || serverDirectory); +export const uploadDirectory = () => + path.resolve(process.env.UPLOAD_TEMP_DIR || path.join(serverDirectory, "external/uploads")); +export const storageRoot = () => { + const id = process.env.ONEDRIVE_FOLDER_ID; + if (!id) throw new Error("Configure ONEDRIVE_FOLDER_ID"); + return id; +}; diff --git a/server/index.js b/server/index.js index 594192d0..65afa5a9 100644 --- a/server/index.js +++ b/server/index.js @@ -28,11 +28,15 @@ import { requestContext, requestErrorHandler } from "./middleware/requestErrors. import Session from "./modules/session/session.model.js"; import { OAuthAttempt } from "./services/oauth.js"; import { AuthRateLimit } from "./middleware/authThrottle.js"; +import operationRoutes from "./modules/operation/operation.routes.js"; +import { OperationModel, CourseLock, StorageLease } from "./modules/operation/operation.model.js"; +import { startOperationWorker } from "./services/operationWorker.js"; const app = express(); const server = http.createServer(app); const __dirname = path.dirname(fileURLToPath(import.meta.url)); let scheduler; +let operationWorker; let shutdownPromise; app.set("trust proxy", validateSecuritySettings()); @@ -61,6 +65,7 @@ app.use("/api/files", fileRoutes); app.use("/api/folder", folderRoutes); app.use("/api/year", yearRoutes); app.use("/api/student", studentRoutes); +app.use("/api/operations", operationRoutes); app.use("/api", (req, res) => res.status(404).json({ error: true, message: "API endpoint not found" }), ); @@ -93,6 +98,7 @@ export function shutdown({ signal, error, exitCode }) { }); else lifecycleLogger.info("Server shutdown started", details); scheduler?.stop(); + await operationWorker?.stop(); await closeServer(); await mongoose.disconnect(); await flushLogging(); @@ -115,7 +121,12 @@ process.once("unhandledRejection", (error) => void terminate({ error, exitCode: export async function start() { await connectDatabase(); - await Promise.all([Session, OAuthAttempt, AuthRateLimit].map((model) => model.createIndexes())); + await Promise.all( + [Session, OAuthAttempt, AuthRateLimit, OperationModel, CourseLock, StorageLease].map( + (model) => model.createIndexes(), + ), + ); + operationWorker = startOperationWorker(); scheduler = initScheduler(); await new Promise((resolve, reject) => { server.once("error", reject); diff --git a/server/middleware/receiveUpload.js b/server/middleware/receiveUpload.js new file mode 100644 index 00000000..69900d8c --- /dev/null +++ b/server/middleware/receiveUpload.js @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import multer from "multer"; +import { multipartLimits, parseMultipart } from "./multipart.js"; +import { uploadLimits, uploadDirectory } from "../config/storage.js"; +import { reserveUpload, receiveComplete, receiveFailed } from "../services/uploads.js"; + +export const upload = multer({ + storage: multer.diskStorage({ + destination(req, file, callback) { + fs.mkdir(uploadDirectory(), { recursive: true, mode: 0o700 }, (error) => + callback(error, uploadDirectory()), + ); + }, + filename: (req, file, callback) => callback(null, req.uploadReservation.temporaryName), + }), + defParamCharset: "utf8", + limits: { + ...multipartLimits, + fileSize: uploadLimits.fileBytes, + files: 1, + fields: 4, + fieldSize: 16384, + parts: 6, + }, +}); + +export async function receiveUpload(req, res, next) { + let reservation; + try { + reservation = await reserveUpload(req); + const response = () => ({ + operationId: reservation.operation._id, + fileId: reservation.entry.id, + statusUrl: `/api/operations/${reservation.operation._id}`, + }); + if (reservation.existing) { + req.resume(); + return res.status(202).json(response()); + } + req.uploadReservation = reservation; + parseMultipart(upload.single("file"))(req, res, async (error) => { + try { + if (error) throw error; + await receiveComplete(reservation, req.file); + res.status(202).json(response()); + } catch (failure) { + try { + await receiveFailed(reservation, failure); + } catch (cleanupError) { + return next(cleanupError); + } + next(failure); + } + }); + } catch (error) { + if (reservation && !reservation.existing) { + try { + await receiveFailed(reservation, error); + } catch (cleanupError) { + return next(cleanupError); + } + } + next(error); + } +} diff --git a/server/modules/admin/adminDashboard.controller.js b/server/modules/admin/adminDashboard.controller.js index 1e2f0f3d..61b1e6d4 100644 --- a/server/modules/admin/adminDashboard.controller.js +++ b/server/modules/admin/adminDashboard.controller.js @@ -2,10 +2,12 @@ import { presentCourse, requireFile, requireFolder, + requireCourse, courseContext, libraryGraph, } from "../../services/authorization.js"; -import { removeFolderFromCourse, removeFile } from "../../services/resourceRemoval.js"; +import { scheduleDeletion } from "../../services/deletions.js"; +import { mutateContent } from "../../services/contentMutation.js"; import { presentContribution } from "../contribution/contribution.controller.js"; import AppError from "../../utils/appError.js"; import CourseModel, { FileModel, FolderModel } from "../course/course.model.js"; @@ -39,15 +41,17 @@ export async function uploadCourses(req, res) { for (const { code, name } of results) { if (!code || !name) continue; const codeUpper = normalizeCourseCode(code); - const course = await CourseModel.findOne({ - code: getCourseCodeCaseInsensitiveRegex(codeUpper), + await mutateContent(req, [codeUpper], async () => { + const course = await CourseModel.findOne({ + code: getCourseCodeCaseInsensitiveRegex(codeUpper), + }); + if (!course) await CourseModel.create({ code: codeUpper, name }); + else { + course.code = codeUpper; + course.name = name; + await course.save(); + } }); - if (!course) await CourseModel.create({ code: codeUpper, name }); - else { - course.code = codeUpper; - course.name = name; - await course.save(); - } } return CourseModel.find({}); }); @@ -71,16 +75,16 @@ export async function getCourseDashboardData(req, res) { res.json({ course, studentCount, contributions }); } export async function deleteNode(req, res) { - const { type, id } = req.params; - if (type === "file") { - const { file } = await requireFile(req, id, req.body.courseCode, "canManage"); - await removeFile(file); - return res.json({ success: true }); - } - if (type !== "folder") throw new AppError(400, "Invalid node type"); - res.json(await removeFolderFromCourse(req, id, req.body.courseCode)); + if (!["file", "folder"].includes(req.params.type)) throw new AppError(400, "Invalid node type"); + res.status(202).json( + await scheduleDeletion(req, { + kind: req.params.type, + id: req.params.id, + code: req.body.courseCode, + }), + ); } -export async function handleContribution(req, res) { +async function approveContribution(req, res) { const { contributionId, action } = req.body; if (typeof contributionId !== "string" || !["approve", "reject"].includes(action)) throw new AppError(400, "Invalid contribution action"); @@ -91,21 +95,34 @@ export async function handleContribution(req, res) { // Validate the entire stored manifest before the first side effect. for (const file of contribution.files) await requireFile(req, String(file._id), code, "canModerate"); - if (action === "approve") { - await FileModel.updateMany( - { _id: { $in: contribution.files.map((f) => f._id) } }, - { $set: { isVerified: true } }, - ); - contribution.approved = true; - await contribution.save(); - } else { - for (const file of contribution.files) await removeFile(file); - await Contribution.deleteOne({ _id: contribution._id }); - } + await FileModel.updateMany( + { _id: { $in: contribution.files.map((file) => file._id) } }, + { $set: { isVerified: true } }, + ); + contribution.approved = true; + await contribution.save(); res.json({ success: true }); } -export async function renameCourse(req, res, next) { +export async function handleContribution(req, res) { + if ( + typeof req.body.contributionId !== "string" || + !["approve", "reject"].includes(req.body.action) + ) + throw new AppError(400, "Invalid contribution action"); + const code = courseContext(req.body.courseCode); + if (req.body.action === "reject") + return res.status(202).json( + await scheduleDeletion(req, { + kind: "contribution", + id: req.body.contributionId, + code, + }), + ); + return mutateContent(req, [code], () => approveContribution(req, res)); +} + +async function renameCourseAction(req, res, next) { const { code } = req.params; const { name, newCode } = req.body; @@ -222,160 +239,18 @@ export async function renameCourse(req, res, next) { /** * Delete a course and remove it from all users' course lists - * @description This function safely deletes a course by: - * 1. Removing the course from all users who have it in their courses, previousCourses, or readOnly arrays - * 2. Deleting all associated folders and files from the database - * 3. Deleting all contributions related to this course - * 4. Marking the course as unavailable in search results - * 5. Finally deleting the course itself from the database - * @param {Object} req - Express request object with course code in params - * @param {Object} res - Express response object - * @param {Function} next - Express next middleware function */ -export async function deleteCourse(req, res, next) { - const { code } = req.params; - - if (!code) { - return next(new AppError(400, "Course code required")); - } - - const codeUpper = normalizeCourseCode(code); - if (!codeUpper) { - return next(new AppError(400, "Course code required")); - } - const codeRegex = getCourseCodeCaseInsensitiveRegex(codeUpper); - - try { - // Find the course first - const course = await CourseModel.findOne({ code: codeRegex }); - if (!course) { - return next(new AppError(404, "Course not found")); - } - - // Find all users who have this course and remove it from their lists - const users = await User.find({ - $or: [ - { courses: { $elemMatch: { code: { $regex: `^${codeUpper}$`, $options: "i" } } } }, - { - previousCourses: { - $elemMatch: { code: { $regex: `^${codeUpper}$`, $options: "i" } }, - }, - }, - { readOnly: { $elemMatch: { code: { $regex: `^${codeUpper}$`, $options: "i" } } } }, - ], - }); - - // Remove the course from each user's lists - for (const user of users) { - // Remove from courses array - user.courses = user.courses.filter((c) => normalizeCourseCode(c.code) !== codeUpper); - - // Remove from previousCourses array - user.previousCourses = user.previousCourses.filter( - (c) => normalizeCourseCode(c.code) !== codeUpper, - ); - - // Remove from readOnly array - user.readOnly = user.readOnly.filter((c) => normalizeCourseCode(c.code) !== codeUpper); - - await user.save(); - - // Delete user update records for affected users - await UserUpdate.deleteOne({ rollNumber: user.rollNumber }); - } - - // Delete all associated folders and files - // Find all folders associated with this course - const courseFolders = await FolderModel.find({ courses: codeRegex }).populate("children"); - - // Process folders that contain files first - for (const folder of courseFolders) { - if (folder.childType === "File" && folder.children && folder.children.length > 0) { - // Delete all files in this folder using the file deletion logic - for (const file of folder.children) { - try { - // Check if this folder is shared with other courses - // If it is, we might not want to delete the files? - // BUT if the user is deleting the course, they probably expect the data to be cleaned up - // UNLESS it's a shared folder. - // If it's a shared folder, we should ONLY delete files if we are deleting the last reference. - if (folder.courses.length <= 1) { - await removeFile(file); - } - } catch (fileError) { - logger.error("Admin file deletion failed", { - error: fileError, - attributes: { - dependency: "microsoft-graph", - operation: "delete-file", - outcome: "failure", - retryable: true, - }, - }); - throw fileError; - } - } - } - } - - // Now handle folders: if shared, just pull the code; if not, delete. - for (const folder of courseFolders) { - if (folder.courses.length > 1) { - await FolderModel.updateOne({ _id: folder._id }, { $pull: { courses: codeUpper } }); - } else { - await FolderModel.deleteOne({ _id: folder._id }); - } - } - - // Delete all contributions related to this course - const contributionsDeleted = await Contribution.deleteMany({ courseCode: codeRegex }); - - // Update search results to mark course as unavailable - const searchResult = await SearchResults.findOne({ code: codeRegex }); - if (searchResult) { - await SearchResults.updateOne({ code: codeRegex }, { isAvailable: false }); - } - - // Finally, delete the course from the database - await CourseModel.deleteOne({ code: codeRegex }); - - res.json({ - message: "Course deleted successfully", - deletedCourse: course, - affectedUsers: users.length, - deletedContributions: contributionsDeleted.deletedCount, - }); - } catch (error) { - return next(error); - } +export async function deleteCourse(req, res) { + res.status(202).json(await scheduleDeletion(req, { kind: "course", code: req.params.code })); } -/** +/* * Internal helper to link a legacy course to a target course */ -const activeLinkLocks = new Map(); - -async function performLinkWithLock(targetCodeRaw, sourceCodeRaw) { - const targetCode = normalizeCourseCode(targetCodeRaw); - const lockKey = targetCode || "global"; - - while (activeLinkLocks.has(lockKey)) { - await activeLinkLocks.get(lockKey); - } - - let resolver; - const lockPromise = new Promise((resolve) => { - resolver = resolve; - }); - activeLinkLocks.set(lockKey, lockPromise); - - try { - const result = await performLink(targetCodeRaw, sourceCodeRaw); - return result; - } finally { - activeLinkLocks.delete(lockKey); - resolver(); - } +async function performLinkWithLock(targetCodeRaw, sourceCodeRaw, req) { + return mutateContent(req, [targetCodeRaw, sourceCodeRaw], () => + performLink(targetCodeRaw, sourceCodeRaw), + ); } async function getAllFolderIdsUnderTree(startFolderId) { @@ -411,42 +286,15 @@ async function addCourseToFolderTree(startFolderId, codeToAdd) { } async function removeCourseFromFolderTree(startFolderId, codeToRemove) { - const folderIds = await getAllFolderIdsUnderTree(startFolderId); - if (folderIds.length === 0) return; - - const folders = await FolderModel.find({ _id: { $in: folderIds } }); - const foldersToDelete = []; - const foldersToUpdate = []; - - for (const folder of folders) { - const remainingCourses = (folder.courses || []).filter((c) => c !== codeToRemove); - if (remainingCourses.length === 0) { - foldersToDelete.push(folder._id); - } else { - foldersToUpdate.push(folder._id); - } - } - - if (foldersToDelete.length > 0) { - const fileFolders = await FolderModel.find({ - _id: { $in: foldersToDelete }, - childType: "File", - }).select("children"); - - for (const f of fileFolders) { - for (const fileId of f.children) { - await removeFile(fileId); - } - } - await FolderModel.deleteMany({ _id: { $in: foldersToDelete } }); - } - - if (foldersToUpdate.length > 0) { - await FolderModel.updateMany( - { _id: { $in: foldersToUpdate } }, - { $pull: { courses: codeToRemove } }, - ); - } + const ids = await getAllFolderIdsUnderTree(startFolderId); + const populated = await FolderModel.exists({ + _id: { $in: ids }, + childType: "File", + "children.0": { $exists: true }, + }); + if (populated) throw new AppError(409, "A populated year cannot be replaced", "LINK_CONFLICT"); + // Linking detaches empty structures + await FolderModel.updateMany({ _id: { $in: ids } }, { $pull: { courses: codeToRemove } }); } async function performLink(targetCodeRaw, sourceCodeRaw) { @@ -529,7 +377,7 @@ async function performLink(targetCodeRaw, sourceCodeRaw) { } // Delete and un-link all other duplicate empty folders for (const d of docs) { - if (d._id.toString() !== chosen._id.toString()) { + if (d._id.toString() !== chosen._id.toString() && (await isFolderEmpty(d._id))) { await removeCourseFromFolderTree(d._id, targetCode); updatedTargetChildIds = updatedTargetChildIds.filter( (id) => id !== d._id.toString(), @@ -602,7 +450,7 @@ export async function linkLegacyCourse(req, res, next) { } try { - const course = await performLinkWithLock(code, legacyCode); + const course = await performLinkWithLock(code, legacyCode, req); res.json({ message: "Legacy course linked successfully", course }); } catch (error) { return next(error); @@ -673,7 +521,7 @@ export async function bulkLinkCourses(req, res) { if (!oldCode || !newCode) continue; try { - await performLinkWithLock(newCode, oldCode); + await performLinkWithLock(newCode, oldCode, req); summary.success++; } catch (err) { summary.failed++; @@ -694,3 +542,10 @@ export async function syncCoursesCacheController(req, res, next) { next(err); } } + +export async function renameCourse(req, res, next) { + const context = await requireCourse(req, req.params.code, "canManage"); + return mutateContent(req, [context.code, req.body.newCode].filter(Boolean), () => + renameCourseAction(req, res, next), + ); +} diff --git a/server/modules/auth/auth.controller.js b/server/modules/auth/auth.controller.js index a97296d6..a5019288 100644 --- a/server/modules/auth/auth.controller.js +++ b/server/modules/auth/auth.controller.js @@ -1,3 +1,4 @@ +import { graph } from "../../services/graphClient.js"; import axios from "axios"; import qs from "querystring"; import AppError from "../../utils/appError.js"; @@ -311,20 +312,7 @@ const getDepartment = async (access_token, roll) => { const dep = rollmap[rollstring.slice(4, 6)]; if (dep) return rollmap[rollstring.slice(4, 6)]; } - var config = { - method: "get", - url: "https://graph.microsoft.com/beta/me/profile", - headers: { - Authorization: `Bearer ${access_token}`, - "Content-Type": "application/x-www-form-urlencoded", - Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8", - Host: "graph.microsoft.com", - }, - }; - const response = await axios.get(config.url, { - headers: config.headers, - timeout: 30000, - }); + const response = await graph.request("/beta/me/profile", { token: access_token }); return response.data.positions[0].detail.company.department; }; diff --git a/server/modules/contribution/contribution.controller.js b/server/modules/contribution/contribution.controller.js index 0cea6ae7..a5adc3cf 100644 --- a/server/modules/contribution/contribution.controller.js +++ b/server/modules/contribution/contribution.controller.js @@ -1,8 +1,6 @@ -import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; +import { createUpload } from "../../services/uploads.js"; +import { presentOperation } from "../operation/operation.controller.js"; import Contribution from "./contribution.model.js"; -import { FolderModel, FileModel } from "../course/course.model.js"; -import UploadFile from "../../services/UploadFile.js"; import AppError from "../../utils/appError.js"; import { actorFor, @@ -12,72 +10,10 @@ import { libraryGraph, } from "../../services/authorization.js"; import { normalizeCourseCode } from "../../utils/course.js"; -import logger from "../../utils/logger.js"; async function CreateNewContribution(req, res) { - const allowed = ["parentFolder", "courseCode", "description"]; - if (Object.keys(req.body).some((key) => !allowed.includes(key))) - throw new AppError(400, "Unexpected contribution fields"); - const { parentFolder, courseCode, description = "" } = req.body; - if (typeof description !== "string" || description.length > 2000) - throw new AppError(400, "Invalid description"); - const context = await requireFolder(req, parentFolder, courseCode, "canContribute"); - if (context.folder.childType !== "File") throw new AppError(400, "Choose a file folder"); - const contribution = await Contribution.create({ - contributionId: randomUUID(), - uploadedBy: context.actor.id, - parentFolder, - courseCode: context.code, - description, - approved: context.capabilities.canManage, - }); - logger.metric?.("contribution_created", { - value: 1, - dimensions: { - courseCode: context.code, - userId: context.actor.id, - department: req.user?.department || "administration", - semester: req.user?.semester || 0, - }, - }); - res.json({ created: true, data: contribution }); -} - -async function HandleFileUpload(req, res) { - const files = req.files || []; - if (!files.length) throw new AppError(400, "No files were uploaded"); - const uploaded = []; - const actor = await actorFor(req); - try { - for (const file of files) { - const name = file.originalname; - if (!name || /[\\/\x00-\x1f]/.test(name)) throw new AppError(400, "Invalid filename"); - const dot = name.lastIndexOf("."); - const base = dot === -1 ? name : name.slice(0, dot); - const extension = dot === -1 ? "" : name.slice(dot); - const contributor = (req.admin?.userId || req.user.name) - .replace(/[\\/:*?"<>|~\x00-\x1f]/g, "") - .slice(0, 80); - const fileId = await UploadFile( - file.path, - base + "~" + contributor + extension, - req.contributionApproved, - ); - if (!fileId) throw new AppError(502, "File upload failed"); - await Contribution.updateOne( - { _id: req.contribution._id, uploadedBy: actor.id }, - { $addToSet: { files: fileId }, $set: { approved: req.contributionApproved } }, - ); - await FolderModel.updateOne( - { _id: req.contribution.parentFolder }, - { $addToSet: { children: fileId } }, - ); - uploaded.push(String(fileId)); - } - res.send(uploaded[0]); - } finally { - await Promise.all(files.map((file) => fs.unlink(file.path).catch(() => {}))); - } + const operation = await createUpload(req); + res.status(201).json(presentOperation(operation, await actorFor(req))); } export async function presentContribution( @@ -94,7 +30,12 @@ export async function presentContribution( if (error.status !== 404) throw error; } } - return { ...contribution.toObject(), files, managementCourseCode: contextCode }; + return { + ...contribution.toObject(), + files, + approved: files.length > 0 && files.every((file) => file.isVerified), + managementCourseCode: contextCode, + }; } async function GetMyContributions(req, res) { @@ -140,4 +81,4 @@ async function GetBrContribution(req, res) { } res.json({ unverifiedContributions: visible }); } -export default { CreateNewContribution, HandleFileUpload, GetMyContributions, GetBrContribution }; +export default { CreateNewContribution, GetMyContributions, GetBrContribution }; diff --git a/server/modules/contribution/contribution.model.js b/server/modules/contribution/contribution.model.js index a361ebe6..4ac40009 100644 --- a/server/modules/contribution/contribution.model.js +++ b/server/modules/contribution/contribution.model.js @@ -2,6 +2,8 @@ import { model, Schema } from "mongoose"; const ContributionSchema = Schema( { + operationId: String, + deletingOperation: String, contributionId: { type: String }, uploadedBy: { type: String }, courseCode: { type: String }, diff --git a/server/modules/contribution/contribution.routes.js b/server/modules/contribution/contribution.routes.js index 618cbe8e..d1c2abc5 100644 --- a/server/modules/contribution/contribution.routes.js +++ b/server/modules/contribution/contribution.routes.js @@ -1,3 +1,4 @@ +import { uploadLimits } from "../../config/storage.js"; import isLibraryAuthenticated from "../../middleware/isLibraryAuthenticated.js"; import express from "express"; const router = express.Router(); @@ -6,18 +7,11 @@ import ContributionController from "./contribution.controller.js"; import catchAsync from "../../utils/catchAsync.js"; import isAuthenticated from "../../middleware/isAuthenticated.js"; import { isBR } from "../../middleware/isBR.js"; -import multer from "multer"; -import { multipartLimits, parseMultipart } from "../../middleware/multipart.js"; -import { authorizeContributionUpload } from "../../services/authorization.js"; +import { receiveUpload } from "../../middleware/receiveUpload.js"; -export const upload = multer({ dest: "external/uploads", limits: multipartLimits }); +router.get("/limits", (req, res) => res.json(uploadLimits)); router.get("/", isAuthenticated, catchAsync(ContributionController.GetMyContributions)); router.post("/", catchAsync(ContributionController.CreateNewContribution)); -router.post( - "/upload", - authorizeContributionUpload, - parseMultipart(upload.array("file")), - catchAsync(ContributionController.HandleFileUpload), -); +router.post("/upload", receiveUpload); router.post("/br", isBR, catchAsync(ContributionController.GetBrContribution)); export default router; diff --git a/server/modules/course/course.model.js b/server/modules/course/course.model.js index 33495e0b..2f27ab0a 100644 --- a/server/modules/course/course.model.js +++ b/server/modules/course/course.model.js @@ -1,6 +1,7 @@ import { model, Schema } from "mongoose"; const FolderSchema = Schema({ + deletingOperation: String, courses: [{ type: String, required: true }], name: { type: String, required: true }, childType: { type: String, enum: ["File", "Folder"], required: true }, @@ -11,6 +12,11 @@ const FolderSchema = Schema({ export const FolderModel = model("Folder", FolderSchema); const FileSchema = Schema({ + deletingOperation: String, + uploadOperation: String, + resourceState: { type: String, enum: ["uploading", "ready"], default: "ready" }, + sizeBytes: { type: Number, min: 0 }, + contributorName: String, name: { type: String, required: true }, fileId: { type: String, required: true }, size: { type: String, required: true }, @@ -19,8 +25,8 @@ const FileSchema = Schema({ fileId: { type: String }, path: { type: String }, }, - webUrl: { type: String, required: true }, - downloadUrl: { type: String, required: true }, + webUrl: String, + downloadUrl: String, isVerified: { type: Boolean, default: false, required: true }, }); @@ -28,6 +34,7 @@ export const FileModel = model("File", FileSchema); const CourseSchema = Schema( { + deletingOperation: String, name: { type: String, required: true }, code: { type: String, required: true, unique: true }, children: { type: [{ type: Schema.Types.ObjectId, ref: "Folder" }], default: [] }, diff --git a/server/modules/course/course.routes.js b/server/modules/course/course.routes.js index b02fed60..33255c1a 100644 --- a/server/modules/course/course.routes.js +++ b/server/modules/course/course.routes.js @@ -4,52 +4,47 @@ import { getAllCourses, getCourse } from "./course.controller.js"; import CourseModel from "./course.model.js"; import { normalizeCourseCode, getCourseCodeCaseInsensitiveRegex } from "../../utils/course.js"; import isAdmin from "../../middleware/isAdmin.js"; -import logger from "../../utils/logger.js"; +import { mutateContent } from "../../services/contentMutation.js"; const router = express.Router(); router.use(isLibraryAuthenticated); import catchAsync from "../../utils/catchAsync.js"; -router.post("/create/:code", isAdmin, async (req, res) => { - try { - const { code } = req.params; - const { name } = req.body; - const normalizedCode = normalizeCourseCode(code); - - if (!normalizedCode) { - return res.status(400).json({ message: "Invalid course code" }); - } - - const existingCourse = await CourseModel.findOne({ - code: getCourseCodeCaseInsensitiveRegex(normalizedCode), +router.post( + "/create/:code", + isAdmin, + catchAsync(async (req, res) => { + return mutateContent(req, [req.params.code], async () => { + const { code } = req.params; + const { name } = req.body; + const normalizedCode = normalizeCourseCode(code); + + if (!normalizedCode) { + return res.status(400).json({ message: "Invalid course code" }); + } + + const existingCourse = await CourseModel.findOne({ + code: getCourseCodeCaseInsensitiveRegex(normalizedCode), + }); + + if (existingCourse) { + return res.status(200).json({ message: "Course already exists" }); + } + + const newCourse = new CourseModel({ + code: normalizedCode, + name, + children: [], + metadata: {}, + }); + + await newCourse.save(); + + return res + .status(201) + .json({ message: "Course created successfully", course: newCourse }); }); - - if (existingCourse) { - return res.status(200).json({ message: "Course already exists" }); - } - - const newCourse = new CourseModel({ - code: normalizedCode, - name, - children: [], - metadata: {}, - }); - - await newCourse.save(); - - return res.status(201).json({ message: "Course created successfully", course: newCourse }); - } catch (error) { - logger.error("Course creation failed", { - error, - attributes: { - dependency: "mongodb", - operation: "create-course", - outcome: "failure", - retryable: false, - }, - }); - res.status(500).json({ message: "Server Error" }); - } -}); + }), +); router.get("/", catchAsync(getAllCourses)); router.get("/:code", catchAsync(getCourse)); diff --git a/server/modules/file/file.controller.js b/server/modules/file/file.controller.js index 01bfaa2f..f4a8f595 100644 --- a/server/modules/file/file.controller.js +++ b/server/modules/file/file.controller.js @@ -1,10 +1,10 @@ import Contribution from "../contribution/contribution.model.js"; -import { RenameOneDriveFile } from "../../services/UploadFile.js"; import { requireFile, presentFile, visibleFiles } from "../../services/authorization.js"; -import { removeFile } from "../../services/resourceRemoval.js"; +import { scheduleDeletion } from "../../services/deletions.js"; +import { mutateContent } from "../../services/contentMutation.js"; import AppError from "../../utils/appError.js"; -export async function verifyFile(req, res) { +async function verifyFileAction(req, res) { const { file, code } = await requireFile( req, req.params.id, @@ -22,9 +22,9 @@ export async function verifyFile(req, res) { res.json({ message: "File verified successfully", file: await presentFile(req, file, code) }); } export async function unverifyFile(req, res) { - const { file } = await requireFile(req, req.params.id, req.body.courseCode, "canManage"); - await removeFile(file); - res.json({ message: "File deleted successfully" }); + res.status(202).json( + await scheduleDeletion(req, { kind: "file", id: req.params.id, code: req.body.courseCode }), + ); } export async function getAllFiles(req, res) { res.json(await visibleFiles(req)); @@ -37,17 +37,30 @@ export async function downloadFiles(req, res) { const { file } = await requireFile(req, req.body.fileId, req.body.courseCode); res.json({ downloadLink: `/api/files/content/${file._id}?download=1` }); } -export async function renameFile(req, res) { +async function renameFileAction(req, res) { const { file, code } = await requireFile(req, req.params.id, req.body.courseCode, "canManage"); const newName = typeof req.body.newName === "string" ? req.body.newName.trim() : ""; - if (!newName || newName.length > 200 || /[\\/:*?"<>|]/.test(newName)) + if (!newName || newName.length > 200 || /[\\/:*?"<>|\x00-\x1f\x7f]/.test(newName)) throw new AppError(400, "A valid file name is required"); const dot = file.name.lastIndexOf("."), - tilde = file.name.lastIndexOf("~"); + tilde = file.contributorName ? -1 : file.name.lastIndexOf("~"); const suffix = tilde !== -1 ? file.name.slice(tilde) : dot !== -1 ? file.name.slice(dot) : ""; const name = newName + suffix; - await RenameOneDriveFile(file.fileId, name); file.name = name; await file.save(); res.json({ message: "File renamed successfully", file: await presentFile(req, file, code) }); } + +export async function verifyFile(req, res) { + const context = await requireFile(req, req.params.id, req.body.courseCode, "canModerate"); + return mutateContent(req, context.affectedCourses || [context.code], () => + verifyFileAction(req, res), + ); +} + +export async function renameFile(req, res) { + const context = await requireFile(req, req.params.id, req.body.courseCode, "canManage"); + return mutateContent(req, context.affectedCourses || [context.code], () => + renameFileAction(req, res), + ); +} diff --git a/server/modules/folder/folder.controller.js b/server/modules/folder/folder.controller.js index 5be90e8d..5883f2ec 100644 --- a/server/modules/folder/folder.controller.js +++ b/server/modules/folder/folder.controller.js @@ -1,9 +1,10 @@ import { FolderModel } from "../course/course.model.js"; import { requireFolder, presentFolder } from "../../services/authorization.js"; -import { removeFolderFromCourse } from "../../services/resourceRemoval.js"; +import { scheduleDeletion } from "../../services/deletions.js"; +import { mutateContent } from "../../services/contentMutation.js"; import AppError from "../../utils/appError.js"; -export async function createFolder(req, res) { +async function createFolderAction(req, res) { const { name, course, parentFolder, childType } = req.body; const context = await requireFolder(req, parentFolder, course, "canManage"); if ( @@ -24,13 +25,19 @@ export async function createFolder(req, res) { res.json(await presentFolder(req, folder._id, context.code)); } export async function deleteFolder(req, res) { - res.json(await removeFolderFromCourse(req, req.body.folderId, req.body.courseCode)); + res.status(202).json( + await scheduleDeletion(req, { + kind: "folder", + id: req.body.folderId, + code: req.body.courseCode, + }), + ); } export async function getFolderContent(req, res) { const context = await requireFolder(req, req.params.folderId, req.query.courseCode); res.json(await presentFolder(req, context.folder._id, context.code)); } -export async function renameFolder(req, res) { +async function renameFolderAction(req, res) { const { folderId, newName, courseCode } = req.body; const context = await requireFolder(req, folderId, courseCode, "canManage"); if (typeof newName !== "string" || !newName.trim() || newName.length > 200) @@ -39,3 +46,17 @@ export async function renameFolder(req, res) { req.authorizationGraph = undefined; res.json(await presentFolder(req, context.folder._id, context.code)); } + +export async function createFolder(req, res) { + const context = await requireFolder(req, req.body.parentFolder, req.body.course, "canManage"); + return mutateContent(req, context.affectedCourses || [context.code], () => + createFolderAction(req, res), + ); +} + +export async function renameFolder(req, res) { + const context = await requireFolder(req, req.body.folderId, req.body.courseCode, "canManage"); + return mutateContent(req, context.affectedCourses || [context.code], () => + renameFolderAction(req, res), + ); +} diff --git a/server/modules/onedrive/onedrive.controller.js b/server/modules/onedrive/onedrive.controller.js deleted file mode 100644 index fe044757..00000000 --- a/server/modules/onedrive/onedrive.controller.js +++ /dev/null @@ -1,74 +0,0 @@ -import axios from "axios"; -import qs from "querystring"; -import AppError from "../../utils/appError.js"; -import settings from "../../config/onedrive.js"; -import fs from "fs"; - -let cachedAccessToken = null; -let tokenExpiry = 0; -let refreshPromise = null; -export async function getAccessToken() { - if (cachedAccessToken && Date.now() < tokenExpiry) { - return cachedAccessToken; - } - if (refreshPromise) { - return await refreshPromise; - } - - refreshPromise = (async () => { - let data; - - if (!fs.existsSync("./onedrive-refresh-token.token")) { - throw new AppError(503, "OneDrive authorization is not provisioned"); - } - data = await refreshAccessToken(); - - cachedAccessToken = data.access_token; - - tokenExpiry = Date.now() + (data.expires_in - 60) * 1000; - - return cachedAccessToken; - })(); - try { - return await refreshPromise; - } finally { - refreshPromise = null; - } -} - -export function clearAccessTokenCache() { - cachedAccessToken = null; - tokenExpiry = 0; - refreshPromise = null; -} - -async function refreshAccessToken() { - const data = qs.stringify({ - client_id: settings.clientId, - client_secret: settings.clientSecret, - refresh_token: `${fs.readFileSync("./onedrive-refresh-token.token", "utf-8")}`, - grant_type: "refresh_token", - }); - - const config = { - method: "post", - url: `https://login.microsoftonline.com/${settings.tenantId}/oauth2/v2.0/token`, - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Host: "login.microsoftonline.com", - }, - data, - }; - const response = await axios.post(config.url, config.data, { - headers: config.headers, - }); - - if (!response.data) throw new AppError(500, "Something went wrong"); - - fs.writeFileSync("./onedrive-access-token.token", response.data.access_token, "utf-8"); - if (response.data.refresh_token) { - fs.writeFileSync("./onedrive-refresh-token.token", response.data.refresh_token, "utf-8"); - } - - return response.data; -} diff --git a/server/modules/operation/operation.controller.js b/server/modules/operation/operation.controller.js new file mode 100644 index 00000000..682d8376 --- /dev/null +++ b/server/modules/operation/operation.controller.js @@ -0,0 +1,156 @@ +import { OperationModel } from "./operation.model.js"; +import { actorFor } from "../../services/authorization.js"; +import { uploadLimits } from "../../config/storage.js"; +import AppError from "../../utils/appError.js"; + +export function presentOperation(operation, actor) { + const own = String(operation.actorId) === actor.id; + return { + id: operation._id, + kind: operation.kind, + status: operation.status, + courseCode: operation.target.code, + folderId: operation.target.folderId, + name: + operation.plan?.name || + (operation.kind === "upload" ? "File upload" : "Content deletion"), + affectedCourses: operation.plan?.affectedCourses || [operation.target.code], + createdAt: operation.createdAt, + updatedAt: operation.updatedAt, + nextRunAt: operation.nextRunAt, + error: operation.error?.message ? operation.error : undefined, + completedSteps: operation.completedSteps.length, + cancelRequested: operation.cancelRequested, + canCancel: + own && + operation.kind === "upload" && + !["completed", "cancelled"].includes(operation.status), + canRetry: + (actor.admin || (own && operation.kind === "upload")) && + ["failed", "partial"].includes(operation.status), + entries: operation.entries.map((entry) => ({ + id: entry.id, + name: entry.name, + size: entry.size, + state: entry.state, + uploadedBytes: entry.uploadedBytes, + fileId: entry.published ? String(entry.fileId) : undefined, + error: + ["failed", "cleanup"].includes(entry.state) && entry.error?.message + ? entry.error + : undefined, + })), + limits: operation.kind === "upload" ? uploadLimits : undefined, + }; +} + +async function permittedOperation(req) { + const actor = await actorFor(req); + if (!/^[a-f0-9-]{36}$/.test(req.params.id)) throw new AppError(404, "Operation not found"); + const operation = await OperationModel.findById(req.params.id); + if ( + !operation || + (!actor.admin && + String(operation.actorId) !== actor.id && + !actor.managed.includes(operation.target.code)) + ) + throw new AppError(404, "Operation not found"); + return { actor, operation }; +} +export async function getOperation(req, res) { + const { actor, operation } = await permittedOperation(req); + res.setHeader("Cache-Control", "private, no-store"); + res.json(presentOperation(operation, actor)); +} +export async function listOperations(req, res) { + const actor = await actorFor(req); + const page = Number(req.query.page || 1), + pageSize = Number(req.query.pageSize || 20); + if ( + !Number.isInteger(page) || + page < 1 || + page > 10000 || + !Number.isInteger(pageSize) || + pageSize < 1 || + pageSize > 100 + ) + throw new AppError(400, "Invalid operation page"); + const filter = actor.admin + ? {} + : { $or: [{ actorId: actor.id }, { "target.code": { $in: actor.managed } }] }; + if (req.query.status) { + if ( + ![ + "failed", + "partial", + "running", + "queued", + "completed", + "awaiting", + "cancelled", + "cancelling", + ].includes(req.query.status) + ) + throw new AppError(400, "Invalid operation status"); + filter.status = req.query.status; + } + const [items, total] = await Promise.all([ + OperationModel.find(filter) + .sort({ createdAt: -1 }) + .skip((page - 1) * pageSize) + .limit(pageSize), + OperationModel.countDocuments(filter), + ]); + res.setHeader("Cache-Control", "private, no-store"); + res.json({ items: items.map((item) => presentOperation(item, actor)), page, pageSize, total }); +} +export async function cancelOperation(req, res) { + const { actor, operation } = await permittedOperation(req); + if (operation.kind !== "upload" || String(operation.actorId) !== actor.id) + throw new AppError(403, "Only the uploader can cancel this batch"); + if (!["completed", "cancelled"].includes(operation.status)) + await OperationModel.updateOne( + { _id: operation._id }, + { + $set: { + cancelRequested: true, + status: "cancelling", + nextRunAt: new Date(), + attempts: 0, + }, + $inc: { revision: 1 }, + }, + ); + res.status(202).json(presentOperation(await OperationModel.findById(operation._id), actor)); +} +export async function retryOperation(req, res) { + const { actor, operation } = await permittedOperation(req); + if (!actor.admin && (operation.kind !== "upload" || String(operation.actorId) !== actor.id)) + throw new AppError(403, "Administrator access is required to retry deletion"); + if (!["failed", "partial"].includes(operation.status)) + throw new AppError(409, "This operation is not waiting for retry"); + const fields = { + status: + operation.kind === "delete" + ? "queued" + : operation.cancelRequested + ? "cancelling" + : "awaiting", + nextRunAt: new Date(), + attempts: 0, + }; + if (operation.kind === "upload" && !operation.cancelRequested) + operation.entries.forEach((entry, index) => { + if (["cleanup", "uploading", "publishing", "queued"].includes(entry.state)) + fields.status = "queued"; + if (entry.state === "failed") { + fields[`entries.${index}.state`] = entry.providerId ? "queued" : "pending"; + if (entry.providerId) fields.status = "queued"; + } + }); + await OperationModel.updateOne( + { _id: operation._id, status: operation.status }, + { $set: fields, $unset: { error: 1 }, $inc: { revision: 1 } }, + ); + res.status(202).json(presentOperation(await OperationModel.findById(operation._id), actor)); +} diff --git a/server/modules/operation/operation.model.js b/server/modules/operation/operation.model.js new file mode 100644 index 00000000..1c1bf5d3 --- /dev/null +++ b/server/modules/operation/operation.model.js @@ -0,0 +1,76 @@ +import { model, Schema } from "mongoose"; + +const Entry = new Schema( + { + id: { type: String, required: true }, + name: { type: String, required: true }, + size: { type: Number, required: true }, + fileId: { type: Schema.Types.ObjectId, required: true }, + remoteName: { type: String, required: true }, + state: { type: String, default: "pending" }, + receivedAt: Date, + receiveUntil: Date, + receiveToken: String, + temporaryName: String, + sha256: String, + sessionUrl: String, + providerId: String, + uploadedBytes: { type: Number, default: 0 }, + published: { type: Boolean, default: false }, + error: { code: String, message: String }, + }, + { _id: false }, +); + +const Operation = new Schema( + { + _id: { type: String, required: true }, + kind: { type: String, enum: ["upload", "delete"], required: true }, + actorId: { type: Schema.Types.ObjectId, required: true }, + actorRole: { type: String, enum: ["student", "admin"], required: true }, + requestKey: String, + status: { + type: String, + enum: [ + "planning", + "awaiting", + "queued", + "running", + "completed", + "partial", + "failed", + "cancelling", + "cancelled", + ], + default: "planning", + }, + courses: [String], + target: { type: Schema.Types.Mixed, required: true }, + plan: Schema.Types.Mixed, + entries: [Entry], + completedSteps: [String], + cancelRequested: { type: Boolean, default: false }, + receivingCount: { type: Number, default: 0 }, + revision: { type: Number, default: 0 }, + attempts: { type: Number, default: 0 }, + nextRunAt: { type: Date, default: Date.now }, + leaseToken: String, + leaseUntil: Date, + error: { code: String, message: String }, + }, + { timestamps: true }, +); +Operation.index( + { actorId: 1, requestKey: 1 }, + { unique: true, partialFilterExpression: { requestKey: { $type: "string" } } }, +); +Operation.index({ status: 1, nextRunAt: 1, leaseUntil: 1 }); +Operation.index({ courses: 1, status: 1 }); +export const OperationModel = model("Operation", Operation); + +const Lock = new Schema( + { _id: String, owner: { type: String, required: true }, expiresAt: Date }, + { timestamps: true }, +); +export const CourseLock = model("CourseLock", Lock); +export const StorageLease = model("StorageLease", Lock.clone()); diff --git a/server/modules/operation/operation.routes.js b/server/modules/operation/operation.routes.js new file mode 100644 index 00000000..adea3eb4 --- /dev/null +++ b/server/modules/operation/operation.routes.js @@ -0,0 +1,16 @@ +import express from "express"; +import isLibraryAuthenticated from "../../middleware/isLibraryAuthenticated.js"; +import catchAsync from "../../utils/catchAsync.js"; +import { + getOperation, + listOperations, + cancelOperation, + retryOperation, +} from "./operation.controller.js"; +const router = express.Router(); +router.use(isLibraryAuthenticated); +router.get("/", catchAsync(listOperations)); +router.get("/:id", catchAsync(getOperation)); +router.post("/:id/cancel", catchAsync(cancelOperation)); +router.post("/:id/retry", catchAsync(retryOperation)); +export default router; diff --git a/server/modules/user/user.model.js b/server/modules/user/user.model.js index bde94405..15aaf47d 100644 --- a/server/modules/user/user.model.js +++ b/server/modules/user/user.model.js @@ -1,7 +1,7 @@ import { model, Schema } from "mongoose"; import Joi from "joi"; import AppError from "../../utils/appError.js"; -import axios from "axios"; +import { graph } from "../../services/graphClient.js"; import { getRandomColor } from "../../utils/generateRandomColor.js"; import { normalizeCourseCode } from "../../utils/course.js"; @@ -102,11 +102,7 @@ export const updateUserData = async (userId, userData) => { return { name: saved.name, semester: saved.semester }; }; -export const getUserFromToken = (accessToken) => - axios.get("https://graph.microsoft.com/v1.0/me", { - headers: { Authorization: `Bearer ${accessToken}` }, - timeout: 30000, - }); +export const getUserFromToken = (accessToken) => graph.request("me", { token: accessToken }); export const findUserWithEmail = async function (email) { const normalizedEmail = email?.toString().trim().toLowerCase(); diff --git a/server/modules/year/year.controller.js b/server/modules/year/year.controller.js index f1c26500..220f0219 100644 --- a/server/modules/year/year.controller.js +++ b/server/modules/year/year.controller.js @@ -1,9 +1,10 @@ import Course from "../course/course.model.js"; -import { requireCourse, requireFolder, presentFolder } from "../../services/authorization.js"; -import { removeFolderFromCourse } from "../../services/resourceRemoval.js"; +import { requireCourse, presentFolder } from "../../services/authorization.js"; +import { scheduleDeletion } from "../../services/deletions.js"; +import { mutateContent } from "../../services/contentMutation.js"; import { createYearFolderWithDefaultStructure } from "../course/course.service.js"; import AppError from "../../utils/appError.js"; -export async function addYear(req, res) { +async function addYearAction(req, res) { const context = await requireCourse(req, req.body.course, "canManage"); if (typeof req.body.name !== "string" || !req.body.name.trim()) throw new AppError(400, "A year name is required"); @@ -13,8 +14,19 @@ export async function addYear(req, res) { res.json(await presentFolder(req, year._id, context.code)); } export async function deleteYear(req, res) { - const context = await requireFolder(req, req.body.folderId, req.body.courseCode, "canManage"); - if (!context.course.children.some((id) => String(id) === String(context.folder._id))) - throw new AppError(404, "Year not found"); - res.json(await removeFolderFromCourse(req, req.body.folderId, context.code)); + res.status(202).json( + await scheduleDeletion(req, { + kind: "folder", + id: req.body.folderId, + code: req.body.courseCode, + rootOnly: true, + }), + ); +} + +export async function addYear(req, res) { + const context = await requireCourse(req, req.body.course, "canManage"); + return mutateContent(req, context.affectedCourses || [context.code], () => + addYearAction(req, res), + ); } diff --git a/server/scripts/generateOneDriveToken.js b/server/scripts/generateOneDriveToken.js index 96fb1a9e..60b20a9b 100644 --- a/server/scripts/generateOneDriveToken.js +++ b/server/scripts/generateOneDriveToken.js @@ -1,11 +1,11 @@ import { fileURLToPath } from "node:url"; import dotenv from "dotenv"; import http from "node:http"; -import fs from "node:fs"; import path from "node:path"; import readline from "node:readline/promises"; import axios from "axios"; import { createOAuthProof, createLocalOAuthCallback } from "../utils/oauthProof.js"; +import { storageTokens } from "../services/tokenStore.js"; const serverDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -56,16 +56,10 @@ export async function provisionOneDriveToken() { ); if (!response.data?.refresh_token) throw new Error("Microsoft did not return a refresh token."); - for (const [name, value] of [ - ["onedrive-refresh-token.token", response.data.refresh_token], - ["onedrive-access-token.token", response.data.access_token], - ]) { - if (!value) continue; - const target = path.join(serverDir, name); - if (fs.existsSync(target)) fs.chmodSync(target, 0o600); - fs.writeFileSync(target, value, { encoding: "utf8", mode: 0o600 }); - } - console.log("OneDrive tokens saved in the server directory with owner-only permissions."); + await storageTokens.save(response.data); + console.log( + "OneDrive tokens saved atomically in ONEDRIVE_TOKEN_DIR with owner-only permissions.", + ); }; const manual = async () => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); diff --git a/server/scripts/inventoryStorage.js b/server/scripts/inventoryStorage.js new file mode 100644 index 00000000..e150d348 --- /dev/null +++ b/server/scripts/inventoryStorage.js @@ -0,0 +1,55 @@ +import "dotenv/config"; +import fs from "node:fs/promises"; +import path from "node:path"; +import mongoose from "mongoose"; +import { FileModel } from "../modules/course/course.model.js"; +import { inventoryStorage } from "../services/storageInventory.js"; +import { storageRoot } from "../config/storage.js"; + +try { + const args = process.argv.slice(2); + if ( + args.length !== 4 || + args[0] !== "--database" || + args[2] !== "--report" || + !/^[a-zA-Z0-9_-]+$/.test(args[1]) + ) + throw new Error("Expected --database --report "); + const reportPath = path.resolve(args[3]); + if (!reportPath.split(path.sep).some((part) => part.startsWith("~"))) + throw new Error("Use a ~-prefixed report path"); + if (!process.env.MONGO_URI) throw new Error("Configure MONGO_URI"); + const root = storageRoot(); + await mongoose.connect(process.env.MONGO_URI, { + dbName: args[1], + serverSelectionTimeoutMS: 5000, + autoIndex: false, + autoCreate: false, + }); + const files = FileModel.find() + .select("_id fileId webUrl downloadUrl thumbnail") + .lean() + .cursor(); + const report = await inventoryStorage(files, root); + report.database = args[1]; + await fs.mkdir(path.dirname(reportPath), { recursive: true, mode: 0o700 }); + await fs.writeFile(reportPath, JSON.stringify(report, null, 2) + "\n", { + flag: "wx", + mode: 0o600, + }); + console.log( + JSON.stringify({ + database: args[1], + counts: report.counts, + providerPermissionsVerified: false, + report: reportPath, + }), + ); +} catch { + console.error( + "Storage inventory failed. Check MONGO_URI, --database and the new ~-prefixed --report path. No database or provider writes are performed.", + ); + process.exitCode = 1; +} finally { + await mongoose.disconnect(); +} diff --git a/server/services/UploadFile.js b/server/services/UploadFile.js deleted file mode 100644 index 0cd44dbe..00000000 --- a/server/services/UploadFile.js +++ /dev/null @@ -1,173 +0,0 @@ -import fs from "fs"; -import { randomUUID } from "node:crypto"; -import path from "node:path"; -import { getAccessToken } from "../modules/onedrive/onedrive.controller.js"; -import axios from "axios"; -import { FileModel } from "../modules/course/course.model.js"; -import logger from "../utils/logger.js"; -import { logGraphError } from "../utils/graphError.js"; -import { uploadThumbnail } from "./imagekit.js"; - -const parent_item_id = process.env.ONEDRIVE_FOLDER_ID; - -async function createUploadSession(folderId, fileName) { - try { - const access_token = await getAccessToken(); - const url = `https://graph.microsoft.com/v1.0/me/drive/items/${folderId}:/${encodeURIComponent(fileName)}:/createUploadSession`; - const config = { - headers: { - Authorization: `Bearer ${access_token}`, - }, - }; - - const { data } = await axios.post(url, {}, config); - return { url: data?.uploadUrl, access_token }; - } catch (error) { - logGraphError(logger, error, "Failed to create Graph upload session"); - return false; - } -} - -async function UploadFile(filePath, fileName, approved) { - const folderId = parent_item_id; - const session = await createUploadSession(folderId, randomUUID() + path.extname(fileName)); - if (!session?.url) { - logger.error("File upload failed", { - attributes: { - dependency: "microsoft-graph", - operation: "upload-file", - outcome: "failure", - retryable: true, - }, - }); - return null; - } - const { url, access_token } = session; - const file = fs.readFileSync(filePath); - const config = { - headers: { - "Content-Range": `bytes 0-${file.length - 1}/${file.length}`, - }, - }; - try { - const { data } = await axios.put(url, file, config); - const createurllink = `https://graph.microsoft.com/v1.0/me/drive/items/${data.id}/createLink`; - const thumbnaillink = `https://graph.microsoft.com/v1.0/me/drive/items/${data.id}/thumbnails`; - - // Run createLink and thumbnail fetch in parallel - const [urldata, thumbnaildata] = await Promise.all([ - axios.post( - createurllink, - { type: "view", scope: "organization" }, - { - headers: { - Authorization: `Bearer ${access_token}`, - "Content-Type": "application/json", - }, - }, - ), - axios.get(thumbnaillink, { - headers: { Authorization: `Bearer ${access_token}` }, - }), - ]); - - const tempThumbnailUrl = thumbnaildata.data.value?.[0]?.medium?.url; - const webUrl = urldata?.data?.link?.webUrl; - - const fileData = new FileModel({ - isVerified: approved === true, - fileId: data.id, - size: data.size, - thumbnail: tempThumbnailUrl ? { url: tempThumbnailUrl } : undefined, - name: fileName, - downloadUrl: `${webUrl}?download=1`, - webUrl: webUrl, - }); - await fileData.save(); - logger.info("File saved"); - - // Upload thumbnail to ImageKit in the background - don't block the response - if (tempThumbnailUrl) { - (async () => { - try { - const imgResponse = await axios.get(tempThumbnailUrl, { - responseType: "arraybuffer", - }); - const { - url: permanentUrl, - fileId: imagekitFileId, - path: imagekitPath, - } = await uploadThumbnail(data.id, Buffer.from(imgResponse.data)); - await FileModel.updateOne( - { fileId: data.id }, - { - thumbnail: { - url: permanentUrl, - fileId: imagekitFileId, - path: imagekitPath, - }, - }, - ); - logger.info("ImageKit thumbnail stored", { - attributes: { - dependency: "imagekit", - operation: "store-thumbnail", - outcome: "success", - }, - }); - } catch (thumbErr) { - logger.warn("ImageKit thumbnail upload failed", { - error: thumbErr, - attributes: { - dependency: "imagekit", - operation: "store-thumbnail", - outcome: "failure", - retryable: true, - }, - }); - } - })(); - } - - return fileData._id; - } catch (error) { - logGraphError(logger, error, "Failed to upload file to Microsoft Graph"); - return null; - } -} - -async function DeleteFile(fileId) { - if (!fileId || fileId === parent_item_id) throw new Error("Storage root cannot be deleted"); - const access_token = await getAccessToken(); - await axios - .delete(`https://graph.microsoft.com/v1.0/me/drive/items/${encodeURIComponent(fileId)}`, { - headers: { Authorization: `Bearer ${access_token}` }, - }) - .catch((error) => { - if (error.response?.status !== 404) throw error; - }); -} - -async function RenameOneDriveFile(fileId, newName) { - try { - const access_token = await getAccessToken(); - const url = `https://graph.microsoft.com/v1.0/me/drive/items/${fileId}`; - const config = { - headers: { - Authorization: `Bearer ${access_token}`, - "Content-Type": "application/json", - }, - }; - const _data = { - name: randomUUID() + path.extname(newName), - }; - const { data } = await axios.patch(url, _data, config); - return data; - } catch (err) { - logGraphError(logger, err, `Failed to rename OneDrive file: ${fileId} to ${newName}`); - throw err; - } -} - -export { DeleteFile, RenameOneDriveFile }; -export default UploadFile; diff --git a/server/services/authorization.js b/server/services/authorization.js index 023e1849..7d378a99 100644 --- a/server/services/authorization.js +++ b/server/services/authorization.js @@ -90,7 +90,8 @@ export async function requireCourse(req, value, action = "read") { throw new AppError(403, "You do not have permission for this course"); const graph = await libraryGraph(req); const course = graph.courses.get(code); - if (!course) throw new AppError(404, "Course not found"); + if (!course || (course.deletingOperation && course.deletingOperation !== req.operationId)) + throw new AppError(404, "Course not found"); return { course, code, actor, capabilities: permitted }; } @@ -99,8 +100,12 @@ export async function libraryGraph(req) { if (!req.authorizationGraph) req.authorizationGraph = (async () => { const [courses, folders] = await Promise.all([ - Course.find().select("_id code name children books createdAt updatedAt").lean(), - FolderModel.find().select("_id name courses childType children").lean(), + Course.find() + .select("_id code name children books createdAt updatedAt deletingOperation") + .lean(), + FolderModel.find() + .select("_id name courses childType children deletingOperation") + .lean(), ]); const graph = { courses: new Map(courses.map((c) => [normalizeCourseCode(c.code), c])), @@ -113,6 +118,8 @@ export async function libraryGraph(req) { map.get(id).add(code); }; for (const [code, course] of graph.courses) { + if (course.deletingOperation && course.deletingOperation !== req.operationId) + continue; const visited = new Set(); const walk = (id, ancestors = new Set()) => { id = idOf(id); @@ -120,7 +127,12 @@ export async function libraryGraph(req) { throw new AppError(409, "Course tree requires repair"); if (visited.has(id)) return; const folder = graph.folders.get(id); - if (!folder || !codesOf(folder.courses).includes(code)) return; + if ( + !folder || + !codesOf(folder.courses).includes(code) || + (folder.deletingOperation && folder.deletingOperation !== req.operationId) + ) + return; visited.add(id); add(graph.folderCourses, id, code); if (folder.childType === "File") @@ -146,7 +158,13 @@ export async function requireFolder(req, id, value, action = "read") { : action === "read" ? affectedCourses[0] : courseContext(value); - if (!code || !affectedCourses.includes(code)) throw new AppError(404, "Folder not found"); + if ( + !code || + !affectedCourses.includes(code) || + (graph.folders.get(id)?.deletingOperation && + graph.folders.get(id).deletingOperation !== req.operationId) + ) + throw new AppError(404, "Folder not found"); const course = await requireCourse(req, code, action); return { ...course, folder: graph.folders.get(id), affectedCourses }; } @@ -165,6 +183,7 @@ async function ownedFiles(req) { } export async function canReadFile(req, file) { + if (file.deletingOperation || file.resourceState === "uploading") return false; const [actor, graph] = await Promise.all([actorFor(req), libraryGraph(req)]); const courses = [...(graph.fileCourses.get(idOf(file)) || [])]; if (!courses.length) return false; @@ -184,7 +203,13 @@ export async function requireFile(req, id, value, action = "read") { actorFor(req), ]); const affectedCourses = [...(graph.fileCourses.get(id) || [])].sort(); - if (!file || !affectedCourses.length) throw new AppError(404, "File not found"); + if ( + !file || + !affectedCourses.length || + (file.deletingOperation && file.deletingOperation !== req.operationId) || + file.resourceState === "uploading" + ) + throw new AppError(404, "File not found"); const code = value ? courseContext(value) : action === "read" @@ -204,6 +229,8 @@ export async function presentFile(req, file, code) { _id: id, name: file.name, size: file.size, + sizeBytes: file.sizeBytes, + contributorName: file.contributorName, isVerified: file.isVerified === true, webUrl: `/api/files/preview/${id}`, downloadUrl: `/api/files/content/${id}?download=1`, @@ -238,7 +265,8 @@ export async function presentFolder(req, id, code) { actorFor(req), ]); const folder = graph.folders.get(idOf(id)); - if (!folder || !graph.folderCourses.get(idOf(id))?.has(code)) return null; + if (!folder || folder.deletingOperation || !graph.folderCourses.get(idOf(id))?.has(code)) + return null; const children = ( await Promise.all( folder.children.map(async (child) => @@ -276,28 +304,3 @@ export async function visibleFiles(req) { [...(await visibleFileMap(req)).values()].map((file) => presentFile(req, file)), ); } - -export async function authorizeContributionUpload(req, res, next) { - try { - const id = req.headers["contribution-id"]; - if (typeof id !== "string" || !id || id.length > 100) - throw new AppError(404, "Contribution not found"); - const contribution = await Contribution.findOne({ - contributionId: id, - uploadedBy: (await actorFor(req)).id, - }); - if (!contribution) throw new AppError(404, "Contribution not found"); - const context = await requireFolder( - req, - idOf(contribution.parentFolder), - contribution.courseCode, - "canContribute", - ); - if (context.folder.childType !== "File") throw new AppError(400, "Choose a file folder"); - req.contribution = contribution; - req.contributionApproved = context.capabilities.canManage; - next(); - } catch (error) { - next(error); - } -} diff --git a/server/services/contentMutation.js b/server/services/contentMutation.js new file mode 100644 index 00000000..010118a2 --- /dev/null +++ b/server/services/contentMutation.js @@ -0,0 +1,45 @@ +import { libraryGraph } from "./authorization.js"; +import { withCourseLocks } from "./courseLocks.js"; +import { normalizeCourseCode } from "../utils/course.js"; +import AppError from "../utils/appError.js"; + +export async function relatedCourses(req, codes) { + const graph = await libraryGraph(req); + const related = new Set(codes.filter(Boolean).map(normalizeCourseCode)); + const memberships = [...graph.folderCourses.values(), ...graph.fileCourses.values()]; + let changed = true; + while (changed) { + changed = false; + for (const courses of memberships) { + if (![...courses].some((code) => related.has(code))) continue; + for (const code of courses) + if (!related.has(code)) { + related.add(code); + changed = true; + } + } + } + return [...related].sort(); +} + +export async function mutateContent(req, codes, task) { + const related = await relatedCourses(req, codes); + return withCourseLocks(related, async (lock) => { + req.authorizationGraph = undefined; + req.authorizationVisibleFiles = undefined; + req.operationId = lock.owner; + try { + await lock.assertHeld(); + const current = await relatedCourses(req, codes); + if (current.some((code) => !related.includes(code))) + throw new AppError( + 409, + "Course sharing changed. Please retry the action.", + "COURSE_BUSY", + ); + return await task(lock); + } finally { + delete req.operationId; + } + }); +} diff --git a/server/services/courseLocks.js b/server/services/courseLocks.js new file mode 100644 index 00000000..a440f950 --- /dev/null +++ b/server/services/courseLocks.js @@ -0,0 +1,83 @@ +import { randomUUID } from "node:crypto"; +import { CourseLock } from "../modules/operation/operation.model.js"; +import { normalizeCourseCode } from "../utils/course.js"; +import AppError from "../utils/appError.js"; + +export async function acquireCourseLocks(codes, owner, { durable = false } = {}) { + const sorted = [...new Set(codes.map(normalizeCourseCode))].sort(); + const acquired = []; + try { + for (const code of sorted) { + try { + await CourseLock.findOneAndUpdate( + { + _id: code, + $or: [{ owner }, { expiresAt: { $lte: new Date() } }], + }, + { $set: { owner, expiresAt: durable ? null : new Date(Date.now() + 120000) } }, + { upsert: true, new: true }, + ); + acquired.push(code); + } catch (error) { + if (error.code === 11000) + throw new AppError( + 409, + "A content operation is in progress for this course", + "COURSE_BUSY", + ); + throw error; + } + } + } catch (error) { + await CourseLock.deleteMany({ _id: { $in: acquired }, owner }); + throw error; + } + return sorted; +} +export const releaseCourseLocks = (owner) => CourseLock.deleteMany({ owner }); +export async function assertCourseWritable(code, owner) { + const lock = await CourseLock.findById(normalizeCourseCode(code)).lean(); + if (lock && lock.owner !== owner && (!lock.expiresAt || lock.expiresAt > new Date())) + throw new AppError( + 409, + "A content operation is in progress for this course", + "COURSE_BUSY", + ); +} + +export async function withCourseLocks( + codes, + task, + { owner = randomUUID(), durable = false, retain = false } = {}, +) { + const sorted = await acquireCourseLocks(codes, owner, { durable }); + let lost; + const assertHeld = async () => { + if (lost) throw lost; + const count = await CourseLock.countDocuments({ _id: { $in: sorted }, owner }); + if (count !== sorted.length) + throw new AppError(409, "Course operation lock was lost", "COURSE_BUSY"); + }; + const heartbeat = durable + ? undefined + : setInterval(() => { + CourseLock.updateMany( + { _id: { $in: sorted }, owner }, + { $set: { expiresAt: new Date(Date.now() + 120000) } }, + ) + .then((result) => { + if (result.matchedCount !== sorted.length) + lost = new AppError(409, "Course operation lock was lost", "COURSE_BUSY"); + }) + .catch((error) => { + lost = error; + }); + }, 15000); + heartbeat?.unref(); + try { + return await task({ owner, assertHeld }); + } finally { + clearInterval(heartbeat); + if (!retain) await releaseCourseLocks(owner); + } +} diff --git a/server/services/deletions.js b/server/services/deletions.js new file mode 100644 index 00000000..fa3f411f --- /dev/null +++ b/server/services/deletions.js @@ -0,0 +1,446 @@ +import { randomUUID } from "node:crypto"; +import { OperationModel } from "../modules/operation/operation.model.js"; +import Course, { FolderModel, FileModel } from "../modules/course/course.model.js"; +import Contribution from "../modules/contribution/contribution.model.js"; +import CourseAllotment from "../modules/course/courseAllotment.model.js"; +import User from "../modules/user/user.model.js"; +import Admin from "../modules/admin/admin.model.js"; +import SearchResults from "../modules/search/search.model.js"; +import { + actorFor, + requireCourse, + requireFile, + requireFolder, + libraryGraph, + courseContext, +} from "./authorization.js"; +import { relatedCourses } from "./contentMutation.js"; +import { acquireCourseLocks, releaseCourseLocks } from "./courseLocks.js"; +import { storage, storageId } from "./storage.js"; +import { storageRoot } from "../config/storage.js"; +import { deleteThumbnail } from "./imagekit.js"; +import { invalidateThumbnail } from "./thumbnails.js"; +import { normalizeCourseCode } from "../utils/course.js"; +import AppError from "../utils/appError.js"; + +export async function operationActor(operation) { + const actor = + operation.actorRole === "admin" + ? await Admin.findById(operation.actorId) + : await User.findById(operation.actorId); + if (!actor) throw new AppError(403, "The account is no longer available"); + return { + [operation.actorRole === "admin" ? "admin" : "user"]: actor, + operationId: operation._id, + }; +} + +async function authorizeTarget(req, target) { + if (target.kind === "file") return requireFile(req, target.id, target.code, "canManage"); + if (target.kind === "folder") { + const context = await requireFolder(req, target.id, target.code, "canManage"); + if (target.rootOnly && !context.course.children.some((id) => String(id) === target.id)) + throw new AppError(404, "Year not found"); + return context; + } + if (target.kind === "course") { + if (!(await actorFor(req)).admin) + throw new AppError(403, "Administrator access is required"); + return requireCourse(req, target.code, "canManage"); + } + if (target.kind === "contribution") { + const contribution = await Contribution.findOne({ contributionId: target.id }); + if (!contribution) throw new AppError(404, "Contribution not found"); + const context = await requireFolder( + req, + String(contribution.parentFolder), + target.code, + "canModerate", + ); + const affected = new Set([context.code]); + for (const id of contribution.files) { + const file = await requireFile(req, String(id), target.code, "canModerate"); + file.affectedCourses.forEach((code) => affected.add(code)); + } + return { ...context, contribution, affectedCourses: [...affected].sort() }; + } + throw new AppError(400, "Invalid deletion target"); +} + +async function deletionPlan(req, target) { + const context = await authorizeTarget(req, target); + const graph = await libraryGraph(req); + const fileIds = new Set(); + const folderIds = new Set(); + const foldersToDelete = []; + const foldersToUnlink = []; + const contributionIds = []; + const collect = (id) => { + id = String(id); + if (folderIds.has(id) || !graph.folderCourses.get(id)?.has(context.code)) return; + if (folderIds.size >= 10000) + throw new AppError( + 409, + "This deletion requires a smaller selection", + "OPERATION_TOO_LARGE", + ); + folderIds.add(id); + const folder = graph.folders.get(id); + if (folder.childType === "Folder") folder.children.forEach(collect); + }; + if (target.kind === "file") fileIds.add(String(context.file._id)); + if (target.kind === "contribution") { + context.contribution.files.forEach((id) => fileIds.add(String(id))); + contributionIds.push(String(context.contribution._id)); + } + if (target.kind === "folder") collect(target.id); + if (target.kind === "course") context.course.children.forEach(collect); + for (const id of folderIds) { + const folder = graph.folders.get(id); + const remaining = folder.courses.filter( + (code) => normalizeCourseCode(code) !== context.code, + ); + if (remaining.length || graph.folderCourses.get(id).size > 1) + foldersToUnlink.push({ id, remaining }); + else foldersToDelete.push(id); + } + const removedFolders = new Set(foldersToDelete); + for (const id of foldersToDelete) { + const folder = graph.folders.get(id); + if (folder.childType === "File") + folder.children.forEach((child) => fileIds.add(String(child))); + } + if (["folder", "course"].includes(target.kind)) { + for (const [id, folder] of graph.folders) { + if (!removedFolders.has(id) && folder.childType === "File") + folder.children.forEach((child) => fileIds.delete(String(child))); + } + } + const files = await FileModel.find({ _id: { $in: [...fileIds] } }).lean(); + const unfinishedUploads = await OperationModel.find({ + kind: "upload", + status: { $nin: ["completed", "cancelled"] }, + $or: [ + { "target.contributionId": { $in: contributionIds } }, + { "target.folderId": { $in: foldersToDelete } }, + { + "target.folderId": { $in: foldersToUnlink.map((folder) => folder.id) }, + "target.code": context.code, + }, + ], + }) + .select("_id") + .lean(); + const affectedCourses = new Set([context.code]); + for (const file of files) { + storageId(file.fileId); + if (file.fileId === storageRoot()) + throw new AppError(403, "The storage root is protected", "STORAGE_ROOT_PROTECTED"); + for (const code of graph.fileCourses.get(String(file._id)) || []) affectedCourses.add(code); + } + const plan = { + courseId: String(context.course?._id || graph.courses.get(context.code)._id), + code: context.code, + name: context.file?.name || context.folder?.name || context.course?.name || "Contribution", + files: files.map((file) => ({ + id: String(file._id), + providerId: file.fileId, + thumbnailId: file.thumbnail?.fileId, + })), + foldersToDelete, + foldersToUnlink, + contributionIds, + uploadOperationIds: unfinishedUploads.map((operation) => operation._id), + rootIds: + target.kind === "course" + ? context.course.children.map(String) + : target.kind === "folder" + ? [target.id] + : [], + deleteCourse: target.kind === "course", + affectedCourses: [...affectedCourses].sort(), + }; + if (JSON.stringify(plan).length > 8 * 1024 * 1024) + throw new AppError( + 409, + "This deletion requires a smaller selection", + "OPERATION_TOO_LARGE", + ); + return plan; +} + +export async function scheduleDeletion(req, target) { + const actor = await actorFor(req); + target = { ...target, code: courseContext(target.code) }; + if ( + target.kind !== "course" && + (typeof target.id !== "string" || + !target.id || + target.id.length > 100 || + (["file", "folder"].includes(target.kind) && !/^[a-f0-9]{24}$/i.test(target.id))) + ) + throw new AppError(404, "Resource not found"); + if (target.kind === "course") { + const course = await Course.findOne({ code: target.code }).select("_id").lean(); + if (course) target.id = String(course._id); + else { + const previous = await OperationModel.findOne({ + kind: "delete", + actorId: actor.id, + "target.kind": "course", + "target.code": target.code, + status: "completed", + }).sort({ createdAt: -1 }); + if (previous) return accepted(previous); + } + } + if (target.id) { + const existing = await OperationModel.findOne({ + kind: "delete", + actorId: actor.id, + "target.kind": target.kind, + "target.id": target.id, + "target.code": normalizeCourseCode(target.code), + $or: [{ status: { $ne: "failed" } }, { plan: { $exists: true } }], + }).sort({ createdAt: -1 }); + if (existing) return accepted(existing); + } + const context = await authorizeTarget(req, target); + if (["file", "contribution"].includes(target.kind) && context.affectedCourses.length > 1) { + if ( + !Array.isArray(req.body.affectedCourses) || + JSON.stringify([...new Set(req.body.affectedCourses)].sort()) !== + JSON.stringify(context.affectedCourses) + ) + throw new AppError( + 409, + "Review every affected course before deleting shared files", + "SHARED_CONFIRMATION_REQUIRED", + ); + } + let operation; + try { + operation = await OperationModel.create({ + _id: randomUUID(), + kind: "delete", + actorId: actor.id, + actorRole: actor.admin ? "admin" : "student", + requestKey: `delete:${target.kind}:${target.id}:${target.code}`, + target: { ...target, code: context.code, confirmedCourses: context.affectedCourses }, + courses: await relatedCourses(req, [context.code]), + status: "planning", + }); + } catch (error) { + if (error.code !== 11000) throw error; + return accepted( + await OperationModel.findOne({ + actorId: actor.id, + requestKey: `delete:${target.kind}:${target.id}:${target.code}`, + }), + ); + } + try { + await prepareDeletion(operation, req); + } catch (error) { + if (error.code === "COURSE_BUSY") { + await OperationModel.updateOne( + { _id: operation._id }, + { $set: { nextRunAt: new Date(Date.now() + 1000) } }, + ); + } else { + await OperationModel.updateOne( + { _id: operation._id }, + { + $set: { + status: "failed", + error: { + code: error.code || "DELETE_FAILED", + message: "Deletion could not be prepared", + }, + }, + }, + ); + if (!(await OperationModel.findById(operation._id)).plan) { + await releaseCourseLocks(operation._id); + await OperationModel.updateOne( + { _id: operation._id }, + { $unset: { requestKey: 1 } }, + ); + } + throw error; + } + } + return { + operationId: operation._id, + status: "queued", + statusUrl: `/api/operations/${operation._id}`, + }; +} + +function accepted(operation) { + return { + operationId: operation._id, + status: operation.status, + statusUrl: `/api/operations/${operation._id}`, + }; +} + +export async function prepareDeletion(operation, req) { + await acquireCourseLocks(operation.courses, operation._id, { durable: true }); + let plan = operation.plan; + if (!plan) { + req ||= await operationActor(operation); + req.operationId = operation._id; + req.authorizationGraph = undefined; + const currentCodes = await relatedCourses(req, [operation.target.code]); + if (currentCodes.some((code) => !operation.courses.includes(code))) + throw new AppError( + 409, + "Course sharing changed; review the deletion again", + "SHARING_CHANGED", + ); + plan = await deletionPlan(req, operation.target); + if ( + ["file", "contribution"].includes(operation.target.kind) && + JSON.stringify(plan.affectedCourses) !== + JSON.stringify(operation.target.confirmedCourses) + ) + throw new AppError( + 409, + "Course sharing changed; review the deletion again", + "SHARING_CHANGED", + ); + await OperationModel.updateOne({ _id: operation._id }, { $set: { plan } }); + } + if (plan.uploadOperationIds?.length) + await OperationModel.updateMany( + { + _id: { $in: plan.uploadOperationIds }, + kind: "upload", + status: { $nin: ["completed", "cancelled"] }, + }, + { + $set: { + cancelRequested: true, + status: "cancelling", + nextRunAt: new Date(), + attempts: 0, + }, + $inc: { revision: 1 }, + }, + ); + await FileModel.updateMany( + { _id: { $in: plan.files.map((file) => file.id) } }, + { $set: { deletingOperation: operation._id } }, + ); + await FolderModel.updateMany( + { _id: { $in: plan.foldersToDelete } }, + { $set: { deletingOperation: operation._id } }, + ); + await Contribution.updateMany( + { _id: { $in: plan.contributionIds } }, + { $set: { deletingOperation: operation._id } }, + ); + if (plan.deleteCourse) + await Course.updateOne( + { _id: plan.courseId }, + { $set: { deletingOperation: operation._id } }, + ); + await OperationModel.updateOne({ _id: operation._id }, { $set: { status: "queued" } }); + return plan; +} + +export async function runDeletion(operation, checkpoint) { + const plan = operation.plan || (await prepareDeletion(operation)); + await acquireCourseLocks(operation.courses, operation._id, { durable: true }); + const step = async (key, action) => { + await checkpoint(); + const current = await OperationModel.findById(operation._id) + .select("completedSteps") + .lean(); + if (current.completedSteps.includes(key)) return; + await action(); + await checkpoint(); + await OperationModel.updateOne( + { _id: operation._id, leaseToken: operation.leaseToken }, + { $addToSet: { completedSteps: key } }, + ); + }; + // Marks are replayed even if the process stopped between journaling and marking. + await step("mark", () => prepareDeletion({ ...(operation.toObject?.() || operation), plan })); + for (const file of plan.files) { + await step(`storage:${file.id}`, () => storage.remove(file.providerId)); + if (file.thumbnailId) + await step(`thumbnail:${file.id}`, async () => { + try { + await deleteThumbnail(file.thumbnailId); + } catch (error) { + if (![error.status, error.statusCode].includes(404)) throw error; + } + }); + await step(`metadata:${file.id}`, async () => { + await FolderModel.updateMany({ children: file.id }, { $pull: { children: file.id } }); + await Contribution.updateMany({ files: file.id }, { $pull: { files: file.id } }); + await FileModel.deleteOne({ _id: file.id, deletingOperation: operation._id }); + invalidateThumbnail(file.providerId); + }); + } + await step("folders", async () => { + for (const folder of plan.foldersToUnlink) + await FolderModel.updateOne( + { _id: folder.id }, + { $set: { courses: folder.remaining } }, + ); + await Course.updateOne( + { _id: plan.courseId }, + { $pull: { children: { $in: plan.rootIds } } }, + ); + await FolderModel.updateMany( + { children: { $in: plan.foldersToDelete } }, + { $pull: { children: { $in: plan.foldersToDelete } } }, + ); + await FolderModel.deleteMany({ + _id: { $in: plan.foldersToDelete }, + deletingOperation: operation._id, + }); + await Contribution.deleteMany({ + $or: [ + { _id: { $in: plan.contributionIds } }, + { parentFolder: { $in: plan.foldersToDelete } }, + ], + }); + }); + if (plan.deleteCourse) + await step("course", async () => { + const code = new RegExp(`^${plan.code.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i"); + await User.updateMany( + { + $or: [ + { "courses.code": code }, + { "readOnly.code": code }, + { "favourites.code": code }, + ], + }, + { + $pull: { courses: { code }, readOnly: { code }, favourites: { code } }, + }, + ); + await User.collection.updateMany( + { "previousCourses.courses.code": code }, + { + $pull: { "previousCourses.$[semester].courses": { code } }, + }, + { arrayFilters: [{ "semester.courses": { $type: "array" } }] }, + ); + await CourseAllotment.updateMany({ courses: code }, { $pull: { courses: code } }); + await SearchResults.updateMany({ code }, { $set: { isAvailable: false } }); + await Course.deleteOne({ _id: plan.courseId, deletingOperation: operation._id }); + }); + await checkpoint(); + const finished = await OperationModel.updateOne( + { _id: operation._id, leaseToken: operation.leaseToken }, + { $set: { status: "completed" }, $unset: { error: 1, leaseUntil: 1, leaseToken: 1 } }, + ); + if (!finished.modifiedCount) throw new AppError(409, "Operation lease was lost", "LEASE_LOST"); + await releaseCourseLocks(operation._id); +} diff --git a/server/services/fileDelivery.js b/server/services/fileDelivery.js index cabd5c24..c558b290 100644 --- a/server/services/fileDelivery.js +++ b/server/services/fileDelivery.js @@ -1,9 +1,8 @@ -import axios from "axios"; import { pipeline } from "node:stream/promises"; import { requireFile } from "./authorization.js"; -import { getAccessToken } from "../modules/onedrive/onedrive.controller.js"; +import { storage } from "./storage.js"; +import { thumbnailStream } from "./thumbnails.js"; import AppError from "../utils/appError.js"; -import { isImageKitUrl } from "./imagekit.js"; export async function filePreview(req, res) { const { file } = await requireFile(req, req.params.id, req.query.courseCode); @@ -18,22 +17,28 @@ export async function filePreview(req, res) { if ( url?.protocol === "https:" && /(^|\.)(sharepoint\.com|onedrive\.live\.com|1drv\.ms)$/i.test(url.hostname) - ) + ) { + await storage.withinRoot(file.fileId); return res.redirect(url.href); + } } + if ( + /\.(doc|docx|dot|dotx|dotm|odp|ods|odt|pps|ppsx|ppt|pptx|rtf|xls|xlsm|xlsx)$/i.test( + file.name, + ) + ) + req.previewAsPdf = true; return fileContent(req, res); } export async function fileContent(req, res) { const { file } = await requireFile(req, req.params.id, req.query.courseCode); - const token = await getAccessToken(); - const response = await axios.get( - `https://graph.microsoft.com/v1.0/me/drive/items/${encodeURIComponent(file.fileId)}/content`, - { - headers: { Authorization: `Bearer ${token}` }, - responseType: "stream", - }, - ); + const controller = new AbortController(); + res.once("close", () => controller.abort()); + const response = await storage.content(file.fileId, { + signal: controller.signal, + ...(req.previewAsPdf ? { format: "pdf" } : {}), + }); res.setHeader("Cache-Control", "private, no-store"); res.setHeader("X-Content-Type-Options", "nosniff"); const contentType = response.headers["content-type"] || "application/octet-stream"; @@ -44,7 +49,7 @@ export async function fileContent(req, res) { /^(application\/pdf|image\/(png|jpeg|webp|gif))(;|$)/i.test(contentType); res.setHeader( "Content-Disposition", - `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(file.name)}`, + `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(req.previewAsPdf ? file.name.replace(/\.[^.]+$/, ".pdf") : file.name)}`, ); if (response.headers["content-length"]) res.setHeader("Content-Length", response.headers["content-length"]); @@ -54,19 +59,9 @@ export async function fileContent(req, res) { export async function fileThumbnail(req, res) { const { file } = await requireFile(req, req.params.id, req.query.courseCode); - let url = typeof file.thumbnail === "string" ? file.thumbnail : file.thumbnail?.url; - if (!url || !isImageKitUrl(url)) { - const token = await getAccessToken(); - const { data } = await axios.get( - `https://graph.microsoft.com/v1.0/me/drive/items/${encodeURIComponent(file.fileId)}/thumbnails`, - { - headers: { Authorization: `Bearer ${token}` }, - }, - ); - url = data.value?.[0]?.medium?.url; - } - if (!url) throw new AppError(404, "Thumbnail not found"); - const response = await axios.get(url, { responseType: "stream" }); + const controller = new AbortController(); + res.once("close", () => controller.abort()); + const response = await thumbnailStream(file, { signal: controller.signal }); const type = response.headers["content-type"] || ""; if (!/^image\/(png|jpeg|webp|gif)(;|$)/i.test(type)) { response.data.destroy(); diff --git a/server/services/graphClient.js b/server/services/graphClient.js new file mode 100644 index 00000000..40c2d8c5 --- /dev/null +++ b/server/services/graphClient.js @@ -0,0 +1,184 @@ +import axios from "axios"; +import { setTimeout as delay } from "node:timers/promises"; +import AppError from "../utils/appError.js"; +import { storageTokens } from "./tokenStore.js"; + +export class StorageError extends AppError { + constructor(providerStatus, code = "STORAGE_UNAVAILABLE", retryAfterMs = 0) { + super( + providerStatus === 504 ? 504 : 502, + "Storage is temporarily unavailable. Please try again.", + code, + ); + this.providerStatus = providerStatus; + this.retryAfterMs = retryAfterMs; + } +} + +export function graphUrl(input) { + let url; + try { + url = new URL(input, "https://graph.microsoft.com/v1.0/"); + } catch { + throw new StorageError(502, "INVALID_STORAGE_RESPONSE"); + } + if ( + url.origin !== "https://graph.microsoft.com" || + url.username || + url.password || + url.hash || + !/^\/(v1\.0|beta)\//.test(url.pathname) + ) + throw new StorageError(502, "INVALID_STORAGE_RESPONSE"); + return url.href; +} + +export function providerUrl(input) { + let url; + try { + url = new URL(input); + } catch { + throw new StorageError(502, "INVALID_STORAGE_RESPONSE"); + } + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.port || + !/(^|\.)(sharepoint\.com|1drv\.com|onedrive\.com|storage\.live\.com|microsoftusercontent\.com)$/i.test( + url.hostname, + ) + ) + throw new StorageError(502, "INVALID_STORAGE_RESPONSE"); + return url.href; +} + +export function createGraphClient({ + tokens = storageTokens, + transport = (config) => axios.request(config), + sleep = delay, + now = Date.now, +} = {}) { + async function request(input, options = {}) { + const { + token: suppliedToken, + preauthenticated = false, + signal, + retries = 2, + trustedOrigin, + ...config + } = options; + const remoteUrl = (value) => { + if (trustedOrigin) { + const expected = new URL(trustedOrigin); + const candidate = new URL(value); + if ( + candidate.protocol === "https:" && + candidate.origin === expected.origin && + !candidate.username && + !candidate.password && + candidate.pathname.startsWith(expected.pathname.replace(/\/$/, "") + "/") + ) + return candidate.href; + } + return providerUrl(value); + }; + let url = preauthenticated ? remoteUrl(input) : graphUrl(input); + let token = preauthenticated ? undefined : suppliedToken || (await tokens.getAccessToken()); + let refreshed = false; + let redirects = 0; + let retry = 0; + const headers = Object.fromEntries( + Object.entries(config.headers || {}).filter( + ([name]) => name.toLowerCase() !== "authorization", + ), + ); + for (;;) { + signal?.throwIfAborted(); + try { + const response = await transport({ + ...config, + url, + method: config.method || "GET", + timeout: 30000, + maxRedirects: 0, + maxContentLength: 8 * 1024 * 1024, + signal, + headers: { + ...headers, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + if (!response || !Number.isInteger(response.status)) + throw new StorageError(502, "INVALID_STORAGE_RESPONSE"); + return response; + } catch (error) { + if (signal?.aborted) throw signal.reason; + if (error instanceof StorageError) throw error; + const status = error.response?.status; + error.response?.data?.destroy?.(); + if ( + [301, 302, 303, 307, 308].includes(status) && + (config.method || "GET") === "GET" && + redirects++ < 3 + ) { + url = remoteUrl(error.response.headers?.location); + token = undefined; + continue; + } + if (status === 401 && token && !suppliedToken && !refreshed) { + token = await tokens.getAccessToken({ rejected: token }); + refreshed = true; + continue; + } + const header = error.response?.headers?.["retry-after"]; + const retryAfterMs = header + ? /^\d+(\.\d+)?$/.test(String(header)) + ? Number(header) * 1000 + : Date.parse(header) - now() + : 0; + const wait = + Math.max(0, Number.isFinite(retryAfterMs) ? retryAfterMs : 0) || + 250 * 2 ** retry; + if ( + (!status || [429, 500, 502, 503, 504].includes(status)) && + retry++ < retries && + wait <= 30000 + ) { + await sleep(wait, undefined, { signal }); + continue; + } + throw new StorageError( + ["ETIMEDOUT", "ECONNABORTED"].includes(error.code) ? 504 : status || 502, + "STORAGE_UNAVAILABLE", + wait, + ); + } + } + } + async function* pages(input, options = {}) { + const seen = new Set(); + let url = graphUrl(input); + while (url) { + if (seen.has(url) || seen.size >= 1000) + throw new StorageError(502, "INVALID_STORAGE_PAGINATION"); + seen.add(url); + const { data } = await request(url, options); + if (!Array.isArray(data?.value)) + throw new StorageError(502, "INVALID_STORAGE_RESPONSE"); + yield data.value; + const next = data["@odata.nextLink"]; + if (next !== undefined && (typeof next !== "string" || !next)) + throw new StorageError(502, "INVALID_STORAGE_PAGINATION"); + url = next ? graphUrl(next) : undefined; + } + } + async function collection(input, options) { + const all = []; + for await (const page of pages(input, options)) all.push(...page); + return all; + } + return { request, pages, collection }; +} + +export const graph = createGraphClient(); diff --git a/server/services/imagekit.js b/server/services/imagekit.js index 02c2bbce..bf52babe 100644 --- a/server/services/imagekit.js +++ b/server/services/imagekit.js @@ -1,4 +1,4 @@ -import ImageKit, { toFile } from "@imagekit/nodejs"; +import ImageKit from "@imagekit/nodejs"; // Lazy singleton - deferred until first use so env vars are loaded let _ik = null; @@ -22,29 +22,6 @@ function getClient() { return _ik; } -/** - * Upload a raw image buffer to ImageKit as WebP and return the permanent URL. - * Uses fileId as the filename so re-uploads overwrite the existing file. - * ImageKit handles WebP conversion at the CDN edge - no sharp needed. - */ -export async function uploadThumbnail(fileId, imageBuffer) { - const fileObject = await toFile(imageBuffer, `${fileId}.webp`, { type: "image/webp" }); - const response = await getClient().files.upload({ - file: fileObject, - fileName: `${fileId}.webp`, - folder: "/thumbnails", - useUniqueFileName: false, // overwrite same fileId on re-upload - transformation: { - pre: "f-webp,q-80", // convert to WebP quality 80 at edge - }, - }); - return { - url: response.url, - fileId: response.fileId, - path: response.filePath || `/thumbnails/${fileId}.webp`, - }; -} - /** * Delete a thumbnail from ImageKit by its internal fileId. */ @@ -56,11 +33,19 @@ export async function deleteThumbnail(imagekitFileId) { * Returns true if the URL is already a permanent ImageKit URL. */ export function isImageKitUrl(url) { - return Boolean( - url && - process.env.IMAGEKIT_URL_ENDPOINT && - url.startsWith(process.env.IMAGEKIT_URL_ENDPOINT), - ); + try { + const expected = new URL(process.env.IMAGEKIT_URL_ENDPOINT); + const actual = new URL(url); + return ( + actual.protocol === "https:" && + actual.origin === expected.origin && + !actual.username && + !actual.password && + actual.pathname.startsWith(expected.pathname.replace(/\/$/, "") + "/") + ); + } catch { + return false; + } } export default getClient; diff --git a/server/services/operationWorker.js b/server/services/operationWorker.js new file mode 100644 index 00000000..8d9f760e --- /dev/null +++ b/server/services/operationWorker.js @@ -0,0 +1,200 @@ +import { randomUUID } from "node:crypto"; +import { OperationModel, CourseLock, StorageLease } from "../modules/operation/operation.model.js"; +import { acquireStorageLease, releaseStorageLease } from "./storageLeases.js"; +import { runDeletion } from "./deletions.js"; +import { runUploads, recoverReceiving, removeTemporary } from "./uploads.js"; +import { releaseCourseLocks } from "./courseLocks.js"; +import AppError from "../utils/appError.js"; +import logger from "../utils/logger.js"; + +export async function processOperation(id) { + const owner = randomUUID(); + try { + await acquireStorageLease("worker", owner, 1, 60000); + } catch (error) { + if (error.code === "STORAGE_BUSY") return false; + throw error; + } + let lost = false; + const timer = setInterval(() => { + StorageLease.updateOne( + { owner, expiresAt: { $gt: new Date() } }, + { $set: { expiresAt: new Date(Date.now() + 60000) } }, + ) + .then((result) => { + if (!result.matchedCount) lost = true; + }) + .catch(() => { + lost = true; + }); + }, 15000); + timer.unref(); + try { + return await processClaim(id, async () => { + if (lost || !(await StorageLease.exists({ owner, expiresAt: { $gt: new Date() } }))) + throw new AppError(409, "Operation lease was lost", "LEASE_LOST"); + }); + } finally { + clearInterval(timer); + await releaseStorageLease(owner); + } +} + +async function processClaim(id, checkWorker) { + const token = randomUUID(); + const operation = await OperationModel.findOneAndUpdate( + { + ...(id ? { _id: id } : {}), + status: { $in: ["planning", "queued", "running", "cancelling"] }, + nextRunAt: { $lte: new Date() }, + $or: [{ leaseUntil: { $exists: false } }, { leaseUntil: { $lte: new Date() } }], + }, + { + $set: { + status: "running", + leaseToken: token, + leaseUntil: new Date(Date.now() + 60000), + }, + $inc: { attempts: 1 }, + }, + { sort: { nextRunAt: 1, createdAt: 1 }, new: true }, + ); + if (!operation) return false; + let leaseError; + const renew = setInterval(() => { + OperationModel.updateOne( + { _id: operation._id, leaseToken: token }, + { $set: { leaseUntil: new Date(Date.now() + 60000) } }, + ) + .then((result) => { + if (!result.modifiedCount) + leaseError = new AppError(409, "Operation lease was lost", "LEASE_LOST"); + }) + .catch((error) => { + leaseError = new AppError(409, "Operation lease was lost", "LEASE_LOST"); + }); + }, 15000); + renew.unref(); + const checkpoint = async () => { + await checkWorker(); + if (leaseError) throw leaseError; + if ( + !(await OperationModel.exists({ + _id: operation._id, + leaseToken: token, + leaseUntil: { $gt: new Date() }, + })) + ) + throw new AppError(409, "Operation lease was lost", "LEASE_LOST"); + }; + try { + if (operation.kind === "delete") await runDeletion(operation, checkpoint); + else await runUploads(operation, checkpoint); + } catch (error) { + if (error.code === "LEASE_LOST") return true; + const current = await OperationModel.findById(operation._id); + const busy = error.code === "COURSE_BUSY"; + const permanent = error instanceof AppError && error.status < 500 && !busy; + const retry = busy || (!permanent && operation.attempts < 5); + const wait = Math.max( + error.retryAfterMs || 0, + busy ? 1000 : Math.min(300000, 2000 * 2 ** operation.attempts), + ); + await OperationModel.updateOne( + { _id: operation._id, leaseToken: token }, + { + $set: { + status: retry ? (current.cancelRequested ? "cancelling" : "queued") : "failed", + nextRunAt: new Date(Date.now() + wait), + error: { + code: error.code || "OPERATION_FAILED", + message: permanent + ? error.message + : "The operation could not finish. Retrying preserves completed work.", + }, + }, + $unset: { leaseToken: 1, leaseUntil: 1 }, + ...(busy ? { $inc: { attempts: -1 } } : {}), + }, + ); + if (operation.kind === "delete" && !current.plan && !retry) { + await releaseCourseLocks(operation._id); + await OperationModel.updateOne({ _id: operation._id }, { $unset: { requestKey: 1 } }); + } + logger.warn("Content operation needs recovery", { + attributes: { + operationId: operation._id, + outcome: "failure", + retryable: retry, + code: error.code || "OPERATION_FAILED", + }, + }); + } finally { + clearInterval(renew); + } + return true; +} + +export async function recoverLocks() { + // Completion may have persisted immediately before a crash prevented lock release + const owners = await CourseLock.distinct("owner", { expiresAt: null }); + const finished = await OperationModel.find({ + _id: { $in: owners }, + $or: [ + { status: { $in: ["completed", "cancelled"] } }, + { + kind: "upload", + status: { $in: ["failed", "partial", "awaiting"] }, + leaseUntil: { $exists: false }, + }, + { kind: "delete", status: "failed", plan: { $exists: false } }, + ], + }) + .select("_id") + .lean(); + for (const operation of finished) await releaseCourseLocks(operation._id); + // A reservation is journaled before any bytes can be written + const reservations = await StorageLease.find({ + _id: /^receive:/, + updatedAt: { $lt: new Date(Date.now() - 300000) }, + }).lean(); + for (const lease of reservations) { + const operation = await OperationModel.findOne({ "entries.receiveToken": lease.owner }) + .select("entries") + .lean(); + const entry = operation?.entries.find((file) => file.receiveToken === lease.owner); + if (!entry || ["completed", "cancelled", "failed"].includes(entry.state)) { + if (entry) await removeTemporary(entry.temporaryName); + await releaseStorageLease(lease.owner); + } + } +} + +export function startOperationWorker({ intervalMs = 1000 } = {}) { + let stopped = false; + let timer; + let active = Promise.resolve(); + const tick = () => { + active = (async () => { + try { + await recoverReceiving(); + await recoverLocks(); + await processOperation(); + } catch { + logger.warn("Content operation worker will retry after a database failure"); + } + if (!stopped) { + timer = setTimeout(tick, intervalMs); + timer.unref(); + } + })(); + }; + tick(); + return { + async stop() { + stopped = true; + clearTimeout(timer); + await active; + }, + }; +} diff --git a/server/services/resourceRemoval.js b/server/services/resourceRemoval.js deleted file mode 100644 index 58cd4097..00000000 --- a/server/services/resourceRemoval.js +++ /dev/null @@ -1,74 +0,0 @@ -import { normalizeCourseCode } from "../utils/course.js"; -import Course, { FileModel, FolderModel } from "../modules/course/course.model.js"; -import Contribution from "../modules/contribution/contribution.model.js"; -import { DeleteFile } from "./UploadFile.js"; -import { deleteThumbnail } from "./imagekit.js"; -import { libraryGraph, requireFolder } from "./authorization.js"; - -export async function removeFile(file) { - // Use only the provider identity persisted on this authorized resource - await DeleteFile(file.fileId); - if (file.thumbnail?.fileId) { - await deleteThumbnail(file.thumbnail.fileId).catch((error) => { - if (error.status !== 404) throw error; - }); - } - await FolderModel.updateMany({ children: file._id }, { $pull: { children: file._id } }); - await Contribution.updateMany({ files: file._id }, { $pull: { files: file._id } }); - await FileModel.deleteOne({ _id: file._id }); -} - -export async function removeFolderFromCourse(req, id, code) { - const context = await requireFolder(req, id, code, "canManage"); - id = String(context.folder._id); - const graph = await libraryGraph(req); - const affected = new Set(); - const collect = (folderId) => { - folderId = String(folderId); - if (affected.has(folderId) || !graph.folderCourses.get(folderId)?.has(context.code)) return; - affected.add(folderId); - const folder = graph.folders.get(folderId); - if (folder.childType === "Folder") folder.children.forEach(collect); - }; - collect(id); - const removed = [...affected].filter( - (folderId) => - graph.folderCourses.get(folderId).size === 1 && - graph.folders - .get(folderId) - .courses.every((c) => normalizeCourseCode(c) === context.code), - ); - const candidateFiles = new Set( - removed.flatMap((folderId) => { - const folder = graph.folders.get(folderId); - return folder.childType === "File" ? folder.children.map(String) : []; - }), - ); - // Files referenced by a surviving folder must remain available there - for (const [folderId, folder] of graph.folders) { - if (!removed.includes(folderId) && folder.childType === "File") - folder.children.forEach((fileId) => candidateFiles.delete(String(fileId))); - } - const files = await FileModel.find({ _id: { $in: [...candidateFiles] } }); - for (const file of files) await removeFile(file); - for (const folderId of affected) { - const folder = graph.folders.get(folderId); - await FolderModel.updateOne( - { _id: folderId }, - { - $set: { - courses: folder.courses.filter((c) => normalizeCourseCode(c) !== context.code), - }, - }, - ); - } - await Course.updateOne({ _id: context.course._id }, { $pull: { children: id } }); - if (removed.length) { - await FolderModel.deleteMany({ _id: { $in: removed } }); - await FolderModel.updateMany( - { children: { $in: removed } }, - { $pull: { children: { $in: removed } } }, - ); - } - return { success: true, folderId: id, unlinked: context.affectedCourses.length > 1 }; -} diff --git a/server/services/storage.js b/server/services/storage.js new file mode 100644 index 00000000..477f77e2 --- /dev/null +++ b/server/services/storage.js @@ -0,0 +1,244 @@ +import fs from "node:fs/promises"; +import { constants } from "node:fs"; +import { setTimeout as delay } from "node:timers/promises"; +import { graph, StorageError } from "./graphClient.js"; +import { storageRoot, graphChunkBytes } from "../config/storage.js"; +import AppError from "../utils/appError.js"; + +export function storageId(id) { + if (typeof id !== "string" || !/^[a-zA-Z0-9!_.-]{1,300}$/.test(id) || [".", ".."].includes(id)) + throw new AppError(400, "Invalid storage identity", "INVALID_STORAGE_ID"); + return encodeURIComponent(id); +} + +export function createStorage({ client = graph, root = storageRoot, sleep = delay } = {}) { + const itemPath = (id) => `me/drive/items/${storageId(id)}`; + async function item(id, options) { + const { data } = await client.request(itemPath(id), options); + if (!data || data.id !== id) throw new StorageError(502, "INVALID_STORAGE_RESPONSE"); + return data; + } + async function withinRoot(id, { allowRoot = false, signal } = {}) { + if (id === root() && !allowRoot) + throw new AppError(403, "The storage root is protected", "STORAGE_ROOT_PROTECTED"); + const seen = new Set(); + let current = id; + let resource; + while (current && seen.size < 64) { + if (seen.has(current)) throw new StorageError(502, "INVALID_STORAGE_TOPOLOGY"); + seen.add(current); + const data = await item(current, { signal }); + resource ||= data; + if (current === root()) { + if (!data.folder) throw new StorageError(502, "INVALID_STORAGE_ROOT"); + return resource; + } + current = data.parentReference?.id; + } + throw new AppError( + 403, + "The resource is outside CourseHub storage", + "STORAGE_OUTSIDE_ROOT", + ); + } + async function children(id = root(), options = {}) { + await withinRoot(id, { ...options, allowRoot: true }); + return client.collection(`${itemPath(id)}/children`, options); + } + async function remove(id, options = {}) { + if (id === root()) + throw new AppError(403, "The storage root is protected", "STORAGE_ROOT_PROTECTED"); + try { + const resource = await withinRoot(id, options); + if (resource.folder) + throw new AppError( + 403, + "Automatic storage-folder deletion is not allowed", + "STORAGE_FOLDER_PROTECTED", + ); + await client.request(itemPath(id), { ...options, method: "DELETE" }); + } catch (error) { + if (error.providerStatus !== 404) throw error; + } + } + async function content(id, options = {}) { + await withinRoot(id, options); + const { format, ...requestOptions } = options; + if (format && format !== "pdf") throw new AppError(400, "Unsupported preview format"); + return client.request(`${itemPath(id)}/content${format ? "?format=pdf" : ""}`, { + ...requestOptions, + responseType: "stream", + }); + } + async function thumbnail(id, options = {}) { + await withinRoot(id, options); + const thumbnails = await client.collection(`${itemPath(id)}/thumbnails`, options); + return thumbnails.find((entry) => entry.medium?.url)?.medium.url; + } + async function findUpload(name, size, options = {}) { + if (!/^[a-f0-9-]{36}(\.[a-zA-Z0-9]{1,15})?$/.test(name)) + throw new AppError(400, "Invalid upload identity"); + try { + const { data } = await client.request( + `${itemPath(root())}:/${encodeURIComponent(name)}`, + options, + ); + if (!data?.id || data.folder || data.name !== name || data.size !== size) + throw new StorageError(409, "STORAGE_UPLOAD_CONFLICT"); + await withinRoot(data.id, options); + return data; + } catch (error) { + if (error.providerStatus === 404) return null; + throw error; + } + } + async function cancelSession(url, options = {}) { + if (!url) return; + try { + await client.request(url, { ...options, method: "DELETE", preauthenticated: true }); + } catch (error) { + if (![404, 410].includes(error.providerStatus)) throw error; + } + } + async function upload({ + filename, + remoteName, + size, + sessionUrl, + signal, + onSession, + onProgress, + checkCancelled = async () => {}, + }) { + await withinRoot(root(), { allowRoot: true, signal }); + const completed = await findUpload(remoteName, size, { signal }); + if (completed) return completed; + let session; + if (sessionUrl) { + try { + session = (await client.request(sessionUrl, { preauthenticated: true, signal })) + .data; + } catch (error) { + if (![404, 410].includes(error.providerStatus)) throw error; + sessionUrl = undefined; + } + } + if (!sessionUrl) { + session = ( + await client.request( + `${itemPath(root())}:/${encodeURIComponent(remoteName)}:/createUploadSession`, + { + method: "POST", + data: { + item: { name: remoteName, "@microsoft.graph.conflictBehavior": "fail" }, + }, + signal, + }, + ) + ).data; + if (typeof session?.uploadUrl !== "string") + throw new StorageError(502, "INVALID_STORAGE_RESPONSE"); + sessionUrl = session.uploadUrl; + await onSession(sessionUrl); + } + let offset = nextOffset(session, size); + let stalled = 0; + const handle = await fs.open(filename, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + if ((await handle.stat()).size !== size) + throw new AppError(400, "Upload size changed", "UPLOAD_SIZE_MISMATCH"); + while (offset < size) { + await checkCancelled(); + signal?.throwIfAborted(); + const length = Math.min(graphChunkBytes, size - offset); + const buffer = Buffer.allocUnsafe(length); + const { bytesRead } = await handle.read(buffer, 0, length, offset); + if (bytesRead !== length) + throw new AppError(400, "Upload size changed", "UPLOAD_SIZE_MISMATCH"); + let result; + try { + result = await client.request(sessionUrl, { + method: "PUT", + preauthenticated: true, + signal, + retries: 0, + data: buffer, + maxBodyLength: graphChunkBytes, + headers: { + "Content-Length": String(length), + "Content-Range": `bytes ${offset}-${offset + length - 1}/${size}`, + }, + }); + } catch (error) { + if ( + ![416, 429, 500, 502, 503, 504].includes(error.providerStatus) || + stalled++ >= 2 + ) + throw error; + if (error.retryAfterMs > 30000) throw error; + await sleep(error.retryAfterMs || 250, undefined, { signal }); + // A lost response may already have committed the chunk/file. + const finished = await findUpload(remoteName, size, { signal }); + if (finished) return finished; + result = await client.request(sessionUrl, { preauthenticated: true, signal }); + } + if ([200, 201].includes(result.status) && result.data?.id) { + if ( + result.data.size !== size || + result.data.folder || + result.data.name !== remoteName + ) + throw new StorageError(502, "INVALID_STORAGE_RESPONSE"); + await withinRoot(result.data.id, { signal }); + await onProgress(size); + return result.data; + } + const next = nextOffset(result.data, size); + if (next <= offset && stalled++ >= 2) + throw new StorageError(502, "STORAGE_UPLOAD_STALLED"); + if (next > offset) stalled = 0; + offset = next; + await onProgress(offset); + } + throw new StorageError(502, "INVALID_STORAGE_RESPONSE"); + } finally { + await handle.close(); + } + } + return { + item, + withinRoot, + children, + remove, + content, + thumbnail, + upload, + findUpload, + cancelSession, + }; +} + +function nextOffset(data, size) { + const ranges = data?.nextExpectedRanges; + if (!Array.isArray(ranges) || !ranges.length) + throw new StorageError(502, "INVALID_STORAGE_RESPONSE"); + const offsets = ranges.map((range) => { + const match = typeof range === "string" && range.match(/^(\d+)-(\d*)$/); + const start = match ? Number(match[1]) : NaN; + if ( + !Number.isSafeInteger(start) || + start < 0 || + start >= size || + start % (320 * 1024) !== 0 || + (match[2] && + (!Number.isSafeInteger(Number(match[2])) || + Number(match[2]) < start || + Number(match[2]) >= size)) + ) + throw new StorageError(502, "INVALID_STORAGE_RESPONSE"); + return start; + }); + return Math.min(...offsets); +} + +export const storage = createStorage(); diff --git a/server/services/storageInventory.js b/server/services/storageInventory.js new file mode 100644 index 00000000..ec7bbc1c --- /dev/null +++ b/server/services/storageInventory.js @@ -0,0 +1,69 @@ +import { createHash } from "node:crypto"; + +function linkSummary(value) { + if (!value) return undefined; + try { + const url = new URL(value); + return { + host: url.hostname, + protocol: url.protocol, + fingerprint: createHash("sha256").update(String(value)).digest("hex"), + }; + } catch { + return { malformed: true }; + } +} + +export async function inventoryStorage(files, root) { + const records = []; + const counts = { + files: 0, + storedWebLinks: 0, + storedDownloadLinks: 0, + thumbnailReferences: 0, + rootReferences: 0, + invalidProviderIds: 0, + }; + for await (const file of files) { + counts.files++; + const webLink = linkSummary(file.webUrl), + downloadLink = linkSummary(file.downloadUrl); + const thumbnail = linkSummary( + typeof file.thumbnail === "string" ? file.thumbnail : file.thumbnail?.url, + ); + if (webLink) counts.storedWebLinks++; + if (downloadLink) counts.storedDownloadLinks++; + if (thumbnail || file.thumbnail?.fileId) counts.thumbnailReferences++; + const protectedRoot = file.fileId === root; + const invalidProviderId = + typeof file.fileId !== "string" || + !/^[a-zA-Z0-9!_.-]{1,300}$/.test(file.fileId) || + [".", ".."].includes(file.fileId); + if (protectedRoot) counts.rootReferences++; + if (invalidProviderId) counts.invalidProviderIds++; + records.push({ + resourceId: String(file._id), + providerId: invalidProviderId ? undefined : file.fileId, + webLink, + downloadLink, + thumbnail, + thumbnailId: + typeof file.thumbnail?.fileId === "string" && + /^[a-zA-Z0-9_-]{1,300}$/.test(file.thumbnail.fileId) + ? file.thumbnail.fileId + : undefined, + protectedRoot, + invalidProviderId, + }); + } + return { + version: 1, + generatedAt: new Date().toISOString(), + mode: "read-only", + counts, + records, + providerPermissionsVerified: false, + nextStep: + "Review existing sharing permissions and root membership in an explicitly selected provider test area. Stored links do not prove current public access, and absence does not prove private access. No permissions were changed.", + }; +} diff --git a/server/services/storageLeases.js b/server/services/storageLeases.js new file mode 100644 index 00000000..5ffe2b37 --- /dev/null +++ b/server/services/storageLeases.js @@ -0,0 +1,25 @@ +import { StorageLease } from "../modules/operation/operation.model.js"; +import AppError from "../utils/appError.js"; + +export async function acquireStorageLease(kind, owner, count, durationMs) { + for (let index = 0; index < count; index++) { + try { + const lease = await StorageLease.findOneAndUpdate( + { _id: `${kind}:${index}`, $or: [{ owner }, { expiresAt: { $lte: new Date() } }] }, + { + $set: { + owner, + expiresAt: durationMs ? new Date(Date.now() + durationMs) : null, + }, + }, + { upsert: true, new: true }, + ); + return lease._id; + } catch (error) { + if (error.code !== 11000) throw error; + } + } + throw new AppError(409, "Storage is busy, retry shortly", "STORAGE_BUSY"); +} + +export const releaseStorageLease = (owner) => StorageLease.deleteMany({ owner }); diff --git a/server/services/thumbnails.js b/server/services/thumbnails.js new file mode 100644 index 00000000..ea3a5146 --- /dev/null +++ b/server/services/thumbnails.js @@ -0,0 +1,41 @@ +import { storage } from "./storage.js"; +import { graph } from "./graphClient.js"; +import { isImageKitUrl } from "./imagekit.js"; +import AppError from "../utils/appError.js"; + +const cache = new Map(); +export async function thumbnailUrl(id, { refresh = false, signal } = {}) { + if (refresh) cache.delete(id); + let record = cache.get(id); + if (!record || record.expires <= Date.now()) { + if (cache.size >= 200) cache.delete(cache.keys().next().value); + record = { expires: Date.now() + 60000, promise: storage.thumbnail(id, { signal }) }; + cache.set(id, record); + record.promise.catch(() => { + if (cache.get(id) === record) cache.delete(id); + }); + } + return record.promise; +} +export async function thumbnailStream(file, { signal } = {}) { + await storage.withinRoot(file.fileId, { signal }); + const cached = typeof file.thumbnail === "string" ? file.thumbnail : file.thumbnail?.url; + let url = isImageKitUrl(cached) ? cached : await thumbnailUrl(file.fileId, { signal }); + for (let attempt = 0; attempt < 2; attempt++) { + if (!url) throw new AppError(404, "Thumbnail not found"); + try { + return await graph.request(url, { + preauthenticated: true, + trustedOrigin: process.env.IMAGEKIT_URL_ENDPOINT, + responseType: "stream", + signal, + }); + } catch (error) { + if (attempt || ![401, 403, 404].includes(error.providerStatus)) throw error; + url = await thumbnailUrl(file.fileId, { refresh: true, signal }); + } + } +} +export function invalidateThumbnail(id) { + cache.delete(id); +} diff --git a/server/services/tokenStore.js b/server/services/tokenStore.js new file mode 100644 index 00000000..d18f35f0 --- /dev/null +++ b/server/services/tokenStore.js @@ -0,0 +1,190 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; +import axios from "axios"; +import { tokenDirectory } from "../config/storage.js"; +import AppError from "../utils/appError.js"; + +export async function atomicPrivateWrite(filename, content) { + await fs.mkdir(path.dirname(filename), { recursive: true, mode: 0o700 }); + const temporary = `${filename}.${randomUUID()}.tmp`; + let handle; + try { + handle = await fs.open(temporary, "wx", 0o600); + await handle.writeFile(content, "utf8"); + await handle.sync(); + await handle.close(); + handle = undefined; + await fs.rename(temporary, filename); + const directory = await fs.open(path.dirname(filename), "r"); + try { + await directory.sync(); + } finally { + await directory.close(); + } + } finally { + await handle?.close(); + await fs.unlink(temporary).catch((error) => { + if (error.code !== "ENOENT") throw error; + }); + } +} + +export function createTokenStore({ + directory = tokenDirectory, + request = (data, options) => + axios.post( + `https://login.microsoftonline.com/${encodeURIComponent(process.env.AZURE_TENANT_ID)}/oauth2/v2.0/token`, + data, + options, + ), + now = Date.now, +} = {}) { + let cached; + let refreshing; + const paths = () => ({ + refresh: path.join(directory(), "onedrive-refresh-token.token"), + access: path.join(directory(), "onedrive-access-token.json"), + lock: path.join(directory(), ".onedrive-refresh.lock"), + }); + async function readCache() { + try { + const value = JSON.parse(await fs.readFile(paths().access, "utf8")); + return typeof value.token === "string" && Number.isFinite(value.expiresAt) + ? value + : undefined; + } catch (error) { + if (error.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error; + } + } + const usable = (value, rejected) => + value?.token && value.expiresAt > now() + 60000 && value.token !== rejected; + async function withRefreshLock(run) { + await fs.mkdir(directory(), { recursive: true, mode: 0o700 }); + const lock = paths().lock; + const owner = randomUUID(); + for (let attempt = 0; attempt < 400; attempt++) { + try { + await fs.mkdir(lock, { mode: 0o700 }); + await fs.writeFile(path.join(lock, "owner"), owner, { mode: 0o600 }); + try { + const assertOwner = async () => { + if ((await fs.readFile(path.join(lock, "owner"), "utf8")) !== owner) + throw new AppError(503, "Storage authorization is busy"); + }; + return await run(assertOwner); + } finally { + const current = await fs + .readFile(path.join(lock, "owner"), "utf8") + .catch(() => ""); + if (current === owner) await fs.rm(lock, { recursive: true, force: true }); + } + } catch (error) { + if (error.code !== "EEXIST") throw error; + const info = await fs.stat(lock).catch(() => null); + if (info && now() - info.mtimeMs > 120000) { + const stale = `${lock}.${owner}.stale`; + await fs + .rename(lock, stale) + .then(() => fs.rm(stale, { recursive: true, force: true })) + .catch((failure) => { + if (failure.code !== "ENOENT") throw failure; + }); + } else await delay(100); + } + } + throw new AppError(503, "Storage authorization is busy"); + } + async function saveUnlocked(data) { + if (typeof data.refresh_token !== "string" || !data.refresh_token) + throw new AppError(502, "Storage authorization was not returned"); + await atomicPrivateWrite(paths().refresh, data.refresh_token); + if ( + typeof data.access_token === "string" && + data.access_token && + Number.isFinite(Number(data.expires_in)) && + Number(data.expires_in) > 60 + ) { + cached = { + token: data.access_token, + expiresAt: now() + Number(data.expires_in) * 1000, + }; + await atomicPrivateWrite(paths().access, JSON.stringify(cached)); + } else { + cached = undefined; + await fs.unlink(paths().access).catch((error) => { + if (error.code !== "ENOENT") throw error; + }); + } + } + const save = (data) => + withRefreshLock(async (assertOwner) => { + await assertOwner(); + return saveUnlocked(data); + }); + async function getAccessToken({ rejected } = {}) { + if (usable(cached, rejected)) return cached.token; + if (refreshing) { + const token = await refreshing; + if (token !== rejected) return token; + } + refreshing = withRefreshLock(async (assertOwner) => { + cached = await readCache(); + if (usable(cached, rejected)) return cached.token; + let refresh; + try { + refresh = (await fs.readFile(paths().refresh, "utf8")).trim(); + } catch (error) { + if (error.code !== "ENOENT") throw error; + throw new AppError(503, "OneDrive authorization is not provisioned"); + } + if (!refresh) throw new AppError(503, "OneDrive authorization is not provisioned"); + await assertOwner(); + let response; + try { + response = await request( + new URLSearchParams({ + client_id: process.env.AZURE_CLIENT_ID, + client_secret: process.env.AZURE_CLIENT_SECRET, + refresh_token: refresh, + grant_type: "refresh_token", + }).toString(), + { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + timeout: 30000, + maxRedirects: 0, + }, + ); + } catch { + throw new AppError(503, "Storage authorization could not be refreshed"); + } + const data = response?.data; + if ( + !data || + typeof data.access_token !== "string" || + !data.access_token || + Number(data.expires_in) <= 60 || + !Number.isFinite(Number(data.expires_in)) + ) + throw new AppError(502, "Storage authorization returned an invalid response"); + await assertOwner(); + await saveUnlocked({ ...data, refresh_token: data.refresh_token || refresh }); + return cached.token; + }); + try { + return await refreshing; + } finally { + refreshing = undefined; + } + } + return { + getAccessToken, + save, + clearMemory: () => { + cached = undefined; + }, + }; +} + +export const storageTokens = createTokenStore(); diff --git a/server/services/uploads.js b/server/services/uploads.js new file mode 100644 index 00000000..4bf588a5 --- /dev/null +++ b/server/services/uploads.js @@ -0,0 +1,611 @@ +import fs from "node:fs/promises"; +import { createReadStream, constants } from "node:fs"; +import path from "node:path"; +import { randomUUID, createHash } from "node:crypto"; +import mongoose from "mongoose"; +import { OperationModel, StorageLease } from "../modules/operation/operation.model.js"; +import Contribution from "../modules/contribution/contribution.model.js"; +import { FileModel, FolderModel } from "../modules/course/course.model.js"; +import { uploadLimits, uploadDirectory } from "../config/storage.js"; +import { actorFor, requireFolder } from "./authorization.js"; +import { operationActor } from "./deletions.js"; +import { relatedCourses } from "./contentMutation.js"; +import { withCourseLocks, assertCourseWritable, releaseCourseLocks } from "./courseLocks.js"; +import { acquireStorageLease, releaseStorageLease } from "./storageLeases.js"; +import { storage } from "./storage.js"; +import AppError from "../utils/appError.js"; + +export function validateManifest(manifest) { + if (!Array.isArray(manifest) || !manifest.length) + throw new AppError(400, "Choose at least one file", "INVALID_MANIFEST"); + if (manifest.length > uploadLimits.files) + throw new AppError(413, "Choose no more than 40 files", "BATCH_TOO_LARGE"); + let total = 0; + return manifest.map((entry) => { + if (!entry || Object.keys(entry).some((key) => !["name", "size"].includes(key))) + throw new AppError(400, "Invalid file manifest", "INVALID_MANIFEST"); + const { name, size } = entry; + if ( + typeof name !== "string" || + !name.trim() || + Buffer.byteLength(name) > 240 || + /[\\/:*?"<>|\x00-\x1f\x7f]/.test(name) || + /[. ]$/.test(name) || + [".", ".."].includes(name) || + /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(name) + ) + throw new AppError(400, "A file has an unsupported name", "INVALID_FILENAME"); + if (!Number.isSafeInteger(size) || size < 1) + throw new AppError( + 400, + "Files must contain data and have a valid byte size", + "INVALID_FILE_SIZE", + ); + if (size > uploadLimits.fileBytes || (total += size) > uploadLimits.batchBytes) + throw new AppError( + 413, + "Uploads allow 100 MiB per file and 1 GiB per batch", + "BATCH_TOO_LARGE", + ); + const extension = path.extname(name); + return { + id: randomUUID(), + name, + size, + fileId: new mongoose.Types.ObjectId(), + state: "pending", + remoteName: randomUUID() + (/^\.[a-zA-Z0-9]{1,15}$/.test(extension) ? extension : ""), + }; + }); +} + +export function temporaryPath(name) { + if (typeof name !== "string" || !/^[a-f0-9-]{36}\.upload$/.test(name)) + throw new AppError(400, "Invalid temporary upload identity"); + const file = path.resolve(uploadDirectory(), name); + if (path.dirname(file) !== uploadDirectory()) + throw new AppError(400, "Invalid temporary upload path"); + return file; +} +export async function removeTemporary(name) { + if (!name) return; + await fs.unlink(temporaryPath(name)).catch((error) => { + if (error.code !== "ENOENT") throw error; + }); +} + +async function ensureContribution(operation) { + await Contribution.updateOne( + { _id: operation.target.contributionId }, + { + $setOnInsert: { + contributionId: operation._id, + operationId: operation._id, + uploadedBy: String(operation.actorId), + parentFolder: operation.target.folderId, + courseCode: operation.target.code, + description: operation.target.description, + approved: false, + files: [], + }, + }, + { upsert: true }, + ); +} + +export async function createUpload(req) { + if ( + Object.keys(req.body || {}).some( + (key) => !["parentFolder", "courseCode", "description", "manifest"].includes(key), + ) + ) + throw new AppError(400, "Unexpected contribution fields"); + const entries = validateManifest(req.body.manifest); + const description = req.body.description || ""; + if (typeof description !== "string" || description.length > 2000) + throw new AppError(400, "Invalid description"); + const context = await requireFolder( + req, + req.body.parentFolder, + req.body.courseCode, + "canContribute", + ); + if (context.folder.childType !== "File") throw new AppError(400, "Choose a file folder"); + await assertCourseWritable(context.code); + const actor = await actorFor(req); + const key = req.headers["idempotency-key"]; + if (typeof key !== "string" || !/^[a-zA-Z0-9-]{16,100}$/.test(key)) + throw new AppError(400, "An upload request identity is required", "INVALID_REQUEST_KEY"); + let operation = await OperationModel.findOne({ actorId: actor.id, requestKey: key }); + if (!operation) { + try { + operation = await OperationModel.create({ + _id: randomUUID(), + kind: "upload", + actorId: actor.id, + actorRole: actor.admin ? "admin" : "student", + requestKey: key, + status: "awaiting", + entries, + courses: await relatedCourses(req, [context.code]), + target: { + folderId: String(context.folder._id), + code: context.code, + description, + contributionId: new mongoose.Types.ObjectId().toString(), + contributorName: req.admin?.userId || req.user.name, + }, + }); + } catch (error) { + if (error.code !== 11000) throw error; + operation = await OperationModel.findOne({ actorId: actor.id, requestKey: key }); + } + } + if ( + operation.kind !== "upload" || + operation.target.folderId !== String(context.folder._id) || + operation.target.code !== context.code || + JSON.stringify(operation.entries.map(({ name, size }) => ({ name, size }))) !== + JSON.stringify(entries.map(({ name, size }) => ({ name, size }))) + ) + throw new AppError( + 409, + "This upload identity already belongs to a different batch", + "UPLOAD_CONFLICT", + ); + await ensureContribution(operation); + return operation; +} + +export async function ownUpload(req, id) { + if (typeof id !== "string" || id.length > 100) + throw new AppError(404, "Contribution not found"); + const operation = await OperationModel.findOne({ + _id: id, + kind: "upload", + actorId: (await actorFor(req)).id, + }); + if (!operation) throw new AppError(404, "Contribution not found"); + const context = await requireFolder( + req, + operation.target.folderId, + operation.target.code, + "canContribute", + ); + if (context.folder.childType !== "File") throw new AppError(400, "Choose a file folder"); + await assertCourseWritable(context.code); + return operation; +} + +export async function reserveUpload(req) { + const operation = await ownUpload(req, req.headers["contribution-id"]); + const id = req.headers["upload-file-id"]; + const index = operation.entries.findIndex((entry) => entry.id === id); + if (index < 0) throw new AppError(404, "Upload file not found"); + if (operation.cancelRequested) + throw new AppError(409, "This upload was cancelled", "UPLOAD_CANCELLED"); + const entry = operation.entries[index]; + if (["queued", "uploading", "publishing", "completed"].includes(entry.state)) + return { operation, entry, existing: true }; + const token = randomUUID(); + const temporaryName = randomUUID() + ".upload"; + await acquireStorageLease("receive", token, 8, null); + let saved; + try { + saved = await OperationModel.updateOne( + { + _id: operation._id, + cancelRequested: false, + receivingCount: { $lt: uploadLimits.concurrentFiles }, + entries: { $elemMatch: { id, state: { $in: ["pending", "failed"] } } }, + }, + { + $set: { + [`entries.${index}.state`]: "receiving", + [`entries.${index}.receiveToken`]: token, + [`entries.${index}.temporaryName`]: temporaryName, + [`entries.${index}.receiveUntil`]: new Date(Date.now() + 15 * 60000), + }, + $inc: { receivingCount: 1, revision: 1 }, + }, + ); + } catch (error) { + await releaseStorageLease(token); + throw error; + } + if (!saved.modifiedCount) { + await releaseStorageLease(token); + throw new AppError( + 409, + "Two files are already being received; retry shortly", + "UPLOAD_BUSY", + ); + } + if (!(await StorageLease.exists({ owner: token }))) { + await receiveFailed({ operation, entry, index, token, temporaryName }); + throw new AppError(409, "Upload reservation expired; retry this file", "UPLOAD_BUSY"); + } + return { operation, entry, index, token, temporaryName }; +} + +export async function receiveFailed(reservation, error) { + const { operation, entry, index, token, temporaryName } = reservation; + await removeTemporary(temporaryName); + await releaseStorageLease(token); + await OperationModel.updateOne( + { + _id: operation._id, + entries: { $elemMatch: { id: entry.id, receiveToken: token, state: "receiving" } }, + }, + { + $set: { + [`entries.${index}.state`]: "failed", + [`entries.${index}.error`]: { + code: error?.code || "UPLOAD_INTERRUPTED", + message: "The file was not received. Retry this file.", + }, + }, + $inc: { receivingCount: -1, revision: 1 }, + }, + ); +} + +export async function receiveComplete(reservation, file) { + const { operation, entry, index, token, temporaryName } = reservation; + if ( + !file || + file.path !== temporaryPath(temporaryName) || + file.originalname !== entry.name || + file.size !== entry.size + ) + throw new AppError( + 400, + "The received file does not match its manifest", + "UPLOAD_SIZE_MISMATCH", + ); + const hash = createHash("sha256"); + for await (const bytes of createReadStream(file.path, { + flags: constants.O_RDONLY | constants.O_NOFOLLOW, + })) + hash.update(bytes); + const sha256 = hash.digest("hex"); + if (entry.sha256 && entry.sha256 !== sha256) + throw new AppError(409, "Retry with the same file contents", "UPLOAD_CONTENT_CHANGED"); + const result = await OperationModel.updateOne( + { + _id: operation._id, + cancelRequested: false, + entries: { $elemMatch: { id: entry.id, receiveToken: token, state: "receiving" } }, + }, + { + $set: { + [`entries.${index}.state`]: "queued", + [`entries.${index}.sha256`]: sha256, + [`entries.${index}.receivedAt`]: new Date(), + status: "queued", + nextRunAt: new Date(), + }, + $unset: { [`entries.${index}.error`]: 1 }, + $inc: { receivingCount: -1, revision: 1 }, + }, + ); + if (!result.modifiedCount) + throw new AppError(409, "The upload was cancelled or interrupted", "UPLOAD_CANCELLED"); +} + +async function publishFile(operation, entry, checkpoint) { + const existing = await FileModel.findById(entry.fileId); + if (existing?.resourceState === "ready" && existing.uploadOperation === operation._id) return; + const currentRequest = await operationActor(operation); + const courses = await relatedCourses(currentRequest, [operation.target.code]); + await checkpoint(); + await OperationModel.updateOne( + { _id: operation._id, leaseToken: operation.leaseToken }, + { $set: { courses } }, + ); + await withCourseLocks( + courses, + async (lock) => { + const req = await operationActor(operation); + const context = await requireFolder( + req, + operation.target.folderId, + operation.target.code, + "canContribute", + ); + const currentCourses = await relatedCourses(req, [operation.target.code]); + if (currentCourses.some((code) => !courses.includes(code))) + throw new AppError( + 409, + "Course sharing changed. Retry the unfinished file.", + "COURSE_BUSY", + ); + await checkpoint(); + await lock.assertHeld(); + await ensureContribution(operation); + await FileModel.updateOne( + { _id: entry.fileId }, + { + $setOnInsert: { + name: entry.name, + contributorName: operation.target.contributorName, + size: String(entry.size), + sizeBytes: entry.size, + fileId: entry.providerId, + resourceState: "uploading", + uploadOperation: operation._id, + isVerified: context.capabilities.canManage, + }, + }, + { upsert: true, runValidators: true }, + ); + await checkpoint(); + await lock.assertHeld(); + const parent = await FolderModel.updateOne( + { _id: operation.target.folderId, deletingOperation: { $exists: false } }, + { $addToSet: { children: entry.fileId } }, + ); + if (!parent.matchedCount) + throw new AppError( + 404, + "The upload folder is no longer available", + "UPLOAD_TARGET_REMOVED", + ); + await Contribution.updateOne( + { _id: operation.target.contributionId }, + { $addToSet: { files: entry.fileId } }, + ); + await checkpoint(); + await lock.assertHeld(); + await FileModel.updateOne( + { _id: entry.fileId, uploadOperation: operation._id }, + { $set: { resourceState: "ready", isVerified: context.capabilities.canManage } }, + ); + const saved = await Contribution.findById(operation.target.contributionId).populate( + "files", + ); + await Contribution.updateOne( + { _id: saved._id }, + { + $set: { + approved: + saved.files.length > 0 && saved.files.every((file) => file.isVerified), + }, + }, + ); + }, + { owner: operation._id, durable: true }, + ); +} + +async function cleanupUnpublished(operation, entry) { + const file = await FileModel.findById(entry.fileId); + if (file?.resourceState === "ready") return true; + if (!file && !entry.providerId && !entry.sessionUrl && !entry.sha256) return false; + await storage.cancelSession(entry.sessionUrl); + const remote = entry.providerId + ? { id: entry.providerId } + : await storage.findUpload(entry.remoteName, entry.size); + if (remote) await storage.remove(remote.id); + if (file) { + await FolderModel.updateMany( + { children: entry.fileId }, + { $pull: { children: entry.fileId } }, + ); + await Contribution.updateMany({ files: entry.fileId }, { $pull: { files: entry.fileId } }); + await FileModel.deleteOne({ + _id: entry.fileId, + uploadOperation: operation._id, + resourceState: "uploading", + }); + } + return false; +} + +export async function runUploads(operation, checkpoint) { + let publication = Promise.resolve(); + const update = (index, fields) => + OperationModel.updateOne( + { _id: operation._id, leaseToken: operation.leaseToken }, + { + $set: Object.fromEntries( + Object.entries(fields).map(([key, value]) => [ + `entries.${index}.${key}`, + value, + ]), + ), + $inc: { revision: 1 }, + }, + ); + const processFile = async (entry, index) => { + let current = await OperationModel.findById(operation._id); + const cancelled = current.cancelRequested; + if ( + entry.state === "completed" || + entry.state === "cancelled" || + entry.state === "receiving" + ) + return; + if (!cancelled && !["queued", "uploading", "publishing", "cleanup"].includes(entry.state)) + return; + try { + await checkpoint(); + if (entry.state === "cleanup") { + const published = await cleanupUnpublished(operation, entry); + await update(index, { + state: published ? "completed" : "failed", + published, + providerId: published ? entry.providerId : null, + sessionUrl: null, + error: published + ? undefined + : { + code: "UPLOAD_TARGET_UNAVAILABLE", + message: + "This destination is no longer available. Unpublished storage was removed.", + }, + }); + return; + } + if (cancelled) { + const published = await cleanupUnpublished(operation, entry); + await update(index, { state: published ? "completed" : "cancelled", published }); + return; + } + await update(index, { state: "uploading" }); + const checkCancelled = async () => { + await checkpoint(); + const data = await OperationModel.findById(operation._id) + .select("cancelRequested") + .lean(); + if (data.cancelRequested) + throw new AppError(409, "Upload cancelled", "UPLOAD_CANCELLED"); + }; + const ready = await FileModel.findById(entry.fileId); + if (ready?.resourceState === "ready" && ready.uploadOperation === operation._id) { + await update(index, { + state: "completed", + published: true, + uploadedBytes: entry.size, + }); + return; + } + const remote = entry.providerId + ? { id: entry.providerId } + : await storage.upload({ + filename: temporaryPath(entry.temporaryName), + remoteName: entry.remoteName, + size: entry.size, + sessionUrl: entry.sessionUrl, + checkCancelled, + onSession: (sessionUrl) => { + entry.sessionUrl = sessionUrl; + return update(index, { sessionUrl }); + }, + onProgress: (uploadedBytes) => update(index, { uploadedBytes }), + }); + entry.providerId = remote.id; + await update(index, { providerId: remote.id, state: "publishing" }); + await checkCancelled(); + const publish = publication.then(() => publishFile(operation, entry, checkpoint)); + publication = publish.catch(() => {}); + await publish; + await update(index, { state: "completed", published: true, uploadedBytes: entry.size }); + } catch (error) { + await checkpoint(); + current = await OperationModel.findById(operation._id); + if (current.cancelRequested || error.code === "UPLOAD_CANCELLED") { + const published = await cleanupUnpublished(operation, entry); + await update(index, { state: published ? "completed" : "cancelled", published }); + } else { + if (error instanceof AppError && [403, 404].includes(error.status)) { + await update(index, { + state: "cleanup", + error: { + code: "UPLOAD_TARGET_UNAVAILABLE", + message: + "This destination is no longer available. Unpublished storage is being cleaned up.", + }, + }); + const published = await cleanupUnpublished(operation, entry); + await update(index, { + state: published ? "completed" : "failed", + published, + providerId: published ? entry.providerId : null, + sessionUrl: null, + error: published + ? undefined + : { + code: "UPLOAD_TARGET_UNAVAILABLE", + message: + "This destination is no longer available. Unpublished storage was removed.", + }, + }); + return; + } + await update(index, { + state: "failed", + error: { + code: error.code || "UPLOAD_FAILED", + message: "This file could not be uploaded. Retry this file.", + }, + }); + } + } finally { + await checkpoint(); + await removeTemporary(entry.temporaryName); + await releaseStorageLease(entry.receiveToken); + } + }; + const work = operation.entries.map((entry, index) => ({ entry, index })); + const outcomes = await Promise.allSettled( + Array.from({ length: uploadLimits.concurrentFiles }, async () => { + while (work.length) { + const { entry, index } = work.shift(); + await processFile(entry, index); + } + }), + ); + const failure = outcomes.find((result) => result.status === "rejected"); + if (failure) throw failure.reason; + for (;;) { + await checkpoint(); + const latest = await OperationModel.findById(operation._id); + const states = latest.entries.map((entry) => entry.state); + const status = states.every((state) => state === "completed") + ? "completed" + : latest.cancelRequested + ? states.includes("receiving") + ? "cancelling" + : "cancelled" + : states.some((state) => + ["queued", "uploading", "publishing", "cleanup"].includes(state), + ) + ? "queued" + : states.some((state) => ["pending", "receiving"].includes(state)) + ? "awaiting" + : states.includes("completed") + ? "partial" + : "failed"; + const finished = await OperationModel.updateOne( + { + _id: operation._id, + leaseToken: operation.leaseToken, + revision: latest.revision, + cancelRequested: latest.cancelRequested, + }, + { + $set: { status, nextRunAt: new Date(Date.now() + 1000) }, + $unset: { leaseToken: 1, leaseUntil: 1, error: 1 }, + }, + ); + if (finished.modifiedCount) { + await releaseCourseLocks(operation._id); + if (["cancelled", "failed"].includes(status)) + await Contribution.deleteOne({ + _id: operation.target.contributionId, + operationId: operation._id, + files: { $size: 0 }, + }); + break; + } + } +} + +export async function recoverReceiving() { + const stale = await OperationModel.find({ + kind: "upload", + entries: { $elemMatch: { state: "receiving", receiveUntil: { $lte: new Date() } } }, + }); + for (const operation of stale) + for (const [index, entry] of operation.entries.entries()) { + if (entry.state !== "receiving" || entry.receiveUntil > new Date()) continue; + await receiveFailed({ + operation, + entry, + index, + token: entry.receiveToken, + temporaryName: entry.temporaryName, + }); + } +} diff --git a/server/test/browser/library-access.test.js b/server/test/browser/library-access.test.js index 67d49444..cdd80a19 100644 --- a/server/test/browser/library-access.test.js +++ b/server/test/browser/library-access.test.js @@ -45,6 +45,7 @@ async function openPage( const page = await context.newPage(); const errors = []; const requests = []; + let operation; page.on("pageerror", (error) => errors.push(error.message)); t.after(async () => { if (process.env.BROWSER_ARTIFACT_DIR) { @@ -134,19 +135,39 @@ async function openPage( data = { unverifiedContributions: moderationQueue }; else if (url.pathname.startsWith("/api/files/verify/")) data = { file: { ...libraryFile, isVerified: true } }; + else if (url.pathname === "/api/contribution/limits") + data = { fileBytes: 104857600, batchBytes: 1073741824, files: 40, concurrentFiles: 2 }; + else if (url.pathname === "/api/operations") + data = { items: [], page: 1, pageSize: 20, total: 0 }; + else if (url.pathname.startsWith("/api/operations/")) data = operation; else if (url.pathname === "/api/contribution/") { - data = - request.method() === "POST" - ? { - created: true, - data: { - ...JSON.parse(request.postData()), - contributionId: "server-issued-contribution", - }, - } - : []; - } else if (url.pathname === "/api/contribution/upload") data = libraryFile._id; - else if (url.pathname === "/api/files/download") + if (request.method() === "POST") { + status = 201; + operation = { + id: "server-issued-contribution", + kind: "upload", + name: "File upload", + status: "awaiting", + canCancel: true, + entries: JSON.parse(request.postData()).manifest.map((file, index) => ({ + ...file, + id: "entry-" + index, + state: "pending", + uploadedBytes: 0, + })), + }; + data = operation; + } else data = []; + } else if (url.pathname === "/api/contribution/upload") { + status = 202; + operation.status = "completed"; + operation.canCancel = false; + operation.entries.forEach((entry) => { + entry.state = "completed"; + entry.uploadedBytes = entry.size; + }); + data = { operationId: operation.id }; + } else if (url.pathname === "/api/files/download") data = { downloadLink: `/api/files/content/${libraryFile._id}?download=1` }; else if (url.pathname === `/api/files/content/${libraryFile._id}`) { assert.ok(hasSession); @@ -258,6 +279,11 @@ test("FilePond sends credentials and the contribution association across origins assert.ok(created?.hasSession && uploaded?.hasSession); const manifest = JSON.parse(created.body); assert.equal(uploaded.headers["contribution-id"], "server-issued-contribution"); + assert.match(created.headers["idempotency-key"], /^[a-f0-9-]{36}$/); + assert.equal(uploaded.headers["upload-file-id"], "entry-0"); + assert.deepEqual(manifest.manifest, [ + { name: "synthetic-notes.pdf", size: Buffer.byteLength("%PDF-1.4\nTest notes\n%%EOF") }, + ]); assert.equal(manifest.contributionId, undefined); assert.equal(manifest.uploadedBy, undefined); assert.equal(manifest.approved, undefined); diff --git a/server/test/browser/operations.test.js b/server/test/browser/operations.test.js new file mode 100644 index 00000000..c96634b7 --- /dev/null +++ b/server/test/browser/operations.test.js @@ -0,0 +1,386 @@ +import assert from "node:assert/strict"; +import { test, before, after } from "node:test"; +import { createRequire } from "node:module"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { student, course, folder } from "../fixtures/library.js"; +import { uploadOperation, deletionOperation } from "../fixtures/operations.js"; +const { chromium } = createRequire(import.meta.url)(process.env.PLAYWRIGHT_MODULE || "playwright"); +const frontend = process.env.BROWSER_CLIENT_ORIGIN || "http://127.0.0.1:4186"; +const adminOrigin = process.env.BROWSER_ADMIN_ORIGIN || "http://127.0.0.1:4184"; +const api = process.env.BROWSER_API_ORIGIN || "http://127.0.0.1:4185"; +const csrf = "c".repeat(43); +let browser; +before(async () => { + browser = await chromium.launch({ + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined, + }); +}); +after(() => browser?.close()); + +async function pageFor(t, width, { admin = false, resumed = true, manager = false } = {}) { + const presentedFolder = manager + ? { ...folder, capabilities: { ...folder.capabilities, canManage: true } } + : folder; + const context = await browser.newContext({ + viewport: { width, height: 900 }, + locale: "en-US", + timezoneId: "Asia/Kolkata", + serviceWorkers: "block", + }); + await context.addCookies([ + { + name: admin ? "adminToken" : "token", + value: "synthetic-session", + url: admin ? adminOrigin : api, + httpOnly: true, + sameSite: "Lax", + }, + ]); + const page = await context.newPage(); + await page.clock.setFixedTime(new Date("2026-09-08T06:30:00Z")); + const errors = [], + requests = []; + const state = { + operation: admin ? deletionOperation() : resumed ? uploadOperation() : null, + failStatus: false, + uploadCalls: [], + retryCalls: 0, + }; + if (manager) { + state.operation.canCancel = false; + state.operation.canRetry = false; + } + page.on("pageerror", (error) => errors.push(error.message)); + t.after(async () => { + if (process.env.BROWSER_ARTIFACT_DIR) + await fs.writeFile( + path.join( + process.env.BROWSER_ARTIFACT_DIR, + t.name.replace(/[^a-z0-9]+/gi, "-") + ".json", + ), + JSON.stringify( + { text: await page.locator("body").innerText(), requests, errors }, + null, + 2, + ), + ); + await context.close(); + assert.deepEqual(errors, []); + }); + await context.route("**/*", async (route) => { + const req = route.request(), + url = new URL(req.url()); + if ([frontend, adminOrigin].includes(url.origin) && !url.pathname.startsWith("/api/")) + return route.continue(); + if (![api, adminOrigin].includes(url.origin)) return route.abort(); + const headers = await req.allHeaders(); + requests.push({ path: url.pathname, method: req.method(), headers, body: req.postData() }); + let data = {}, + status = 200; + if (url.pathname === "/api/user") + data = { + ...student, + isBR: manager, + capabilities: { + ...student.capabilities, + canManageCourses: manager ? ["CS101"] : [], + }, + csrfToken: csrf, + }; + else if (url.pathname === "/api/admin/") data = { user: { userId: "audit-admin" } }; + else if (url.pathname === "/api/auth/csrf") data = { csrfToken: csrf }; + else if (url.pathname === "/api/course/CS101") + data = { + found: true, + ...course, + children: course.children.map((year) => ({ ...year, children: [presentedFolder] })), + }; + else if (url.pathname === "/api/admin/course/cs101/dashboard") + data = { + course, + studentCount: 24, + contributions: [ + { + contributionId: "shared-review", + approved: false, + files: [ + { + name: "Shared lecture notes.pdf", + affectedCourses: ["CS101", "MA101"], + }, + ], + }, + ], + }; + else if (url.pathname === "/api/admin/contribution/action") { + const body = JSON.parse(req.postData()); + assert.deepEqual(body.affectedCourses, ["CS101", "MA101"]); + state.operation = deletionOperation("queued"); + status = 202; + data = { operationId: state.operation.id }; + } else if (url.pathname.startsWith("/api/folder/content/")) data = presentedFolder; + else if (url.pathname === "/api/contribution/limits") { + status = state.limitsFail ? 503 : 200; + data = state.limitsFail + ? { message: "Upload limits are unavailable" } + : { files: 40, fileBytes: 104857600, batchBytes: 1073741824, concurrentFiles: 2 }; + } else if (url.pathname === "/api/contribution/") { + if (req.method() === "POST") { + status = 201; + state.operation = uploadOperation("awaiting"); + state.operation.entries = JSON.parse(req.postData()).manifest.map( + (file, index) => ({ + ...file, + id: `file-${index}`, + state: "pending", + uploadedBytes: 0, + }), + ); + data = state.operation; + } else data = []; + } else if (url.pathname === "/api/operations") { + if (state.holdListings) await state.holdListings; + data = { + items: state.operation ? [state.operation] : [], + total: state.operation ? 1 : 0, + page: 1, + pageSize: 20, + }; + if (state.failStatus) { + status = 503; + data = { message: "Status is temporarily unavailable" }; + } + } else if (url.pathname.endsWith("/retry")) { + assert.equal(headers["x-csrf-token"], csrf); + state.retryCalls++; + status = 202; + if (admin) state.operation = deletionOperation("queued"); + else { + state.operation.status = "awaiting"; + state.operation.entries.forEach((entry) => { + if (entry.state === "failed") entry.state = "pending"; + }); + } + data = state.operation; + } else if (url.pathname.endsWith("/cancel")) { + assert.equal(headers["x-csrf-token"], csrf); + status = 202; + state.operation.status = "cancelled"; + state.operation.canCancel = false; + state.operation.entries.forEach((entry) => { + if (entry.state !== "completed") entry.state = "cancelled"; + }); + data = state.operation; + } else if (url.pathname.startsWith("/api/operations/")) data = state.operation; + else if (url.pathname === "/api/contribution/upload") { + assert.ok(headers.cookie?.includes("token=synthetic-session")); + assert.equal(headers["x-csrf-token"], csrf); + const entry = state.operation.entries.find( + (entry) => entry.id === headers["upload-file-id"], + ); + assert.ok(entry); + state.uploadCalls.push(entry.id); + entry.state = "completed"; + entry.uploadedBytes = entry.size; + delete entry.error; + state.operation.status = state.operation.entries.every( + (file) => file.state === "completed", + ) + ? "completed" + : "awaiting"; + state.operation.canCancel = state.operation.status !== "completed"; + status = 202; + data = { operationId: state.operation.id }; + } else if (url.pathname === "/api/contribution/br") data = { unverifiedContributions: [] }; + else { + status = 404; + data = { message: "Fixture resource is unavailable" }; + } + await route + .fulfill({ + status, + contentType: "application/json", + headers: { + "access-control-allow-origin": admin ? adminOrigin : frontend, + "access-control-allow-credentials": "true", + }, + body: JSON.stringify(data), + }) + .catch(() => {}); + }); + return { page, state, requests }; +} + +async function capture(page, name) { + if (!process.env.BROWSER_ARTIFACT_DIR) return; + await fs.mkdir(process.env.BROWSER_ARTIFACT_DIR, { recursive: true }); + await page.screenshot({ + path: path.join(process.env.BROWSER_ARTIFACT_DIR, name + ".png"), + fullPage: true, + animations: "disabled", + }); +} + +for (const width of [320, 390, 768, 1024, 1440]) { + test(`resumed upload retains successful files and retries only the selected failed file at ${width}px`, async (t) => { + const { page, state } = await pageFor(t, width); + await page.goto(`${frontend}/browse/CS101/${folder._id}?upload=${state.operation.id}`); + await page.getByText("1 of 2 files uploaded", { exact: true }).waitFor(); + await capture(page, `upload-partial-${width}`); + const metrics = await page.evaluate(() => ({ + viewport: innerWidth, + content: document.documentElement.scrollWidth, + })); + assert.ok(metrics.content <= metrics.viewport + 1, JSON.stringify(metrics)); + const failed = state.operation.entries[1]; + await page.locator(".filepond--browser").setInputFiles({ + name: failed.name, + mimeType: "application/pdf", + buffer: Buffer.from("12345"), + }); + const button = page.getByRole("button", { name: "RETRY FAILED FILES", exact: true }); + await page.waitForFunction( + () => document.querySelector(".contri .upload-actions .button")?.disabled === false, + ); + await button.focus(); + await page.keyboard.press("Enter"); + await page.getByText("2 of 2 files uploaded", { exact: true }).waitFor(); + await page.getByRole("button", { name: "Close", exact: true }).waitFor(); + assert.equal(state.retryCalls, 1); + assert.deepEqual(state.uploadCalls, [failed.id]); + await capture(page, `upload-completed-${width}`); + }); + test(`administrator reviews failed cleanup and retries persisted work at ${width}px`, async (t) => { + const { page, state, requests } = await pageFor(t, width, { admin: true }); + await page.goto(adminOrigin + "/admin/operations"); + await page + .getByText("Thumbnail cleanup could not finish. Completed steps are preserved.") + .waitFor(); + await capture(page, `admin-operations-failed-${width}`); + await page.getByRole("button", { name: "Retry unfinished work" }).click(); + await page.locator("li").getByText("queued", { exact: true }).waitFor(); + state.operation = deletionOperation("completed"); + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await page.locator("li").getByText("completed", { exact: true }).waitFor(); + assert.equal(state.retryCalls, 1); + assert.ok( + requests.some( + (request) => + request.path.endsWith("/retry") && + request.headers["x-session-role"] === "admin", + ), + ); + assert.equal(await page.getByRole("button", { name: "Retry unfinished work" }).count(), 0); + const metrics = await page.evaluate(() => ({ + viewport: innerWidth, + content: document.documentElement.scrollWidth, + })); + assert.ok(metrics.content <= metrics.viewport + 1, JSON.stringify(metrics)); + }); +} +for (const width of [390, 1440]) { + test(`operation loading and valid empty results remain distinct at ${width}px`, async (t) => { + const { page, state } = await pageFor(t, width, { admin: true }); + let release; + state.holdListings = new Promise((resolve) => { + release = resolve; + }); + await page.goto(adminOrigin + "/admin/operations"); + await page.getByText("Updating operations…", { exact: true }).waitFor(); + await capture(page, `admin-operations-loading-${width}`); + state.operation = null; + release(); + await page.getByText("No operations match this status.", { exact: true }).waitFor(); + await capture(page, `admin-operations-empty-${width}`); + }); + test(`upload limits failure is recoverable without losing the resumed batch at ${width}px`, async (t) => { + const { page, state } = await pageFor(t, width); + state.limitsFail = true; + await page.goto(`${frontend}/browse/CS101/${folder._id}?upload=${state.operation.id}`); + await page + .getByText("Upload limits are unavailable. Please retry.", { exact: true }) + .waitFor(); + await capture(page, `upload-limits-error-${width}`); + state.limitsFail = false; + await page.getByRole("button", { name: "Retry upload limits" }).click(); + await page + .getByRole("button", { name: "Retry upload limits" }) + .waitFor({ state: "hidden" }); + assert.ok(await page.getByText("1 of 2 files uploaded", { exact: true }).isVisible()); + }); + test(`server upload progress remains visible and cancellation can be requested at ${width}px`, async (t) => { + const { page, state } = await pageFor(t, width); + state.operation.status = "running"; + state.operation.entries[1].state = "uploading"; + state.operation.entries[1].uploadedBytes = 2; + delete state.operation.entries[1].error; + await page.goto(`${frontend}/browse/CS101/${folder._id}?upload=${state.operation.id}`); + await page.getByRole("progressbar").waitFor(); + await capture(page, `upload-progress-${width}`); + await page.getByRole("button", { name: "Cancel remaining uploads" }).click(); + await page.locator(".upload-results").getByText("Cancelled", { exact: true }).waitFor(); + assert.equal(state.operation.entries[0].state, "completed"); + }); +} + +test("a course manager can inspect another uploader's results without resuming or cancelling their batch", async (t) => { + const { page, state } = await pageFor(t, 390, { manager: true }); + await page.goto(`${frontend}/browse/CS101/${folder._id}?upload=${state.operation.id}`); + await page.getByText("1 of 2 files uploaded", { exact: true }).waitFor(); + assert.equal( + await page.getByRole("button", { name: "RETRY FAILED FILES", exact: true }).count(), + 0, + ); + assert.equal( + await page.getByRole("button", { name: "Cancel remaining uploads", exact: true }).count(), + 0, + ); + assert.equal(await page.locator(".filepond--browser").count(), 0); + await capture(page, "upload-manager-review-390"); +}); + +test("cancelling a resumed partial batch keeps successful results visible", async (t) => { + const { page, state } = await pageFor(t, 390); + await page.goto(`${frontend}/browse/CS101/${folder._id}?upload=${state.operation.id}`); + await page.getByRole("button", { name: "Cancel remaining uploads" }).click(); + await page.locator(".upload-results").getByText("Cancelled", { exact: true }).waitFor(); + assert.equal(state.operation.entries[0].state, "completed"); + await page.getByText("1 of 2 files uploaded", { exact: true }).waitFor(); + await capture(page, "upload-cancelled-390"); +}); +test("shared contribution rejection confirms affected courses and waits for actual cleanup completion", async (t) => { + const { page, state } = await pageFor(t, 1440, { admin: true }); + const dialogs = []; + page.on("dialog", async (dialog) => { + dialogs.push({ type: dialog.type(), message: dialog.message() }); + await dialog.accept(); + }); + await page.goto(adminOrigin + "/admin/courses/CS101"); + await page.getByRole("button", { name: "Reject", exact: true }).click(); + await page + .getByText("Cleanup is in progress. You can leave this page.", { exact: true }) + .waitFor(); + assert.equal(dialogs.length, 1); + assert.equal(dialogs[0].type, "confirm"); + assert.match(dialogs[0].message, /CS101, MA101/); + state.operation = deletionOperation("completed"); + await page.getByText("Cleanup completed.", { exact: true }).waitFor(); + assert.ok(dialogs.some((dialog) => dialog.type === "alert" && /Rejected/.test(dialog.message))); +}); +test("administrator status failure preserves the last results and offers an explicit retry", async (t) => { + const { page, state } = await pageFor(t, 390, { admin: true }); + await page.goto(adminOrigin + "/admin/operations"); + await page + .getByText("Thumbnail cleanup could not finish. Completed steps are preserved.") + .waitFor(); + state.failStatus = true; + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await page.getByRole("alert").waitFor(); + assert.ok(await page.getByText("Shared lecture notes.pdf", { exact: false }).isVisible()); + await capture(page, "admin-operations-error-390"); + state.failStatus = false; + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await page.getByRole("alert").waitFor({ state: "hidden" }); +}); diff --git a/server/test/browser/session-profile.test.js b/server/test/browser/session-profile.test.js index 7ac33c3b..a5f5bc2a 100644 --- a/server/test/browser/session-profile.test.js +++ b/server/test/browser/session-profile.test.js @@ -66,6 +66,7 @@ async function studentPage( : { message: "Sign in to continue" }; } else if (url.pathname === "/api/auth/csrf") data = { csrfToken: csrf }; else if (url.pathname === "/api/contribution/") data = []; + else if (url.pathname === "/api/operations") data = { items: [], total: 0 }; else if (url.pathname === "/api/user/update") { assert.ok(headers.cookie?.includes("token=synthetic-session")); assert.equal(headers.authorization, undefined); diff --git a/server/test/fixtures/operations.js b/server/test/fixtures/operations.js new file mode 100644 index 00000000..579153af --- /dev/null +++ b/server/test/fixtures/operations.js @@ -0,0 +1,66 @@ +import { folder } from "./library.js"; + +export function uploadOperation(status = "partial") { + return { + id: "88718faa-8b50-492e-ae06-0a332dcb808b", + kind: "upload", + name: "File upload", + courseCode: "CS101", + folderId: folder._id, + affectedCourses: ["CS101"], + status, + canCancel: !["completed", "cancelled"].includes(status), + canRetry: status === "partial", + completedSteps: 0, + createdAt: "2026-09-08T06:30:00Z", + updatedAt: "2026-09-08T06:30:00Z", + entries: [ + { + id: "c0ddc5c4-a080-4c2c-8aa3-6ff47096caf3", + name: "Lecture notes.pdf", + size: 5, + uploadedBytes: 5, + state: "completed", + }, + { + id: "978915e6-2d0e-4345-bdd1-58f2b4c10f3e", + name: "Tutorial solutions with additional worked examples.pdf", + size: 5, + uploadedBytes: 0, + state: + status === "completed" + ? "completed" + : status === "cancelled" + ? "cancelled" + : "failed", + error: { + message: "The storage service could not finish this file. Retry this file.", + }, + }, + ], + }; +} + +export function deletionOperation(status = "failed") { + return { + id: "99433f37-fb89-4cac-a4dc-d1a6f7619eb4", + kind: "delete", + name: "Shared lecture notes.pdf", + courseCode: "CS101", + affectedCourses: ["CS101", "MA101"], + status, + entries: [], + completedSteps: status === "completed" ? 4 : 1, + canRetry: status === "failed", + canCancel: false, + createdAt: "2026-09-08T06:30:00Z", + updatedAt: "2026-09-08T06:30:00Z", + error: + status === "failed" + ? { + code: "STORAGE_UNAVAILABLE", + message: "Thumbnail cleanup could not finish. Completed steps are preserved.", + } + : undefined, + }; +} diff --git a/server/test/integration/server.test.js b/server/test/integration/server.test.js index 1046853a..f3d4d121 100644 --- a/server/test/integration/server.test.js +++ b/server/test/integration/server.test.js @@ -12,13 +12,15 @@ import path from "node:path"; import axios from "axios"; import User from "../../modules/user/user.model.js"; import { Readable } from "node:stream"; +import { storage } from "../../services/storage.js"; +import { OperationModel } from "../../modules/operation/operation.model.js"; +import { processOperation } from "../../services/operationWorker.js"; import CourseAllotment from "../../modules/course/courseAllotment.model.js"; import { academicPeriod } from "../../services/authorization.js"; import UserUpdate from "../../modules/user/userUpdate.model.js"; import Course, { FolderModel, FileModel } from "../../modules/course/course.model.js"; import Contribution from "../../modules/contribution/contribution.model.js"; -import { upload } from "../../modules/contribution/contribution.routes.js"; -import { clearAccessTokenCache } from "../../modules/onedrive/onedrive.controller.js"; +import { upload } from "../../middleware/receiveUpload.js"; import { guardExternalServices } from "../support/provider-guards.js"; import { student, course, year, folder } from "../fixtures/library.js"; import { app } from "../../index.js"; @@ -55,6 +57,7 @@ before(async () => { ); await db.collection("_testRun").insertOne({ _id: "owner", token: owner }); ownsDatabase = true; + await OperationModel.createIndexes(); listener = app.listen(0, "127.0.0.1"); await once(listener, "listening"); @@ -289,138 +292,95 @@ test("authenticated browsing and multipart upload persist content with mocked Gr const unsigned = await fetch(origin + "/api/course/CS101"); assert.equal(unsigned.status, 401); + const contents = Buffer.from("%PDF-1.4\nSynthetic upload test\n%%EOF\n"); + const filename = "notes.pdf"; const created = await fetch(origin + "/api/contribution", { method: "POST", - headers: { ...headers, "content-type": "application/json" }, + headers: { + ...headers, + "content-type": "application/json", + "idempotency-key": randomUUID(), + }, body: JSON.stringify({ courseCode: "CS101", parentFolder: folder._id, description: "Test upload", + manifest: [0, 1].map(() => ({ name: filename, size: contents.length })), }), }); - assert.equal(created.status, 200); - const createdData = await created.json(); - assert.equal(createdData.created, true); - const contributionId = createdData.data.contributionId; - - clearAccessTokenCache(); - t.after(clearAccessTokenCache); - for (const method of ["existsSync", "readFileSync", "writeFileSync"]) { - const original = fs[method]; - t.mock.method(fs, method, (file, ...rest) => { - if (!String(file).endsWith(".token")) return original(file, ...rest); - if (method === "existsSync") return true; - if (method === "readFileSync") return "test-refresh-token"; - }); - } - const calls = []; - t.mock.method(axios, "post", async (url) => { - calls.push(url); - if (url.startsWith("https://login.microsoftonline.com/")) - return { - data: { - access_token: "test-access", - expires_in: 3600, - refresh_token: "test-refresh", - }, - }; - if (url.endsWith("/createUploadSession")) { - assert.ok(url.includes("/test-storage-root:/")); - return { data: { uploadUrl: "https://upload.example.test/session" } }; - } - if (url.endsWith("/createLink")) - return { data: { link: { webUrl: "https://example.test/notes" } } }; - assert.fail(`Unexpected mock Graph POST: ${url}`); + assert.equal(created.status, 201, await created.clone().text()); + const operation = await created.json(); + const remoteNames = []; + const temporaryPaths = []; + t.mock.method(storage, "upload", async (args) => { + remoteNames.push(args.remoteName); + temporaryPaths.push(args.filename); + assert.deepEqual(await fs.promises.readFile(args.filename), contents); + assert.match(path.basename(args.filename), /^[a-f0-9-]{36}\.upload$/); + return { id: "test-uploaded-drive-file-" + remoteNames.length }; }); - const contents = Buffer.from("%PDF-1.4\nSynthetic upload test\n%%EOF\n"); - let uploadedCount = 0; - t.mock.method(axios, "put", async (url, bytes, config) => { - calls.push(url); - assert.equal(url, "https://upload.example.test/session"); - assert.deepEqual(bytes, contents); - assert.equal( - config.headers["Content-Range"], - `bytes 0-${contents.length - 1}/${contents.length}`, - ); - return { - data: { - id: uploadedCount++ ? "second-uploaded-drive-file" : "test-uploaded-drive-file", - size: contents.length, + t.mock.method(storage, "content", async () => ({ + data: Readable.from([contents]), + headers: { "content-type": "application/pdf" }, + })); + for (const entry of operation.entries) { + const form = new FormData(); + form.append("file", new Blob([contents]), filename); + const response = await fetch(origin + "/api/contribution/upload", { + method: "POST", + headers: { + ...headers, + "contribution-id": operation.id, + "upload-file-id": entry.id, + username: "forged", }, - }; - }); - t.mock.method(axios, "get", async (url) => { - calls.push(url); - if (url.endsWith("/thumbnails")) return { data: { value: [] } }; - if (url.endsWith("/test-uploaded-drive-file/content")) - return { - data: Readable.from([contents]), - headers: { "content-type": "application/pdf" }, - }; - assert.fail(`Unexpected mock Graph GET: ${url}`); - }); - const temporaryPaths = []; - const handleFile = upload.storage._handleFile; - t.mock.method(upload.storage, "_handleFile", function (req, file, callback) { - handleFile.call(this, req, file, (error, info) => { - if (info?.path) temporaryPaths.push(info.path); - callback(error, info); + body: form, }); - }); - const filename = `test-${randomUUID()}.pdf`; - const renamedPath = path.join("external/uploads", filename.replace(".pdf", "~TestStudent.pdf")); - t.after(async () => { - for (const file of [...temporaryPaths, renamedPath]) - await fs.promises.rm(file, { force: true }); - }); - const form = new FormData(); - form.append("file", new Blob([contents], { type: "application/pdf" }), filename); - const uploaded = await fetch(origin + "/api/contribution/upload", { - method: "POST", - headers: { ...headers, "contribution-id": contributionId, username: "TestStudent" }, - body: form, - }); - assert.equal(uploaded.status, 200, await uploaded.clone().text()); - const id = await uploaded.text(); - const saved = await FileModel.findById(id); - assert.equal(saved.fileId, "test-uploaded-drive-file"); - assert.equal(saved.isVerified, false); - assert.equal(Number(saved.size), contents.length); - const savedContribution = await Contribution.findOne({ contributionId }); - assert.equal(savedContribution.uploadedBy, person.id); - assert.ok(savedContribution.files.some((file) => file.toString() === id)); - assert.ok( - (await FolderModel.findById(folder._id)).children.some((file) => file.toString() === id), - ); - assert.equal(temporaryPaths.length, 1); - assert.ok(temporaryPaths.every((file) => !fs.existsSync(file))); - assert.equal(fs.existsSync(renamedPath), false); - const download = await fetch(origin + `/api/files/content/${id}`, { - headers, - }); - assert.equal(download.status, 200); - assert.deepEqual(Buffer.from(await download.arrayBuffer()), contents); - assert.equal(saved.name, filename.replace(".pdf", "~Library Test Student.pdf")); - const secondForm = new FormData(); - secondForm.append("file", new Blob([contents], { type: "application/pdf" }), filename); - const secondUpload = await fetch(origin + "/api/contribution/upload", { - method: "POST", - headers: { ...headers, "contribution-id": contributionId }, - body: secondForm, - }); - assert.equal(secondUpload.status, 200); - const second = await FileModel.findById(await secondUpload.text()); - assert.equal(second.name, saved.name); - assert.notEqual(second.fileId, saved.fileId); - const sessions = calls.filter((url) => url.endsWith("/createUploadSession")); - assert.equal(sessions.length, 2); + assert.equal(response.status, 202, await response.clone().text()); + } + assert.equal(await processOperation(operation.id), true); + const completed = await ( + await fetch(origin + "/api/operations/" + operation.id, { headers }) + ).json(); + assert.equal(completed.status, "completed", JSON.stringify(completed)); assert.equal( - new Set(sessions).size, + new Set(remoteNames).size, 2, - "Equal display names must target different storage paths", + "Equal display names have distinct storage identities", ); - assert.ok(temporaryPaths.every((file) => !fs.existsSync(file))); - assert.equal(calls.filter((url) => url === "https://upload.example.test/session").length, 2); + for (const entry of completed.entries) { + const saved = await FileModel.findById(entry.fileId); + assert.equal(saved.isVerified, false); + assert.equal(saved.sizeBytes, contents.length); + assert.equal(saved.name, filename); + assert.equal(saved.contributorName, person.name); + const contribution = await Contribution.findOne({ contributionId: operation.id }); + assert.equal(contribution.uploadedBy, person.id); + assert.ok(contribution.files.some((id) => String(id) === entry.fileId)); + assert.ok( + (await FolderModel.findById(folder._id)).children.some( + (id) => String(id) === entry.fileId, + ), + ); + const download = await fetch(origin + "/api/files/content/" + entry.fileId, { headers }); + assert.equal(download.status, 200); + assert.deepEqual(Buffer.from(await download.arrayBuffer()), contents); + } + assert.equal(temporaryPaths.length, 2); + assert.ok(temporaryPaths.every((filename) => !fs.existsSync(filename))); + const retry = new FormData(); + retry.append("file", new Blob([contents]), filename); + const replay = await fetch(origin + "/api/contribution/upload", { + method: "POST", + headers: { + ...headers, + "contribution-id": operation.id, + "upload-file-id": operation.entries[0].id, + }, + body: retry, + }); + assert.equal(replay.status, 202); + assert.equal(remoteNames.length, 2, "A repeated upload request does not create another file"); }); test("an administrator session can explicitly create a course", async () => { @@ -458,3 +418,6 @@ test("course permission and moderation boundaries", async (t) => import { exerciseSessionSecurity } from "../support/session-security.js"; test("session, OAuth, CSRF and profile boundaries", async (t) => exerciseSessionSecurity(t, origin)); + +import { exerciseOperations } from "../support/operations.js"; +test("upload and deletion journal recovery", async (t) => exerciseOperations(t, origin)); diff --git a/server/test/library-authentication.test.js b/server/test/library-authentication.test.js index 843ac692..d1115627 100644 --- a/server/test/library-authentication.test.js +++ b/server/test/library-authentication.test.js @@ -20,7 +20,7 @@ import CourseAllotment from "../modules/course/courseAllotment.model.js"; import BR from "../modules/br/br.model.js"; import { academicPeriod } from "../services/authorization.js"; import Contribution from "../modules/contribution/contribution.model.js"; -import { upload } from "../modules/contribution/contribution.routes.js"; +import { upload } from "../middleware/receiveUpload.js"; import { student, administrator, course, year, folder, libraryFile } from "./fixtures/library.js"; beforeEach(guardExternalServices); @@ -149,6 +149,7 @@ for (const [prefix, module] of [ ["folder", "folder"], ["year", "year"], ["student", "student"], + ["operations", "operation"], ]) { const { default: router } = await import(`../modules/${module}/${module}.routes.js`); for (const { route } of router.stack) { diff --git a/server/test/oauth-provisioning.test.js b/server/test/oauth-provisioning.test.js index 56d7dbf1..cfbba385 100644 --- a/server/test/oauth-provisioning.test.js +++ b/server/test/oauth-provisioning.test.js @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { once } from "node:events"; import http from "node:http"; -import fs from "node:fs"; +import { storageTokens } from "../services/tokenStore.js"; import dotenv from "dotenv"; import axios from "axios"; import { createOAuthProof, createLocalOAuthCallback, hashOAuthValue } from "../utils/oauthProof.js"; @@ -51,13 +51,7 @@ test("the OneDrive CLI exchanges PKCE through a loopback callback and saves toke const messages = [], saved = []; t.mock.method(console, "log", (value) => messages.push(value)); - const originalExists = fs.existsSync; - t.mock.method(fs, "existsSync", (file) => - String(file).endsWith(".token") ? false : originalExists(file), - ); - t.mock.method(fs, "writeFileSync", (file, value, options) => - saved.push({ file, value, options }), - ); + t.mock.method(storageTokens, "save", async (data) => saved.push(data)); t.mock.method(axios, "post", async (url, data) => { const params = new URLSearchParams(data); const authorize = new URL( @@ -91,8 +85,9 @@ test("the OneDrive CLI exchanges PKCE through a loopback callback and saves toke assert.equal(response.status, 200); assert.match(await response.text(), /tokens saved/); await running; - assert.equal(saved.length, 2); - assert.ok(saved.every((file) => file.options.mode === 0o600)); + assert.deepEqual(saved, [ + { refresh_token: "synthetic-refresh-secret", access_token: "synthetic-access-secret" }, + ]); assert.doesNotMatch( messages.join("\n"), /synthetic-refresh-secret|synthetic-access-secret|synthetic-client-secret/, diff --git a/server/test/storage-inventory.test.js b/server/test/storage-inventory.test.js new file mode 100644 index 00000000..cbf1b681 --- /dev/null +++ b/server/test/storage-inventory.test.js @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { inventoryStorage } from "../services/storageInventory.js"; + +test("storage inventory identifies stored sharing and protected roots without exposing bearer URLs or claiming provider verification", async () => { + const report = await inventoryStorage( + [ + { + _id: "file-one", + fileId: "root", + webUrl: "https://tenant.sharepoint.com/file?token=private-sharing-key", + downloadUrl: "https://files.1drv.com/download?secret=private-download-key", + thumbnail: { + fileId: "thumbnail-id", + url: "https://ik.imagekit.io/coursehub/thumb.webp", + }, + }, + { _id: "file-two", fileId: "../foreign", webUrl: "malformed" }, + { _id: "file-three", fileId: "plain-file" }, + ], + "root", + ); + assert.deepEqual(report.counts, { + files: 3, + storedWebLinks: 2, + storedDownloadLinks: 1, + thumbnailReferences: 1, + rootReferences: 1, + invalidProviderIds: 1, + }); + assert.equal(report.providerPermissionsVerified, false); + assert.equal(report.records[1].webLink.malformed, true); + assert.equal(report.records[0].webLink.host, "tenant.sharepoint.com"); + assert.doesNotMatch(JSON.stringify(report), /private-sharing-key|private-download-key|token=/); +}); diff --git a/server/test/storage.test.js b/server/test/storage.test.js new file mode 100644 index 00000000..ce7916ed --- /dev/null +++ b/server/test/storage.test.js @@ -0,0 +1,358 @@ +import "./support/environment.js"; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { tmpdir } from "node:os"; +import { createTokenStore, atomicPrivateWrite } from "../services/tokenStore.js"; +import { createGraphClient, StorageError } from "../services/graphClient.js"; +import { createStorage } from "../services/storage.js"; +import { graphChunkBytes } from "../config/storage.js"; +import { storage } from "../services/storage.js"; +import { graph } from "../services/graphClient.js"; +import { thumbnailStream, invalidateThumbnail } from "../services/thumbnails.js"; + +async function directory(t) { + const dir = await fs.mkdtemp(path.join(tmpdir(), "coursehub-storage-")); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + return dir; +} +const response = (data, status = 200, headers = {}) => ({ data, status, headers }); +const failure = (status, headers = {}) => + Object.assign(new Error("private provider response"), { + response: { status, headers, data: { secret: "provider-secret" } }, + }); + +test("Graph timeouts and malformed responses are bounded and discard provider details", async () => { + let attempts = 0; + const client = createGraphClient({ + tokens: { getAccessToken: async () => "private-token" }, + sleep: async () => {}, + transport: async (config) => { + assert.equal(config.timeout, 30000); + attempts++; + throw Object.assign(new Error("private provider payload"), { + code: "ECONNABORTED", + config, + }); + }, + }); + await assert.rejects( + client.request("me/drive"), + (error) => error.status === 504 && !JSON.stringify(error).includes("private"), + ); + assert.equal(attempts, 3); + const malformed = createGraphClient({ + tokens: { getAccessToken: async () => "token" }, + transport: async () => ({ data: {} }), + }); + await assert.rejects(malformed.request("me"), { code: "INVALID_STORAGE_RESPONSE" }); +}); + +test("thumbnail delivery rechecks root membership, refreshes expired URLs and rejects lookalike ImageKit paths", async (t) => { + const id = "thumbnail-test"; + t.after(() => invalidateThumbnail(id)); + let lookups = 0, + deliveries = 0, + roots = 0; + t.mock.method(storage, "withinRoot", async () => { + roots++; + }); + t.mock.method( + storage, + "thumbnail", + async () => `https://tenant.sharepoint.com/thumb-${++lookups}`, + ); + t.mock.method(graph, "request", async (url, options) => { + assert.equal(options.preauthenticated, true); + deliveries++; + assert.ok(url.startsWith("https://tenant.sharepoint.com/")); + if (deliveries === 1) throw new StorageError(403); + return { data: "thumbnail", headers: { "content-type": "image/webp" } }; + }); + const file = { + fileId: id, + thumbnail: { url: "https://ik.imagekit.io/coursehub-test-attacker/image.webp" }, + }; + assert.equal((await thumbnailStream(file)).data, "thumbnail"); + await Promise.all([thumbnailStream(file), thumbnailStream(file)]); + assert.equal(lookups, 2); + assert.equal(deliveries, 4); + assert.equal(roots, 3); + t.mock.method(storage, "withinRoot", async () => { + throw new StorageError(404); + }); + await assert.rejects(thumbnailStream(file), StorageError); + assert.equal(deliveries, 4); +}); + +test("concurrent token stores share one refresh and atomically preserve rotating credentials", async (t) => { + const dir = await directory(t); + await atomicPrivateWrite(path.join(dir, "onedrive-refresh-token.token"), "old-refresh"); + let requests = 0; + const request = async (body, options) => { + requests++; + assert.equal( + new URLSearchParams(body).get("refresh_token"), + requests === 1 ? "old-refresh" : "new-refresh", + ); + assert.equal(options.timeout, 30000); + return response({ + access_token: "access-" + requests, + refresh_token: "new-refresh", + expires_in: 3600, + }); + }; + const stores = [ + createTokenStore({ directory: () => dir, request }), + createTokenStore({ directory: () => dir, request }), + ]; + const tokens = await Promise.all( + Array.from({ length: 12 }, (_, i) => stores[i % 2].getAccessToken()), + ); + assert.deepEqual(new Set(tokens), new Set(["access-1"])); + assert.equal(requests, 1); + assert.equal(await stores[0].getAccessToken({ rejected: "access-1" }), "access-2"); + for (const filename of ["onedrive-refresh-token.token", "onedrive-access-token.json"]) { + assert.equal((await fs.stat(path.join(dir, filename))).mode & 0o777, 0o600); + } + assert.equal( + await fs.readFile(path.join(dir, "onedrive-refresh-token.token"), "utf8"), + "new-refresh", + ); + assert.ok( + (await fs.readdir(dir)).every((name) => !name.endsWith(".tmp") && !name.endsWith(".lock")), + ); +}); + +test("failed or malformed token refresh preserves the last stored credential and exposes no provider details", async (t) => { + const dir = await directory(t); + const filename = path.join(dir, "onedrive-refresh-token.token"); + await atomicPrivateWrite(filename, "preserve-this-refresh"); + for (const request of [ + async () => { + throw failure(500); + }, + async () => response({ access_token: "bad", expires_in: "invalid" }), + ]) { + const store = createTokenStore({ directory: () => dir, request }); + await assert.rejects( + store.getAccessToken(), + (error) => !JSON.stringify(error).includes("provider-secret"), + ); + assert.equal(await fs.readFile(filename, "utf8"), "preserve-this-refresh"); + assert.ok(!(await fs.readdir(dir)).some((name) => name.endsWith(".lock"))); + } +}); + +test("Graph follows every nextLink and rejects cycles, foreign origins and malformed collections", async () => { + const calls = []; + const client = createGraphClient({ + tokens: { getAccessToken: async () => "synthetic" }, + transport: async (config) => { + calls.push(config.url); + return response( + config.url.includes("skiptoken") + ? { value: [2] } + : { + value: [1], + "@odata.nextLink": + "https://graph.microsoft.com/v1.0/me/drive/items/root/children?$skiptoken=opaque%2Btoken", + }, + ); + }, + }); + assert.deepEqual(await client.collection("me/drive/items/root/children"), [1, 2]); + assert.ok(calls[1].endsWith("opaque%2Btoken")); + for (const data of [ + { value: [], "@odata.nextLink": "https://untrusted.test/steal" }, + { value: [], "@odata.nextLink": "https://graph.microsoft.com/v1.0/me" }, + { invalid: [] }, + ]) { + let requests = 0; + const invalid = createGraphClient({ + tokens: { getAccessToken: async () => "token" }, + transport: async () => { + requests++; + return response(data); + }, + }); + await assert.rejects(invalid.collection("me"), StorageError); + assert.equal(requests, 1); + } +}); + +test("Graph refreshes storage authentication once, respects throttling and bounds repeated failures", async () => { + const refreshes = [], + waits = [], + headers = []; + let calls = 0; + const client = createGraphClient({ + tokens: { + getAccessToken: async (options) => { + refreshes.push(options); + return options?.rejected ? "new" : "old"; + }, + }, + sleep: async (ms) => waits.push(ms), + transport: async (config) => { + calls++; + headers.push(config.headers.Authorization); + if (calls === 1) throw failure(401); + if (calls === 2) throw failure(429, { "retry-after": "2" }); + return response({ id: "ok" }); + }, + }); + assert.deepEqual((await client.request("me")).data, { id: "ok" }); + assert.equal(refreshes.length, 2); + assert.equal(refreshes[1].rejected, "old"); + assert.deepEqual(headers, ["Bearer old", "Bearer new", "Bearer new"]); + assert.deepEqual(waits, [2000]); + calls = 0; + const down = createGraphClient({ + tokens: { getAccessToken: async () => "token" }, + sleep: async () => {}, + transport: async () => { + calls++; + throw failure(503); + }, + }); + await assert.rejects( + down.request("me"), + (error) => error.providerStatus === 503 && !error.message.includes("private"), + ); + assert.equal(calls, 3); + const delegated = createGraphClient({ + tokens: { + getAccessToken: () => + assert.fail("A student token must not fall back to storage credentials"), + }, + transport: async () => { + throw failure(401); + }, + }); + await assert.rejects( + delegated.request("me", { token: "student-token" }), + (error) => error.providerStatus === 401, + ); +}); + +test("content redirects are restricted and do not forward Graph bearer credentials", async () => { + const calls = []; + const client = createGraphClient({ + tokens: { getAccessToken: async () => "secret-token" }, + transport: async (config) => { + calls.push(config); + if (calls.length === 1) + throw failure(302, { + location: "https://tenant.sharepoint.com/content?capability=synthetic", + }); + return response("content"); + }, + }); + await client.request("me/drive/items/file/content"); + assert.equal(calls[0].headers.Authorization, "Bearer secret-token"); + assert.equal(calls[1].headers.Authorization, undefined); + await assert.rejects( + client.request("https://127.0.0.1/private", { preauthenticated: true }), + StorageError, + ); + await assert.rejects(client.request("https://graph.microsoft.com@evil.test/me"), StorageError); +}); + +test("root-bound resolution forbids automatic folder/root deletion and foreign items; absent files are idempotent", async () => { + const items = { + root: { id: "root", folder: {} }, + folder: { id: "folder", folder: {}, parentReference: { id: "root" } }, + file: { id: "file", file: {}, parentReference: { id: "folder" } }, + foreign: { id: "foreign", parentReference: { id: "outside" } }, + outside: { id: "outside", folder: {} }, + }; + const deleted = []; + const client = { + request: async (url, options = {}) => { + const id = url.split("/").at(-1); + if (!items[id]) throw new StorageError(404); + if (options.method === "DELETE") { + deleted.push(id); + delete items[id]; + return response(null, 204); + } + return response(items[id]); + }, + }; + const store = createStorage({ client, root: () => "root" }); + for (const id of ["root", "folder", "foreign"]) await assert.rejects(store.remove(id)); + assert.deepEqual(deleted, []); + await store.remove("file"); + await store.remove("file"); + assert.deepEqual(deleted, ["file"]); +}); + +test("storage sends sequential Graph-compliant chunks and recovers a lost final response without a duplicate file", async (t) => { + const dir = await directory(t); + const filename = path.join(dir, "synthetic.upload"); + const size = graphChunkBytes * 2 + 11; + const handle = await fs.open(filename, "w"); + await handle.truncate(size); + await handle.close(); + const remoteName = "12345678-1234-1234-1234-123456789abc.pdf"; + const item = { + id: "uploaded", + name: remoteName, + size, + file: {}, + parentReference: { id: "root" }, + }; + const chunks = []; + let committed = false, + persistedSession = false; + const client = { + request: async (url, options = {}) => { + if (url === "me/drive/items/root") return response({ id: "root", folder: {} }); + if (url === "me/drive/items/uploaded") return response(item); + if (url.endsWith("createUploadSession")) + return response({ + uploadUrl: "https://tenant.sharepoint.com/upload", + nextExpectedRanges: ["0-"], + }); + if (url.startsWith("me/drive/items/root:/")) { + if (!committed) throw new StorageError(404); + return response(item); + } + assert.equal(options.method, "PUT"); + assert.equal( + persistedSession, + true, + "Session identity must be journaled before sending bytes", + ); + assert.equal(options.preauthenticated, true); + assert.equal(options.headers.Authorization, undefined); + chunks.push({ range: options.headers["Content-Range"], bytes: options.data.length }); + if (chunks.length === 3) { + committed = true; + throw new StorageError(503); + } + return response({ nextExpectedRanges: [`${chunks.length * graphChunkBytes}-`] }, 202); + }, + }; + const store = createStorage({ client, root: () => "root" }); + const args = { + filename, + remoteName, + size, + onSession: async () => { + persistedSession = true; + }, + onProgress: async () => {}, + }; + assert.equal((await store.upload(args)).id, "uploaded"); + assert.deepEqual( + chunks.map((chunk) => chunk.bytes), + [graphChunkBytes, graphChunkBytes, 11], + ); + assert.equal(chunks[0].range, `bytes 0-${graphChunkBytes - 1}/${size}`); + assert.equal(chunks[2].range, `bytes ${graphChunkBytes * 2}-${size - 1}/${size}`); + assert.equal((await store.upload(args)).id, "uploaded"); + assert.equal(chunks.length, 3); +}); diff --git a/server/test/support/course-permissions.js b/server/test/support/course-permissions.js index d30de229..ce5a876b 100644 --- a/server/test/support/course-permissions.js +++ b/server/test/support/course-permissions.js @@ -2,16 +2,19 @@ import getImageKit from "../../services/imagekit.js"; import assert from "node:assert/strict"; import fs from "node:fs"; import { Readable } from "node:stream"; -import axios from "axios"; +import { randomUUID } from "node:crypto"; +import { storage } from "../../services/storage.js"; +import { graph } from "../../services/graphClient.js"; +import { OperationModel } from "../../modules/operation/operation.model.js"; +import { processOperation } from "../../services/operationWorker.js"; import { sessionHeaders } from "../fixtures/sessions.js"; import Course, { FolderModel, FileModel } from "../../modules/course/course.model.js"; import BR from "../../modules/br/br.model.js"; import CourseAllotment from "../../modules/course/courseAllotment.model.js"; import Contribution from "../../modules/contribution/contribution.model.js"; import Admin from "../../modules/admin/admin.model.js"; -import { upload } from "../../modules/contribution/contribution.routes.js"; +import { upload } from "../../middleware/receiveUpload.js"; import { permissionFixtures } from "../fixtures/permissions.js"; -import { clearAccessTokenCache } from "../../modules/onedrive/onedrive.controller.js"; import { academicPeriod } from "../../services/authorization.js"; export async function exerciseCoursePermissions(t, origin) { @@ -27,7 +30,7 @@ export async function exerciseCoursePermissions(t, origin) { const request = async (actor, method, route, body, status = 200) => { const response = await fetch(origin + route, { method, - headers: f.actors[actor].headers, + headers: { ...f.actors[actor].headers, "idempotency-key": randomUUID() }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(5000), }); @@ -42,54 +45,32 @@ export async function exerciseCoursePermissions(t, origin) { const deletedThumbnails = []; process.env.IMAGEKIT_PUBLIC_KEY = "fake-test-public-key"; process.env.IMAGEKIT_PRIVATE_KEY = "fake-test-private-key"; - clearAccessTokenCache(); - t.after(clearAccessTokenCache); t.beforeEach((sub) => { sub.mock.method(getImageKit().files, "delete", async (id) => { deletedThumbnails.push(id); }); - for (const method of ["existsSync", "readFileSync", "writeFileSync"]) { - const original = fs[method]; - sub.mock.method(fs, method, (name, ...args) => - String(name).endsWith(".token") - ? method === "existsSync" - ? true - : method === "readFileSync" - ? "fake-refresh" - : undefined - : original(name, ...args), - ); - } - sub.mock.method(axios, "post", async (url) => { - providerCalls.push(url); - assert.ok(url.startsWith("https://login.microsoftonline.com/")); - return { data: { access_token: "fake-access", expires_in: 3600 } }; + sub.mock.method(storage, "withinRoot", async (id) => { + providerCalls.push(id); + return { id, file: {} }; }); - sub.mock.method(axios, "get", async (url) => { - providerCalls.push(url); - assert.ok( - url.startsWith("https://graph.microsoft.com/v1.0/me/drive/items/") || - url === f.pending.thumbnail.url, - ); + sub.mock.method(storage, "content", async (id) => { + providerCalls.push(id); return { - data: Readable.from([ - url === f.pending.thumbnail.url - ? "image-fixture" - : "%PDF-1.4 permission document", - ]), - headers: { - "content-type": - url === f.pending.thumbnail.url ? "image/webp" : "application/pdf", - }, + data: Readable.from(["%PDF-1.4 permission document"]), + headers: { "content-type": "application/pdf" }, }; }); - sub.mock.method(axios, "delete", async (url) => { + sub.mock.method(graph, "request", async (url) => { providerCalls.push(url); - return { status: 204 }; + assert.equal(url, f.pending.thumbnail.url); + return { + status: 200, + data: Readable.from(["image-fixture"]), + headers: { "content-type": "image/webp" }, + }; }); - sub.mock.method(axios, "patch", async (url, body) => { - providerCalls.push(url); - return { data: { name: body.name } }; + sub.mock.method(storage, "remove", async (id) => { + providerCalls.push("delete/" + id); }); }); @@ -312,6 +293,7 @@ export async function exerciseCoursePermissions(t, origin) { parentFolder: f.leaf.id, courseCode: "AUTH101", description: "matrix upload", + manifest: [{ name: "notes.pdf", size: 14 }], }; for (const [key, value] of [ ["uploadedBy", f.actors.student.person.id], @@ -333,12 +315,14 @@ export async function exerciseCoursePermissions(t, origin) { "revokedBR", "admin", ]) { - const { data } = await ( - await request(role, "POST", "/api/contribution", input) + const operation = await ( + await request(role, "POST", "/api/contribution", input, 201) ).json(); + const data = await Contribution.findOne({ contributionId: operation.id }); assert.equal(data.uploadedBy, f.actors[role].person.id); assert.match(data.contributionId, /^[0-9a-f-]{36}$/); - assert.equal(data.approved, ["currentBR", "historicalBR", "admin"].includes(role)); + assert.equal(data.approved, false); + assert.equal(operation.entries[0].name, "notes.pdf"); } for (const role of ["unrelatedBR", "unallottedBR"]) await request(role, "POST", "/api/contribution", input, 403); @@ -500,10 +484,17 @@ export async function exerciseCoursePermissions(t, origin) { name: "2030", }) ).json(); - await request("historicalBR", "DELETE", "/api/year/delete", { - courseCode: "AUTH101", - folderId: year._id, - }); + const removedYear = await request( + "historicalBR", + "DELETE", + "/api/year/delete", + { + courseCode: "AUTH101", + folderId: year._id, + }, + 202, + ); + await processOperation((await removedYear.json()).operationId); assert.equal(await FolderModel.findById(year._id), null); }, ); @@ -552,14 +543,20 @@ export async function exerciseCoursePermissions(t, origin) { await t.test( "uploading after BR revocation rechecks approval and ignores claimed uploader headers", async (sub) => { - const { data } = await ( - await request("currentBR", "POST", "/api/contribution", { - courseCode: "AUTH101", - parentFolder: f.leaf.id, - description: "revocation test", - }) + const operation = await ( + await request( + "currentBR", + "POST", + "/api/contribution", + { + courseCode: "AUTH101", + parentFolder: f.leaf.id, + description: "revocation test", + manifest: [{ name: "notes.pdf", size: 14 }], + }, + 201, + ) ).json(); - assert.equal(data.approved, true); await BR.deleteOne({ email: f.actors.currentBR.person.email }); sub.after(async () => { await BR.updateOne( @@ -568,21 +565,7 @@ export async function exerciseCoursePermissions(t, origin) { { upsert: true }, ); }); - sub.mock.method(axios, "post", async (url) => { - if (url.endsWith("/createUploadSession")) - return { data: { uploadUrl: "https://upload.example.test/revoked" } }; - if (url.endsWith("/createLink")) - return { data: { link: { webUrl: "https://tenant.sharepoint.com/revoked" } } }; - assert.fail("Unexpected upload provider POST"); - }); - sub.mock.method(axios, "put", async (url, bytes) => { - assert.equal(url, "https://upload.example.test/revoked"); - return { data: { id: "provider-revoked-upload", size: bytes.length } }; - }); - sub.mock.method(axios, "get", async (url) => { - assert.ok(url.endsWith("/thumbnails")); - return { data: { value: [] } }; - }); + sub.mock.method(storage, "upload", async () => ({ id: "provider-revoked-upload" })); const form = new FormData(); form.append("file", new Blob(["revoked upload"]), "notes.pdf"); const response = await fetch(origin + "/api/contribution/upload", { @@ -593,34 +576,46 @@ export async function exerciseCoursePermissions(t, origin) { ([name]) => name !== "content-type", ), ), - "contribution-id": data.contributionId, + "contribution-id": operation.id, + "upload-file-id": operation.entries[0].id, username: "Administrator", approved: "true", }, body: form, }); - assert.equal(response.status, 200, await response.clone().text()); - const fileId = await response.text(); + assert.equal(response.status, 202, await response.clone().text()); + await processOperation(operation.id); + const finished = await OperationModel.findById(operation.id); + assert.equal(finished.status, "completed", JSON.stringify(finished.error)); + const fileId = finished.entries[0].fileId; const file = await FileModel.findById(fileId); assert.equal(file.isVerified, false); - assert.equal(file.name, "notes~Permission currentBR.pdf"); + assert.equal(file.name, "notes.pdf"); + assert.equal(file.contributorName, "Permission currentBR"); assert.equal( - (await Contribution.findOne({ contributionId: data.contributionId })).approved, + (await Contribution.findOne({ contributionId: operation.id })).approved, false, ); await FolderModel.updateOne({ _id: f.leaf.id }, { $pull: { children: file._id } }); await FileModel.deleteOne({ _id: file._id }); - await Contribution.deleteOne({ contributionId: data.contributionId }); + await Contribution.deleteOne({ contributionId: operation.id }); }, ); await t.test( "shared removal unlinks only the active course and ignores forged descendants", async () => { - await request("currentBR", "DELETE", "/api/folder/delete", { - courseCode: "AUTH101", - folderId: f.leaf.id, - folder: { _id: f.foreignLeaf.id, children: [f.foreign] }, - }); + const unlink = await request( + "currentBR", + "DELETE", + "/api/folder/delete", + { + courseCode: "AUTH101", + folderId: f.leaf.id, + folder: { _id: f.foreignLeaf.id, children: [f.foreign] }, + }, + 202, + ); + await processOperation((await unlink.json()).operationId); assert.ok(await FileModel.findById(f.pending.id)); await FileModel.updateOne( { _id: f.pending.id }, @@ -651,9 +646,16 @@ export async function exerciseCoursePermissions(t, origin) { { courseCode: "AUTH101" }, 404, ); - await request("admin", "DELETE", `/api/admin/node/file/${f.pending.id}`, { - courseCode: "AUTH301", - }); + const deletion = await request( + "admin", + "DELETE", + `/api/admin/node/file/${f.pending.id}`, + { + courseCode: "AUTH301", + }, + 202, + ); + await processOperation((await deletion.json()).operationId); assert.equal(await FileModel.findById(f.pending.id), null); assert.deepEqual(deletedThumbnails, ["owned-thumbnail"]); assert.ok( diff --git a/server/test/support/environment.js b/server/test/support/environment.js index 74109a3a..b1891fb4 100644 --- a/server/test/support/environment.js +++ b/server/test/support/environment.js @@ -12,3 +12,4 @@ process.env.JWT_SECRET = "coursehub-student-unit-test-key"; process.env.ADMIN_JWT_SECRET = "coursehub-admin-unit-test-key"; process.env.IMAGEKIT_URL_ENDPOINT = "https://ik.imagekit.io/coursehub-test/"; process.env.ONEDRIVE_FOLDER_ID = "test-storage-root"; +process.env.ONEDRIVE_TOKEN_DIR = `/tmp/coursehub-test-unprovisioned-${process.pid}`; diff --git a/server/test/support/operations.js b/server/test/support/operations.js new file mode 100644 index 00000000..c7fbcbe6 --- /dev/null +++ b/server/test/support/operations.js @@ -0,0 +1,640 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import http from "node:http"; +import { once } from "node:events"; +import { Readable } from "node:stream"; +import User from "../../modules/user/user.model.js"; +import Admin from "../../modules/admin/admin.model.js"; +import BR from "../../modules/br/br.model.js"; +import CourseAllotment from "../../modules/course/courseAllotment.model.js"; +import Course, { FolderModel, FileModel } from "../../modules/course/course.model.js"; +import Contribution from "../../modules/contribution/contribution.model.js"; +import { + OperationModel, + CourseLock, + StorageLease, +} from "../../modules/operation/operation.model.js"; +import { academicPeriod } from "../../services/authorization.js"; +import { processOperation, recoverLocks } from "../../services/operationWorker.js"; +import { + reserveUpload, + receiveFailed, + recoverReceiving, + temporaryPath, +} from "../../services/uploads.js"; +import { acquireCourseLocks } from "../../services/courseLocks.js"; +import { storage } from "../../services/storage.js"; +import { StorageError } from "../../services/graphClient.js"; +import getImageKit from "../../services/imagekit.js"; +import { uploadDirectory, uploadLimits } from "../../config/storage.js"; +import { sessionHeaders } from "../fixtures/sessions.js"; +import { student } from "../fixtures/library.js"; +import AppError from "../../utils/appError.js"; + +export async function exerciseOperations(t, origin) { + let serial = 0; + async function fixture() { + const number = ++serial; + const code = `JOUR${number}01`, + sharedCode = `JOUR${number}02`; + const { _id, ...base } = student; + const owner = await User.create({ + ...base, + email: `journal${number}@example.test`, + rollNumber: 298000000 + number, + }); + const manager = await User.create({ + ...base, + email: `journal-br${number}@example.test`, + rollNumber: 297000000 + number, + }); + await BR.create({ email: manager.email }); + for (const person of [owner, manager]) + await CourseAllotment.create({ + rollNumber: person.rollNumber, + ...academicPeriod(), + courses: [code], + }); + const folder = await FolderModel.create({ + name: "Shared lecture notes", + courses: [code, sharedCode], + childType: "File", + children: [], + }); + const root = await FolderModel.create({ + name: "2026", + courses: [code, sharedCode], + childType: "Folder", + children: [folder._id], + }); + await Course.create([ + { code, name: "Journal test", children: [root._id] }, + { code: sharedCode, name: "Linked journal test", children: [root._id] }, + ]); + const admin = await Admin.findOne(); + return { + code, + sharedCode, + owner, + manager, + folder, + root, + ownerHeaders: await sessionHeaders(owner.id), + managerHeaders: await sessionHeaders(manager.id), + adminHeaders: await sessionHeaders(admin.id, "admin"), + }; + } + const send = async (headers, path, method = "GET", body, status = 200, extra = {}) => { + const response = await fetch(origin + path, { + method, + headers: { + ...headers, + ...(body ? { "content-type": "application/json" } : {}), + ...extra, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(15000), + }); + assert.equal( + response.status, + status, + `${method} ${path}: ${await response.clone().text()}`, + ); + return response.json(); + }; + const create = (f, manifest, key = randomUUID()) => + send( + f.ownerHeaders, + "/api/contribution", + "POST", + { courseCode: f.code, parentFolder: f.folder.id, manifest }, + 201, + { "idempotency-key": key }, + ); + const receive = async ( + f, + op, + index, + bytes, + status = 202, + filename = op.entries[index].name, + ) => { + const body = new FormData(); + body.append("file", new Blob([bytes]), filename); + const response = await fetch(origin + "/api/contribution/upload", { + method: "POST", + headers: { + ...f.ownerHeaders, + "contribution-id": op.id, + "upload-file-id": op.entries[index].id, + }, + body, + }); + assert.equal(response.status, status, await response.clone().text()); + return response.json(); + }; + const run = async (id) => { + await OperationModel.updateOne({ _id: id }, { $set: { nextRunAt: new Date(0) } }); + assert.equal(await processOperation(id), true); + return OperationModel.findById(id); + }; + t.beforeEach((sub) => { + sub.mock.method(storage, "findUpload", async () => null); + sub.mock.method(storage, "cancelSession", async () => {}); + sub.mock.method(storage, "remove", async () => {}); + sub.mock.method(getImageKit().files, "delete", async () => {}); + }); + + await t.test( + "manifest creation is idempotent; forged ownership and actual byte/name mismatches cannot stage a file", + async () => { + const f = await fixture(), + key = randomUUID(), + manifest = [{ name: "notes.pdf", size: 5 }]; + const [op, replay] = await Promise.all([ + create(f, manifest, key), + create(f, manifest, key), + ]); + assert.equal(op.id, replay.id); + assert.equal(await Contribution.countDocuments({ contributionId: op.id }), 1); + await send( + f.ownerHeaders, + "/api/contribution", + "POST", + { + courseCode: f.code, + parentFolder: f.folder.id, + manifest: [{ name: "other.pdf", size: 5 }], + }, + 409, + { "idempotency-key": key }, + ); + await receive(f, op, 0, "four", 400); + await receive(f, op, 0, "12345", 400, "renamed.pdf"); + const other = { ...f, ownerHeaders: f.managerHeaders }; + await receive(other, op, 0, "12345", 404); + await receive( + f, + { ...op, entries: [{ id: randomUUID(), name: "notes.pdf" }] }, + 0, + "12345", + 404, + ); + const record = await OperationModel.findById(op.id); + assert.equal(record.receivingCount, 0); + assert.equal(await StorageLease.countDocuments({ _id: /^receive:/ }), 0); + await assert.rejects(fs.access(temporaryPath(record.entries[0].temporaryName)), { + code: "ENOENT", + }); + assert.equal(await FileModel.countDocuments({ uploadOperation: op.id }), 0); + }, + ); + + await t.test( + "partial uploads preserve successes, retry only failed bytes and enforce unchanged retry content", + async (sub) => { + const f = await fixture(), + op = await create(f, [ + { name: "one~notes.pdf", size: 5 }, + { name: "two.pdf", size: 5 }, + ]); + const calls = []; + sub.mock.method(storage, "upload", async (args) => { + const text = await fs.readFile(args.filename, "utf8"); + calls.push(text); + if (text === "22222" && calls.filter((value) => value === text).length === 1) + throw new StorageError(503); + return { id: "partial-" + text }; + }); + await receive(f, op, 0, "11111"); + await receive(f, op, 1, "22222"); + let result = await run(op.id); + assert.equal(result.status, "partial"); + assert.deepEqual( + result.entries.map((entry) => entry.state), + ["completed", "failed"], + ); + for (const entry of result.entries) + await assert.rejects(fs.access(temporaryPath(entry.temporaryName)), { + code: "ENOENT", + }); + const own = await send(f.ownerHeaders, "/api/operations/" + op.id); + assert.doesNotMatch( + JSON.stringify(own), + /sessionUrl|providerId|temporaryName|remoteName|sha256|leaseToken/, + ); + await send(f.ownerHeaders, `/api/operations/${op.id}/retry`, "POST", {}, 202); + await receive(f, op, 1, "wrong", 409); + await receive(f, op, 1, "22222"); + result = await run(op.id); + assert.equal(result.status, "completed"); + assert.deepEqual([...calls].sort(), ["11111", "22222", "22222"]); + assert.equal(await FileModel.countDocuments({ uploadOperation: op.id }), 2); + const first = result.entries[0]; + const renamed = await send( + f.managerHeaders, + `/api/files/rename/${first.fileId}`, + "PUT", + { courseCode: f.code, newName: "Renamed notes" }, + ); + assert.equal(renamed.file.name, "Renamed notes.pdf"); + assert.equal((await FileModel.findById(first.fileId)).fileId, first.providerId); + }, + ); + + await t.test( + "cancellation after storage completion removes unpublished bytes and keeps earlier successful files", + async (sub) => { + const f = await fixture(), + op = await create(f, [ + { name: "keep.pdf", size: 4 }, + { name: "cancel.pdf", size: 4 }, + ]); + const deleted = [], + sessions = []; + let uploads = 0; + sub.mock.method(storage, "upload", async (args) => { + if (++uploads === 1) return { id: "keep-file" }; + await args.onSession("https://tenant.sharepoint.com/upload-session"); + await send(f.ownerHeaders, `/api/operations/${op.id}/cancel`, "POST", {}, 202); + return { id: "unpublished-file" }; + }); + sub.mock.method(storage, "remove", async (id) => { + deleted.push(id); + }); + sub.mock.method(storage, "cancelSession", async (url) => { + sessions.push(url); + }); + await receive(f, op, 0, "keep"); + assert.equal((await run(op.id)).status, "awaiting"); + await receive(f, op, 1, "stop"); + const result = await run(op.id); + assert.equal(result.status, "cancelled"); + assert.deepEqual( + result.entries.map((entry) => entry.state), + ["completed", "cancelled"], + ); + assert.deepEqual(deleted, ["unpublished-file"]); + assert.equal(sessions.length, 1); + assert.equal(await FileModel.countDocuments({ uploadOperation: op.id }), 1); + await receive(f, op, 1, "stop", 409); + await send(f.managerHeaders, `/api/operations/${op.id}/cancel`, "POST", {}, 403); + }, + ); + + await t.test( + "an expired worker resumes publication from saved storage IDs without uploading again", + async (sub) => { + const f = await fixture(), + op = await create(f, [{ name: "restart.pdf", size: 5 }]); + await receive(f, op, 0, "12345"); + const record = await OperationModel.findById(op.id), + entry = record.entries[0]; + await OperationModel.updateOne( + { _id: op.id }, + { + $set: { + status: "running", + leaseToken: "stopped-process", + leaseUntil: new Date(0), + "entries.0.state": "publishing", + "entries.0.providerId": "already-uploaded", + }, + }, + ); + await FileModel.create({ + _id: entry.fileId, + name: entry.name, + fileId: "already-uploaded", + size: "5", + uploadOperation: op.id, + resourceState: "uploading", + }); + await FolderModel.updateOne( + { _id: f.folder.id }, + { $addToSet: { children: entry.fileId } }, + ); + await acquireCourseLocks(record.courses, op.id, { durable: true }); + await send(f.ownerHeaders, `/api/files/link/${entry.fileId}`, "GET", undefined, 404); + sub.mock.method(storage, "upload", () => + assert.fail("Publication recovery must reuse the journaled provider ID"), + ); + assert.equal((await run(op.id)).status, "completed"); + assert.equal((await FileModel.findById(entry.fileId)).resourceState, "ready"); + assert.equal(await CourseLock.countDocuments({ owner: op.id }), 0); + await assert.rejects(fs.access(temporaryPath(entry.temporaryName)), { code: "ENOENT" }); + }, + ); + + await t.test( + "moderation rejection cancels unfinished contribution uploads and confirms every shared course", + async (sub) => { + const f = await fixture(), + op = await create(f, [ + { name: "reject.pdf", size: 5 }, + { name: "waiting.pdf", size: 5 }, + ]); + sub.mock.method(storage, "upload", async () => ({ id: "reject-upload" })); + await receive(f, op, 0, "12345"); + assert.equal((await run(op.id)).status, "awaiting"); + const body = { contributionId: op.id, action: "reject", courseCode: f.code }; + await send(f.adminHeaders, "/api/admin/contribution/action", "POST", body, 409); + const deletion = await send( + f.adminHeaders, + "/api/admin/contribution/action", + "POST", + { ...body, affectedCourses: [f.code, f.sharedCode] }, + 202, + ); + assert.equal((await OperationModel.findById(op.id)).cancelRequested, true); + assert.equal((await run(deletion.operationId)).status, "completed"); + assert.equal((await run(op.id)).status, "cancelled"); + assert.equal(await FileModel.countDocuments({ uploadOperation: op.id }), 0); + assert.equal(await Contribution.countDocuments({ contributionId: op.id }), 0); + }, + ); + + await t.test( + "a lost worker lease cannot publish or remove a successor's staged file", + async (sub) => { + const f = await fixture(), + op = await create(f, [{ name: "lease.pdf", size: 5 }]); + await receive(f, op, 0, "12345"); + let calls = 0; + sub.mock.method(storage, "upload", async () => { + if (++calls === 1) + await OperationModel.updateOne( + { _id: op.id }, + { $set: { leaseToken: "replacement-worker", leaseUntil: new Date(0) } }, + ); + return { id: "lease-upload" }; + }); + const interrupted = await run(op.id); + assert.equal(interrupted.status, "running"); + assert.equal(await FileModel.countDocuments({ uploadOperation: op.id }), 0); + await fs.access(temporaryPath(interrupted.entries[0].temporaryName)); + assert.equal((await run(op.id)).status, "completed"); + assert.equal(await FileModel.countDocuments({ uploadOperation: op.id }), 1); + }, + ); + + await t.test( + "a removed upload destination schedules recoverable cleanup of unpublished provider files", + async (sub) => { + const f = await fixture(), + op = await create(f, [{ name: "removed-folder.pdf", size: 5 }]); + await receive(f, op, 0, "12345"); + sub.mock.method(storage, "upload", async () => { + await FolderModel.deleteOne({ _id: f.folder.id }); + return { id: "unpublished-after-removal" }; + }); + let attempts = 0; + sub.mock.method(storage, "remove", async (id) => { + assert.equal(id, "unpublished-after-removal"); + if (++attempts === 1) throw new StorageError(503); + }); + const interrupted = await run(op.id); + assert.equal(interrupted.status, "queued"); + assert.equal(interrupted.entries[0].state, "cleanup"); + assert.equal(interrupted.entries[0].providerId, "unpublished-after-removal"); + const recovered = await run(op.id); + assert.equal(recovered.status, "failed"); + assert.equal(recovered.entries[0].providerId, null); + assert.equal(attempts, 2); + assert.equal(await FileModel.countDocuments({ uploadOperation: op.id }), 0); + assert.equal(await Contribution.countDocuments({ contributionId: op.id }), 0); + }, + ); + + await t.test( + "deletion journals identifiers before side effects, hides content and resumes after provider failure without repeating completed steps", + async (sub) => { + const f = await fixture(); + const file = await FileModel.create({ + name: "shared.pdf", + fileId: "journal-shared-file", + size: "5", + isVerified: true, + thumbnail: { fileId: "journal-thumbnail" }, + }); + await FolderModel.updateOne( + { _id: f.folder.id }, + { $addToSet: { children: file._id } }, + ); + const path = `/api/files/unverify/${file.id}`; + await send(f.managerHeaders, path, "DELETE", { courseCode: f.code }, 409); + const body = { courseCode: f.code, affectedCourses: [f.code, f.sharedCode] }; + const [accepted, duplicate] = await Promise.all([ + send(f.managerHeaders, path, "DELETE", body, 202), + send(f.managerHeaders, path, "DELETE", body, 202), + ]); + assert.equal(accepted.operationId, duplicate.operationId); + const id = accepted.operationId; + assert.equal((await FileModel.findById(file.id)).deletingOperation, id); + await send(f.ownerHeaders, `/api/files/link/${file.id}`, "GET", undefined, 404); + await send( + f.managerHeaders, + "/api/folder/rename", + "POST", + { folderId: f.folder.id, courseCode: f.code, newName: "blocked" }, + 409, + ); + let removed = 0, + thumbnails = 0; + sub.mock.method(storage, "remove", async (providerId) => { + const journal = await OperationModel.findById(id); + assert.deepEqual( + journal.plan.files.map((entry) => [ + entry.id, + entry.providerId, + entry.thumbnailId, + ]), + [[file.id, file.fileId, "journal-thumbnail"]], + ); + assert.equal(providerId, file.fileId); + removed++; + }); + sub.mock.method(getImageKit().files, "delete", async () => { + thumbnails++; + throw new StorageError(503); + }); + await OperationModel.updateOne({ _id: id }, { $set: { attempts: 4 } }); + assert.equal((await run(id)).status, "failed"); + assert.ok(await FileModel.findById(file.id)); + assert.equal(await CourseLock.countDocuments({ owner: id }), 2); + await send(f.ownerHeaders, `/api/operations/${id}`, "GET", undefined, 404); + await send(f.managerHeaders, `/api/operations/${id}/retry`, "POST", {}, 403); + await send(f.adminHeaders, `/api/operations/${id}/retry`, "POST", {}, 202); + sub.mock.method(getImageKit().files, "delete", async () => { + thumbnails++; + throw Object.assign(new Error("already absent"), { status: 404 }); + }); + await OperationModel.updateOne( + { _id: id }, + { $set: { status: "running", leaseToken: "crashed", leaseUntil: new Date(0) } }, + ); + assert.equal((await run(id)).status, "completed"); + assert.equal(removed, 1); + assert.equal(thumbnails, 2); + assert.equal(await FileModel.findById(file.id), null); + assert.equal(await CourseLock.countDocuments({ owner: id }), 0); + assert.equal((await send(f.managerHeaders, path, "DELETE", body, 202)).operationId, id); + await acquireCourseLocks([f.code], id, { durable: true }); + await recoverLocks(); + assert.equal(await CourseLock.countDocuments({ owner: id }), 0); + }, + ); + + await t.test( + "shared year removal only unlinks; deleting the remaining course cleans unique content and protects the provider root", + async (sub) => { + const f = await fixture(); + const file = await FileModel.create({ + name: "unique.pdf", + fileId: "test-storage-root", + size: "1", + isVerified: true, + }); + await FolderModel.updateOne( + { _id: f.folder.id }, + { $addToSet: { children: file._id } }, + ); + await send( + f.managerHeaders, + `/api/files/unverify/${file.id}`, + "DELETE", + { courseCode: f.code, affectedCourses: [f.code, f.sharedCode] }, + 403, + ); + assert.equal((await FileModel.findById(file.id)).deletingOperation, undefined); + await FileModel.updateOne({ _id: file.id }, { $set: { fileId: "unique-item" } }); + const unlinked = await send( + f.managerHeaders, + "/api/year/delete", + "DELETE", + { folderId: f.root.id, courseCode: f.code }, + 202, + ); + assert.equal((await run(unlinked.operationId)).status, "completed"); + assert.deepEqual((await FolderModel.findById(f.folder.id)).courses, [f.sharedCode]); + assert.ok(await FileModel.findById(file.id)); + assert.equal( + ( + await send( + f.managerHeaders, + "/api/year/delete", + "DELETE", + { folderId: f.root.id, courseCode: f.code }, + 202, + ) + ).operationId, + unlinked.operationId, + ); + const deleted = []; + sub.mock.method(storage, "remove", async (id) => { + deleted.push(id); + }); + const removed = await send( + f.adminHeaders, + `/api/admin/course/${f.sharedCode}/delete`, + "DELETE", + {}, + 202, + ); + assert.equal((await run(removed.operationId)).status, "completed"); + assert.deepEqual(deleted, ["unique-item"]); + assert.equal(await FolderModel.findById(f.root.id), null); + assert.equal(await FileModel.findById(file.id), null); + assert.ok(await Course.findOne({ code: f.code })); + }, + ); + + await t.test( + "stale multipart reservations and orphaned locks release bounded staging capacity on restart", + async () => { + const f = await fixture(), + op = await create(f, [{ name: "stalled.pdf", size: 5 }]); + const req = { + user: f.owner, + headers: { "contribution-id": op.id, "upload-file-id": op.entries[0].id }, + }; + const reservation = await reserveUpload(req); + await fs.mkdir(uploadDirectory(), { recursive: true }); + await fs.writeFile(temporaryPath(reservation.temporaryName), "part"); + await OperationModel.updateOne( + { _id: op.id }, + { $set: { "entries.0.receiveUntil": new Date(0) } }, + ); + await recoverReceiving(); + assert.equal((await OperationModel.findById(op.id)).entries[0].state, "failed"); + assert.equal(await StorageLease.countDocuments({ owner: reservation.token }), 0); + await assert.rejects(fs.access(temporaryPath(reservation.temporaryName)), { + code: "ENOENT", + }); + const reservations = []; + for (let index = 0; index < 8; index++) { + const next = await create(f, [{ name: "bounded.pdf", size: 5 }]); + reservations.push( + await reserveUpload({ + user: f.owner, + headers: { + "contribution-id": next.id, + "upload-file-id": next.entries[0].id, + }, + }), + ); + } + try { + await assert.rejects(reserveUpload(req), { code: "STORAGE_BUSY" }); + } finally { + for (const item of reservations) await receiveFailed(item); + } + assert.equal(await StorageLease.countDocuments({ _id: /^receive:/ }), 0); + }, + ); + + await t.test( + "the production multipart boundary rejects actual bytes above 100 MiB and cleans its temporary file", + async () => { + const f = await fixture(), + op = await create(f, [{ name: "oversize.pdf", size: uploadLimits.fileBytes }]); + const boundary = "coursehub-byte-limit", + start = Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="oversize.pdf"\r\nContent-Type: application/pdf\r\n\r\n`, + ), + end = Buffer.from(`\r\n--${boundary}--\r\n`); + const size = uploadLimits.fileBytes + 1; + const request = http.request(origin + "/api/contribution/upload", { + method: "POST", + headers: { + ...f.ownerHeaders, + "contribution-id": op.id, + "upload-file-id": op.entries[0].id, + "content-type": `multipart/form-data; boundary=${boundary}`, + "content-length": start.length + size + end.length, + }, + }); + const responsePromise = once(request, "response"); + request.write(start); + const chunk = Buffer.alloc(1024 * 1024, 120); + for (let sent = 0; sent < size; ) { + const bytes = chunk.subarray(0, Math.min(chunk.length, size - sent)); + if (!request.write(bytes)) await once(request, "drain"); + sent += bytes.length; + } + request.end(end); + const [response] = await responsePromise; + assert.equal(response.statusCode, 413); + for await (const _ of response) { + /* Drain the safe error response. */ + } + const record = await OperationModel.findById(op.id); + assert.equal(record.entries[0].state, "failed"); + await assert.rejects(fs.access(temporaryPath(record.entries[0].temporaryName)), { + code: "ENOENT", + }); + assert.equal(await StorageLease.countDocuments({ _id: /^receive:/ }), 0); + }, + ); +} diff --git a/server/test/support/provider-guards.js b/server/test/support/provider-guards.js index 870c1ce7..52f1745d 100644 --- a/server/test/support/provider-guards.js +++ b/server/test/support/provider-guards.js @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import axios from "axios"; +import { storageTokens } from "../../services/tokenStore.js"; export function guardExternalServices(t) { const attempts = []; @@ -8,6 +9,7 @@ export function guardExternalServices(t) { attempts.push(name); throw new Error(`External services are blocked in tests: ${name}`); }; + t.mock.method(storageTokens, "getAccessToken", () => deny("storage token")); for (const method of ["get", "post", "put", "patch", "delete", "request"]) { t.mock.method(axios, method, () => deny(`axios.${method}`)); } diff --git a/server/test/support/session-security.js b/server/test/support/session-security.js index 298b310c..13fcfc74 100644 --- a/server/test/support/session-security.js +++ b/server/test/support/session-security.js @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import axios from "axios"; +import { graph } from "../../services/graphClient.js"; import jwt from "jsonwebtoken"; import User from "../../modules/user/user.model.js"; import UserUpdate from "../../modules/user/userUpdate.model.js"; @@ -326,7 +327,7 @@ export async function exerciseSessionSecurity(t, origin) { ); return { data: { access_token: "test-graph-access" } }; }); - st.mock.method(axios, "get", async () => ({ + st.mock.method(graph, "request", async () => ({ data: { mail: person.email, surname: String(person.rollNumber) }, })); for (const headers of [{}, { cookie: other.cookie }]) diff --git a/server/test/uploads.test.js b/server/test/uploads.test.js new file mode 100644 index 00000000..4dbe2e86 --- /dev/null +++ b/server/test/uploads.test.js @@ -0,0 +1,68 @@ +import "./support/environment.js"; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { validateManifest, temporaryPath } from "../services/uploads.js"; +import { uploadLimits } from "../config/storage.js"; + +test("upload manifests enforce exact byte/count limits and generate independent identities", () => { + const file = { name: "Lecture notes.pdf", size: uploadLimits.fileBytes }; + assert.equal(validateManifest(Array.from({ length: 10 }, () => file)).length, 10); + assert.equal( + validateManifest(Array.from({ length: 40 }, () => ({ ...file, size: 1 }))).length, + 40, + ); + const full = [...Array.from({ length: 10 }, () => file), { ...file, size: 24 * 1024 * 1024 }]; + assert.equal( + validateManifest(full).reduce((sum, entry) => sum + entry.size, 0), + uploadLimits.batchBytes, + ); + for (const manifest of [ + [{ ...file, size: file.size + 1 }], + [...full, { ...file, size: 1 }], + Array.from({ length: 41 }, () => ({ ...file, size: 1 })), + ]) + assert.throws(() => validateManifest(manifest), { status: 413 }); + for (const manifest of [ + null, + {}, + [], + [null], + [{ ...file, size: 0 }], + [{ ...file, size: 1.5 }], + [{ ...file, size: "12" }], + [{ ...file, approved: true }], + [{ ...file, fileId: "forged" }], + ]) + assert.throws(() => validateManifest(manifest), { status: 400 }); + const entries = validateManifest([file, file]); + assert.notEqual(entries[0].id, entries[1].id); + assert.notEqual(String(entries[0].fileId), String(entries[1].fileId)); + assert.notEqual(entries[0].remoteName, entries[1].remoteName); + assert.equal(entries[0].name, file.name); +}); + +test("display filenames cannot escape storage or masquerade as path identities", () => { + for (const name of [ + "../notes.pdf", + "a/b.pdf", + "a\\b.pdf", + "..", + ".", + "", + "CON.pdf", + "COM9", + "LPT1.txt", + "trailing.", + "trailing ", + "secret\0.pdf", + "a:b.pdf", + "x".repeat(241), + ]) + assert.throws(() => validateManifest([{ name, size: 1 }]), { code: "INVALID_FILENAME" }); + assert.equal( + validateManifest([{ name: "Lecture १ — résumé.pdf", size: 1 }])[0].name, + "Lecture १ — résumé.pdf", + ); + for (const name of ["../example.upload", "/tmp/example.upload", "example.pdf", null]) + assert.throws(() => temporaryPath(name), { status: 400 }); +}); diff --git a/server/utils/graphError.js b/server/utils/graphError.js deleted file mode 100644 index b4908a5b..00000000 --- a/server/utils/graphError.js +++ /dev/null @@ -1,79 +0,0 @@ -function normalizeMessage(message) { - if (!message) return "Microsoft Graph request failed"; - const singleLine = String(message).replace(/\s+/g, " ").trim(); - return singleLine.slice(0, 400); -} - -function getGraphErrorPayload(data) { - if (!data) return {}; - if (typeof data === "string") { - return { - message: normalizeMessage(data), - }; - } - - if (data.error && typeof data.error === "object") { - return data.error; - } - - return data; -} - -export function extractGraphErrorDetails(error) { - const response = error?.response; - const payload = getGraphErrorPayload(response?.data); - const innerError = payload?.innerError || {}; - - const status = response?.status || undefined; - const code = payload?.code || response?.data?.code || undefined; - const message = normalizeMessage( - payload?.message || - response?.data?.error_description || - error?.message || - "Microsoft Graph request failed", - ); - const requestId = - innerError?.["request-id"] || - response?.headers?.["request-id"] || - response?.headers?.["x-ms-request-id"] || - undefined; - const clientRequestId = - innerError?.["client-request-id"] || response?.headers?.["client-request-id"] || undefined; - - return { - status, - code, - message, - requestId, - clientRequestId, - method: error?.config?.method ? String(error.config.method).toUpperCase() : undefined, - endpoint: error?.config?.url || undefined, - }; -} - -export function formatGraphErrorMessage(details, prefix = "Microsoft Graph request failed") { - const statusPart = details?.status ? ` (${details.status})` : ""; - const codePart = details?.code ? ` [${details.code}]` : ""; - const message = details?.message || "Unknown error"; - return `${prefix}${statusPart}${codePart}: ${message}`; -} - -export function isGraphError(error) { - if (error?.graphDetails) return true; - const endpoint = error?.config?.url || error?.response?.config?.url || ""; - return endpoint.includes("graph.microsoft.com"); -} - -export function logGraphError(logger, error, context = "Microsoft Graph request failed") { - const details = error?.graphDetails || extractGraphErrorDetails(error); - logger.error("Microsoft Graph request failed", { - error, - attributes: { - dependency: "microsoft-graph", - operation: "request", - outcome: "failure", - retryable: true, - }, - }); - return details; -} From 4d1a4f12eea11ac8f5b6d2e62c0c40b4a12dc3be Mon Sep 17 00:00:00 2001 From: maydayv7 Date: Wed, 9 Sep 2026 13:44:26 +0530 Subject: [PATCH 07/20] fix: Preserve shared course content - Consolidated tree traversal, membership checks and removal planning. - Preserved populated duplicate years and added visible linking conflicts. - Added recoverable linking with course locks, saved plans and retries. - Removed obsolete walkers, count scripts and unused code. --- README.md | 1 + admin/src/apis/courses.js | 24 - admin/src/apis/operations.js | 8 +- admin/src/components/LinkingResult.jsx | 31 ++ admin/src/components/OperationNotice.jsx | 12 +- admin/src/pages/CourseLinking.jsx | 153 ++++-- admin/src/pages/Operations.jsx | 19 +- client/src/screens/contributions/index.jsx | 2 +- .../components/contributionbanner/index.jsx | 8 +- docs/shared-course-trees.md | 26 + .../admin/adminDashboard.controller.js | 231 +------- server/modules/course/course.service.js | 135 +++-- server/modules/folder/folder.controller.js | 19 +- .../modules/operation/operation.controller.js | 22 +- server/modules/operation/operation.model.js | 2 +- server/scripts/recalculateFolderCounts.js | 63 --- server/services/authorization.js | 129 ++--- server/services/contentMutation.js | 8 +- server/services/courseLinking.js | 291 ++++++++++ server/services/deletions.js | 43 +- server/services/folderTrees.js | 137 +++++ server/services/operationJournal.js | 38 ++ server/services/operationWorker.js | 6 +- server/services/uploads.js | 6 + server/test/browser/linking.test.js | 180 +++++++ server/test/browser/session-profile.test.js | 20 +- server/test/fixtures/linking.js | 39 ++ server/test/folder-trees.test.js | 170 ++++++ server/test/integration/server.test.js | 3 + server/test/support/shared-trees.js | 500 ++++++++++++++++++ server/test/uploads.test.js | 4 +- server/utils/folder.js | 42 -- 32 files changed, 1764 insertions(+), 608 deletions(-) create mode 100644 admin/src/components/LinkingResult.jsx create mode 100644 docs/shared-course-trees.md delete mode 100644 server/scripts/recalculateFolderCounts.js create mode 100644 server/services/courseLinking.js create mode 100644 server/services/folderTrees.js create mode 100644 server/services/operationJournal.js create mode 100644 server/test/browser/linking.test.js create mode 100644 server/test/fixtures/linking.js create mode 100644 server/test/folder-trees.test.js create mode 100644 server/test/support/shared-trees.js delete mode 100644 server/utils/folder.js diff --git a/README.md b/README.md index 8829c00d..5ef93d1f 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,7 @@ npm run build - [Usage Guide](https://codingclub.in/blog/meet-coursehub-find-share-and-organise-course-material) - [Authentication and session configuration](docs/authentication.md) - [Storage configuration, uploads, and operation recovery](docs/storage-operations.md) +- [Shared course trees and linking](./docs/shared-course-trees.md) - [Course linking and shared-folder model](./docs/course_link_logic.md) - [Frontend caching](./docs/frontend-caching.md) diff --git a/admin/src/apis/courses.js b/admin/src/apis/courses.js index 078c7b23..99001ff7 100644 --- a/admin/src/apis/courses.js +++ b/admin/src/apis/courses.js @@ -127,30 +127,6 @@ export const bulkSyncCourses = async (courses, analysis, onProgress = null) => { return results; }; -export const linkLegacyCourse = async (targetCode, legacyCode) => { - try { - const safeCode = targetCode.toLowerCase().trim(); - const response = await apiFetch(`${API_BASE_URL}api/admin/course/${safeCode}/link`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ legacyCode }), - credentials: "include", - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || "Failed to link legacy course"); - } - - return await response.json(); - } catch (error) { - console.error("Error linking legacy course:", error); - throw error; - } -}; - // Delete a course export const deleteCourse = async (code) => { try { diff --git a/admin/src/apis/operations.js b/admin/src/apis/operations.js index a866c69b..6aa9fdb3 100644 --- a/admin/src/apis/operations.js +++ b/admin/src/apis/operations.js @@ -13,7 +13,11 @@ export const listOperations = (page, status, signal) => export const retryOperation = (id) => request(`/${id}/retry`, { method: "POST" }); export async function waitForOperation(accepted) { if (!accepted?.operationId) return accepted; - window.dispatchEvent(new CustomEvent(operationEvent)); + window.dispatchEvent( + new CustomEvent(operationEvent, { + detail: { kind: accepted.kind || "delete", status: "queued" }, + }), + ); for (;;) { const operation = await getOperation(accepted.operationId); window.dispatchEvent(new CustomEvent(operationEvent, { detail: operation })); @@ -21,7 +25,7 @@ export async function waitForOperation(accepted) { if (["failed", "cancelled"].includes(operation.status)) throw new Error( operation.error?.message || - "Cleanup could not finish. Open Operations to review and retry.", + "The operation could not finish. Open Operations to review and retry.", ); await new Promise((resolve) => setTimeout(resolve, 1000)); } diff --git a/admin/src/components/LinkingResult.jsx b/admin/src/components/LinkingResult.jsx new file mode 100644 index 00000000..caaf9a19 --- /dev/null +++ b/admin/src/components/LinkingResult.jsx @@ -0,0 +1,31 @@ +export default function LinkingResult({ result, completed = true }) { + if (!result) return null; + return ( +
+

+ {result.sourceCode} → {result.targetCode} +

+

+ {completed ? "Linked" : "Planned"}: {result.linked.length}{" "} + {result.linked.length === 1 ? "year" : "years"}. Already linked:{" "} + {result.alreadyLinked.length}. Empty replacements: {result.replaced.length}. +

+ {result.conflicts.length > 0 && ( +
+

+ {result.conflicts.length} year{" "} + {result.conflicts.length === 1 ? "conflict" : "conflicts"} - content + preserved +

+
    + {result.conflicts.map((conflict, index) => ( +
  • + {conflict.year}: {conflict.reason} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/admin/src/components/OperationNotice.jsx b/admin/src/components/OperationNotice.jsx index 11fd6dba..e1a55eff 100644 --- a/admin/src/components/OperationNotice.jsx +++ b/admin/src/components/OperationNotice.jsx @@ -9,15 +9,21 @@ export default function OperationNotice() { window.addEventListener(operationEvent, update); return () => window.removeEventListener(operationEvent, update); }, []); + const activity = + operation?.kind === "link" + ? "Linking" + : operation?.kind === "upload" + ? "Upload" + : "Cleanup"; return (
{operation && ( {operation.status === "completed" - ? "Cleanup completed." + ? `${activity} completed.` : operation.status === "failed" - ? "Cleanup needs attention." - : "Cleanup is in progress. You can leave this page."} + ? `${activity} needs attention.` + : `${activity} is in progress. You can leave this page.`} )} { setUploading(true); setError(null); setResults(null); - + const formData = new FormData(); formData.append("file", file); @@ -58,17 +60,38 @@ const CourseLinking = () => { try { data = JSON.parse(text); } catch { - throw new Error(`Server error (${response.status}): ${text.slice(0, 100)}`); + throw new Error(`Linking is unavailable (${response.status}). Please retry.`); } if (!response.ok) { throw new Error(data.message || "Bulk linking failed"); } - setResults(data.summary); + const summary = { + success: 0, + failed: data.summary.failed, + errors: [...data.summary.errors], + items: [], + }; + setResults({ ...summary }); + for (const accepted of data.summary.operations) { + try { + const operation = await waitForOperation(accepted); + summary.success++; + summary.items.push(operation.linking); + } catch (failure) { + summary.failed++; + summary.errors.push({ + oldCode: accepted.oldCode, + newCode: accepted.newCode, + error: failure.message, + }); + } + setResults({ ...summary, items: [...summary.items], errors: [...summary.errors] }); + } setFile(null); // Reset file input after success - - if (data.summary.success > 0 && data.summary.failed === 0) { + + if (summary.success > 0 && summary.failed === 0) { setError(null); } } catch (err) { @@ -87,21 +110,25 @@ const CourseLinking = () => { setManualSuccess(null); try { - const response = await apiFetch(`${API_BASE_URL}api/admin/course/${manualNewCode.toLowerCase().trim()}/link`, { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await apiFetch( + `${API_BASE_URL}api/admin/course/${manualNewCode.toLowerCase().trim()}/link`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ legacyCode: manualOldCode.toUpperCase().trim() }), + credentials: "include", }, - body: JSON.stringify({ legacyCode: manualOldCode.toUpperCase().trim() }), - credentials: "include", - }); + ); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || "Manual linking failed"); } - setManualSuccess(`Successfully linked ${manualOldCode.toUpperCase()} to ${manualNewCode.toUpperCase()}`); + const operation = await waitForOperation(await response.json()); + setManualSuccess(operation.linking); setManualOldCode(""); setManualNewCode(""); } catch (err) { @@ -115,7 +142,6 @@ const CourseLinking = () => { return (
- {/* Header */}
@@ -134,12 +160,23 @@ const CourseLinking = () => { {/* Instructions Alert */} - What happens when you link courses? + + What happens when you link courses? +
    -
  • The new course code will display folders from the legacy course.
  • -
  • If the new course already has files in a specific year, those files are kept. The legacy folders for that year will not overwrite them.
  • -
  • Folders are shared. Deleting a shared folder from one course only removes the link for that course; the folder is kept for the other course.
  • +
  • + The new course code will display folders from the legacy course. +
  • +
  • + If the new course already has files in a specific year, those files + are kept. The legacy folders for that year will not overwrite them. +
  • +
  • + Folders are shared. Deleting a shared folder from one course only + removes the link for that course; the folder is kept for the other + course. +
@@ -169,7 +206,6 @@ const CourseLinking = () => {
- {/* Left Column: Input Forms */}
{activeTab === "manual" && ( @@ -180,8 +216,11 @@ const CourseLinking = () => {
- + setManualOldCode(e.target.value)} placeholder="e.g., CS101" @@ -189,8 +228,11 @@ const CourseLinking = () => { />
- + setManualNewCode(e.target.value)} placeholder="e.g., CSN101" @@ -215,19 +257,26 @@ const CourseLinking = () => { Upload CSV

- Upload a CSV file containing exactly two columns. The first column is the legacy course code, and the second column is the new course code. No column headers are required. + Upload a CSV file containing exactly two columns. The first + column is the legacy course code, and the + second column is the new course code. No column + headers are required.

- +
-