diff --git a/classes/Project/ProjectFactory.js b/classes/Project/ProjectFactory.js index 9e6b9213..ad7f2b71 100644 --- a/classes/Project/ProjectFactory.js +++ b/classes/Project/ProjectFactory.js @@ -729,6 +729,7 @@ export default class ProjectFactory { * manifest data is assembled, and the final JSON is saved to the filesystem. * * @param {string} projectId - Project ID for a specific project. + * @param {Object|null} preloadedProjectData - Pre-loaded project data to avoid a redundant DB query. Falls back to loadAsUser() if null. * @returns {Object} - Returns the assembled IIIF manifest object. * * The manifest follows the IIIF Presentation API 3.0 specification and includes: @@ -736,12 +737,15 @@ export default class ProjectFactory { * - A dynamically fetched list of manifest items, including canvases and their annotations. * - All elements are embedded in the manifest object. */ - static async exportManifest(projectId) { + static async exportManifest(projectId, preloadedProjectData = null) { if (!projectId) { throw { status: 400, message: "No project ID provided" } } - const project = await ProjectFactory.loadAsUser(projectId, null) + const project = preloadedProjectData ?? await ProjectFactory.loadAsUser(projectId, null) + if (!project || project instanceof Error) { + throw { status: project?.status || 404, message: project?.message || `No project found with ID '${projectId}'` } + } const manifestJson = await this.fetchJson(project.manifest[0]) const manifest = { @@ -1003,7 +1007,7 @@ export default class ProjectFactory { }, { $set: { - roles: { $mergeObjects: [{ $ifNull: ['$thisGroup.customRoles', {}] }, Group.defaultRoles] }, + roles: { $mergeObjects: [Group.defaultRoles, { $ifNull: ['$thisGroup.customRoles', {}] }] }, } }, { diff --git a/layer/index.js b/layer/index.js index f91b666a..fef114f8 100644 --- a/layer/index.js +++ b/layer/index.js @@ -42,7 +42,7 @@ router.route('/:layerId') return respondWithError(res, 403, 'You do not have permission to update this layer') } if (!project?.data) return respondWithError(res, 404, `Project ${projectId} was not found`) - const layer = await findLayerById(layerId, projectId) + const layer = await findLayerById(layerId, projectId, project) // Only update top-level properties that are present in the request Object.keys(update ?? {}).forEach(key => { layer[key] = update[key] @@ -56,7 +56,7 @@ router.route('/:layerId') }) let pages = [] if (providedPages && Array.isArray(providedPages) && providedPages.length > 0) { - pages = await Promise.all(providedPages.map(p => findPageById(p.split("/").pop(), projectId) )) + pages = await Promise.all(providedPages.map(p => findPageById(p.split("/").pop(), projectId, project) )) layer.pages = pages } await updateLayerAndProject(layer, project, user._id) diff --git a/line/index.js b/line/index.js index 43b1ff35..724e54b4 100644 --- a/line/index.js +++ b/line/index.js @@ -56,7 +56,7 @@ router.post('/', auth0Middleware(), async (req, res) => { return respondWithError(res, 403, 'You do not have permission to create lines in this project') } if (!project?.data) return respondWithError(res, 404, `Project ${req.params.projectId} was not found`) - const page = await findPageById(req.params.pageId, req.params.projectId) + const page = await findPageById(req.params.pageId, req.params.projectId, project) if (!req.body || (Array.isArray(req.body) && req.body.length === 0)) { return respondWithError(res, 400, "Request body with line data is required") @@ -120,7 +120,7 @@ router.put('/:lineId', auth0Middleware(), screenContentMiddleware(), async (req, return respondWithError(res, 403, 'You do not have permission to update lines in this project') } if (!project?.data) return respondWithError(res, 404, `Project ${req.params.projectId} was not found`) - const page = await findPageById(req.params.pageId, req.params.projectId) + const page = await findPageById(req.params.pageId, req.params.projectId, project) let oldLine = page.items?.find(l => l.id.split('/').pop() === req.params.lineId?.split('/').pop()) if (!oldLine) { return respondWithError(res, 404, `Line with ID '${req.params.lineId}' not found in page '${req.params.pageId}'`) @@ -202,7 +202,7 @@ router.patch('/:lineId/text', auth0Middleware(), screenContentMiddleware(), asyn if (typeof req.body !== 'string') { return respondWithError(res, 400, 'Invalid request body. Expected a string.') } - const page = await findPageById(req.params.pageId, req.params.projectId) + const page = await findPageById(req.params.pageId, req.params.projectId, project) const oldLine = page.items?.find(l => l.id.split('/').pop() === req.params.lineId?.split('/').pop()) if (!oldLine) { return respondWithError(res, 404, `Line with ID '${req.params.lineId}' not found in page '${req.params.pageId}'`) @@ -282,7 +282,7 @@ router.patch('/:lineId/bounds', auth0Middleware(), async (req, res) => { return respondWithError(res, 400, 'Invalid request body. Expected an object with x, y, w, and h as non-negative integers.') } const bounds = { x: parseInt(req.body.x, 10), y: parseInt(req.body.y, 10), w: parseInt(req.body.w, 10), h: parseInt(req.body.h, 10) } - const page = await findPageById(req.params.pageId, req.params.projectId) + const page = await findPageById(req.params.pageId, req.params.projectId, project) const findOldLine = page.items?.find(l => l.id.split('/').pop() === req.params.lineId?.split('/').pop()) if (!findOldLine) { return respondWithError(res, 404, `Line with ID '${req.params.lineId}' not found in page '${req.params.pageId}'`) diff --git a/page/index.js b/page/index.js index 012e6de1..da2d61a9 100644 --- a/page/index.js +++ b/page/index.js @@ -91,7 +91,7 @@ router.route('/:pageId') try { if (hasSuspiciousPageData(req.body)) return respondWithError(res, 400, "Suspicious page data will not be processed.") // Find the page object - const page = await findPageById(pageId, projectId) + const page = await findPageById(pageId, projectId, project) page.creator = user.agent.split('/').pop() page.partOf = layerId diff --git a/project/customMetadataRouter.js b/project/customMetadataRouter.js index d232d351..3609bd8d 100644 --- a/project/customMetadataRouter.js +++ b/project/customMetadataRouter.js @@ -90,8 +90,7 @@ router.route("/:id/custom").get(auth0Middleware(), async (req, res) => { return respondWithError(res, 403, "You do not have permission to read this project's metadata") } - // Fetch the project from database - const projectData = await database.findOne({ _id: id }, "projects") + const projectData = project.data if (!projectData) { return respondWithError(res, 404, `No TPEN3 project with ID '${id}' found`) @@ -139,8 +138,8 @@ router.route("/:id/custom").post(auth0Middleware(), async (req, res) => { // Get namespace from request origin const namespace = getNamespaceFromOrigin(req) - // Fetch the full project data - const projectData = await database.findOne({ _id: id }, "projects") + // Use the already-loaded project data + const projectData = project.data if (!projectData) { return respondWithError(res, 404, `No TPEN3 project with ID '${id}' found`) @@ -195,8 +194,8 @@ router.route("/:id/custom").put(auth0Middleware(), async (req, res) => { // Get namespace from request origin const namespace = getNamespaceFromOrigin(req) - // Fetch the full project data - const projectData = await database.findOne({ _id: id }, "projects") + // Use the already-loaded project data + const projectData = project.data if (!projectData) { return respondWithError(res, 404, `No TPEN3 project with ID '${id}' found`) diff --git a/project/projectReadRouter.js b/project/projectReadRouter.js index 60cb9bf5..e82a7d06 100644 --- a/project/projectReadRouter.js +++ b/project/projectReadRouter.js @@ -92,6 +92,37 @@ function filterProjectInterfaces(project, namespaces) { } } +/** + * Check whether a user has the required access on a project loaded via ProjectFactory.loadAsUser(). + * This avoids a second database round-trip when the project data (with collaborators/roles) is + * already available from the loadAsUser aggregation result. + * + * @param {Object} projectData - The project object returned by ProjectFactory.loadAsUser() + * @param {string} userId - The user ID to check + * @param {string} action - Required action (e.g. ACTIONS.READ) + * @param {string} scope - Required scope (e.g. SCOPES.ALL) + * @param {string} entity - Required entity (e.g. ENTITIES.PROJECT) + * @returns {boolean} + */ +function userHasAccess(projectData, userId, action, scope, entity) { + const userRoleNames = projectData?.collaborators?.[userId]?.roles + if (!userRoleNames || !Array.isArray(userRoleNames)) return false + const rolePermissions = projectData.roles ?? {} + return userRoleNames.some(role => { + let perms = rolePermissions[role] + if (!perms) return false + // Custom roles may store permissions as a space-delimited string + if (typeof perms === 'string') perms = perms.split(' ') + if (!Array.isArray(perms)) return false + return perms.some(perm => { + const [permAction, permScope, permEntity] = perm.split("_") + return (permAction === action || permAction === "*") && + (permScope === scope || permScope === "*") && + (permEntity === entity || permEntity === "*") + }) + }) +} + router.route("/:id/manifest").get(auth0Middleware(), async (req, res) => { const { id } = req.params @@ -100,14 +131,14 @@ router.route("/:id/manifest").get(auth0Middleware(), async (req, res) => { if (!id) return respondWithError(res, 400, "No TPEN3 ID provided") if (!validateID(id)) return respondWithError(res, 400, "The TPEN3 project ID provided is invalid") try { - if (!await new Project(id).checkUserAccess(user._id, ACTIONS.UPDATE, SCOPES.ALL, ENTITIES.PROJECT)) { + const project = new Project(id) + if (!await project.checkUserAccess(user._id, ACTIONS.UPDATE, SCOPES.ALL, ENTITIES.PROJECT)) { return respondWithError(res, 403, "You do not have permission to export this project") } - const project = await ProjectFactory.loadAsUser(id, null) - if (!project) { + if (!project.data) { return respondWithError(res, 404, `No TPEN3 project with ID '${id}' found`) } - const manifest = await ProjectFactory.exportManifest(id) + const manifest = await ProjectFactory.exportManifest(id, project.data) await ProjectFactory.uploadFileToGitHub(manifest, `${id}`) res.status(200).json(manifest) } catch (error) { @@ -158,14 +189,17 @@ router.route("/:id").get(auth0Middleware(), async (req, res) => { if (!id) return respondWithError(res, 400, "No TPEN3 ID provided") if (!validateID(id)) return respondWithError(res, 400, "The TPEN3 project ID provided is invalid") try { - const project = new Project(id) - if (!(await project.checkUserAccess(user._id, ACTIONS.READ, SCOPES.ALL, ENTITIES.PROJECT))) { - return respondWithError(res, 403, "You do not have permission to view this project") - } + // loadAsUser() returns an Error object (not throws) on DB failure const projectData = await ProjectFactory.loadAsUser(id, user._id) + if (projectData instanceof Error) { + return respondWithError(res, projectData.status || 500, projectData.message ?? "An error occurred while fetching the project data.") + } if (!projectData) { return respondWithError(res, 404, `No TPEN3 project with ID '${id}' found`) } + if (!userHasAccess(projectData, user._id, ACTIONS.READ, SCOPES.ALL, ENTITIES.PROJECT)) { + return respondWithError(res, 403, "You do not have permission to view this project") + } // Filter interfaces based on origin and query parameters const namespacesToInclude = getNamespacesToInclude(req) diff --git a/utilities/shared.js b/utilities/shared.js index e2ff2441..096182e2 100644 --- a/utilities/shared.js +++ b/utilities/shared.js @@ -252,8 +252,8 @@ export const getLayerContainingPage = (project, pageId) => { } // Find a page by ID (moved from page/index.js) -export async function findPageById(pageId, projectId) { - const projectData = (await Project.getById(projectId))?.data +export async function findPageById(pageId, projectId, project = null) { + const projectData = project?.data ?? (await Project.getById(projectId))?.data if (!projectData) { const error = new Error(`Project ${projectId} was not found`) error.status = 404 @@ -279,8 +279,8 @@ export async function findPageById(pageId, projectId) { return new Page(layerContainingPage.id, page) } -export async function findLayerById(layerId, projectId) { - const p = await Project.getById(projectId) +export async function findLayerById(layerId, projectId, project = null) { + const p = project?.data ? project : await Project.getById(projectId) if (!p?.data) { const error = new Error(`Project ${projectId} was not found`) error.status = 404