Template gallery: add template management authorization middleware

- Middleware to enforce template access rules
- Introduce template manager role
- Fix minor issues
This commit is contained in:
yu-i-i
2026-02-26 03:08:22 +01:00
parent 5aa6d8809a
commit 976716f607
12 changed files with 62 additions and 98 deletions

View File

@@ -753,7 +753,7 @@ const _ProjectController = {
}
const isAdminOrTemplateOwner =
hasAdminAccess(user) || Settings.templates?.nonAdminCanManage
hasAdminAccess(user) || Settings.templates?.nonAdminCanManage || Settings.templates?.user_id === userId
const showTemplatesServerPro =
Features.hasFeature('templates-server-pro') && isAdminOrTemplateOwner

View File

@@ -83,6 +83,7 @@ export const db = {
systemmessages: internalDb.collection('systemmessages'),
tags: internalDb.collection('tags'),
teamInvites: internalDb.collection('teamInvites'),
templates: internalDb.collection('templates'),
tokens: internalDb.collection('tokens'),
userAuditLogEntries: internalDb.collection('userAuditLogEntries'),
users: internalDb.collection('users'),

View File

@@ -0,0 +1,38 @@
import Settings from '@overleaf/settings'
import AdminAuthorizationHelper from '../../../../app/src/Features/Helpers/AdminAuthorizationHelper.mjs'
const { hasAdminAccess } = AdminAuthorizationHelper
import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.mjs'
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs'
import { Template } from './models/Template.mjs'
async function ensureTemplateManagementAccess(req, res, next) {
const user = SessionManager.getSessionUser(req.session)
const userId = SessionManager.getLoggedInUserId(req.session)
const isPrivileged =
hasAdminAccess(user) ||
Settings.templates?.user_id === userId
if (isPrivileged) return next()
const templateId = req.params?.template_id
if (!templateId) {
if (Settings.templates?.nonAdminCanManage) return next()
return HttpErrorHandler.forbidden(req, res)
}
// unprivileged owner is allowed to edit/delete own template
// even non-admin is not allowed to manage templates
const template = await Template.findById(templateId)
.select('owner')
.lean()
if (template?.owner?.toString() === userId) return next()
return HttpErrorHandler.forbidden(req, res)
}
export default {
ensureTemplateManagementAccess,
}

View File

@@ -123,12 +123,14 @@ async function templatesCategoryPage(req, res, next) {
async function templateDetailsPage(req, res, next) {
const t = req.i18n.translate
const userId = SessionManager.getLoggedInUserId(req.session)
try {
const template = await TemplateGalleryManager.getTemplate('_id', req.params.template_id)
res.render(Path.resolve(__dirname, '../views/template_gallery/template'), {
title: `${t('template')}: ${template.name}`,
template: JSON.stringify(template),
languages: Settings.languages,
userIsTemplatesManager: Boolean(Settings.templates?.user_id && Settings.templates.user_id === userId)
})
} catch (error) {
return ErrorController.notFound(req, res, next)

View File

@@ -71,13 +71,13 @@ export async function canUserOverrideTemplate(template, userId) {
userIsOwner = false
try {
userIsAdmin = (await UserGetter.promises.getUser(userId, { isAdmin: 1 })).isAdmin
} catch {
} catch (error) {
logger.error({ error, userId }, 'Logged in user does not exist, strange...')
userIsAdmin = false
}
templateOwnerName = await getUserName(templateOwnerId) || 'unknown'
}
const canOverride = userIsOwner || userIsAdmin
const canOverride = userIsOwner || userIsAdmin || (settings.templates?.user_id === userId)
return { canOverride, templateOwnerName }
}
@@ -193,7 +193,7 @@ export async function deleteTemplateAssets(templateId, version, deleteFromDb) {
try {
await Template.deleteOne({ _id: templateId }).exec()
} catch (error) {
logger.error({ err, templateId }, 'Failed to delete template from MongoDB')
logger.error({ error, templateId }, 'Failed to delete template from MongoDB')
throw error
}
}

View File

@@ -4,6 +4,7 @@ import AuthenticationController from '../../../../app/src/Features/Authenticatio
import RateLimiterMiddleware from '../../../../app/src/Features/Security/RateLimiterMiddleware.mjs'
import { RateLimiter } from '../../../../app/src/infrastructure/RateLimiter.mjs'
import TemplateGalleryController from './TemplateGalleryController.mjs'
import TemplateAuthorizationMiddleware from './TemplateAuthorizationMiddleware.mjs'
const rateLimiterNewTemplate = new RateLimiter('create-template-from-project', {
points: 20,
@@ -18,7 +19,6 @@ const rateLimiterThumbnails = new RateLimiter('template-gallery-thumbnails', {
duration: 60,
})
export default {
rateLimiter,
apply(webRouter) {
@@ -28,6 +28,7 @@ export default {
'/template/new/:Project_id',
AuthenticationController.requireLogin(),
RateLimiterMiddleware.rateLimit(rateLimiterNewTemplate),
TemplateAuthorizationMiddleware.ensureTemplateManagementAccess,
TemplateGalleryController.createTemplateFromProject
)
@@ -41,6 +42,7 @@ export default {
'/template/:template_id/edit',
AuthenticationController.requireLogin(),
RateLimiterMiddleware.rateLimit(rateLimiter),
TemplateAuthorizationMiddleware.ensureTemplateManagementAccess,
TemplateGalleryController.editTemplate
)
@@ -48,6 +50,7 @@ export default {
'/template/:template_id/delete',
AuthenticationController.requireLogin(),
RateLimiterMiddleware.rateLimit(rateLimiter),
TemplateAuthorizationMiddleware.ensureTemplateManagementAccess,
TemplateGalleryController.deleteTemplate
)

View File

@@ -12,6 +12,7 @@ block append meta
meta(name="ol-template" data-type="json" content=template)
meta(name="ol-languages" data-type="json" content=languages)
meta(name="ol-userIsAdmin" data-type="boolean" content=hasAdminAccess())
meta(name="ol-userIsTemplatesManager" data-type="boolean" content=userIsTemplatesManager)
block content
#template-root

View File

@@ -1,80 +0,0 @@
import { useTranslation } from 'react-i18next'
export default function Pagination({ currentPage, totalPages, onPageChange }) {
const { t } = useTranslation()
if (totalPages <= 1) return null
const pageNumbers = []
let startPage = Math.max(1, currentPage - 4)
let endPage = Math.min(totalPages, currentPage + 4)
if (startPage > 1) {
pageNumbers.push(1)
if (startPage > 2) {
pageNumbers.push("...")
}
}
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(i)
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
pageNumbers.push("...")
}
pageNumbers.push(totalPages)
}
return (
<nav role="navigation" aria-label={t('pagination_navigation')}>
<ul className="pagination">
{/*
{currentPage > 1 && (
<li>
<button aria-label={t('go_to_first_page')} onClick={() => onPageChange(1)}>
&lt;&lt; {t('first')}
</button>
</li>
)}
*/}
{currentPage > 1 && (
<li>
<button aria-label={t('go_prev_page')} onClick={() => onPageChange(currentPage - 1)}>
&lt; {t('prev')}
</button>
</li>
)}
{pageNumbers.map((page, index) => (
<li key={index} className={page === currentPage ? "active" : ""}>
{page === "..." ? (
<span aria-hidden="true">{page}</span>
) : page === currentPage ? (
<span aria-label={t('page_current', { page })} aria-current="true">{page}</span>
) : (
<button aria-label={t('go_page', { page })} onClick={() => onPageChange(page)}>
{page}
</button>
)}
</li>
))}
{currentPage < totalPages && (
<li>
<button aria-label={t('go_next_page')} onClick={() => onPageChange(currentPage + 1)}>
{t('next')} &gt;
</button>
</li>
)}
{/*
{currentPage < totalPages && (
<li>
<button aria-label={t('go_to_last_page')} onClick={() => onPageChange(totalPages)}>
{t('last')} &gt;&gt;
</button>
</li>
)}
*/}
</ul>
</nav>
)
}

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import OLRow from '@/shared/components/ol/ol-row'
import { useTemplateGalleryContext } from '../context/template-gallery-context'
import TemplateGalleryEntry from './template-gallery-entry'
import Pagination from './pagination'
import Pagination from '@/shared/components/pagination-cep'
export default function TemplateGallery() {
const { t } = useTranslation()

View File

@@ -16,7 +16,8 @@ function TemplateDetails() {
const lastUpdatedDate = fromNowDate(template.lastUpdated)
const tooltipText = formatDate(template.lastUpdated)
const loggedInUserId = getMeta('ol-user_id')
const loggedInUserIsAdmin = getMeta('ol-userIsAdmin')
const loggedInUserCanManageTemplates = getMeta('ol-userIsAdmin')
|| getMeta('ol-userIsTemplatesManager')
const openAsTemplateParams = new URLSearchParams({
version: template.version,
@@ -90,7 +91,7 @@ function TemplateDetails() {
</div>
)}
</div>
{loggedInUserId && (loggedInUserId === template.owner || loggedInUserIsAdmin) && (
{loggedInUserId && (loggedInUserId === template.owner || loggedInUserCanManageTemplates) && (
<OLRow className="cta-links-container">
<OLCol md={12} className="text-end">
<EditTemplateButton />

View File

@@ -11,17 +11,16 @@ export function deleteTemplate(template: Template) {
type UpdateTemplateOptions = {
template: Template
initialTemplate: Template
descriptionEdited: boolean
editedTemplate: Template
}
export function updateTemplate({
editedTemplate,
template
template,
editedTemplate
}: UpdateTemplateOptions): Promise<Template | null> {
const updatedFields: Partial<Template> = {
name: editedTemplate.name.trim(),
license: editedTemplate.license.trim(),
license: editedTemplate.license,
category: editedTemplate.category,
language: editedTemplate.language,
authorMD: editedTemplate.authorMD.trim(),
@@ -36,12 +35,10 @@ export function updateTemplate({
}, {} as Partial<Template>)
if (Object.keys(changedFields).length === 0) {
return null
return Promise.resolve(null)
}
const updated = postJSON(`/template/${editedTemplate.id}/edit`, {
return postJSON(`/template/${editedTemplate.id}/edit`, {
body: changedFields
})
return updated
}

View File

@@ -19,7 +19,8 @@ if (process.env.OVERLEAF_TEMPLATE_GALLERY === 'true') {
}
Settings.templates = {
nonAdminCanManage: boolFromEnv(process.env.OVERLEAF_NON_ADMIN_CAN_PUBLISH_TEMPLATES)
nonAdminCanManage: boolFromEnv(process.env.OVERLEAF_NON_ADMIN_CAN_PUBLISH_TEMPLATES),
user_id: process.env.OVERLEAF_TEMPLATES_USER_ID
}
const templateKeys = process.env.OVERLEAF_TEMPLATE_CATEGORIES