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
+1
View File
@@ -0,0 +1 @@
client/
+22
View File
@@ -0,0 +1,22 @@
# http://editorconfig.org
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.json]
insert_final_newline = unset
[**.min.js]
indent_style = unset
insert_final_newline = unset
[MakeFile]
indent_style = space
[*.md]
trim_trailing_whitespace = false
+18
View File
@@ -0,0 +1,18 @@
TZ=UTC
PORT=3333
HOST=localhost
LOG_LEVEL=info
APP_KEY=
NODE_ENV=development
DB_HOST=127.0.0.1
DB_PORT=5432
DB_USER=root
DB_PASSWORD=root
DB_DATABASE=app
DRIVE_DISK=fs
AWS_ACCESS_KEY_ID=minioadmin
AWS_SECRET_ACCESS_KEY=minioadmin
AWS_REGION=us-east-1
S3_BUCKET=crux-drive
S3_ENDPOINT=http://localhost:9000
S3_CDN_URL=http://localhost:9000/crux-drive/
+21
View File
@@ -0,0 +1,21 @@
# Adonis v6 backend (development mode)
FROM node:22-alpine3.20
WORKDIR /app
# Copy only root workspace files first
COPY package.json package-lock.json ./
# Install workspace dependencies (monorepo-aware)
RUN npm ci
# Copy backend source
COPY backend ./backend
COPY packages ./packages
WORKDIR /app/backend
# Expose dev port
EXPOSE 3333
CMD ["npm", "run", "dev"]
Executable
+27
View File
@@ -0,0 +1,27 @@
/*
|--------------------------------------------------------------------------
| JavaScript entrypoint for running ace commands
|--------------------------------------------------------------------------
|
| DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD
| PROCESS.
|
| See docs.adonisjs.com/guides/typescript-build-process#creating-production-build
|
| Since, we cannot run TypeScript source code using "node" binary, we need
| a JavaScript entrypoint to run ace commands.
|
| This file registers the "ts-node/esm" hook with the Node.js module system
| and then imports the "bin/console.ts" file.
|
*/
/**
* Register hook to process TypeScript files using ts-node
*/
import 'ts-node-maintained/register/esm'
/**
* Import ace console entrypoint
*/
await import('./bin/console.js')
+86
View File
@@ -0,0 +1,86 @@
import { defineConfig } from '@adonisjs/core/app'
export default defineConfig({
/*
|--------------------------------------------------------------------------
| Experimental flags
|--------------------------------------------------------------------------
|
| The following features will be enabled by default in the next major release
| of AdonisJS. You can opt into them today to avoid any breaking changes
| during upgrade.
|
*/
experimental: {
mergeMultipartFieldsAndFiles: true,
shutdownInReverseOrder: true,
},
/*
|--------------------------------------------------------------------------
| Commands
|--------------------------------------------------------------------------
|
| List of ace commands to register from packages. The application commands
| will be scanned automatically from the "./commands" directory.
|
*/
commands: [
() => import('@adonisjs/core/commands'),
() => import('@adonisjs/lucid/commands'),
() => import('@adonisjs/bouncer/commands'),
],
/*
|--------------------------------------------------------------------------
| Service providers
|--------------------------------------------------------------------------
|
| List of service providers to import and register when booting the
| application
|
*/
providers: [
() => import('@adonisjs/core/providers/app_provider'),
() => import('@adonisjs/core/providers/hash_provider'),
{
file: () => import('@adonisjs/core/providers/repl_provider'),
environment: ['repl', 'test'],
},
() => import('@adonisjs/core/providers/vinejs_provider'),
() => import('@adonisjs/cors/cors_provider'),
() => import('@adonisjs/lucid/database_provider'),
() => import('@adonisjs/auth/auth_provider'),
() => import('@adonisjs/bouncer/bouncer_provider'),
() => import('@adonisjs/drive/drive_provider'),
],
/*
|--------------------------------------------------------------------------
| Preloads
|--------------------------------------------------------------------------
|
| List of modules to import before starting the application.
|
*/
preloads: [() => import('#start/routes'), () => import('#start/kernel')],
/*
|--------------------------------------------------------------------------
| Tests
|--------------------------------------------------------------------------
|
| List of test suites to organize tests by their type. Feel free to remove
| and add additional suites.
|
*/
tests: {
suites: [
{
name: 'functional',
files: ['tests/**/*.spec.(ts|js)', 'modules/**/tests/**/*.spec.(ts|js)'],
},
],
forceExit: false,
},
})
+23
View File
@@ -0,0 +1,23 @@
/*
|--------------------------------------------------------------------------
| Bouncer abilities
|--------------------------------------------------------------------------
|
| You may export multiple abilities from this file and pre-register them
| when creating the Bouncer instance.
|
| Pre-registered policies and abilities can be referenced as a string by their
| name. Also they are must if want to perform authorization inside Edge
| templates.
|
*/
import { Bouncer } from '@adonisjs/bouncer'
/**
* Delete the following ability to start from
* scratch
*/
export const editUser = Bouncer.ability(() => {
return true
})
+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()),
})
)
+482
View File
@@ -0,0 +1,482 @@
import { HttpContext } from '@adonisjs/core/http'
import { join, dirname } from 'node:path'
import { mkdirSync, writeFileSync, readFileSync, existsSync, cpSync } from 'node:fs'
import { modelToSchema } from '#services/swagger_model'
import handlebars from 'handlebars'
import {
getDatabaseTables,
getColumnsForTable,
getForeignKeysReferencing,
} from '#services/database_inspector'
handlebars.registerHelper('eq', (a, b) => a === b)
handlebars.registerHelper('not', (v) => !v)
handlebars.registerHelper('or', (a, b) => a || b)
handlebars.registerHelper('and', (a, b) => a && b)
function renderTemplate(templatePath: string, context: any): string {
const raw = readFileSync(templatePath, 'utf8')
const template = handlebars.compile(raw)
return template(context)
}
export default class MakeModuleController {
capitalize(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
camelCase(str: string) {
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
}
pascalCase(str: string) {
return this.capitalize(this.camelCase(str))
}
snakeCase(str: string) {
return str.slice(0, str.length - 1)
}
mapDBTypeToTs(type: string): string {
if (type.includes('int')) return 'number'
if (type === 'boolean') return 'boolean'
if (type.includes('timestamp') || type === 'date') return 'DateTime'
return 'string'
}
mapDBTypeToValidator(type: string): string {
if (
type.includes('int') ||
type === 'numeric' ||
type === 'decimal' ||
type === 'real' ||
type === 'double precision'
) {
return 'number'
}
if (type === 'boolean') return 'boolean'
if (type.includes('timestamp') || type === 'date') return 'date'
return 'string'
}
mapDBTypeToFrontend(type: string): string {
if (type.includes('int') || type === 'numeric' || type === 'bigint') return 'number'
if (type === 'boolean') return 'boolean'
if (type.includes('timestamp') || type === 'date') return 'IsoDateString'
return 'string'
}
async generateRelationships(table: string) {
const lines: string[] = []
const imports: Set<string> = new Set()
const columns = await getColumnsForTable(table)
const references = await getForeignKeysReferencing(table)
for (const col of columns) {
if (col.column_name.endsWith('_id')) {
const refTable = col.column_name.replace('_id', '')
const refModel = this.capitalize(refTable)
imports.add('belongsTo')
lines.push(
` @belongsTo(() => ${refModel})\n declare ${refTable}: BelongsTo<typeof ${refModel}>`
)
}
}
for (const ref of references) {
const relModel = this.capitalize(ref.table_name)
imports.add('hasMany')
lines.push(
` @hasMany(() => ${relModel})\n declare ${ref.table_name}: HasMany<typeof ${relModel}>`
)
}
return {
imports: Array.from(imports),
code: lines,
}
}
async tables({ response }: HttpContext) {
const tables = await getDatabaseTables()
return response.ok(tables)
}
async generate({ request, response }: HttpContext) {
const {
name,
table,
generate,
form,
formDefinition,
dataTable,
list,
formDialog,
menu,
module,
} = request.only([
'name',
'table',
'generate',
'form',
'formDefinition',
'dataTable',
'list',
'formDialog',
'menu',
'module',
])
const appBase = process.cwd()
const backendTplDir = join(appBase, 'app/templates/backend')
const apiTplDir = join(appBase, 'app/templates/backend/api')
const moduleName = this.capitalize(name)
const modulePath = join(appBase, 'modules', moduleName)
const snake = this.snakeCase(table)
const clientBase = join(appBase, '..', 'apps', 'admin', 'src', 'modules', moduleName)
const templateModuleDir = join(appBase, '..', 'apps', 'admin', 'src', 'modules', '_template')
const paths = {
// backend
controller: join(modulePath, 'controller', `${snake}_controller.ts`),
model: join(modulePath, 'model', `${snake}.ts`),
// validatorCreate: join('app', 'validators', `${snake}_create_validator.ts`),
// validatorUpdate: join('app', 'validators', `${snake}_update_validator.ts`),
validatorCreate: join(modulePath, 'validators', `${snake}_create_validator.ts`),
validatorUpdate: join(modulePath, 'validators', `${snake}_update_validator.ts`),
routes: join(modulePath, 'routes.ts'),
policy: join(modulePath, 'policy', `${snake}_policy.ts`),
policiesMain: join(appBase, 'app', 'policies', 'main.ts'),
permissionsConfig: join(modulePath, 'config', 'permissions.ts'),
// API subtree
apiController: join(modulePath, 'api', 'controllers', `${snake}_api_controller.ts`),
// apiValidatorCreate: join('app', 'validators', `${snake}_create_api_validator.ts`),
// apiValidatorUpdate: join('app', 'validators', `${snake}_update_api_validator.ts`),
apiValidatorCreate: join(modulePath, 'api', 'validators', `${snake}_create_api_validator.ts`),
apiValidatorUpdate: join(modulePath, 'api', 'validators', `${snake}_update_api_validator.ts`),
apiRoutes: join(modulePath, 'api', 'routes.ts'),
// tests
testController: join(
appBase,
'tests',
'modules',
moduleName,
`${this.snakeCase(table)}.spec.ts`
),
testApi: join(
appBase,
'tests',
'modules',
moduleName,
`${this.snakeCase(table)}.api.spec.ts`
),
// frontend
clientModule: clientBase,
clientForm: join(clientBase, 'view', 'forms', 'MainForm', 'Component.tsx'),
clientFormDefinition: join(clientBase, 'view', 'forms', 'MainForm', 'form.ts'),
clientDatatable: join(clientBase, 'view', 'widgets', 'DataTable.tsx'),
clientList: join(clientBase, 'pages', 'ListPage', 'index.tsx'),
clientDialog: join(clientBase, 'view', 'widgets', 'ManageTrigger.tsx'),
clientConfig: join(clientBase, 'config', 'meta.ts'),
clientMenu: join(clientBase, 'config', 'menu.ts'),
clientModuleConfig: join(clientBase, 'config', 'module.ts'),
clientEntity: join(clientBase, 'entities', 'Entity.ts'),
}
if (existsSync(templateModuleDir)) {
cpSync(templateModuleDir, paths.clientModule, { recursive: true })
}
Object.values(paths).forEach((filePath) => {
mkdirSync(dirname(filePath), { recursive: true })
})
const columns = await getColumnsForTable(table)
const modelClass = this.capitalize(table)
const relationship = await this.generateRelationships(table)
const enhancedColumns = columns.map((col) => {
const colName = col.column_name
const camelName = this.camelCase(colName)
const type = this.mapDBTypeToTs(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : '')
return {
name: colName,
camelCase: camelName,
type,
isPrimary: colName === 'id',
isPassword: colName === 'password',
isEmail: colName === 'email',
isDateTime: ['created_at', 'updated_at'].includes(colName),
isSpecial: ['created_at', 'updated_at', 'deleted_at'].includes(colName),
isRequired: col.is_nullable === 'NO',
isNullable: col.is_nullable === 'YES',
validatorType: this.mapDBTypeToValidator(col.data_type),
needsTrim:
this.mapDBTypeToValidator(col.data_type) === 'string' &&
colName !== 'email' &&
colName !== 'password',
}
})
const sampleFor = (col: any) => {
const t = col.validatorType
if (t === 'number') return 1
if (t === 'boolean') return false
if (t === 'date') return `'${new Date().toISOString()}'`
return `'sample_${col.camelCase}'`
}
const createFields = enhancedColumns
.filter((c) => !c.isPrimary && !c.isSpecial)
.map((c) => ({
camelCase: c.camelCase,
sampleValue: sampleFor(c),
column_name: c.name,
}))
const updateFields = enhancedColumns
.filter((c) => !c.isPrimary && !c.isDateTime)
.map((c) => ({
camelCase: c.camelCase,
sampleValue: sampleFor(c),
column_name: c.name,
}))
// minimal insert json for show/update/destroy fixtures
const insertJson = `{
${createFields.map((f) => ` ${f.column_name}: ${f.sampleValue}`).join(',\n')}
}`
const frontendFields = columns.map((col) => {
return {
name: col.column_name,
type:
this.mapDBTypeToFrontend(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : ''),
}
})
// FRONTEND writer
writeFileSync(paths.clientForm, form ?? '', 'utf8')
writeFileSync(paths.clientFormDefinition, formDefinition ?? '', 'utf8')
writeFileSync(paths.clientDatatable, dataTable ?? '', 'utf8')
writeFileSync(paths.clientList, list ?? '', 'utf8')
writeFileSync(paths.clientDialog, formDialog ?? '', 'utf8')
writeFileSync(paths.clientMenu, menu ?? '', 'utf8')
writeFileSync(paths.clientModuleConfig, module ?? '', 'utf8')
const configContent = renderTemplate(join(backendTplDir, '..', 'frontend', 'config.hbs'), {
name,
})
writeFileSync(paths.clientConfig, configContent)
const entityContent = renderTemplate(join(backendTplDir, '..', 'frontend', 'entity.hbs'), {
fields: frontendFields,
})
writeFileSync(paths.clientEntity, entityContent)
// BACKEND: model
if (generate.includes('model')) {
const content = renderTemplate(join(backendTplDir, 'model.hbs'), {
name,
pascalCaseName: this.pascalCase(name),
capName: this.capitalize(name),
className: modelClass,
imports: relationship.imports.join(', '),
columns: enhancedColumns,
relationships: relationship.code,
hasPassword: columns.some((col) => col.column_name === 'password'),
})
await writeFileSync(paths.model, content)
}
// BACKEND: controller
if (generate.includes('controller')) {
const content = renderTemplate(join(backendTplDir, 'controller.hbs'), {
pascalCaseName: this.pascalCase(name),
modelName: modelClass,
capName: this.capitalize(name),
moduleName,
tableName: table,
snakeName: snake,
name,
})
writeFileSync(paths.controller, content)
const testsTplDir = join(backendTplDir, 'tests')
// controller tests
const ctrlSpec = renderTemplate(join(testsTplDir, 'controller.spec.hbs'), {
pascalCaseName: this.pascalCase(name),
snakeCaseName: this.snakeCase(table),
tableName: table,
createFields,
updateFields,
insertJson,
})
mkdirSync(dirname(paths.testController), { recursive: true })
writeFileSync(paths.testController, ctrlSpec, 'utf8')
}
// BACKEND: routes
if (generate.includes('routes')) {
const content = renderTemplate(join(backendTplDir, 'routes.hbs'), {
name,
capName: this.capitalize(name),
className: moduleName,
routeName: name,
snakeCaseName: snake,
})
writeFileSync(paths.routes, content)
}
// BACKEND: validators
if (generate.includes('validator')) {
const createContent = renderTemplate(join(backendTplDir, 'validator.hbs'), {
className: moduleName,
functionName: `create${moduleName}Validator`,
fields: enhancedColumns.filter((col) => !col.isPrimary && !col.isSpecial),
})
writeFileSync(paths.validatorCreate, createContent)
const updateContent = renderTemplate(join(backendTplDir, 'validator.hbs'), {
className: moduleName,
functionName: `update${moduleName}Validator`,
fields: enhancedColumns.filter((col) => !col.isPrimary && !col.isDateTime),
})
writeFileSync(paths.validatorUpdate, updateContent)
}
// BACKEND: policy + permissions
if (generate.includes('policy')) {
const policyTpl = join(backendTplDir, 'policy.hbs')
const base = name.toLowerCase()
const slugBase = base.endsWith('s') ? base : `${base}s`
const policyContent = renderTemplate(policyTpl, {
name,
slugBase,
moduleName,
modelName: modelClass,
pascalCaseName: this.pascalCase(name),
})
writeFileSync(paths.policy, policyContent)
if (!existsSync(paths.policiesMain)) {
mkdirSync(dirname(paths.policiesMain), { recursive: true })
writeFileSync(paths.policiesMain, `export const policies = {}\n`)
}
const capName = this.capitalize(name)
const key = `${modelClass}Policy`
const importLine = ` ${key}: () => import('#modules/${capName}/policy/${name}_policy'),`
const current = readFileSync(paths.policiesMain, 'utf8')
if (current.includes('export const policies =')) {
if (!current.includes(importLine)) {
const updated = current.replace(
/export const policies\s*=\s*\{\s*/m,
(m) => m + '\n' + importLine + '\n'
)
writeFileSync(paths.policiesMain, updated)
}
} else {
writeFileSync(paths.policiesMain, `export const policies = {\n${importLine}\n}\n`)
}
const labelBase = this.capitalize(name.endsWith('s') ? name.slice(0, -1) : name)
const perms = [
{ action: 'view', slug: `${name}s.view`, name: `${labelBase} view` },
{ action: 'create', slug: `${name}s.create`, name: `${labelBase} create` },
{ action: 'update', slug: `${name}s.update`, name: `${labelBase} update` },
{ action: 'delete', slug: `${name}s.delete`, name: `${labelBase} delete` },
]
const fileContents =
'export default [\n' +
perms.map((p) => ` {\n slug: '${p.slug}',\n name: '${p.name}',\n },`).join('\n') +
'\n]\n'
if (!existsSync(paths.permissionsConfig)) {
mkdirSync(dirname(paths.permissionsConfig), { recursive: true })
writeFileSync(paths.permissionsConfig, fileContents, 'utf8')
}
}
// API subtree
if (generate.includes('api')) {
const { default: genModel } = await import(paths.model)
const test = await modelToSchema(genModel)
console.warn(test)
const apiCtx = {
pascalCaseName: this.pascalCase(name),
modelName: modelClass,
capName: this.capitalize(name),
moduleName,
tableName: table,
snakeCaseName: snake,
name,
snake,
swaggerModel: await modelToSchema(genModel),
}
// Controller
const apiControllerTpl = join(apiTplDir, 'controller.hbs')
const apiControllerContent = renderTemplate(apiControllerTpl, apiCtx)
writeFileSync(paths.apiController, apiControllerContent, 'utf8')
// test
const testsTplDir = join(backendTplDir, 'tests')
const apiSpec = renderTemplate(join(testsTplDir, 'api.spec.hbs'), {
snakeCaseName: this.snakeCase(table),
createFields,
})
writeFileSync(paths.testApi, apiSpec, 'utf8')
// Validators
const apiValidatorTpl = join(apiTplDir, 'validator.hbs')
const apiCreateValidatorContent = renderTemplate(apiValidatorTpl, {
...apiCtx,
className: moduleName,
functionName: `create${moduleName}ApiValidator`,
fields: enhancedColumns.filter((c) => !c.isPrimary && !c.isSpecial),
})
writeFileSync(paths.apiValidatorCreate, apiCreateValidatorContent, 'utf8')
const apiUpdateValidatorContent = renderTemplate(apiValidatorTpl, {
...apiCtx,
className: moduleName,
functionName: `update${moduleName}ApiValidator`,
fields: enhancedColumns.filter((c) => !c.isPrimary && !c.isDateTime),
})
writeFileSync(paths.apiValidatorUpdate, apiUpdateValidatorContent, 'utf8')
// Routes
const apiRoutesTpl = join(apiTplDir, 'routes.hbs')
const apiRoutesContent = renderTemplate(apiRoutesTpl, apiCtx)
writeFileSync(paths.apiRoutes, apiRoutesContent, 'utf8')
}
return response.ok({ message: `Module "${moduleName}" created based on table "${table}"` })
}
public async columns({ request, response }: HttpContext) {
const { table } = request.params()
const columns = await getColumnsForTable(table)
const enhancedColumns = columns.map((col) => {
const colName = col.column_name
const type = this.mapDBTypeToTs(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : '')
return {
name: colName,
type,
isDateTime: ['created_at', 'updated_at'].includes(colName),
}
})
return response.ok({ columns: enhancedColumns })
}
}
@@ -0,0 +1,55 @@
import { Exception } from '@adonisjs/core/exceptions'
export class FilenameMissingExtensionException extends Exception {
static status = 400
static code = 'E_FILE_NAME_MISSING_EXTENSION'
constructor(message: string = 'File name missing extension') {
super(message)
}
}
export class EntryNameNotChangedException extends Exception {
static status = 400
static code = 'E_ENTRY_NAME_NOT_CHANGED'
constructor(message: string = 'Entry name was not changed') {
super(message)
}
}
export class EntryExistsException extends Exception {
static status = 400
static code = 'E_ENTRY_ALREADY_EXISTS'
constructor(message: string = 'Entry already exists') {
super(message)
}
}
export class DirExistsException extends Exception {
static status = 400
static code = 'E_DIRECTORY_ALREADY_EXISTS'
constructor(message: string = 'Directory already exists') {
super(message)
}
}
export class FileNotFoundException extends Exception {
static status = 404
static code = 'E_FILE_NOT_FOUND'
constructor(message: string = 'File not found') {
super(message)
}
}
export class DirNotFoundException extends Exception {
static status = 404
static code = 'E_DIR_NOT_FOUND'
constructor(message: string = 'Directory not found') {
super(message)
}
}
+66
View File
@@ -0,0 +1,66 @@
import app from '@adonisjs/core/services/app'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
export default class HttpExceptionHandler extends ExceptionHandler {
/**
* In debug mode, the exception handler will display verbose errors
* with pretty printed stack traces.
*/
protected debug = !app.inProduction
/**
* The method is used for handling errors and returning
* response to the client
*/
async handle(error: unknown, ctx: HttpContext) {
if (error instanceof Exception && error.code === 'E_FILE_NAME_MISSING_EXTENSION') {
return ctx.response.status(error.status).send({
message: error.message,
code: error.code,
})
}
if (error instanceof Exception && error.code === 'E_ENTRY_NAME_NOT_CHANGED') {
return ctx.response.status(error.status).send({
message: error.message,
code: error.code,
})
}
if (error instanceof Exception && error.code === 'E_ENTRY_ALREADY_EXISTS') {
return ctx.response.status(error.status).send({
message: error.message,
code: error.code,
})
}
if (error instanceof Exception && error.code === 'E_DIRECTORY_ALREADY_EXISTS') {
return ctx.response.status(error.status).send({
message: error.message,
code: error.code,
})
}
if (error instanceof Exception && error.code === 'E_FILE_NOT_FOUND') {
return ctx.response.status(error.status).send({
message: error.message,
code: error.code,
})
}
if (error instanceof Exception && error.code === 'E_DIR_NOT_FOUND') {
return ctx.response.status(error.status).send({
message: error.message,
code: error.code,
})
}
return super.handle(error, ctx)
}
/**
* The method is used to report error to the logging service or
* the third party error monitoring service.
*
* @note You should not attempt to send a response from this method.
*/
async report(error: unknown, ctx: HttpContext) {
return super.report(error, ctx)
}
}
+8
View File
@@ -0,0 +1,8 @@
interface Message422 {
message: string
rule: string
field: string
}
export interface Error422 {
errors: Message422[]
}
+25
View File
@@ -0,0 +1,25 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import type { Authenticators } from '@adonisjs/auth/types'
/**
* Auth middleware is used authenticate HTTP requests and deny
* access to unauthenticated users.
*/
export default class AuthMiddleware {
/**
* The URL to redirect to, when authentication fails
*/
redirectTo = '/login'
async handle(
ctx: HttpContext,
next: NextFn,
options: {
guards?: (keyof Authenticators)[]
} = {}
) {
await ctx.auth.authenticateUsing(options.guards, { loginRoute: this.redirectTo })
return next()
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Logger } from '@adonisjs/core/logger'
import { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* The container bindings middleware binds classes to their request
* specific value using the container resolver.
*
* - We bind "HttpContext" class to the "ctx" object
* - And bind "Logger" class to the "ctx.logger" object
*/
export default class ContainerBindingsMiddleware {
handle(ctx: HttpContext, next: NextFn) {
ctx.containerResolver.bindValue(HttpContext, ctx)
ctx.containerResolver.bindValue(Logger, ctx.logger)
return next()
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* Updating the "Accept" header to always accept "application/json" response
* from the server. This will force the internals of the framework like
* validator errors or auth errors to return a JSON response.
*/
export default class ForceJsonResponseMiddleware {
async handle({ request }: HttpContext, next: NextFn) {
const headers = request.headers()
headers.accept = 'application/json'
return next()
}
}
+43
View File
@@ -0,0 +1,43 @@
import { policies } from '#policies/main'
import * as abilities from '#abilities/main'
import { Bouncer } from '@adonisjs/bouncer'
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* Init bouncer middleware is used to create a bouncer instance
* during an HTTP request
*/
export default class InitializeBouncerMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
/**
* Create bouncer instance for the ongoing HTTP request.
* We will pull the user from the HTTP context.
*/
ctx.bouncer = new Bouncer(
() => ctx.auth.user || null,
abilities,
policies
).setContainerResolver(ctx.containerResolver)
/**
* Share bouncer helpers with Edge templates.
*/
// if ('view' in ctx) {
// ctx.view?.share(ctx.bouncer.edgeHelpers)
// }
return next()
}
}
declare module '@adonisjs/core/http' {
export interface HttpContext {
bouncer: Bouncer<
Exclude<HttpContext['auth']['user'], undefined>,
typeof abilities,
typeof policies
>
}
}
+17
View File
@@ -0,0 +1,17 @@
/*
|--------------------------------------------------------------------------
| Bouncer policies
|--------------------------------------------------------------------------
|
| You may define a collection of policies inside this file and pre-register
| them when creating a new bouncer instance.
|
| Pre-registered policies and abilities can be referenced as a string by their
| name. Also they are must if want to perform authorization inside Edge
| templates.
|
*/
export const policies = {
AdminsPolicy: () => import('#base_modules/Admin/policy/admin_policy'),
}
+96
View File
@@ -0,0 +1,96 @@
import db from '@adonisjs/lucid/services/db'
import { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'
export async function getDatabaseTables(): Promise<string[]> {
const result = await db
.from('information_schema.tables')
.select('table_name')
.where('table_schema', 'public')
.whereNotIn('table_name', [
'adonis_schema',
'adonis_schema_versions',
'auth_access_tokens',
'admins',
'permissions',
'roles',
'admin_roles',
'role_permissions',
'migrations',
'migrations_lock',
'pg_stat_statements',
'pg_stat_activity',
'pg_type',
'pg_enum',
'pg_class',
'pg_attribute',
'pg_index',
'pg_namespace',
'pg_tables',
])
.whereRaw(`table_name NOT LIKE 'pg_%'`)
.whereRaw(`table_name NOT LIKE 'sql_%'`)
return result.map((row) => row.table_name)
}
export async function getColumnsForTable(table: string) {
const rows = await db
.from('information_schema.columns')
.select('column_name', 'data_type', 'is_nullable')
.where('table_schema', 'public')
.andWhere('table_name', table)
return rows.map((row) => {
const col = {
...row,
isSpecial: ['created_at', 'updated_at', 'deleted_at'].includes(row.column_name),
}
return col
})
}
export async function getForeignKeysReferencing(table: string) {
return await db
.from('information_schema.columns')
.select('table_name', 'column_name')
.where('column_name', `${table}_id`)
.andWhere('table_schema', 'public')
}
export async function searchableColumns(table: string) {
const columns = await getColumnsForTable(table)
return {
string: columns
.filter((col) =>
['character varying', 'text', 'varchar', 'char', 'uuid'].includes(col.data_type)
)
.map((col) => col.column_name),
number: columns
.filter((col) => ['integer', 'int4', 'int8', 'numeric', 'bigint'].includes(col.data_type))
.map((col) => col.column_name),
}
}
export function addSearchFilter<T extends ModelQueryBuilderContract<any>>(
query: T,
columns: { string: string[]; number: string[] },
search: string
) {
const keywords = search.trim().split(/\s+/)
query.where((builder) => {
for (const keyword of keywords) {
builder.orWhere((subBuilder) => {
for (const column of columns.string) {
subBuilder.orWhereILike(column, `%${keyword}%`)
}
})
}
if (!Number.isNaN(Number(search))) {
for (const column of columns.number) {
builder.orWhere(column, Number(search))
}
}
})
}
+58
View File
@@ -0,0 +1,58 @@
import Admins from '#base_modules/Admin/model/admin'
import { readdirSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import db from '@adonisjs/lucid/services/db'
interface PermissionItem {
slug: string
name: string
}
export async function getAdminPermissionSlugs(admin: Admins): Promise<Set<string>> {
await admin.load('roles')
const roles = admin.roles.map((role) => role.id)
const permissions = await db
.from('role_permissions')
.whereIn('role_id', roles)
.select('permission_slug')
const slugs = new Set<string>()
permissions.map((perm) => slugs.add(perm.permission_slug))
return slugs
}
export async function getAllPermissions(): Promise<PermissionItem[]> {
const appRoot = process.cwd()
const baseModulesPath = join(appRoot, 'app', 'base_modules')
const modulesPath = join(appRoot, 'modules')
const result: PermissionItem[] = []
const loadPermissionsFromDir = async (dir: string) => {
if (!existsSync(dir)) return
const modules = readdirSync(dir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
for (const moduleName of modules) {
const configPath = join(dir, moduleName, 'config', 'permissions.ts')
if (existsSync(configPath)) {
try {
const fileUrl = pathToFileURL(configPath)
const mod = await import(fileUrl.href)
const perms = mod.default || []
if (Array.isArray(perms)) result.push(...perms)
} catch (err) {
console.error(`Failed to load permissions for module "${moduleName}":`, err)
}
}
}
}
await loadPermissionsFromDir(baseModulesPath)
await loadPermissionsFromDir(modulesPath)
return result
}
+18
View File
@@ -0,0 +1,18 @@
const typeMap: Record<string, any> = {
number: { type: 'integer', format: 'int64' },
string: { type: 'string' },
Date: { type: 'string', format: 'date-time' },
boolean: { type: 'boolean' },
}
export async function modelToSchema(model: any) {
const properties: Record<string, any> = {}
for (const [key, meta] of model.$columnsDefinitions.entries()) {
const tsType = meta.meta?.designType?.name || 'string'
const mapped = typeMap[tsType] ?? { type: 'string' }
properties[meta?.serializeAs || key] = {
...mapped,
description: key.replace(/([A-Z])/g, ' $1').trim(),
}
}
return JSON.stringify(properties)
}
+103
View File
@@ -0,0 +1,103 @@
import type { HttpContext } from '@adonisjs/core/http'
import {{pascalCaseName}} from '#modules/{{capName}}/model/{{snakeCaseName}}'
import { create{{pascalCaseName}}ApiValidator } from '#modules/{{capName}}/api/validators/{{snakeCaseName}}_create_api_validator'
import { update{{pascalCaseName}}ApiValidator } from '#modules/{{capName}}/api/validators/{{snakeCaseName}}_update_api_validator'
export default class {{pascalCaseName}}ApiController {
/**
* GET /api/{{name}}
*/
/**
* @index
* @operationId get{{pascalCaseName}}
* @description Returns array of records
* @responseBody 200 - [{{{swaggerModel}}}]
* @responseHeader 200 - X-pages - A description of the header - @example(test)
*/
async index({ response }: HttpContext) {
const result = await {{pascalCaseName}}.all()
return response.ok(result)
}
/**
* GET /api/{{name}}/:id
*/
/**
* @show
* @operationId show{{pascalCaseName}}
* @description Returns record
* @responseBody 200 - {{{swaggerModel}}}
* @responseBody 404 - { "message": "Not found" }
* @responseHeader 200 - X-pages - A description of the header - @example(test)
*/
async show({ params, response }: HttpContext) {
const record = await {{pascalCaseName}}.find(params.id)
if (!record) return response.notFound({ message: 'Not found' })
return response.ok(record)
}
/**
* POST /api/{{name}}
*/
/**
* @store
* @operationId store{{pascalCaseName}}
* @description Creates and Returns record
* @requestBody <create{{pascalCaseName}}ApiValidator>
* @responseBody 200 - {{{swaggerModel}}}
* @responseBody 404 - { "message": "Not found" }
* @responseBody 422 - <Error422> - Returns an error if the request body fails validation.
* @responseHeader 200 - X-pages - A description of the header - @example(test)
*/
async store({ request, response }: HttpContext) {
const payload = await create{{pascalCaseName}}ApiValidator.validate(request.all())
const record = await {{pascalCaseName}}.create(payload)
return response.created(record)
}
/**
* PUT /api/{{name}}/:id
*/
/**
* @update
* @operationId update{{pascalCaseName}}
* @description Updates and Returns record
* @requestBody <update{{pascalCaseName}}ApiValidator>
* @responseBody 200 - {{{swaggerModel}}}
* @responseBody 404 - { "message": "Not found" }
* @responseBody 422 - <Error422> - Returns an error if the request body fails validation.
* @responseHeader 200 - X-pages - A description of the header - @example(test)
*/
async update({ params, request, response }: HttpContext) {
const record = await {{pascalCaseName}}.find(params.id)
if (!record) return response.notFound({ message: 'Not found' })
const payload = await update{{pascalCaseName}}ApiValidator.validate(request.all())
record.merge(payload)
await record.save()
return response.ok(record)
}
/**
* DELETE /api/{{name}}/:id
*/
/**
* @destroy
* @operationId destroy{{pascalCaseName}}
* @description Destroys record
* @paramPath id - The ID of the source - @required
* @responseBody 200 - { "message": "Deleted" }
* @responseHeader 200 - X-pages - A description of the header - @example(test)
*/
async destroy({ params, response }: HttpContext) {
const record = await {{pascalCaseName}}.find(params.id)
if (!record) return response.notFound({ message: 'Not found' })
await record.delete()
return response.ok({ message: 'Deleted' })
}
}
+12
View File
@@ -0,0 +1,12 @@
import router from '@adonisjs/core/services/router'
const {{pascalCaseName}}ApiController = () => import('#modules/{{moduleName}}/api/controllers/{{snakeCaseName}}_api_controller')
router
.group(() => {
router.get('/', [{{pascalCaseName}}ApiController, 'index'])
router.get('/:id', [{{pascalCaseName}}ApiController, 'show'])
router.post('/', [{{pascalCaseName}}ApiController, 'store'])
router.put('/:id', [{{pascalCaseName}}ApiController, 'update'])
router.delete('/:id', [{{pascalCaseName}}ApiController, 'destroy'])
})
.prefix('/api/{{snakeCaseName}}')
+10
View File
@@ -0,0 +1,10 @@
import vine from '@vinejs/vine'
export const {{functionName}} = vine.compile(
vine.object({
id: vine.number().optional(),
{{#each fields}}
{{name}}: vine.{{validatorType}}(){{#if isEmail}}.trim().email(){{/if}}{{#if isPassword}}.trim().minLength(8){{/if}}{{#if needsTrim}}.trim(){{/if}}{{#unless isRequired}}.optional(){{/unless}},
{{/each}}
})
)
+81
View File
@@ -0,0 +1,81 @@
import { HttpContext } from '@adonisjs/core/http'
import { create{{capName}}Validator } from '#modules/{{capName}}/validators/{{snakeName}}_create_validator'
import { update{{capName}}Validator } from '#modules/{{capName}}/validators/{{snakeName}}_update_validator'
import { addSearchFilter, searchableColumns } from '#services/database_inspector'
import {{pascalCaseName}} from '#modules/{{capName}}/model/{{snakeName}}'
import {{pascalCaseName}}Policy from '#modules/{{capName}}/policy/{{snakeName}}_policy'
export default class {{pascalCaseName}}Controller {
public async index({ request, response, bouncer }: HttpContext) {
await bouncer.with({{pascalCaseName}}Policy).authorize('view{{pascalCaseName}}List')
const {
page = 1,
perPage = 10,
search = null,
sortBy = 'id',
sortOrder = 'desc',
} = request.all()
const query = {{pascalCaseName}}.query()
if (search) {
const columns = {{pascalCaseName}}.searchable || (await searchableColumns('{{tableName}}'))
addSearchFilter(query, columns, search)
}
const result = await query.orderBy(sortBy, sortOrder).paginate(page, perPage)
return response.ok(result.toJSON())
}
public async store({ request, response, bouncer }: HttpContext) {
await bouncer.with({{pascalCaseName}}Policy).authorize('create{{pascalCaseName}}')
const data = await create{{capName}}Validator.validate(request.all())
const record = await {{pascalCaseName}}.create(data)
if (record) {
return response.created(record.toJSON())
}
return response.badRequest({ message: 'Cannot create.' })
}
public async show({ request, response, bouncer }: HttpContext) {
const { id } = request.params()
if (!id) return response.notFound({ message: 'Not found.' })
const record = await {{pascalCaseName}}.find(id)
if (!record) return response.notFound({ message: 'Not found.' })
await bouncer.with({{pascalCaseName}}Policy).authorize('view{{pascalCaseName}}')
return response.ok(record.toJSON())
}
public async update({ request, response, bouncer }: HttpContext) {
const { id } = request.params()
if (!id) return response.notFound({ message: 'Not found.' })
const data = await update{{capName}}Validator.validate(request.all())
const record = await {{pascalCaseName}}.find(id)
if (!record) return response.notFound({ message: 'Not found.' })
await bouncer.with({{pascalCaseName}}Policy).authorize('update{{pascalCaseName}}')
record.merge(data)
await record.save()
return response.ok(record.toJSON())
}
public async destroy({ request, response, bouncer }: HttpContext) {
const { id } = request.params()
if (!id) return response.notFound({ message: 'Not found.' })
const record = await {{pascalCaseName}}.find(id)
if (!record) return response.notFound({ message: 'Not found.' })
await bouncer.with({{pascalCaseName}}Policy).authorize('delete{{pascalCaseName}}')
await {{pascalCaseName}}.query().where('id', id).delete()
return response.ok({ message: 'Deleted.' })
}
}
+40
View File
@@ -0,0 +1,40 @@
import { BaseModel, column{{#if imports}}, {{imports}}{{/if}} } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import { DateTime } from 'luxon'
{{#if hasPassword}}
import hash from '@adonisjs/core/services/hash'
import { beforeSave } from '@adonisjs/lucid/orm'
{{/if}}
export default class {{pascalCaseName}} extends BaseModel {
static searchable = {
string: [{{#each columns}}{{#if (and (eq validatorType "string") (not isPassword))}}'{{camelCase}}'{{#unless @last}}, {{/unless}}{{/if}}{{/each}}],
number: [{{#each columns}}{{#if (eq validatorType "number")}}'{{camelCase}}'{{#unless @last}}, {{/unless}}{{/if}}{{/each}}],
}
{{#each columns}}
{{#if isPrimary}}
@column({ isPrimary: true, columnName: '{{name}}', serializeAs: '{{name}}' })
declare {{camelCase}}: {{type}}
{{else if isDateTime}}
@column.dateTime({ autoCreate: true{{#if (eq name "updated_at")}}, autoUpdate: true{{/if}}, columnName: '{{name}}', serializeAs: '{{name}}' })
declare {{camelCase}}: DateTime | null
{{else}}
@column({ columnName: '{{name}}', serializeAs: '{{name}}' })
declare {{camelCase}}: {{type}}
{{/if}}
{{/each}}
{{#each relationships}}
{{{this}}}
{{/each}}
{{#if hasPassword}}
@beforeSave()
static async hashPassword(model: {{className}}) {
if (model.$dirty.password) {
model.password = await hash.make(model.password)
}
}
{{/if}}
}
+36
View File
@@ -0,0 +1,36 @@
import { BasePolicy } from '@adonisjs/bouncer'
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
import { getAdminPermissionSlugs } from '#services/permission_service'
import Admins from '#base_modules/Admin/model/admin'
export default class {{pascalCaseName}}Policy extends BasePolicy {
async view{{pascalCaseName}}List(user: Admins): Promise<AuthorizerResponse> {
if (!user) return false
const perms = await getAdminPermissionSlugs(await user)
return perms.has('{{name}}s.view')
}
async view{{pascalCaseName}}(user: Admins): Promise<AuthorizerResponse> {
if (!user) return false
const perms = await getAdminPermissionSlugs(await user)
return perms.has('{{name}}s.view')
}
async create{{pascalCaseName}}(user: Admins): Promise<AuthorizerResponse> {
if (!user) return false
const perms = await getAdminPermissionSlugs(await user)
return perms.has('{{name}}s.create')
}
async update{{pascalCaseName}}(user: Admins): Promise<AuthorizerResponse> {
if (!user) return false
const perms = await getAdminPermissionSlugs(await user)
return perms.has('{{name}}s.update')
}
async delete{{pascalCaseName}}(user: Admins): Promise<AuthorizerResponse> {
if (!user) return false
const perms = await getAdminPermissionSlugs(await user)
return perms.has('{{name}}s.delete')
}
}
+9
View File
@@ -0,0 +1,9 @@
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
const {{name}}Controller = () => import('#modules/{{capName}}/controller/{{snakeCaseName}}_controller')
router.resource('/{{routeName}}', {{name}}Controller).apiOnly().use('*', [middleware.auth()])
// If you prefer only mutating actions protected, use this instead:
// router.resource('/{{routeName}}', {{name}}Controller).apiOnly().use(['store', 'update', 'destroy'], [middleware.auth()])
@@ -0,0 +1,24 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
test.group('API /api/{{snakeCaseName}}', (group) => {
group.each.setup(async () => {
await db.beginGlobalTransaction()
return () => db.rollbackGlobalTransaction()
})
test('GET /api/{{snakeCaseName}} -> list', async ({ client }) => {
const res = await client.get('/api/{{snakeCaseName}}')
res.assertStatus(200)
})
test('POST /api/{{snakeCaseName}} -> create', async ({ client }) => {
const payload = {
{{#each createFields}}
{{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}}
{{/each}}
}
const res = await client.post('/api/{{snakeCaseName}}').json(payload)
res.assertStatus(201)
})
})
@@ -0,0 +1,106 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import { loginAsAdmin } from '../../utils/auth.js'
test.group('{{pascalCaseName}}Controller', (group) => {
group.each.setup(async () => {
await db.beginGlobalTransaction()
return () => db.rollbackGlobalTransaction()
})
test('index: lists paginated {{snakeCaseName}}', async ({ client, assert }) => {
const token = await loginAsAdmin(client)
const res = await client.get('/{{snakeCaseName}}').header('Authorization', `Bearer ${token}`)
res.assertStatus(200)
assert.exists(res.body().meta, 'pagination meta exists')
})
test('store: creates a new {{snakeCaseName}}', async ({ client, assert }) => {
const token = await loginAsAdmin(client)
const payload = {
{{#each createFields}}
{{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}}
{{/each}}
}
const res = await client
.post('/{{snakeCaseName}}')
.header('Authorization', `Bearer ${token}`)
.json(payload)
res.assertStatus(201)
const body = res.body()
{{#each createFields}}
assert.property(body, '{{column_name}}')
{{/each}}
})
test('show: returns one {{snakeCaseName}} by id', async ({ client }) => {
const token = await loginAsAdmin(client)
const created = await db
.table('{{tableName}}')
.insert(
{{{insertJson}}}
)
.returning('id')
const id = Array.isArray(created) ? (created[0]?.id ?? created[0]) : created
const res = await client
.get(`/{{snakeCaseName}}/${id}`)
.header('Authorization', `Bearer ${token}`)
res.assertStatus(200)
})
test('update: updates {{snakeCaseName}}', async ({ client, assert }) => {
const token = await loginAsAdmin(client)
const created = await db
.table('{{tableName}}')
.insert(
{{{insertJson}}}
)
.returning('id')
const id = Array.isArray(created) ? (created[0]?.id ?? created[0]) : created
const patch = {
{{#each updateFields}}
{{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}}
{{/each}}
}
const res = await client
.put(`/{{snakeCaseName}}/${id}`)
.header('Authorization', `Bearer ${token}`)
.json(patch)
res.assertStatus(200)
const body = res.body()
{{#each updateFields}}
assert.property(body, '{{column_name}}')
{{/each}}
})
test('destroy: deletes {{snakeCaseName}}', async ({ client }) => {
const token = await loginAsAdmin(client)
const created = await db
.table('{{tableName}}')
.insert(
{{{insertJson}}}
)
.returning('id')
const id = Array.isArray(created) ? (created[0]?.id ?? created[0]) : created
const res = await client
.delete(`/{{snakeCaseName}}/${id}`)
.header('Authorization', `Bearer ${token}`)
res.assertStatus(200)
})
})
+10
View File
@@ -0,0 +1,10 @@
import vine from '@vinejs/vine'
export const {{functionName}} = vine.compile(
vine.object({
id: vine.number().optional(),
{{#each fields}}
{{name}}: vine.{{validatorType}}(){{#if isEmail}}.trim().email(){{/if}}{{#if isPassword}}.trim().minLength(8){{/if}}{{#if needsTrim}}.trim(){{/if}}{{#unless isRequired}}.optional(){{/unless}},
{{/each}}
})
)
+9
View File
@@ -0,0 +1,9 @@
export const MODULE_NAME = '{{name}}'
export const MODULE_NAME_LABEL = MODULE_NAME.replace(/^./, (char) => char.toUpperCase()); // prettier-ignore
export const MODULE_API_ENDPOINT = '/' + MODULE_NAME
export const MODULE_ROOT_PATH = '/' + MODULE_NAME
export const MODULE_PERMISSION_ROOT = MODULE_NAME + 's'
export const MODULE_PERMISSION_CREATE = MODULE_PERMISSION_ROOT + '.create'
export const MODULE_PERMISSION_UPDATE = MODULE_PERMISSION_ROOT + '.update'
export const MODULE_PERMISSION_DELETE = MODULE_PERMISSION_ROOT + '.delete'
export const MODULE_PERMISSION_VIEW = MODULE_PERMISSION_ROOT + '.view'
+1
View File
@@ -0,0 +1 @@
{{detaTable}}
@@ -0,0 +1,7 @@
import type { IsoDateString } from '@crux-kit/types';
export interface Entity {
{{#each fields}}
{{name}}: {{type}}
{{/each}}
}
+1
View File
@@ -0,0 +1 @@
{{form}}
@@ -0,0 +1,23 @@
import { VineMultipartFile } from '@adonisjs/core/vine'
import { OptionalModifier } from '@vinejs/vine/schema/base/literal'
import vine from '@vinejs/vine'
/**
* Creates a reusable upload file schema that requires either:
* - `ref` (reference to an existing file), or
* - `file` (new uploaded file).
*
* At least one field must be present. When `ref` is missing, `file`
* becomes required. When `file` is missing, `ref` becomes required.
*
* @param fileScheme Custom optional file validation schema.
* @returns Vine object schema with mutually dependent `ref` and `file` fields.
*/
export default function createFileInputSchema(
fileScheme: OptionalModifier<VineMultipartFile> = vine.file({ size: '5mb' }).optional()
) {
return vine.object({
ref: vine.string().optional().requiredIfMissing('file'),
file: fileScheme.requiredIfMissing('ref'),
})
}
+47
View File
@@ -0,0 +1,47 @@
/*
|--------------------------------------------------------------------------
| Ace entry point
|--------------------------------------------------------------------------
|
| The "console.ts" file is the entrypoint for booting the AdonisJS
| command-line framework and executing commands.
|
| Commands do not boot the application, unless the currently running command
| has "options.startApp" flag set to true.
|
*/
import 'reflect-metadata'
import { Ignitor, prettyPrintError } from '@adonisjs/core'
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.ace()
.handle(process.argv.splice(2))
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})
+45
View File
@@ -0,0 +1,45 @@
/*
|--------------------------------------------------------------------------
| HTTP server entrypoint
|--------------------------------------------------------------------------
|
| The "server.ts" file is the entrypoint for starting the AdonisJS HTTP
| server. Either you can run this file directly or use the "serve"
| command to run this file and monitor file changes
|
*/
import 'reflect-metadata'
import { Ignitor, prettyPrintError } from '@adonisjs/core'
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.httpServer()
.start()
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})
+62
View File
@@ -0,0 +1,62 @@
/*
|--------------------------------------------------------------------------
| Test runner entrypoint
|--------------------------------------------------------------------------
|
| The "test.ts" file is the entrypoint for running tests using Japa.
|
| Either you can run this file directly or use the "test"
| command to run this file and monitor file changes.
|
*/
process.env.NODE_ENV = 'test'
import 'reflect-metadata'
import { Ignitor, prettyPrintError } from '@adonisjs/core'
import { configure, processCLIArgs, run } from '@japa/runner'
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.testRunner()
.configure(async (app) => {
const { runnerHooks, ...config } = await import('../tests/bootstrap.js')
processCLIArgs(process.argv.splice(2))
configure({
...app.rcFile.tests,
...config,
...{
setup: runnerHooks.setup,
teardown: runnerHooks.teardown.concat([() => app.terminate()]),
},
})
})
.run(() => run())
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})
+40
View File
@@ -0,0 +1,40 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { Secret } from '@adonisjs/core/helpers'
import { defineConfig } from '@adonisjs/core/http'
/**
* The app key is used for encrypting cookies, generating signed URLs,
* and by the "encryption" module.
*
* The encryption module will fail to decrypt data if the key is lost or
* changed. Therefore it is recommended to keep the app key secure.
*/
export const appKey = new Secret(env.get('APP_KEY'))
/**
* The configuration settings used by the HTTP server
*/
export const http = defineConfig({
generateRequestId: true,
allowMethodSpoofing: false,
/**
* Enabling async local storage will let you access HTTP context
* from anywhere inside your application.
*/
useAsyncLocalStorage: false,
/**
* Manage cookies configuration. The settings for the session id cookie are
* defined inside the "config/session.ts" file.
*/
cookie: {
domain: '',
path: '/',
maxAge: '2h',
httpOnly: true,
secure: app.inProduction,
sameSite: 'lax',
},
})
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from '@adonisjs/auth'
import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens'
import type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types'
const authConfig = defineConfig({
default: 'api',
guards: {
api: tokensGuard({
provider: tokensUserProvider({
tokens: 'accessTokens',
model: () => import('../app/base_modules/Admin/model/admin.js'),
}),
}),
},
})
export default authConfig
/**
* Inferring types from the configured auth
* guards.
*/
declare module '@adonisjs/auth/types' {
export interface Authenticators extends InferAuthenticators<typeof authConfig> {}
}
declare module '@adonisjs/core/types' {
interface EventsList extends InferAuthEvents<Authenticators> {}
}
+55
View File
@@ -0,0 +1,55 @@
import { defineConfig } from '@adonisjs/core/bodyparser'
const bodyParserConfig = defineConfig({
/**
* The bodyparser middleware will parse the request body
* for the following HTTP methods.
*/
allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
/**
* Config for the "application/x-www-form-urlencoded"
* content-type parser
*/
form: {
convertEmptyStringsToNull: true,
types: ['application/x-www-form-urlencoded'],
},
/**
* Config for the JSON parser
*/
json: {
convertEmptyStringsToNull: true,
types: [
'application/json',
'application/json-patch+json',
'application/vnd.api+json',
'application/csp-report',
],
},
/**
* Config for the "multipart/form-data" content-type parser.
* File uploads are handled by the multipart parser.
*/
multipart: {
/**
* Enabling auto process allows bodyparser middleware to
* move all uploaded files inside the tmp folder of your
* operating system
*/
autoProcess: true,
convertEmptyStringsToNull: true,
processManually: [],
/**
* Maximum limit of data to parse including all files
* and fields
*/
limit: '20mb',
types: ['multipart/form-data'],
},
})
export default bodyParserConfig
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from '@adonisjs/cors'
/**
* Configuration options to tweak the CORS policy. The following
* options are documented on the official documentation website.
*
* https://docs.adonisjs.com/guides/security/cors
*/
const corsConfig = defineConfig({
enabled: true,
origin: true,
methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'],
headers: true,
exposeHeaders: [],
credentials: true,
maxAge: 90,
})
export default corsConfig
+24
View File
@@ -0,0 +1,24 @@
import env from '#start/env'
import { defineConfig } from '@adonisjs/lucid'
const dbConfig = defineConfig({
connection: 'postgres',
connections: {
postgres: {
client: 'pg',
connection: {
host: env.get('DB_HOST'),
port: env.get('DB_PORT'),
user: env.get('DB_USER'),
password: env.get('DB_PASSWORD'),
database: env.get('DB_DATABASE'),
},
migrations: {
naturalSort: true,
paths: ['database/migrations'],
},
},
},
})
export default dbConfig
+34
View File
@@ -0,0 +1,34 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, services } from '@adonisjs/drive'
const driveConfig = defineConfig({
default: env.get('DRIVE_DISK'),
services: {
fs: services.fs({
location: app.makePath('public/uploads'),
serveFiles: true,
routeBasePath: '/uploads',
visibility: 'public',
}),
s3: services.s3({
credentials: {
accessKeyId: env.get('AWS_ACCESS_KEY_ID', 'minioadmin'),
secretAccessKey: env.get('AWS_SECRET_ACCESS_KEY', 'minioadmin'),
},
region: env.get('AWS_REGION', 'us-east-1'),
bucket: env.get('S3_BUCKET', 'crux-drive'),
endpoint: env.get('S3_ENDPOINT'),
forcePathStyle: true,
visibility: 'public',
supportsACL: false,
cdnUrl: env.get('S3_CDN_URL'),
}),
},
})
export default driveConfig
declare module '@adonisjs/drive/types' {
export interface DriveDisks extends InferDriveDisks<typeof driveConfig> {}
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig, drivers } from '@adonisjs/core/hash'
const hashConfig = defineConfig({
default: 'scrypt',
list: {
scrypt: drivers.scrypt({
cost: 16384,
blockSize: 8,
parallelization: 1,
maxMemory: 33554432,
}),
},
})
export default hashConfig
/**
* Inferring types for the list of hashers you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface HashersList extends InferHashers<typeof hashConfig> {}
}
+34
View File
@@ -0,0 +1,34 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, targets } from '@adonisjs/core/logger'
const loggerConfig = defineConfig({
default: 'app',
/**
* The loggers object can be used to define multiple loggers.
* By default, we configure only one logger (named "app").
*/
loggers: {
app: {
enabled: true,
name: env.get('APP_NAME'),
level: env.get('LOG_LEVEL'),
transport: {
targets: targets()
.pushIf(!app.inProduction, targets.pretty())
.pushIf(app.inProduction, targets.file({ destination: 1 }))
.toArray(),
},
},
},
})
export default loggerConfig
/**
* Inferring types for the list of loggers you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface LoggersList extends InferLoggers<typeof loggerConfig> {}
}
+54
View File
@@ -0,0 +1,54 @@
// for AdonisJS v6
import path from 'node:path'
import url from 'node:url'
// ---
export default {
// path: __dirname + "/../", for AdonisJS v5
path: path.dirname(url.fileURLToPath(import.meta.url)) + '/../', // for AdonisJS v6
title: 'Foo', // use info instead
version: '1.0.0', // use info instead
description: '', // use info instead
tagIndex: 2,
productionEnv: 'production', // optional
info: {
title: 'Crux',
version: '1.0.0',
description: '',
},
snakeCase: true,
debug: true, // set to true, to get some useful debug output
ignore: [
'/swagger',
'/docs',
'/auth*',
'/admin*',
'/permission*',
'/role*',
'/makeModule*',
'/testing*',
],
preferredPutPatch: 'PUT', // if PUT/PATCH are provided for the same route, prefer PUT
common: {
parameters: {}, // OpenAPI conform parameters that are commonly used
headers: {}, // OpenAPI conform headers that are commonly used
},
securitySchemes: {}, // optional
authMiddlewares: ['auth', 'auth:api'], // optional
defaultSecurityScheme: 'BearerAuth', // optional
persistAuthorization: true, // persist authorization between reloads on the swagger page
showFullPath: false, // the path displayed after endpoint summary,
// modulesPath: {
// models: ['models', 'api/models'],
// interfaces: ['interfaces', 'api/interfaces'],
// validators: ['validators', 'api/validators'],
// types: ['types', 'api/types'],
// },
modulesPath: {
models: ['api/models'],
interfaces: ['api/interfaces'],
validators: ['api/validators'],
types: ['api/types'],
},
}
@@ -0,0 +1,29 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'admins'
async up() {
await this.schema.raw('DROP TYPE IF EXISTS admin_status_enum')
this.schema.createTable(this.tableName, (table) => {
table.increments('id').notNullable()
table.string('email').notNullable().unique()
table.string('password').notNullable()
table.string('first_name').nullable()
table.string('last_name').nullable()
table
.enu('status', ['active', 'inactive'], {
useNative: true,
enumName: 'admin_status_enum',
})
.notNullable()
.defaultTo('active')
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -0,0 +1,31 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'auth_access_tokens'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table
.integer('tokenable_id')
.notNullable()
.unsigned()
.references('id')
.inTable('admins')
.onDelete('CASCADE')
table.string('type').notNullable()
table.string('name').nullable()
table.string('hash').notNullable()
table.text('abilities').notNullable()
table.timestamp('created_at')
table.timestamp('updated_at')
table.timestamp('last_used_at').nullable()
table.timestamp('expires_at').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -0,0 +1,18 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'roles'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('name').notNullable().unique()
table.timestamp('created_at')
table.timestamp('updated_at')
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -0,0 +1,19 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'permissions'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('name').notNullable().unique()
table.string('slug').notNullable().unique()
table.timestamp('created_at')
table.timestamp('updated_at')
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -0,0 +1,20 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'admin_roles'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE')
table.integer('admin_id').unsigned().references('id').inTable('admins').onDelete('CASCADE')
table.unique(['role_id', 'admin_id'])
table.timestamp('created_at')
table.timestamp('updated_at')
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -0,0 +1,20 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'role_permissions'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE')
table.string('permission_slug').unsigned()
table.unique(['role_id', 'permission_slug'])
table.timestamp('created_at')
table.timestamp('updated_at')
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
+17
View File
@@ -0,0 +1,17 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'testings'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('name').notNullable().unique()
table.timestamp('created_at')
table.timestamp('updated_at')
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

Some files were not shown because too many files have changed in this diff Show More