mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
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:
@@ -784,7 +784,7 @@ const _ProjectController = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isAdminOrTemplateOwner =
|
const isAdminOrTemplateOwner =
|
||||||
hasAdminAccess(user) || Settings.templates?.nonAdminCanManage
|
hasAdminAccess(user) || Settings.templates?.nonAdminCanManage || Settings.templates?.user_id === userId
|
||||||
const showTemplatesServerPro =
|
const showTemplatesServerPro =
|
||||||
Features.hasFeature('templates-server-pro') && isAdminOrTemplateOwner
|
Features.hasFeature('templates-server-pro') && isAdminOrTemplateOwner
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export const db = {
|
|||||||
systemmessages: internalDb.collection('systemmessages'),
|
systemmessages: internalDb.collection('systemmessages'),
|
||||||
tags: internalDb.collection('tags'),
|
tags: internalDb.collection('tags'),
|
||||||
teamInvites: internalDb.collection('teamInvites'),
|
teamInvites: internalDb.collection('teamInvites'),
|
||||||
|
templates: internalDb.collection('templates'),
|
||||||
tokens: internalDb.collection('tokens'),
|
tokens: internalDb.collection('tokens'),
|
||||||
userAuditLogEntries: internalDb.collection('userAuditLogEntries'),
|
userAuditLogEntries: internalDb.collection('userAuditLogEntries'),
|
||||||
users: internalDb.collection('users'),
|
users: internalDb.collection('users'),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -123,12 +123,14 @@ async function templatesCategoryPage(req, res, next) {
|
|||||||
|
|
||||||
async function templateDetailsPage(req, res, next) {
|
async function templateDetailsPage(req, res, next) {
|
||||||
const t = req.i18n.translate
|
const t = req.i18n.translate
|
||||||
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
try {
|
try {
|
||||||
const template = await TemplateGalleryManager.getTemplate('_id', req.params.template_id)
|
const template = await TemplateGalleryManager.getTemplate('_id', req.params.template_id)
|
||||||
res.render(Path.resolve(__dirname, '../views/template_gallery/template'), {
|
res.render(Path.resolve(__dirname, '../views/template_gallery/template'), {
|
||||||
title: `${t('template')}: ${template.name}`,
|
title: `${t('template')}: ${template.name}`,
|
||||||
template: JSON.stringify(template),
|
template: JSON.stringify(template),
|
||||||
languages: Settings.languages,
|
languages: Settings.languages,
|
||||||
|
userIsTemplatesManager: Boolean(Settings.templates?.user_id && Settings.templates.user_id === userId)
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return ErrorController.notFound(req, res, next)
|
return ErrorController.notFound(req, res, next)
|
||||||
|
|||||||
@@ -71,13 +71,13 @@ export async function canUserOverrideTemplate(template, userId) {
|
|||||||
userIsOwner = false
|
userIsOwner = false
|
||||||
try {
|
try {
|
||||||
userIsAdmin = (await UserGetter.promises.getUser(userId, { isAdmin: 1 })).isAdmin
|
userIsAdmin = (await UserGetter.promises.getUser(userId, { isAdmin: 1 })).isAdmin
|
||||||
} catch {
|
} catch (error) {
|
||||||
logger.error({ error, userId }, 'Logged in user does not exist, strange...')
|
logger.error({ error, userId }, 'Logged in user does not exist, strange...')
|
||||||
userIsAdmin = false
|
userIsAdmin = false
|
||||||
}
|
}
|
||||||
templateOwnerName = await getUserName(templateOwnerId) || 'unknown'
|
templateOwnerName = await getUserName(templateOwnerId) || 'unknown'
|
||||||
}
|
}
|
||||||
const canOverride = userIsOwner || userIsAdmin
|
const canOverride = userIsOwner || userIsAdmin || (settings.templates?.user_id === userId)
|
||||||
return { canOverride, templateOwnerName }
|
return { canOverride, templateOwnerName }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ export async function deleteTemplateAssets(templateId, version, deleteFromDb) {
|
|||||||
try {
|
try {
|
||||||
await Template.deleteOne({ _id: templateId }).exec()
|
await Template.deleteOne({ _id: templateId }).exec()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err, templateId }, 'Failed to delete template from MongoDB')
|
logger.error({ error, templateId }, 'Failed to delete template from MongoDB')
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import AuthenticationController from '../../../../app/src/Features/Authenticatio
|
|||||||
import RateLimiterMiddleware from '../../../../app/src/Features/Security/RateLimiterMiddleware.mjs'
|
import RateLimiterMiddleware from '../../../../app/src/Features/Security/RateLimiterMiddleware.mjs'
|
||||||
import { RateLimiter } from '../../../../app/src/infrastructure/RateLimiter.mjs'
|
import { RateLimiter } from '../../../../app/src/infrastructure/RateLimiter.mjs'
|
||||||
import TemplateGalleryController from './TemplateGalleryController.mjs'
|
import TemplateGalleryController from './TemplateGalleryController.mjs'
|
||||||
|
import TemplateAuthorizationMiddleware from './TemplateAuthorizationMiddleware.mjs'
|
||||||
|
|
||||||
const rateLimiterNewTemplate = new RateLimiter('create-template-from-project', {
|
const rateLimiterNewTemplate = new RateLimiter('create-template-from-project', {
|
||||||
points: 20,
|
points: 20,
|
||||||
@@ -18,7 +19,6 @@ const rateLimiterThumbnails = new RateLimiter('template-gallery-thumbnails', {
|
|||||||
duration: 60,
|
duration: 60,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
rateLimiter,
|
rateLimiter,
|
||||||
apply(webRouter) {
|
apply(webRouter) {
|
||||||
@@ -28,6 +28,7 @@ export default {
|
|||||||
'/template/new/:Project_id',
|
'/template/new/:Project_id',
|
||||||
AuthenticationController.requireLogin(),
|
AuthenticationController.requireLogin(),
|
||||||
RateLimiterMiddleware.rateLimit(rateLimiterNewTemplate),
|
RateLimiterMiddleware.rateLimit(rateLimiterNewTemplate),
|
||||||
|
TemplateAuthorizationMiddleware.ensureTemplateManagementAccess,
|
||||||
TemplateGalleryController.createTemplateFromProject
|
TemplateGalleryController.createTemplateFromProject
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export default {
|
|||||||
'/template/:template_id/edit',
|
'/template/:template_id/edit',
|
||||||
AuthenticationController.requireLogin(),
|
AuthenticationController.requireLogin(),
|
||||||
RateLimiterMiddleware.rateLimit(rateLimiter),
|
RateLimiterMiddleware.rateLimit(rateLimiter),
|
||||||
|
TemplateAuthorizationMiddleware.ensureTemplateManagementAccess,
|
||||||
TemplateGalleryController.editTemplate
|
TemplateGalleryController.editTemplate
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ export default {
|
|||||||
'/template/:template_id/delete',
|
'/template/:template_id/delete',
|
||||||
AuthenticationController.requireLogin(),
|
AuthenticationController.requireLogin(),
|
||||||
RateLimiterMiddleware.rateLimit(rateLimiter),
|
RateLimiterMiddleware.rateLimit(rateLimiter),
|
||||||
|
TemplateAuthorizationMiddleware.ensureTemplateManagementAccess,
|
||||||
TemplateGalleryController.deleteTemplate
|
TemplateGalleryController.deleteTemplate
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ block append meta
|
|||||||
meta(name="ol-template" data-type="json" content=template)
|
meta(name="ol-template" data-type="json" content=template)
|
||||||
meta(name="ol-languages" data-type="json" content=languages)
|
meta(name="ol-languages" data-type="json" content=languages)
|
||||||
meta(name="ol-userIsAdmin" data-type="boolean" content=hasAdminAccess())
|
meta(name="ol-userIsAdmin" data-type="boolean" content=hasAdminAccess())
|
||||||
|
meta(name="ol-userIsTemplatesManager" data-type="boolean" content=userIsTemplatesManager)
|
||||||
|
|
||||||
block content
|
block content
|
||||||
#template-root
|
#template-root
|
||||||
|
|||||||
-80
@@ -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)}>
|
|
||||||
<< {t('first')}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
*/}
|
|
||||||
{currentPage > 1 && (
|
|
||||||
<li>
|
|
||||||
<button aria-label={t('go_prev_page')} onClick={() => onPageChange(currentPage - 1)}>
|
|
||||||
< {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')} >
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{/*
|
|
||||||
{currentPage < totalPages && (
|
|
||||||
<li>
|
|
||||||
<button aria-label={t('go_to_last_page')} onClick={() => onPageChange(totalPages)}>
|
|
||||||
{t('last')} >>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
*/}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+1
-1
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import OLRow from '@/shared/components/ol/ol-row'
|
import OLRow from '@/shared/components/ol/ol-row'
|
||||||
import { useTemplateGalleryContext } from '../context/template-gallery-context'
|
import { useTemplateGalleryContext } from '../context/template-gallery-context'
|
||||||
import TemplateGalleryEntry from './template-gallery-entry'
|
import TemplateGalleryEntry from './template-gallery-entry'
|
||||||
import Pagination from './pagination'
|
import Pagination from '@/shared/components/pagination-cep'
|
||||||
|
|
||||||
export default function TemplateGallery() {
|
export default function TemplateGallery() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|||||||
+3
-2
@@ -16,7 +16,8 @@ function TemplateDetails() {
|
|||||||
const lastUpdatedDate = fromNowDate(template.lastUpdated)
|
const lastUpdatedDate = fromNowDate(template.lastUpdated)
|
||||||
const tooltipText = formatDate(template.lastUpdated)
|
const tooltipText = formatDate(template.lastUpdated)
|
||||||
const loggedInUserId = getMeta('ol-user_id')
|
const loggedInUserId = getMeta('ol-user_id')
|
||||||
const loggedInUserIsAdmin = getMeta('ol-userIsAdmin')
|
const loggedInUserCanManageTemplates = getMeta('ol-userIsAdmin')
|
||||||
|
|| getMeta('ol-userIsTemplatesManager')
|
||||||
|
|
||||||
const openAsTemplateParams = new URLSearchParams({
|
const openAsTemplateParams = new URLSearchParams({
|
||||||
version: template.version,
|
version: template.version,
|
||||||
@@ -90,7 +91,7 @@ function TemplateDetails() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{loggedInUserId && (loggedInUserId === template.owner || loggedInUserIsAdmin) && (
|
{loggedInUserId && (loggedInUserId === template.owner || loggedInUserCanManageTemplates) && (
|
||||||
<OLRow className="cta-links-container">
|
<OLRow className="cta-links-container">
|
||||||
<OLCol md={12} className="text-end">
|
<OLCol md={12} className="text-end">
|
||||||
<EditTemplateButton />
|
<EditTemplateButton />
|
||||||
|
|||||||
@@ -11,17 +11,16 @@ export function deleteTemplate(template: Template) {
|
|||||||
|
|
||||||
type UpdateTemplateOptions = {
|
type UpdateTemplateOptions = {
|
||||||
template: Template
|
template: Template
|
||||||
initialTemplate: Template
|
editedTemplate: Template
|
||||||
descriptionEdited: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateTemplate({
|
export function updateTemplate({
|
||||||
editedTemplate,
|
template,
|
||||||
template
|
editedTemplate
|
||||||
}: UpdateTemplateOptions): Promise<Template | null> {
|
}: UpdateTemplateOptions): Promise<Template | null> {
|
||||||
const updatedFields: Partial<Template> = {
|
const updatedFields: Partial<Template> = {
|
||||||
name: editedTemplate.name.trim(),
|
name: editedTemplate.name.trim(),
|
||||||
license: editedTemplate.license.trim(),
|
license: editedTemplate.license,
|
||||||
category: editedTemplate.category,
|
category: editedTemplate.category,
|
||||||
language: editedTemplate.language,
|
language: editedTemplate.language,
|
||||||
authorMD: editedTemplate.authorMD.trim(),
|
authorMD: editedTemplate.authorMD.trim(),
|
||||||
@@ -36,12 +35,10 @@ export function updateTemplate({
|
|||||||
}, {} as Partial<Template>)
|
}, {} as Partial<Template>)
|
||||||
|
|
||||||
if (Object.keys(changedFields).length === 0) {
|
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
|
body: changedFields
|
||||||
})
|
})
|
||||||
|
|
||||||
return updated
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ if (process.env.OVERLEAF_TEMPLATE_GALLERY === 'true') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Settings.templates = {
|
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
|
const templateKeys = process.env.OVERLEAF_TEMPLATE_CATEGORIES
|
||||||
|
|||||||
Reference in New Issue
Block a user