From ae34b30e4f646b3c738a32eafa6b7833c212dc28 Mon Sep 17 00:00:00 2001 From: Igor Martyniuk Date: Mon, 31 Aug 2026 12:38:14 +0000 Subject: [PATCH] TASKS-1832 --- CHANGELOG.md | 16 +++++ .../core/AccessControl/data/api/AdminAPI.ts | 14 ++--- .../AccessControl/data/api/PermissionAPI.ts | 4 +- .../core/AccessControl/data/api/RoleAPI.ts | 14 ++--- .../core/WebStudio/data/api/WebStudioAPI.ts | 6 +- .../core/WebStudio/data/api/WebStudioDTO.ts | 22 +++++++ .../data/mappers/SiteSettingsMapper.ts | 28 +++++++++ .../core/WebStudio/data/queries/settings.ts | 5 +- .../pages/PageSettingsPage/index.tsx | 36 +++++++++-- .../view/forms/WebSettingsForm/Component.tsx | 12 ++-- .../view/forms/WebSettingsForm/form.ts | 9 ++- .../view/forms/WebSettingsForm/index.ts | 2 +- .../Admin/controller/admin_controller.ts | 5 ++ .../controller/file_manager_controller.ts | 4 +- .../services/entry_list_service.ts | 2 +- .../services/file_manager_service.ts | 10 +-- .../services/file_preview_service.ts | 2 +- .../storage_service/create_storage_service.ts | 2 +- .../storage_service/fs_storage_service.ts | 60 ++++++++++++------ .../storage_service/s3_storage_service.ts | 61 +++++++++++++------ .../storage_service_interface.ts | 6 +- .../WebManager/types/web_content.ts | 8 +-- .../validator/web_content_validator.ts | 8 +-- .../tests/unit/fs_storage_directories.spec.ts | 54 ++++++++++++++++ docker-compose.yml | 1 + 25 files changed, 293 insertions(+), 98 deletions(-) create mode 100644 apps/admin/src/core/WebStudio/data/mappers/SiteSettingsMapper.ts create mode 100644 apps/backend/tests/unit/fs_storage_directories.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ae0d0..161abf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- Fixed File Manager operations on directories without a `.keep` marker. A directory a file was + uploaded into, or one that exists in the bucket but was not created through the app, was listed + but could not be renamed, deleted or resolved (`E_DIR_NOT_FOUND`); directory existence now falls + back to the prefix holding any entry instead of relying on the marker alone. +- Fixed renaming or deleting a folder destroying sibling folders whose name shares its prefix + (renaming `aa` overwrote and then deleted `aab`). Drive strips the trailing slash off a listing + prefix, so a directory's contents are now filtered on the real `/` prefix, and the S3 + service deletes the listed keys instead of passing that prefix to `deleteAll`. +- Moving, copying and deleting a folder now follow the listing pagination token, so folders holding + more than 1000 objects are no longer processed only in part. +- Fixed `getEntry` raising a bare `Error` (HTTP 500) for a path that is neither a file nor a + directory; it now raises the `E_DIR_NOT_FOUND` 404. +- Fixed the web container running against the host's `node_modules`: the `web` service keeps + `apps/web/node_modules` in an anonymous volume, so the bind mount no longer shadows the + dependencies installed in the image. + ### Removed ## [1.3.0] - 2026-08-28 diff --git a/apps/admin/src/core/AccessControl/data/api/AdminAPI.ts b/apps/admin/src/core/AccessControl/data/api/AdminAPI.ts index 84017a1..a7a16f0 100644 --- a/apps/admin/src/core/AccessControl/data/api/AdminAPI.ts +++ b/apps/admin/src/core/AccessControl/data/api/AdminAPI.ts @@ -2,34 +2,30 @@ import { axiosClient } from '#infrastructure/axios'; import type { ListQueryParams, SuccessListResponse, SuccessMutationResponse } from '#infrastructure/reactQuery'; import type { AdminDTO } from './AccessControlDTO'; -const ROOT_API_ENDPOINT = '/admin'; - export default class AdminAPI { getList = async (params: ListQueryParams) => { return axiosClient - .get>(ROOT_API_ENDPOINT, { params }) + .get>('/admin', { params }) .then((data) => data.data); }; getDetailed = async (id: string) => { - return axiosClient.get(`${ROOT_API_ENDPOINT}/${id}`).then((data) => data.data); + return axiosClient.get(`/admin/${id}`).then((data) => data.data); }; create = async (payload: Record) => { - return axiosClient - .post(ROOT_API_ENDPOINT, payload) - .then((data) => data.data); + return axiosClient.post('/admin', payload).then((data) => data.data); }; update = async (payload: { id: number } & Record) => { return axiosClient - .put(`${ROOT_API_ENDPOINT}/${payload.id}`, payload) + .put(`/admin/${payload.id}`, payload) .then((data) => data.data); }; delete = async (payload: { id: number }) => { return axiosClient - .delete(`${ROOT_API_ENDPOINT}/${payload.id}`) + .delete(`/admin/${payload.id}`) .then((data) => data.data); }; } diff --git a/apps/admin/src/core/AccessControl/data/api/PermissionAPI.ts b/apps/admin/src/core/AccessControl/data/api/PermissionAPI.ts index 789f0c2..c5e0023 100644 --- a/apps/admin/src/core/AccessControl/data/api/PermissionAPI.ts +++ b/apps/admin/src/core/AccessControl/data/api/PermissionAPI.ts @@ -1,10 +1,8 @@ import { axiosClient } from '#infrastructure/axios'; import type { PermissionDTO } from './AccessControlDTO'; -const ROOT_API_ENDPOINT = '/permission'; - export default class PermissionAPI { getList = async () => { - return axiosClient.get>(ROOT_API_ENDPOINT).then((data) => data.data); + return axiosClient.get>('/permission').then((data) => data.data); }; } diff --git a/apps/admin/src/core/AccessControl/data/api/RoleAPI.ts b/apps/admin/src/core/AccessControl/data/api/RoleAPI.ts index 4b2580e..29ed563 100644 --- a/apps/admin/src/core/AccessControl/data/api/RoleAPI.ts +++ b/apps/admin/src/core/AccessControl/data/api/RoleAPI.ts @@ -2,34 +2,30 @@ import { axiosClient } from '#infrastructure/axios'; import type { ListQueryParams, SuccessListResponse, SuccessMutationResponse } from '#infrastructure/reactQuery'; import type { RoleDTO } from './AccessControlDTO'; -const ROOT_API_ENDPOINT = '/role'; - export default class RoleAPI { getList = async (params: ListQueryParams) => { return axiosClient - .get>(ROOT_API_ENDPOINT, { params }) + .get>('/role', { params }) .then((data) => data.data); }; getDetailed = async (id: string) => { - return axiosClient.get(`${ROOT_API_ENDPOINT}/${id}`).then((data) => data.data); + return axiosClient.get(`/role/${id}`).then((data) => data.data); }; create = async (payload: { name: string; permissions: string[] }) => { - return axiosClient - .post(ROOT_API_ENDPOINT, payload) - .then((data) => data.data); + return axiosClient.post('/role', payload).then((data) => data.data); }; update = async (payload: { id: number } & Record) => { return axiosClient - .put(`${ROOT_API_ENDPOINT}/${payload.id}`, payload) + .put(`/role/${payload.id}`, payload) .then((data) => data.data); }; delete = async (payload: { id: number }) => { return axiosClient - .delete(`${ROOT_API_ENDPOINT}/${payload.id}`) + .delete(`/role/${payload.id}`) .then((data) => data.data); }; } diff --git a/apps/admin/src/core/WebStudio/data/api/WebStudioAPI.ts b/apps/admin/src/core/WebStudio/data/api/WebStudioAPI.ts index 3859aa7..d073e20 100644 --- a/apps/admin/src/core/WebStudio/data/api/WebStudioAPI.ts +++ b/apps/admin/src/core/WebStudio/data/api/WebStudioAPI.ts @@ -3,7 +3,7 @@ import type { ListQueryParams, SuccessListResponse } from '#infrastructure/react import type { WebStudioSiteSettings } from '#core/WebStudio/config/siteSettings'; import type { PageStatus } from '#core/WebStudio/entities/PageStatus'; import PagePayloadSerializer from './PagePayloadSerializer'; -import type { CreatePagePayloadDTO, PageDetailsDTO, PageListItemDTO, PreviewTokenDTO, PublishPageResultDTO, RevalidationDTO } from './WebStudioDTO'; +import type { CreatePagePayloadDTO, PageDetailsDTO, PageListItemDTO, PreviewTokenDTO, PublishPageResultDTO, RevalidationDTO, SiteSettingsDTO } from './WebStudioDTO'; export interface PageListQueryParams extends ListQueryParams { locale?: string; @@ -93,7 +93,7 @@ class WebStudioAPI { getSettings = async (locale: string) => { return axiosClient - .get<{ data: WebStudioSiteSettings | null }>('/web-manager/settings', { + .get<{ data: SiteSettingsDTO | null }>('/web-manager/settings', { params: { locale } }) .then((response) => response.data); @@ -101,7 +101,7 @@ class WebStudioAPI { updateSettings = async (settings: WebStudioSiteSettings) => { return axiosClient - .put<{ data: WebStudioSiteSettings }>('/web-manager/settings', settings) + .put<{ data: SiteSettingsDTO }>('/web-manager/settings', settings) .then((response) => response.data); }; } diff --git a/apps/admin/src/core/WebStudio/data/api/WebStudioDTO.ts b/apps/admin/src/core/WebStudio/data/api/WebStudioDTO.ts index 6e0f815..eb875ee 100644 --- a/apps/admin/src/core/WebStudio/data/api/WebStudioDTO.ts +++ b/apps/admin/src/core/WebStudio/data/api/WebStudioDTO.ts @@ -1,4 +1,26 @@ import type { IsoDateString } from '@crux-kit/types'; +import type { NavigationLink } from '#core/WebStudio/entities/field/NavigationLink'; + +export interface SiteSettingsMenuDTO { + title?: string; + items: NavigationLink[]; +} + +export interface SiteSettingsDTO { + locale: string; + header: { + menus: SiteSettingsMenuDTO[]; + ctaItems: NavigationLink[]; + }; + footer: { + copyright?: string; + menus: SiteSettingsMenuDTO[]; + }; + legal: { + privacyPath?: string; + termsPath?: string; + }; +} export interface PageListItemDTO { id: number; diff --git a/apps/admin/src/core/WebStudio/data/mappers/SiteSettingsMapper.ts b/apps/admin/src/core/WebStudio/data/mappers/SiteSettingsMapper.ts new file mode 100644 index 0000000..46add88 --- /dev/null +++ b/apps/admin/src/core/WebStudio/data/mappers/SiteSettingsMapper.ts @@ -0,0 +1,28 @@ +import type { PageLanguage } from '#core/WebStudio/config/languages'; +import type { WebStudioSettingsMenu, WebStudioSiteSettings } from '#core/WebStudio/config/siteSettings'; +import type { SiteSettingsDTO, SiteSettingsMenuDTO } from '#core/WebStudio/data/api/WebStudioDTO'; + +class SiteSettingsMapper { + toEntity = (dto: SiteSettingsDTO): WebStudioSiteSettings => ({ + locale: dto.locale as PageLanguage, + header: { + menus: dto.header.menus.map(this.toMenu), + ctaItems: dto.header.ctaItems + }, + footer: { + copyright: dto.footer.copyright ?? '', + menus: dto.footer.menus.map(this.toMenu) + }, + legal: { + privacyPath: dto.legal.privacyPath ?? '', + termsPath: dto.legal.termsPath ?? '' + } + }); + + private toMenu = (menu: SiteSettingsMenuDTO): WebStudioSettingsMenu => ({ + title: menu.title ?? '', + items: menu.items + }); +} + +export default new SiteSettingsMapper(); diff --git a/apps/admin/src/core/WebStudio/data/queries/settings.ts b/apps/admin/src/core/WebStudio/data/queries/settings.ts index 0e87dce..5707b2f 100644 --- a/apps/admin/src/core/WebStudio/data/queries/settings.ts +++ b/apps/admin/src/core/WebStudio/data/queries/settings.ts @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { PageLanguage } from '#core/WebStudio/config/languages'; import type { WebStudioSiteSettings } from '#core/WebStudio/config/siteSettings'; import WebStudioAPI from '#core/WebStudio/data/api/WebStudioAPI'; +import SiteSettingsMapper from '#core/WebStudio/data/mappers/SiteSettingsMapper'; import { pageSettingsKeyBuilder } from './keys'; export const useGetPageSettings = (locale: PageLanguage) => { @@ -9,7 +10,7 @@ export const useGetPageSettings = (locale: PageLanguage) => { queryKey: pageSettingsKeyBuilder.store({ locale }), queryFn: async () => { const response = await WebStudioAPI.getSettings(locale); - return response.data; + return response.data ? SiteSettingsMapper.toEntity(response.data) : null; } }); }; @@ -20,7 +21,7 @@ export const useUpdatePageSettings = () => { return useMutation({ mutationFn: async (settings: WebStudioSiteSettings) => { const response = await WebStudioAPI.updateSettings(settings); - return response.data; + return SiteSettingsMapper.toEntity(response.data); }, onSuccess: (settings) => { queryClient.setQueryData(pageSettingsKeyBuilder.store({ locale: settings.locale }), settings); diff --git a/apps/admin/src/core/WebStudio/pages/PageSettingsPage/index.tsx b/apps/admin/src/core/WebStudio/pages/PageSettingsPage/index.tsx index 18cd562..aa6e65e 100644 --- a/apps/admin/src/core/WebStudio/pages/PageSettingsPage/index.tsx +++ b/apps/admin/src/core/WebStudio/pages/PageSettingsPage/index.tsx @@ -1,14 +1,14 @@ import { LoaderCircle, Save } from 'lucide-react'; import React from 'react'; -import type { FieldErrors } from 'react-hook-form'; +import type { FieldPath } from 'react-hook-form'; import { useIntl } from 'react-intl'; import { toast } from 'sonner'; -import { handleQueryError } from '#infrastructure/reactQuery'; +import { extractValidationErrors, getErrorMessage, handleQueryError, isValidationError, type ErrorResponse } from '#infrastructure/reactQuery'; import { PermissionPageGate } from '#core/Permission'; import { DEFAULT_WEB_STUDIO_SITE_SETTINGS_RECORD } from '#core/WebStudio/config/siteSettings'; import { useGetPageSettings, useUpdatePageSettings } from '#core/WebStudio/data/queries/settings'; import { useWebStudioQueryParams } from '#core/WebStudio/services/useWebStudioQueryParams'; -import { WEB_SETTINGS_FORM_ID, WebSettingsForm, type FormValues } from '#core/WebStudio/view/forms/WebSettingsForm'; +import { WEB_SETTINGS_FORM_ID, WebSettingsForm, type FormValues, type WebSettingsInvalidHandler, type WebSettingsSubmitHandler } from '#core/WebStudio/view/forms/WebSettingsForm'; import { Button } from '#shared/ui/Button'; import { Skeleton } from '#shared/ui/Skeleton'; import { Tabs, TabsList, TabsTrigger } from '#shared/ui/Tabs'; @@ -26,18 +26,42 @@ const PageSettings: React.FC = () => { const { data, isLoading } = useGetPageSettings(locale); const { mutate, isPending } = useUpdatePageSettings(); - const handleSubmit = (values: FormValues) => { + const handleSubmit: WebSettingsSubmitHandler = (values, form) => { mutate(values, { onSuccess: () => { toast.success(intl.formatMessage({ id: 'WEB_STUDIO.SETTINGS.SAVE_SUCCESS' })); }, onError: (error) => { - handleQueryError(error); + if (!isValidationError(error as ErrorResponse)) { + handleQueryError(error); + return; + } + + const validationErrors = extractValidationErrors(error as ErrorResponse); + if (validationErrors.length === 0) { + toast.error(getErrorMessage(error)); + return; + } + + validationErrors.forEach((validationError) => { + form.setError(validationError.field as FieldPath, { + type: 'apiError', + message: validationError.message + }); + }); + toast.error(intl.formatMessage({ id: 'WEB_STUDIO.FORM.VALIDATION_ERROR' })); + + const sectionWithError = SETTINGS_SECTIONS.find((candidate) => + validationErrors.some((validationError) => validationError.field.startsWith(candidate)) + ); + if (sectionWithError && sectionWithError !== section) { + changeSection(sectionWithError); + } } }); }; - const handleInvalid = (errors: FieldErrors) => { + const handleInvalid: WebSettingsInvalidHandler = (errors) => { toast.error(intl.formatMessage({ id: 'WEB_STUDIO.FORM.VALIDATION_ERROR' })); const sectionWithError = SETTINGS_SECTIONS.find((candidate) => errors[candidate]); diff --git a/apps/admin/src/core/WebStudio/view/forms/WebSettingsForm/Component.tsx b/apps/admin/src/core/WebStudio/view/forms/WebSettingsForm/Component.tsx index 15f01c1..4c6bf2a 100644 --- a/apps/admin/src/core/WebStudio/view/forms/WebSettingsForm/Component.tsx +++ b/apps/admin/src/core/WebStudio/view/forms/WebSettingsForm/Component.tsx @@ -1,7 +1,6 @@ -import type { FieldErrors } from 'react-hook-form'; import { FormElement } from '#shared/forms/composed'; import { Form } from '#shared/ui/form'; -import type { FormValues } from './form'; +import type { FormValues, WebSettingsInvalidHandler, WebSettingsSubmitHandler } from './form'; import { useWebSettingsForm, WEB_SETTINGS_FORM_ID } from './form'; import FooterSettingsSection from './sections/FooterSettingsSection'; import HeaderSettingsSection from './sections/HeaderSettingsSection'; @@ -11,8 +10,8 @@ interface WebSettingsFormProps { initialData: FormValues; section: string; isSubmitting?: boolean; - onSubmit: (settings: FormValues) => void; - onInvalid?: (errors: FieldErrors) => void; + onSubmit: WebSettingsSubmitHandler; + onInvalid?: WebSettingsInvalidHandler; } export default function WebSettingsForm({ @@ -26,7 +25,10 @@ export default function WebSettingsForm({ return (
- + onSubmit(values, form), onInvalid)} + >
>; +export type WebSettingsSubmitHandler = ( + settings: FormValues, + form: UseFormReturn +) => void; + +export type WebSettingsInvalidHandler = (errors: FieldErrors) => void; + export const useWebSettingsForm = (initialData: FormValues) => { const intl = useIntl(); diff --git a/apps/admin/src/core/WebStudio/view/forms/WebSettingsForm/index.ts b/apps/admin/src/core/WebStudio/view/forms/WebSettingsForm/index.ts index 929a8e5..ae956c8 100644 --- a/apps/admin/src/core/WebStudio/view/forms/WebSettingsForm/index.ts +++ b/apps/admin/src/core/WebStudio/view/forms/WebSettingsForm/index.ts @@ -1,3 +1,3 @@ export { default as WebSettingsForm } from './Component'; export { WEB_SETTINGS_FORM_ID } from './form'; -export type { FormValues } from './form'; +export type { FormValues, WebSettingsInvalidHandler, WebSettingsSubmitHandler } from './form'; diff --git a/apps/backend/app/base_modules/Admin/controller/admin_controller.ts b/apps/backend/app/base_modules/Admin/controller/admin_controller.ts index a99db99..623281b 100755 --- a/apps/backend/app/base_modules/Admin/controller/admin_controller.ts +++ b/apps/backend/app/base_modules/Admin/controller/admin_controller.ts @@ -16,8 +16,13 @@ export default class AdminsController { search = null, sortBy = 'id', sortOrder = 'desc', + status = null, } = request.all() const query = Admins.query() + if (status) { + const statuses = Array.isArray(status) ? status : [status] + query.whereIn('status', statuses) + } if (search) { const columns = Admins.searchable || (await searchableColumns('admins')) addSearchFilter(query, columns, search) diff --git a/apps/backend/app/base_modules/FileManager/controller/file_manager_controller.ts b/apps/backend/app/base_modules/FileManager/controller/file_manager_controller.ts index 1818c36..9d1661e 100755 --- a/apps/backend/app/base_modules/FileManager/controller/file_manager_controller.ts +++ b/apps/backend/app/base_modules/FileManager/controller/file_manager_controller.ts @@ -1,11 +1,11 @@ -import { HttpContext } from '@adonisjs/core/http' +import type { HttpContext } from '@adonisjs/core/http' import listValidator from '#base_modules/FileManager/validator/list_validator' import uploadFileValidator from '#base_modules/FileManager/validator/upload_file_validator' import createDirectoryValidator from '#base_modules/FileManager/validator/create_directory_validator' import renameEntryValidator from '#base_modules/FileManager/validator/rename_entry_validator' import duplicateEntriesValidator from '#base_modules/FileManager/validator/duplicate_entries_validator' import FileMangerPolicy from '#base_modules/FileManager/policy/file_manager_policy' -import { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries' +import type { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries' import getValidatedPathFromRequest from '#base_modules/FileManager/helpers/get_validated_path_from_request' import FileManagerService from '#base_modules/FileManager/services/file_manager_service' diff --git a/apps/backend/app/base_modules/FileManager/services/entry_list_service.ts b/apps/backend/app/base_modules/FileManager/services/entry_list_service.ts index 582ed67..688f276 100644 --- a/apps/backend/app/base_modules/FileManager/services/entry_list_service.ts +++ b/apps/backend/app/base_modules/FileManager/services/entry_list_service.ts @@ -1,4 +1,4 @@ -import { Entry } from '#base_modules/FileManager/types/entries' +import type { Entry } from '#base_modules/FileManager/types/entries' export type EntryFilter = 'media' | 'archives' | 'documents' | 'all' export type EntrySortBy = 'name' | 'size' | 'type' | 'date' diff --git a/apps/backend/app/base_modules/FileManager/services/file_manager_service.ts b/apps/backend/app/base_modules/FileManager/services/file_manager_service.ts index f1358fc..bf134c9 100644 --- a/apps/backend/app/base_modules/FileManager/services/file_manager_service.ts +++ b/apps/backend/app/base_modules/FileManager/services/file_manager_service.ts @@ -1,12 +1,12 @@ -import { MultipartFile } from '@adonisjs/core/bodyparser' -import { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries' +import type { MultipartFile } from '@adonisjs/core/bodyparser' +import type { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries' import { default as EntryListService, - EntryListParams, - EntryListResult, + 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 { +import type { FileStream, StorageService, } from '#base_modules/FileManager/services/storage_service/storage_service_interface' diff --git a/apps/backend/app/base_modules/FileManager/services/file_preview_service.ts b/apps/backend/app/base_modules/FileManager/services/file_preview_service.ts index e92ea68..379e144 100644 --- a/apps/backend/app/base_modules/FileManager/services/file_preview_service.ts +++ b/apps/backend/app/base_modules/FileManager/services/file_preview_service.ts @@ -1,4 +1,4 @@ -import { FileEntry } from '#base_modules/FileManager/types/entries' +import type { FileEntry } from '#base_modules/FileManager/types/entries' const PREVIEWABLE_TEXT_EXTENSIONS = ['txt', 'md', 'log', 'json', 'csv'] diff --git a/apps/backend/app/base_modules/FileManager/services/storage_service/create_storage_service.ts b/apps/backend/app/base_modules/FileManager/services/storage_service/create_storage_service.ts index 746b06b..cba7c5a 100644 --- a/apps/backend/app/base_modules/FileManager/services/storage_service/create_storage_service.ts +++ b/apps/backend/app/base_modules/FileManager/services/storage_service/create_storage_service.ts @@ -1,5 +1,5 @@ import env from '#start/env' -import { StorageService } from './storage_service_interface.js' +import type { StorageService } from './storage_service_interface.js' import { FSStorageService } from './fs_storage_service.js' import { S3StorageService } from './s3_storage_service.js' diff --git a/apps/backend/app/base_modules/FileManager/services/storage_service/fs_storage_service.ts b/apps/backend/app/base_modules/FileManager/services/storage_service/fs_storage_service.ts index 47acd8b..41a6049 100644 --- a/apps/backend/app/base_modules/FileManager/services/storage_service/fs_storage_service.ts +++ b/apps/backend/app/base_modules/FileManager/services/storage_service/fs_storage_service.ts @@ -4,9 +4,9 @@ import { DirNotFoundException, FileNotFoundException, } from '#exceptions/file_manager_exceptions' -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 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 { @@ -14,21 +14,41 @@ export class FSStorageService implements StorageService { 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) { - const response = await this.disk.listAll(oldPath, { recursive: true }) + await this.disk.put(this.directoryMarkerService.markerPath(newPath), '') - 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('/') + 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(item.key, target) + await this.disk.move(file.key, target) } await this.disk.deleteAll(oldPath) @@ -43,7 +63,15 @@ export class FSStorageService implements StorageService { } private async directoryExists(path: string): Promise { - return this.disk.exists(this.directoryMarkerService.markerPath(path)) + 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( @@ -226,11 +254,7 @@ export class FSStorageService implements StorageService { 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') + return this.getDirectoryEntry(path) } public writeFile: StorageService['writeFile'] = async (path, file) => { diff --git a/apps/backend/app/base_modules/FileManager/services/storage_service/s3_storage_service.ts b/apps/backend/app/base_modules/FileManager/services/storage_service/s3_storage_service.ts index 807de83..1e40ea8 100644 --- a/apps/backend/app/base_modules/FileManager/services/storage_service/s3_storage_service.ts +++ b/apps/backend/app/base_modules/FileManager/services/storage_service/s3_storage_service.ts @@ -5,9 +5,9 @@ import { DirNotFoundException, FileNotFoundException, } from '#exceptions/file_manager_exceptions' -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 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 { @@ -19,23 +19,38 @@ export class S3StorageService implements StorageService { 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) { - const oldPrefix = this.directoryPrefix(oldPath) - const response = await this.disk.listAll(oldPrefix, { recursive: true }) + await this.disk.put(this.directoryMarkerService.markerPath(newPath), '') - 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('/') + 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(item.key, target) + await this.disk.move(file.key, target) } - - await this.disk.deleteAll(oldPrefix) } public entryExists: StorageService['entryExists'] = async (path) => { @@ -47,7 +62,15 @@ export class S3StorageService implements StorageService { } private async directoryExists(path: string): Promise { - return this.disk.exists(this.directoryMarkerService.markerPath(path)) + 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( @@ -157,7 +180,9 @@ export class S3StorageService implements StorageService { if (entry.type === 'file') { await this.disk.delete(storagePath) } else { - await this.disk.deleteAll(this.directoryPrefix(storagePath)) + for (const file of await this.listDirectoryFiles(storagePath)) { + await this.disk.delete(file.key) + } } return { path } @@ -238,11 +263,7 @@ export class S3StorageService implements StorageService { 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') + return this.getDirectoryEntry(path) } public writeFile: StorageService['writeFile'] = async (path, file) => { diff --git a/apps/backend/app/base_modules/FileManager/services/storage_service/storage_service_interface.ts b/apps/backend/app/base_modules/FileManager/services/storage_service/storage_service_interface.ts index c56b793..74eb6e8 100644 --- a/apps/backend/app/base_modules/FileManager/services/storage_service/storage_service_interface.ts +++ b/apps/backend/app/base_modules/FileManager/services/storage_service/storage_service_interface.ts @@ -1,6 +1,6 @@ -import { Readable } from 'node:stream' -import { MultipartFile } from '@adonisjs/core/bodyparser' -import { Entry, FileEntry } from '#base_modules/FileManager/types/entries' +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 diff --git a/apps/backend/app/base_modules/WebManager/types/web_content.ts b/apps/backend/app/base_modules/WebManager/types/web_content.ts index cfd0b9c..13ba625 100644 --- a/apps/backend/app/base_modules/WebManager/types/web_content.ts +++ b/apps/backend/app/base_modules/WebManager/types/web_content.ts @@ -136,7 +136,7 @@ export type ComponentContent = export type BlockData = ComponentContent['data'] export interface SiteMenu { - title: string + title?: string items: NavigationLink[] } @@ -146,11 +146,11 @@ export interface SiteSettingsContent { ctaItems: NavigationLink[] } footer: { - copyright: string + copyright?: string menus: SiteMenu[] } legal: { - privacyPath: string - termsPath: string + privacyPath?: string + termsPath?: string } } diff --git a/apps/backend/app/base_modules/WebManager/validator/web_content_validator.ts b/apps/backend/app/base_modules/WebManager/validator/web_content_validator.ts index 9957f05..8ade659 100644 --- a/apps/backend/app/base_modules/WebManager/validator/web_content_validator.ts +++ b/apps/backend/app/base_modules/WebManager/validator/web_content_validator.ts @@ -164,7 +164,7 @@ const componentContentSchema = () => const siteMenuSchema = () => vine.object({ - title: vine.string(), + title: vine.string().optional(), items: vine.array(navigationLinkSchema()), }) @@ -175,12 +175,12 @@ const siteSettingsContentSchema = () => ctaItems: vine.array(navigationLinkSchema()), }), footer: vine.object({ - copyright: vine.string(), + copyright: vine.string().optional(), menus: vine.array(siteMenuSchema()), }), legal: vine.object({ - privacyPath: vine.string(), - termsPath: vine.string(), + privacyPath: vine.string().optional(), + termsPath: vine.string().optional(), }), }) diff --git a/apps/backend/tests/unit/fs_storage_directories.spec.ts b/apps/backend/tests/unit/fs_storage_directories.spec.ts new file mode 100644 index 0000000..cd39f82 --- /dev/null +++ b/apps/backend/tests/unit/fs_storage_directories.spec.ts @@ -0,0 +1,54 @@ +import { test } from '@japa/runner' +import drive from '@adonisjs/drive/services/main' +import { FSStorageService } from '#base_modules/FileManager/services/storage_service/fs_storage_service' + +const disk = drive.use('fs') +const storage = new FSStorageService() + +async function cleanup() { + for (const dir of ['aa', 'aab', 'cc', 'ghostdir', 'ghostdir2']) { + await disk.deleteAll(dir) + } +} + +test.group('FSStorageService directories', (group) => { + group.each.setup(() => cleanup()) + group.each.teardown(() => cleanup()) + + test('finds a directory that has no .keep marker', async ({ assert }) => { + await disk.put('ghostdir/a.txt', 'hello') + + assert.isTrue(await storage.entryExists('ghostdir')) + assert.deepInclude(await storage.getEntry('ghostdir'), { type: 'folder', name: 'ghostdir' }) + }) + + test('moves a marker-less directory with its contents', async ({ assert }) => { + await disk.put('ghostdir/nested/a.txt', 'hello') + + await storage.moveEntry('ghostdir', 'ghostdir2') + + assert.isFalse(await disk.exists('ghostdir/nested/a.txt')) + assert.equal(await disk.get('ghostdir2/nested/a.txt'), 'hello') + assert.deepInclude(await storage.getEntry('ghostdir2'), { type: 'folder' }) + }) + + test('does not touch a sibling directory sharing the same name prefix', async ({ assert }) => { + await disk.put('aa/a.txt', 'from aa') + await disk.put('aab/a.txt', 'from aab') + + await storage.moveEntry('aa', 'cc') + + assert.equal(await disk.get('cc/a.txt'), 'from aa') + assert.equal(await disk.get('aab/a.txt'), 'from aab') + }) + + test('deletes a directory without touching a prefix sibling', async ({ assert }) => { + await disk.put('aa/a.txt', 'from aa') + await disk.put('aab/a.txt', 'from aab') + + await storage.deleteEntry('aa') + + assert.isFalse(await disk.exists('aa/a.txt')) + assert.equal(await disk.get('aab/a.txt'), 'from aab') + }) +}) diff --git a/docker-compose.yml b/docker-compose.yml index c1141a9..754e1b0 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -131,6 +131,7 @@ services: - backend volumes: - ./apps/web:/app/apps/web:cached + - /app/apps/web/node_modules - /app/apps/web/.next environment: HOSTNAME: 0.0.0.0