Initial commit

This commit is contained in:
Igor Martyniuk
2026-06-25 15:53:49 +03:00
commit 1bb0b7e68a
1517 changed files with 218469 additions and 0 deletions
+18
View File
@@ -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,91 @@
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',
} = request.all()
const query = Admins.query()
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
View File
@@ -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
}
}
+37
View File
@@ -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
View File
@@ -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()),
})
)
@@ -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) })
}
}
+8
View File
@@ -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 = '/'
+18
View File
@@ -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',
},
]
@@ -0,0 +1,150 @@
import { 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 { 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,13 @@
import vine from '@vinejs/vine'
import type { Request } 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: 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
View File
@@ -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'])
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'])
})
.prefix('/file-manager')
.use([middleware.auth()])
@@ -0,0 +1,132 @@
import { 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 { MultipartFile } from '@adonisjs/core/bodyparser'
import { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
import {
default as EntryListService,
EntryListParams,
EntryListResult,
} from '#base_modules/FileManager/services/entry_list_service'
import createStorageService from '#base_modules/FileManager/services/storage_service/create_storage_service'
import {
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_exeptions'
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 { 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 { cuid } from '@adonisjs/core/helpers'
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 = `${cuid()}${ext}`
break
}
}
return newName
}
}
@@ -0,0 +1,21 @@
import env from '#start/env'
import { 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)
}
}
@@ -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}$`), '')
}
}
@@ -0,0 +1,250 @@
import drive from '@adonisjs/drive/services/main'
import {
DirExistsException,
DirNotFoundException,
FileNotFoundException,
} from '#exceptions/file_manager_exeptions'
import { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
import { StorageService } from './storage_service_interface.js'
import PathService from '#base_modules/FileManager/services/path_service'
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 async moveDirectory(oldPath: string, newPath: string) {
const response = await this.disk.listAll(oldPath, { recursive: true })
for (const item of response.objects) {
if (!item.isFile) continue
const relative = item.key.slice(oldPath.length).replace(/^\/+/, '')
const target = `${newPath}/${relative}`
const dir = target.split('/').slice(0, -1).join('/')
if (dir) await this.disk.put(this.directoryMarkerService.markerPath(dir), '')
await this.disk.move(item.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> {
return this.disk.exists(this.directoryMarkerService.markerPath(path))
}
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)
const response = await this.disk.listAll(source)
if (Array.from(response.objects).length) return this.getDirectoryEntry(path)
throw new Error('Entry not found')
}
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) }
}
}
@@ -0,0 +1,270 @@
import { createReadStream } from 'node:fs'
import drive from '@adonisjs/drive/services/main'
import {
DirExistsException,
DirNotFoundException,
FileNotFoundException,
} from '#exceptions/file_manager_exeptions'
import { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
import { StorageService } from './storage_service_interface.js'
import PathService from '#base_modules/FileManager/services/path_service'
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 moveDirectory(oldPath: string, newPath: string) {
const oldPrefix = this.directoryPrefix(oldPath)
const response = await this.disk.listAll(oldPrefix, { recursive: true })
for (const item of response.objects) {
if (!item.isFile) continue
const relative = item.key.slice(oldPrefix.length).replace(/^\/+/, '')
const target = this.pathService.join(newPath, relative)
const dir = target.split('/').slice(0, -1).join('/')
if (dir) await this.disk.put(this.directoryMarkerService.markerPath(dir), '')
await this.disk.move(item.key, target)
}
await this.disk.deleteAll(oldPrefix)
}
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> {
return this.disk.exists(this.directoryMarkerService.markerPath(path))
}
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 {
await this.disk.deleteAll(this.directoryPrefix(storagePath))
}
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)
const response = await this.disk.listAll(source)
if (Array.from(response.objects).length) return this.getDirectoryEntry(path)
throw new Error('Entry not found')
}
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) }
}
}
@@ -0,0 +1,25 @@
import { Readable } from 'node:stream'
import { MultipartFile } from '@adonisjs/core/bodyparser'
import { 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
@@ -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())
}
}
+14
View File
@@ -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
View File
@@ -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()])
@@ -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(),
})
)
@@ -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(),
})
)
+18
View File
@@ -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
View File
@@ -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
}
+36
View File
@@ -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')
}
}
+6
View File
@@ -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()),
})
)