TASKS-1823 WebManager module

This commit is contained in:
Igor Martyniuk
2026-08-28 08:35:58 +00:00
parent fdb2cf3837
commit c93d8c025b
654 changed files with 26232 additions and 6075 deletions
@@ -0,0 +1,50 @@
import { BREADCRUMB_PAGE_TYPE_PARENTS } from '#base_modules/WebManager/config/general'
/**
* Utility functions that identify candidate pages for buildBreadcrumbs database queries.
* Breadcrumbs climb hierarchically from a target page to the home page.
* To avoid querying all published pages in a locale, queries are filtered down to two
* predictable sets of candidates:
* 1. Path-based ancestors: The small chain of parent paths above the target page.
* 2. Group-key ancestors: Static relationships where pages belong to a specific parent
* type regardless of location (e.g., blog-item under blog).
*/
export const BREADCRUMB_PARENT_GROUP_KEYS = [
...new Set(Object.values(BREADCRUMB_PAGE_TYPE_PARENTS)),
]
/**
* The paths a page can climb to, nearest parent first, ending at the home page (`''`).
*
* 'blog/post/hello' → ['blog/post', 'blog', '']
* 'about' → ['']
* '' → ['']
*
* The page's own path is never included — a page cannot be its own parent.
*/
export function ancestorPaths(path: string) {
const segments = path.split('/').filter(Boolean)
const paths: string[] = []
for (let depth = segments.length - 1; depth >= 1; depth--) {
paths.push(segments.slice(0, depth).join('/'))
}
paths.push('')
return paths
}
/**
* True when `path` is an already loaded path or an ancestor of one. Only false after a
* group-key jump to another branch, never during a path climb.
*/
export function ancestorsAlreadyLoaded(path: string, loadedPaths: Iterable<string>) {
for (const loaded of loadedPaths) {
// The trailing slash keeps 'blog' from matching 'blogging'; '' matches every path.
if (loaded === path || loaded.startsWith(path === '' ? '' : `${path}/`)) return true
}
return false
}
@@ -0,0 +1,103 @@
import sanitizeHtml from 'sanitize-html'
const ALLOWED_REL_VALUES = new Set(['nofollow', 'noopener', 'noreferrer'])
const ALLOWED_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:'])
const URL_KEYS = new Set(['href', 'url', 'externalUrl'])
const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = {
allowedTags: [
'a',
'b',
'blockquote',
'br',
'code',
'em',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'li',
'ol',
'p',
'pre',
's',
'span',
'strong',
'u',
'ul',
],
allowedAttributes: {
'*': ['class', 'title'],
'a': ['href', 'target', 'rel'],
},
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
allowProtocolRelative: true,
disallowedTagsMode: 'discard',
nonTextTags: [
'script',
'style',
'textarea',
'option',
'iframe',
'object',
'embed',
'form',
'svg',
'math',
],
transformTags: {
a: (tagName, attribs) => {
if (attribs.target !== undefined && !['_blank', '_self'].includes(attribs.target)) {
delete attribs.target
}
if (attribs.rel !== undefined) {
const rel = attribs.rel
.split(/\s+/)
.filter((value) => ALLOWED_REL_VALUES.has(value))
.join(' ')
if (rel) attribs.rel = rel
else delete attribs.rel
}
if (attribs.target === '_blank') attribs.rel = 'noopener noreferrer'
return { tagName, attribs }
},
},
}
export function sanitizeRichText(value: string) {
return sanitizeHtml(value, RICH_TEXT_OPTIONS)
}
export function sanitizeUrl(value: string) {
try {
const parsed = new URL(value, 'https://relative.invalid')
return ALLOWED_URL_PROTOCOLS.has(parsed.protocol) ? value : ''
} catch {
return ''
}
}
export function sanitizePayload(value: unknown, key = ''): unknown {
if (typeof value === 'string') {
return URL_KEYS.has(key) ? sanitizeUrl(value) : sanitizeRichText(value)
}
if (Array.isArray(value)) return value.map((item) => sanitizePayload(item))
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([childKey, childValue]) => [
childKey,
sanitizePayload(childValue, childKey),
])
)
}
return value
}
@@ -0,0 +1,127 @@
import type { HttpContext } from '@adonisjs/core/http'
import {
InvalidPagePayloadException,
PageMediaFileInvalidException,
PageMediaFileMissingException,
} from '#exceptions/web_manager_exceptions'
import FileManagerService from '#base_modules/FileManager/services/file_manager_service'
import {
PAGE_MEDIA_FILE_SIZE_LIMIT,
PAGE_MEDIA_UPLOADS_PATH,
} from '#base_modules/WebManager/config/general'
import type { JsonObject, PageInput } from '#base_modules/WebManager/types/web_manager'
type Request = HttpContext['request']
interface StoredFileRef {
name: string
ref: string
}
export default class PagePayloadService {
private fileManagerService = new FileManagerService()
/**
* Returns the page body from a save request. Multipart requests carry the page
* as a JSON string in the `payload` field (so value types survive transport);
* plain JSON requests are passed through unchanged.
*/
parse(request: Request): Record<string, unknown> {
const rawPayload = request.input('payload')
if (rawPayload === undefined) return request.all()
if (typeof rawPayload !== 'string') throw new InvalidPagePayloadException()
let parsed: unknown
try {
parsed = JSON.parse(rawPayload)
} catch {
throw new InvalidPagePayloadException()
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new InvalidPagePayloadException()
}
return parsed as Record<string, unknown>
}
/**
* Stores every uploaded file referenced by a `{ name, fileKey }` media value in
* the hero or component data, and replaces the value with `{ name, ref }` so
* only stored-file references are persisted.
*/
async resolveLocalFiles(request: Request, input: PageInput): Promise<PageInput> {
const resolvedByKey = new Map<string, StoredFileRef>()
const hero = input.hero
? ((await this.resolveValue(request, input.hero, resolvedByKey)) as JsonObject)
: input.hero
const components: PageInput['components'] = []
for (const component of input.components) {
components.push({
...component,
data: (await this.resolveValue(request, component.data, resolvedByKey)) as JsonObject,
})
}
return { ...input, hero, components }
}
private async resolveValue(
request: Request,
value: unknown,
resolvedByKey: Map<string, StoredFileRef>
): Promise<unknown> {
if (Array.isArray(value)) {
const items: unknown[] = []
for (const item of value) {
items.push(await this.resolveValue(request, item, resolvedByKey))
}
return items
}
if (value !== null && typeof value === 'object') {
if (this.isFileKeyValue(value)) {
return this.storeFile(request, value.fileKey, resolvedByKey)
}
const entries: Record<string, unknown> = {}
for (const [key, item] of Object.entries(value)) {
entries[key] = await this.resolveValue(request, item, resolvedByKey)
}
return entries
}
return value
}
private isFileKeyValue(value: object): value is { fileKey: string } {
return 'fileKey' in value && typeof (value as { fileKey: unknown }).fileKey === 'string'
}
private async storeFile(
request: Request,
fileKey: string,
resolvedByKey: Map<string, StoredFileRef>
): Promise<StoredFileRef> {
const cached = resolvedByKey.get(fileKey)
if (cached) return cached
const file = request.file(fileKey, { size: PAGE_MEDIA_FILE_SIZE_LIMIT })
if (!file) {
throw new PageMediaFileMissingException(fileKey)
}
if (!file.isValid) {
const message = file.errors.map((error: { message: string }) => error.message).join(', ')
throw new PageMediaFileInvalidException(fileKey, message)
}
const entry = await this.fileManagerService.writeFile(PAGE_MEDIA_UPLOADS_PATH, file)
const stored: StoredFileRef = { name: entry.name, ref: entry.path }
resolvedByKey.set(fileKey, stored)
return stored
}
}
@@ -0,0 +1,61 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { PREVIEW_TOKEN_DEFAULT_TTL_SECONDS } from '#base_modules/WebManager/config/general'
import env from '#start/env'
interface PreviewTokenPayload {
pageId: number
expiresAt: number
}
export default class PreviewTokenService {
create(pageId: number, expiresInSeconds = PREVIEW_TOKEN_DEFAULT_TTL_SECONDS) {
const payload: PreviewTokenPayload = {
pageId,
expiresAt: Math.floor(Date.now() / 1000) + expiresInSeconds,
}
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url')
return {
token: `${encodedPayload}.${this.sign(encodedPayload)}`,
expiresAt: new Date(payload.expiresAt * 1000).toISOString(),
}
}
verify(token: string): PreviewTokenPayload | null {
const [encodedPayload, suppliedSignature, ...extraParts] = token.split('.')
if (!encodedPayload || !suppliedSignature || extraParts.length > 0) return null
const expectedSignature = this.sign(encodedPayload)
const suppliedBuffer = Buffer.from(suppliedSignature)
const expectedBuffer = Buffer.from(expectedSignature)
if (
suppliedBuffer.length !== expectedBuffer.length ||
!timingSafeEqual(suppliedBuffer, expectedBuffer)
) {
return null
}
try {
const payload = JSON.parse(
Buffer.from(encodedPayload, 'base64url').toString('utf8')
) as Partial<PreviewTokenPayload>
if (
!Number.isInteger(payload.pageId) ||
typeof payload.expiresAt !== 'number' ||
payload.expiresAt <= Math.floor(Date.now() / 1000)
) {
return null
}
return payload as PreviewTokenPayload
} catch {
return null
}
}
private sign(payload: string) {
return createHmac('sha256', env.get('APP_KEY')).update(payload).digest('base64url')
}
}
@@ -0,0 +1,113 @@
import drive from '@adonisjs/drive/services/main'
import WebPage from '#base_modules/WebManager/model/web_page'
import { sanitizePayload } from '#base_modules/WebManager/services/content_sanitizer'
import type { JsonObject } from '#base_modules/WebManager/types/web_manager'
function publicHref(locale: string, path: string) {
return `/${locale}${path ? `/${path}` : ''}`
}
export default class PublicContentService {
/**
* Page ids referenced by internal links anywhere inside a stored content value. Collected
* before rendering so the link map can be fetched by id instead of loading every published
* page in the site.
*/
collectInternalPageIds(value: unknown, into: Set<string> = new Set<string>()) {
if (Array.isArray(value)) {
for (const item of value) this.collectInternalPageIds(item, into)
return into
}
if (!value || typeof value !== 'object') return into
const record = value as JsonObject
if (record.linkType === 'internal' && record.pageId !== undefined) {
into.add(String(record.pageId))
}
for (const child of Object.values(record)) this.collectInternalPageIds(child, into)
return into
}
/**
* Maps the given page ids to their public hrefs. Ids that are unknown or no longer
* published are simply absent, which `prepare` renders as an empty href — the same
* outcome as before, when the map only ever held published pages.
*/
async createPageLinkMap(pageIds: Iterable<string>) {
const ids = [...new Set(pageIds)]
.map((pageId) => Number(pageId))
.filter((pageId) => Number.isInteger(pageId) && pageId > 0)
if (ids.length === 0) return new Map<string, string>()
const linkedPages = await WebPage.query()
.where('status', 'published')
.whereIn('id', ids)
.select(['id', 'locale', 'path'])
return new Map(linkedPages.map((page) => [String(page.id), publicHref(page.locale, page.path)]))
}
/**
* Turns a stored content value (hero, block data, settings section) into its
* public shape: internal links gain an href, stored-file refs become URLs and
* rich text is sanitized. Works structurally, so it accepts any of the typed
* stored content entities.
*/
async prepare(value: object | null, pageLinks: Map<string, string>) {
if (!value) return null
const resolveLinks = (candidate: unknown): unknown => {
if (Array.isArray(candidate)) return candidate.map(resolveLinks)
if (!candidate || typeof candidate !== 'object') return candidate
const record = Object.fromEntries(
Object.entries(candidate).map(([key, child]) => [key, resolveLinks(child)])
) as JsonObject
if (record.linkType === 'internal' && record.pageId !== undefined) {
record.href = pageLinks.get(String(record.pageId)) ?? ''
}
return record
}
const resolved = await this.resolveStoredFiles(resolveLinks(value))
const sanitized = sanitizePayload(resolved) as JsonObject
return sanitized
}
/**
* Media values are persisted as `{ name, ref }` stored-file references;
* public payloads need browser-loadable URLs instead.
*/
private async resolveStoredFiles(candidate: unknown): Promise<unknown> {
if (Array.isArray(candidate)) {
return Promise.all(candidate.map((item) => this.resolveStoredFiles(item)))
}
if (!candidate || typeof candidate !== 'object') return candidate
const record = candidate as JsonObject
if (
typeof record.ref === 'string' &&
Object.keys(record).every((k) => k === 'ref' || k === 'name')
) {
try {
return await drive.use().getUrl(record.ref)
} catch {
return ''
}
}
const entries: JsonObject = {}
for (const [key, child] of Object.entries(record)) {
entries[key] = (await this.resolveStoredFiles(child)) as JsonObject[string]
}
return entries
}
}
@@ -0,0 +1,654 @@
import db from '@adonisjs/lucid/services/db'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
import { DateTime } from 'luxon'
import {
PageNotFoundException,
PageNotPublishedException,
PageRouteConflictException,
} from '#exceptions/web_manager_exceptions'
import WebComponent from '#base_modules/WebManager/model/web_component'
import WebPage from '#base_modules/WebManager/model/web_page'
import WebSetting from '#base_modules/WebManager/model/web_setting'
import PublicContentService from '#base_modules/WebManager/services/public_content_service'
import WebRevalidationService from '#base_modules/WebManager/services/web_revalidation_service'
import {
BREADCRUMB_PAGE_TYPE_PARENTS,
SITEMAP_PAGE_SIZE,
} from '#base_modules/WebManager/config/general'
import {
ancestorPaths,
ancestorsAlreadyLoaded,
BREADCRUMB_PARENT_GROUP_KEYS,
} from '#base_modules/WebManager/services/breadcrumb_paths'
import type {
NormalizedPageInput,
PageInput,
SettingsInput,
} from '#base_modules/WebManager/types/web_manager'
import type { PageStatus } from '#base_modules/WebManager/types/page_status'
import type { ComponentContent, PageType } from '#base_modules/WebManager/types/web_content'
import {
parseComponentContent,
parseHero,
parseSiteSettingsContent,
} from '#base_modules/WebManager/validator/web_content_validator'
export default class WebManagerService {
private publicContentService = new PublicContentService()
private webRevalidationService = new WebRevalidationService()
async listPages(filters: {
locale?: string
status?: PageStatus
groupKey?: string
pageType?: PageType
search?: string
page?: number
perPage?: number
}) {
const query = WebPage.query()
.whereNull('draft_of_id')
.preload('draft')
.orderBy('updated_at', 'desc')
if (filters.locale) query.where('locale', filters.locale)
if (filters.status) query.where('status', filters.status)
if (filters.groupKey) query.where('group_key', filters.groupKey)
if (filters.pageType) query.where('page_type', filters.pageType)
if (filters.search) {
query.where((searchQuery) => {
searchQuery
.whereILike('title', `%${filters.search}%`)
.orWhereILike('group_key', `%${filters.search}%`)
.orWhereILike('path', `%${filters.search}%`)
.orWhereILike('seo_title', `%${filters.search}%`)
})
}
return query.paginate(filters.page ?? 1, filters.perPage ?? 25)
}
async listGroupKeys() {
const pages = await WebPage.query()
.select('group_key')
.whereNot('group_key', '')
.distinct()
.orderBy('group_key')
return pages.map((page) => page.groupKey)
}
async getPage(id: number) {
const page = await this.findPage(id)
return this.serializeAdminPage(page)
}
async createDraft(input: PageInput) {
const normalized = await this.normalizeInput(input)
await this.assertNewRouteAvailable(normalized.locale, normalized.path)
const page = await db.transaction(async (trx) => {
const createdPage = await WebPage.create(
{
status: 'draft',
draftOfId: null,
...this.pageFields(normalized),
publishedAt: null,
},
{ client: trx }
)
await this.replaceComponents(createdPage.id, normalized.components, trx)
return createdPage
})
return this.serializeAdminPage(page)
}
async saveDraft(id: number, input: PageInput) {
const normalized = await this.normalizeInput(input)
const draft = await this.ensureDraft(id)
await db.transaction(async (trx) => {
const lockedDraft = await WebPage.query({ client: trx })
.where('id', draft.id)
.whereNot('status', 'published')
.forUpdate()
.first()
if (!lockedDraft) throw new PageNotFoundException('Draft page')
if (lockedDraft.draftOfId === null) {
await this.assertNewRouteAvailable(normalized.locale, normalized.path, lockedDraft.id, trx)
}
lockedDraft.merge(this.pageFields(normalized))
await lockedDraft.save()
await this.replaceComponents(lockedDraft.id, normalized.components, trx)
})
return this.getPage(draft.id)
}
async publish(id: number) {
const published = await db.transaction(async (trx) => {
const target = await WebPage.query({ client: trx }).where('id', id).forUpdate().first()
if (!target) throw new PageNotFoundException()
let draft = target
if (target.draftOfId === null) {
const pendingDraft = await WebPage.query({ client: trx })
.where('draft_of_id', target.id)
.forUpdate()
.first()
if (pendingDraft) draft = pendingDraft
else if (target.status === 'published') return { page: target, structural: false }
}
if (draft.draftOfId === null) {
await this.assertPublishedRouteAvailable(draft.locale, draft.path, draft.id, trx)
draft.status = 'published'
draft.publishedAt = DateTime.now()
await draft.save()
return { page: draft, structural: true }
}
const original = await WebPage.query({ client: trx })
.where('id', draft.draftOfId)
.forUpdate()
.first()
if (!original) throw new PageNotFoundException('Published page')
await this.assertPublishedRouteAvailable(draft.locale, draft.path, original.id, trx)
const structural =
original.status !== 'published' ||
original.locale !== draft.locale ||
original.path !== draft.path ||
original.title !== draft.title ||
original.groupKey !== draft.groupKey ||
original.pageType !== draft.pageType
original.merge({
status: 'published',
locale: draft.locale,
path: draft.path,
groupKey: draft.groupKey,
pageType: draft.pageType,
title: draft.title,
hero: draft.hero,
seoTitle: draft.seoTitle,
seoDescription: draft.seoDescription,
publishedAt: DateTime.now(),
})
await original.save()
await WebComponent.query({ client: trx }).where('page_id', original.id).delete()
await WebComponent.query({ client: trx })
.where('page_id', draft.id)
.update({ page_id: original.id })
await draft.delete()
return { page: original, structural }
})
const revalidation = await this.revalidatePublishedPage(published.page, published.structural)
return { page: await this.getPage(published.page.id), revalidation }
}
async unpublish(id: number) {
const page = await db.transaction(async (trx) => {
const target = await WebPage.query({ client: trx }).where('id', id).forUpdate().first()
if (!target) throw new PageNotFoundException()
if (target.status !== 'published') throw new PageNotPublishedException()
target.status = 'unpublished'
await target.save()
return target
})
const revalidation = await this.webRevalidationService.revalidatePublicContent()
return { page: await this.getPage(page.id), revalidation }
}
private async revalidatePublishedPage(page: WebPage, structural: boolean) {
if (structural) {
return this.webRevalidationService.revalidatePublicContent()
}
return this.webRevalidationService.revalidatePage({
locale: page.locale,
path: page.path,
pageType: page.pageType,
translations: await this.findPublishedTranslations(page),
})
}
private async findPublishedTranslations(page: WebPage) {
if (!page.groupKey) return []
const siblings = await WebPage.query()
.where('status', 'published')
.where('group_key', page.groupKey)
.whereNot('id', page.id)
.select(['locale', 'path'])
return siblings.map((sibling) => ({ locale: sibling.locale, path: sibling.path }))
}
async discard(id: number) {
const page = await this.findPage(id)
const wasPublished = page.status === 'published'
await page.delete()
if (wasPublished) {
await this.webRevalidationService.revalidatePublicContent()
}
}
async getSettings(locale?: string) {
if (locale) return WebSetting.query().where('locale', locale).first()
return WebSetting.query().orderBy('locale')
}
async saveSettings(input: SettingsInput) {
const content = await parseSiteSettingsContent({
header: input.header,
footer: input.footer,
legal: input.legal,
})
const settings = await WebSetting.updateOrCreate(
{ locale: input.locale },
{ header: content.header, footer: content.footer, legal: content.legal }
)
const revalidation = await this.webRevalidationService.revalidateSettings(input.locale)
return { settings, revalidation }
}
async listPublicBlogPages(filters: { locale: string; page?: number; perPage?: number }) {
const paginated = await WebPage.query()
.where('status', 'published')
.where('locale', filters.locale)
.where('page_type', 'blog-item' satisfies PageType)
.orderBy('published_at', 'desc')
.orderBy('id', 'desc')
.paginate(filters.page ?? 1, filters.perPage ?? 24)
const noLinks = new Map<string, string>()
const items = await Promise.all(
paginated.all().map(async (page) => {
const heroMedia = page.hero?.media ?? null
const prepared = heroMedia
? await this.publicContentService.prepare({ media: heroMedia }, noLinks)
: null
return {
id: page.id,
locale: page.locale,
path: page.path,
groupKey: page.groupKey,
pageType: page.pageType,
title: page.title,
description: page.seoDescription,
media: prepared?.media ?? null,
publishedAt: page.publishedAt?.toISO() ?? null,
}
})
)
return {
items,
pagination: {
page: paginated.currentPage,
perPage: paginated.perPage,
total: paginated.total,
lastPage: paginated.lastPage,
},
}
}
async listPublicSitemapPages(filters: { page?: number; perPage?: number }) {
const paginated = await WebPage.query()
.where('status', 'published')
.whereNull('draft_of_id')
.select(['id', 'locale', 'path', 'group_key', 'page_type', 'published_at', 'updated_at'])
.orderBy('locale')
.orderBy('path')
.paginate(filters.page ?? 1, filters.perPage ?? SITEMAP_PAGE_SIZE)
return {
items: paginated.all().map((page) => ({
id: page.id,
locale: page.locale,
path: page.path,
groupKey: page.groupKey,
pageType: page.pageType,
publishedAt: page.publishedAt?.toISO() ?? null,
updatedAt: page.updatedAt?.toISO() ?? null,
})),
pagination: {
page: paginated.currentPage,
perPage: paginated.perPage,
total: paginated.total,
lastPage: paginated.lastPage,
},
}
}
async resolvePublicPage(locale: string, path: string) {
const page = await WebPage.query()
.where('status', 'published')
.where('locale', locale)
.where('path', this.normalizePath(path))
.first()
if (!page) return null
return this.serializePublicPage(page)
}
async resolvePreviewPage(id: number) {
const page = await this.findPage(id)
return this.serializePublicPage(page)
}
private async ensureDraft(id: number) {
return db.transaction(async (trx) => {
const page = await WebPage.query({ client: trx }).where('id', id).forUpdate().first()
if (!page) throw new PageNotFoundException()
if (page.status !== 'published') return page
const existingDraft = await WebPage.query({ client: trx })
.where('draft_of_id', page.id)
.first()
if (existingDraft) return existingDraft
const draft = await WebPage.create(
{
status: 'draft',
draftOfId: page.id,
locale: page.locale,
path: page.path,
groupKey: page.groupKey,
pageType: page.pageType,
title: page.title,
hero: page.hero,
seoTitle: page.seoTitle,
seoDescription: page.seoDescription,
publishedAt: null,
},
{ client: trx }
)
const components = await WebComponent.query({ client: trx })
.where('page_id', page.id)
.orderBy('sort_order')
if (components.length > 0) {
await WebComponent.createMany(
components.map((component) => ({
pageId: draft.id,
type: component.type,
data: component.data,
sortOrder: component.sortOrder,
})),
{ client: trx }
)
}
return draft
})
}
private async serializeAdminPage(page: WebPage) {
const components = await WebComponent.query()
.where('page_id', page.id)
.orderBy('sort_order')
.orderBy('id')
return {
page: page.serialize(),
components: components.map((component) => component.serialize()),
}
}
private async serializePublicPage(page: WebPage) {
const [components, settings, alternatives, breadcrumbs] = await Promise.all([
WebComponent.query().where('page_id', page.id).orderBy('sort_order').orderBy('id'),
WebSetting.query().where('locale', page.locale).first(),
WebPage.query()
.where('status', 'published')
.where('group_key', page.groupKey)
.select(['locale', 'path'])
.orderBy('locale'),
this.buildBreadcrumbs(page),
])
const linkedPageIds = new Set<string>()
this.publicContentService.collectInternalPageIds(page.hero, linkedPageIds)
for (const component of components) {
this.publicContentService.collectInternalPageIds(component.data, linkedPageIds)
}
if (settings) {
this.publicContentService.collectInternalPageIds(settings.header, linkedPageIds)
this.publicContentService.collectInternalPageIds(settings.footer, linkedPageIds)
this.publicContentService.collectInternalPageIds(settings.legal, linkedPageIds)
}
const pageLinks = await this.publicContentService.createPageLinkMap(linkedPageIds)
const hero = await this.publicContentService.prepare(page.hero, pageLinks)
const publicComponents = await Promise.all(
components.map(async (component) => ({
id: component.id,
type: component.type,
data: await this.publicContentService.prepare(component.data, pageLinks),
}))
)
const publicSettings = settings
? {
locale: settings.locale,
header: await this.publicContentService.prepare(settings.header, pageLinks),
footer: await this.publicContentService.prepare(settings.footer, pageLinks),
legal: await this.publicContentService.prepare(settings.legal, pageLinks),
}
: null
return {
page: {
id: page.id,
status: page.status,
locale: page.locale,
path: page.path,
groupKey: page.groupKey,
layout: page.pageType,
pageType: page.pageType,
title: page.title,
hero,
seo: { title: page.seoTitle || page.title, description: page.seoDescription },
publishedAt: page.publishedAt?.toISO() ?? null,
updatedAt: page.updatedAt?.toISO() ?? null,
},
components: publicComponents,
settings: publicSettings,
breadcrumbs,
languageAlternatives: alternatives.map((alternative) => ({
locale: alternative.locale,
path: alternative.path,
})),
}
}
private async buildBreadcrumbs(page: WebPage) {
const byPath = new Map<string, WebPage>()
const byGroupKey = new Map<string, WebPage>()
const loadedPaths: string[] = []
const loadCandidatesFor = async (target: WebPage) => {
if (ancestorsAlreadyLoaded(target.path, loadedPaths)) return
loadedPaths.push(target.path)
const candidates = await WebPage.query()
.where('status', 'published')
.where('locale', page.locale)
.whereNull('draft_of_id')
.where((scope) => {
scope.whereIn('path', ancestorPaths(target.path))
if (BREADCRUMB_PARENT_GROUP_KEYS.length > 0) {
scope.orWhereIn('group_key', BREADCRUMB_PARENT_GROUP_KEYS)
}
})
.select(['id', 'path', 'group_key', 'page_type', 'title'])
for (const candidate of candidates) {
if (!byPath.has(candidate.path)) byPath.set(candidate.path, candidate)
if (candidate.groupKey && !byGroupKey.has(candidate.groupKey)) {
byGroupKey.set(candidate.groupKey, candidate)
}
}
}
const resolveParent = (current: WebPage) => {
const parentGroupKey = BREADCRUMB_PAGE_TYPE_PARENTS[current.pageType]
if (parentGroupKey) {
const parent = byGroupKey.get(parentGroupKey)
if (parent && parent.id !== current.id) return parent
}
const segments = current.path.split('/').filter(Boolean)
for (let depth = segments.length - 1; depth >= 1; depth--) {
const ancestor = byPath.get(segments.slice(0, depth).join('/'))
if (ancestor && ancestor.id !== current.id) return ancestor
}
if (current.path !== '') {
const home = byPath.get('')
if (home && home.id !== current.id) return home
}
return null
}
const chain: Array<{ title: string; path: string }> = [{ title: page.title, path: page.path }]
const visited = new Set<number>([page.id])
let current: WebPage = page
await loadCandidatesFor(current)
while (true) {
const parent = resolveParent(current)
if (!parent || visited.has(parent.id)) break
visited.add(parent.id)
chain.unshift({ title: parent.title, path: parent.path })
current = parent
await loadCandidatesFor(current)
}
if (chain[0].path !== '') {
chain.unshift({ title: 'Home', path: '' })
}
return chain.map((crumb) => ({
label: crumb.title,
href: `/${page.locale}${crumb.path ? `/${crumb.path}` : ''}`,
}))
}
private async replaceComponents(
pageId: number,
components: ComponentContent[],
trx: TransactionClientContract
) {
await WebComponent.query({ client: trx }).where('page_id', pageId).delete()
if (components.length === 0) return
await WebComponent.createMany(
components.map((component, index) => ({
pageId,
type: component.type,
data: component.data,
sortOrder: index,
})),
{ client: trx }
)
}
private async normalizeInput(input: PageInput): Promise<NormalizedPageInput> {
let hero = null
if (input.hero) hero = await parseHero(input.hero)
const components = await Promise.all(
input.components.map(async (component, index) =>
parseComponentContent({ type: component.type.trim(), data: component.data }, index)
)
)
return {
locale: input.locale.trim(),
path: this.normalizePath(input.path),
groupKey: input.groupKey.trim(),
pageType: input.pageType,
title: input.title.trim(),
hero,
seoTitle: input.seoTitle?.trim() ?? '',
seoDescription: input.seoDescription?.trim() ?? '',
components,
}
}
private pageFields(input: NormalizedPageInput) {
return {
locale: input.locale,
path: input.path,
groupKey: input.groupKey,
pageType: input.pageType,
title: input.title,
hero: input.hero,
seoTitle: input.seoTitle,
seoDescription: input.seoDescription,
}
}
private normalizePath(path: string) {
return path.split('/').filter(Boolean).join('/')
}
private async findPage(id: number) {
const page = await WebPage.find(id)
if (!page) throw new PageNotFoundException()
return page
}
private async assertNewRouteAvailable(
locale: string,
path: string,
exceptId?: number,
trx?: TransactionClientContract
) {
const query = WebPage.query(trx ? { client: trx } : undefined)
.where('locale', locale)
.where('path', path)
.whereNull('draft_of_id')
if (exceptId) query.whereNot('id', exceptId)
if (await query.first()) throw new PageRouteConflictException(locale, path)
}
private async assertPublishedRouteAvailable(
locale: string,
path: string,
exceptId: number,
trx: TransactionClientContract
) {
const conflict = await WebPage.query({ client: trx })
.where('status', 'published')
.where('locale', locale)
.where('path', path)
.whereNot('id', exceptId)
.first()
if (conflict) throw new PageRouteConflictException(locale, path)
}
}
@@ -0,0 +1,178 @@
import logger from '@adonisjs/core/services/logger'
import env from '#start/env'
import type { PageType } from '#base_modules/WebManager/types/web_content'
export const WEB_MANAGER_CACHE_TAG = 'web-manager'
/**
* Tag builders mirroring `apps/web/web-runtime/api/cache-tags.ts`. The two files must
* stay in sync: the web app labels its cached fetches with these names and this service
* purges by the same names.
*/
export function pageCacheTag(locale: string, path: string) {
return `${WEB_MANAGER_CACHE_TAG}:page:${locale}:${path || '/'}`
}
export function settingsCacheTag(locale: string) {
return `${WEB_MANAGER_CACHE_TAG}:settings:${locale}`
}
export function blogCacheTag(locale: string) {
return `${WEB_MANAGER_CACHE_TAG}:blog:${locale}`
}
export const REVALIDATION_MAX_ATTEMPTS = 3
export const REVALIDATION_TIMEOUT_MS = 5000
export const REVALIDATION_RETRY_BASE_DELAY_MS = 1000
/**
* `purged` — the web app confirmed the purge.
* `retrying` — the first attempt failed and retries are running in the background;
* until one succeeds the public site serves cached content up to its `revalidate`
* window (60s, see `apps/web/web-runtime/api/web-runtime-api.ts`).
* `disabled` — no `WEB_APP_URL`/`WEB_REVALIDATE_SECRET`, so nothing was sent.
*/
export type RevalidationStatus = 'purged' | 'retrying' | 'disabled'
export interface RevalidationResult {
status: RevalidationStatus
tags: string[]
}
export interface PageRevalidationTarget {
locale: string
path: string
pageType: PageType
translations: Array<{ locale: string; path: string }>
}
const pendingRetries = new Set<Promise<unknown>>()
export async function flushPendingRevalidations() {
while (pendingRetries.size > 0) {
await Promise.all([...pendingRetries])
}
}
export default class WebRevalidationService {
/**
* Full-site revalidation. Reserved for structural changes — a page created, deleted or moved —
* where internal links resolved into other pages' HTML and descendants' breadcrumbs go
* stale in ways that would take a dependency graph to enumerate.
*/
async revalidatePublicContent() {
return this.revalidate([WEB_MANAGER_CACHE_TAG])
}
async revalidatePage(target: PageRevalidationTarget) {
const tags = new Set<string>([pageCacheTag(target.locale, target.path)])
for (const translation of target.translations) {
tags.add(pageCacheTag(translation.locale, translation.path))
}
if (target.pageType === 'blog-item') {
tags.add(blogCacheTag(target.locale))
}
return this.revalidate([...tags])
}
async revalidateSettings(locale: string) {
return this.revalidate([settingsCacheTag(locale)])
}
async revalidate(tags: string[]): Promise<RevalidationResult> {
const target = this.resolveTarget(tags)
if (!target) return { status: 'disabled', tags }
const failure = await this.attempt(tags, target.webAppUrl, target.secret)
if (!failure) return { status: 'purged', tags }
this.reportFailedAttempt(tags, 1, failure)
this.scheduleRetries(tags, target.webAppUrl, target.secret)
return { status: 'retrying', tags }
}
protected resolveTarget(tags: string[]): { webAppUrl: string; secret: string } | null {
const webAppUrl = env.get('WEB_APP_URL')
const secret = env.get('WEB_REVALIDATE_SECRET')
if (webAppUrl && secret) return { webAppUrl, secret }
const message = `Web revalidation of [${tags.join(', ')}] skipped: WEB_APP_URL or WEB_REVALIDATE_SECRET is not set`
if (env.get('NODE_ENV') === 'production') logger.warn(message)
else logger.debug(message)
return null
}
private reportFailedAttempt(tags: string[], attempt: number, failure: string) {
logger.warn(
`Web revalidation of [${tags.join(', ')}] failed on attempt ${attempt}/${REVALIDATION_MAX_ATTEMPTS}: ${failure}`
)
}
/** Returns a failure description, or `null` when the web app confirmed the purge. */
protected async attempt(tags: string[], webAppUrl: string, secret: string) {
try {
const response = await fetch(new URL('/api/revalidate', webAppUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Revalidate-Secret': secret,
},
body: JSON.stringify({ tags }),
signal: AbortSignal.timeout(REVALIDATION_TIMEOUT_MS),
})
return response.ok ? null : `responded with status ${response.status}`
} catch (error) {
return error instanceof Error ? error.message : String(error)
}
}
private scheduleRetries(tags: string[], webAppUrl: string, secret: string) {
const retries = this.runRetries(tags, webAppUrl, secret)
pendingRetries.add(retries)
void retries.finally(() => pendingRetries.delete(retries))
}
/** Runs attempts 2..N with backoff, stopping at the first one that purges. */
private async runRetries(tags: string[], webAppUrl: string, secret: string) {
let failure = 'not attempted'
for (let attempt = 2; attempt <= REVALIDATION_MAX_ATTEMPTS; attempt++) {
await this.wait(this.backoffFor(attempt))
const attemptFailure = await this.attempt(tags, webAppUrl, secret)
if (!attemptFailure) {
logger.info(`Web revalidation of [${tags.join(', ')}] succeeded on attempt ${attempt}`)
return
}
failure = attemptFailure
this.reportFailedAttempt(tags, attempt, failure)
}
logger.error(
`Web revalidation of [${tags.join(', ')}] gave up after ${REVALIDATION_MAX_ATTEMPTS} attempts: ${failure}. ` +
'The public site serves cached content until its revalidate window expires (60s); ' +
'publishing again sends a fresh purge.'
)
}
protected backoffFor(attempt: number) {
const delay = REVALIDATION_RETRY_BASE_DELAY_MS * 2 ** (attempt - 2)
return delay + Math.random() * delay
}
protected wait(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms).unref?.()
})
}
}