Add initial project files
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
export default [
|
||||
{
|
||||
slug: 'admins.view',
|
||||
name: 'Admin view',
|
||||
},
|
||||
{
|
||||
slug: 'admins.create',
|
||||
name: 'Admin create',
|
||||
},
|
||||
{
|
||||
slug: 'admins.update',
|
||||
name: 'Admin update',
|
||||
},
|
||||
{
|
||||
slug: 'admins.delete',
|
||||
name: 'Admin delete',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,96 @@
|
||||
import { HttpContext } from '@adonisjs/core/http'
|
||||
import { createAdminValidator } from '#base_modules/Admin/validators/admin_create_validator'
|
||||
import { updateAdminValidator } from '#base_modules/Admin/validators/admin_update_validator'
|
||||
import { addSearchFilter, searchableColumns } from '#services/database_inspector'
|
||||
import Admins from '#base_modules/Admin/model/admin'
|
||||
import AdminsPolicy from '#base_modules/Admin/policy/admin_policy'
|
||||
import { updateProfileAdminValidator } from '#base_modules/Admin/validators/admin_update_profile_validator'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
|
||||
export default class AdminsController {
|
||||
public async index({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(AdminsPolicy).authorize('viewList')
|
||||
const {
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
search = null,
|
||||
sortBy = 'id',
|
||||
sortOrder = 'desc',
|
||||
status = null,
|
||||
} = request.all()
|
||||
const query = Admins.query()
|
||||
if (status) {
|
||||
const statuses = Array.isArray(status) ? status : [status]
|
||||
query.whereIn('status', statuses)
|
||||
}
|
||||
if (search) {
|
||||
const columns = Admins.searchable || (await searchableColumns('admins'))
|
||||
addSearchFilter(query, columns, search)
|
||||
}
|
||||
const result = await query.orderBy(sortBy, sortOrder).paginate(page, perPage)
|
||||
return response.ok(result)
|
||||
}
|
||||
|
||||
public async store({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(AdminsPolicy).authorize('create')
|
||||
const data = await createAdminValidator.validate(request.all())
|
||||
const record = await Admins.create(data)
|
||||
if (record) {
|
||||
await record.related('roles').sync(data.roles)
|
||||
return response.created({ message: 'Successfully created.' })
|
||||
}
|
||||
return response.badRequest({ message: 'Cannot create.' })
|
||||
}
|
||||
|
||||
public async show({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(AdminsPolicy).authorize('view')
|
||||
const { id } = request.params()
|
||||
if (!id) return response.notFound({ message: 'Not found.' })
|
||||
const record = await Admins.find(id)
|
||||
if (!record) return response.notFound({ message: 'Not found.' })
|
||||
await record.load('roles')
|
||||
return response.ok(record)
|
||||
}
|
||||
|
||||
public async update({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(AdminsPolicy).authorize('update')
|
||||
const { id } = request.params()
|
||||
if (!id) return response.notFound({ message: 'Not found.' })
|
||||
const data = await updateAdminValidator.validate(request.all())
|
||||
const record = await Admins.find(id)
|
||||
if (!record) return response.notFound({ message: 'Not found.' })
|
||||
record.merge(data)
|
||||
await record.save()
|
||||
await record.related('roles').sync(data.roles)
|
||||
return response.ok({ message: 'Updated.' })
|
||||
}
|
||||
|
||||
public async destroy({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(AdminsPolicy).authorize('delete')
|
||||
const { id } = request.params()
|
||||
if (!id) return response.notFound({ message: 'Not found.' })
|
||||
const admins = await Admins.all()
|
||||
if (admins.length === 1) {
|
||||
return response.badRequest({ message: "Last Admin can't be deleted!" })
|
||||
}
|
||||
const record = await Admins.find(id)
|
||||
if (!record) return response.notFound({ message: 'Not found.' })
|
||||
await Admins.query().where('id', id).delete()
|
||||
return response.ok({ message: 'Deleted.' })
|
||||
}
|
||||
|
||||
public async updateProfile({ request, response, auth }: HttpContext) {
|
||||
const user = auth.use('api').user
|
||||
if (!user) return response.unauthorized()
|
||||
const data = await updateProfileAdminValidator.validate(request.all())
|
||||
user.merge(data)
|
||||
user.save()
|
||||
await user.load('roles')
|
||||
const roles = user.roles.map((role) => role.id)
|
||||
const permissions = await db
|
||||
.from('role_permissions')
|
||||
.whereIn('role_id', roles)
|
||||
.select('permission_slug')
|
||||
return response.ok({ user, permissions: permissions.map((perm) => perm.permission_slug) })
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import hash from '@adonisjs/core/services/hash'
|
||||
import { compose } from '@adonisjs/core/helpers'
|
||||
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
|
||||
import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
|
||||
import { BaseModel, column, manyToMany } from '@adonisjs/lucid/orm'
|
||||
import type { ManyToMany } from '@adonisjs/lucid/types/relations'
|
||||
import { DateTime } from 'luxon'
|
||||
import Role from '#base_modules/Role/model/role'
|
||||
import Permission from '#base_modules/Permission/model/permission'
|
||||
|
||||
const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
|
||||
uids: ['email'],
|
||||
passwordColumnName: 'password',
|
||||
})
|
||||
|
||||
const ComposedModel = compose(BaseModel, AuthFinder) as typeof BaseModel
|
||||
|
||||
export default class Admins extends ComposedModel {
|
||||
static searchable = {
|
||||
string: ['email', 'first_name', 'last_name'],
|
||||
number: ['id'],
|
||||
}
|
||||
|
||||
@column({ isPrimary: true, columnName: 'id' })
|
||||
declare id: number
|
||||
|
||||
@column({ columnName: 'first_name' })
|
||||
declare firstName: string | null
|
||||
|
||||
@column({ columnName: 'last_name' })
|
||||
declare lastName: string | null
|
||||
|
||||
@column({ columnName: 'email' })
|
||||
declare email: string
|
||||
|
||||
@column({ columnName: 'status' })
|
||||
declare status: 'active' | 'inactive'
|
||||
|
||||
@column({ serializeAs: null, columnName: 'password' })
|
||||
declare password: string
|
||||
|
||||
@column.dateTime({ autoCreate: true, columnName: 'created_at' })
|
||||
declare createdAt: DateTime
|
||||
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true, columnName: 'updated_at' })
|
||||
declare updatedAt: DateTime | null
|
||||
|
||||
@manyToMany(() => Role, {
|
||||
pivotTable: 'admin_roles',
|
||||
pivotForeignKey: 'admin_id',
|
||||
pivotRelatedForeignKey: 'role_id',
|
||||
})
|
||||
declare roles: ManyToMany<typeof Role>
|
||||
|
||||
declare permissions: Permission[]
|
||||
|
||||
static accessTokens = DbAccessTokensProvider.forModel(Admins)
|
||||
|
||||
async loadPermissions() {
|
||||
this.permissions = await Permission.query()
|
||||
.join('role_permissions', 'permissions.id', 'role_permissions.permission_id')
|
||||
.join('admin_roles', 'role_permissions.role_id', 'admin_roles.role_id')
|
||||
.where('admin_roles.admin_id', this.id)
|
||||
.exec()
|
||||
return this
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BasePolicy } from '@adonisjs/bouncer'
|
||||
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
|
||||
import Admins from '#base_modules/Admin/model/admin'
|
||||
import { getAdminPermissionSlugs } from '#services/permission_service'
|
||||
|
||||
export default class AdminsPolicy extends BasePolicy {
|
||||
// Example permission slugs: admins.view, admins.create, admins.update, admins.delete
|
||||
async viewList(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('admins.view')
|
||||
}
|
||||
|
||||
async view(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('admins.view')
|
||||
}
|
||||
|
||||
async create(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('admins.create')
|
||||
}
|
||||
|
||||
async update(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('admins.update')
|
||||
}
|
||||
|
||||
async delete(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('admins.delete')
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import router from '@adonisjs/core/services/router'
|
||||
import { middleware } from '#start/kernel'
|
||||
|
||||
const adminController = () => import('#base_modules/Admin/controller/admin_controller')
|
||||
|
||||
router.put('/admin/update-profile', [adminController, 'updateProfile']).use([middleware.auth()])
|
||||
router.resource('/admin', adminController).apiOnly().use('*', [middleware.auth()]) //.use(['store', 'update', 'destroy'], [middleware.auth()])
|
||||
@@ -0,0 +1,20 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const createAdminValidator = vine.compile(
|
||||
vine.object({
|
||||
id: vine.number().optional(),
|
||||
email: vine
|
||||
.string()
|
||||
.trim()
|
||||
.email()
|
||||
.unique(async (db, value) => {
|
||||
const exists = await db.from('admins').where('email', value).first()
|
||||
return !exists
|
||||
}),
|
||||
password: vine.string().trim().minLength(8),
|
||||
firstName: vine.string().trim().optional(),
|
||||
lastName: vine.string().trim().optional(),
|
||||
status: vine.enum(['active', 'inactive']),
|
||||
roles: vine.array(vine.number()),
|
||||
})
|
||||
)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const updateProfileAdminValidator = vine.compile(
|
||||
vine.object({
|
||||
id: vine.number(),
|
||||
email: vine
|
||||
.string()
|
||||
.trim()
|
||||
.email()
|
||||
.unique(async (db, value, field) => {
|
||||
const id = field.data.id
|
||||
const exists = await db.from('admins').where('email', value).whereNot('id', id).first()
|
||||
return !exists
|
||||
}),
|
||||
password: vine.string().trim().minLength(8).optional(),
|
||||
firstName: vine.string().trim().minLength(1),
|
||||
lastName: vine.string().trim().minLength(1),
|
||||
status: vine.enum(['active', 'inactive']).optional(),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const updateAdminValidator = vine.compile(
|
||||
vine.object({
|
||||
id: vine.number().optional(),
|
||||
email: vine
|
||||
.string()
|
||||
.trim()
|
||||
.email()
|
||||
.unique(async (db, value, field) => {
|
||||
const id = field.data.id
|
||||
const exists = await db.from('admins').where('email', value).whereNot('id', id).first()
|
||||
return !exists
|
||||
})
|
||||
.optional(),
|
||||
password: vine.string().trim().minLength(8).optional(),
|
||||
firstName: vine.string().trim().optional(),
|
||||
lastName: vine.string().trim().optional(),
|
||||
status: vine.enum(['active', 'inactive']).optional(),
|
||||
roles: vine.array(vine.number()),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
import Admin from '#base_modules/Admin/model/admin'
|
||||
import { HttpContext } from '@adonisjs/core/http'
|
||||
import hash from '@adonisjs/core/services/hash'
|
||||
import { authLoginValidator } from '#base_modules/Auth/validators/auth_login_validator'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
|
||||
export default class AuthController {
|
||||
async login({ request, response }: HttpContext) {
|
||||
const { email, password } = await request.validateUsing(authLoginValidator)
|
||||
const admin = await Admin.findBy('email', email)
|
||||
if (!admin) {
|
||||
return response.unauthorized({
|
||||
message: 'Invalid credentials.',
|
||||
})
|
||||
}
|
||||
|
||||
const verified = await hash.verify(admin.password, password)
|
||||
if (!verified) {
|
||||
return response.unauthorized({
|
||||
message: 'Invalid credentials.',
|
||||
})
|
||||
}
|
||||
|
||||
const token = await Admin.accessTokens.create(admin)
|
||||
|
||||
return response.ok({
|
||||
type: 'bearer',
|
||||
token: token.value?.release(),
|
||||
user: admin,
|
||||
})
|
||||
}
|
||||
|
||||
async logout({ auth }: HttpContext) {
|
||||
await auth.use('api').invalidateToken()
|
||||
return { message: 'Logged out' }
|
||||
}
|
||||
|
||||
async user({ response, auth }: HttpContext) {
|
||||
const user = await auth.use('api').user
|
||||
if (!user) return response.unauthorized()
|
||||
await user.load('roles')
|
||||
const roles = user.roles.map((role) => role.id)
|
||||
const permissions = await db
|
||||
.from('role_permissions')
|
||||
.whereIn('role_id', roles)
|
||||
.select('permission_slug')
|
||||
return response.ok({ user, permissions: permissions.map((perm) => perm.permission_slug) })
|
||||
}
|
||||
}
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
import { middleware } from '#start/kernel'
|
||||
import router from '@adonisjs/core/services/router'
|
||||
|
||||
const AuthController = () => import('#base_modules/Auth/controller/auth_controller')
|
||||
|
||||
router.post('/auth/login', [AuthController, 'login'])
|
||||
router.post('/auth/logout', [AuthController, 'logout']).use([middleware.auth()])
|
||||
router.get('/auth/user', [AuthController, 'user']).use([middleware.auth()])
|
||||
@@ -0,0 +1,8 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const authLoginValidator = vine.compile(
|
||||
vine.object({
|
||||
email: vine.string().trim().email(),
|
||||
password: vine.string().trim().minLength(8),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
export const ROOT_PATH = '/'
|
||||
@@ -0,0 +1,18 @@
|
||||
export default [
|
||||
{
|
||||
slug: 'file_manager.view',
|
||||
name: 'File Manager view',
|
||||
},
|
||||
{
|
||||
slug: 'file_manager.create',
|
||||
name: 'File Manager create',
|
||||
},
|
||||
{
|
||||
slug: 'file_manager.update',
|
||||
name: 'File Manager update',
|
||||
},
|
||||
{
|
||||
slug: 'file_manager.delete',
|
||||
name: 'File Manager delete',
|
||||
},
|
||||
]
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import listValidator from '#base_modules/FileManager/validator/list_validator'
|
||||
import uploadFileValidator from '#base_modules/FileManager/validator/upload_file_validator'
|
||||
import createDirectoryValidator from '#base_modules/FileManager/validator/create_directory_validator'
|
||||
import renameEntryValidator from '#base_modules/FileManager/validator/rename_entry_validator'
|
||||
import duplicateEntriesValidator from '#base_modules/FileManager/validator/duplicate_entries_validator'
|
||||
import FileMangerPolicy from '#base_modules/FileManager/policy/file_manager_policy'
|
||||
import type { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||
import getValidatedPathFromRequest from '#base_modules/FileManager/helpers/get_validated_path_from_request'
|
||||
import FileManagerService from '#base_modules/FileManager/services/file_manager_service'
|
||||
|
||||
export default class FileManagerController {
|
||||
private fileManagerService = new FileManagerService()
|
||||
|
||||
async index({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('viewList')
|
||||
|
||||
const path = await getValidatedPathFromRequest(request)
|
||||
|
||||
const query = request.all()
|
||||
|
||||
const params = await listValidator.validate({
|
||||
filterBy: query?.filterBy ?? 'all',
|
||||
sortBy: query?.sortBy ?? 'name',
|
||||
sortOrder: query?.sortOrder ?? 'asc',
|
||||
maxResults: query?.maxResults,
|
||||
paginationToken: query?.paginationToken,
|
||||
})
|
||||
|
||||
const entries = await this.fileManagerService.listDirectory(path, params)
|
||||
|
||||
const payload: {
|
||||
data: Entry[]
|
||||
meta: {
|
||||
nextPaginationToken: string | null
|
||||
}
|
||||
} = {
|
||||
data: entries.data,
|
||||
meta: entries.meta,
|
||||
}
|
||||
|
||||
return response.ok(payload)
|
||||
}
|
||||
|
||||
async show({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('view')
|
||||
|
||||
const path = await getValidatedPathFromRequest(request)
|
||||
const result = await this.fileManagerService.getFileEntryWithPreview(path)
|
||||
|
||||
const payload: { data: FileEntry & { content?: string } } = {
|
||||
data: result,
|
||||
}
|
||||
|
||||
return response.ok(payload)
|
||||
}
|
||||
|
||||
public async raw({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('view')
|
||||
|
||||
const path = await getValidatedPathFromRequest(request)
|
||||
const { stream, contentType, size, name } = await this.fileManagerService.getFileStream(path)
|
||||
|
||||
if (contentType) response.header('Content-Type', contentType)
|
||||
if (size !== undefined) response.header('Content-Length', String(size))
|
||||
response.header('Content-Disposition', `inline; filename="${encodeURIComponent(name)}"`)
|
||||
|
||||
return response.stream(stream)
|
||||
}
|
||||
|
||||
async createFolder({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('create')
|
||||
|
||||
const { name, path } = await createDirectoryValidator.validate(request.all())
|
||||
|
||||
const result = await this.fileManagerService.createDirectory(path, name)
|
||||
|
||||
const payload: { data: FolderEntry } = {
|
||||
data: result,
|
||||
}
|
||||
|
||||
return response.created(payload)
|
||||
}
|
||||
|
||||
async destroy({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('delete')
|
||||
|
||||
const path = await getValidatedPathFromRequest(request)
|
||||
|
||||
await this.fileManagerService.deleteEntry(path)
|
||||
|
||||
return response.noContent()
|
||||
}
|
||||
|
||||
async rename({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('update')
|
||||
|
||||
const { name, path } = await renameEntryValidator.validate(request.all())
|
||||
|
||||
const entry = await this.fileManagerService.moveEntry(path, name)
|
||||
|
||||
const payload: { data: Entry } = { data: entry }
|
||||
|
||||
return response.ok(payload)
|
||||
}
|
||||
|
||||
async upload({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('create')
|
||||
|
||||
const { file, path } = await uploadFileValidator.validate(request.all())
|
||||
|
||||
const entry = await this.fileManagerService.writeFile(path, file)
|
||||
|
||||
const payload: { data: Entry } = { data: entry }
|
||||
|
||||
return response.created(payload)
|
||||
}
|
||||
|
||||
async duplicate({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('create')
|
||||
|
||||
const { paths } = await duplicateEntriesValidator.validate(request.all())
|
||||
|
||||
const results = await this.fileManagerService.copyEntries(paths)
|
||||
|
||||
const payload: { data: Entry[] } = { data: results }
|
||||
|
||||
return response.created(payload)
|
||||
}
|
||||
|
||||
async search({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(FileMangerPolicy).authorize('viewList')
|
||||
|
||||
const query = request.input('q', '')
|
||||
|
||||
if (!query.trim()) {
|
||||
return response.badRequest({ message: 'Search query is required' })
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
request.request.on('close', () => abortController.abort())
|
||||
|
||||
const results = await this.fileManagerService.search('/', query, abortController.signal)
|
||||
|
||||
const payload: { data: Entry[] } = { data: results }
|
||||
|
||||
return response.ok(payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import vine from '@vinejs/vine'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { pathSchema } from '#base_modules/FileManager/validator/schemas'
|
||||
import { ROOT_PATH } from '#base_modules/FileManager/config/general'
|
||||
import PathService from '#base_modules/FileManager/services/path_service'
|
||||
|
||||
const pathService = new PathService()
|
||||
|
||||
export default async function getValidatedPathFromRequest(
|
||||
request: HttpContext['request']
|
||||
): Promise<string> {
|
||||
const segments = request.param('*', [ROOT_PATH])
|
||||
const path = pathService.join(...segments.map(decodeURIComponent))
|
||||
return vine.compile(pathSchema).validate(path)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BasePolicy } from '@adonisjs/bouncer'
|
||||
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
|
||||
import Admins from '#base_modules/Admin/model/admin'
|
||||
import { getAdminPermissionSlugs } from '#services/permission_service'
|
||||
|
||||
export default class FileMangerPolicy extends BasePolicy {
|
||||
async viewList(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('file_manager.view')
|
||||
}
|
||||
|
||||
async view(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('file_manager.view')
|
||||
}
|
||||
|
||||
async create(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('file_manager.create')
|
||||
}
|
||||
|
||||
async update(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('file_manager.update')
|
||||
}
|
||||
|
||||
async delete(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('file_manager.delete')
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import router from '@adonisjs/core/services/router'
|
||||
import { middleware } from '#start/kernel'
|
||||
|
||||
const FileManagerController = () =>
|
||||
import('#base_modules/FileManager/controller/file_manager_controller')
|
||||
|
||||
router
|
||||
.group(() => {
|
||||
router.get('/', [FileManagerController, 'index']).as('root')
|
||||
|
||||
router.put('/entries/rename', [FileManagerController, 'rename'])
|
||||
router.post('/folders', [FileManagerController, 'createFolder'])
|
||||
|
||||
router.get('/files/raw/*', [FileManagerController, 'raw'])
|
||||
router.get('/files/details/*', [FileManagerController, 'show'])
|
||||
router.post('/files', [FileManagerController, 'upload'])
|
||||
router.post('/files/duplicate', [FileManagerController, 'duplicate'])
|
||||
|
||||
router.get('/search', [FileManagerController, 'search'])
|
||||
|
||||
router.delete('/*', [FileManagerController, 'destroy'])
|
||||
router.get('/*', [FileManagerController, 'index']).as('nested')
|
||||
})
|
||||
.prefix('/file-manager')
|
||||
.use([middleware.auth()])
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Entry } from '#base_modules/FileManager/types/entries'
|
||||
|
||||
export type EntryFilter = 'media' | 'archives' | 'documents' | 'all'
|
||||
export type EntrySortBy = 'name' | 'size' | 'type' | 'date'
|
||||
export type EntrySortOrder = 'asc' | 'desc'
|
||||
|
||||
export interface EntryListParams {
|
||||
filterBy?: EntryFilter
|
||||
sortBy?: EntrySortBy
|
||||
sortOrder?: EntrySortOrder
|
||||
maxResults?: number
|
||||
paginationToken?: string
|
||||
}
|
||||
|
||||
export interface EntryListResult {
|
||||
data: Entry[]
|
||||
meta: {
|
||||
nextPaginationToken: string | null
|
||||
}
|
||||
}
|
||||
|
||||
interface PaginationTokenPayload {
|
||||
offset: number
|
||||
}
|
||||
|
||||
const FILTER_EXTENSIONS: Record<Exclude<EntryFilter, 'all'>, string[]> = {
|
||||
media: ['jpg', 'jpeg', 'png', 'gif', 'mp4', 'mp3', 'wav'],
|
||||
archives: ['zip', 'rar', '7z'],
|
||||
documents: ['pdf', 'doc', 'docx', 'txt'],
|
||||
}
|
||||
|
||||
export default class EntryListService {
|
||||
public apply(entries: Entry[], params: EntryListParams = {}): EntryListResult {
|
||||
const {
|
||||
filterBy = 'all',
|
||||
sortBy = 'name',
|
||||
sortOrder = 'asc',
|
||||
maxResults = 25,
|
||||
paginationToken,
|
||||
} = params
|
||||
|
||||
const limit = Math.min(Math.max(maxResults, 1), 100)
|
||||
const decoded = this.decodeToken(paginationToken)
|
||||
const offset = decoded?.offset ?? 0
|
||||
|
||||
const result = this.sortEntries(this.filterEntries(entries, filterBy), sortBy, sortOrder)
|
||||
const data = result.slice(offset, offset + limit)
|
||||
const nextOffset = offset + limit
|
||||
const nextPaginationToken =
|
||||
nextOffset < result.length
|
||||
? this.encodeToken({
|
||||
offset: nextOffset,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
filterBy,
|
||||
})
|
||||
: null
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
nextPaginationToken,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private filterEntries(entries: Entry[], filterBy: EntryFilter): Entry[] {
|
||||
if (filterBy === 'all') return entries
|
||||
|
||||
return entries.filter((entry) => {
|
||||
if (entry.type === 'folder') return true
|
||||
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (!ext) return false
|
||||
|
||||
return FILTER_EXTENSIONS[filterBy].includes(ext)
|
||||
})
|
||||
}
|
||||
|
||||
private sortEntries(entries: Entry[], sortBy: EntrySortBy, sortOrder: EntrySortOrder): Entry[] {
|
||||
return [...entries].sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === 'folder' ? -1 : 1
|
||||
}
|
||||
|
||||
let valA: string | number
|
||||
let valB: string | number
|
||||
|
||||
switch (sortBy) {
|
||||
case 'size':
|
||||
valA = a.type === 'file' ? (a.size ?? 0) : 0
|
||||
valB = b.type === 'file' ? (b.size ?? 0) : 0
|
||||
break
|
||||
|
||||
case 'date':
|
||||
valA = a.type === 'file' ? new Date(a.updatedAt ?? 0).getTime() : 0
|
||||
valB = b.type === 'file' ? new Date(b.updatedAt ?? 0).getTime() : 0
|
||||
break
|
||||
|
||||
case 'type':
|
||||
valA = a.name.split('.').pop() ?? ''
|
||||
valB = b.name.split('.').pop() ?? ''
|
||||
break
|
||||
|
||||
case 'name':
|
||||
default:
|
||||
valA = a.name.toLowerCase()
|
||||
valB = b.name.toLowerCase()
|
||||
}
|
||||
|
||||
if (valA < valB) return sortOrder === 'desc' ? 1 : -1
|
||||
if (valA > valB) return sortOrder === 'desc' ? -1 : 1
|
||||
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
private encodeToken(payload: object): string {
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64')
|
||||
}
|
||||
|
||||
private decodeToken(token?: string): PaginationTokenPayload | null {
|
||||
if (!token) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(Buffer.from(token, 'base64').toString('utf-8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Entry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||
|
||||
export default class EntrySearchService {
|
||||
public search(entries: Entry[], query: string, signal?: AbortSignal): Entry[] {
|
||||
if (signal?.aborted) return []
|
||||
|
||||
const results: Entry[] = []
|
||||
const addedFolders = new Set<string>()
|
||||
const q = query.toLowerCase()
|
||||
|
||||
for (const entry of entries) {
|
||||
if (signal?.aborted) return results
|
||||
|
||||
if (entry.type === 'folder') {
|
||||
this.addFolderIfMatches(entry, q, addedFolders, results)
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.path.toLowerCase().includes(q)) {
|
||||
results.push(entry)
|
||||
}
|
||||
|
||||
for (const folder of this.getFileParentFolders(entry.path)) {
|
||||
this.addFolderIfMatches(folder, q, addedFolders, results)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private addFolderIfMatches(
|
||||
folder: FolderEntry,
|
||||
query: string,
|
||||
addedFolders: Set<string>,
|
||||
results: Entry[]
|
||||
) {
|
||||
if (!folder.path.toLowerCase().includes(query) || addedFolders.has(folder.path)) return
|
||||
|
||||
addedFolders.add(folder.path)
|
||||
results.push(folder)
|
||||
}
|
||||
|
||||
private getFileParentFolders(path: string): FolderEntry[] {
|
||||
const segments = path.replace(/^\/+/, '').split('/').slice(0, -1)
|
||||
const folders: FolderEntry[] = []
|
||||
let current = ''
|
||||
|
||||
for (const segment of segments) {
|
||||
current = current ? `${current}/${segment}` : segment
|
||||
|
||||
folders.push({
|
||||
name: segment,
|
||||
path: current,
|
||||
type: 'folder',
|
||||
})
|
||||
}
|
||||
|
||||
return folders
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { MultipartFile } from '@adonisjs/core/bodyparser'
|
||||
import type { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||
import {
|
||||
default as EntryListService,
|
||||
type EntryListParams,
|
||||
type EntryListResult,
|
||||
} from '#base_modules/FileManager/services/entry_list_service'
|
||||
import createStorageService from '#base_modules/FileManager/services/storage_service/create_storage_service'
|
||||
import type {
|
||||
FileStream,
|
||||
StorageService,
|
||||
} from '#base_modules/FileManager/services/storage_service/storage_service_interface'
|
||||
import FilePreviewService from '#base_modules/FileManager/services/file_preview_service'
|
||||
import EntrySearchService from '#base_modules/FileManager/services/entry_search_service'
|
||||
import PathService from '#base_modules/FileManager/services/path_service'
|
||||
import {
|
||||
EntryExistsException,
|
||||
FilenameMissingExtensionException,
|
||||
} from '#exceptions/file_manager_exceptions'
|
||||
|
||||
export default class FileManagerService {
|
||||
private filePreviewService = new FilePreviewService()
|
||||
private entrySearchService = new EntrySearchService()
|
||||
private entryListService = new EntryListService()
|
||||
private pathService = new PathService()
|
||||
private storageService: StorageService
|
||||
|
||||
constructor(storageService?: StorageService) {
|
||||
if (storageService) this.storageService = storageService
|
||||
else this.storageService = createStorageService()
|
||||
}
|
||||
|
||||
public async listDirectory(path: string, params: EntryListParams): Promise<EntryListResult> {
|
||||
const entries = await this.storageService.listDirectoryEntries(path)
|
||||
|
||||
return this.entryListService.apply(entries, params)
|
||||
}
|
||||
|
||||
public async getFileEntryWithPreview(path: string): Promise<FileEntry & { content?: string }> {
|
||||
const entry = await this.storageService.getFileEntry(path)
|
||||
|
||||
return this.filePreviewService.withPreview(entry, () =>
|
||||
this.storageService.getFileContent(path)
|
||||
)
|
||||
}
|
||||
|
||||
public getFileStream(path: string): Promise<FileStream> {
|
||||
return this.storageService.getFileStream(path)
|
||||
}
|
||||
|
||||
public async createDirectory(path: string, name: string): Promise<FolderEntry> {
|
||||
const result = await this.storageService.createDirectory(path, name)
|
||||
|
||||
return {
|
||||
name,
|
||||
path: result.path,
|
||||
type: 'folder',
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteEntry(path: string): Promise<void> {
|
||||
await this.storageService.deleteEntry(path)
|
||||
}
|
||||
|
||||
public async moveEntry(path: string, name: string): Promise<Entry> {
|
||||
const entry = await this.storageService.getEntry(path)
|
||||
const dir = this.pathService.dirname(path)
|
||||
const newPath = dir ? this.pathService.join(dir, name) : name
|
||||
|
||||
if (entry.type === 'file' && !this.pathService.getFileNameMeta(name).ext) {
|
||||
throw new FilenameMissingExtensionException()
|
||||
}
|
||||
|
||||
if (await this.storageService.entryExists(newPath)) {
|
||||
throw new EntryExistsException()
|
||||
}
|
||||
|
||||
const result = await this.storageService.moveEntry(path, newPath)
|
||||
|
||||
return this.storageService.getEntry(result.path)
|
||||
}
|
||||
|
||||
public async writeFile(path: string, file: MultipartFile): Promise<Entry> {
|
||||
const dir = this.pathService.toStoragePath(path)
|
||||
const safeName = await this.pathService.generateUniqueName(dir, file.clientName, (target) => {
|
||||
return this.storageService.entryExists(target)
|
||||
})
|
||||
const writePath = this.pathService.join(dir, safeName)
|
||||
const result = await this.storageService.writeFile(writePath, file)
|
||||
|
||||
return this.storageService.getEntry(result.path)
|
||||
}
|
||||
|
||||
public async copyEntries(paths: string[]): Promise<Entry[]> {
|
||||
const results: Entry[] = []
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const sourceEntry = await this.storageService.getFileEntry(path)
|
||||
const dir = this.pathService.dirname(sourceEntry.path)
|
||||
const newName = await this.pathService.generateUniqueName(
|
||||
dir,
|
||||
sourceEntry.name,
|
||||
(target) => this.storageService.entryExists(target),
|
||||
(data) => {
|
||||
const template = 'copy_'
|
||||
const regex = new RegExp(`\\s?${template}(\\d+)$`)
|
||||
const base = data.base.replace(regex, '').trim()
|
||||
|
||||
return `${base} ${template}${data.count}${data.extension}`
|
||||
}
|
||||
)
|
||||
const copyPath = this.pathService.join(dir, newName)
|
||||
const result = await this.storageService.copyEntry(path, copyPath)
|
||||
const copiedEntry = await this.storageService.getFileEntry(result.path)
|
||||
|
||||
results.push(copiedEntry)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
public async search(path: string, query: string, signal?: AbortSignal): Promise<Entry[]> {
|
||||
const entries = await this.storageService.listAllEntries(path)
|
||||
|
||||
return this.entrySearchService.search(entries, query, signal)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FileEntry } from '#base_modules/FileManager/types/entries'
|
||||
|
||||
const PREVIEWABLE_TEXT_EXTENSIONS = ['txt', 'md', 'log', 'json', 'csv']
|
||||
|
||||
export default class FilePreviewService {
|
||||
public async withPreview(
|
||||
entry: FileEntry,
|
||||
getContent: () => Promise<string>
|
||||
): Promise<FileEntry & { content?: string }> {
|
||||
if (!this.isPreviewableTextFile(entry.name)) return entry
|
||||
|
||||
const content = await getContent()
|
||||
|
||||
return { ...entry, content }
|
||||
}
|
||||
|
||||
private isPreviewableTextFile(name: string): boolean {
|
||||
const fileExt = name.split('.').pop()?.toLowerCase()
|
||||
|
||||
return PREVIEWABLE_TEXT_EXTENSIONS.includes(fileExt as string)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { posix } from 'node:path'
|
||||
|
||||
export interface FileNameMeta {
|
||||
name: string
|
||||
ext: string
|
||||
}
|
||||
|
||||
export interface UniqueNameFormatterParams {
|
||||
base: string
|
||||
count: number
|
||||
extension: string
|
||||
}
|
||||
|
||||
export type UniqueNameFormatter = (params: UniqueNameFormatterParams) => string
|
||||
|
||||
export default class PathService {
|
||||
public toStoragePath(path: string = ''): string {
|
||||
let safePath = path.replace(/^(\.\.(\/|\\|$))+/, '')
|
||||
safePath = safePath.startsWith('/') ? safePath.slice(1) : safePath
|
||||
|
||||
return safePath
|
||||
}
|
||||
|
||||
public toClientPath(path: string = ''): string {
|
||||
return path.replace(/^\/?uploads\//, '/')
|
||||
}
|
||||
|
||||
public join(...parts: string[]): string {
|
||||
return posix.join(...parts)
|
||||
}
|
||||
|
||||
public dirname(path: string): string {
|
||||
return this.toStoragePath(path).split('/').slice(0, -1).join('/')
|
||||
}
|
||||
|
||||
public basename(path: string): string {
|
||||
return path.split('/').reverse()[0]
|
||||
}
|
||||
|
||||
public getFileNameMeta(name: string): FileNameMeta {
|
||||
const extIndex = name.lastIndexOf('.')
|
||||
const rawName = extIndex !== -1 ? name.slice(0, extIndex) : name
|
||||
const ext = extIndex !== -1 ? name.slice(extIndex).trim() : ''
|
||||
|
||||
return {
|
||||
name: rawName,
|
||||
ext,
|
||||
}
|
||||
}
|
||||
|
||||
public sanitizeFileName(name: string): string {
|
||||
return name.replace(/[()]/g, '').replace(/[^a-zA-Z0-9.\-_ ]/g, '')
|
||||
}
|
||||
|
||||
public async generateUniqueName(
|
||||
dir: string,
|
||||
fileName: string,
|
||||
exists: (path: string) => Promise<boolean>,
|
||||
formatter?: UniqueNameFormatter
|
||||
): Promise<string> {
|
||||
const { ext, name } = this.getFileNameMeta(fileName)
|
||||
const safeBase = this.sanitizeFileName(name)
|
||||
|
||||
let counter = 1
|
||||
let newName = `${safeBase}${ext}`
|
||||
|
||||
while (await exists(this.join(dir, newName))) {
|
||||
newName =
|
||||
typeof formatter === 'function'
|
||||
? formatter({ base: safeBase, count: counter, extension: ext })
|
||||
: `${safeBase}-${counter}${ext}`
|
||||
|
||||
counter++
|
||||
|
||||
if (counter > 5) {
|
||||
newName = `${randomUUID()}${ext}`
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return newName
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import env from '#start/env'
|
||||
import type { StorageService } from './storage_service_interface.js'
|
||||
import { FSStorageService } from './fs_storage_service.js'
|
||||
import { S3StorageService } from './s3_storage_service.js'
|
||||
|
||||
type StorageDisk = 'fs' | 's3'
|
||||
|
||||
export default function createStorageService(
|
||||
disk: StorageDisk = env.get('DRIVE_DISK')
|
||||
): StorageService {
|
||||
switch (disk) {
|
||||
case 'fs':
|
||||
return new FSStorageService()
|
||||
|
||||
case 's3':
|
||||
return new S3StorageService()
|
||||
|
||||
default:
|
||||
throw new Error('Unsupported: ' + disk)
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
export default class FSDirectoryMarkerService {
|
||||
public markerName = '.keep'
|
||||
|
||||
public markerPath(path: string): string {
|
||||
return `${path}/${this.markerName}`
|
||||
}
|
||||
|
||||
public isMarkerName(name: string): boolean {
|
||||
return name === this.markerName
|
||||
}
|
||||
|
||||
public isMarkerPath(path: string): boolean {
|
||||
return path.endsWith(`/${this.markerName}`)
|
||||
}
|
||||
|
||||
public directoryPathFromMarker(path: string): string {
|
||||
return path.replace(new RegExp(`/${this.markerName}$`), '')
|
||||
}
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
import drive from '@adonisjs/drive/services/main'
|
||||
import {
|
||||
DirExistsException,
|
||||
DirNotFoundException,
|
||||
FileNotFoundException,
|
||||
} from '#exceptions/file_manager_exceptions'
|
||||
import PathService from '#base_modules/FileManager/services/path_service'
|
||||
import type { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||
import type { StorageService } from '#base_modules/FileManager/services/storage_service/storage_service_interface'
|
||||
import FSDirectoryMarkerService from '#base_modules/FileManager/services/storage_service/fs_directory_marker_service'
|
||||
|
||||
export class FSStorageService implements StorageService {
|
||||
private disk = drive.use('fs')
|
||||
private pathService = new PathService()
|
||||
private directoryMarkerService = new FSDirectoryMarkerService()
|
||||
|
||||
private directoryPrefix(path: string): string {
|
||||
return path ? `${path.replace(/\/$/, '')}/` : path
|
||||
}
|
||||
|
||||
private async listDirectoryFiles(path: string): Promise<{ key: string; relative: string }[]> {
|
||||
const prefix = this.directoryPrefix(path)
|
||||
const files: { key: string; relative: string }[] = []
|
||||
|
||||
let paginationToken: string | undefined
|
||||
|
||||
do {
|
||||
const response = await this.disk.listAll(prefix, { recursive: true, paginationToken })
|
||||
|
||||
for (const item of response.objects) {
|
||||
if (!item.isFile || !item.key.startsWith(prefix)) continue
|
||||
|
||||
files.push({ key: item.key, relative: item.key.slice(prefix.length) })
|
||||
}
|
||||
|
||||
paginationToken = response.paginationToken
|
||||
} while (paginationToken)
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
private async moveDirectory(oldPath: string, newPath: string) {
|
||||
await this.disk.put(this.directoryMarkerService.markerPath(newPath), '')
|
||||
|
||||
for (const file of await this.listDirectoryFiles(oldPath)) {
|
||||
const target = this.pathService.join(newPath, file.relative)
|
||||
const dir = this.pathService.dirname(target)
|
||||
|
||||
if (dir) await this.disk.put(this.directoryMarkerService.markerPath(dir), '')
|
||||
|
||||
await this.disk.move(file.key, target)
|
||||
}
|
||||
|
||||
await this.disk.deleteAll(oldPath)
|
||||
}
|
||||
|
||||
public entryExists: StorageService['entryExists'] = async (path) => {
|
||||
const storagePath = this.pathService.toStoragePath(path)
|
||||
|
||||
if (await this.disk.exists(storagePath)) return true
|
||||
|
||||
return this.directoryExists(storagePath)
|
||||
}
|
||||
|
||||
private async directoryExists(path: string): Promise<boolean> {
|
||||
if (await this.disk.exists(this.directoryMarkerService.markerPath(path))) return true
|
||||
|
||||
try {
|
||||
const response = await this.disk.listAll(this.directoryPrefix(path))
|
||||
|
||||
return Array.from(response.objects).length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async getFileEntryFromStoragePath(
|
||||
storagePath: string,
|
||||
params: {
|
||||
name?: string
|
||||
path?: string
|
||||
url?: string
|
||||
} = {}
|
||||
): Promise<FileEntry> {
|
||||
const metadata = await this.disk.getMetaData(storagePath)
|
||||
const path = params.path ?? this.pathService.toClientPath(storagePath)
|
||||
const url = params.url ?? (await this.disk.getUrl(storagePath))
|
||||
|
||||
return {
|
||||
name: params.name ?? this.pathService.basename(path),
|
||||
type: 'file',
|
||||
size: metadata?.contentLength ?? 0,
|
||||
path,
|
||||
url,
|
||||
updatedAt: metadata.lastModified.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
public listDirectoryEntries: StorageService['listDirectoryEntries'] = async (path) => {
|
||||
const diskPath = this.pathService.toStoragePath(path)
|
||||
|
||||
const response = await this.disk.listAll(diskPath)
|
||||
|
||||
const entries = await Promise.all(
|
||||
[...response.objects].map(async (file) => {
|
||||
if (file.isFile && !this.directoryMarkerService.isMarkerName(file.name)) {
|
||||
const relativePath = this.pathService.toClientPath(file.key)
|
||||
|
||||
return this.getFileEntryFromStoragePath(file.key, {
|
||||
name: file.name,
|
||||
path: relativePath,
|
||||
url: await this.disk.getUrl(relativePath),
|
||||
})
|
||||
}
|
||||
|
||||
if (file.isDirectory) {
|
||||
return {
|
||||
name: file.name,
|
||||
type: 'folder',
|
||||
path: this.pathService.toClientPath(file.prefix),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
)
|
||||
|
||||
return entries.filter(Boolean) as Entry[]
|
||||
}
|
||||
|
||||
public listAllEntries: StorageService['listAllEntries'] = async (path) => {
|
||||
let response: Awaited<ReturnType<typeof this.disk.listAll>>
|
||||
|
||||
try {
|
||||
response = await this.disk.listAll(this.pathService.toStoragePath(path), { recursive: true })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = await Promise.all(
|
||||
[...response.objects].map(async (item) => {
|
||||
if (item.isDirectory) return null
|
||||
|
||||
if (this.directoryMarkerService.isMarkerName(item.name)) {
|
||||
const folderPath = this.directoryMarkerService.directoryPathFromMarker(item.key)
|
||||
|
||||
return {
|
||||
name: this.pathService.basename(folderPath) || 'root',
|
||||
path: this.pathService.toClientPath(folderPath),
|
||||
type: 'folder',
|
||||
}
|
||||
}
|
||||
|
||||
return this.getFileEntryFromStoragePath(item.key, {
|
||||
name: item.name,
|
||||
path: this.pathService.toClientPath(item.key),
|
||||
url: '',
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return entries.filter(Boolean) as Entry[]
|
||||
}
|
||||
|
||||
public createDirectory: StorageService['createDirectory'] = async (path, name) => {
|
||||
const dirPath = this.pathService.join(this.pathService.toStoragePath(path), name)
|
||||
|
||||
const isExists = await this.directoryExists(dirPath)
|
||||
|
||||
if (isExists) throw new DirExistsException()
|
||||
|
||||
await this.disk.put(this.directoryMarkerService.markerPath(dirPath), '')
|
||||
|
||||
return { path: this.pathService.toClientPath(dirPath) }
|
||||
}
|
||||
|
||||
public deleteEntry: StorageService['deleteEntry'] = async (path) => {
|
||||
await this.disk.deleteAll(this.pathService.toStoragePath(path))
|
||||
return { path }
|
||||
}
|
||||
|
||||
public moveEntry: StorageService['moveEntry'] = async (path, newPath) => {
|
||||
const oldPath = this.pathService.toStoragePath(path)
|
||||
const targetPath = this.pathService.toStoragePath(newPath)
|
||||
const entry = await this.getEntry(path)
|
||||
|
||||
if (entry.type === 'file') {
|
||||
await this.disk.move(oldPath, targetPath)
|
||||
} else {
|
||||
await this.moveDirectory(oldPath, targetPath)
|
||||
}
|
||||
|
||||
return { path: this.pathService.toClientPath(targetPath) }
|
||||
}
|
||||
|
||||
public getFileEntry: StorageService['getFileEntry'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
return this.getFileEntryFromStoragePath(source, {
|
||||
name: this.pathService.basename(path),
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
public getFileContent: StorageService['getFileContent'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
return this.disk.get(source)
|
||||
}
|
||||
|
||||
public getFileStream: StorageService['getFileStream'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
const metadata = await this.disk.getMetaData(source)
|
||||
const stream = await this.disk.getStream(source)
|
||||
const name = this.pathService.basename(path)
|
||||
|
||||
return {
|
||||
stream,
|
||||
contentType: metadata.contentType,
|
||||
size: metadata.contentLength,
|
||||
name,
|
||||
}
|
||||
}
|
||||
|
||||
private async getDirectoryEntry(path: string): Promise<FolderEntry> {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const isExists = await this.directoryExists(source)
|
||||
|
||||
if (!isExists) throw new DirNotFoundException()
|
||||
|
||||
const name = this.pathService.basename(path) || 'root'
|
||||
|
||||
return { type: 'folder', name, path }
|
||||
}
|
||||
|
||||
public getEntry: StorageService['getEntry'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (exists) return this.getFileEntry(path)
|
||||
|
||||
return this.getDirectoryEntry(path)
|
||||
}
|
||||
|
||||
public writeFile: StorageService['writeFile'] = async (path, file) => {
|
||||
const writePath = this.pathService.toStoragePath(path)
|
||||
await file.moveToDisk(writePath)
|
||||
|
||||
return { path: this.pathService.toClientPath(writePath) }
|
||||
}
|
||||
|
||||
public copyEntry: StorageService['copyEntry'] = async (sourcePath, targetPath) => {
|
||||
const source = this.pathService.toStoragePath(sourcePath)
|
||||
const target = this.pathService.toStoragePath(targetPath)
|
||||
await this.disk.copy(source, target)
|
||||
|
||||
return { path: this.pathService.toClientPath(target) }
|
||||
}
|
||||
}
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import drive from '@adonisjs/drive/services/main'
|
||||
import {
|
||||
DirExistsException,
|
||||
DirNotFoundException,
|
||||
FileNotFoundException,
|
||||
} from '#exceptions/file_manager_exceptions'
|
||||
import PathService from '#base_modules/FileManager/services/path_service'
|
||||
import type { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||
import type { StorageService } from '#base_modules/FileManager/services/storage_service/storage_service_interface'
|
||||
import FSDirectoryMarkerService from '#base_modules/FileManager/services/storage_service/fs_directory_marker_service'
|
||||
|
||||
export class S3StorageService implements StorageService {
|
||||
private disk = drive.use('s3')
|
||||
private pathService = new PathService()
|
||||
private directoryMarkerService = new FSDirectoryMarkerService()
|
||||
|
||||
private directoryPrefix(path: string): string {
|
||||
return path ? `${path.replace(/\/$/, '')}/` : path
|
||||
}
|
||||
|
||||
private async listDirectoryFiles(path: string): Promise<{ key: string; relative: string }[]> {
|
||||
const prefix = this.directoryPrefix(path)
|
||||
const files: { key: string; relative: string }[] = []
|
||||
|
||||
let paginationToken: string | undefined
|
||||
|
||||
do {
|
||||
const response = await this.disk.listAll(prefix, { recursive: true, paginationToken })
|
||||
|
||||
for (const item of response.objects) {
|
||||
if (!item.isFile || !item.key.startsWith(prefix)) continue
|
||||
|
||||
files.push({ key: item.key, relative: item.key.slice(prefix.length) })
|
||||
}
|
||||
|
||||
paginationToken = response.paginationToken
|
||||
} while (paginationToken)
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
private async moveDirectory(oldPath: string, newPath: string) {
|
||||
await this.disk.put(this.directoryMarkerService.markerPath(newPath), '')
|
||||
|
||||
for (const file of await this.listDirectoryFiles(oldPath)) {
|
||||
const target = this.pathService.join(newPath, file.relative)
|
||||
const dir = this.pathService.dirname(target)
|
||||
|
||||
if (dir) await this.disk.put(this.directoryMarkerService.markerPath(dir), '')
|
||||
|
||||
await this.disk.move(file.key, target)
|
||||
}
|
||||
}
|
||||
|
||||
public entryExists: StorageService['entryExists'] = async (path) => {
|
||||
const storagePath = this.pathService.toStoragePath(path)
|
||||
|
||||
if (await this.disk.exists(storagePath)) return true
|
||||
|
||||
return this.directoryExists(storagePath)
|
||||
}
|
||||
|
||||
private async directoryExists(path: string): Promise<boolean> {
|
||||
if (await this.disk.exists(this.directoryMarkerService.markerPath(path))) return true
|
||||
|
||||
try {
|
||||
const response = await this.disk.listAll(this.directoryPrefix(path))
|
||||
|
||||
return Array.from(response.objects).length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async getFileEntryFromStoragePath(
|
||||
storagePath: string,
|
||||
params: {
|
||||
name?: string
|
||||
path?: string
|
||||
url?: string
|
||||
} = {}
|
||||
): Promise<FileEntry> {
|
||||
const metadata = await this.disk.getMetaData(storagePath)
|
||||
const path = params.path ?? this.pathService.toClientPath(storagePath)
|
||||
const url = params.url ?? (await this.disk.getUrl(storagePath))
|
||||
|
||||
return {
|
||||
name: params.name ?? this.pathService.basename(path),
|
||||
type: 'file',
|
||||
size: metadata?.contentLength ?? 0,
|
||||
path,
|
||||
url,
|
||||
updatedAt: metadata.lastModified.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
public listDirectoryEntries: StorageService['listDirectoryEntries'] = async (path) => {
|
||||
const diskPath = this.pathService.toStoragePath(path)
|
||||
const response = await this.disk.listAll(diskPath)
|
||||
|
||||
const entries = await Promise.all(
|
||||
[...response.objects].map(async (file) => {
|
||||
if (file.isFile && !this.directoryMarkerService.isMarkerName(file.name)) {
|
||||
const relativePath = this.pathService.toClientPath(file.key)
|
||||
|
||||
return this.getFileEntryFromStoragePath(file.key, {
|
||||
name: file.name,
|
||||
path: relativePath,
|
||||
url: await this.disk.getUrl(relativePath),
|
||||
})
|
||||
}
|
||||
|
||||
if (file.isDirectory) {
|
||||
return {
|
||||
name: file.name,
|
||||
type: 'folder',
|
||||
path: this.pathService.toClientPath(file.prefix),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
)
|
||||
|
||||
return entries.filter(Boolean) as Entry[]
|
||||
}
|
||||
|
||||
public listAllEntries: StorageService['listAllEntries'] = async (path) => {
|
||||
let response: Awaited<ReturnType<typeof this.disk.listAll>>
|
||||
|
||||
try {
|
||||
const storagePath = this.pathService.toStoragePath(path)
|
||||
response = await this.disk.listAll(this.directoryPrefix(storagePath), { recursive: true })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = await Promise.all(
|
||||
[...response.objects].map(async (item) => {
|
||||
if (item.isDirectory) return null
|
||||
|
||||
if (this.directoryMarkerService.isMarkerName(item.name)) {
|
||||
const folderPath = this.directoryMarkerService.directoryPathFromMarker(item.key)
|
||||
|
||||
return {
|
||||
name: this.pathService.basename(folderPath) || 'root',
|
||||
path: this.pathService.toClientPath(folderPath),
|
||||
type: 'folder',
|
||||
}
|
||||
}
|
||||
|
||||
return this.getFileEntryFromStoragePath(item.key, {
|
||||
name: item.name,
|
||||
path: this.pathService.toClientPath(item.key),
|
||||
url: '',
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return entries.filter(Boolean) as Entry[]
|
||||
}
|
||||
|
||||
public createDirectory: StorageService['createDirectory'] = async (path, name) => {
|
||||
const dirPath = this.pathService.join(this.pathService.toStoragePath(path), name)
|
||||
|
||||
const isExists = await this.directoryExists(dirPath)
|
||||
|
||||
if (isExists) throw new DirExistsException()
|
||||
|
||||
await this.disk.put(this.directoryMarkerService.markerPath(dirPath), '')
|
||||
|
||||
return { path: this.pathService.toClientPath(dirPath) }
|
||||
}
|
||||
|
||||
public deleteEntry: StorageService['deleteEntry'] = async (path) => {
|
||||
const storagePath = this.pathService.toStoragePath(path)
|
||||
const entry = await this.getEntry(path)
|
||||
|
||||
if (entry.type === 'file') {
|
||||
await this.disk.delete(storagePath)
|
||||
} else {
|
||||
for (const file of await this.listDirectoryFiles(storagePath)) {
|
||||
await this.disk.delete(file.key)
|
||||
}
|
||||
}
|
||||
|
||||
return { path }
|
||||
}
|
||||
|
||||
public moveEntry: StorageService['moveEntry'] = async (path, newPath) => {
|
||||
const oldPath = this.pathService.toStoragePath(path)
|
||||
const targetPath = this.pathService.toStoragePath(newPath)
|
||||
const entry = await this.getEntry(path)
|
||||
|
||||
if (entry.type === 'file') {
|
||||
await this.disk.move(oldPath, targetPath)
|
||||
} else {
|
||||
await this.moveDirectory(oldPath, targetPath)
|
||||
}
|
||||
|
||||
return { path: this.pathService.toClientPath(targetPath) }
|
||||
}
|
||||
|
||||
public getFileEntry: StorageService['getFileEntry'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
return this.getFileEntryFromStoragePath(source, {
|
||||
name: this.pathService.basename(path),
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
public getFileContent: StorageService['getFileContent'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
return this.disk.get(source)
|
||||
}
|
||||
|
||||
public getFileStream: StorageService['getFileStream'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
const metadata = await this.disk.getMetaData(source)
|
||||
const stream = await this.disk.getStream(source)
|
||||
const name = this.pathService.basename(path)
|
||||
|
||||
return {
|
||||
stream,
|
||||
contentType: metadata.contentType,
|
||||
size: metadata.contentLength,
|
||||
name,
|
||||
}
|
||||
}
|
||||
|
||||
private async getDirectoryEntry(path: string): Promise<FolderEntry> {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const isExists = await this.directoryExists(source)
|
||||
|
||||
if (!isExists) throw new DirNotFoundException()
|
||||
|
||||
const name = this.pathService.basename(path) || 'root'
|
||||
|
||||
return { type: 'folder', name, path }
|
||||
}
|
||||
|
||||
public getEntry: StorageService['getEntry'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (exists) return this.getFileEntry(path)
|
||||
|
||||
return this.getDirectoryEntry(path)
|
||||
}
|
||||
|
||||
public writeFile: StorageService['writeFile'] = async (path, file) => {
|
||||
const writePath = this.pathService.toStoragePath(path)
|
||||
|
||||
if (!file.tmpPath) {
|
||||
throw new Error('Cannot write multipart file to S3 without a temporary file path')
|
||||
}
|
||||
|
||||
await this.disk.putStream(writePath, createReadStream(file.tmpPath), {
|
||||
contentType: file.headers['content-type'],
|
||||
contentLength: file.size,
|
||||
})
|
||||
|
||||
return { path: this.pathService.toClientPath(writePath) }
|
||||
}
|
||||
|
||||
public copyEntry: StorageService['copyEntry'] = async (sourcePath, targetPath) => {
|
||||
const source = this.pathService.toStoragePath(sourcePath)
|
||||
const target = this.pathService.toStoragePath(targetPath)
|
||||
await this.disk.copy(source, target)
|
||||
|
||||
return { path: this.pathService.toClientPath(target) }
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import type { Readable } from 'node:stream'
|
||||
import type { MultipartFile } from '@adonisjs/core/bodyparser'
|
||||
import type { Entry, FileEntry } from '#base_modules/FileManager/types/entries'
|
||||
|
||||
export interface FileStream {
|
||||
stream: Readable
|
||||
contentType?: string
|
||||
size?: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface StorageService {
|
||||
listDirectoryEntries(path: string): Promise<Entry[]>
|
||||
listAllEntries(path: string): Promise<Entry[]>
|
||||
entryExists(path: string): Promise<boolean>
|
||||
getEntry(path: string): Promise<Entry>
|
||||
getFileEntry(path: string): Promise<FileEntry>
|
||||
getFileContent(path: string): Promise<string>
|
||||
getFileStream(path: string): Promise<FileStream>
|
||||
writeFile(path: string, file: MultipartFile): Promise<{ path: string }>
|
||||
createDirectory(path: string, name: string): Promise<{ path: string }>
|
||||
moveEntry(oldPath: string, newPath: string): Promise<{ path: string }>
|
||||
deleteEntry(path: string): Promise<{ path: string }>
|
||||
copyEntry(sourcePath: string, targetPath: string): Promise<{ path: string }>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
interface EntryBase {
|
||||
name: string
|
||||
path: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface FileEntry extends EntryBase {
|
||||
type: 'file'
|
||||
size?: number
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface FolderEntry extends EntryBase {
|
||||
type: 'folder'
|
||||
}
|
||||
|
||||
export type Entry = FileEntry | FolderEntry
|
||||
@@ -0,0 +1,11 @@
|
||||
import vine from '@vinejs/vine'
|
||||
import { pathSchema, nameSchema } from './schemas.js'
|
||||
|
||||
const createDirectoryValidator = vine.compile(
|
||||
vine.object({
|
||||
name: nameSchema,
|
||||
path: pathSchema,
|
||||
})
|
||||
)
|
||||
|
||||
export default createDirectoryValidator
|
||||
@@ -0,0 +1,10 @@
|
||||
import vine from '@vinejs/vine'
|
||||
import { pathSchema } from './schemas.js'
|
||||
|
||||
const duplicateEntriesValidator = vine.compile(
|
||||
vine.object({
|
||||
paths: vine.array(pathSchema).minLength(1).maxLength(3),
|
||||
})
|
||||
)
|
||||
|
||||
export default duplicateEntriesValidator
|
||||
@@ -0,0 +1,13 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
const listValidator = vine.compile(
|
||||
vine.object({
|
||||
filterBy: vine.enum(['media', 'archives', 'documents', 'all']),
|
||||
sortBy: vine.enum(['name', 'size', 'type', 'date']),
|
||||
sortOrder: vine.enum(['asc', 'desc']),
|
||||
maxResults: vine.number().max(100).optional(),
|
||||
paginationToken: vine.string().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export default listValidator
|
||||
@@ -0,0 +1,11 @@
|
||||
import vine from '@vinejs/vine'
|
||||
import { pathSchema, nameSchema } from './schemas.js'
|
||||
|
||||
const renameEntryValidator = vine.compile(
|
||||
vine.object({
|
||||
name: nameSchema,
|
||||
path: pathSchema,
|
||||
})
|
||||
)
|
||||
|
||||
export default renameEntryValidator
|
||||
@@ -0,0 +1,13 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const nameSchema = vine
|
||||
.string()
|
||||
.trim()
|
||||
.minLength(1)
|
||||
.regex(/^[a-zA-Z0-9_\-. ]+$/)
|
||||
|
||||
export const pathSchema = vine
|
||||
.string()
|
||||
.trim()
|
||||
.minLength(1)
|
||||
.regex(/^[a-zA-Z0-9_\-. /]+$/)
|
||||
@@ -0,0 +1,13 @@
|
||||
import vine from '@vinejs/vine'
|
||||
import { pathSchema } from './schemas.js'
|
||||
|
||||
const uploadFileValidator = vine.compile(
|
||||
vine.object({
|
||||
path: pathSchema,
|
||||
file: vine.file({
|
||||
size: '10mb',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
export default uploadFileValidator
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
import { getAllPermissions } from '#services/permission_service'
|
||||
|
||||
export default class PermissionsController {
|
||||
public async index({ response }: HttpContext) {
|
||||
return response.ok(await getAllPermissions())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { BaseModel, column } from '@adonisjs/lucid/orm'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
export default class Permission extends BaseModel {
|
||||
static searchable = {
|
||||
string: ['name', 'slug'],
|
||||
number: ['id'],
|
||||
}
|
||||
@column({ isPrimary: true }) declare id: number
|
||||
@column() declare name: string
|
||||
@column() declare slug: string
|
||||
@column.dateTime({ columnName: 'created_at' }) declare createdAt: DateTime | null
|
||||
@column.dateTime({ columnName: 'updated_at' }) declare updatedAt: DateTime | null
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BasePolicy } from '@adonisjs/bouncer'
|
||||
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
|
||||
import Admins from '#base_modules/Admin/model/admin'
|
||||
import { getAdminPermissionSlugs } from '#services/permission_service'
|
||||
|
||||
export default class PermissionPolicy extends BasePolicy {
|
||||
async permissionList(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('permission.view')
|
||||
}
|
||||
|
||||
async viewPermission(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('permission.view')
|
||||
}
|
||||
|
||||
async createPermission(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('permission.create')
|
||||
}
|
||||
|
||||
async updatePermission(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('permission.update')
|
||||
}
|
||||
|
||||
async deletePermission(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('permission.delete')
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import router from '@adonisjs/core/services/router'
|
||||
import { middleware } from '#start/kernel'
|
||||
|
||||
const permissionController = () =>
|
||||
import('#base_modules/Permission/controller/permission_controller')
|
||||
|
||||
router.resource('/permission', permissionController).apiOnly().use('*', [middleware.auth()]) //.use(['store', 'update', 'destroy'], [middleware.auth()])
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const createPermissionValidator = vine.compile(
|
||||
vine.object({
|
||||
id: vine.number().optional(),
|
||||
name: vine.string().trim(),
|
||||
slug: vine.string().trim(),
|
||||
})
|
||||
)
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const updatePermissionValidator = vine.compile(
|
||||
vine.object({
|
||||
id: vine.number().optional(),
|
||||
name: vine.string().trim(),
|
||||
slug: vine.string().trim(),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
export default [
|
||||
{
|
||||
slug: 'role.view',
|
||||
name: 'Role view',
|
||||
},
|
||||
{
|
||||
slug: 'role.create',
|
||||
name: 'Role create',
|
||||
},
|
||||
{
|
||||
slug: 'role.update',
|
||||
name: 'Role update',
|
||||
},
|
||||
{
|
||||
slug: 'role.delete',
|
||||
name: 'Role delete',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,89 @@
|
||||
import { HttpContext } from '@adonisjs/core/http'
|
||||
import { addSearchFilter, searchableColumns } from '#services/database_inspector'
|
||||
import Role from '#base_modules/Role/model/role'
|
||||
import RolePolicy from '#base_modules/Role/policy/role_policy'
|
||||
import { createRoleValidator } from '#base_modules/Role/validator/role_create_validator'
|
||||
import { updateRoleValidator } from '#base_modules/Role/validator/role_update_validator'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
|
||||
export default class RoleController {
|
||||
public async index({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(RolePolicy).authorize('roleList')
|
||||
const {
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
search = null,
|
||||
sortBy = 'id',
|
||||
sortOrder = 'desc',
|
||||
} = request.all()
|
||||
const query = Role.query()
|
||||
if (search) {
|
||||
const columns = Role.searchable || (await searchableColumns('admins'))
|
||||
addSearchFilter(query, columns, search)
|
||||
}
|
||||
const result = await query.orderBy(sortBy, sortOrder).paginate(page, perPage)
|
||||
return response.ok(result)
|
||||
}
|
||||
|
||||
public async store({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(RolePolicy).authorize('createRole')
|
||||
const { permissions, ...data } = await createRoleValidator.validate(request.all())
|
||||
const record = await Role.create(data)
|
||||
if (record) {
|
||||
// await record.related('permissions').sync(data.permissions)
|
||||
await this.updatePermissions(record, permissions)
|
||||
return response.created({ message: 'Successfully created.' })
|
||||
}
|
||||
return response.badRequest({ message: 'Cannot create.' })
|
||||
}
|
||||
|
||||
public async show({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(RolePolicy).authorize('viewRole')
|
||||
const { id } = request.params()
|
||||
if (!id) return response.notFound({ message: 'Not found.' })
|
||||
const record = await Role.find(id)
|
||||
if (!record) return response.notFound({ message: 'Not found.' })
|
||||
const permissions = await db
|
||||
.from('role_permissions')
|
||||
.where('role_id', id)
|
||||
.select(['permission_slug as slug', 'role_id'])
|
||||
return response.ok({ ...record.serialize(), permissions })
|
||||
}
|
||||
|
||||
public async update({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(RolePolicy).authorize('updateRole')
|
||||
const { id } = request.params()
|
||||
if (!id) return response.notFound({ message: 'Not found.' })
|
||||
const data = await updateRoleValidator.validate(request.all())
|
||||
const record = await Role.find(id)
|
||||
if (!record) return response.notFound({ message: 'Not found.' })
|
||||
record.name = data.name
|
||||
await record.save()
|
||||
await this.updatePermissions(record, data.permissions)
|
||||
return response.ok({ message: 'Updated.' })
|
||||
}
|
||||
|
||||
public async destroy({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(RolePolicy).authorize('deleteRole')
|
||||
const { id } = request.params()
|
||||
if (!id) return response.notFound({ message: 'Not found.' })
|
||||
const record = await Role.find(id)
|
||||
if (!record) return response.notFound({ message: 'Not found.' })
|
||||
await Role.query().where('id', id).delete()
|
||||
return response.ok({ message: 'Deleted.' })
|
||||
}
|
||||
|
||||
private async updatePermissions(record: Role, permissions?: string[]) {
|
||||
await db.from('role_permissions').where('role_id', record.id).delete()
|
||||
if (!permissions || permissions.length === 0) {
|
||||
console.log('no permissions to insert')
|
||||
return
|
||||
}
|
||||
const newPermissions = permissions.map((perm) => ({
|
||||
role_id: record.id,
|
||||
permission_slug: perm,
|
||||
}))
|
||||
await db.table('role_permissions').insert(newPermissions)
|
||||
console.log('inserted permissions:', newPermissions.length)
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { BaseModel, column } from '@adonisjs/lucid/orm'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
export default class Role extends BaseModel {
|
||||
static searchable = {
|
||||
string: ['name'],
|
||||
number: ['id'],
|
||||
}
|
||||
@column({ isPrimary: true }) declare id: number
|
||||
@column() declare name: string
|
||||
@column.dateTime({ columnName: 'created_at' }) declare createdAt: DateTime | null
|
||||
@column.dateTime({ columnName: 'updated_at' }) declare updatedAt: DateTime | null
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BasePolicy } from '@adonisjs/bouncer'
|
||||
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
|
||||
import Admins from '#base_modules/Admin/model/admin'
|
||||
import { getAdminPermissionSlugs } from '#services/permission_service'
|
||||
|
||||
export default class RolePolicy extends BasePolicy {
|
||||
async roleList(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('role.view')
|
||||
}
|
||||
|
||||
async viewRole(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('role.view')
|
||||
}
|
||||
|
||||
async createRole(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('role.create')
|
||||
}
|
||||
|
||||
async updateRole(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('role.update')
|
||||
}
|
||||
|
||||
async deleteRole(user: Admins): Promise<AuthorizerResponse> {
|
||||
if (!user) return false
|
||||
const perms = await getAdminPermissionSlugs(user)
|
||||
return perms.has('role.delete')
|
||||
}
|
||||
}
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
import router from '@adonisjs/core/services/router'
|
||||
import { middleware } from '#start/kernel'
|
||||
|
||||
const roleController = () => import('#base_modules/Role/controller/role_controller')
|
||||
|
||||
router.resource('/role', roleController).apiOnly().use('*', [middleware.auth()]) //.use(['store', 'update', 'destroy'], [middleware.auth()])
|
||||
@@ -0,0 +1,9 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const createRoleValidator = vine.compile(
|
||||
vine.object({
|
||||
id: vine.number().optional(),
|
||||
name: vine.string().trim(),
|
||||
permissions: vine.array(vine.string()),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const updateRoleValidator = vine.compile(
|
||||
vine.object({
|
||||
id: vine.number().optional(),
|
||||
name: vine.string().trim(),
|
||||
permissions: vine.array(vine.string()),
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
export const PAGE_MEDIA_UPLOADS_PATH = '/'
|
||||
|
||||
export const PAGE_MEDIA_FILE_SIZE_LIMIT = '10mb'
|
||||
|
||||
export const PREVIEW_TOKEN_DEFAULT_TTL_SECONDS = 900
|
||||
|
||||
export const PREVIEW_TOKEN_MIN_TTL_SECONDS = 60
|
||||
|
||||
export const PREVIEW_TOKEN_MAX_TTL_SECONDS = 3600
|
||||
|
||||
export const BREADCRUMB_PAGE_TYPE_PARENTS: Record<string, string> = {
|
||||
'blog-item': 'blog',
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages per sitemap request. Large enough that most sites need one round trip, small enough
|
||||
* that no single query is unbounded; the sitemap protocol's own limit is 50,000 URLs per file.
|
||||
*/
|
||||
export const SITEMAP_PAGE_SIZE = 1000
|
||||
|
||||
export const SITEMAP_MAX_PAGE_SIZE = 5000
|
||||
@@ -0,0 +1,8 @@
|
||||
export default [
|
||||
{ slug: 'web_manager.view', name: 'Web Manager view' },
|
||||
{ slug: 'web_manager.create', name: 'Web Manager create' },
|
||||
{ slug: 'web_manager.update', name: 'Web Manager update drafts' },
|
||||
{ slug: 'web_manager.publish', name: 'Web Manager publish' },
|
||||
{ slug: 'web_manager.delete', name: 'Web Manager delete drafts and pages' },
|
||||
{ slug: 'web_manager.settings', name: 'Web Manager manage settings' },
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import WebManagerService from '#base_modules/WebManager/services/web_manager_service'
|
||||
import PreviewTokenService from '#base_modules/WebManager/services/preview_token_service'
|
||||
import {
|
||||
previewRequestValidator,
|
||||
publicBlogListValidator,
|
||||
publicPageValidator,
|
||||
publicSitemapValidator,
|
||||
} from '#base_modules/WebManager/validator/page_validator'
|
||||
import { settingsQueryValidator } from '#base_modules/WebManager/validator/settings_validator'
|
||||
|
||||
export default class PublicWebManagerController {
|
||||
private webManagerService = new WebManagerService()
|
||||
private previewTokenService = new PreviewTokenService()
|
||||
|
||||
async blogList({ request }: HttpContext) {
|
||||
const filters = await publicBlogListValidator.validate(request.qs())
|
||||
return this.webManagerService.listPublicBlogPages(filters)
|
||||
}
|
||||
|
||||
async sitemap({ request }: HttpContext) {
|
||||
const filters = await publicSitemapValidator.validate(request.qs())
|
||||
return this.webManagerService.listPublicSitemapPages(filters)
|
||||
}
|
||||
|
||||
async resolve({ request, response }: HttpContext) {
|
||||
const { locale, path } = await publicPageValidator.validate(request.qs())
|
||||
const result = await this.webManagerService.resolvePublicPage(locale, path)
|
||||
if (!result) return response.notFound({ message: 'Page not found' })
|
||||
return response.ok(result)
|
||||
}
|
||||
|
||||
async preview({ request, response }: HttpContext) {
|
||||
const { token } = await previewRequestValidator.validate(request.qs())
|
||||
const payload = this.previewTokenService.verify(token)
|
||||
if (!payload) return response.notFound({ message: 'Preview token is invalid or expired' })
|
||||
|
||||
const result = await this.webManagerService.resolvePreviewPage(payload.pageId)
|
||||
return response.ok(result)
|
||||
}
|
||||
|
||||
async settings({ request, response }: HttpContext) {
|
||||
const { locale } = await settingsQueryValidator.validate(request.qs())
|
||||
if (!locale) return response.badRequest({ message: 'Locale is required' })
|
||||
|
||||
const settings = await this.webManagerService.getSettings(locale)
|
||||
if (!settings) return response.notFound({ message: 'Settings not found' })
|
||||
return response.ok({ settings })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import WebManagerPolicy from '#base_modules/WebManager/policy/web_manager_policy'
|
||||
import WebManagerService from '#base_modules/WebManager/services/web_manager_service'
|
||||
import PagePayloadService from '#base_modules/WebManager/services/page_payload_service'
|
||||
import PreviewTokenService from '#base_modules/WebManager/services/preview_token_service'
|
||||
import {
|
||||
createPageValidator,
|
||||
listPagesValidator,
|
||||
previewTokenValidator,
|
||||
saveDraftValidator,
|
||||
} from '#base_modules/WebManager/validator/page_validator'
|
||||
import {
|
||||
saveSettingsValidator,
|
||||
settingsQueryValidator,
|
||||
} from '#base_modules/WebManager/validator/settings_validator'
|
||||
|
||||
export default class WebManagerController {
|
||||
private webManagerService = new WebManagerService()
|
||||
private pagePayloadService = new PagePayloadService()
|
||||
private previewTokenService = new PreviewTokenService()
|
||||
|
||||
async index({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(WebManagerPolicy).authorize('view')
|
||||
const filters = await listPagesValidator.validate(request.qs())
|
||||
const pages = await this.webManagerService.listPages(filters)
|
||||
return response.ok(pages.serialize())
|
||||
}
|
||||
|
||||
async groupKeys({ response, bouncer }: HttpContext) {
|
||||
await bouncer.with(WebManagerPolicy).authorize('view')
|
||||
const groupKeys = await this.webManagerService.listGroupKeys()
|
||||
return response.ok({ data: groupKeys })
|
||||
}
|
||||
|
||||
async store({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(WebManagerPolicy).authorize('create')
|
||||
const payload = this.pagePayloadService.parse(request)
|
||||
const input = await createPageValidator.validate(payload)
|
||||
const resolved = await this.pagePayloadService.resolveLocalFiles(request, input)
|
||||
const page = await this.webManagerService.createDraft(resolved)
|
||||
return response.created({ data: page })
|
||||
}
|
||||
|
||||
async show({ params, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(WebManagerPolicy).authorize('view')
|
||||
const page = await this.webManagerService.getPage(Number(params.id))
|
||||
return response.ok({ data: page })
|
||||
}
|
||||
|
||||
async saveDraft({ params, request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(WebManagerPolicy).authorize('update')
|
||||
const payload = this.pagePayloadService.parse(request)
|
||||
const input = await saveDraftValidator.validate(payload)
|
||||
const resolved = await this.pagePayloadService.resolveLocalFiles(request, input)
|
||||
const page = await this.webManagerService.saveDraft(Number(params.id), resolved)
|
||||
return response.ok({ data: page })
|
||||
}
|
||||
|
||||
async publish({ params, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(WebManagerPolicy).authorize('publish')
|
||||
const { page, revalidation } = await this.webManagerService.publish(Number(params.id))
|
||||
return response.ok({ data: page, meta: { revalidation } })
|
||||
}
|
||||
|
||||
async unpublish({ params, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(WebManagerPolicy).authorize('publish')
|
||||
const { page, revalidation } = await this.webManagerService.unpublish(Number(params.id))
|
||||
return response.ok({ data: page, meta: { revalidation } })
|
||||
}
|
||||
|
||||
async destroy({ params, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(WebManagerPolicy).authorize('delete')
|
||||
await this.webManagerService.discard(Number(params.id))
|
||||
return response.noContent()
|
||||
}
|
||||
|
||||
async createPreviewToken({ params, request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(WebManagerPolicy).authorize('view')
|
||||
await this.webManagerService.getPage(Number(params.id))
|
||||
const { expiresIn } = await previewTokenValidator.validate(request.all())
|
||||
const preview = this.previewTokenService.create(Number(params.id), expiresIn)
|
||||
return response.created({ data: preview })
|
||||
}
|
||||
|
||||
async getSettings({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(WebManagerPolicy).authorize('view')
|
||||
const { locale } = await settingsQueryValidator.validate(request.qs())
|
||||
const settings = await this.webManagerService.getSettings(locale)
|
||||
return response.ok({ data: settings })
|
||||
}
|
||||
|
||||
async saveSettings({ request, response, bouncer }: HttpContext) {
|
||||
await bouncer.with(WebManagerPolicy).authorize('manageSettings')
|
||||
const input = await saveSettingsValidator.validate(request.all())
|
||||
const { settings, revalidation } = await this.webManagerService.saveSettings(input)
|
||||
return response.ok({ data: settings, meta: { revalidation } })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { belongsTo } from '@adonisjs/lucid/orm'
|
||||
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
|
||||
import WebPage from '#base_modules/WebManager/model/web_page'
|
||||
import type { BlockData, BlockType } from '#base_modules/WebManager/types/web_content'
|
||||
import { WebComponentSchema } from '#database/schema'
|
||||
|
||||
export default class WebComponent extends WebComponentSchema {
|
||||
static table = 'web_components'
|
||||
|
||||
declare type: BlockType
|
||||
declare data: BlockData
|
||||
|
||||
@belongsTo(() => WebPage, { foreignKey: 'pageId' })
|
||||
declare page: BelongsTo<typeof WebPage>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { hasMany, hasOne } from '@adonisjs/lucid/orm'
|
||||
import type { HasMany, HasOne } from '@adonisjs/lucid/types/relations'
|
||||
import WebComponent from '#base_modules/WebManager/model/web_component'
|
||||
import type { Hero, PageType } from '#base_modules/WebManager/types/web_content'
|
||||
import type { PageStatus } from '#base_modules/WebManager/types/page_status'
|
||||
import { WebPageSchema } from '#database/schema'
|
||||
|
||||
export default class WebPage extends WebPageSchema {
|
||||
static table = 'web_pages'
|
||||
|
||||
declare status: PageStatus
|
||||
declare pageType: PageType
|
||||
declare hero: Hero | null
|
||||
|
||||
@hasMany(() => WebComponent, { foreignKey: 'pageId' })
|
||||
declare components: HasMany<typeof WebComponent>
|
||||
|
||||
@hasOne(() => WebPage, { foreignKey: 'draftOfId' })
|
||||
declare draft: HasOne<typeof WebPage>
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { SiteSettingsContent } from '#base_modules/WebManager/types/web_content'
|
||||
import { WebSettingSchema } from '#database/schema'
|
||||
|
||||
export default class WebSetting extends WebSettingSchema {
|
||||
static table = 'web_settings'
|
||||
|
||||
declare header: SiteSettingsContent['header']
|
||||
declare footer: SiteSettingsContent['footer']
|
||||
declare legal: SiteSettingsContent['legal']
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BasePolicy } from '@adonisjs/bouncer'
|
||||
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
|
||||
import Admin from '#base_modules/Admin/model/admin'
|
||||
import { getAdminPermissionSlugs } from '#services/permission_service'
|
||||
|
||||
export default class WebManagerPolicy extends BasePolicy {
|
||||
view(user: Admin): Promise<AuthorizerResponse> {
|
||||
return this.hasPermission(user, 'web_manager.view')
|
||||
}
|
||||
|
||||
create(user: Admin): Promise<AuthorizerResponse> {
|
||||
return this.hasPermission(user, 'web_manager.create')
|
||||
}
|
||||
|
||||
update(user: Admin): Promise<AuthorizerResponse> {
|
||||
return this.hasPermission(user, 'web_manager.update')
|
||||
}
|
||||
|
||||
publish(user: Admin): Promise<AuthorizerResponse> {
|
||||
return this.hasPermission(user, 'web_manager.publish')
|
||||
}
|
||||
|
||||
delete(user: Admin): Promise<AuthorizerResponse> {
|
||||
return this.hasPermission(user, 'web_manager.delete')
|
||||
}
|
||||
|
||||
manageSettings(user: Admin): Promise<AuthorizerResponse> {
|
||||
return this.hasPermission(user, 'web_manager.settings')
|
||||
}
|
||||
|
||||
private async hasPermission(user: Admin, permission: string) {
|
||||
if (!user) return false
|
||||
const permissions = await getAdminPermissionSlugs(user)
|
||||
return permissions.has(permission)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import router from '@adonisjs/core/services/router'
|
||||
import { middleware } from '#start/kernel'
|
||||
|
||||
const WebManagerController = () =>
|
||||
import('#base_modules/WebManager/controller/web_manager_controller')
|
||||
const PublicWebManagerController = () =>
|
||||
import('#base_modules/WebManager/controller/public_web_manager_controller')
|
||||
|
||||
router
|
||||
.group(() => {
|
||||
router.get('/blog/pages', [PublicWebManagerController, 'blogList'])
|
||||
router.get('/pages/sitemap', [PublicWebManagerController, 'sitemap'])
|
||||
router.get('/pages/resolve', [PublicWebManagerController, 'resolve'])
|
||||
router.get('/pages/preview', [PublicWebManagerController, 'preview'])
|
||||
router.get('/settings', [PublicWebManagerController, 'settings'])
|
||||
})
|
||||
.prefix('/web-manager/public')
|
||||
|
||||
router
|
||||
.group(() => {
|
||||
router.get('/pages', [WebManagerController, 'index'])
|
||||
router.get('/pages/group-keys', [WebManagerController, 'groupKeys'])
|
||||
router.post('/pages', [WebManagerController, 'store'])
|
||||
router.get('/pages/:id', [WebManagerController, 'show'])
|
||||
router.put('/pages/:id/draft', [WebManagerController, 'saveDraft'])
|
||||
router.post('/pages/:id/publish', [WebManagerController, 'publish'])
|
||||
router.post('/pages/:id/unpublish', [WebManagerController, 'unpublish'])
|
||||
router.post('/pages/:id/preview-token', [WebManagerController, 'createPreviewToken'])
|
||||
router.delete('/pages/:id', [WebManagerController, 'destroy'])
|
||||
router.get('/settings', [WebManagerController, 'getSettings'])
|
||||
router.put('/settings', [WebManagerController, 'saveSettings'])
|
||||
})
|
||||
.prefix('/web-manager')
|
||||
.use([middleware.auth()])
|
||||
@@ -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?.()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Lifecycle of a page row:
|
||||
*
|
||||
* - `draft` — never published, or the working copy of a published page.
|
||||
* - `published` — live on the public site.
|
||||
* - `unpublished` — was live and was taken down; keeps its content, components and
|
||||
* `published_at`, and keeps its route reserved so nothing else can claim it.
|
||||
*
|
||||
*/
|
||||
export const PAGE_STATUSES = ['draft', 'published', 'unpublished'] as const
|
||||
|
||||
export type PageStatus = (typeof PAGE_STATUSES)[number]
|
||||
@@ -0,0 +1,156 @@
|
||||
export const PAGE_TYPES = ['landing', 'blog', 'blog-item'] as const
|
||||
export type PageType = (typeof PAGE_TYPES)[number]
|
||||
|
||||
export const BLOCK_TYPES = [
|
||||
'content',
|
||||
'faq',
|
||||
'media-content',
|
||||
'articles',
|
||||
'banner',
|
||||
'media',
|
||||
] as const
|
||||
export type BlockType = (typeof BLOCK_TYPES)[number]
|
||||
|
||||
export const HERO_TYPES = ['none', 'low', 'middle', 'high'] as const
|
||||
export type HeroType = (typeof HERO_TYPES)[number]
|
||||
|
||||
export const NAVIGATION_LINK_VARIANTS = [
|
||||
'primary',
|
||||
'secondary',
|
||||
'outline',
|
||||
'ghost',
|
||||
'inline',
|
||||
] as const
|
||||
export type NavigationLinkVariant = (typeof NAVIGATION_LINK_VARIANTS)[number]
|
||||
|
||||
export const BANNER_STYLES = ['info', 'success', 'warning', 'error'] as const
|
||||
export type BannerStyle = (typeof BANNER_STYLES)[number]
|
||||
|
||||
export const MEDIA_CONTENT_POSITIONS = ['left', 'right'] as const
|
||||
export type MediaContentPosition = (typeof MEDIA_CONTENT_POSITIONS)[number]
|
||||
|
||||
export interface RichText {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface FaqItem {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
export interface MediaRef {
|
||||
name: string
|
||||
ref: string
|
||||
}
|
||||
|
||||
export interface Media {
|
||||
url: MediaRef | null
|
||||
alt: string
|
||||
width?: number
|
||||
height?: number
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
interface NavigationLinkBase {
|
||||
openInNewTab: boolean
|
||||
label: string
|
||||
variant: NavigationLinkVariant
|
||||
}
|
||||
|
||||
export interface InternalNavigationLink extends NavigationLinkBase {
|
||||
linkType: 'internal'
|
||||
pageId: string
|
||||
}
|
||||
|
||||
export interface ExternalNavigationLink extends NavigationLinkBase {
|
||||
linkType: 'external'
|
||||
externalUrl: string
|
||||
}
|
||||
|
||||
export type NavigationLink = InternalNavigationLink | ExternalNavigationLink
|
||||
|
||||
export interface Hero {
|
||||
type: HeroType
|
||||
title?: string
|
||||
subtitle?: string
|
||||
content?: RichText
|
||||
ctaItems: NavigationLink[]
|
||||
media?: Media
|
||||
}
|
||||
|
||||
export interface ContentColumn {
|
||||
content: RichText
|
||||
ctaItems: NavigationLink[]
|
||||
}
|
||||
|
||||
export interface ContentBlockData {
|
||||
title: string
|
||||
subtitle: string
|
||||
columns: ContentColumn[]
|
||||
}
|
||||
|
||||
export interface FaqBlockData {
|
||||
title: string
|
||||
subtitle: string
|
||||
items: FaqItem[]
|
||||
}
|
||||
|
||||
export interface MediaContentBlockData {
|
||||
title: string
|
||||
mediaPosition: MediaContentPosition
|
||||
media: Media
|
||||
content: RichText
|
||||
ctaItems: NavigationLink[]
|
||||
}
|
||||
|
||||
export interface ArticlesBlockItem {
|
||||
title: string
|
||||
content: RichText
|
||||
}
|
||||
|
||||
export interface ArticlesBlockData {
|
||||
cta?: NavigationLink
|
||||
title?: string
|
||||
subtitle?: RichText
|
||||
items: ArticlesBlockItem[]
|
||||
}
|
||||
|
||||
export interface BannerBlockData {
|
||||
style: BannerStyle
|
||||
content: RichText
|
||||
ctaItems: NavigationLink[]
|
||||
}
|
||||
|
||||
export interface MediaBlockData {
|
||||
media: Media
|
||||
}
|
||||
|
||||
export type ComponentContent =
|
||||
| { type: 'content'; data: ContentBlockData }
|
||||
| { type: 'faq'; data: FaqBlockData }
|
||||
| { type: 'media-content'; data: MediaContentBlockData }
|
||||
| { type: 'articles'; data: ArticlesBlockData }
|
||||
| { type: 'banner'; data: BannerBlockData }
|
||||
| { type: 'media'; data: MediaBlockData }
|
||||
|
||||
export type BlockData = ComponentContent['data']
|
||||
|
||||
export interface SiteMenu {
|
||||
title?: string
|
||||
items: NavigationLink[]
|
||||
}
|
||||
|
||||
export interface SiteSettingsContent {
|
||||
header: {
|
||||
menus: SiteMenu[]
|
||||
ctaItems: NavigationLink[]
|
||||
}
|
||||
footer: {
|
||||
copyright?: string
|
||||
menus: SiteMenu[]
|
||||
}
|
||||
legal: {
|
||||
privacyPath?: string
|
||||
termsPath?: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ComponentContent, Hero, PageType } from '#base_modules/WebManager/types/web_content'
|
||||
|
||||
export type JsonObject = Record<string, unknown>
|
||||
|
||||
export interface ComponentInput {
|
||||
type: string
|
||||
data: JsonObject
|
||||
}
|
||||
|
||||
export interface PageInput {
|
||||
locale: string
|
||||
path: string
|
||||
groupKey: string
|
||||
pageType: PageType
|
||||
title: string
|
||||
hero?: JsonObject | null
|
||||
seoTitle?: string
|
||||
seoDescription?: string
|
||||
components: ComponentInput[]
|
||||
}
|
||||
|
||||
export interface NormalizedPageInput {
|
||||
locale: string
|
||||
path: string
|
||||
groupKey: string
|
||||
pageType: PageType
|
||||
title: string
|
||||
hero: Hero | null
|
||||
seoTitle: string
|
||||
seoDescription: string
|
||||
components: ComponentContent[]
|
||||
}
|
||||
|
||||
export interface SettingsInput {
|
||||
locale: string
|
||||
header: JsonObject
|
||||
footer: JsonObject
|
||||
legal: JsonObject
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import vine from '@vinejs/vine'
|
||||
import {
|
||||
PREVIEW_TOKEN_MAX_TTL_SECONDS,
|
||||
PREVIEW_TOKEN_MIN_TTL_SECONDS,
|
||||
SITEMAP_MAX_PAGE_SIZE,
|
||||
} from '#base_modules/WebManager/config/general'
|
||||
import {
|
||||
componentSchema,
|
||||
groupKeySchema,
|
||||
localeSchema,
|
||||
pathSchema,
|
||||
} from '#base_modules/WebManager/validator/schemas'
|
||||
import { PAGE_TYPES } from '#base_modules/WebManager/types/web_content'
|
||||
import { PAGE_STATUSES } from '#base_modules/WebManager/types/page_status'
|
||||
import { jsonObjectSchema } from '#validators/schemas/json_object_schema'
|
||||
|
||||
const pageFields = {
|
||||
locale: localeSchema,
|
||||
path: pathSchema,
|
||||
groupKey: groupKeySchema,
|
||||
pageType: vine.enum(PAGE_TYPES),
|
||||
title: vine.string().trim().minLength(1).maxLength(255),
|
||||
hero: jsonObjectSchema,
|
||||
seoTitle: vine.string().trim().maxLength(255).optional(),
|
||||
seoDescription: vine.string().trim().maxLength(2000).optional(),
|
||||
components: vine.array(componentSchema).maxLength(200),
|
||||
}
|
||||
|
||||
export const createPageValidator = vine.create(vine.object(pageFields))
|
||||
export const saveDraftValidator = vine.create(vine.object(pageFields))
|
||||
|
||||
export const listPagesValidator = vine.create(
|
||||
vine.object({
|
||||
locale: localeSchema.optional(),
|
||||
status: vine.enum(PAGE_STATUSES).optional(),
|
||||
groupKey: groupKeySchema.optional(),
|
||||
pageType: vine.enum(PAGE_TYPES).optional(),
|
||||
search: vine.string().trim().maxLength(255).optional(),
|
||||
page: vine.number().min(1).optional(),
|
||||
perPage: vine.number().min(1).max(100).optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export const publicPageValidator = vine.create(
|
||||
vine.object({
|
||||
locale: localeSchema,
|
||||
path: pathSchema,
|
||||
})
|
||||
)
|
||||
|
||||
export const publicBlogListValidator = vine.create(
|
||||
vine.object({
|
||||
locale: localeSchema,
|
||||
page: vine.number().min(1).optional(),
|
||||
perPage: vine.number().min(1).max(100).optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export const publicSitemapValidator = vine.create(
|
||||
vine.object({
|
||||
page: vine.number().min(1).optional(),
|
||||
perPage: vine.number().min(1).max(SITEMAP_MAX_PAGE_SIZE).optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export const previewTokenValidator = vine.create(
|
||||
vine.object({
|
||||
expiresIn: vine
|
||||
.number()
|
||||
.min(PREVIEW_TOKEN_MIN_TTL_SECONDS)
|
||||
.max(PREVIEW_TOKEN_MAX_TTL_SECONDS)
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export const previewRequestValidator = vine.create(
|
||||
vine.object({ token: vine.string().trim().minLength(1) })
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
import { jsonObjectSchema } from '#validators/schemas/json_object_schema'
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const localeSchema = vine
|
||||
.string()
|
||||
.trim()
|
||||
.minLength(2)
|
||||
.maxLength(16)
|
||||
.regex(/^[a-z]{2,3}(?:-[A-Z]{2})?$/)
|
||||
|
||||
export const pathSchema = vine
|
||||
.string()
|
||||
.trim()
|
||||
.maxLength(2048)
|
||||
.regex(/^(?:(?:[a-z0-9]+(?:-[a-z0-9]+)*)(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*)?$/)
|
||||
|
||||
export const groupKeySchema = vine
|
||||
.string()
|
||||
.trim()
|
||||
.minLength(1)
|
||||
.maxLength(255)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
|
||||
export const componentSchema = vine.object({
|
||||
type: vine
|
||||
.string()
|
||||
.trim()
|
||||
.minLength(1)
|
||||
.maxLength(100)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
data: jsonObjectSchema,
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import vine from '@vinejs/vine'
|
||||
import { localeSchema } from '#base_modules/WebManager/validator/schemas'
|
||||
import { jsonObjectSchema } from '#validators/schemas/json_object_schema'
|
||||
|
||||
export const settingsQueryValidator = vine.create(vine.object({ locale: localeSchema.optional() }))
|
||||
|
||||
export const saveSettingsValidator = vine.create(
|
||||
vine.object({
|
||||
locale: localeSchema,
|
||||
header: jsonObjectSchema,
|
||||
footer: jsonObjectSchema,
|
||||
legal: jsonObjectSchema,
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,225 @@
|
||||
import vine, { errors } from '@vinejs/vine'
|
||||
import { InvalidPagePayloadException } from '#exceptions/web_manager_exceptions'
|
||||
import {
|
||||
BANNER_STYLES,
|
||||
HERO_TYPES,
|
||||
MEDIA_CONTENT_POSITIONS,
|
||||
NAVIGATION_LINK_VARIANTS,
|
||||
} from '#base_modules/WebManager/types/web_content'
|
||||
import type {
|
||||
ComponentContent,
|
||||
Hero,
|
||||
SiteSettingsContent,
|
||||
} from '#base_modules/WebManager/types/web_content'
|
||||
|
||||
const richTextSchema = () => vine.object({ content: vine.string() })
|
||||
|
||||
const faqItemSchema = () =>
|
||||
vine.object({
|
||||
question: vine.string(),
|
||||
answer: vine.string(),
|
||||
})
|
||||
|
||||
const mediaSchema = () =>
|
||||
vine.object({
|
||||
url: vine
|
||||
.object({
|
||||
name: vine.string(),
|
||||
ref: vine.string(),
|
||||
})
|
||||
.nullable(),
|
||||
alt: vine.string(),
|
||||
width: vine.number().optional(),
|
||||
height: vine.number().optional(),
|
||||
mimeType: vine.string().optional(),
|
||||
})
|
||||
|
||||
const navigationLinkBaseShape = () => ({
|
||||
openInNewTab: vine.boolean(),
|
||||
label: vine.string(),
|
||||
variant: vine.enum(NAVIGATION_LINK_VARIANTS),
|
||||
})
|
||||
|
||||
const isObjectWith = (value: unknown, key: string, expected: string) =>
|
||||
vine.helpers.isObject(value) && value[key] === expected
|
||||
|
||||
const navigationLinkSchema = () =>
|
||||
vine
|
||||
.union([
|
||||
vine.union.if(
|
||||
(value) => isObjectWith(value, 'linkType', 'internal'),
|
||||
vine.object({
|
||||
...navigationLinkBaseShape(),
|
||||
linkType: vine.literal('internal'),
|
||||
pageId: vine.string().trim().minLength(1),
|
||||
})
|
||||
),
|
||||
vine.union.if(
|
||||
(value) => isObjectWith(value, 'linkType', 'external'),
|
||||
vine.object({
|
||||
...navigationLinkBaseShape(),
|
||||
linkType: vine.literal('external'),
|
||||
externalUrl: vine.string().url(),
|
||||
})
|
||||
),
|
||||
])
|
||||
.otherwise((_, field) => {
|
||||
field.report('Navigation link must be internal or external', 'navigationLink', field)
|
||||
})
|
||||
|
||||
const heroSchema = () =>
|
||||
vine.object({
|
||||
type: vine.enum(HERO_TYPES),
|
||||
title: vine.string().optional(),
|
||||
subtitle: vine.string().optional(),
|
||||
content: richTextSchema().optional(),
|
||||
ctaItems: vine.array(navigationLinkSchema()),
|
||||
media: mediaSchema().optional(),
|
||||
})
|
||||
|
||||
const contentBlockDataSchema = () =>
|
||||
vine.object({
|
||||
title: vine.string(),
|
||||
subtitle: vine.string(),
|
||||
columns: vine.array(
|
||||
vine.object({
|
||||
content: richTextSchema(),
|
||||
ctaItems: vine.array(navigationLinkSchema()),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
const faqBlockDataSchema = () =>
|
||||
vine.object({
|
||||
title: vine.string(),
|
||||
subtitle: vine.string(),
|
||||
items: vine.array(faqItemSchema()),
|
||||
})
|
||||
|
||||
const mediaContentBlockDataSchema = () =>
|
||||
vine.object({
|
||||
title: vine.string(),
|
||||
mediaPosition: vine.enum(MEDIA_CONTENT_POSITIONS),
|
||||
media: mediaSchema(),
|
||||
content: richTextSchema(),
|
||||
ctaItems: vine.array(navigationLinkSchema()),
|
||||
})
|
||||
|
||||
const articlesBlockDataSchema = () =>
|
||||
vine.object({
|
||||
cta: navigationLinkSchema().optional(),
|
||||
title: vine.string().optional(),
|
||||
subtitle: richTextSchema().optional(),
|
||||
items: vine.array(
|
||||
vine.object({
|
||||
title: vine.string(),
|
||||
content: richTextSchema(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
const bannerBlockDataSchema = () =>
|
||||
vine.object({
|
||||
style: vine.enum(BANNER_STYLES),
|
||||
content: richTextSchema(),
|
||||
ctaItems: vine.array(navigationLinkSchema()),
|
||||
})
|
||||
|
||||
const mediaBlockDataSchema = () =>
|
||||
vine.object({
|
||||
media: mediaSchema(),
|
||||
})
|
||||
|
||||
const componentContentSchema = () =>
|
||||
vine
|
||||
.union([
|
||||
vine.union.if(
|
||||
(value) => isObjectWith(value, 'type', 'content'),
|
||||
vine.object({ type: vine.literal('content'), data: contentBlockDataSchema() })
|
||||
),
|
||||
vine.union.if(
|
||||
(value) => isObjectWith(value, 'type', 'faq'),
|
||||
vine.object({ type: vine.literal('faq'), data: faqBlockDataSchema() })
|
||||
),
|
||||
vine.union.if(
|
||||
(value) => isObjectWith(value, 'type', 'media-content'),
|
||||
vine.object({ type: vine.literal('media-content'), data: mediaContentBlockDataSchema() })
|
||||
),
|
||||
vine.union.if(
|
||||
(value) => isObjectWith(value, 'type', 'articles'),
|
||||
vine.object({ type: vine.literal('articles'), data: articlesBlockDataSchema() })
|
||||
),
|
||||
vine.union.if(
|
||||
(value) => isObjectWith(value, 'type', 'banner'),
|
||||
vine.object({ type: vine.literal('banner'), data: bannerBlockDataSchema() })
|
||||
),
|
||||
vine.union.if(
|
||||
(value) => isObjectWith(value, 'type', 'media'),
|
||||
vine.object({ type: vine.literal('media'), data: mediaBlockDataSchema() })
|
||||
),
|
||||
])
|
||||
.otherwise((_, field) => {
|
||||
field.report('Unknown component type', 'componentType', field)
|
||||
})
|
||||
|
||||
const siteMenuSchema = () =>
|
||||
vine.object({
|
||||
title: vine.string().optional(),
|
||||
items: vine.array(navigationLinkSchema()),
|
||||
})
|
||||
|
||||
const siteSettingsContentSchema = () =>
|
||||
vine.object({
|
||||
header: vine.object({
|
||||
menus: vine.array(siteMenuSchema()),
|
||||
ctaItems: vine.array(navigationLinkSchema()),
|
||||
}),
|
||||
footer: vine.object({
|
||||
copyright: vine.string().optional(),
|
||||
menus: vine.array(siteMenuSchema()),
|
||||
}),
|
||||
legal: vine.object({
|
||||
privacyPath: vine.string().optional(),
|
||||
termsPath: vine.string().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
const heroValidator = vine.create(heroSchema())
|
||||
const componentContentValidator = vine.create(componentContentSchema())
|
||||
const siteSettingsContentValidator = vine.create(siteSettingsContentSchema())
|
||||
|
||||
function describeError(context: string, error: unknown): string {
|
||||
if (error instanceof errors.E_VALIDATION_ERROR) {
|
||||
const issue = (error.messages as Array<{ field?: string; message?: string }>)[0]
|
||||
const path = issue?.field ? ` at "${issue.field}"` : ''
|
||||
return `${context}${path}: ${issue?.message ?? 'invalid value'}`
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
async function runValidator<Output>(
|
||||
context: string,
|
||||
validator: { validate(value: unknown): Promise<Output> },
|
||||
value: unknown
|
||||
): Promise<Output> {
|
||||
try {
|
||||
return await validator.validate(value)
|
||||
} catch (error) {
|
||||
throw new InvalidPagePayloadException(describeError(context, error))
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseHero(value: unknown): Promise<Hero> {
|
||||
return runValidator('Invalid hero', heroValidator, value)
|
||||
}
|
||||
|
||||
export async function parseComponentContent(
|
||||
value: unknown,
|
||||
index: number
|
||||
): Promise<ComponentContent> {
|
||||
return runValidator(`Invalid component #${index}`, componentContentValidator, value)
|
||||
}
|
||||
|
||||
export async function parseSiteSettingsContent(value: unknown): Promise<SiteSettingsContent> {
|
||||
return runValidator('Invalid settings', siteSettingsContentValidator, value)
|
||||
}
|
||||
Reference in New Issue
Block a user