Add initial project files
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import type { Entry } from '#base_modules/FileManager/types/entries'
|
||||
|
||||
export type EntryFilter = 'media' | 'archives' | 'documents' | 'all'
|
||||
export type EntrySortBy = 'name' | 'size' | 'type' | 'date'
|
||||
export type EntrySortOrder = 'asc' | 'desc'
|
||||
|
||||
export interface EntryListParams {
|
||||
filterBy?: EntryFilter
|
||||
sortBy?: EntrySortBy
|
||||
sortOrder?: EntrySortOrder
|
||||
maxResults?: number
|
||||
paginationToken?: string
|
||||
}
|
||||
|
||||
export interface EntryListResult {
|
||||
data: Entry[]
|
||||
meta: {
|
||||
nextPaginationToken: string | null
|
||||
}
|
||||
}
|
||||
|
||||
interface PaginationTokenPayload {
|
||||
offset: number
|
||||
}
|
||||
|
||||
const FILTER_EXTENSIONS: Record<Exclude<EntryFilter, 'all'>, string[]> = {
|
||||
media: ['jpg', 'jpeg', 'png', 'gif', 'mp4', 'mp3', 'wav'],
|
||||
archives: ['zip', 'rar', '7z'],
|
||||
documents: ['pdf', 'doc', 'docx', 'txt'],
|
||||
}
|
||||
|
||||
export default class EntryListService {
|
||||
public apply(entries: Entry[], params: EntryListParams = {}): EntryListResult {
|
||||
const {
|
||||
filterBy = 'all',
|
||||
sortBy = 'name',
|
||||
sortOrder = 'asc',
|
||||
maxResults = 25,
|
||||
paginationToken,
|
||||
} = params
|
||||
|
||||
const limit = Math.min(Math.max(maxResults, 1), 100)
|
||||
const decoded = this.decodeToken(paginationToken)
|
||||
const offset = decoded?.offset ?? 0
|
||||
|
||||
const result = this.sortEntries(this.filterEntries(entries, filterBy), sortBy, sortOrder)
|
||||
const data = result.slice(offset, offset + limit)
|
||||
const nextOffset = offset + limit
|
||||
const nextPaginationToken =
|
||||
nextOffset < result.length
|
||||
? this.encodeToken({
|
||||
offset: nextOffset,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
filterBy,
|
||||
})
|
||||
: null
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
nextPaginationToken,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private filterEntries(entries: Entry[], filterBy: EntryFilter): Entry[] {
|
||||
if (filterBy === 'all') return entries
|
||||
|
||||
return entries.filter((entry) => {
|
||||
if (entry.type === 'folder') return true
|
||||
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (!ext) return false
|
||||
|
||||
return FILTER_EXTENSIONS[filterBy].includes(ext)
|
||||
})
|
||||
}
|
||||
|
||||
private sortEntries(entries: Entry[], sortBy: EntrySortBy, sortOrder: EntrySortOrder): Entry[] {
|
||||
return [...entries].sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === 'folder' ? -1 : 1
|
||||
}
|
||||
|
||||
let valA: string | number
|
||||
let valB: string | number
|
||||
|
||||
switch (sortBy) {
|
||||
case 'size':
|
||||
valA = a.type === 'file' ? (a.size ?? 0) : 0
|
||||
valB = b.type === 'file' ? (b.size ?? 0) : 0
|
||||
break
|
||||
|
||||
case 'date':
|
||||
valA = a.type === 'file' ? new Date(a.updatedAt ?? 0).getTime() : 0
|
||||
valB = b.type === 'file' ? new Date(b.updatedAt ?? 0).getTime() : 0
|
||||
break
|
||||
|
||||
case 'type':
|
||||
valA = a.name.split('.').pop() ?? ''
|
||||
valB = b.name.split('.').pop() ?? ''
|
||||
break
|
||||
|
||||
case 'name':
|
||||
default:
|
||||
valA = a.name.toLowerCase()
|
||||
valB = b.name.toLowerCase()
|
||||
}
|
||||
|
||||
if (valA < valB) return sortOrder === 'desc' ? 1 : -1
|
||||
if (valA > valB) return sortOrder === 'desc' ? -1 : 1
|
||||
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
private encodeToken(payload: object): string {
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64')
|
||||
}
|
||||
|
||||
private decodeToken(token?: string): PaginationTokenPayload | null {
|
||||
if (!token) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(Buffer.from(token, 'base64').toString('utf-8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Entry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||
|
||||
export default class EntrySearchService {
|
||||
public search(entries: Entry[], query: string, signal?: AbortSignal): Entry[] {
|
||||
if (signal?.aborted) return []
|
||||
|
||||
const results: Entry[] = []
|
||||
const addedFolders = new Set<string>()
|
||||
const q = query.toLowerCase()
|
||||
|
||||
for (const entry of entries) {
|
||||
if (signal?.aborted) return results
|
||||
|
||||
if (entry.type === 'folder') {
|
||||
this.addFolderIfMatches(entry, q, addedFolders, results)
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.path.toLowerCase().includes(q)) {
|
||||
results.push(entry)
|
||||
}
|
||||
|
||||
for (const folder of this.getFileParentFolders(entry.path)) {
|
||||
this.addFolderIfMatches(folder, q, addedFolders, results)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private addFolderIfMatches(
|
||||
folder: FolderEntry,
|
||||
query: string,
|
||||
addedFolders: Set<string>,
|
||||
results: Entry[]
|
||||
) {
|
||||
if (!folder.path.toLowerCase().includes(query) || addedFolders.has(folder.path)) return
|
||||
|
||||
addedFolders.add(folder.path)
|
||||
results.push(folder)
|
||||
}
|
||||
|
||||
private getFileParentFolders(path: string): FolderEntry[] {
|
||||
const segments = path.replace(/^\/+/, '').split('/').slice(0, -1)
|
||||
const folders: FolderEntry[] = []
|
||||
let current = ''
|
||||
|
||||
for (const segment of segments) {
|
||||
current = current ? `${current}/${segment}` : segment
|
||||
|
||||
folders.push({
|
||||
name: segment,
|
||||
path: current,
|
||||
type: 'folder',
|
||||
})
|
||||
}
|
||||
|
||||
return folders
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { MultipartFile } from '@adonisjs/core/bodyparser'
|
||||
import type { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||
import {
|
||||
default as EntryListService,
|
||||
type EntryListParams,
|
||||
type EntryListResult,
|
||||
} from '#base_modules/FileManager/services/entry_list_service'
|
||||
import createStorageService from '#base_modules/FileManager/services/storage_service/create_storage_service'
|
||||
import type {
|
||||
FileStream,
|
||||
StorageService,
|
||||
} from '#base_modules/FileManager/services/storage_service/storage_service_interface'
|
||||
import FilePreviewService from '#base_modules/FileManager/services/file_preview_service'
|
||||
import EntrySearchService from '#base_modules/FileManager/services/entry_search_service'
|
||||
import PathService from '#base_modules/FileManager/services/path_service'
|
||||
import {
|
||||
EntryExistsException,
|
||||
FilenameMissingExtensionException,
|
||||
} from '#exceptions/file_manager_exceptions'
|
||||
|
||||
export default class FileManagerService {
|
||||
private filePreviewService = new FilePreviewService()
|
||||
private entrySearchService = new EntrySearchService()
|
||||
private entryListService = new EntryListService()
|
||||
private pathService = new PathService()
|
||||
private storageService: StorageService
|
||||
|
||||
constructor(storageService?: StorageService) {
|
||||
if (storageService) this.storageService = storageService
|
||||
else this.storageService = createStorageService()
|
||||
}
|
||||
|
||||
public async listDirectory(path: string, params: EntryListParams): Promise<EntryListResult> {
|
||||
const entries = await this.storageService.listDirectoryEntries(path)
|
||||
|
||||
return this.entryListService.apply(entries, params)
|
||||
}
|
||||
|
||||
public async getFileEntryWithPreview(path: string): Promise<FileEntry & { content?: string }> {
|
||||
const entry = await this.storageService.getFileEntry(path)
|
||||
|
||||
return this.filePreviewService.withPreview(entry, () =>
|
||||
this.storageService.getFileContent(path)
|
||||
)
|
||||
}
|
||||
|
||||
public getFileStream(path: string): Promise<FileStream> {
|
||||
return this.storageService.getFileStream(path)
|
||||
}
|
||||
|
||||
public async createDirectory(path: string, name: string): Promise<FolderEntry> {
|
||||
const result = await this.storageService.createDirectory(path, name)
|
||||
|
||||
return {
|
||||
name,
|
||||
path: result.path,
|
||||
type: 'folder',
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteEntry(path: string): Promise<void> {
|
||||
await this.storageService.deleteEntry(path)
|
||||
}
|
||||
|
||||
public async moveEntry(path: string, name: string): Promise<Entry> {
|
||||
const entry = await this.storageService.getEntry(path)
|
||||
const dir = this.pathService.dirname(path)
|
||||
const newPath = dir ? this.pathService.join(dir, name) : name
|
||||
|
||||
if (entry.type === 'file' && !this.pathService.getFileNameMeta(name).ext) {
|
||||
throw new FilenameMissingExtensionException()
|
||||
}
|
||||
|
||||
if (await this.storageService.entryExists(newPath)) {
|
||||
throw new EntryExistsException()
|
||||
}
|
||||
|
||||
const result = await this.storageService.moveEntry(path, newPath)
|
||||
|
||||
return this.storageService.getEntry(result.path)
|
||||
}
|
||||
|
||||
public async writeFile(path: string, file: MultipartFile): Promise<Entry> {
|
||||
const dir = this.pathService.toStoragePath(path)
|
||||
const safeName = await this.pathService.generateUniqueName(dir, file.clientName, (target) => {
|
||||
return this.storageService.entryExists(target)
|
||||
})
|
||||
const writePath = this.pathService.join(dir, safeName)
|
||||
const result = await this.storageService.writeFile(writePath, file)
|
||||
|
||||
return this.storageService.getEntry(result.path)
|
||||
}
|
||||
|
||||
public async copyEntries(paths: string[]): Promise<Entry[]> {
|
||||
const results: Entry[] = []
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const sourceEntry = await this.storageService.getFileEntry(path)
|
||||
const dir = this.pathService.dirname(sourceEntry.path)
|
||||
const newName = await this.pathService.generateUniqueName(
|
||||
dir,
|
||||
sourceEntry.name,
|
||||
(target) => this.storageService.entryExists(target),
|
||||
(data) => {
|
||||
const template = 'copy_'
|
||||
const regex = new RegExp(`\\s?${template}(\\d+)$`)
|
||||
const base = data.base.replace(regex, '').trim()
|
||||
|
||||
return `${base} ${template}${data.count}${data.extension}`
|
||||
}
|
||||
)
|
||||
const copyPath = this.pathService.join(dir, newName)
|
||||
const result = await this.storageService.copyEntry(path, copyPath)
|
||||
const copiedEntry = await this.storageService.getFileEntry(result.path)
|
||||
|
||||
results.push(copiedEntry)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
public async search(path: string, query: string, signal?: AbortSignal): Promise<Entry[]> {
|
||||
const entries = await this.storageService.listAllEntries(path)
|
||||
|
||||
return this.entrySearchService.search(entries, query, signal)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FileEntry } from '#base_modules/FileManager/types/entries'
|
||||
|
||||
const PREVIEWABLE_TEXT_EXTENSIONS = ['txt', 'md', 'log', 'json', 'csv']
|
||||
|
||||
export default class FilePreviewService {
|
||||
public async withPreview(
|
||||
entry: FileEntry,
|
||||
getContent: () => Promise<string>
|
||||
): Promise<FileEntry & { content?: string }> {
|
||||
if (!this.isPreviewableTextFile(entry.name)) return entry
|
||||
|
||||
const content = await getContent()
|
||||
|
||||
return { ...entry, content }
|
||||
}
|
||||
|
||||
private isPreviewableTextFile(name: string): boolean {
|
||||
const fileExt = name.split('.').pop()?.toLowerCase()
|
||||
|
||||
return PREVIEWABLE_TEXT_EXTENSIONS.includes(fileExt as string)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { posix } from 'node:path'
|
||||
|
||||
export interface FileNameMeta {
|
||||
name: string
|
||||
ext: string
|
||||
}
|
||||
|
||||
export interface UniqueNameFormatterParams {
|
||||
base: string
|
||||
count: number
|
||||
extension: string
|
||||
}
|
||||
|
||||
export type UniqueNameFormatter = (params: UniqueNameFormatterParams) => string
|
||||
|
||||
export default class PathService {
|
||||
public toStoragePath(path: string = ''): string {
|
||||
let safePath = path.replace(/^(\.\.(\/|\\|$))+/, '')
|
||||
safePath = safePath.startsWith('/') ? safePath.slice(1) : safePath
|
||||
|
||||
return safePath
|
||||
}
|
||||
|
||||
public toClientPath(path: string = ''): string {
|
||||
return path.replace(/^\/?uploads\//, '/')
|
||||
}
|
||||
|
||||
public join(...parts: string[]): string {
|
||||
return posix.join(...parts)
|
||||
}
|
||||
|
||||
public dirname(path: string): string {
|
||||
return this.toStoragePath(path).split('/').slice(0, -1).join('/')
|
||||
}
|
||||
|
||||
public basename(path: string): string {
|
||||
return path.split('/').reverse()[0]
|
||||
}
|
||||
|
||||
public getFileNameMeta(name: string): FileNameMeta {
|
||||
const extIndex = name.lastIndexOf('.')
|
||||
const rawName = extIndex !== -1 ? name.slice(0, extIndex) : name
|
||||
const ext = extIndex !== -1 ? name.slice(extIndex).trim() : ''
|
||||
|
||||
return {
|
||||
name: rawName,
|
||||
ext,
|
||||
}
|
||||
}
|
||||
|
||||
public sanitizeFileName(name: string): string {
|
||||
return name.replace(/[()]/g, '').replace(/[^a-zA-Z0-9.\-_ ]/g, '')
|
||||
}
|
||||
|
||||
public async generateUniqueName(
|
||||
dir: string,
|
||||
fileName: string,
|
||||
exists: (path: string) => Promise<boolean>,
|
||||
formatter?: UniqueNameFormatter
|
||||
): Promise<string> {
|
||||
const { ext, name } = this.getFileNameMeta(fileName)
|
||||
const safeBase = this.sanitizeFileName(name)
|
||||
|
||||
let counter = 1
|
||||
let newName = `${safeBase}${ext}`
|
||||
|
||||
while (await exists(this.join(dir, newName))) {
|
||||
newName =
|
||||
typeof formatter === 'function'
|
||||
? formatter({ base: safeBase, count: counter, extension: ext })
|
||||
: `${safeBase}-${counter}${ext}`
|
||||
|
||||
counter++
|
||||
|
||||
if (counter > 5) {
|
||||
newName = `${randomUUID()}${ext}`
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return newName
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import env from '#start/env'
|
||||
import type { StorageService } from './storage_service_interface.js'
|
||||
import { FSStorageService } from './fs_storage_service.js'
|
||||
import { S3StorageService } from './s3_storage_service.js'
|
||||
|
||||
type StorageDisk = 'fs' | 's3'
|
||||
|
||||
export default function createStorageService(
|
||||
disk: StorageDisk = env.get('DRIVE_DISK')
|
||||
): StorageService {
|
||||
switch (disk) {
|
||||
case 'fs':
|
||||
return new FSStorageService()
|
||||
|
||||
case 's3':
|
||||
return new S3StorageService()
|
||||
|
||||
default:
|
||||
throw new Error('Unsupported: ' + disk)
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
export default class FSDirectoryMarkerService {
|
||||
public markerName = '.keep'
|
||||
|
||||
public markerPath(path: string): string {
|
||||
return `${path}/${this.markerName}`
|
||||
}
|
||||
|
||||
public isMarkerName(name: string): boolean {
|
||||
return name === this.markerName
|
||||
}
|
||||
|
||||
public isMarkerPath(path: string): boolean {
|
||||
return path.endsWith(`/${this.markerName}`)
|
||||
}
|
||||
|
||||
public directoryPathFromMarker(path: string): string {
|
||||
return path.replace(new RegExp(`/${this.markerName}$`), '')
|
||||
}
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
import drive from '@adonisjs/drive/services/main'
|
||||
import {
|
||||
DirExistsException,
|
||||
DirNotFoundException,
|
||||
FileNotFoundException,
|
||||
} from '#exceptions/file_manager_exceptions'
|
||||
import PathService from '#base_modules/FileManager/services/path_service'
|
||||
import type { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||
import type { StorageService } from '#base_modules/FileManager/services/storage_service/storage_service_interface'
|
||||
import FSDirectoryMarkerService from '#base_modules/FileManager/services/storage_service/fs_directory_marker_service'
|
||||
|
||||
export class FSStorageService implements StorageService {
|
||||
private disk = drive.use('fs')
|
||||
private pathService = new PathService()
|
||||
private directoryMarkerService = new FSDirectoryMarkerService()
|
||||
|
||||
private directoryPrefix(path: string): string {
|
||||
return path ? `${path.replace(/\/$/, '')}/` : path
|
||||
}
|
||||
|
||||
private async listDirectoryFiles(path: string): Promise<{ key: string; relative: string }[]> {
|
||||
const prefix = this.directoryPrefix(path)
|
||||
const files: { key: string; relative: string }[] = []
|
||||
|
||||
let paginationToken: string | undefined
|
||||
|
||||
do {
|
||||
const response = await this.disk.listAll(prefix, { recursive: true, paginationToken })
|
||||
|
||||
for (const item of response.objects) {
|
||||
if (!item.isFile || !item.key.startsWith(prefix)) continue
|
||||
|
||||
files.push({ key: item.key, relative: item.key.slice(prefix.length) })
|
||||
}
|
||||
|
||||
paginationToken = response.paginationToken
|
||||
} while (paginationToken)
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
private async moveDirectory(oldPath: string, newPath: string) {
|
||||
await this.disk.put(this.directoryMarkerService.markerPath(newPath), '')
|
||||
|
||||
for (const file of await this.listDirectoryFiles(oldPath)) {
|
||||
const target = this.pathService.join(newPath, file.relative)
|
||||
const dir = this.pathService.dirname(target)
|
||||
|
||||
if (dir) await this.disk.put(this.directoryMarkerService.markerPath(dir), '')
|
||||
|
||||
await this.disk.move(file.key, target)
|
||||
}
|
||||
|
||||
await this.disk.deleteAll(oldPath)
|
||||
}
|
||||
|
||||
public entryExists: StorageService['entryExists'] = async (path) => {
|
||||
const storagePath = this.pathService.toStoragePath(path)
|
||||
|
||||
if (await this.disk.exists(storagePath)) return true
|
||||
|
||||
return this.directoryExists(storagePath)
|
||||
}
|
||||
|
||||
private async directoryExists(path: string): Promise<boolean> {
|
||||
if (await this.disk.exists(this.directoryMarkerService.markerPath(path))) return true
|
||||
|
||||
try {
|
||||
const response = await this.disk.listAll(this.directoryPrefix(path))
|
||||
|
||||
return Array.from(response.objects).length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async getFileEntryFromStoragePath(
|
||||
storagePath: string,
|
||||
params: {
|
||||
name?: string
|
||||
path?: string
|
||||
url?: string
|
||||
} = {}
|
||||
): Promise<FileEntry> {
|
||||
const metadata = await this.disk.getMetaData(storagePath)
|
||||
const path = params.path ?? this.pathService.toClientPath(storagePath)
|
||||
const url = params.url ?? (await this.disk.getUrl(storagePath))
|
||||
|
||||
return {
|
||||
name: params.name ?? this.pathService.basename(path),
|
||||
type: 'file',
|
||||
size: metadata?.contentLength ?? 0,
|
||||
path,
|
||||
url,
|
||||
updatedAt: metadata.lastModified.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
public listDirectoryEntries: StorageService['listDirectoryEntries'] = async (path) => {
|
||||
const diskPath = this.pathService.toStoragePath(path)
|
||||
|
||||
const response = await this.disk.listAll(diskPath)
|
||||
|
||||
const entries = await Promise.all(
|
||||
[...response.objects].map(async (file) => {
|
||||
if (file.isFile && !this.directoryMarkerService.isMarkerName(file.name)) {
|
||||
const relativePath = this.pathService.toClientPath(file.key)
|
||||
|
||||
return this.getFileEntryFromStoragePath(file.key, {
|
||||
name: file.name,
|
||||
path: relativePath,
|
||||
url: await this.disk.getUrl(relativePath),
|
||||
})
|
||||
}
|
||||
|
||||
if (file.isDirectory) {
|
||||
return {
|
||||
name: file.name,
|
||||
type: 'folder',
|
||||
path: this.pathService.toClientPath(file.prefix),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
)
|
||||
|
||||
return entries.filter(Boolean) as Entry[]
|
||||
}
|
||||
|
||||
public listAllEntries: StorageService['listAllEntries'] = async (path) => {
|
||||
let response: Awaited<ReturnType<typeof this.disk.listAll>>
|
||||
|
||||
try {
|
||||
response = await this.disk.listAll(this.pathService.toStoragePath(path), { recursive: true })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = await Promise.all(
|
||||
[...response.objects].map(async (item) => {
|
||||
if (item.isDirectory) return null
|
||||
|
||||
if (this.directoryMarkerService.isMarkerName(item.name)) {
|
||||
const folderPath = this.directoryMarkerService.directoryPathFromMarker(item.key)
|
||||
|
||||
return {
|
||||
name: this.pathService.basename(folderPath) || 'root',
|
||||
path: this.pathService.toClientPath(folderPath),
|
||||
type: 'folder',
|
||||
}
|
||||
}
|
||||
|
||||
return this.getFileEntryFromStoragePath(item.key, {
|
||||
name: item.name,
|
||||
path: this.pathService.toClientPath(item.key),
|
||||
url: '',
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return entries.filter(Boolean) as Entry[]
|
||||
}
|
||||
|
||||
public createDirectory: StorageService['createDirectory'] = async (path, name) => {
|
||||
const dirPath = this.pathService.join(this.pathService.toStoragePath(path), name)
|
||||
|
||||
const isExists = await this.directoryExists(dirPath)
|
||||
|
||||
if (isExists) throw new DirExistsException()
|
||||
|
||||
await this.disk.put(this.directoryMarkerService.markerPath(dirPath), '')
|
||||
|
||||
return { path: this.pathService.toClientPath(dirPath) }
|
||||
}
|
||||
|
||||
public deleteEntry: StorageService['deleteEntry'] = async (path) => {
|
||||
await this.disk.deleteAll(this.pathService.toStoragePath(path))
|
||||
return { path }
|
||||
}
|
||||
|
||||
public moveEntry: StorageService['moveEntry'] = async (path, newPath) => {
|
||||
const oldPath = this.pathService.toStoragePath(path)
|
||||
const targetPath = this.pathService.toStoragePath(newPath)
|
||||
const entry = await this.getEntry(path)
|
||||
|
||||
if (entry.type === 'file') {
|
||||
await this.disk.move(oldPath, targetPath)
|
||||
} else {
|
||||
await this.moveDirectory(oldPath, targetPath)
|
||||
}
|
||||
|
||||
return { path: this.pathService.toClientPath(targetPath) }
|
||||
}
|
||||
|
||||
public getFileEntry: StorageService['getFileEntry'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
return this.getFileEntryFromStoragePath(source, {
|
||||
name: this.pathService.basename(path),
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
public getFileContent: StorageService['getFileContent'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
return this.disk.get(source)
|
||||
}
|
||||
|
||||
public getFileStream: StorageService['getFileStream'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
const metadata = await this.disk.getMetaData(source)
|
||||
const stream = await this.disk.getStream(source)
|
||||
const name = this.pathService.basename(path)
|
||||
|
||||
return {
|
||||
stream,
|
||||
contentType: metadata.contentType,
|
||||
size: metadata.contentLength,
|
||||
name,
|
||||
}
|
||||
}
|
||||
|
||||
private async getDirectoryEntry(path: string): Promise<FolderEntry> {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const isExists = await this.directoryExists(source)
|
||||
|
||||
if (!isExists) throw new DirNotFoundException()
|
||||
|
||||
const name = this.pathService.basename(path) || 'root'
|
||||
|
||||
return { type: 'folder', name, path }
|
||||
}
|
||||
|
||||
public getEntry: StorageService['getEntry'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (exists) return this.getFileEntry(path)
|
||||
|
||||
return this.getDirectoryEntry(path)
|
||||
}
|
||||
|
||||
public writeFile: StorageService['writeFile'] = async (path, file) => {
|
||||
const writePath = this.pathService.toStoragePath(path)
|
||||
await file.moveToDisk(writePath)
|
||||
|
||||
return { path: this.pathService.toClientPath(writePath) }
|
||||
}
|
||||
|
||||
public copyEntry: StorageService['copyEntry'] = async (sourcePath, targetPath) => {
|
||||
const source = this.pathService.toStoragePath(sourcePath)
|
||||
const target = this.pathService.toStoragePath(targetPath)
|
||||
await this.disk.copy(source, target)
|
||||
|
||||
return { path: this.pathService.toClientPath(target) }
|
||||
}
|
||||
}
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import drive from '@adonisjs/drive/services/main'
|
||||
import {
|
||||
DirExistsException,
|
||||
DirNotFoundException,
|
||||
FileNotFoundException,
|
||||
} from '#exceptions/file_manager_exceptions'
|
||||
import PathService from '#base_modules/FileManager/services/path_service'
|
||||
import type { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||
import type { StorageService } from '#base_modules/FileManager/services/storage_service/storage_service_interface'
|
||||
import FSDirectoryMarkerService from '#base_modules/FileManager/services/storage_service/fs_directory_marker_service'
|
||||
|
||||
export class S3StorageService implements StorageService {
|
||||
private disk = drive.use('s3')
|
||||
private pathService = new PathService()
|
||||
private directoryMarkerService = new FSDirectoryMarkerService()
|
||||
|
||||
private directoryPrefix(path: string): string {
|
||||
return path ? `${path.replace(/\/$/, '')}/` : path
|
||||
}
|
||||
|
||||
private async listDirectoryFiles(path: string): Promise<{ key: string; relative: string }[]> {
|
||||
const prefix = this.directoryPrefix(path)
|
||||
const files: { key: string; relative: string }[] = []
|
||||
|
||||
let paginationToken: string | undefined
|
||||
|
||||
do {
|
||||
const response = await this.disk.listAll(prefix, { recursive: true, paginationToken })
|
||||
|
||||
for (const item of response.objects) {
|
||||
if (!item.isFile || !item.key.startsWith(prefix)) continue
|
||||
|
||||
files.push({ key: item.key, relative: item.key.slice(prefix.length) })
|
||||
}
|
||||
|
||||
paginationToken = response.paginationToken
|
||||
} while (paginationToken)
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
private async moveDirectory(oldPath: string, newPath: string) {
|
||||
await this.disk.put(this.directoryMarkerService.markerPath(newPath), '')
|
||||
|
||||
for (const file of await this.listDirectoryFiles(oldPath)) {
|
||||
const target = this.pathService.join(newPath, file.relative)
|
||||
const dir = this.pathService.dirname(target)
|
||||
|
||||
if (dir) await this.disk.put(this.directoryMarkerService.markerPath(dir), '')
|
||||
|
||||
await this.disk.move(file.key, target)
|
||||
}
|
||||
}
|
||||
|
||||
public entryExists: StorageService['entryExists'] = async (path) => {
|
||||
const storagePath = this.pathService.toStoragePath(path)
|
||||
|
||||
if (await this.disk.exists(storagePath)) return true
|
||||
|
||||
return this.directoryExists(storagePath)
|
||||
}
|
||||
|
||||
private async directoryExists(path: string): Promise<boolean> {
|
||||
if (await this.disk.exists(this.directoryMarkerService.markerPath(path))) return true
|
||||
|
||||
try {
|
||||
const response = await this.disk.listAll(this.directoryPrefix(path))
|
||||
|
||||
return Array.from(response.objects).length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async getFileEntryFromStoragePath(
|
||||
storagePath: string,
|
||||
params: {
|
||||
name?: string
|
||||
path?: string
|
||||
url?: string
|
||||
} = {}
|
||||
): Promise<FileEntry> {
|
||||
const metadata = await this.disk.getMetaData(storagePath)
|
||||
const path = params.path ?? this.pathService.toClientPath(storagePath)
|
||||
const url = params.url ?? (await this.disk.getUrl(storagePath))
|
||||
|
||||
return {
|
||||
name: params.name ?? this.pathService.basename(path),
|
||||
type: 'file',
|
||||
size: metadata?.contentLength ?? 0,
|
||||
path,
|
||||
url,
|
||||
updatedAt: metadata.lastModified.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
public listDirectoryEntries: StorageService['listDirectoryEntries'] = async (path) => {
|
||||
const diskPath = this.pathService.toStoragePath(path)
|
||||
const response = await this.disk.listAll(diskPath)
|
||||
|
||||
const entries = await Promise.all(
|
||||
[...response.objects].map(async (file) => {
|
||||
if (file.isFile && !this.directoryMarkerService.isMarkerName(file.name)) {
|
||||
const relativePath = this.pathService.toClientPath(file.key)
|
||||
|
||||
return this.getFileEntryFromStoragePath(file.key, {
|
||||
name: file.name,
|
||||
path: relativePath,
|
||||
url: await this.disk.getUrl(relativePath),
|
||||
})
|
||||
}
|
||||
|
||||
if (file.isDirectory) {
|
||||
return {
|
||||
name: file.name,
|
||||
type: 'folder',
|
||||
path: this.pathService.toClientPath(file.prefix),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
)
|
||||
|
||||
return entries.filter(Boolean) as Entry[]
|
||||
}
|
||||
|
||||
public listAllEntries: StorageService['listAllEntries'] = async (path) => {
|
||||
let response: Awaited<ReturnType<typeof this.disk.listAll>>
|
||||
|
||||
try {
|
||||
const storagePath = this.pathService.toStoragePath(path)
|
||||
response = await this.disk.listAll(this.directoryPrefix(storagePath), { recursive: true })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = await Promise.all(
|
||||
[...response.objects].map(async (item) => {
|
||||
if (item.isDirectory) return null
|
||||
|
||||
if (this.directoryMarkerService.isMarkerName(item.name)) {
|
||||
const folderPath = this.directoryMarkerService.directoryPathFromMarker(item.key)
|
||||
|
||||
return {
|
||||
name: this.pathService.basename(folderPath) || 'root',
|
||||
path: this.pathService.toClientPath(folderPath),
|
||||
type: 'folder',
|
||||
}
|
||||
}
|
||||
|
||||
return this.getFileEntryFromStoragePath(item.key, {
|
||||
name: item.name,
|
||||
path: this.pathService.toClientPath(item.key),
|
||||
url: '',
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return entries.filter(Boolean) as Entry[]
|
||||
}
|
||||
|
||||
public createDirectory: StorageService['createDirectory'] = async (path, name) => {
|
||||
const dirPath = this.pathService.join(this.pathService.toStoragePath(path), name)
|
||||
|
||||
const isExists = await this.directoryExists(dirPath)
|
||||
|
||||
if (isExists) throw new DirExistsException()
|
||||
|
||||
await this.disk.put(this.directoryMarkerService.markerPath(dirPath), '')
|
||||
|
||||
return { path: this.pathService.toClientPath(dirPath) }
|
||||
}
|
||||
|
||||
public deleteEntry: StorageService['deleteEntry'] = async (path) => {
|
||||
const storagePath = this.pathService.toStoragePath(path)
|
||||
const entry = await this.getEntry(path)
|
||||
|
||||
if (entry.type === 'file') {
|
||||
await this.disk.delete(storagePath)
|
||||
} else {
|
||||
for (const file of await this.listDirectoryFiles(storagePath)) {
|
||||
await this.disk.delete(file.key)
|
||||
}
|
||||
}
|
||||
|
||||
return { path }
|
||||
}
|
||||
|
||||
public moveEntry: StorageService['moveEntry'] = async (path, newPath) => {
|
||||
const oldPath = this.pathService.toStoragePath(path)
|
||||
const targetPath = this.pathService.toStoragePath(newPath)
|
||||
const entry = await this.getEntry(path)
|
||||
|
||||
if (entry.type === 'file') {
|
||||
await this.disk.move(oldPath, targetPath)
|
||||
} else {
|
||||
await this.moveDirectory(oldPath, targetPath)
|
||||
}
|
||||
|
||||
return { path: this.pathService.toClientPath(targetPath) }
|
||||
}
|
||||
|
||||
public getFileEntry: StorageService['getFileEntry'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
return this.getFileEntryFromStoragePath(source, {
|
||||
name: this.pathService.basename(path),
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
public getFileContent: StorageService['getFileContent'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
return this.disk.get(source)
|
||||
}
|
||||
|
||||
public getFileStream: StorageService['getFileStream'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (!exists) throw new FileNotFoundException()
|
||||
|
||||
const metadata = await this.disk.getMetaData(source)
|
||||
const stream = await this.disk.getStream(source)
|
||||
const name = this.pathService.basename(path)
|
||||
|
||||
return {
|
||||
stream,
|
||||
contentType: metadata.contentType,
|
||||
size: metadata.contentLength,
|
||||
name,
|
||||
}
|
||||
}
|
||||
|
||||
private async getDirectoryEntry(path: string): Promise<FolderEntry> {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const isExists = await this.directoryExists(source)
|
||||
|
||||
if (!isExists) throw new DirNotFoundException()
|
||||
|
||||
const name = this.pathService.basename(path) || 'root'
|
||||
|
||||
return { type: 'folder', name, path }
|
||||
}
|
||||
|
||||
public getEntry: StorageService['getEntry'] = async (path) => {
|
||||
const source = this.pathService.toStoragePath(path)
|
||||
|
||||
const exists = await this.disk.exists(source)
|
||||
|
||||
if (exists) return this.getFileEntry(path)
|
||||
|
||||
return this.getDirectoryEntry(path)
|
||||
}
|
||||
|
||||
public writeFile: StorageService['writeFile'] = async (path, file) => {
|
||||
const writePath = this.pathService.toStoragePath(path)
|
||||
|
||||
if (!file.tmpPath) {
|
||||
throw new Error('Cannot write multipart file to S3 without a temporary file path')
|
||||
}
|
||||
|
||||
await this.disk.putStream(writePath, createReadStream(file.tmpPath), {
|
||||
contentType: file.headers['content-type'],
|
||||
contentLength: file.size,
|
||||
})
|
||||
|
||||
return { path: this.pathService.toClientPath(writePath) }
|
||||
}
|
||||
|
||||
public copyEntry: StorageService['copyEntry'] = async (sourcePath, targetPath) => {
|
||||
const source = this.pathService.toStoragePath(sourcePath)
|
||||
const target = this.pathService.toStoragePath(targetPath)
|
||||
await this.disk.copy(source, target)
|
||||
|
||||
return { path: this.pathService.toClientPath(target) }
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import type { Readable } from 'node:stream'
|
||||
import type { MultipartFile } from '@adonisjs/core/bodyparser'
|
||||
import type { Entry, FileEntry } from '#base_modules/FileManager/types/entries'
|
||||
|
||||
export interface FileStream {
|
||||
stream: Readable
|
||||
contentType?: string
|
||||
size?: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface StorageService {
|
||||
listDirectoryEntries(path: string): Promise<Entry[]>
|
||||
listAllEntries(path: string): Promise<Entry[]>
|
||||
entryExists(path: string): Promise<boolean>
|
||||
getEntry(path: string): Promise<Entry>
|
||||
getFileEntry(path: string): Promise<FileEntry>
|
||||
getFileContent(path: string): Promise<string>
|
||||
getFileStream(path: string): Promise<FileStream>
|
||||
writeFile(path: string, file: MultipartFile): Promise<{ path: string }>
|
||||
createDirectory(path: string, name: string): Promise<{ path: string }>
|
||||
moveEntry(oldPath: string, newPath: string): Promise<{ path: string }>
|
||||
deleteEntry(path: string): Promise<{ path: string }>
|
||||
copyEntry(sourcePath: string, targetPath: string): Promise<{ path: string }>
|
||||
}
|
||||
Reference in New Issue
Block a user