Skip to content
10 changes: 7 additions & 3 deletions classes/Project/ProjectFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -729,19 +729,23 @@ 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:
* - Context, ID, Type, Label, Metadata, Items and Annotations
* - 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 = {
Expand Down Expand Up @@ -1003,7 +1007,7 @@ export default class ProjectFactory {
},
{
$set: {
roles: { $mergeObjects: [{ $ifNull: ['$thisGroup.customRoles', {}] }, Group.defaultRoles] },
roles: { $mergeObjects: [Group.defaultRoles, { $ifNull: ['$thisGroup.customRoles', {}] }] },
}
},
{
Expand Down
4 changes: 2 additions & 2 deletions layer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions line/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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}'`)
Expand Down Expand Up @@ -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}'`)
Expand Down Expand Up @@ -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}'`)
Expand Down
2 changes: 1 addition & 1 deletion page/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 5 additions & 6 deletions project/customMetadataRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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`)
Expand Down
47 changes: 39 additions & 8 deletions project/projectReadRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,34 @@ 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 => {
const perms = rolePermissions[role]
if (!perms || !Array.isArray(perms)) return false
return perms.some(perm => {
Comment on lines +111 to +117
const [permAction, permScope, permEntity] = perm.split("_")
return (permAction === action || permAction === "*") &&
(permScope === scope || permScope === "*") &&
(permEntity === entity || permEntity === "*")
})
})
Comment on lines +107 to +123
}


router.route("/:id/manifest").get(auth0Middleware(), async (req, res) => {
const { id } = req.params
Expand All @@ -100,14 +128,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) {
Expand Down Expand Up @@ -158,14 +186,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)
Expand Down
8 changes: 4 additions & 4 deletions utilities/shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading