From 4ae6befc7be874e7a626bf1e27f7eb4c9e473dce Mon Sep 17 00:00:00 2001 From: root Date: Sun, 21 Dec 2025 10:09:54 -0500 Subject: [PATCH] Initial Version --- .gitignore | 37 + .stubs/mongodb.stub.php | 143 + LICENSE | 0 bin/console | 117 + bin/phpunit | 4 + composer.json | 43 + composer.lock | 3148 ++++++++++ config/system.php | 40 + core/lib/Application.php | 216 + core/lib/Console/ModuleDisableCommand.php | 92 + core/lib/Console/ModuleEnableCommand.php | 86 + core/lib/Console/ModuleListCommand.php | 86 + .../Controllers/AuthenticationController.php | 361 ++ core/lib/Controllers/DefaultController.php | 144 + core/lib/Controllers/InitController.php | 95 + core/lib/Controllers/ModuleController.php | 68 + .../Controllers/UserAccountsController.php | 251 + .../lib/Controllers/UserProfileController.php | 76 + core/lib/Controllers/UserRolesController.php | 201 + .../Controllers/UserSettingsController.php | 71 + core/lib/Db/Client.php | 76 + core/lib/Db/Collection.php | 295 + core/lib/Db/Cursor.php | 86 + core/lib/Db/DataStore.php | 97 + core/lib/Db/Database.php | 104 + core/lib/Db/ObjectId.php | 71 + core/lib/Db/UTCDateTime.php | 89 + core/lib/Http/Cookie.php | 407 ++ .../Http/Exception/BadRequestException.php | 11 + .../Exception/ConflictingHeadersException.php | 12 + core/lib/Http/Exception/JsonException.php | 12 + .../Exception/SessionNotFoundException.php | 12 + .../SuspiciousOperationException.php | 12 + .../Exception/UnexpectedValueException.php | 9 + core/lib/Http/File/UploadedFile.php | 288 + core/lib/Http/HeaderParameters.php | 275 + core/lib/Http/HeaderUtils.php | 298 + .../Middleware/AuthenticationMiddleware.php | 38 + .../Http/Middleware/FirewallMiddleware.php | 32 + .../Http/Middleware/MiddlewareInterface.php | 21 + .../Http/Middleware/MiddlewarePipeline.php | 112 + .../Middleware/RequestHandlerInterface.php | 20 + core/lib/Http/Middleware/RouterMiddleware.php | 62 + core/lib/Http/Middleware/TenantMiddleware.php | 35 + core/lib/Http/Request/Request.php | 2107 +++++++ .../Http/Request/RequestFileCollection.php | 129 + core/lib/Http/Request/RequestHeaderAccept.php | 154 + .../Http/Request/RequestHeaderAcceptItem.php | 163 + .../Http/Request/RequestHeaderParameters.php | 12 + .../Http/Request/RequestInputParameters.php | 155 + core/lib/Http/Request/RequestParameters.php | 260 + .../Http/Request/RequestServerParameters.php | 99 + core/lib/Http/Response/FileResponse.php | 78 + core/lib/Http/Response/JsonResponse.php | 189 + core/lib/Http/Response/RedirectResponse.php | 94 + core/lib/Http/Response/Response.php | 1336 +++++ .../Response/ResponseHeaderParameters.php | 275 + .../Http/Response/StreamedJsonResponse.php | 164 + core/lib/Http/Response/StreamedResponse.php | 152 + core/lib/Http/Session/SessionInterface.php | 148 + core/lib/Injection/Builder.php | 5 + core/lib/Injection/Container.php | 5 + core/lib/Kernel.php | 494 ++ core/lib/Logger/FileLogger.php | 125 + .../lib/Models/Firewall/FirewallLogObject.php | 255 + .../Models/Firewall/FirewallRuleObject.php | 241 + core/lib/Models/Identity/User.php | 156 + core/lib/Models/Tenant/DomainCollection.php | 13 + .../Models/Tenant/TenantAuthentication.php | 22 + core/lib/Models/Tenant/TenantCollection.php | 13 + .../lib/Models/Tenant/TenantConfiguration.php | 29 + core/lib/Models/Tenant/TenantObject.php | 148 + core/lib/Models/Tenant/TenantSecurity.php | 22 + core/lib/Module/Module.php | 112 + core/lib/Module/ModuleAutoloader.php | 195 + core/lib/Module/ModuleCollection.php | 23 + core/lib/Module/ModuleManager.php | 595 ++ core/lib/Module/ModuleObject.php | 179 + core/lib/Module/Store/ModuleEntry.php | 119 + core/lib/Module/Store/ModuleStore.php | 66 + core/lib/Resource/ProviderManager.php | 89 + core/lib/Routing/Route.php | 30 + core/lib/Routing/Router.php | 244 + .../Authentication/AuthenticationRequest.php | 180 + .../Authentication/AuthenticationResponse.php | 272 + core/lib/Security/AuthenticationManager.php | 806 +++ .../Authorization/PermissionChecker.php | 124 + core/lib/Server.php | 92 + core/lib/Service/ConfigurationService.php | 228 + core/lib/Service/FirewallService.php | 630 ++ core/lib/Service/SecurityService.php | 145 + core/lib/Service/TenantService.php | 19 + core/lib/Service/TokenService.php | 309 + core/lib/Service/UserAccountsService.php | 179 + core/lib/Service/UserRolesService.php | 143 + core/lib/SessionIdentity.php | 94 + core/lib/SessionTenant.php | 125 + core/lib/Stores/FirewallStore.php | 309 + core/lib/Stores/TenantStore.php | 75 + core/lib/Stores/UserAccountsStore.php | 297 + core/lib/Stores/UserRolesStore.php | 142 + core/lib/index.php | 22 + core/src/App.vue | 20 + core/src/assets/images/favicon.svg | 16 + .../assets/images/maintenance/Error404.png | Bin 0 -> 26107 bytes .../assets/images/maintenance/Error500.png | Bin 0 -> 17083 bytes .../src/assets/images/maintenance/TwoCone.png | Bin 0 -> 10764 bytes .../assets/images/maintenance/coming-soon.png | Bin 0 -> 21545 bytes .../images/maintenance/under-construction.svg | 353 ++ core/src/assets/images/users/avatar-1.png | Bin 0 -> 2455 bytes core/src/assets/images/users/avatar-2.png | Bin 0 -> 2332 bytes core/src/assets/images/users/avatar-3.png | Bin 0 -> 2174 bytes core/src/assets/images/users/avatar-4.png | Bin 0 -> 2152 bytes core/src/assets/images/users/avatar-5.png | Bin 0 -> 2308 bytes core/src/assets/images/users/avatar-group.png | Bin 0 -> 4045 bytes core/src/components/shared/BaseBreadcrumb.vue | 16 + core/src/components/shared/UiParentCard.vue | 16 + core/src/composables/index.ts | 6 + core/src/composables/useClipboard.ts | 45 + core/src/composables/useSnackbar.ts | 38 + core/src/composables/useUser.ts | 103 + core/src/config.ts | 15 + core/src/layouts/blank/BlankLayout.vue | 9 + core/src/layouts/footer/LayoutFooter.vue | 29 + core/src/layouts/header/LayoutHeader.vue | 105 + core/src/layouts/header/NotificationDD.vue | 119 + core/src/layouts/header/SearchBarPanel.vue | 13 + core/src/layouts/logo/LogoDark.vue | 42 + core/src/layouts/menus/LayoutSystemMenu.vue | 120 + .../menus/LayoutSystemMenuGroupDynamic.vue | 42 + .../menus/LayoutSystemMenuGroupStatic.vue | 15 + .../layouts/menus/LayoutSystemMenuItem.vue | 29 + core/src/layouts/menus/LayoutUserMenu.vue | 110 + core/src/models/userProfile.ts | 98 + core/src/models/userSettings.ts | 73 + core/src/plugins/vuetify/defaults.ts | 148 + core/src/plugins/vuetify/icons.ts | 14 + core/src/plugins/vuetify/index.ts | 24 + core/src/plugins/vuetify/theme.ts | 144 + core/src/private.html | 23 + core/src/private.ts | 72 + core/src/public.html | 13 + core/src/public.ts | 39 + core/src/router/index.ts | 116 + core/src/scss/_override.scss | 115 + core/src/scss/_variables.scss | 140 + core/src/scss/components/_VAlert.scss | 37 + core/src/scss/components/_VBadge.scss | 11 + core/src/scss/components/_VBreadcrumb.scss | 32 + core/src/scss/components/_VButtons.scss | 68 + core/src/scss/components/_VCard.scss | 40 + core/src/scss/components/_VField.scss | 9 + core/src/scss/components/_VInput.scss | 55 + core/src/scss/components/_VList.scss | 47 + .../scss/components/_VNavigationDrawer.scss | 3 + core/src/scss/components/_VShadow.scss | 20 + core/src/scss/components/_VTextField.scss | 18 + core/src/scss/components/_VTextarea.scss | 7 + core/src/scss/layout/_container.scss | 146 + core/src/scss/layout/_footer.scss | 25 + core/src/scss/layout/_sidebar.scss | 165 + core/src/scss/layout/_topbar.scss | 41 + core/src/scss/style.scss | 9 + core/src/services/authenticationService.ts | 100 + core/src/services/user/index.ts | 1 + core/src/services/user/userService.ts | 153 + core/src/shims-vue.d.ts | 5 + core/src/stores/integrationStore.ts | 183 + core/src/stores/layoutStore.ts | 85 + core/src/stores/moduleStore.ts | 34 + core/src/stores/tenantStore.ts | 27 + core/src/stores/userStore.ts | 275 + core/src/types/authenticationTypes.ts | 87 + core/src/types/env.d.ts | 1 + core/src/types/integrationTypes.ts | 45 + core/src/types/layouts/layoutSystemMenu.ts | 30 + core/src/types/moduleTypes.ts | 67 + core/src/types/user/userProfileTypes.ts | 13 + core/src/types/user/userSettingsTypes.ts | 7 + core/src/utils/helpers/fetch-wrapper-core.ts | 161 + core/src/utils/helpers/fetch-wrapper.ts | 10 + core/src/utils/helpers/shared.ts | 6 + core/src/utils/modules.ts | 107 + core/src/views/PrivateLayout.vue | 28 + core/src/views/PublicLayout.vue | 26 + core/src/views/authentication/AuthFooter.vue | 38 + core/src/views/authentication/AuthLogin.vue | 586 ++ core/src/views/authentication/LoginPage.vue | 53 + .../pages/maintenance/error/Error404Page.vue | 50 + .../pages/maintenance/error/Error500Page.vue | 28 + deploy/nginx/vhost-dev.conf | 125 + deploy/nginx/vhost.conf | 83 + deploy/supervisor/mail-daemon.conf | 22 + deploy/systemd/mail-daemon.service | 30 + eslint.config.js | 18 + package-lock.json | 5307 +++++++++++++++++ package.json | 56 + phpunit.dist.xml | 42 + scripts/generate-vendor-shims.ts | 95 + shared/lib/Blob/MimeTypes.php | 219 + shared/lib/Blob/Signature.php | 230 + shared/lib/Cache/BlobCacheInterface.php | 123 + shared/lib/Cache/CacheInterface.php | 72 + shared/lib/Cache/CacheScope.php | 50 + shared/lib/Cache/EphemeralCacheInterface.php | 57 + shared/lib/Cache/PersistentCacheInterface.php | 66 + shared/lib/Cache/Store/FileBlobCache.php | 412 ++ shared/lib/Cache/Store/FileEphemeralCache.php | 346 ++ .../lib/Cache/Store/FilePersistentCache.php | 433 ++ .../Chrono/Collection/CollectionContent.php | 24 + .../Collection/CollectionPermissions.php | 26 + .../lib/Chrono/Collection/CollectionRoles.php | 23 + .../lib/Chrono/Collection/ICollectionBase.php | 160 + .../Chrono/Collection/ICollectionMutable.php | 58 + .../lib/Chrono/Entity/EntityPermissions.php | 25 + shared/lib/Chrono/Entity/IEntityBase.php | 93 + shared/lib/Chrono/Entity/IEntityMutable.php | 51 + .../Chrono/Event/EventAvailabilityTypes.php | 15 + shared/lib/Chrono/Event/EventCommonObject.php | 50 + .../Event/EventLocationPhysicalCollection.php | 20 + .../Event/EventLocationPhysicalObject.php | 22 + .../Event/EventLocationVirtualCollection.php | 20 + .../Event/EventLocationVirtualObject.php | 22 + .../Chrono/Event/EventMutationCollection.php | 20 + .../lib/Chrono/Event/EventMutationObject.php | 21 + .../Event/EventNotificationAnchorTypes.php | 15 + .../Event/EventNotificationCollection.php | 20 + .../Chrono/Event/EventNotificationObject.php | 24 + .../Event/EventNotificationPatterns.php | 16 + .../Chrono/Event/EventNotificationTypes.php | 16 + shared/lib/Chrono/Event/EventObject.php | 31 + .../Chrono/Event/EventOccurrenceObject.php | 36 + .../Event/EventOccurrencePatternTypes.php | 15 + .../Event/EventOccurrencePrecisionTypes.php | 20 + .../lib/Chrono/Event/EventOrganizerObject.php | 20 + .../Event/EventParticipantCollection.php | 20 + .../Chrono/Event/EventParticipantObject.php | 31 + .../Chrono/Event/EventParticipantRealm.php | 15 + .../Event/EventParticipantRoleCollection.php | 20 + .../Event/EventParticipantRoleTypes.php | 19 + .../Event/EventParticipantStatusTypes.php | 18 + .../Chrono/Event/EventParticipantTypes.php | 18 + .../Chrono/Event/EventSensitivityTypes.php | 16 + .../lib/Chrono/Event/EventTagCollection.php | 20 + shared/lib/Chrono/Provider/IProviderBase.php | 96 + .../Provider/IProviderServiceMutate.php | 50 + shared/lib/Chrono/Service/IServiceBase.php | 197 + .../Service/IServiceCollectionMutable.php | 79 + .../Chrono/Service/IServiceEntityMutable.php | 81 + shared/lib/Chrono/Service/IServiceMutable.php | 30 + shared/lib/Controller/ControllerAbstract.php | 8 + shared/lib/Event/Event.php | 146 + shared/lib/Event/EventBus.php | 186 + shared/lib/Event/SecurityEvent.php | 303 + shared/lib/Exception/BaseException.php | 175 + shared/lib/Exception/RuntimeException.php | 9 + shared/lib/Files/Node/INodeBase.php | 105 + shared/lib/Files/Node/INodeCollectionBase.php | 22 + .../lib/Files/Node/INodeCollectionMutable.php | 35 + shared/lib/Files/Node/INodeEntityBase.php | 54 + shared/lib/Files/Node/INodeEntityMutable.php | 56 + shared/lib/Files/Node/NodeType.php | 23 + shared/lib/Files/Provider/IProviderBase.php | 16 + shared/lib/Files/Service/IServiceBase.php | 333 ++ .../Service/IServiceCollectionMutable.php | 100 + .../Files/Service/IServiceEntityMutable.php | 158 + shared/lib/Files/Service/IServiceMutable.php | 30 + shared/lib/IpUtils.php | 275 + shared/lib/Json/JsonDeserializable.php | 11 + shared/lib/Json/JsonSerializable.php | 11 + .../lib/Json/JsonSerializableCollection.php | 68 + shared/lib/Json/JsonSerializableObject.php | 165 + .../Collection/CollectionBaseAbstract.php | 31 + .../Collection/CollectionBaseInterface.php | 30 + .../Collection/CollectionMutableAbstract.php | 49 + .../Collection/CollectionMutableInterface.php | 32 + .../CollectionPropertiesBaseAbstract.php | 68 + .../CollectionPropertiesBaseInterface.php | 35 + .../CollectionPropertiesMutableAbstract.php | 65 + .../CollectionPropertiesMutableInterface.php | 26 + .../lib/Mail/Collection/CollectionRoles.php | 37 + shared/lib/Mail/Entity/EntityBaseAbstract.php | 32 + .../lib/Mail/Entity/EntityBaseInterface.php | 27 + .../lib/Mail/Entity/EntityMutableAbstract.php | 48 + .../Mail/Entity/EntityMutableInterface.php | 29 + shared/lib/Mail/Exception/SendException.php | 68 + shared/lib/Mail/Object/Address.php | 127 + shared/lib/Mail/Object/AddressInterface.php | 63 + shared/lib/Mail/Object/Attachment.php | 194 + .../lib/Mail/Object/AttachmentInterface.php | 93 + .../Mail/Object/MessagePartBaseAbstract.php | 122 + .../lib/Mail/Object/MessagePartInterface.php | 104 + .../Object/MessagePartMutableAbstract.php | 146 + .../Object/MessagePropertiesBaseAbstract.php | 400 ++ .../Object/MessagePropertiesBaseInterface.php | 252 + .../MessagePropertiesMutableAbstract.php | 455 ++ .../MessagePropertiesMutableInterface.php | 255 + .../Mail/Provider/ProviderBaseInterface.php | 41 + .../ProviderServiceDiscoverInterface.php | 52 + .../ProviderServiceMutateInterface.php | 35 + .../Provider/ProviderServiceTestInterface.php | 77 + shared/lib/Mail/Queue/SendOptions.php | 81 + .../lib/Mail/Service/ServiceBaseInterface.php | 237 + .../ServiceCollectionMutableInterface.php | 89 + .../Service/ServiceConfigurableInterface.php | 26 + .../Service/ServiceEntityMutableInterface.php | 103 + .../ServiceEntityTransmitInterface.php | 48 + .../Mail/Service/ServiceMutableInterface.php | 46 + shared/lib/Module/ModuleBrowserInterface.php | 13 + shared/lib/Module/ModuleConsoleInterface.php | 13 + shared/lib/Module/ModuleInstanceAbstract.php | 73 + shared/lib/Module/ModuleInstanceInterface.php | 61 + .../People/Collection/CollectionContent.php | 24 + .../Collection/CollectionPermissions.php | 26 + .../lib/People/Collection/CollectionRoles.php | 24 + .../lib/People/Collection/ICollectionBase.php | 160 + .../People/Collection/ICollectionMutable.php | 58 + .../lib/People/Entity/EntityPermissions.php | 25 + shared/lib/People/Entity/IEntityBase.php | 94 + shared/lib/People/Entity/IEntityMutable.php | 52 + .../Individual/IndividualAliasCollection.php | 20 + .../Individual/IndividualAliasObject.php | 20 + .../IndividualAnniversaryCollection.php | 20 + .../IndividualAnniversaryObject.php | 21 + .../Individual/IndividualAnniversaryTypes.php | 16 + .../Individual/IndividualCryptoCollection.php | 20 + .../Individual/IndividualCryptoObject.php | 21 + .../Individual/IndividualEmailCollection.php | 20 + .../Individual/IndividualEmailObject.php | 20 + .../IndividualLanguageCollection.php | 18 + .../Individual/IndividualLanguageObject.php | 22 + .../Individual/IndividualMediaCollection.php | 20 + .../Individual/IndividualMediaObject.php | 24 + .../Individual/IndividualNameObject.php | 30 + .../Individual/IndividualNoteCollection.php | 20 + .../Individual/IndividualNoteObject.php | 25 + .../People/Individual/IndividualObject.php | 62 + .../IndividualOrganizationCollection.php | 20 + .../IndividualOrganizationObject.php | 29 + .../Individual/IndividualPhoneCollection.php | 20 + .../Individual/IndividualPhoneObject.php | 21 + .../IndividualPhysicalLocationCollection.php | 20 + .../IndividualPhysicalLocationObject.php | 31 + .../IndividualPronounCollection.php | 20 + .../Individual/IndividualPronounObject.php | 21 + .../Individual/IndividualTagCollection.php | 20 + .../Individual/IndividualTitleCollection.php | 20 + .../Individual/IndividualTitleObject.php | 23 + .../Individual/IndividualTitleTypes.php | 15 + .../IndividualVirtualLocationCollection.php | 20 + .../IndividualVirtualLocationObject.php | 22 + shared/lib/People/Provider/IProviderBase.php | 96 + .../Provider/IProviderServiceMutate.php | 69 + shared/lib/People/Service/IServiceBase.php | 221 + .../Service/IServiceCollectionMutable.php | 79 + .../People/Service/IServiceEntityMutable.php | 82 + shared/lib/People/Service/IServiceMutable.php | 30 + shared/lib/Resource/Delta/Delta.php | 45 + shared/lib/Resource/Delta/DeltaCollection.php | 18 + .../Exceptions/InvalidParameterException.php | 21 + .../Exceptions/UnauthorizedException.php | 21 + .../Exceptions/UnsupportedException.php | 21 + shared/lib/Resource/Filter/Filter.php | 130 + .../Filter/FilterComparisonOperator.php | 23 + .../Filter/FilterConjunctionOperator.php | 16 + shared/lib/Resource/Filter/IFilter.php | 53 + .../Provider/Node/NodeBaseAbstract.php | 116 + .../Provider/Node/NodeBaseInterface.php | 95 + .../Provider/Node/NodeMutableAbstract.php | 95 + .../Provider/Node/NodeMutableInterface.php | 35 + .../Node/NodePropertiesBaseAbstract.php | 57 + .../Node/NodePropertiesBaseInterface.php | 37 + .../Node/NodePropertiesMutableAbstract.php | 34 + .../Node/NodePropertiesMutableInterface.php | 21 + .../Resource/Provider/ProviderInterface.php | 44 + .../ResourceProviderBaseInterface.php | 84 + ...ResourceProviderServiceMutateInterface.php | 44 + .../Provider/ResourceServiceBaseInterface.php | 98 + .../ResourceServiceConfigureInterface.php | 62 + .../Provider/ResourceServiceIdentityBasic.php | 61 + .../ResourceServiceIdentityCertificate.php | 82 + .../ResourceServiceIdentityInterface.php | 38 + .../Provider/ResourceServiceIdentityOAuth.php | 121 + .../Provider/ResourceServiceIdentityToken.php | 42 + .../Provider/ResourceServiceLocationFile.php | 51 + .../ResourceServiceLocationInterface.php | 37 + .../ResourceServiceLocationSocketSole.php | 131 + .../ResourceServiceLocationSocketSplit.php | 240 + .../Provider/ResourceServiceLocationUri.php | 150 + .../ResourceServiceMutateInterface.php | 57 + shared/lib/Resource/Range/IRange.php | 21 + shared/lib/Resource/Range/IRangeDate.php | 40 + shared/lib/Resource/Range/IRangeTally.php | 56 + shared/lib/Resource/Range/Range.php | 23 + shared/lib/Resource/Range/RangeAnchorType.php | 15 + shared/lib/Resource/Range/RangeDate.php | 65 + shared/lib/Resource/Range/RangeTally.php | 81 + shared/lib/Resource/Range/RangeType.php | 16 + .../Resource/Selector/CollectionSelector.php | 22 + .../lib/Resource/Selector/EntitySelector.php | 46 + .../Resource/Selector/SelectorAbstract.php | 129 + .../lib/Resource/Selector/ServiceSelector.php | 22 + .../lib/Resource/Selector/SourceSelector.php | 22 + shared/lib/Resource/Sort/ISort.php | 42 + shared/lib/Resource/Sort/Sort.php | 57 + .../lib/Routing/Attributes/AnonymousRoute.php | 15 + .../Routing/Attributes/AuthenticatedRoute.php | 26 + .../AuthenticationProviderAbstract.php | 68 + .../AuthenticationProviderInterface.php | 100 + .../Authentication/AuthenticationSession.php | 217 + .../Authentication/ProviderContext.php | 75 + .../Authentication/ProviderResult.php | 156 + shared/lib/Security/Crypto.php | 170 + .../Utile/Collection/CollectionAbstract.php | 136 + shared/lib/Utile/UUID.php | 54 + tests/php/bootstrap.php | 7 + .../Json/JsonSerializableObjectTest.php | 64 + .../Individual/IndividualObjectTest.php | 109 + tsconfig.app.json | 21 + tsconfig.json | 7 + tsconfig.node.json | 25 + vite.config.ts | 108 + 422 files changed, 47225 insertions(+) create mode 100644 .gitignore create mode 100644 .stubs/mongodb.stub.php create mode 100644 LICENSE create mode 100755 bin/console create mode 100755 bin/phpunit create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/system.php create mode 100644 core/lib/Application.php create mode 100644 core/lib/Console/ModuleDisableCommand.php create mode 100644 core/lib/Console/ModuleEnableCommand.php create mode 100644 core/lib/Console/ModuleListCommand.php create mode 100644 core/lib/Controllers/AuthenticationController.php create mode 100644 core/lib/Controllers/DefaultController.php create mode 100644 core/lib/Controllers/InitController.php create mode 100644 core/lib/Controllers/ModuleController.php create mode 100644 core/lib/Controllers/UserAccountsController.php create mode 100644 core/lib/Controllers/UserProfileController.php create mode 100644 core/lib/Controllers/UserRolesController.php create mode 100644 core/lib/Controllers/UserSettingsController.php create mode 100644 core/lib/Db/Client.php create mode 100644 core/lib/Db/Collection.php create mode 100644 core/lib/Db/Cursor.php create mode 100644 core/lib/Db/DataStore.php create mode 100644 core/lib/Db/Database.php create mode 100644 core/lib/Db/ObjectId.php create mode 100644 core/lib/Db/UTCDateTime.php create mode 100644 core/lib/Http/Cookie.php create mode 100644 core/lib/Http/Exception/BadRequestException.php create mode 100644 core/lib/Http/Exception/ConflictingHeadersException.php create mode 100644 core/lib/Http/Exception/JsonException.php create mode 100644 core/lib/Http/Exception/SessionNotFoundException.php create mode 100644 core/lib/Http/Exception/SuspiciousOperationException.php create mode 100644 core/lib/Http/Exception/UnexpectedValueException.php create mode 100644 core/lib/Http/File/UploadedFile.php create mode 100644 core/lib/Http/HeaderParameters.php create mode 100644 core/lib/Http/HeaderUtils.php create mode 100644 core/lib/Http/Middleware/AuthenticationMiddleware.php create mode 100644 core/lib/Http/Middleware/FirewallMiddleware.php create mode 100644 core/lib/Http/Middleware/MiddlewareInterface.php create mode 100644 core/lib/Http/Middleware/MiddlewarePipeline.php create mode 100644 core/lib/Http/Middleware/RequestHandlerInterface.php create mode 100644 core/lib/Http/Middleware/RouterMiddleware.php create mode 100644 core/lib/Http/Middleware/TenantMiddleware.php create mode 100644 core/lib/Http/Request/Request.php create mode 100644 core/lib/Http/Request/RequestFileCollection.php create mode 100644 core/lib/Http/Request/RequestHeaderAccept.php create mode 100644 core/lib/Http/Request/RequestHeaderAcceptItem.php create mode 100644 core/lib/Http/Request/RequestHeaderParameters.php create mode 100644 core/lib/Http/Request/RequestInputParameters.php create mode 100644 core/lib/Http/Request/RequestParameters.php create mode 100644 core/lib/Http/Request/RequestServerParameters.php create mode 100644 core/lib/Http/Response/FileResponse.php create mode 100644 core/lib/Http/Response/JsonResponse.php create mode 100644 core/lib/Http/Response/RedirectResponse.php create mode 100644 core/lib/Http/Response/Response.php create mode 100644 core/lib/Http/Response/ResponseHeaderParameters.php create mode 100644 core/lib/Http/Response/StreamedJsonResponse.php create mode 100644 core/lib/Http/Response/StreamedResponse.php create mode 100644 core/lib/Http/Session/SessionInterface.php create mode 100644 core/lib/Injection/Builder.php create mode 100644 core/lib/Injection/Container.php create mode 100644 core/lib/Kernel.php create mode 100644 core/lib/Logger/FileLogger.php create mode 100644 core/lib/Models/Firewall/FirewallLogObject.php create mode 100644 core/lib/Models/Firewall/FirewallRuleObject.php create mode 100644 core/lib/Models/Identity/User.php create mode 100644 core/lib/Models/Tenant/DomainCollection.php create mode 100644 core/lib/Models/Tenant/TenantAuthentication.php create mode 100644 core/lib/Models/Tenant/TenantCollection.php create mode 100644 core/lib/Models/Tenant/TenantConfiguration.php create mode 100644 core/lib/Models/Tenant/TenantObject.php create mode 100644 core/lib/Models/Tenant/TenantSecurity.php create mode 100644 core/lib/Module/Module.php create mode 100644 core/lib/Module/ModuleAutoloader.php create mode 100644 core/lib/Module/ModuleCollection.php create mode 100644 core/lib/Module/ModuleManager.php create mode 100644 core/lib/Module/ModuleObject.php create mode 100644 core/lib/Module/Store/ModuleEntry.php create mode 100644 core/lib/Module/Store/ModuleStore.php create mode 100644 core/lib/Resource/ProviderManager.php create mode 100644 core/lib/Routing/Route.php create mode 100644 core/lib/Routing/Router.php create mode 100644 core/lib/Security/Authentication/AuthenticationRequest.php create mode 100644 core/lib/Security/Authentication/AuthenticationResponse.php create mode 100644 core/lib/Security/AuthenticationManager.php create mode 100644 core/lib/Security/Authorization/PermissionChecker.php create mode 100644 core/lib/Server.php create mode 100644 core/lib/Service/ConfigurationService.php create mode 100644 core/lib/Service/FirewallService.php create mode 100644 core/lib/Service/SecurityService.php create mode 100644 core/lib/Service/TenantService.php create mode 100644 core/lib/Service/TokenService.php create mode 100644 core/lib/Service/UserAccountsService.php create mode 100644 core/lib/Service/UserRolesService.php create mode 100644 core/lib/SessionIdentity.php create mode 100644 core/lib/SessionTenant.php create mode 100644 core/lib/Stores/FirewallStore.php create mode 100644 core/lib/Stores/TenantStore.php create mode 100644 core/lib/Stores/UserAccountsStore.php create mode 100644 core/lib/Stores/UserRolesStore.php create mode 100644 core/lib/index.php create mode 100644 core/src/App.vue create mode 100644 core/src/assets/images/favicon.svg create mode 100644 core/src/assets/images/maintenance/Error404.png create mode 100644 core/src/assets/images/maintenance/Error500.png create mode 100644 core/src/assets/images/maintenance/TwoCone.png create mode 100644 core/src/assets/images/maintenance/coming-soon.png create mode 100644 core/src/assets/images/maintenance/under-construction.svg create mode 100644 core/src/assets/images/users/avatar-1.png create mode 100644 core/src/assets/images/users/avatar-2.png create mode 100644 core/src/assets/images/users/avatar-3.png create mode 100644 core/src/assets/images/users/avatar-4.png create mode 100644 core/src/assets/images/users/avatar-5.png create mode 100644 core/src/assets/images/users/avatar-group.png create mode 100644 core/src/components/shared/BaseBreadcrumb.vue create mode 100644 core/src/components/shared/UiParentCard.vue create mode 100644 core/src/composables/index.ts create mode 100644 core/src/composables/useClipboard.ts create mode 100644 core/src/composables/useSnackbar.ts create mode 100644 core/src/composables/useUser.ts create mode 100644 core/src/config.ts create mode 100644 core/src/layouts/blank/BlankLayout.vue create mode 100644 core/src/layouts/footer/LayoutFooter.vue create mode 100644 core/src/layouts/header/LayoutHeader.vue create mode 100644 core/src/layouts/header/NotificationDD.vue create mode 100644 core/src/layouts/header/SearchBarPanel.vue create mode 100644 core/src/layouts/logo/LogoDark.vue create mode 100644 core/src/layouts/menus/LayoutSystemMenu.vue create mode 100644 core/src/layouts/menus/LayoutSystemMenuGroupDynamic.vue create mode 100644 core/src/layouts/menus/LayoutSystemMenuGroupStatic.vue create mode 100644 core/src/layouts/menus/LayoutSystemMenuItem.vue create mode 100644 core/src/layouts/menus/LayoutUserMenu.vue create mode 100644 core/src/models/userProfile.ts create mode 100644 core/src/models/userSettings.ts create mode 100644 core/src/plugins/vuetify/defaults.ts create mode 100644 core/src/plugins/vuetify/icons.ts create mode 100644 core/src/plugins/vuetify/index.ts create mode 100644 core/src/plugins/vuetify/theme.ts create mode 100644 core/src/private.html create mode 100644 core/src/private.ts create mode 100644 core/src/public.html create mode 100644 core/src/public.ts create mode 100644 core/src/router/index.ts create mode 100644 core/src/scss/_override.scss create mode 100644 core/src/scss/_variables.scss create mode 100644 core/src/scss/components/_VAlert.scss create mode 100644 core/src/scss/components/_VBadge.scss create mode 100644 core/src/scss/components/_VBreadcrumb.scss create mode 100644 core/src/scss/components/_VButtons.scss create mode 100644 core/src/scss/components/_VCard.scss create mode 100644 core/src/scss/components/_VField.scss create mode 100644 core/src/scss/components/_VInput.scss create mode 100644 core/src/scss/components/_VList.scss create mode 100644 core/src/scss/components/_VNavigationDrawer.scss create mode 100644 core/src/scss/components/_VShadow.scss create mode 100644 core/src/scss/components/_VTextField.scss create mode 100644 core/src/scss/components/_VTextarea.scss create mode 100644 core/src/scss/layout/_container.scss create mode 100644 core/src/scss/layout/_footer.scss create mode 100644 core/src/scss/layout/_sidebar.scss create mode 100644 core/src/scss/layout/_topbar.scss create mode 100644 core/src/scss/style.scss create mode 100644 core/src/services/authenticationService.ts create mode 100644 core/src/services/user/index.ts create mode 100644 core/src/services/user/userService.ts create mode 100644 core/src/shims-vue.d.ts create mode 100644 core/src/stores/integrationStore.ts create mode 100644 core/src/stores/layoutStore.ts create mode 100644 core/src/stores/moduleStore.ts create mode 100644 core/src/stores/tenantStore.ts create mode 100644 core/src/stores/userStore.ts create mode 100644 core/src/types/authenticationTypes.ts create mode 100644 core/src/types/env.d.ts create mode 100644 core/src/types/integrationTypes.ts create mode 100644 core/src/types/layouts/layoutSystemMenu.ts create mode 100644 core/src/types/moduleTypes.ts create mode 100644 core/src/types/user/userProfileTypes.ts create mode 100644 core/src/types/user/userSettingsTypes.ts create mode 100644 core/src/utils/helpers/fetch-wrapper-core.ts create mode 100644 core/src/utils/helpers/fetch-wrapper.ts create mode 100644 core/src/utils/helpers/shared.ts create mode 100644 core/src/utils/modules.ts create mode 100644 core/src/views/PrivateLayout.vue create mode 100644 core/src/views/PublicLayout.vue create mode 100644 core/src/views/authentication/AuthFooter.vue create mode 100644 core/src/views/authentication/AuthLogin.vue create mode 100644 core/src/views/authentication/LoginPage.vue create mode 100644 core/src/views/pages/maintenance/error/Error404Page.vue create mode 100644 core/src/views/pages/maintenance/error/Error500Page.vue create mode 100644 deploy/nginx/vhost-dev.conf create mode 100644 deploy/nginx/vhost.conf create mode 100644 deploy/supervisor/mail-daemon.conf create mode 100644 deploy/systemd/mail-daemon.service create mode 100644 eslint.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 phpunit.dist.xml create mode 100644 scripts/generate-vendor-shims.ts create mode 100644 shared/lib/Blob/MimeTypes.php create mode 100644 shared/lib/Blob/Signature.php create mode 100644 shared/lib/Cache/BlobCacheInterface.php create mode 100644 shared/lib/Cache/CacheInterface.php create mode 100644 shared/lib/Cache/CacheScope.php create mode 100644 shared/lib/Cache/EphemeralCacheInterface.php create mode 100644 shared/lib/Cache/PersistentCacheInterface.php create mode 100644 shared/lib/Cache/Store/FileBlobCache.php create mode 100644 shared/lib/Cache/Store/FileEphemeralCache.php create mode 100644 shared/lib/Cache/Store/FilePersistentCache.php create mode 100644 shared/lib/Chrono/Collection/CollectionContent.php create mode 100644 shared/lib/Chrono/Collection/CollectionPermissions.php create mode 100644 shared/lib/Chrono/Collection/CollectionRoles.php create mode 100644 shared/lib/Chrono/Collection/ICollectionBase.php create mode 100644 shared/lib/Chrono/Collection/ICollectionMutable.php create mode 100644 shared/lib/Chrono/Entity/EntityPermissions.php create mode 100644 shared/lib/Chrono/Entity/IEntityBase.php create mode 100644 shared/lib/Chrono/Entity/IEntityMutable.php create mode 100644 shared/lib/Chrono/Event/EventAvailabilityTypes.php create mode 100644 shared/lib/Chrono/Event/EventCommonObject.php create mode 100644 shared/lib/Chrono/Event/EventLocationPhysicalCollection.php create mode 100644 shared/lib/Chrono/Event/EventLocationPhysicalObject.php create mode 100644 shared/lib/Chrono/Event/EventLocationVirtualCollection.php create mode 100644 shared/lib/Chrono/Event/EventLocationVirtualObject.php create mode 100644 shared/lib/Chrono/Event/EventMutationCollection.php create mode 100644 shared/lib/Chrono/Event/EventMutationObject.php create mode 100644 shared/lib/Chrono/Event/EventNotificationAnchorTypes.php create mode 100644 shared/lib/Chrono/Event/EventNotificationCollection.php create mode 100644 shared/lib/Chrono/Event/EventNotificationObject.php create mode 100644 shared/lib/Chrono/Event/EventNotificationPatterns.php create mode 100644 shared/lib/Chrono/Event/EventNotificationTypes.php create mode 100644 shared/lib/Chrono/Event/EventObject.php create mode 100644 shared/lib/Chrono/Event/EventOccurrenceObject.php create mode 100644 shared/lib/Chrono/Event/EventOccurrencePatternTypes.php create mode 100644 shared/lib/Chrono/Event/EventOccurrencePrecisionTypes.php create mode 100644 shared/lib/Chrono/Event/EventOrganizerObject.php create mode 100644 shared/lib/Chrono/Event/EventParticipantCollection.php create mode 100644 shared/lib/Chrono/Event/EventParticipantObject.php create mode 100644 shared/lib/Chrono/Event/EventParticipantRealm.php create mode 100644 shared/lib/Chrono/Event/EventParticipantRoleCollection.php create mode 100644 shared/lib/Chrono/Event/EventParticipantRoleTypes.php create mode 100644 shared/lib/Chrono/Event/EventParticipantStatusTypes.php create mode 100644 shared/lib/Chrono/Event/EventParticipantTypes.php create mode 100644 shared/lib/Chrono/Event/EventSensitivityTypes.php create mode 100644 shared/lib/Chrono/Event/EventTagCollection.php create mode 100644 shared/lib/Chrono/Provider/IProviderBase.php create mode 100644 shared/lib/Chrono/Provider/IProviderServiceMutate.php create mode 100644 shared/lib/Chrono/Service/IServiceBase.php create mode 100644 shared/lib/Chrono/Service/IServiceCollectionMutable.php create mode 100644 shared/lib/Chrono/Service/IServiceEntityMutable.php create mode 100644 shared/lib/Chrono/Service/IServiceMutable.php create mode 100644 shared/lib/Controller/ControllerAbstract.php create mode 100644 shared/lib/Event/Event.php create mode 100644 shared/lib/Event/EventBus.php create mode 100644 shared/lib/Event/SecurityEvent.php create mode 100644 shared/lib/Exception/BaseException.php create mode 100644 shared/lib/Exception/RuntimeException.php create mode 100644 shared/lib/Files/Node/INodeBase.php create mode 100644 shared/lib/Files/Node/INodeCollectionBase.php create mode 100644 shared/lib/Files/Node/INodeCollectionMutable.php create mode 100644 shared/lib/Files/Node/INodeEntityBase.php create mode 100644 shared/lib/Files/Node/INodeEntityMutable.php create mode 100644 shared/lib/Files/Node/NodeType.php create mode 100644 shared/lib/Files/Provider/IProviderBase.php create mode 100644 shared/lib/Files/Service/IServiceBase.php create mode 100644 shared/lib/Files/Service/IServiceCollectionMutable.php create mode 100644 shared/lib/Files/Service/IServiceEntityMutable.php create mode 100644 shared/lib/Files/Service/IServiceMutable.php create mode 100644 shared/lib/IpUtils.php create mode 100644 shared/lib/Json/JsonDeserializable.php create mode 100644 shared/lib/Json/JsonSerializable.php create mode 100644 shared/lib/Json/JsonSerializableCollection.php create mode 100644 shared/lib/Json/JsonSerializableObject.php create mode 100644 shared/lib/Mail/Collection/CollectionBaseAbstract.php create mode 100644 shared/lib/Mail/Collection/CollectionBaseInterface.php create mode 100644 shared/lib/Mail/Collection/CollectionMutableAbstract.php create mode 100644 shared/lib/Mail/Collection/CollectionMutableInterface.php create mode 100644 shared/lib/Mail/Collection/CollectionPropertiesBaseAbstract.php create mode 100644 shared/lib/Mail/Collection/CollectionPropertiesBaseInterface.php create mode 100644 shared/lib/Mail/Collection/CollectionPropertiesMutableAbstract.php create mode 100644 shared/lib/Mail/Collection/CollectionPropertiesMutableInterface.php create mode 100644 shared/lib/Mail/Collection/CollectionRoles.php create mode 100644 shared/lib/Mail/Entity/EntityBaseAbstract.php create mode 100644 shared/lib/Mail/Entity/EntityBaseInterface.php create mode 100644 shared/lib/Mail/Entity/EntityMutableAbstract.php create mode 100644 shared/lib/Mail/Entity/EntityMutableInterface.php create mode 100644 shared/lib/Mail/Exception/SendException.php create mode 100644 shared/lib/Mail/Object/Address.php create mode 100644 shared/lib/Mail/Object/AddressInterface.php create mode 100644 shared/lib/Mail/Object/Attachment.php create mode 100644 shared/lib/Mail/Object/AttachmentInterface.php create mode 100644 shared/lib/Mail/Object/MessagePartBaseAbstract.php create mode 100644 shared/lib/Mail/Object/MessagePartInterface.php create mode 100644 shared/lib/Mail/Object/MessagePartMutableAbstract.php create mode 100644 shared/lib/Mail/Object/MessagePropertiesBaseAbstract.php create mode 100644 shared/lib/Mail/Object/MessagePropertiesBaseInterface.php create mode 100644 shared/lib/Mail/Object/MessagePropertiesMutableAbstract.php create mode 100644 shared/lib/Mail/Object/MessagePropertiesMutableInterface.php create mode 100644 shared/lib/Mail/Provider/ProviderBaseInterface.php create mode 100644 shared/lib/Mail/Provider/ProviderServiceDiscoverInterface.php create mode 100644 shared/lib/Mail/Provider/ProviderServiceMutateInterface.php create mode 100644 shared/lib/Mail/Provider/ProviderServiceTestInterface.php create mode 100644 shared/lib/Mail/Queue/SendOptions.php create mode 100644 shared/lib/Mail/Service/ServiceBaseInterface.php create mode 100644 shared/lib/Mail/Service/ServiceCollectionMutableInterface.php create mode 100644 shared/lib/Mail/Service/ServiceConfigurableInterface.php create mode 100644 shared/lib/Mail/Service/ServiceEntityMutableInterface.php create mode 100644 shared/lib/Mail/Service/ServiceEntityTransmitInterface.php create mode 100644 shared/lib/Mail/Service/ServiceMutableInterface.php create mode 100644 shared/lib/Module/ModuleBrowserInterface.php create mode 100644 shared/lib/Module/ModuleConsoleInterface.php create mode 100644 shared/lib/Module/ModuleInstanceAbstract.php create mode 100644 shared/lib/Module/ModuleInstanceInterface.php create mode 100644 shared/lib/People/Collection/CollectionContent.php create mode 100644 shared/lib/People/Collection/CollectionPermissions.php create mode 100644 shared/lib/People/Collection/CollectionRoles.php create mode 100644 shared/lib/People/Collection/ICollectionBase.php create mode 100644 shared/lib/People/Collection/ICollectionMutable.php create mode 100644 shared/lib/People/Entity/EntityPermissions.php create mode 100644 shared/lib/People/Entity/IEntityBase.php create mode 100644 shared/lib/People/Entity/IEntityMutable.php create mode 100644 shared/lib/People/Individual/IndividualAliasCollection.php create mode 100644 shared/lib/People/Individual/IndividualAliasObject.php create mode 100644 shared/lib/People/Individual/IndividualAnniversaryCollection.php create mode 100644 shared/lib/People/Individual/IndividualAnniversaryObject.php create mode 100644 shared/lib/People/Individual/IndividualAnniversaryTypes.php create mode 100644 shared/lib/People/Individual/IndividualCryptoCollection.php create mode 100644 shared/lib/People/Individual/IndividualCryptoObject.php create mode 100644 shared/lib/People/Individual/IndividualEmailCollection.php create mode 100644 shared/lib/People/Individual/IndividualEmailObject.php create mode 100644 shared/lib/People/Individual/IndividualLanguageCollection.php create mode 100644 shared/lib/People/Individual/IndividualLanguageObject.php create mode 100644 shared/lib/People/Individual/IndividualMediaCollection.php create mode 100644 shared/lib/People/Individual/IndividualMediaObject.php create mode 100644 shared/lib/People/Individual/IndividualNameObject.php create mode 100644 shared/lib/People/Individual/IndividualNoteCollection.php create mode 100644 shared/lib/People/Individual/IndividualNoteObject.php create mode 100644 shared/lib/People/Individual/IndividualObject.php create mode 100644 shared/lib/People/Individual/IndividualOrganizationCollection.php create mode 100644 shared/lib/People/Individual/IndividualOrganizationObject.php create mode 100644 shared/lib/People/Individual/IndividualPhoneCollection.php create mode 100644 shared/lib/People/Individual/IndividualPhoneObject.php create mode 100644 shared/lib/People/Individual/IndividualPhysicalLocationCollection.php create mode 100644 shared/lib/People/Individual/IndividualPhysicalLocationObject.php create mode 100644 shared/lib/People/Individual/IndividualPronounCollection.php create mode 100644 shared/lib/People/Individual/IndividualPronounObject.php create mode 100644 shared/lib/People/Individual/IndividualTagCollection.php create mode 100644 shared/lib/People/Individual/IndividualTitleCollection.php create mode 100644 shared/lib/People/Individual/IndividualTitleObject.php create mode 100644 shared/lib/People/Individual/IndividualTitleTypes.php create mode 100644 shared/lib/People/Individual/IndividualVirtualLocationCollection.php create mode 100644 shared/lib/People/Individual/IndividualVirtualLocationObject.php create mode 100644 shared/lib/People/Provider/IProviderBase.php create mode 100644 shared/lib/People/Provider/IProviderServiceMutate.php create mode 100644 shared/lib/People/Service/IServiceBase.php create mode 100644 shared/lib/People/Service/IServiceCollectionMutable.php create mode 100644 shared/lib/People/Service/IServiceEntityMutable.php create mode 100644 shared/lib/People/Service/IServiceMutable.php create mode 100644 shared/lib/Resource/Delta/Delta.php create mode 100644 shared/lib/Resource/Delta/DeltaCollection.php create mode 100644 shared/lib/Resource/Exceptions/InvalidParameterException.php create mode 100644 shared/lib/Resource/Exceptions/UnauthorizedException.php create mode 100644 shared/lib/Resource/Exceptions/UnsupportedException.php create mode 100644 shared/lib/Resource/Filter/Filter.php create mode 100644 shared/lib/Resource/Filter/FilterComparisonOperator.php create mode 100644 shared/lib/Resource/Filter/FilterConjunctionOperator.php create mode 100644 shared/lib/Resource/Filter/IFilter.php create mode 100644 shared/lib/Resource/Provider/Node/NodeBaseAbstract.php create mode 100644 shared/lib/Resource/Provider/Node/NodeBaseInterface.php create mode 100644 shared/lib/Resource/Provider/Node/NodeMutableAbstract.php create mode 100644 shared/lib/Resource/Provider/Node/NodeMutableInterface.php create mode 100644 shared/lib/Resource/Provider/Node/NodePropertiesBaseAbstract.php create mode 100644 shared/lib/Resource/Provider/Node/NodePropertiesBaseInterface.php create mode 100644 shared/lib/Resource/Provider/Node/NodePropertiesMutableAbstract.php create mode 100644 shared/lib/Resource/Provider/Node/NodePropertiesMutableInterface.php create mode 100644 shared/lib/Resource/Provider/ProviderInterface.php create mode 100644 shared/lib/Resource/Provider/ResourceProviderBaseInterface.php create mode 100644 shared/lib/Resource/Provider/ResourceProviderServiceMutateInterface.php create mode 100644 shared/lib/Resource/Provider/ResourceServiceBaseInterface.php create mode 100644 shared/lib/Resource/Provider/ResourceServiceConfigureInterface.php create mode 100644 shared/lib/Resource/Provider/ResourceServiceIdentityBasic.php create mode 100644 shared/lib/Resource/Provider/ResourceServiceIdentityCertificate.php create mode 100644 shared/lib/Resource/Provider/ResourceServiceIdentityInterface.php create mode 100644 shared/lib/Resource/Provider/ResourceServiceIdentityOAuth.php create mode 100644 shared/lib/Resource/Provider/ResourceServiceIdentityToken.php create mode 100644 shared/lib/Resource/Provider/ResourceServiceLocationFile.php create mode 100644 shared/lib/Resource/Provider/ResourceServiceLocationInterface.php create mode 100644 shared/lib/Resource/Provider/ResourceServiceLocationSocketSole.php create mode 100644 shared/lib/Resource/Provider/ResourceServiceLocationSocketSplit.php create mode 100644 shared/lib/Resource/Provider/ResourceServiceLocationUri.php create mode 100644 shared/lib/Resource/Provider/ResourceServiceMutateInterface.php create mode 100644 shared/lib/Resource/Range/IRange.php create mode 100644 shared/lib/Resource/Range/IRangeDate.php create mode 100644 shared/lib/Resource/Range/IRangeTally.php create mode 100644 shared/lib/Resource/Range/Range.php create mode 100644 shared/lib/Resource/Range/RangeAnchorType.php create mode 100644 shared/lib/Resource/Range/RangeDate.php create mode 100644 shared/lib/Resource/Range/RangeTally.php create mode 100644 shared/lib/Resource/Range/RangeType.php create mode 100644 shared/lib/Resource/Selector/CollectionSelector.php create mode 100644 shared/lib/Resource/Selector/EntitySelector.php create mode 100644 shared/lib/Resource/Selector/SelectorAbstract.php create mode 100644 shared/lib/Resource/Selector/ServiceSelector.php create mode 100644 shared/lib/Resource/Selector/SourceSelector.php create mode 100644 shared/lib/Resource/Sort/ISort.php create mode 100644 shared/lib/Resource/Sort/Sort.php create mode 100644 shared/lib/Routing/Attributes/AnonymousRoute.php create mode 100644 shared/lib/Routing/Attributes/AuthenticatedRoute.php create mode 100644 shared/lib/Security/Authentication/AuthenticationProviderAbstract.php create mode 100644 shared/lib/Security/Authentication/AuthenticationProviderInterface.php create mode 100644 shared/lib/Security/Authentication/AuthenticationSession.php create mode 100644 shared/lib/Security/Authentication/ProviderContext.php create mode 100644 shared/lib/Security/Authentication/ProviderResult.php create mode 100644 shared/lib/Security/Crypto.php create mode 100644 shared/lib/Utile/Collection/CollectionAbstract.php create mode 100644 shared/lib/Utile/UUID.php create mode 100644 tests/php/bootstrap.php create mode 100644 tests/php/shared/Json/JsonSerializableObjectTest.php create mode 100644 tests/php/shared/People/Entity/Individual/IndividualObjectTest.php create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f6a527 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Frontend development +node_modules/ +*.local +.env.local +.env.*.local +.cache/ +.vite/ +.temp/ +.tmp/ + +# Frontend build +/public/ +/static/ + +# Backend development +/vendor/ +coverage/ +phpunit.xml.cache +.phpunit.result.cache +.php-cs-fixer.cache +.phpstan.cache +.phpactor/ + +# Editors +.DS_Store +.vscode/ +.idea/ + +# Logs +logs +*.log +*.log* + +# Runtime +/modules/ +/storage/ +/var/ \ No newline at end of file diff --git a/.stubs/mongodb.stub.php b/.stubs/mongodb.stub.php new file mode 100644 index 0000000..80ed022 --- /dev/null +++ b/.stubs/mongodb.stub.php @@ -0,0 +1,143 @@ +kernel()->boot(); + + // Get the container + $container = $app->container(); + + // Create Symfony Console Application + $console = new ConsoleApplication('Ktrix Console', Kernel::VERSION); + + // Collect all command classes + $commandClasses = []; + + // Collect commands from modules + /** @var ModuleManager $moduleManager */ + $moduleManager = $container->get(ModuleManager::class); + + foreach ($moduleManager->list() as $module) { + $moduleInstance = $module->instance(); + + // Skip if module instance is not available + if ($moduleInstance === null) { + continue; + } + + // Check if module implements console command provider + if ($moduleInstance instanceof ModuleConsoleInterface) { + try { + $commands = $moduleInstance->registerCI(); + + foreach ($commands as $commandClass) { + if (!class_exists($commandClass)) { + fwrite(STDERR, "Warning: Command class not found: {$commandClass}\n"); + continue; + } + $commandClasses[] = $commandClass; + } + } catch (\Throwable $e) { + fwrite(STDERR, "Warning: Failed to load commands from module {$module->handle()}: {$e->getMessage()}\n"); + } + } + } + + // Register commands using lazy loading + foreach ($commandClasses as $commandClass) { + try { + // Use reflection to read #[AsCommand] attribute without instantiation + $reflection = new \ReflectionClass($commandClass); + $attributes = $reflection->getAttributes(\Symfony\Component\Console\Attribute\AsCommand::class); + + if (empty($attributes)) { + fwrite(STDERR, "Warning: Command {$commandClass} missing #[AsCommand] attribute\n"); + continue; + } + + // Get attribute instance + /** @var \Symfony\Component\Console\Attribute\AsCommand $commandAttr */ + $commandAttr = $attributes[0]->newInstance(); + + // Create lazy command wrapper that defers instantiation + $lazyCommand = new LazyCommand( + $commandAttr->name, + [], + $commandAttr->description ?? '', + $commandAttr->hidden ?? false, + fn() => $container->get($commandClass) // Only instantiate when executed + ); + + $console->add($lazyCommand); + + } catch (\Throwable $e) { + fwrite(STDERR, "Warning: Failed to register command {$commandClass}: {$e->getMessage()}\n"); + } + } + + // Run the console application + $exitCode = $console->run(); + exit($exitCode); + +} catch (\Throwable $e) { + fwrite(STDERR, "Fatal error: " . $e->getMessage() . "\n"); + if (isset($app) && $app->debug()) { + fwrite(STDERR, $e->getTraceAsString() . "\n"); + } + exit(1); +} diff --git a/bin/phpunit b/bin/phpunit new file mode 100755 index 0000000..ac5eef1 --- /dev/null +++ b/bin/phpunit @@ -0,0 +1,4 @@ +#!/usr/bin/env php +=8.2", + "ext-ctype": "*", + "ext-iconv": "*", + "mongodb/mongodb": "^2.1", + "php-di/php-di": "*", + "phpseclib/phpseclib": "^3.0", + "symfony/console": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "config": { + "allow-plugins": { + "php-http/discovery": true + }, + "bump-after-update": true, + "sort-packages": true + }, + "autoload": { + "psr-4": { + "KTXC\\": "core/lib/", + "KTXF\\": "shared/lib/" + } + }, + "autoload-dev": { + "psr-4": { + "KTXT\\": "tests/php/" + } + }, + "scripts": { + "post-install-cmd": [ + ], + "post-update-cmd": [ + ], + "test": "phpunit --colors=always --testdox" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..198aeb1 --- /dev/null +++ b/composer.lock @@ -0,0 +1,3148 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "2798ba3263d47fe84e91ee7dfd47e310", + "packages": [ + { + "name": "laravel/serializable-closure", + "version": "v2.0.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/cb291e4c998ac50637c7eeb58189c14f5de5b9dd", + "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2025-11-21T20:52:36+00:00" + }, + { + "name": "mongodb/mongodb", + "version": "2.1.2", + "source": { + "type": "git", + "url": "https://github.com/mongodb/mongo-php-library.git", + "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/0a2472ba9cbb932f7e43a8770aedb2fc30612a67", + "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0", + "ext-mongodb": "^2.1", + "php": "^8.1", + "psr/log": "^1.1.4|^2|^3", + "symfony/polyfill-php85": "^1.32" + }, + "replace": { + "mongodb/builder": "*" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0", + "phpunit/phpunit": "^10.5.35", + "rector/rector": "^2.1.4", + "squizlabs/php_codesniffer": "^3.7", + "vimeo/psalm": "6.5.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "MongoDB\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Andreas Braun", + "email": "andreas.braun@mongodb.com" + }, + { + "name": "Jeremy Mikola", + "email": "jmikola@gmail.com" + }, + { + "name": "Jérôme Tamarelle", + "email": "jerome.tamarelle@mongodb.com" + } + ], + "description": "MongoDB driver library", + "homepage": "https://jira.mongodb.org/browse/PHPLIB", + "keywords": [ + "database", + "driver", + "mongodb", + "persistence" + ], + "support": { + "issues": "https://github.com/mongodb/mongo-php-library/issues", + "source": "https://github.com/mongodb/mongo-php-library/tree/2.1.2" + }, + "time": "2025-10-06T12:12:40+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "php-di/invoker", + "version": "2.3.7", + "source": { + "type": "git", + "url": "https://github.com/PHP-DI/Invoker.git", + "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/3c1ddfdef181431fbc4be83378f6d036d59e81e1", + "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "psr/container": "^1.0|^2.0" + }, + "require-dev": { + "athletic/athletic": "~0.1.8", + "mnapoli/hard-mode": "~0.3.0", + "phpunit/phpunit": "^9.0 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Invoker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Generic and extensible callable invoker", + "homepage": "https://github.com/PHP-DI/Invoker", + "keywords": [ + "callable", + "dependency", + "dependency-injection", + "injection", + "invoke", + "invoker" + ], + "support": { + "issues": "https://github.com/PHP-DI/Invoker/issues", + "source": "https://github.com/PHP-DI/Invoker/tree/2.3.7" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2025-08-30T10:22:22+00:00" + }, + { + "name": "php-di/php-di", + "version": "7.1.1", + "source": { + "type": "git", + "url": "https://github.com/PHP-DI/PHP-DI.git", + "reference": "f88054cc052e40dbe7b383c8817c19442d480352" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/f88054cc052e40dbe7b383c8817c19442d480352", + "reference": "f88054cc052e40dbe7b383c8817c19442d480352", + "shasum": "" + }, + "require": { + "laravel/serializable-closure": "^1.0 || ^2.0", + "php": ">=8.0", + "php-di/invoker": "^2.0", + "psr/container": "^1.1 || ^2.0" + }, + "provide": { + "psr/container-implementation": "^1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "friendsofphp/proxy-manager-lts": "^1", + "mnapoli/phpunit-easymock": "^1.3", + "phpunit/phpunit": "^9.6 || ^10 || ^11", + "vimeo/psalm": "^5|^6" + }, + "suggest": { + "friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "DI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The dependency injection container for humans", + "homepage": "https://php-di.org/", + "keywords": [ + "PSR-11", + "container", + "container-interop", + "dependency injection", + "di", + "ioc", + "psr11" + ], + "support": { + "issues": "https://github.com/PHP-DI/PHP-DI/issues", + "source": "https://github.com/PHP-DI/PHP-DI/tree/7.1.1" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/php-di/php-di", + "type": "tidelift" + } + ], + "time": "2025-08-16T11:10:48+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.48", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/64065a5679c50acb886e82c07aa139b0f757bb89", + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.48" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2025-12-15T11:51:42+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6", + "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-23T14:50:43+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T09:58:17+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-23T16:12:55+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:30:57+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/d50e862cb0a0e0886f73ca1f31b865efbb795003", + "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-27T13:27:24+00:00" + } + ], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.11", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.4.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.0", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.2" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.11" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-08-27T14:37:49+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-27T05:02:59+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.46", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "75dfe79a2aa30085b7132bb84377c24062193f33" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/75dfe79a2aa30085b7132bb84377c24062193f33", + "reference": "75dfe79a2aa30085b7132bb84377c24062193f33", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.11", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.2", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.46" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2025-12-06T08:01:15+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2025-08-10T08:07:46+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": ">=8.2", + "ext-ctype": "*", + "ext-iconv": "*" + }, + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/config/system.php b/config/system.php new file mode 100644 index 0000000..fb5bc4b --- /dev/null +++ b/config/system.php @@ -0,0 +1,40 @@ + 'Ktrix', + 'environment' => 'dev', + 'debug' => true, + // Database Configuration + 'database' => [ + // MongoDB connection URI (include credentials if needed) + 'uri' => 'mongodb://ktrix:ktrix@127.0.0.1:27017/?authSource=ktrix&tls=false', + 'database' => 'ktrix', + // optional driver options + 'options' => [], + 'driverOptions' => [], + ], + + /** + * Cache Configuration + * + * Set the cache store classes for different cache types. + * Uncomment and adjust the class names as needed. + * + * Available Cache Stores: + * - Ephemeral Cache: Short-lived, in-memory or file-based cache for sessions, rate limits, etc. + * - Persistent Cache: Long-lived cache for routes, modules, compiled configs, etc. + * - Blob Cache: Large binary objects storage. + * + * Predefined cache types: + * file - File-based cache store + * redis - Redis-based cache store + * memcached - Memcached-based cache store + */ + //'cache.ephemeral' => 'file', + //'cache.persistent' => 'file', + //'cache.blob' => 'file', + + // Security Configuration + 'security.salt' => 'a5418ed8c120b9d12c793ccea10571b74d0dcd4a4db7ca2f75e80fbdafb2bd9b', +]; diff --git a/core/lib/Application.php b/core/lib/Application.php new file mode 100644 index 0000000..ef48cc3 --- /dev/null +++ b/core/lib/Application.php @@ -0,0 +1,216 @@ +rootDir = $this->resolveProjectRoot($rootDir); + + // Load configuration + $this->config = $this->loadConfig(); + + // Determine environment and debug mode + $environment = $environment ?? $this->config['environment'] ?? 'prod'; + $debug = $debug ?? $this->config['debug'] ?? false; + + // Create kernel with configuration + $this->kernel = new Kernel($environment, $debug, $this->config, $rootDir); + } + + /** + * Run the application - handle incoming request and send response + */ + public function run(): void + { + try { + $request = Request::createFromGlobals(); + $response = $this->handle($request); + $response->send(); + $this->terminate(); + } catch (\Throwable $e) { + // Last resort error handling for kernel initialization failures + error_log('Application error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); + $content = $this->kernel->debug() + ? '
' . htmlspecialchars((string) $e) . '
' + : 'An error occurred. Please try again later.'; + $response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [ + 'Content-Type' => 'text/html; charset=UTF-8', + ]); + $response->send(); + exit(1); + } + } + + /** + * Handle a request + */ + public function handle(Request $request): Response + { + return $this->kernel->handle($request); + } + + /** + * Terminate the application - process deferred events + */ + public function terminate(): void + { + $this->kernel->processEvents(); + } + + /** + * Get the kernel instance + */ + public function kernel(): Kernel + { + return $this->kernel; + } + + /** + * Get the container instance + */ + public function container(): ContainerInterface + { + return $this->kernel->container(); + } + + /** + * Get the application root directory + */ + public function rootDir(): string + { + return $this->rootDir; + } + + /** + * Get the modules directory + */ + public function moduleDir(): string + { + return $this->rootDir . '/modules'; + } + + /** + * Get configuration value + */ + public function config(?string $key = null, mixed $default = null): mixed + { + if ($key === null) { + return $this->config; + } + + // Support dot notation: 'database.uri' + $keys = explode('.', $key); + $value = $this->config; + + foreach ($keys as $k) { + if (!is_array($value) || !array_key_exists($k, $value)) { + return $default; + } + $value = $value[$k]; + } + + return $value; + } + + /** + * Get environment + */ + public function environment(): string + { + return $this->kernel->environment(); + } + + /** + * Check if debug mode is enabled + */ + public function debug(): bool + { + return $this->kernel->debug(); + } + + /** + * Load configuration from config directory + */ + protected function loadConfig(): array + { + $configFile = $this->rootDir . '/config/system.php'; + + if (!file_exists($configFile)) { + error_log('Configuration file not found: ' . $configFile); + return []; + } + + $config = include $configFile; + + if (!is_array($config)) { + throw new \RuntimeException('Configuration file must return an array'); + } + + return $config; + } + + /** + * Resolve the project root directory. + * + * Some entrypoints may pass the public/ directory or another subdirectory. + * We walk up the directory tree until we find composer.json. + */ + private function resolveProjectRoot(string $startDir): string + { + $dir = rtrim($startDir, '/'); + if ($dir === '') { + return $startDir; + } + + // If startDir is a file path, use its directory. + if (is_file($dir)) { + $dir = dirname($dir); + } + + $current = $dir; + while (true) { + if (is_file($current . '/composer.json')) { + return $current; + } + + $parent = dirname($current); + if ($parent === $current) { + // Reached filesystem root + return $dir; + } + $current = $parent; + } + } + + /** + * Set the Composer ClassLoader instance + */ + public static function setComposerLoader($loader): void + { + self::$composerLoader = $loader; + } + + /** + * Get the Composer ClassLoader instance + */ + public static function getComposerLoader() + { + return self::$composerLoader; + } +} diff --git a/core/lib/Console/ModuleDisableCommand.php b/core/lib/Console/ModuleDisableCommand.php new file mode 100644 index 0000000..b4881d7 --- /dev/null +++ b/core/lib/Console/ModuleDisableCommand.php @@ -0,0 +1,92 @@ +addArgument('handle', InputArgument::REQUIRED, 'Module handle to disable') + ->setHelp('This command disables an enabled module without uninstalling it.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $handle = $input->getArgument('handle'); + + $io->title('Disable Module'); + + try { + // Prevent disabling core module + if ($handle === 'core') { + $io->error('Cannot disable the core module.'); + return Command::FAILURE; + } + + // Find the module + $modules = $this->moduleManager->list(installedOnly: true, enabledOnly: false); + $module = $modules[$handle] ?? null; + + if (!$module) { + $io->error("Module '{$handle}' not found or not installed."); + return Command::FAILURE; + } + + if (!$module->enabled()) { + $io->warning("Module '{$handle}' is already disabled."); + return Command::SUCCESS; + } + + // Disable the module + $io->text("Disabling module '{$handle}'..."); + $this->moduleManager->disable($handle); + + $this->logger->info('Module disabled via console', [ + 'handle' => $handle, + 'command' => $this->getName(), + ]); + + $io->success("Module '{$handle}' disabled successfully!"); + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $io->error('Failed to disable module: ' . $e->getMessage()); + $this->logger->error('Module disable failed', [ + 'handle' => $handle, + 'error' => $e->getMessage(), + ]); + return Command::FAILURE; + } + } +} diff --git a/core/lib/Console/ModuleEnableCommand.php b/core/lib/Console/ModuleEnableCommand.php new file mode 100644 index 0000000..297b2d7 --- /dev/null +++ b/core/lib/Console/ModuleEnableCommand.php @@ -0,0 +1,86 @@ +addArgument('handle', InputArgument::REQUIRED, 'Module handle to enable') + ->setHelp('This command enables a previously disabled module.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $handle = $input->getArgument('handle'); + + $io->title('Enable Module'); + + try { + // Find the module + $modules = $this->moduleManager->list(installedOnly: true, enabledOnly: false); + $module = $modules[$handle] ?? null; + + if (!$module) { + $io->error("Module '{$handle}' not found or not installed."); + return Command::FAILURE; + } + + if ($module->enabled()) { + $io->warning("Module '{$handle}' is already enabled."); + return Command::SUCCESS; + } + + // Enable the module + $io->text("Enabling module '{$handle}'..."); + $this->moduleManager->enable($handle); + + $this->logger->info('Module enabled via console', [ + 'handle' => $handle, + 'command' => $this->getName(), + ]); + + $io->success("Module '{$handle}' enabled successfully!"); + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $io->error('Failed to enable module: ' . $e->getMessage()); + $this->logger->error('Module enable failed', [ + 'handle' => $handle, + 'error' => $e->getMessage(), + ]); + return Command::FAILURE; + } + } +} diff --git a/core/lib/Console/ModuleListCommand.php b/core/lib/Console/ModuleListCommand.php new file mode 100644 index 0000000..a27c49b --- /dev/null +++ b/core/lib/Console/ModuleListCommand.php @@ -0,0 +1,86 @@ +addOption('all', 'a', InputOption::VALUE_NONE, 'Show all modules including disabled ones') + ->setHelp('This command lists all installed modules with their status and version information.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $showAll = $input->getOption('all'); + + $io->title('Installed Modules'); + + try { + $modules = $this->moduleManager->list( + installedOnly: true, + enabledOnly: !$showAll + ); + + if (count($modules) === 0) { + $io->warning('No modules found.'); + return Command::SUCCESS; + } + + $rows = []; + foreach ($modules as $module) { + $status = $module->enabled() ? 'Enabled' : 'Disabled'; + $upgrade = $module->needsUpgrade() ? 'Yes' : ''; + + $rows[] = [ + $module->handle(), + $module->version(), + $status, + $upgrade, + $module->namespace() ?? 'N/A', + ]; + } + + $io->table( + ['Handle', 'Version', 'Status', 'Needs Upgrade', 'Namespace'], + $rows + ); + + $io->success(sprintf('Found %d module(s).', count($modules))); + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $io->error('Failed to list modules: ' . $e->getMessage()); + return Command::FAILURE; + } + } +} diff --git a/core/lib/Controllers/AuthenticationController.php b/core/lib/Controllers/AuthenticationController.php new file mode 100644 index 0000000..aebce0b --- /dev/null +++ b/core/lib/Controllers/AuthenticationController.php @@ -0,0 +1,361 @@ +authManager->handle($request); + + return $this->buildJsonResponse($response); + } + + /** + * Identify user for identity-first login flow + */ + #[AnonymousRoute('/auth/identify', name: 'auth.identify', methods: ['POST'])] + public function identify(string $session, string $identity): JsonResponse + { + if (empty($session) || empty($identity)) { + return new JsonResponse( + ['error' => 'Session and identity are required', 'error_code' => 'invalid_request'], + JsonResponse::HTTP_BAD_REQUEST + ); + } + + $request = AuthenticationRequest::identify($session, trim($identity)); + $response = $this->authManager->handle($request); + + return $this->buildJsonResponse($response); + } + + /** + * Start a challenge for methods that require it (SMS, email, TOTP) + */ + #[AnonymousRoute('/auth/challenge', name: 'auth.challenge', methods: ['POST'])] + public function challenge(string $session, string $method): JsonResponse + { + if (empty($session) || empty($method)) { + return new JsonResponse( + ['error' => 'Session ID and method are required', 'error_code' => 'invalid_request'], + JsonResponse::HTTP_BAD_REQUEST + ); + } + + $request = AuthenticationRequest::challenge($session, $method); + $response = $this->authManager->handle($request); + + return $this->buildJsonResponse($response); + } + + /** + * Verify a credential or challenge response + */ + #[AnonymousRoute('/auth/verify', name: 'auth.verify', methods: ['POST'])] + public function verify(string $session, string $method, string $response): JsonResponse + { + if (empty($session) || empty($method)) { + return new JsonResponse( + ['error' => 'Session ID and method are required', 'error_code' => 'invalid_request'], + JsonResponse::HTTP_BAD_REQUEST + ); + } + + $request = AuthenticationRequest::verify($session, $method, $response); + $authResponse = $this->authManager->handle($request); + + return $this->buildJsonResponse($authResponse); + } + + /** + * Begin redirect-based authentication (OIDC/SAML) + */ + #[AnonymousRoute('/auth/redirect', name: 'auth.redirect', methods: ['POST'])] + public function redirect(Request $request): JsonResponse + { + $data = $this->getRequestData($request); + + $sessionId = $data['session'] ?? ''; + $method = $data['method'] ?? ''; + $returnUrl = $data['return_url'] ?? '/'; + + if (empty($sessionId) || empty($method)) { + return new JsonResponse( + ['error' => 'Session ID and method are required', 'error_code' => 'invalid_request'], + JsonResponse::HTTP_BAD_REQUEST + ); + } + + $scheme = $request->isSecure() ? 'https' : 'http'; + $host = $request->getHost(); + $callbackUrl = "{$scheme}://{$host}/auth/callback/{$method}"; + + $authRequest = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl); + $response = $this->authManager->handle($authRequest); + + return $this->buildJsonResponse($response); + } + + /** + * Handle callback from identity provider (OIDC/SAML) + */ + #[AnonymousRoute('/auth/callback/{provider}', name: 'auth.callback', methods: ['GET', 'POST'])] + public function callback(Request $request, string $provider): JsonResponse|RedirectResponse + { + $params = $request->isMethod('POST') + ? $request->request->all() + : $request->query->all(); + + $sessionId = $params['state'] ?? null; + + if (!$sessionId) { + return $this->redirectWithError('Missing state parameter'); + } + + $authRequest = AuthenticationRequest::callback($sessionId, $provider, $params); + $response = $this->authManager->handle($authRequest); + + if ($response->isSuccess()) { + $returnUrl = $response->returnUrl ?? '/'; + $httpResponse = new RedirectResponse($returnUrl); + + if ($response->hasTokens()) { + return $this->setTokenCookies($httpResponse, $response->tokens, $request->isSecure()); + } + + return $httpResponse; + } + + if ($response->isPending()) { + return new RedirectResponse('/login/mfa?session=' . urlencode($response->sessionId)); + } + + return $this->redirectWithError($response->errorMessage ?? 'Authentication failed'); + } + + /** + * Get current session status + */ + #[AnonymousRoute('/auth/status', name: 'auth.status', methods: ['GET'])] + public function status(Request $request): JsonResponse + { + $sessionId = $request->query->get('session', ''); + + if (empty($sessionId)) { + return new JsonResponse( + ['error' => 'Session ID is required', 'error_code' => 'invalid_request'], + JsonResponse::HTTP_BAD_REQUEST + ); + } + + $authRequest = AuthenticationRequest::status($sessionId); + $response = $this->authManager->handle($authRequest); + + return $this->buildJsonResponse($response); + } + + /** + * Cancel authentication session + */ + #[AnonymousRoute('/auth/session', name: 'auth.session.cancel', methods: ['DELETE'])] + public function cancel(Request $request): JsonResponse + { + $sessionId = $request->query->get('session', ''); + + $authRequest = AuthenticationRequest::cancel($sessionId); + $this->authManager->handle($authRequest); + + return new JsonResponse(['status' => 'cancelled', 'message' => 'Session cancelled']); + } + + // ========================================================================= + // Token Operations + // ========================================================================= + + /** + * Refresh access token + */ + #[AnonymousRoute('/auth/refresh', name: 'auth.refresh', methods: ['POST'])] + public function refresh(Request $request): JsonResponse + { + $refreshToken = $request->cookies->get('refreshToken'); + + if (!$refreshToken) { + return new JsonResponse( + ['error' => 'Refresh token required', 'error_code' => 'missing_token'], + JsonResponse::HTTP_UNAUTHORIZED + ); + } + + $authRequest = AuthenticationRequest::refresh($refreshToken); + $response = $this->authManager->handle($authRequest); + + if ($response->isFailed()) { + $httpResponse = new JsonResponse($response->toArray(), $response->httpStatus); + return $this->clearTokenCookies($httpResponse); + } + + $httpResponse = new JsonResponse(['status' => 'success', 'message' => 'Token refreshed']); + + if ($response->tokens && isset($response->tokens['access'])) { + $httpResponse->headers->setCookie( + Cookie::create('accessToken') + ->withValue($response->tokens['access']) + ->withExpires(time() + 900) + ->withPath('/') + ->withSecure($request->isSecure()) + ->withHttpOnly(true) + ->withSameSite(Cookie::SAMESITE_STRICT) + ); + } + + return $httpResponse; + } + + /** + * Logout current device + */ + #[AuthenticatedRoute('/auth/logout', name: 'auth.logout', methods: ['POST'])] + public function logout(Request $request): JsonResponse + { + $token = $request->cookies->get('accessToken'); + + $authRequest = AuthenticationRequest::logout($token, false); + $this->authManager->handle($authRequest); + + $response = new JsonResponse(['status' => 'success', 'message' => 'Logged out successfully']); + return $this->clearTokenCookies($response); + } + + /** + * Logout all devices + */ + #[AuthenticatedRoute('/auth/logout-all', name: 'auth.logout.all', methods: ['POST'])] + public function logoutAll(Request $request): JsonResponse + { + $token = $request->cookies->get('accessToken'); + + $authRequest = AuthenticationRequest::logout($token, true); + $this->authManager->handle($authRequest); + + $response = new JsonResponse(['status' => 'success', 'message' => 'Logged out from all devices']); + return $this->clearTokenCookies($response); + } + + // ========================================================================= + // Response Helpers + // ========================================================================= + + /** + * Build JSON response from AuthenticationResponse + */ + private function buildJsonResponse(AuthenticationResponse $response): JsonResponse + { + $httpResponse = new JsonResponse($response->toArray(), $response->httpStatus); + + // Set token cookies if present + if ($response->hasTokens()) { + return $this->setTokenCookies($httpResponse, $response->tokens, true); + } + + return $httpResponse; + } + + /** + * Set authentication token cookies + */ + private function setTokenCookies(JsonResponse|RedirectResponse $response, array $tokens, bool $secure = true): JsonResponse|RedirectResponse + { + if (isset($tokens['access'])) { + $response->headers->setCookie( + Cookie::create('accessToken') + ->withValue($tokens['access']) + ->withExpires(time() + 900) + ->withPath('/') + ->withSecure($secure) + ->withHttpOnly(true) + ->withSameSite(Cookie::SAMESITE_STRICT) + ); + } + + if (isset($tokens['refresh'])) { + $response->headers->setCookie( + Cookie::create('refreshToken') + ->withValue($tokens['refresh']) + ->withExpires(time() + 604800) + ->withPath('/auth/refresh') + ->withSecure($secure) + ->withHttpOnly(true) + ->withSameSite(Cookie::SAMESITE_STRICT) + ); + } + + return $response; + } + + /** + * Clear authentication token cookies + */ + private function clearTokenCookies(JsonResponse $response): JsonResponse + { + $response->headers->clearCookie('accessToken', '/'); + $response->headers->clearCookie('refreshToken', '/auth/refresh'); + return $response; + } + + /** + * Redirect with error message + */ + private function redirectWithError(string $error): RedirectResponse + { + return new RedirectResponse('/login?error=' . urlencode($error)); + } + + /** + * Get request data from JSON body or form data + */ + private function getRequestData(Request $request): array + { + $contentType = $request->headers->get('Content-Type', ''); + + if (str_contains($contentType, 'application/json')) { + try { + return $request->toArray(); + } catch (\Throwable) { + return []; + } + } + + return $request->request->all(); + } +} diff --git a/core/lib/Controllers/DefaultController.php b/core/lib/Controllers/DefaultController.php new file mode 100644 index 0000000..63ae379 --- /dev/null +++ b/core/lib/Controllers/DefaultController.php @@ -0,0 +1,144 @@ +identity->identifier()) { + return new FileResponse( + $this->rootDir . '/public/private.html', + Response::HTTP_OK, + ['Content-Type' => 'text/html'] + ); + } + + // User is not authenticated - serve the public app + // If there's an accessToken cookie present but invalid, clear it + $response = new FileResponse( + $this->rootDir . '/public/public.html', + Response::HTTP_OK, + ['Content-Type' => 'text/html'] + ); + + // Clear any stale auth cookies since the user is not authenticated + if ($request->cookies->has('accessToken')) { + $response->headers->clearCookie('accessToken', '/'); + } + if ($request->cookies->has('refreshToken')) { + $response->headers->clearCookie('refreshToken', '/security/refresh'); + } + + return $response; + } + + #[AnonymousRoute('/login', name: 'login', methods: ['GET'])] + public function login(): Response + { + return new FileResponse( + $this->rootDir . '/public/public.html', + Response::HTTP_OK, + ['Content-Type' => 'text/html'] + ); + } + + #[AnonymousRoute('/logout', name: 'logout_get', methods: ['GET'])] + public function logoutGet(Request $request): Response + { + // Blacklist the current access token if present + $accessToken = $request->cookies->get('accessToken'); + if ($accessToken) { + $claims = $this->securityService->extractTokenClaims($accessToken); + if ($claims && isset($claims['jti'])) { + $this->securityService->logout($claims['jti'], $claims['exp'] ?? null); + } + } + + $response = new RedirectResponse( + '/login', + Response::HTTP_SEE_OTHER + ); + + // Clear both authentication cookies + $response->headers->clearCookie('accessToken', '/'); + $response->headers->clearCookie('refreshToken', '/security/refresh'); + + return $response; + } + + #[AnonymousRoute('/logout', name: 'logout_post', methods: ['POST'])] + public function logoutPost(Request $request): Response + { + // Blacklist the current access token if present + $accessToken = $request->cookies->get('accessToken'); + if ($accessToken) { + $claims = $this->securityService->extractTokenClaims($accessToken); + if ($claims && isset($claims['jti'])) { + $this->securityService->logout($claims['jti'], $claims['exp'] ?? null); + } + } + + $response = new JsonResponse(['message' => 'Logged out successfully']); + + // Clear both authentication cookies + $response->headers->clearCookie('accessToken', '/'); + $response->headers->clearCookie('refreshToken', '/security/refresh'); + + return $response; + } + + /** + * Catch-all route for SPA routing. + * Serves the appropriate HTML based on authentication status, + * allowing client-side routing to handle the actual path. + */ + #[AnonymousRoute('/{path}', name: 'spa_catchall', methods: ['GET'])] + public function catchAll(Request $request, string $path = ''): Response + { + // If an authenticated identity is available, serve the private app + if ($this->identity->identifier()) { + return new FileResponse( + $this->rootDir . '/public/private.html', + Response::HTTP_OK, + ['Content-Type' => 'text/html'] + ); + } + + // User is not authenticated - serve the public app + $response = new FileResponse( + $this->rootDir . '/public/public.html', + Response::HTTP_OK, + ['Content-Type' => 'text/html'] + ); + + // Clear any stale auth cookies since the user is not authenticated + if ($request->cookies->has('accessToken')) { + $response->headers->clearCookie('accessToken', '/'); + } + if ($request->cookies->has('refreshToken')) { + $response->headers->clearCookie('refreshToken', '/security/refresh'); + } + + return $response; + } +} diff --git a/core/lib/Controllers/InitController.php b/core/lib/Controllers/InitController.php new file mode 100644 index 0000000..1df5c07 --- /dev/null +++ b/core/lib/Controllers/InitController.php @@ -0,0 +1,95 @@ +moduleManager->list() as $module) { + // Check if user has permission to view this module + // Allow access if user has: {module_handle}, {module_handle}.*, or * permission + $handle = $module->handle(); + if (!$this->hasModuleViewPermission($handle)) { + continue; + } + + $integrations = $module->registerBI(); + if ($integrations !== null) { + $configuration['modules'][$handle] = $integrations; + } + } + + // tenant + $configuration['tenant'] = [ + 'id' => $this->tenant->identifier(), + 'domain' => $this->tenant->domain(), + 'label' => $this->tenant->label(), + ]; + + // user + $configuration['user'] = [ + 'auth' => [ + 'identifier' => $this->userIdentity->identifier(), + 'identity' => $this->userIdentity->identity()->getIdentity(), + 'label' => $this->userIdentity->label(), + 'roles' => $this->userIdentity->identity()->getRoles(), + 'permissions' => $this->userIdentity->identity()->getPermissions(), + ], + 'profile' => $this->userService->getEditableFields($this->userIdentity->identifier()), + 'settings' => $this->userService->fetchSettings(), + ]; + + return new JsonResponse($configuration); + + } + + /** + * Check if user has permission to view a module + * + * Checks for the following permissions (in order): + * 1. {module_handle} - module access permission + * 2. {module_handle}.* - wildcard for the module + * 3. * - global wildcard + * + * @param string $moduleHandle The module handle to check + * @return bool + */ + private function hasModuleViewPermission(string $moduleHandle): bool + { + // Core module is always accessible to authenticated users + if ($moduleHandle === 'core') { + return true; + } + + // Check for specific module permission or wildcard permissions + return $this->permissionChecker->canAny([ + "{$moduleHandle}", + "{$moduleHandle}.*", + ]); + } + +} diff --git a/core/lib/Controllers/ModuleController.php b/core/lib/Controllers/ModuleController.php new file mode 100644 index 0000000..140d329 --- /dev/null +++ b/core/lib/Controllers/ModuleController.php @@ -0,0 +1,68 @@ +moduleManager->list(false); + + return new JsonResponse(['modules' => $modules]); + } + + #[AuthenticatedRoute( + '/modules/manage', + name: 'modules.manage', + methods: ['POST'], + permissions: ['module_manager.modules.manage'] + )] + public function manage(string $handle, string $action): JsonResponse + { + // Verify module exists + $moduleInstance = $this->moduleManager->moduleInstance($handle, null); + if (!$moduleInstance) { + return new JsonResponse(['error' => 'Module "' . $handle . '" not found.'], 404); + } + + switch ($action) { + case 'install': + $this->moduleManager->install($handle); + return new JsonResponse(['message' => 'Module "' . $handle . '" installed successfully.']); + + case 'uninstall': + $this->moduleManager->uninstall($handle); + return new JsonResponse(['message' => 'Module "' . $handle . '" uninstalled successfully.']); + + case 'enable': + $this->moduleManager->enable($handle); + return new JsonResponse(['message' => 'Module "' . $handle . '" enabled successfully.']); + + case 'disable': + $this->moduleManager->disable($handle); + return new JsonResponse(['message' => 'Module "' . $handle . '" disabled successfully.']); + + case 'upgrade': + $this->moduleManager->upgrade($handle); + return new JsonResponse(['message' => 'Module "' . $handle . '" upgraded successfully.']); + + default: + return new JsonResponse(['error' => 'Invalid action.'], 400); + } + } +} diff --git a/core/lib/Controllers/UserAccountsController.php b/core/lib/Controllers/UserAccountsController.php new file mode 100644 index 0000000..57bac4e --- /dev/null +++ b/core/lib/Controllers/UserAccountsController.php @@ -0,0 +1,251 @@ +userIdentity->hasPermission('user.admin')) { + return new JsonResponse([ + 'status' => 'error', + 'data' => ['code' => 403, 'message' => 'Insufficient permissions'] + ], JsonResponse::HTTP_FORBIDDEN); + } + + $result = $this->process($operation, $data); + + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'success', + 'data' => $result, + ], JsonResponse::HTTP_OK); + + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'error', + 'data' => ['code' => 400, 'message' => $e->getMessage()] + ], JsonResponse::HTTP_BAD_REQUEST); + + } catch (\Throwable $e) { + $this->logger->error('User manager operation failed', [ + 'operation' => $operation, + 'error' => $e->getMessage() + ]); + + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'error', + 'data' => ['code' => $e->getCode(), 'message' => $e->getMessage()] + ], JsonResponse::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * Process operation + */ + private function process(string $operation, array $data): mixed + { + return match ($operation) { + 'user.list' => $this->userList($data), + 'user.fetch' => $this->userFetch($data), + 'user.create' => $this->userCreate($data), + 'user.update' => $this->userUpdate($data), + 'user.delete' => $this->userDelete($data), + 'user.provider.unlink' => $this->userProviderUnlink($data), + default => throw new \InvalidArgumentException("Invalid operation: {$operation}"), + }; + } + + // ========================================================================= + // User Operations + // ========================================================================= + + /** + * List all users for tenant + */ + private function userList(array $data): array + { + return $this->userService->listUsers($data); + } + + /** + * Fetch single user by UID + */ + private function userFetch(array $data): array + { + $uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required'); + + $user = $this->userService->fetchByIdentifier($uid); + if (!$user) { + throw new \InvalidArgumentException('User not found'); + } + + // Get editable fields for profile + $editableFields = $this->userService->getEditableFields($uid); + $user['profile_editable'] = $editableFields; + + return $user; + } + + /** + * Create new user + */ + private function userCreate(array $data): array + { + if (!$this->userIdentity->hasPermission('user.create')) { + throw new \InvalidArgumentException('Insufficient permissions to create users'); + } + + $userData = [ + 'identity' => $data['identity'] ?? throw new \InvalidArgumentException('Identity required'), + 'label' => $data['label'] ?? $data['identity'], + 'enabled' => $data['enabled'] ?? true, + 'roles' => $data['roles'] ?? [], + 'profile' => $data['profile'] ?? [], + 'settings' => [], + 'provider' => null, + 'provider_subject' => null, + 'provider_managed_fields' => [] + ]; + + $this->logger->info('Creating user', [ + 'tenant' => $this->tenantIdentity->identifier(), + 'identity' => $userData['identity'], + 'actor' => $this->userIdentity->identifier() + ]); + + return $this->userService->createUser($userData); + } + + /** + * Update existing user + */ + private function userUpdate(array $data): bool + { + if (!$this->userIdentity->hasPermission('user.update')) { + throw new \InvalidArgumentException('Insufficient permissions to update users'); + } + + $uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required'); + + // Build updates (exclude sensitive fields) + $updates = []; + $allowedFields = ['label', 'enabled', 'roles', 'profile']; + + foreach ($allowedFields as $field) { + if (isset($data[$field])) { + $updates[$field] = $data[$field]; + } + } + + if (empty($updates)) { + throw new \InvalidArgumentException('No valid fields to update'); + } + + // Special handling for profile updates (respect managed fields) + if (isset($updates['profile'])) { + $user = $this->userService->fetchByIdentifier($uid); + $managedFields = $user['provider_managed_fields'] ?? []; + + foreach ($managedFields as $field) { + unset($updates['profile'][$field]); + } + } + + $this->logger->info('Updating user', [ + 'tenant' => $this->tenantIdentity->identifier(), + 'uid' => $uid, + 'actor' => $this->userIdentity->identifier() + ]); + + return $this->userService->updateUser($uid, $updates); + } + + /** + * Delete user + */ + private function userDelete(array $data): bool + { + if (!$this->userIdentity->hasPermission('user.delete')) { + throw new \InvalidArgumentException('Insufficient permissions to delete users'); + } + + $uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required'); + + // Prevent self-deletion + if ($uid === $this->userIdentity->identifier()) { + throw new \InvalidArgumentException('Cannot delete your own account'); + } + + $this->logger->info('Deleting user', [ + 'tenant' => $this->tenantIdentity->identifier(), + 'uid' => $uid, + 'actor' => $this->userIdentity->identifier() + ]); + + return $this->userService->deleteUser($uid); + } + + // ========================================================================= + // Security Operations + // ========================================================================= + + /** + * Unlink external provider + */ + private function userProviderUnlink(array $data): bool + { + if (!$this->userIdentity->hasPermission('user.admin')) { + throw new \InvalidArgumentException('Insufficient permissions'); + } + + $uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required'); + + $updates = [ + 'provider' => null, + 'provider_subject' => null, + 'provider_managed_fields' => [] + ]; + + $this->logger->info('Unlinking provider', [ + 'tenant' => $this->tenantIdentity->identifier(), + 'uid' => $uid, + 'actor' => $this->userIdentity->identifier() + ]); + + return $this->userService->updateUser($uid, $updates); + } +} diff --git a/core/lib/Controllers/UserProfileController.php b/core/lib/Controllers/UserProfileController.php new file mode 100644 index 0000000..fbeedad --- /dev/null +++ b/core/lib/Controllers/UserProfileController.php @@ -0,0 +1,76 @@ +userIdentity->identifier(); + + // Get profile with editability metadata + $profile = $this->userService->getEditableFields($userId); + + return new JsonResponse($profile, JsonResponse::HTTP_OK); + } + + /** + * Update user profile fields + * Only editable fields can be updated. Provider-managed fields are automatically filtered out. + * + * @param array $data Key-value pairs of profile fields to update + * + * @example request body: + * { + * "data": { + * "name_given": "John", + * "name_family": "Doe", + * "phone": "+1234567890" + * } + * } + * + * @return JsonResponse Updated profile data + */ + #[AuthenticatedRoute( + '/user/profile', + name: 'user.profile.update', + methods: ['PUT', 'PATCH'], + permissions: ['user.profile.update'] + )] + public function update(array $data): JsonResponse + { + $userId = $this->userIdentity->identifier(); + + // storeProfile automatically filters out provider-managed fields + $this->userService->storeProfile($userId, $data); + + // Return updated profile with metadata + $updatedProfile = $this->userService->getEditableFields($userId); + + return new JsonResponse($updatedProfile, JsonResponse::HTTP_OK); + } +} diff --git a/core/lib/Controllers/UserRolesController.php b/core/lib/Controllers/UserRolesController.php new file mode 100644 index 0000000..630c58c --- /dev/null +++ b/core/lib/Controllers/UserRolesController.php @@ -0,0 +1,201 @@ +userIdentity->hasPermission('role.admin')) { + return new JsonResponse([ + 'status' => 'error', + 'data' => ['code' => 403, 'message' => 'Insufficient permissions'] + ], JsonResponse::HTTP_FORBIDDEN); + } + + $result = $this->process($operation, $data); + + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'success', + 'data' => $result, + ], JsonResponse::HTTP_OK); + + } catch (\InvalidArgumentException $e) { + $this->logger->error('Role manager validation error', [ + 'operation' => $operation, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'error', + 'data' => ['code' => 400, 'message' => $e->getMessage()] + ], JsonResponse::HTTP_BAD_REQUEST); + + } catch (\Throwable $e) { + $this->logger->error('Role manager operation failed', [ + 'operation' => $operation, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'error', + 'data' => ['code' => $e->getCode(), 'message' => $e->getMessage()] + ], JsonResponse::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * Process operation + */ + private function process(string $operation, array $data): mixed + { + return match ($operation) { + 'role.list' => $this->roleList($data), + 'role.fetch' => $this->roleFetch($data), + 'role.create' => $this->roleCreate($data), + 'role.update' => $this->roleUpdate($data), + 'role.delete' => $this->roleDelete($data), + 'permissions.list' => $this->permissionsList($data), + default => throw new \InvalidArgumentException("Invalid operation: {$operation}"), + }; + } + + // ========================================================================= + // Role Operations + // ========================================================================= + + /** + * List all roles + */ + private function roleList(array $data): array + { + $roles = $this->roleService->listRoles(); + + // Add user count to each role + foreach ($roles as &$role) { + $role['user_count'] = $this->roleService->getRoleUserCount($role['rid']); + } + + return $roles; + } + + /** + * Fetch single role + */ + private function roleFetch(array $data): array + { + $rid = $data['rid'] ?? throw new \InvalidArgumentException('Role ID required'); + + $role = $this->roleService->getRole($rid); + if (!$role) { + throw new \InvalidArgumentException('Role not found'); + } + + $role['user_count'] = $this->roleService->getRoleUserCount($rid); + + return $role; + } + + /** + * Create new role + */ + private function roleCreate(array $data): array + { + if (!$this->userIdentity->hasPermission('role.manage')) { + throw new \InvalidArgumentException('Insufficient permissions to create roles'); + } + + $roleData = [ + 'label' => $data['label'] ?? throw new \InvalidArgumentException('Role label required'), + 'description' => $data['description'] ?? '', + 'permissions' => $data['permissions'] ?? [] + ]; + + return $this->roleService->createRole($roleData); + } + + /** + * Update existing role + */ + private function roleUpdate(array $data): bool + { + if (!$this->userIdentity->hasPermission('role.manage')) { + throw new \InvalidArgumentException('Insufficient permissions to update roles'); + } + + $rid = $data['rid'] ?? throw new \InvalidArgumentException('Role ID required'); + + $updates = []; + $allowedFields = ['label', 'description', 'permissions']; + + foreach ($allowedFields as $field) { + if (isset($data[$field])) { + $updates[$field] = $data[$field]; + } + } + + if (empty($updates)) { + throw new \InvalidArgumentException('No valid fields to update'); + } + + return $this->roleService->updateRole($rid, $updates); + } + + /** + * Delete role + */ + private function roleDelete(array $data): bool + { + if (!$this->userIdentity->hasPermission('role.manage')) { + throw new \InvalidArgumentException('Insufficient permissions to delete roles'); + } + + $rid = $data['rid'] ?? throw new \InvalidArgumentException('Role ID required'); + + return $this->roleService->deleteRole($rid); + } + + /** + * Get available permissions + */ + private function permissionsList(array $data): array + { + return $this->roleService->availablePermissions(); + } +} diff --git a/core/lib/Controllers/UserSettingsController.php b/core/lib/Controllers/UserSettingsController.php new file mode 100644 index 0000000..ce0ef04 --- /dev/null +++ b/core/lib/Controllers/UserSettingsController.php @@ -0,0 +1,71 @@ +userService->fetchSettings(); + + return new JsonResponse($settings, JsonResponse::HTTP_OK); + } + + /** + * Update user settings + * + * @param array $data Key-value pairs of settings to update + * + * @example request body: + * { + * "data": { + * "theme": "dark", + * "language": "en", + * "notifications": true + * } + * } + * + * @return JsonResponse Updated settings data + */ + #[AuthenticatedRoute( + '/user/settings', + name: 'user.settings.update', + methods: ['PUT', 'PATCH'], + permissions: ['user.settings.update'] + )] + public function update(array $data): JsonResponse + { + $this->userService->storeSettings($data); + + // Return updated settings + $updatedSettings = $this->userService->fetchSettings(array_keys($data)); + + return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK); + } +} diff --git a/core/lib/Db/Client.php b/core/lib/Db/Client.php new file mode 100644 index 0000000..a2f7a3d --- /dev/null +++ b/core/lib/Db/Client.php @@ -0,0 +1,76 @@ +client = new MongoClient($uri, $uriOptions, $driverOptions); + } + + /** + * Select a database + * + * @param string $databaseName Database name + * @param array $options Database options + * @return Database + */ + public function selectDatabase(string $databaseName, array $options = []): Database + { + $mongoDatabase = $this->client->selectDatabase($databaseName, $options); + return new Database($mongoDatabase); + } + + /** + * List databases + */ + public function listDatabases(array $options = []): array + { + $databases = []; + foreach ($this->client->listDatabases($options) as $databaseInfo) { + $databases[] = $databaseInfo; + } + return $databases; + } + + /** + * Drop a database + */ + public function dropDatabase(string $databaseName, array $options = []): array|object|null + { + return $this->client->dropDatabase($databaseName, $options); + } + + /** + * Get the underlying MongoDB Client + * Use sparingly - prefer using wrapper methods + */ + public function getMongoClient(): MongoClient + { + return $this->client; + } + + /** + * Magic method to access database as property + */ + public function __get(string $databaseName): Database + { + return $this->selectDatabase($databaseName); + } +} diff --git a/core/lib/Db/Collection.php b/core/lib/Db/Collection.php new file mode 100644 index 0000000..857e8ef --- /dev/null +++ b/core/lib/Db/Collection.php @@ -0,0 +1,295 @@ +collection = $collection; + + // Set type map to return plain arrays instead of objects + // This converts BSON types to PHP native types + $this->collection = $collection->withOptions([ + 'typeMap' => [ + 'root' => 'array', + 'document' => 'array', + 'array' => 'array' + ] + ]); + } + + /** + * Find documents in the collection + * + * @param array $filter Query filter + * @param array $options Query options + * @return Cursor + */ + public function find(array $filter = [], array $options = []): Cursor + { + $filter = $this->convertFilter($filter); + /** @var \Iterator $cursor */ + $cursor = $this->collection->find($filter, $options); + return new Cursor($cursor); + } + + /** + * Find a single document + * + * @param array $filter Query filter + * @param array $options Query options + * @return array|null Returns array with _id as string + */ + public function findOne(array $filter = [], array $options = []): ?array + { + $filter = $this->convertFilter($filter); + $result = $this->collection->findOne($filter, $options); + + if ($result === null) { + return null; + } + + // Convert to array if it's an object + if (is_object($result)) { + $result = (array) $result; + } + + return $this->convertBsonToNative($result); + } + + /** + * Insert a single document + * + * @param array|object $document Document to insert + * @param array $options Insert options + * @return InsertOneResult + */ + public function insertOne(array|object $document, array $options = []): InsertOneResult + { + $document = $this->convertDocument($document); + return $this->collection->insertOne($document, $options); + } + + /** + * Insert multiple documents + * + * @param array $documents Documents to insert + * @param array $options Insert options + */ + public function insertMany(array $documents, array $options = []): mixed + { + $documents = array_map(fn($doc) => $this->convertDocument($doc), $documents); + return $this->collection->insertMany($documents, $options); + } + + /** + * Update a single document + * + * @param array $filter Query filter + * @param array $update Update operations + * @param array $options Update options + * @return UpdateResult + */ + public function updateOne(array $filter, array $update, array $options = []): UpdateResult + { + $filter = $this->convertFilter($filter); + $update = $this->convertDocument($update); + return $this->collection->updateOne($filter, $update, $options); + } + + /** + * Update multiple documents + * + * @param array $filter Query filter + * @param array $update Update operations + * @param array $options Update options + * @return UpdateResult + */ + public function updateMany(array $filter, array $update, array $options = []): UpdateResult + { + $filter = $this->convertFilter($filter); + $update = $this->convertDocument($update); + return $this->collection->updateMany($filter, $update, $options); + } + + /** + * Delete a single document + * + * @param array $filter Query filter + * @param array $options Delete options + * @return DeleteResult + */ + public function deleteOne(array $filter, array $options = []): DeleteResult + { + $filter = $this->convertFilter($filter); + return $this->collection->deleteOne($filter, $options); + } + + /** + * Delete multiple documents + * + * @param array $filter Query filter + * @param array $options Delete options + * @return DeleteResult + */ + public function deleteMany(array $filter, array $options = []): DeleteResult + { + $filter = $this->convertFilter($filter); + return $this->collection->deleteMany($filter, $options); + } + + /** + * Count documents matching filter + * + * @param array $filter Query filter + * @param array $options Count options + * @return int + */ + public function countDocuments(array $filter = [], array $options = []): int + { + $filter = $this->convertFilter($filter); + return $this->collection->countDocuments($filter, $options); + } + + /** + * Execute aggregation pipeline + * + * @param array $pipeline Aggregation pipeline + * @param array $options Aggregation options + * @return Cursor + */ + public function aggregate(array $pipeline, array $options = []): Cursor + { + /** @var \Iterator $cursor */ + $cursor = $this->collection->aggregate($pipeline, $options); + return new Cursor($cursor); + } + + /** + * Create an index + * + * @param array $key Index specification + * @param array $options Index options + * @return string Index name + */ + public function createIndex(array $key, array $options = []): string + { + return $this->collection->createIndex($key, $options); + } + + /** + * Drop the collection + */ + public function drop(): array|object|null + { + return $this->collection->drop(); + } + + /** + * Get collection name + */ + public function getCollectionName(): string + { + return $this->collection->getCollectionName(); + } + + /** + * Get database name + */ + public function getDatabaseName(): string + { + return $this->collection->getDatabaseName(); + } + + /** + * Convert ObjectId instances in filter to MongoDB ObjectId + */ + private function convertFilter(array $filter): array + { + return $this->convertArray($filter); + } + + /** + * Convert ObjectId instances in document to MongoDB ObjectId + */ + private function convertDocument(array|object $document): array|object + { + if (is_array($document)) { + return $this->convertArray($document); + } + return $document; + } + + /** + * Recursively convert ObjectId and UTCDateTime instances + */ + private function convertArray(array $data): array + { + foreach ($data as $key => $value) { + if ($value instanceof ObjectId) { + $data[$key] = $value->toBSON(); + } elseif ($value instanceof UTCDateTime) { + $data[$key] = $value->toBSON(); + } elseif (is_array($value)) { + $data[$key] = $this->convertArray($value); + } + } + return $data; + } + + /** + * Get the underlying MongoDB Collection + * Use sparingly - prefer using wrapper methods + */ + public function getMongoCollection(): MongoCollection + { + return $this->collection; + } + + /** + * Convert BSON objects to native PHP types + * Handles ObjectId, UTCDateTime, and other BSON types + */ + private function convertBsonToNative(mixed $data): mixed + { + if (is_array($data)) { + foreach ($data as $key => $value) { + $data[$key] = $this->convertBsonToNative($value); + } + return $data; + } + + if (is_object($data)) { + // Convert MongoDB BSON ObjectId to string + if ($data instanceof \MongoDB\BSON\ObjectId) { + return (string) $data; + } + + // Convert MongoDB BSON UTCDateTime to string or DateTime + if ($data instanceof \MongoDB\BSON\UTCDateTime) { + return (string) $data->toDateTime()->format('c'); + } + + // Convert other objects to arrays recursively + if (method_exists($data, 'bsonSerialize')) { + return $this->convertBsonToNative($data->bsonSerialize()); + } + + return (array) $data; + } + + return $data; + } +} diff --git a/core/lib/Db/Cursor.php b/core/lib/Db/Cursor.php new file mode 100644 index 0000000..13ef296 --- /dev/null +++ b/core/lib/Db/Cursor.php @@ -0,0 +1,86 @@ +cursor = $cursor; + } + + /** + * Convert cursor to array with BSON types converted to native PHP types + */ + public function toArray(): array + { + $result = iterator_to_array($this->cursor); + return $this->convertBsonToNative($result); + } + + /** + * Get iterator for foreach loops + * Note: Items will be returned as-is (may contain BSON objects) + * Use toArray() if you need full conversion + */ + public function getIterator(): Traversable + { + return $this->cursor; + } + + /** + * Get underlying MongoDB cursor + */ + public function getMongoCursor(): Iterator + { + return $this->cursor; + } + + /** + * Convert BSON objects to native PHP types + * Handles ObjectId, UTCDateTime, and other BSON types + */ + private function convertBsonToNative(mixed $data): mixed + { + if (is_array($data)) { + foreach ($data as $key => $value) { + $data[$key] = $this->convertBsonToNative($value); + } + return $data; + } + + if (is_object($data)) { + // Convert MongoDB BSON ObjectId to string + if ($data instanceof \MongoDB\BSON\ObjectId) { + return (string) $data; + } + + // Convert MongoDB BSON UTCDateTime to ISO8601 string + if ($data instanceof \MongoDB\BSON\UTCDateTime) { + return $data->toDateTime()->format('c'); + } + + // Convert other objects to arrays recursively + if (method_exists($data, 'bsonSerialize')) { + return $this->convertBsonToNative($data->bsonSerialize()); + } + + // Convert stdClass and other objects to array + $array = (array) $data; + return $this->convertBsonToNative($array); + } + + return $data; + } +} diff --git a/core/lib/Db/DataStore.php b/core/lib/Db/DataStore.php new file mode 100644 index 0000000..91b604a --- /dev/null +++ b/core/lib/Db/DataStore.php @@ -0,0 +1,97 @@ +configuration = $configuration; + + $uri = $configuration['uri']; + $databaseName = $configuration['database']; + $options = $configuration['options'] ?? []; + $driverOptions = $configuration['driverOptions'] ?? []; + + $this->client = new Client($uri, $options, $driverOptions); + $this->database = $this->client->selectDatabase($databaseName, $options); + } + + /** + * Select a collection from the database + * + * @param string $collectionName Collection name + * @param array $options Collection options + * @return Collection + */ + public function selectCollection(string $collectionName, array $options = []): Collection + { + return $this->database->selectCollection($collectionName, $options); + } + + /** + * Get the underlying Database instance + */ + public function getDatabase(): Database + { + return $this->database; + } + + /** + * Get the Client instance + */ + public function getClient(): Client + { + return $this->client; + } + + /** + * List all collections + */ + public function listCollections(array $options = []): array + { + return $this->database->listCollections($options); + } + + /** + * Create a collection + */ + public function createCollection(string $collectionName, array $options = []): Collection + { + return $this->database->createCollection($collectionName, $options); + } + + /** + * Drop a collection + */ + public function dropCollection(string $collectionName, array $options = []): array|object + { + return $this->database->dropCollection($collectionName, $options); + } + + /** + * Get database name + */ + public function getDatabaseName(): string + { + return $this->database->getDatabaseName(); + } + + /** + * Magic method to access collection as property + */ + public function __get(string $collectionName): Collection + { + return $this->selectCollection($collectionName); + } +} diff --git a/core/lib/Db/Database.php b/core/lib/Db/Database.php new file mode 100644 index 0000000..02db600 --- /dev/null +++ b/core/lib/Db/Database.php @@ -0,0 +1,104 @@ +database = $database; + } + + /** + * Select a collection + * + * @param string $collectionName Collection name + * @param array $options Collection options + * @return Collection + */ + public function selectCollection(string $collectionName, array $options = []): Collection + { + $mongoCollection = $this->database->selectCollection($collectionName, $options); + return new Collection($mongoCollection); + } + + /** + * List collections + */ + public function listCollections(array $options = []): array + { + $collections = []; + foreach ($this->database->listCollections($options) as $collectionInfo) { + $collections[] = $collectionInfo; + } + return $collections; + } + + /** + * Drop the database + */ + public function drop(array $options = []): array|object|null + { + return $this->database->drop($options); + } + + /** + * Get database name + */ + public function getDatabaseName(): string + { + return $this->database->getDatabaseName(); + } + + /** + * Create a collection + */ + public function createCollection(string $collectionName, array $options = []): Collection|null + { + $mongoCollection = $this->database->createCollection($collectionName, $options); + return $mongoCollection ? new Collection($mongoCollection) : null; + } + + /** + * Drop a collection + */ + public function dropCollection(string $collectionName, array $options = []): array|object|null + { + return $this->database->dropCollection($collectionName, $options); + } + + /** + * Execute a database command + */ + public function command(array|object $command, array $options = []): Cursor + { + /** @var \Iterator $cursor */ + $cursor = $this->database->command($command, $options); + return new Cursor($cursor); + } + + /** + * Get the underlying MongoDB Database + * Use sparingly - prefer using wrapper methods + */ + public function getMongoDatabase(): MongoDatabase + { + return $this->database; + } + + /** + * Magic method to access collection as property + */ + public function __get(string $collectionName): Collection + { + return $this->selectCollection($collectionName); + } +} diff --git a/core/lib/Db/ObjectId.php b/core/lib/Db/ObjectId.php new file mode 100644 index 0000000..4b53e49 --- /dev/null +++ b/core/lib/Db/ObjectId.php @@ -0,0 +1,71 @@ +objectId = $id; + } elseif (is_string($id)) { + $this->objectId = new MongoObjectId($id); + } else { + $this->objectId = new MongoObjectId(); + } + } + + /** + * Get the string representation of the ObjectId + */ + public function __toString(): string + { + return (string) $this->objectId; + } + + /** + * Get the underlying MongoDB ObjectId + * Used internally when interacting with MongoDB driver + */ + public function toBSON(): MongoObjectId + { + return $this->objectId; + } + + /** + * Get the timestamp from the ObjectId + */ + public function getTimestamp(): int + { + return $this->objectId->getTimestamp(); + } + + /** + * Create ObjectId from string + */ + public static function fromString(string $id): self + { + return new self($id); + } + + /** + * Check if a string is a valid ObjectId + */ + public static function isValid(string $id): bool + { + return MongoObjectId::isValid($id); + } +} diff --git a/core/lib/Db/UTCDateTime.php b/core/lib/Db/UTCDateTime.php new file mode 100644 index 0000000..d83afbf --- /dev/null +++ b/core/lib/Db/UTCDateTime.php @@ -0,0 +1,89 @@ +dateTime = new MongoUTCDateTime($milliseconds); + } else { + // Fallback for environments without MongoDB extension (testing, linting) + $this->dateTime = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format(DATE_ATOM); + } + } + + /** + * Get the string representation + */ + public function __toString(): string + { + if ($this->dateTime instanceof MongoUTCDateTime) { + return $this->dateTime->toDateTime()->format(DATE_ATOM); + } + return $this->dateTime; + } + + /** + * Get the underlying MongoDB UTCDateTime or fallback string + * Used internally when interacting with MongoDB driver + */ + public function toBSON(): MongoUTCDateTime|string + { + return $this->dateTime; + } + + /** + * Convert to PHP DateTime + */ + public function toDateTime(): \DateTimeImmutable + { + if ($this->dateTime instanceof MongoUTCDateTime) { + return \DateTimeImmutable::createFromMutable($this->dateTime->toDateTime()); + } + return new \DateTimeImmutable($this->dateTime); + } + + /** + * Get milliseconds since epoch + */ + public function toMilliseconds(): int + { + if ($this->dateTime instanceof MongoUTCDateTime) { + return (int) $this->dateTime; + } + return (int) ((new \DateTimeImmutable($this->dateTime))->getTimestamp() * 1000); + } + + /** + * Create from DateTime + */ + public static function fromDateTime(DateTimeInterface $dateTime): self + { + return new self($dateTime); + } + + /** + * Create current timestamp + */ + public static function now(): self + { + return new self(); + } +} diff --git a/core/lib/Http/Cookie.php b/core/lib/Http/Cookie.php new file mode 100644 index 0000000..1e0cc99 --- /dev/null +++ b/core/lib/Http/Cookie.php @@ -0,0 +1,407 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http; + +/** + * Represents a cookie. + * + * @author Johannes M. Schmitt + */ +class Cookie +{ + public const SAMESITE_NONE = 'none'; + public const SAMESITE_LAX = 'lax'; + public const SAMESITE_STRICT = 'strict'; + + protected int $expire; + protected string $path; + + private ?string $sameSite = null; + private bool $secureDefault = false; + + private const RESERVED_CHARS_LIST = "=,; \t\r\n\v\f"; + private const RESERVED_CHARS_FROM = ['=', ',', ';', ' ', "\t", "\r", "\n", "\v", "\f"]; + private const RESERVED_CHARS_TO = ['%3D', '%2C', '%3B', '%20', '%09', '%0D', '%0A', '%0B', '%0C']; + + /** + * Creates cookie from raw header string. + */ + public static function fromString(string $cookie, bool $decode = false): static + { + $data = [ + 'expires' => 0, + 'path' => '/', + 'domain' => null, + 'secure' => false, + 'httponly' => false, + 'raw' => !$decode, + 'samesite' => null, + 'partitioned' => false, + ]; + + $parts = HeaderUtils::split($cookie, ';='); + $part = array_shift($parts); + + $name = $decode ? urldecode($part[0]) : $part[0]; + $value = isset($part[1]) ? ($decode ? urldecode($part[1]) : $part[1]) : null; + + $data = HeaderUtils::combine($parts) + $data; + $data['expires'] = self::expiresTimestamp($data['expires']); + + if (isset($data['max-age']) && ($data['max-age'] > 0 || $data['expires'] > time())) { + $data['expires'] = time() + (int) $data['max-age']; + } + + return new static($name, $value, $data['expires'], $data['path'], $data['domain'], $data['secure'], $data['httponly'], $data['raw'], $data['samesite'], $data['partitioned']); + } + + /** + * @see self::__construct + * + * @param self::SAMESITE_*|''|null $sameSite + */ + public static function create(string $name, ?string $value = null, int|string|\DateTimeInterface $expire = 0, ?string $path = '/', ?string $domain = null, ?bool $secure = null, bool $httpOnly = true, bool $raw = false, ?string $sameSite = self::SAMESITE_LAX, bool $partitioned = false): self + { + return new self($name, $value, $expire, $path, $domain, $secure, $httpOnly, $raw, $sameSite, $partitioned); + } + + /** + * @param string $name The name of the cookie + * @param string|null $value The value of the cookie + * @param int|string|\DateTimeInterface $expire The time the cookie expires + * @param string|null $path The path on the server in which the cookie will be available on + * @param string|null $domain The domain that the cookie is available to + * @param bool|null $secure Whether the client should send back the cookie only over HTTPS or null to auto-enable this when the request is already using HTTPS + * @param bool $httpOnly Whether the cookie will be made accessible only through the HTTP protocol + * @param bool $raw Whether the cookie value should be sent with no url encoding + * @param self::SAMESITE_*|''|null $sameSite Whether the cookie will be available for cross-site requests + * + * @throws \InvalidArgumentException + */ + public function __construct( + protected string $name, + protected ?string $value = null, + int|string|\DateTimeInterface $expire = 0, + ?string $path = '/', + protected ?string $domain = null, + protected ?bool $secure = null, + protected bool $httpOnly = true, + private bool $raw = false, + ?string $sameSite = self::SAMESITE_LAX, + private bool $partitioned = false, + ) { + // from PHP source code + if ($raw && false !== strpbrk($name, self::RESERVED_CHARS_LIST)) { + throw new \InvalidArgumentException(\sprintf('The cookie name "%s" contains invalid characters.', $name)); + } + + if (!$name) { + throw new \InvalidArgumentException('The cookie name cannot be empty.'); + } + + $this->expire = self::expiresTimestamp($expire); + $this->path = $path ?: '/'; + $this->sameSite = $this->withSameSite($sameSite)->sameSite; + } + + /** + * Creates a cookie copy with a new value. + */ + public function withValue(?string $value): static + { + $cookie = clone $this; + $cookie->value = $value; + + return $cookie; + } + + /** + * Creates a cookie copy with a new domain that the cookie is available to. + */ + public function withDomain(?string $domain): static + { + $cookie = clone $this; + $cookie->domain = $domain; + + return $cookie; + } + + /** + * Creates a cookie copy with a new time the cookie expires. + */ + public function withExpires(int|string|\DateTimeInterface $expire = 0): static + { + $cookie = clone $this; + $cookie->expire = self::expiresTimestamp($expire); + + return $cookie; + } + + /** + * Converts expires formats to a unix timestamp. + */ + private static function expiresTimestamp(int|string|\DateTimeInterface $expire = 0): int + { + // convert expiration time to a Unix timestamp + if ($expire instanceof \DateTimeInterface) { + $expire = $expire->format('U'); + } elseif (!is_numeric($expire)) { + $expire = strtotime($expire); + + if (false === $expire) { + throw new \InvalidArgumentException('The cookie expiration time is not valid.'); + } + } + + return 0 < $expire ? (int) $expire : 0; + } + + /** + * Creates a cookie copy with a new path on the server in which the cookie will be available on. + */ + public function withPath(string $path): static + { + $cookie = clone $this; + $cookie->path = '' === $path ? '/' : $path; + + return $cookie; + } + + /** + * Creates a cookie copy that only be transmitted over a secure HTTPS connection from the client. + */ + public function withSecure(bool $secure = true): static + { + $cookie = clone $this; + $cookie->secure = $secure; + + return $cookie; + } + + /** + * Creates a cookie copy that be accessible only through the HTTP protocol. + */ + public function withHttpOnly(bool $httpOnly = true): static + { + $cookie = clone $this; + $cookie->httpOnly = $httpOnly; + + return $cookie; + } + + /** + * Creates a cookie copy that uses no url encoding. + */ + public function withRaw(bool $raw = true): static + { + if ($raw && false !== strpbrk($this->name, self::RESERVED_CHARS_LIST)) { + throw new \InvalidArgumentException(\sprintf('The cookie name "%s" contains invalid characters.', $this->name)); + } + + $cookie = clone $this; + $cookie->raw = $raw; + + return $cookie; + } + + /** + * Creates a cookie copy with SameSite attribute. + * + * @param self::SAMESITE_*|''|null $sameSite + */ + public function withSameSite(?string $sameSite): static + { + if ('' === $sameSite) { + $sameSite = null; + } elseif (null !== $sameSite) { + $sameSite = strtolower($sameSite); + } + + if (!\in_array($sameSite, [self::SAMESITE_LAX, self::SAMESITE_STRICT, self::SAMESITE_NONE, null], true)) { + throw new \InvalidArgumentException('The "sameSite" parameter value is not valid.'); + } + + $cookie = clone $this; + $cookie->sameSite = $sameSite; + + return $cookie; + } + + /** + * Creates a cookie copy that is tied to the top-level site in cross-site context. + */ + public function withPartitioned(bool $partitioned = true): static + { + $cookie = clone $this; + $cookie->partitioned = $partitioned; + + return $cookie; + } + + /** + * Returns the cookie as a string. + */ + public function __toString(): string + { + if ($this->isRaw()) { + $str = $this->getName(); + } else { + $str = str_replace(self::RESERVED_CHARS_FROM, self::RESERVED_CHARS_TO, $this->getName()); + } + + $str .= '='; + + if ('' === (string) $this->getValue()) { + $str .= 'deleted; expires='.gmdate('D, d M Y H:i:s T', time() - 31536001).'; Max-Age=0'; + } else { + $str .= $this->isRaw() ? $this->getValue() : rawurlencode($this->getValue()); + + if (0 !== $this->getExpiresTime()) { + $str .= '; expires='.gmdate('D, d M Y H:i:s T', $this->getExpiresTime()).'; Max-Age='.$this->getMaxAge(); + } + } + + if ($this->getPath()) { + $str .= '; path='.$this->getPath(); + } + + if ($this->getDomain()) { + $str .= '; domain='.$this->getDomain(); + } + + if ($this->isSecure()) { + $str .= '; secure'; + } + + if ($this->isHttpOnly()) { + $str .= '; httponly'; + } + + if (null !== $this->getSameSite()) { + $str .= '; samesite='.$this->getSameSite(); + } + + if ($this->isPartitioned()) { + $str .= '; partitioned'; + } + + return $str; + } + + /** + * Gets the name of the cookie. + */ + public function getName(): string + { + return $this->name; + } + + /** + * Gets the value of the cookie. + */ + public function getValue(): ?string + { + return $this->value; + } + + /** + * Gets the domain that the cookie is available to. + */ + public function getDomain(): ?string + { + return $this->domain; + } + + /** + * Gets the time the cookie expires. + */ + public function getExpiresTime(): int + { + return $this->expire; + } + + /** + * Gets the max-age attribute. + */ + public function getMaxAge(): int + { + $maxAge = $this->expire - time(); + + return max(0, $maxAge); + } + + /** + * Gets the path on the server in which the cookie will be available on. + */ + public function getPath(): string + { + return $this->path; + } + + /** + * Checks whether the cookie should only be transmitted over a secure HTTPS connection from the client. + */ + public function isSecure(): bool + { + return $this->secure ?? $this->secureDefault; + } + + /** + * Checks whether the cookie will be made accessible only through the HTTP protocol. + */ + public function isHttpOnly(): bool + { + return $this->httpOnly; + } + + /** + * Whether this cookie is about to be cleared. + */ + public function isCleared(): bool + { + return 0 !== $this->expire && $this->expire < time(); + } + + /** + * Checks if the cookie value should be sent with no url encoding. + */ + public function isRaw(): bool + { + return $this->raw; + } + + /** + * Checks whether the cookie should be tied to the top-level site in cross-site context. + */ + public function isPartitioned(): bool + { + return $this->partitioned; + } + + /** + * @return self::SAMESITE_*|null + */ + public function getSameSite(): ?string + { + return $this->sameSite; + } + + /** + * @param bool $default The default value of the "secure" flag when it is set to null + */ + public function setSecureDefault(bool $default): void + { + $this->secureDefault = $default; + } +} diff --git a/core/lib/Http/Exception/BadRequestException.php b/core/lib/Http/Exception/BadRequestException.php new file mode 100644 index 0000000..7f55cce --- /dev/null +++ b/core/lib/Http/Exception/BadRequestException.php @@ -0,0 +1,11 @@ +originalName = $this->getName($originalName); + $this->mimeType = $mimeType ?? 'application/octet-stream'; + $this->error = $error ?? \UPLOAD_ERR_OK; + $this->test = $test; + + parent::__construct($path); + } + + /** + * Returns the original file name. + * + * It is extracted from the request from which the file has been uploaded. + * This should not be considered as a safe value to use for a file name on your servers. + * + * @return string The original name + */ + public function getClientOriginalName(): string + { + return $this->originalName; + } + + /** + * Returns the original file extension. + * + * It is extracted from the original file name that was uploaded. + * This should not be considered as a safe value to use for a file name on your servers. + * + * @return string The extension + */ + public function getClientOriginalExtension(): string + { + return pathinfo($this->originalName, \PATHINFO_EXTENSION); + } + + /** + * Returns the file mime type. + * + * The client mime type is extracted from the request from which the file was uploaded, + * so it should not be considered as a safe value. + * + * @return string The mime type + */ + public function getClientMimeType(): string + { + return $this->mimeType; + } + + /** + * Returns the extension based on the client mime type. + * + * If the mime type is unknown, returns null. + * + * This method uses a built-in list of mime type / extension pairs. + * + * @return string|null The guessed extension or null if it cannot be guessed + */ + public function guessClientExtension(): ?string + { + return self::mimeToExtension($this->mimeType); + } + + /** + * Returns the upload error. + * + * If the upload was successful, the constant UPLOAD_ERR_OK is returned. + * Otherwise one of the other UPLOAD_ERR_XXX constants is returned. + * + * @return int The upload error + */ + public function getError(): int + { + return $this->error; + } + + /** + * Returns whether the file has been uploaded with HTTP and no error occurred. + * + * @return bool True if the file is valid, false otherwise + */ + public function isValid(): bool + { + $isOk = \UPLOAD_ERR_OK === $this->error; + + return $this->test ? $isOk : $isOk && is_uploaded_file($this->getPathname()); + } + + /** + * Moves the file to a new location. + * + * @param string $directory The destination folder + * @param string|null $name The new file name + * + * @return \SplFileInfo A SplFileInfo object for the new file + * + * @throws \RuntimeException if the file cannot be moved + */ + public function move(string $directory, ?string $name = null): \SplFileInfo + { + if ($this->isValid()) { + if ($this->test) { + return $this->doMove($directory, $name); + } + + $target = $this->getTargetFile($directory, $name); + + if (!@move_uploaded_file($this->getPathname(), $target)) { + $error = error_get_last(); + throw new \RuntimeException(sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $target, strip_tags($error['message'] ?? 'unknown error'))); + } + + @chmod($target, 0666 & ~umask()); + + return new \SplFileInfo($target); + } + + throw new \RuntimeException($this->getErrorMessage()); + } + + /** + * Returns the maximum size of an uploaded file as configured in php.ini. + * + * @return int|float The maximum size of an uploaded file in bytes (returns float on 32-bit for large values) + */ + public static function getMaxFilesize(): int|float + { + $sizePostMax = self::parseFilesize(\ini_get('post_max_size')); + $sizeUploadMax = self::parseFilesize(\ini_get('upload_max_filesize')); + + return min($sizePostMax ?: \PHP_INT_MAX, $sizeUploadMax ?: \PHP_INT_MAX); + } + + /** + * Returns an informative upload error message. + * + * @return string The error message regarding the specified error code + */ + public function getErrorMessage(): string + { + return match ($this->error) { + \UPLOAD_ERR_INI_SIZE => 'The file "%s" exceeds your upload_max_filesize ini directive.', + \UPLOAD_ERR_FORM_SIZE => 'The file "%s" exceeds the upload limit defined in your form.', + \UPLOAD_ERR_PARTIAL => 'The file "%s" was only partially uploaded.', + \UPLOAD_ERR_NO_FILE => 'No file was uploaded.', + \UPLOAD_ERR_CANT_WRITE => 'The file "%s" could not be written on disk.', + \UPLOAD_ERR_NO_TMP_DIR => 'File could not be uploaded: missing temporary directory.', + \UPLOAD_ERR_EXTENSION => 'File upload was stopped by a PHP extension.', + default => 'The file "%s" was not uploaded due to an unknown error.', + }; + } + + /** + * Returns locale independent base name of the given path. + * + * @param string $name The new file name + * + * @return string The base name + */ + protected function getName(string $name): string + { + $originalName = str_replace('\\', '/', $name); + $pos = strrpos($originalName, '/'); + $originalName = false === $pos ? $originalName : substr($originalName, $pos + 1); + + return $originalName; + } + + protected function getTargetFile(string $directory, ?string $name = null): string + { + if (!is_dir($directory)) { + if (false === @mkdir($directory, 0777, true) && !is_dir($directory)) { + throw new \RuntimeException(sprintf('Unable to create the "%s" directory.', $directory)); + } + } elseif (!is_writable($directory)) { + throw new \RuntimeException(sprintf('Unable to write in the "%s" directory.', $directory)); + } + + $target = rtrim($directory, '/\\') . \DIRECTORY_SEPARATOR . (null === $name ? $this->getBasename() : $this->getName($name)); + + return $target; + } + + /** + * Moves the file to a new location (used in test mode). + */ + protected function doMove(string $directory, ?string $name = null): \SplFileInfo + { + $target = $this->getTargetFile($directory, $name); + + if (!@rename($this->getPathname(), $target)) { + $error = error_get_last(); + throw new \RuntimeException(sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $target, strip_tags($error['message'] ?? 'unknown error'))); + } + + @chmod($target, 0666 & ~umask()); + + return new \SplFileInfo($target); + } + + private static function parseFilesize(string $size): int|float + { + if ('' === $size) { + return 0; + } + + $size = strtolower($size); + + $max = ltrim($size, '+'); + if (str_starts_with($max, '0x')) { + $max = \intval($max, 16); + } elseif (str_starts_with($max, '0')) { + $max = \intval($max, 8); + } else { + $max = (int) $max; + } + + switch (substr($size, -1)) { + case 't': $max *= 1024; + // no break + case 'g': $max *= 1024; + // no break + case 'm': $max *= 1024; + // no break + case 'k': $max *= 1024; + } + + return $max; + } + + private static function mimeToExtension(string $mimeType): ?string + { + $map = [ + 'application/pdf' => 'pdf', + 'application/zip' => 'zip', + 'application/json' => 'json', + 'application/xml' => 'xml', + 'application/octet-stream' => 'bin', + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'image/svg+xml' => 'svg', + 'text/plain' => 'txt', + 'text/html' => 'html', + 'text/css' => 'css', + 'text/javascript' => 'js', + 'audio/mpeg' => 'mp3', + 'audio/wav' => 'wav', + 'video/mp4' => 'mp4', + 'video/webm' => 'webm', + ]; + + return $map[$mimeType] ?? null; + } +} diff --git a/core/lib/Http/HeaderParameters.php b/core/lib/Http/HeaderParameters.php new file mode 100644 index 0000000..21b216e --- /dev/null +++ b/core/lib/Http/HeaderParameters.php @@ -0,0 +1,275 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http; + +/** + * HeaderBag is a container for HTTP headers. + * + * @author Fabien Potencier + * + * @implements \IteratorAggregate> + */ +class HeaderParameters implements \IteratorAggregate, \Countable, \Stringable +{ + protected const UPPER = '_ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + protected const LOWER = '-abcdefghijklmnopqrstuvwxyz'; + + /** + * @var array> + */ + protected array $headers = []; + protected array $cacheControl = []; + + public function __construct(array $headers = []) + { + foreach ($headers as $key => $values) { + $this->set($key, $values); + } + } + + /** + * Returns the headers as a string. + */ + public function __toString(): string + { + if (!$headers = $this->all()) { + return ''; + } + + ksort($headers); + $max = max(array_map('strlen', array_keys($headers))) + 1; + $content = ''; + foreach ($headers as $name => $values) { + $name = ucwords($name, '-'); + foreach ($values as $value) { + $content .= \sprintf("%-{$max}s %s\r\n", $name.':', $value); + } + } + + return $content; + } + + /** + * Returns the headers. + * + * @param string|null $key The name of the headers to return or null to get them all + * + * @return ($key is null ? array> : list) + */ + public function all(?string $key = null): array + { + if (null !== $key) { + return $this->headers[strtr($key, self::UPPER, self::LOWER)] ?? []; + } + + return $this->headers; + } + + /** + * Returns the parameter keys. + * + * @return string[] + */ + public function keys(): array + { + return array_keys($this->all()); + } + + /** + * Replaces the current HTTP headers by a new set. + */ + public function replace(array $headers = []): void + { + $this->headers = []; + $this->add($headers); + } + + /** + * Adds new headers the current HTTP headers set. + */ + public function add(array $headers): void + { + foreach ($headers as $key => $values) { + $this->set($key, $values); + } + } + + /** + * Returns the first header by name or the default one. + */ + public function get(string $key, ?string $default = null): ?string + { + $headers = $this->all($key); + + if (!$headers) { + return $default; + } + + if (null === $headers[0]) { + return null; + } + + return $headers[0]; + } + + /** + * Sets a header by name. + * + * @param string|string[]|null $values The value or an array of values + * @param bool $replace Whether to replace the actual value or not (true by default) + */ + public function set(string $key, string|array|null $values, bool $replace = true): void + { + $key = strtr($key, self::UPPER, self::LOWER); + + if (\is_array($values)) { + $values = array_values($values); + + if (true === $replace || !isset($this->headers[$key])) { + $this->headers[$key] = $values; + } else { + $this->headers[$key] = array_merge($this->headers[$key], $values); + } + } else { + if (true === $replace || !isset($this->headers[$key])) { + $this->headers[$key] = [$values]; + } else { + $this->headers[$key][] = $values; + } + } + + if ('cache-control' === $key) { + $this->cacheControl = $this->parseCacheControl(implode(', ', $this->headers[$key])); + } + } + + /** + * Returns true if the HTTP header is defined. + */ + public function has(string $key): bool + { + return \array_key_exists(strtr($key, self::UPPER, self::LOWER), $this->all()); + } + + /** + * Returns true if the given HTTP header contains the given value. + */ + public function contains(string $key, string $value): bool + { + return \in_array($value, $this->all($key), true); + } + + /** + * Removes a header. + */ + public function remove(string $key): void + { + $key = strtr($key, self::UPPER, self::LOWER); + + unset($this->headers[$key]); + + if ('cache-control' === $key) { + $this->cacheControl = []; + } + } + + /** + * Returns the HTTP header value converted to a date. + * + * @throws \RuntimeException When the HTTP header is not parseable + */ + public function getDate(string $key, ?\DateTimeInterface $default = null): ?\DateTimeImmutable + { + if (null === $value = $this->get($key)) { + return null !== $default ? \DateTimeImmutable::createFromInterface($default) : null; + } + + if (false === $date = \DateTimeImmutable::createFromFormat(\DATE_RFC2822, $value)) { + throw new \RuntimeException(\sprintf('The "%s" HTTP header is not parseable (%s).', $key, $value)); + } + + return $date; + } + + /** + * Adds a custom Cache-Control directive. + */ + public function addCacheControlDirective(string $key, bool|string $value = true): void + { + $this->cacheControl[$key] = $value; + + $this->set('Cache-Control', $this->getCacheControlHeader()); + } + + /** + * Returns true if the Cache-Control directive is defined. + */ + public function hasCacheControlDirective(string $key): bool + { + return \array_key_exists($key, $this->cacheControl); + } + + /** + * Returns a Cache-Control directive value by name. + */ + public function getCacheControlDirective(string $key): bool|string|null + { + return $this->cacheControl[$key] ?? null; + } + + /** + * Removes a Cache-Control directive. + */ + public function removeCacheControlDirective(string $key): void + { + unset($this->cacheControl[$key]); + + $this->set('Cache-Control', $this->getCacheControlHeader()); + } + + /** + * Returns an iterator for headers. + * + * @return \ArrayIterator> + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->headers); + } + + /** + * Returns the number of headers. + */ + public function count(): int + { + return \count($this->headers); + } + + protected function getCacheControlHeader(): string + { + ksort($this->cacheControl); + + return HeaderUtils::toString($this->cacheControl, ','); + } + + /** + * Parses a Cache-Control HTTP header. + */ + protected function parseCacheControl(string $header): array + { + $parts = HeaderUtils::split($header, ',='); + + return HeaderUtils::combine($parts); + } +} diff --git a/core/lib/Http/HeaderUtils.php b/core/lib/Http/HeaderUtils.php new file mode 100644 index 0000000..f57d261 --- /dev/null +++ b/core/lib/Http/HeaderUtils.php @@ -0,0 +1,298 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http; + +/** + * HTTP header utility functions. + * + * @author Christian Schmidt + */ +class HeaderUtils +{ + public const DISPOSITION_ATTACHMENT = 'attachment'; + public const DISPOSITION_INLINE = 'inline'; + + /** + * This class should not be instantiated. + */ + private function __construct() + { + } + + /** + * Splits an HTTP header by one or more separators. + * + * Example: + * + * HeaderUtils::split('da, en-gb;q=0.8', ',;') + * # returns [['da'], ['en-gb', 'q=0.8']] + * + * @param string $separators List of characters to split on, ordered by + * precedence, e.g. ',', ';=', or ',;=' + * + * @return array Nested array with as many levels as there are characters in + * $separators + */ + public static function split(string $header, string $separators): array + { + if ('' === $separators) { + throw new \InvalidArgumentException('At least one separator must be specified.'); + } + + $quotedSeparators = preg_quote($separators, '/'); + + preg_match_all(' + / + (?!\s) + (?: + # quoted-string + "(?:[^"\\\\]|\\\\.)*(?:"|\\\\|$) + | + # token + [^"'.$quotedSeparators.']+ + )+ + (?['.$quotedSeparators.']) + \s* + /x', trim($header), $matches, \PREG_SET_ORDER); + + return self::groupParts($matches, $separators); + } + + /** + * Combines an array of arrays into one associative array. + * + * Each of the nested arrays should have one or two elements. The first + * value will be used as the keys in the associative array, and the second + * will be used as the values, or true if the nested array only contains one + * element. Array keys are lowercased. + * + * Example: + * + * HeaderUtils::combine([['foo', 'abc'], ['bar']]) + * // => ['foo' => 'abc', 'bar' => true] + */ + public static function combine(array $parts): array + { + $assoc = []; + foreach ($parts as $part) { + $name = strtolower($part[0]); + $value = $part[1] ?? true; + $assoc[$name] = $value; + } + + return $assoc; + } + + /** + * Joins an associative array into a string for use in an HTTP header. + * + * The key and value of each entry are joined with '=', and all entries + * are joined with the specified separator and an additional space (for + * readability). Values are quoted if necessary. + * + * Example: + * + * HeaderUtils::toString(['foo' => 'abc', 'bar' => true, 'baz' => 'a b c'], ',') + * // => 'foo=abc, bar, baz="a b c"' + */ + public static function toString(array $assoc, string $separator): string + { + $parts = []; + foreach ($assoc as $name => $value) { + if (true === $value) { + $parts[] = $name; + } else { + $parts[] = $name.'='.self::quote($value); + } + } + + return implode($separator.' ', $parts); + } + + /** + * Encodes a string as a quoted string, if necessary. + * + * If a string contains characters not allowed by the "token" construct in + * the HTTP specification, it is backslash-escaped and enclosed in quotes + * to match the "quoted-string" construct. + */ + public static function quote(string $s): string + { + if (preg_match('/^[a-z0-9!#$%&\'*.^_`|~-]+$/i', $s)) { + return $s; + } + + return '"'.addcslashes($s, '"\\"').'"'; + } + + /** + * Decodes a quoted string. + * + * If passed an unquoted string that matches the "token" construct (as + * defined in the HTTP specification), it is passed through verbatim. + */ + public static function unquote(string $s): string + { + return preg_replace('/\\\\(.)|"/', '$1', $s); + } + + /** + * Generates an HTTP Content-Disposition field-value. + * + * @param string $disposition One of "inline" or "attachment" + * @param string $filename A unicode string + * @param string $filenameFallback A string containing only ASCII characters that + * is semantically equivalent to $filename. If the filename is already ASCII, + * it can be omitted, or just copied from $filename + * + * @throws \InvalidArgumentException + * + * @see RFC 6266 + */ + public static function makeDisposition(string $disposition, string $filename, string $filenameFallback = ''): string + { + if (!\in_array($disposition, [self::DISPOSITION_ATTACHMENT, self::DISPOSITION_INLINE])) { + throw new \InvalidArgumentException(\sprintf('The disposition must be either "%s" or "%s".', self::DISPOSITION_ATTACHMENT, self::DISPOSITION_INLINE)); + } + + if ('' === $filenameFallback) { + $filenameFallback = $filename; + } + + // filenameFallback is not ASCII. + if (!preg_match('/^[\x20-\x7e]*$/', $filenameFallback)) { + throw new \InvalidArgumentException('The filename fallback must only contain ASCII characters.'); + } + + // percent characters aren't safe in fallback. + if (str_contains($filenameFallback, '%')) { + throw new \InvalidArgumentException('The filename fallback cannot contain the "%" character.'); + } + + // path separators aren't allowed in either. + if (str_contains($filename, '/') || str_contains($filename, '\\') || str_contains($filenameFallback, '/') || str_contains($filenameFallback, '\\')) { + throw new \InvalidArgumentException('The filename and the fallback cannot contain the "/" and "\\" characters.'); + } + + $params = ['filename' => $filenameFallback]; + if ($filename !== $filenameFallback) { + $params['filename*'] = "utf-8''".rawurlencode($filename); + } + + return $disposition.'; '.self::toString($params, ';'); + } + + /** + * Like parse_str(), but preserves dots in variable names. + */ + public static function parseQuery(string $query, bool $ignoreBrackets = false, string $separator = '&'): array + { + $q = []; + + foreach (explode($separator, $query) as $v) { + if (false !== $i = strpos($v, "\0")) { + $v = substr($v, 0, $i); + } + + if (false === $i = strpos($v, '=')) { + $k = urldecode($v); + $v = ''; + } else { + $k = urldecode(substr($v, 0, $i)); + $v = substr($v, $i); + } + + if (false !== $i = strpos($k, "\0")) { + $k = substr($k, 0, $i); + } + + $k = ltrim($k, ' '); + + if ($ignoreBrackets) { + $q[$k][] = urldecode(substr($v, 1)); + + continue; + } + + if (false === $i = strpos($k, '[')) { + $q[] = bin2hex($k).$v; + } else { + $q[] = bin2hex(substr($k, 0, $i)).rawurlencode(substr($k, $i)).$v; + } + } + + if ($ignoreBrackets) { + return $q; + } + + parse_str(implode('&', $q), $q); + + $query = []; + + foreach ($q as $k => $v) { + if (false !== $i = strpos($k, '_')) { + $query[substr_replace($k, hex2bin(substr($k, 0, $i)).'[', 0, 1 + $i)] = $v; + } else { + $query[hex2bin($k)] = $v; + } + } + + return $query; + } + + private static function groupParts(array $matches, string $separators, bool $first = true): array + { + $separator = $separators[0]; + $separators = substr($separators, 1) ?: ''; + $i = 0; + + if ('' === $separators && !$first) { + $parts = ['']; + + foreach ($matches as $match) { + if (!$i && isset($match['separator'])) { + $i = 1; + $parts[1] = ''; + } else { + $parts[$i] .= self::unquote($match[0]); + } + } + + return $parts; + } + + $parts = []; + $partMatches = []; + + foreach ($matches as $match) { + if (($match['separator'] ?? null) === $separator) { + ++$i; + } else { + $partMatches[$i][] = $match; + } + } + + foreach ($partMatches as $matches) { + if ('' === $separators && '' !== $unquoted = self::unquote($matches[0][0])) { + $parts[] = $unquoted; + } elseif ($groupedParts = self::groupParts($matches, $separators, false)) { + $parts[] = $groupedParts; + } + } + + return $parts; + } +} diff --git a/core/lib/Http/Middleware/AuthenticationMiddleware.php b/core/lib/Http/Middleware/AuthenticationMiddleware.php new file mode 100644 index 0000000..73a72ab --- /dev/null +++ b/core/lib/Http/Middleware/AuthenticationMiddleware.php @@ -0,0 +1,38 @@ +securityService->authenticate($request); + + // Initialize session identity if authentication succeeded + if ($identity) { + $this->sessionIdentity->initialize($identity, true); + } + + // Continue to next middleware (authentication is optional at this stage) + return $handler->handle($request); + } +} diff --git a/core/lib/Http/Middleware/FirewallMiddleware.php b/core/lib/Http/Middleware/FirewallMiddleware.php new file mode 100644 index 0000000..ebaf437 --- /dev/null +++ b/core/lib/Http/Middleware/FirewallMiddleware.php @@ -0,0 +1,32 @@ +firewall->authorized($request)) { + return new Response( + Response::$statusTexts[Response::HTTP_FORBIDDEN], + Response::HTTP_FORBIDDEN + ); + } + + // Continue to next middleware + return $handler->handle($request); + } +} diff --git a/core/lib/Http/Middleware/MiddlewareInterface.php b/core/lib/Http/Middleware/MiddlewareInterface.php new file mode 100644 index 0000000..8806316 --- /dev/null +++ b/core/lib/Http/Middleware/MiddlewareInterface.php @@ -0,0 +1,21 @@ + */ + private array $middleware = []; + + private ?ContainerInterface $container = null; + + public function __construct(?ContainerInterface $container = null) + { + $this->container = $container; + } + + /** + * Add middleware to the pipeline + * + * @param string|MiddlewareInterface $middleware Middleware class name or instance + * @return self + */ + public function pipe(string|MiddlewareInterface $middleware): self + { + $this->middleware[] = $middleware; + return $this; + } + + /** + * Handle the request through the middleware pipeline + * + * @param Request $request + * @return Response + */ + public function handle(Request $request): Response + { + // Create a handler for the pipeline + $handler = $this->createHandler(0); + + return $handler->handle($request); + } + + /** + * Create a handler for a specific position in the pipeline + * + * @param int $index Current position in the middleware stack + * @return RequestHandlerInterface + */ + public function createHandler(int $index): RequestHandlerInterface + { + // If we've reached the end of the pipeline, return a default handler + if (!isset($this->middleware[$index])) { + return new class implements RequestHandlerInterface { + public function handle(Request $request): Response { + return new Response(Response::$statusTexts[Response::HTTP_NOT_FOUND], Response::HTTP_NOT_FOUND); + } + }; + } + + return new class($this->middleware[$index], $this, $index, $this->container) implements RequestHandlerInterface { + private string|MiddlewareInterface $middleware; + private MiddlewarePipeline $pipeline; + private int $index; + private ?ContainerInterface $container; + + public function __construct( + string|MiddlewareInterface $middleware, + MiddlewarePipeline $pipeline, + int $index, + ?ContainerInterface $container + ) { + $this->middleware = $middleware; + $this->pipeline = $pipeline; + $this->index = $index; + $this->container = $container; + } + + public function handle(Request $request): Response + { + // Resolve middleware instance if it's a class name + $middleware = $this->middleware; + + if (is_string($middleware)) { + if ($this->container && $this->container->has($middleware)) { + $middleware = $this->container->get($middleware); + } else { + $middleware = new $middleware(); + } + } + + if (!$middleware instanceof MiddlewareInterface) { + throw new \RuntimeException( + sprintf('Middleware must implement %s', MiddlewareInterface::class) + ); + } + + // Create the next handler in the chain + $next = $this->pipeline->createHandler($this->index + 1); + + // Process this middleware + return $middleware->process($request, $next); + } + }; + } +} diff --git a/core/lib/Http/Middleware/RequestHandlerInterface.php b/core/lib/Http/Middleware/RequestHandlerInterface.php new file mode 100644 index 0000000..4177755 --- /dev/null +++ b/core/lib/Http/Middleware/RequestHandlerInterface.php @@ -0,0 +1,20 @@ +router->match($request); + + if (!$match instanceof Route) { + // No route matched, continue to next handler (will return 404) + return $handler->handle($request); + } + + // Check if route requires authentication + if ($match->authenticated && $this->sessionIdentity->identity() === null) { + return new Response( + Response::$statusTexts[Response::HTTP_UNAUTHORIZED], + Response::HTTP_UNAUTHORIZED + ); + } + + // Check permissions (if any specified) + if ($match->authenticated && !empty($match->permissions)) { + if (!$this->permissionChecker->canAny($match->permissions)) { + return new Response( + Response::$statusTexts[Response::HTTP_FORBIDDEN], + Response::HTTP_FORBIDDEN + ); + } + } + + // Dispatch to the controller + $response = $this->router->dispatch($match, $request); + + if ($response instanceof Response) { + return $response; + } + + // If dispatch didn't return a response, continue to next handler + return $handler->handle($request); + } +} diff --git a/core/lib/Http/Middleware/TenantMiddleware.php b/core/lib/Http/Middleware/TenantMiddleware.php new file mode 100644 index 0000000..8f9be5d --- /dev/null +++ b/core/lib/Http/Middleware/TenantMiddleware.php @@ -0,0 +1,35 @@ +sessionTenant->configure($request->getHost()); + + // Check if tenant is configured and enabled + if (!$this->sessionTenant->configured() || !$this->sessionTenant->enabled()) { + return new Response( + Response::$statusTexts[Response::HTTP_UNAUTHORIZED], + Response::HTTP_UNAUTHORIZED + ); + } + + // Continue to next middleware + return $handler->handle($request); + } +} diff --git a/core/lib/Http/Request/Request.php b/core/lib/Http/Request/Request.php new file mode 100644 index 0000000..892144f --- /dev/null +++ b/core/lib/Http/Request/Request.php @@ -0,0 +1,2107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Request; + +use KTXC\Http\Exception\BadRequestException; +use KTXC\Http\Exception\ConflictingHeadersException; +use KTXC\Http\Exception\JsonException; +use KTXC\Http\Exception\SessionNotFoundException; +use KTXC\Http\Exception\SuspiciousOperationException; +use KTXC\Http\HeaderUtils; +use KTXC\Http\Session\SessionInterface; +use KTXF\IpUtils; + +// Help opcache.preload discover always-needed symbols +class_exists(RequestHeaderAccept::class); +class_exists(RequestFileCollection::class); +class_exists(RequestHeaderParameters::class); +class_exists(HeaderUtils::class); +class_exists(RequestInputParameters::class); +class_exists(RequestServerParameters::class); + +/** + * Request represents an HTTP request. + * + * The methods dealing with URL accept / return a raw path (% encoded): + * * getBasePath + * * getBaseUrl + * * getPathInfo + * * getRequestUri + * * getUri + * * getUriForPath + * + * @author Fabien Potencier + */ +class Request +{ + public const HEADER_FORWARDED = 0b000001; // When using RFC 7239 + public const HEADER_X_FORWARDED_FOR = 0b000010; + public const HEADER_X_FORWARDED_HOST = 0b000100; + public const HEADER_X_FORWARDED_PROTO = 0b001000; + public const HEADER_X_FORWARDED_PORT = 0b010000; + public const HEADER_X_FORWARDED_PREFIX = 0b100000; + + public const HEADER_X_FORWARDED_AWS_ELB = 0b0011010; // AWS ELB doesn't send X-Forwarded-Host + public const HEADER_X_FORWARDED_TRAEFIK = 0b0111110; // All "X-Forwarded-*" headers sent by Traefik reverse proxy + + public const METHOD_HEAD = 'HEAD'; + public const METHOD_GET = 'GET'; + public const METHOD_POST = 'POST'; + public const METHOD_PUT = 'PUT'; + public const METHOD_PATCH = 'PATCH'; + public const METHOD_DELETE = 'DELETE'; + public const METHOD_PURGE = 'PURGE'; + public const METHOD_OPTIONS = 'OPTIONS'; + public const METHOD_TRACE = 'TRACE'; + public const METHOD_CONNECT = 'CONNECT'; + + /** + * @var string[] + */ + protected static array $trustedProxies = []; + + /** + * @var string[] + */ + protected static array $trustedHostPatterns = []; + + /** + * @var string[] + */ + protected static array $trustedHosts = []; + + protected static bool $httpMethodParameterOverride = false; + + /** + * Custom parameters. + */ + public RequestParameters $attributes; + + /** + * Request body parameters ($_POST). + * + * @see getPayload() for portability between content types + */ + public RequestInputParameters $request; + + /** + * Query string parameters ($_GET). + */ + public RequestInputParameters $query; + + /** + * Server and execution environment parameters ($_SERVER). + */ + public RequestServerParameters $server; + + /** + * Uploaded files ($_FILES). + */ + public RequestFileCollection $files; + + /** + * Cookies ($_COOKIE). + */ + public RequestInputParameters $cookies; + + /** + * Headers (taken from the $_SERVER). + */ + public RequestHeaderParameters $headers; + + /** + * @var string|resource|false|null + */ + protected $content; + + /** + * @var string[]|null + */ + protected ?array $languages = null; + + /** + * @var string[]|null + */ + protected ?array $charsets = null; + + /** + * @var string[]|null + */ + protected ?array $encodings = null; + + /** + * @var string[]|null + */ + protected ?array $acceptableContentTypes = null; + + protected ?string $pathInfo = null; + protected ?string $requestUri = null; + protected ?string $baseUrl = null; + protected ?string $basePath = null; + protected ?string $method = null; + protected ?string $format = null; + protected SessionInterface|\Closure|null $session = null; + protected ?string $locale = null; + protected string $defaultLocale = 'en'; + + /** + * @var array|null + */ + protected static ?array $formats = null; + + protected static ?\Closure $requestFactory = null; + + private ?string $preferredFormat = null; + + private bool $isHostValid = true; + private bool $isForwardedValid = true; + private bool $isSafeContentPreferred; + + private array $trustedValuesCache = []; + + private static int $trustedHeaderSet = -1; + + private const FORWARDED_PARAMS = [ + self::HEADER_X_FORWARDED_FOR => 'for', + self::HEADER_X_FORWARDED_HOST => 'host', + self::HEADER_X_FORWARDED_PROTO => 'proto', + self::HEADER_X_FORWARDED_PORT => 'host', + ]; + + /** + * Names for headers that can be trusted when + * using trusted proxies. + * + * The FORWARDED header is the standard as of rfc7239. + * + * The other headers are non-standard, but widely used + * by popular reverse proxies (like Apache mod_proxy or Amazon EC2). + */ + private const TRUSTED_HEADERS = [ + self::HEADER_FORWARDED => 'FORWARDED', + self::HEADER_X_FORWARDED_FOR => 'X_FORWARDED_FOR', + self::HEADER_X_FORWARDED_HOST => 'X_FORWARDED_HOST', + self::HEADER_X_FORWARDED_PROTO => 'X_FORWARDED_PROTO', + self::HEADER_X_FORWARDED_PORT => 'X_FORWARDED_PORT', + self::HEADER_X_FORWARDED_PREFIX => 'X_FORWARDED_PREFIX', + ]; + + private bool $isIisRewrite = false; + + /** + * @param array $query The GET parameters + * @param array $request The POST parameters + * @param array $attributes The request attributes (parameters parsed from the PATH_INFO, ...) + * @param array $cookies The COOKIE parameters + * @param array $files The FILES parameters + * @param array $server The SERVER parameters + * @param string|resource|null $content The raw body data + */ + public function __construct(array $query = [], array $request = [], array $attributes = [], array $cookies = [], array $files = [], array $server = [], $content = null) + { + $this->initialize($query, $request, $attributes, $cookies, $files, $server, $content); + } + + /** + * Sets the parameters for this request. + * + * This method also re-initializes all properties. + * + * @param array $query The GET parameters + * @param array $request The POST parameters + * @param array $attributes The request attributes (parameters parsed from the PATH_INFO, ...) + * @param array $cookies The COOKIE parameters + * @param array $files The FILES parameters + * @param array $server The SERVER parameters + * @param string|resource|null $content The raw body data + */ + public function initialize(array $query = [], array $request = [], array $attributes = [], array $cookies = [], array $files = [], array $server = [], $content = null): void + { + $this->request = new RequestInputParameters($request); + $this->query = new RequestInputParameters($query); + $this->attributes = new RequestParameters($attributes); + $this->cookies = new RequestInputParameters($cookies); + $this->files = new RequestFileCollection($files); + $this->server = new RequestServerParameters($server); + $this->headers = new RequestHeaderParameters($this->server->getHeaders()); + + $this->content = $content; + $this->languages = null; + $this->charsets = null; + $this->encodings = null; + $this->acceptableContentTypes = null; + $this->pathInfo = null; + $this->requestUri = null; + $this->baseUrl = null; + $this->basePath = null; + $this->method = null; + $this->format = null; + } + + /** + * Creates a new request with values from PHP's super globals. + */ + public static function createFromGlobals(): static + { + $request = self::createRequestFromFactory($_GET, $_POST, [], $_COOKIE, $_FILES, $_SERVER); + + if (str_starts_with($request->headers->get('CONTENT_TYPE', ''), 'application/x-www-form-urlencoded') + && \in_array(strtoupper($request->server->get('REQUEST_METHOD', 'GET')), ['PUT', 'DELETE', 'PATCH'], true) + ) { + parse_str($request->getContent(), $data); + $request->request = new RequestInputParameters($data); + } + + return $request; + } + + /** + * Creates a Request based on a given URI and configuration. + * + * The information contained in the URI always take precedence + * over the other information (server and parameters). + * + * @param string $uri The URI + * @param string $method The HTTP method + * @param array $parameters The query (GET) or request (POST) parameters + * @param array $cookies The request cookies ($_COOKIE) + * @param array $files The request files ($_FILES) + * @param array $server The server parameters ($_SERVER) + * @param string|resource|null $content The raw body data + * + * @throws BadRequestException When the URI is invalid + */ + public static function create(string $uri, string $method = 'GET', array $parameters = [], array $cookies = [], array $files = [], array $server = [], $content = null): static + { + $server = array_replace([ + 'SERVER_NAME' => 'localhost', + 'SERVER_PORT' => 80, + 'HTTP_HOST' => 'localhost', + 'HTTP_USER_AGENT' => 'Symfony', + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'HTTP_ACCEPT_LANGUAGE' => 'en-us,en;q=0.5', + 'HTTP_ACCEPT_CHARSET' => 'ISO-8859-1,utf-8;q=0.7,*;q=0.7', + 'REMOTE_ADDR' => '127.0.0.1', + 'SCRIPT_NAME' => '', + 'SCRIPT_FILENAME' => '', + 'SERVER_PROTOCOL' => 'HTTP/1.1', + 'REQUEST_TIME' => time(), + 'REQUEST_TIME_FLOAT' => microtime(true), + ], $server); + + $server['PATH_INFO'] = ''; + $server['REQUEST_METHOD'] = strtoupper($method); + + if (false === $components = parse_url(\strlen($uri) !== strcspn($uri, '?#') ? $uri : $uri.'#')) { + throw new BadRequestException('Invalid URI.'); + } + + if (false !== ($i = strpos($uri, '\\')) && $i < strcspn($uri, '?#')) { + throw new BadRequestException('Invalid URI: A URI cannot contain a backslash.'); + } + if (\strlen($uri) !== strcspn($uri, "\r\n\t")) { + throw new BadRequestException('Invalid URI: A URI cannot contain CR/LF/TAB characters.'); + } + if ('' !== $uri && (\ord($uri[0]) <= 32 || \ord($uri[-1]) <= 32)) { + throw new BadRequestException('Invalid URI: A URI must not start nor end with ASCII control characters or spaces.'); + } + + if (isset($components['host'])) { + $server['SERVER_NAME'] = $components['host']; + $server['HTTP_HOST'] = $components['host']; + } + + if (isset($components['scheme'])) { + if ('https' === $components['scheme']) { + $server['HTTPS'] = 'on'; + $server['SERVER_PORT'] = 443; + } else { + unset($server['HTTPS']); + $server['SERVER_PORT'] = 80; + } + } + + if (isset($components['port'])) { + $server['SERVER_PORT'] = $components['port']; + $server['HTTP_HOST'] .= ':'.$components['port']; + } + + if (isset($components['user'])) { + $server['PHP_AUTH_USER'] = $components['user']; + } + + if (isset($components['pass'])) { + $server['PHP_AUTH_PW'] = $components['pass']; + } + + if (!isset($components['path'])) { + $components['path'] = '/'; + } + + switch (strtoupper($method)) { + case 'POST': + case 'PUT': + case 'DELETE': + if (!isset($server['CONTENT_TYPE'])) { + $server['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + } + // no break + case 'PATCH': + $request = $parameters; + $query = []; + break; + default: + $request = []; + $query = $parameters; + break; + } + + $queryString = ''; + if (isset($components['query'])) { + parse_str(html_entity_decode($components['query']), $qs); + + if ($query) { + $query = array_replace($qs, $query); + $queryString = http_build_query($query, '', '&'); + } else { + $query = $qs; + $queryString = $components['query']; + } + } elseif ($query) { + $queryString = http_build_query($query, '', '&'); + } + + $server['REQUEST_URI'] = $components['path'].('' !== $queryString ? '?'.$queryString : ''); + $server['QUERY_STRING'] = $queryString; + + return self::createRequestFromFactory($query, $request, [], $cookies, $files, $server, $content); + } + + /** + * Sets a callable able to create a Request instance. + * + * This is mainly useful when you need to override the Request class + * to keep BC with an existing system. It should not be used for any + * other purpose. + */ + public static function setFactory(?callable $callable): void + { + self::$requestFactory = null === $callable ? null : $callable(...); + } + + /** + * Clones a request and overrides some of its parameters. + * + * @param array|null $query The GET parameters + * @param array|null $request The POST parameters + * @param array|null $attributes The request attributes (parameters parsed from the PATH_INFO, ...) + * @param array|null $cookies The COOKIE parameters + * @param array|null $files The FILES parameters + * @param array|null $server The SERVER parameters + */ + public function duplicate(?array $query = null, ?array $request = null, ?array $attributes = null, ?array $cookies = null, ?array $files = null, ?array $server = null): static + { + $dup = clone $this; + if (null !== $query) { + $dup->query = new RequestInputParameters($query); + } + if (null !== $request) { + $dup->request = new RequestInputParameters($request); + } + if (null !== $attributes) { + $dup->attributes = new RequestParameters($attributes); + } + if (null !== $cookies) { + $dup->cookies = new RequestInputParameters($cookies); + } + if (null !== $files) { + $dup->files = new RequestFileCollection($files); + } + if (null !== $server) { + $dup->server = new RequestServerParameters($server); + $dup->headers = new RequestHeaderParameters($dup->server->getHeaders()); + } + $dup->languages = null; + $dup->charsets = null; + $dup->encodings = null; + $dup->acceptableContentTypes = null; + $dup->pathInfo = null; + $dup->requestUri = null; + $dup->baseUrl = null; + $dup->basePath = null; + $dup->method = null; + $dup->format = null; + + if (!$dup->get('_format') && $this->get('_format')) { + $dup->attributes->set('_format', $this->get('_format')); + } + + if (!$dup->getRequestFormat(null)) { + $dup->setRequestFormat($this->getRequestFormat(null)); + } + + return $dup; + } + + /** + * Clones the current request. + * + * Note that the session is not cloned as duplicated requests + * are most of the time sub-requests of the main one. + */ + public function __clone() + { + $this->query = clone $this->query; + $this->request = clone $this->request; + $this->attributes = clone $this->attributes; + $this->cookies = clone $this->cookies; + $this->files = clone $this->files; + $this->server = clone $this->server; + $this->headers = clone $this->headers; + } + + public function __toString(): string + { + $content = $this->getContent(); + + $cookieHeader = ''; + $cookies = []; + + foreach ($this->cookies as $k => $v) { + $cookies[] = \is_array($v) ? http_build_query([$k => $v], '', '; ', \PHP_QUERY_RFC3986) : "$k=$v"; + } + + if ($cookies) { + $cookieHeader = 'Cookie: '.implode('; ', $cookies)."\r\n"; + } + + return + \sprintf('%s %s %s', $this->getMethod(), $this->getRequestUri(), $this->server->get('SERVER_PROTOCOL'))."\r\n". + $this->headers. + $cookieHeader."\r\n". + $content; + } + + /** + * Overrides the PHP global variables according to this request instance. + * + * It overrides $_GET, $_POST, $_REQUEST, $_SERVER, $_COOKIE. + * $_FILES is never overridden, see rfc1867 + */ + public function overrideGlobals(): void + { + $this->server->set('QUERY_STRING', static::normalizeQueryString(http_build_query($this->query->all(), '', '&'))); + + $_GET = $this->query->all(); + $_POST = $this->request->all(); + $_SERVER = $this->server->all(); + $_COOKIE = $this->cookies->all(); + + foreach ($this->headers->all() as $key => $value) { + $key = strtoupper(str_replace('-', '_', $key)); + if (\in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'], true)) { + $_SERVER[$key] = implode(', ', $value); + } else { + $_SERVER['HTTP_'.$key] = implode(', ', $value); + } + } + + $request = ['g' => $_GET, 'p' => $_POST, 'c' => $_COOKIE]; + + $requestOrder = \ini_get('request_order') ?: \ini_get('variables_order'); + $requestOrder = preg_replace('#[^cgp]#', '', strtolower($requestOrder)) ?: 'gp'; + + $_REQUEST = [[]]; + + foreach (str_split($requestOrder) as $order) { + $_REQUEST[] = $request[$order]; + } + + $_REQUEST = array_merge(...$_REQUEST); + } + + /** + * Sets a list of trusted proxies. + * + * You should only list the reverse proxies that you manage directly. + * + * @param array $proxies A list of trusted proxies, the string 'REMOTE_ADDR' will be replaced with $_SERVER['REMOTE_ADDR'] and 'PRIVATE_SUBNETS' by IpUtils::PRIVATE_SUBNETS + * @param int-mask-of $trustedHeaderSet A bit field to set which headers to trust from your proxies + */ + public static function setTrustedProxies(array $proxies, int $trustedHeaderSet): void + { + if (false !== $i = array_search('REMOTE_ADDR', $proxies, true)) { + if (isset($_SERVER['REMOTE_ADDR'])) { + $proxies[$i] = $_SERVER['REMOTE_ADDR']; + } else { + unset($proxies[$i]); + $proxies = array_values($proxies); + } + } + + if (false !== ($i = array_search('PRIVATE_SUBNETS', $proxies, true)) || false !== ($i = array_search('private_ranges', $proxies, true))) { + unset($proxies[$i]); + $proxies = array_merge($proxies, IpUtils::PRIVATE_SUBNETS); + } + + self::$trustedProxies = $proxies; + self::$trustedHeaderSet = $trustedHeaderSet; + } + + /** + * Gets the list of trusted proxies. + * + * @return string[] + */ + public static function getTrustedProxies(): array + { + return self::$trustedProxies; + } + + /** + * Gets the set of trusted headers from trusted proxies. + * + * @return int A bit field of Request::HEADER_* that defines which headers are trusted from your proxies + */ + public static function getTrustedHeaderSet(): int + { + return self::$trustedHeaderSet; + } + + /** + * Sets a list of trusted host patterns. + * + * You should only list the hosts you manage using regexs. + * + * @param array $hostPatterns A list of trusted host patterns + */ + public static function setTrustedHosts(array $hostPatterns): void + { + self::$trustedHostPatterns = array_map(fn ($hostPattern) => \sprintf('{%s}i', $hostPattern), $hostPatterns); + // we need to reset trusted hosts on trusted host patterns change + self::$trustedHosts = []; + } + + /** + * Gets the list of trusted host patterns. + * + * @return string[] + */ + public static function getTrustedHosts(): array + { + return self::$trustedHostPatterns; + } + + /** + * Normalizes a query string. + * + * It builds a normalized query string, where keys/value pairs are alphabetized, + * have consistent escaping and unneeded delimiters are removed. + */ + public static function normalizeQueryString(?string $qs): string + { + if ('' === ($qs ?? '')) { + return ''; + } + + $qs = HeaderUtils::parseQuery($qs); + ksort($qs); + + return http_build_query($qs, '', '&', \PHP_QUERY_RFC3986); + } + + /** + * Enables support for the _method request parameter to determine the intended HTTP method. + * + * Be warned that enabling this feature might lead to CSRF issues in your code. + * Check that you are using CSRF tokens when required. + * If the HTTP method parameter override is enabled, an html-form with method "POST" can be altered + * and used to send a "PUT" or "DELETE" request via the _method request parameter. + * If these methods are not protected against CSRF, this presents a possible vulnerability. + * + * The HTTP method can only be overridden when the real HTTP method is POST. + */ + public static function enableHttpMethodParameterOverride(): void + { + self::$httpMethodParameterOverride = true; + } + + /** + * Checks whether support for the _method request parameter is enabled. + */ + public static function getHttpMethodParameterOverride(): bool + { + return self::$httpMethodParameterOverride; + } + + /** + * Gets a "parameter" value from any bag. + * + * This method is mainly useful for libraries that want to provide some flexibility. If you don't need the + * flexibility in controllers, it is better to explicitly get request parameters from the appropriate + * public property instead (attributes, query, request). + * + * Order of precedence: PATH (routing placeholders or custom attributes), GET, POST + * + * @internal use explicit input sources instead + */ + public function get(string $key, mixed $default = null): mixed + { + if ($this !== $result = $this->attributes->get($key, $this)) { + return $result; + } + + if ($this->query->has($key)) { + return $this->query->all()[$key]; + } + + if ($this->request->has($key)) { + return $this->request->all()[$key]; + } + + return $default; + } + + /** + * Gets the Session. + * + * @throws SessionNotFoundException When session is not set properly + */ + public function getSession(): SessionInterface + { + $session = $this->session; + if (!$session instanceof SessionInterface && null !== $session) { + $this->setSession($session = $session()); + } + + if (null === $session) { + throw new SessionNotFoundException('Session has not been set.'); + } + + return $session; + } + + /** + * Whether the request contains a Session which was started in one of the + * previous requests. + */ + public function hasPreviousSession(): bool + { + // the check for $this->session avoids malicious users trying to fake a session cookie with proper name + return $this->hasSession() && $this->cookies->has($this->getSession()->getName()); + } + + /** + * Whether the request contains a Session object. + * + * This method does not give any information about the state of the session object, + * like whether the session is started or not. It is just a way to check if this Request + * is associated with a Session instance. + * + * @param bool $skipIfUninitialized When true, ignores factories injected by `setSessionFactory` + */ + public function hasSession(bool $skipIfUninitialized = false): bool + { + return null !== $this->session && (!$skipIfUninitialized || $this->session instanceof SessionInterface); + } + + public function setSession(SessionInterface $session): void + { + $this->session = $session; + } + + /** + * @internal + * + * @param callable(): SessionInterface $factory + */ + public function setSessionFactory(callable $factory): void + { + $this->session = $factory(...); + } + + /** + * Returns the client IP addresses. + * + * In the returned array the most trusted IP address is first, and the + * least trusted one last. The "real" client IP address is the last one, + * but this is also the least trusted one. Trusted proxies are stripped. + * + * Use this method carefully; you should use getClientIp() instead. + * + * @see getClientIp() + */ + public function getClientIps(): array + { + $ip = $this->server->get('REMOTE_ADDR'); + + if (!$this->isFromTrustedProxy()) { + return [$ip]; + } + + return $this->getTrustedValues(self::HEADER_X_FORWARDED_FOR, $ip) ?: [$ip]; + } + + /** + * Returns the client IP address. + * + * This method can read the client IP address from the "X-Forwarded-For" header + * when trusted proxies were set via "setTrustedProxies()". The "X-Forwarded-For" + * header value is a comma+space separated list of IP addresses, the left-most + * being the original client, and each successive proxy that passed the request + * adding the IP address where it received the request from. + * + * If your reverse proxy uses a different header name than "X-Forwarded-For", + * ("Client-Ip" for instance), configure it via the $trustedHeaderSet + * argument of the Request::setTrustedProxies() method instead. + * + * @see getClientIps() + * @see https://wikipedia.org/wiki/X-Forwarded-For + */ + public function getClientIp(): ?string + { + return $this->getClientIps()[0]; + } + + /** + * Returns current script name. + */ + public function getScriptName(): string + { + return $this->server->get('SCRIPT_NAME', $this->server->get('ORIG_SCRIPT_NAME', '')); + } + + /** + * Returns the path being requested relative to the executed script. + * + * The path info always starts with a /. + * + * Suppose this request is instantiated from /mysite on localhost: + * + * * http://localhost/mysite returns an empty string + * * http://localhost/mysite/about returns '/about' + * * http://localhost/mysite/enco%20ded returns '/enco%20ded' + * * http://localhost/mysite/about?var=1 returns '/about' + * + * @return string The raw path (i.e. not urldecoded) + */ + public function getPathInfo(): string + { + return $this->pathInfo ??= $this->preparePathInfo(); + } + + /** + * Returns the root path from which this request is executed. + * + * Suppose that an index.php file instantiates this request object: + * + * * http://localhost/index.php returns an empty string + * * http://localhost/index.php/page returns an empty string + * * http://localhost/web/index.php returns '/web' + * * http://localhost/we%20b/index.php returns '/we%20b' + * + * @return string The raw path (i.e. not urldecoded) + */ + public function getBasePath(): string + { + return $this->basePath ??= $this->prepareBasePath(); + } + + /** + * Returns the root URL from which this request is executed. + * + * The base URL never ends with a /. + * + * This is similar to getBasePath(), except that it also includes the + * script filename (e.g. index.php) if one exists. + * + * @return string The raw URL (i.e. not urldecoded) + */ + public function getBaseUrl(): string + { + $trustedPrefix = ''; + + // the proxy prefix must be prepended to any prefix being needed at the webserver level + if ($this->isFromTrustedProxy() && $trustedPrefixValues = $this->getTrustedValues(self::HEADER_X_FORWARDED_PREFIX)) { + $trustedPrefix = rtrim($trustedPrefixValues[0], '/'); + } + + return $trustedPrefix.$this->getBaseUrlReal(); + } + + /** + * Returns the real base URL received by the webserver from which this request is executed. + * The URL does not include trusted reverse proxy prefix. + * + * @return string The raw URL (i.e. not urldecoded) + */ + private function getBaseUrlReal(): string + { + return $this->baseUrl ??= $this->prepareBaseUrl(); + } + + /** + * Gets the request's scheme. + */ + public function getScheme(): string + { + return $this->isSecure() ? 'https' : 'http'; + } + + /** + * Returns the port on which the request is made. + * + * This method can read the client port from the "X-Forwarded-Port" header + * when trusted proxies were set via "setTrustedProxies()". + * + * The "X-Forwarded-Port" header must contain the client port. + * + * @return int|string|null Can be a string if fetched from the server bag + */ + public function getPort(): int|string|null + { + if ($this->isFromTrustedProxy() && $host = $this->getTrustedValues(self::HEADER_X_FORWARDED_PORT)) { + $host = $host[0]; + } elseif ($this->isFromTrustedProxy() && $host = $this->getTrustedValues(self::HEADER_X_FORWARDED_HOST)) { + $host = $host[0]; + } elseif (!$host = $this->headers->get('HOST')) { + return $this->server->get('SERVER_PORT'); + } + + if ('[' === $host[0]) { + $pos = strpos($host, ':', strrpos($host, ']')); + } else { + $pos = strrpos($host, ':'); + } + + if (false !== $pos && $port = substr($host, $pos + 1)) { + return (int) $port; + } + + return 'https' === $this->getScheme() ? 443 : 80; + } + + /** + * Returns the user. + */ + public function getUser(): ?string + { + return $this->headers->get('PHP_AUTH_USER'); + } + + /** + * Returns the password. + */ + public function getPassword(): ?string + { + return $this->headers->get('PHP_AUTH_PW'); + } + + /** + * Gets the user info. + * + * @return string|null A user name if any and, optionally, scheme-specific information about how to gain authorization to access the server + */ + public function getUserInfo(): ?string + { + $userinfo = $this->getUser(); + + $pass = $this->getPassword(); + if ('' != $pass) { + $userinfo .= ":$pass"; + } + + return $userinfo; + } + + /** + * Returns the HTTP host being requested. + * + * The port name will be appended to the host if it's non-standard. + */ + public function getHttpHost(): string + { + $scheme = $this->getScheme(); + $port = $this->getPort(); + + if (('http' === $scheme && 80 == $port) || ('https' === $scheme && 443 == $port)) { + return $this->getHost(); + } + + return $this->getHost().':'.$port; + } + + /** + * Returns the requested URI (path and query string). + * + * @return string The raw URI (i.e. not URI decoded) + */ + public function getRequestUri(): string + { + return $this->requestUri ??= $this->prepareRequestUri(); + } + + /** + * Gets the scheme and HTTP host. + * + * If the URL was called with basic authentication, the user + * and the password are not added to the generated string. + */ + public function getSchemeAndHttpHost(): string + { + return $this->getScheme().'://'.$this->getHttpHost(); + } + + /** + * Generates a normalized URI (URL) for the Request. + * + * @see getQueryString() + */ + public function getUri(): string + { + if (null !== $qs = $this->getQueryString()) { + $qs = '?'.$qs; + } + + return $this->getSchemeAndHttpHost().$this->getBaseUrl().$this->getPathInfo().$qs; + } + + /** + * Generates a normalized URI for the given path. + * + * @param string $path A path to use instead of the current one + */ + public function getUriForPath(string $path): string + { + return $this->getSchemeAndHttpHost().$this->getBaseUrl().$path; + } + + /** + * Returns the path as relative reference from the current Request path. + * + * Only the URIs path component (no schema, host etc.) is relevant and must be given. + * Both paths must be absolute and not contain relative parts. + * Relative URLs from one resource to another are useful when generating self-contained downloadable document archives. + * Furthermore, they can be used to reduce the link size in documents. + * + * Example target paths, given a base path of "/a/b/c/d": + * - "/a/b/c/d" -> "" + * - "/a/b/c/" -> "./" + * - "/a/b/" -> "../" + * - "/a/b/c/other" -> "other" + * - "/a/x/y" -> "../../x/y" + */ + public function getRelativeUriForPath(string $path): string + { + // be sure that we are dealing with an absolute path + if (!isset($path[0]) || '/' !== $path[0]) { + return $path; + } + + if ($path === $basePath = $this->getPathInfo()) { + return ''; + } + + $sourceDirs = explode('/', isset($basePath[0]) && '/' === $basePath[0] ? substr($basePath, 1) : $basePath); + $targetDirs = explode('/', substr($path, 1)); + array_pop($sourceDirs); + $targetFile = array_pop($targetDirs); + + foreach ($sourceDirs as $i => $dir) { + if (isset($targetDirs[$i]) && $dir === $targetDirs[$i]) { + unset($sourceDirs[$i], $targetDirs[$i]); + } else { + break; + } + } + + $targetDirs[] = $targetFile; + $path = str_repeat('../', \count($sourceDirs)).implode('/', $targetDirs); + + // A reference to the same base directory or an empty subdirectory must be prefixed with "./". + // This also applies to a segment with a colon character (e.g., "file:colon") that cannot be used + // as the first segment of a relative-path reference, as it would be mistaken for a scheme name + // (see https://tools.ietf.org/html/rfc3986#section-4.2). + return !isset($path[0]) || '/' === $path[0] + || false !== ($colonPos = strpos($path, ':')) && ($colonPos < ($slashPos = strpos($path, '/')) || false === $slashPos) + ? "./$path" : $path; + } + + /** + * Generates the normalized query string for the Request. + * + * It builds a normalized query string, where keys/value pairs are alphabetized + * and have consistent escaping. + */ + public function getQueryString(): ?string + { + $qs = static::normalizeQueryString($this->server->get('QUERY_STRING')); + + return '' === $qs ? null : $qs; + } + + /** + * Checks whether the request is secure or not. + * + * This method can read the client protocol from the "X-Forwarded-Proto" header + * when trusted proxies were set via "setTrustedProxies()". + * + * The "X-Forwarded-Proto" header must contain the protocol: "https" or "http". + */ + public function isSecure(): bool + { + if ($this->isFromTrustedProxy() && $proto = $this->getTrustedValues(self::HEADER_X_FORWARDED_PROTO)) { + return \in_array(strtolower($proto[0]), ['https', 'on', 'ssl', '1'], true); + } + + $https = $this->server->get('HTTPS'); + + return $https && 'off' !== strtolower($https); + } + + /** + * Returns the host name. + * + * This method can read the client host name from the "X-Forwarded-Host" header + * when trusted proxies were set via "setTrustedProxies()". + * + * The "X-Forwarded-Host" header must contain the client host name. + * + * @throws SuspiciousOperationException when the host name is invalid or not trusted + */ + public function getHost(): string + { + if ($this->isFromTrustedProxy() && $host = $this->getTrustedValues(self::HEADER_X_FORWARDED_HOST)) { + $host = $host[0]; + } elseif (!$host = $this->headers->get('HOST')) { + if (!$host = $this->server->get('SERVER_NAME')) { + $host = $this->server->get('SERVER_ADDR', ''); + } + } + + // trim and remove port number from host + // host is lowercase as per RFC 952/2181 + $host = strtolower(preg_replace('/:\d+$/', '', trim($host))); + + // as the host can come from the user (HTTP_HOST and depending on the configuration, SERVER_NAME too can come from the user) + // check that it does not contain forbidden characters (see RFC 952 and RFC 2181) + // use preg_replace() instead of preg_match() to prevent DoS attacks with long host names + if ($host && '' !== preg_replace('/(?:^\[)?[a-zA-Z0-9-:\]_]+\.?/', '', $host)) { + if (!$this->isHostValid) { + return ''; + } + $this->isHostValid = false; + + throw new SuspiciousOperationException(\sprintf('Invalid Host "%s".', $host)); + } + + if (\count(self::$trustedHostPatterns) > 0) { + // to avoid host header injection attacks, you should provide a list of trusted host patterns + + if (\in_array($host, self::$trustedHosts, true)) { + return $host; + } + + foreach (self::$trustedHostPatterns as $pattern) { + if (preg_match($pattern, $host)) { + self::$trustedHosts[] = $host; + + return $host; + } + } + + if (!$this->isHostValid) { + return ''; + } + $this->isHostValid = false; + + throw new SuspiciousOperationException(\sprintf('Untrusted Host "%s".', $host)); + } + + return $host; + } + + /** + * Sets the request method. + */ + public function setMethod(string $method): void + { + $this->method = null; + $this->server->set('REQUEST_METHOD', $method); + } + + /** + * Gets the request "intended" method. + * + * If the X-HTTP-Method-Override header is set, and if the method is a POST, + * then it is used to determine the "real" intended HTTP method. + * + * The _method request parameter can also be used to determine the HTTP method, + * but only if enableHttpMethodParameterOverride() has been called. + * + * The method is always an uppercased string. + * + * @see getRealMethod() + */ + public function getMethod(): string + { + if (null !== $this->method) { + return $this->method; + } + + $this->method = strtoupper($this->server->get('REQUEST_METHOD', 'GET')); + + if ('POST' !== $this->method) { + return $this->method; + } + + $method = $this->headers->get('X-HTTP-METHOD-OVERRIDE'); + + if (!$method && self::$httpMethodParameterOverride) { + $method = $this->request->get('_method', $this->query->get('_method', 'POST')); + } + + if (!\is_string($method)) { + return $this->method; + } + + $method = strtoupper($method); + + if (\in_array($method, ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'PATCH', 'PURGE', 'TRACE'], true)) { + return $this->method = $method; + } + + if (!preg_match('/^[A-Z]++$/D', $method)) { + throw new SuspiciousOperationException('Invalid HTTP method override.'); + } + + return $this->method = $method; + } + + /** + * Gets the "real" request method. + * + * @see getMethod() + */ + public function getRealMethod(): string + { + return strtoupper($this->server->get('REQUEST_METHOD', 'GET')); + } + + /** + * Gets the mime type associated with the format. + */ + public function getMimeType(string $format): ?string + { + if (null === static::$formats) { + static::initializeFormats(); + } + + return isset(static::$formats[$format]) ? static::$formats[$format][0] : null; + } + + /** + * Gets the mime types associated with the format. + * + * @return string[] + */ + public static function getMimeTypes(string $format): array + { + if (null === static::$formats) { + static::initializeFormats(); + } + + return static::$formats[$format] ?? []; + } + + /** + * Gets the format associated with the mime type. + */ + public function getFormat(?string $mimeType): ?string + { + $canonicalMimeType = null; + if ($mimeType && false !== $pos = strpos($mimeType, ';')) { + $canonicalMimeType = trim(substr($mimeType, 0, $pos)); + } + + if (null === static::$formats) { + static::initializeFormats(); + } + + foreach (static::$formats as $format => $mimeTypes) { + if (\in_array($mimeType, (array) $mimeTypes, true)) { + return $format; + } + if (null !== $canonicalMimeType && \in_array($canonicalMimeType, (array) $mimeTypes, true)) { + return $format; + } + } + + return null; + } + + /** + * Associates a format with mime types. + * + * @param string|string[] $mimeTypes The associated mime types (the preferred one must be the first as it will be used as the content type) + */ + public function setFormat(?string $format, string|array $mimeTypes): void + { + if (null === static::$formats) { + static::initializeFormats(); + } + + static::$formats[$format] = \is_array($mimeTypes) ? $mimeTypes : [$mimeTypes]; + } + + /** + * Gets the request format. + * + * Here is the process to determine the format: + * + * * format defined by the user (with setRequestFormat()) + * * _format request attribute + * * $default + * + * @see getPreferredFormat + */ + public function getRequestFormat(?string $default = 'html'): ?string + { + $this->format ??= $this->attributes->get('_format'); + + return $this->format ?? $default; + } + + /** + * Sets the request format. + */ + public function setRequestFormat(?string $format): void + { + $this->format = $format; + } + + /** + * Gets the usual name of the format associated with the request's media type (provided in the Content-Type header). + * + * @see Request::$formats + */ + public function getContentTypeFormat(): ?string + { + return $this->getFormat($this->headers->get('CONTENT_TYPE', '')); + } + + /** + * Sets the default locale. + */ + public function setDefaultLocale(string $locale): void + { + $this->defaultLocale = $locale; + + if (null === $this->locale) { + $this->setPhpDefaultLocale($locale); + } + } + + /** + * Get the default locale. + */ + public function getDefaultLocale(): string + { + return $this->defaultLocale; + } + + /** + * Sets the locale. + */ + public function setLocale(string $locale): void + { + $this->setPhpDefaultLocale($this->locale = $locale); + } + + /** + * Get the locale. + */ + public function getLocale(): string + { + return $this->locale ?? $this->defaultLocale; + } + + /** + * Checks if the request method is of specified type. + * + * @param string $method Uppercase request method (GET, POST etc) + */ + public function isMethod(string $method): bool + { + return $this->getMethod() === strtoupper($method); + } + + /** + * Checks whether or not the method is safe. + * + * @see https://tools.ietf.org/html/rfc7231#section-4.2.1 + */ + public function isMethodSafe(): bool + { + return \in_array($this->getMethod(), ['GET', 'HEAD', 'OPTIONS', 'TRACE']); + } + + /** + * Checks whether or not the method is idempotent. + */ + public function isMethodIdempotent(): bool + { + return \in_array($this->getMethod(), ['HEAD', 'GET', 'PUT', 'DELETE', 'TRACE', 'OPTIONS', 'PURGE']); + } + + /** + * Checks whether the method is cacheable or not. + * + * @see https://tools.ietf.org/html/rfc7231#section-4.2.3 + */ + public function isMethodCacheable(): bool + { + return \in_array($this->getMethod(), ['GET', 'HEAD']); + } + + /** + * Returns the protocol version. + * + * If the application is behind a proxy, the protocol version used in the + * requests between the client and the proxy and between the proxy and the + * server might be different. This returns the former (from the "Via" header) + * if the proxy is trusted (see "setTrustedProxies()"), otherwise it returns + * the latter (from the "SERVER_PROTOCOL" server parameter). + */ + public function getProtocolVersion(): ?string + { + if ($this->isFromTrustedProxy()) { + preg_match('~^(HTTP/)?([1-9]\.[0-9])\b~', $this->headers->get('Via') ?? '', $matches); + + if ($matches) { + return 'HTTP/'.$matches[2]; + } + } + + return $this->server->get('SERVER_PROTOCOL'); + } + + /** + * Returns the request body content. + * + * @param bool $asResource If true, a resource will be returned + * + * @return string|resource + * + * @psalm-return ($asResource is true ? resource : string) + */ + public function getContent(bool $asResource = false) + { + $currentContentIsResource = \is_resource($this->content); + + if (true === $asResource) { + if ($currentContentIsResource) { + rewind($this->content); + + return $this->content; + } + + // Content passed in parameter (test) + if (\is_string($this->content)) { + $resource = fopen('php://temp', 'r+'); + fwrite($resource, $this->content); + rewind($resource); + + return $resource; + } + + $this->content = false; + + return fopen('php://input', 'r'); + } + + if ($currentContentIsResource) { + rewind($this->content); + + return stream_get_contents($this->content); + } + + if (null === $this->content || false === $this->content) { + $this->content = file_get_contents('php://input'); + } + + return $this->content; + } + + /** + * Gets the decoded form or json request body. + * + * @throws JsonException When the body cannot be decoded to an array + */ + public function getPayload(): RequestInputParameters + { + if ($this->request->count()) { + return clone $this->request; + } + + if ('' === $content = $this->getContent()) { + return new RequestInputParameters([]); + } + + try { + $content = json_decode($content, true, 512, \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new JsonException('Could not decode request body.', $e->getCode(), $e); + } + + if (!\is_array($content)) { + throw new JsonException(\sprintf('JSON content was expected to decode to an array, "%s" returned.', get_debug_type($content))); + } + + return new RequestInputParameters($content); + } + + /** + * Gets the request body decoded as array, typically from a JSON payload. + * + * @see getPayload() for portability between content types + * + * @throws JsonException When the body cannot be decoded to an array + */ + public function toArray(): array + { + if ('' === $content = $this->getContent()) { + throw new JsonException('Request body is empty.'); + } + + try { + $content = json_decode($content, true, 512, \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new JsonException('Could not decode request body.', $e->getCode(), $e); + } + + if (!\is_array($content)) { + throw new JsonException(\sprintf('JSON content was expected to decode to an array, "%s" returned.', get_debug_type($content))); + } + + return $content; + } + + /** + * Gets the Etags. + */ + public function getETags(): array + { + return preg_split('/\s*,\s*/', $this->headers->get('If-None-Match', ''), -1, \PREG_SPLIT_NO_EMPTY); + } + + public function isNoCache(): bool + { + return $this->headers->hasCacheControlDirective('no-cache') || 'no-cache' == $this->headers->get('Pragma'); + } + + /** + * Gets the preferred format for the response by inspecting, in the following order: + * * the request format set using setRequestFormat; + * * the values of the Accept HTTP header. + * + * Note that if you use this method, you should send the "Vary: Accept" header + * in the response to prevent any issues with intermediary HTTP caches. + */ + public function getPreferredFormat(?string $default = 'html'): ?string + { + if (!isset($this->preferredFormat) && null !== $preferredFormat = $this->getRequestFormat(null)) { + $this->preferredFormat = $preferredFormat; + } + + if ($this->preferredFormat ?? null) { + return $this->preferredFormat; + } + + foreach ($this->getAcceptableContentTypes() as $mimeType) { + if ($this->preferredFormat = $this->getFormat($mimeType)) { + return $this->preferredFormat; + } + } + + return $default; + } + + /** + * Returns the preferred language. + * + * @param string[] $locales An array of ordered available locales + */ + public function getPreferredLanguage(?array $locales = null): ?string + { + $preferredLanguages = $this->getLanguages(); + + if (!$locales) { + return $preferredLanguages[0] ?? null; + } + + $locales = array_map($this->formatLocale(...), $locales); + if (!$preferredLanguages) { + return $locales[0]; + } + + $combinations = array_merge(...array_map($this->getLanguageCombinations(...), $preferredLanguages)); + foreach ($combinations as $combination) { + foreach ($locales as $locale) { + if (str_starts_with($locale, $combination)) { + return $locale; + } + } + } + + return $locales[0]; + } + + /** + * Gets a list of languages acceptable by the client browser ordered in the user browser preferences. + * + * @return string[] + */ + public function getLanguages(): array + { + if (null !== $this->languages) { + return $this->languages; + } + + $languages = RequestHeaderAccept::fromString($this->headers->get('Accept-Language'))->all(); + $this->languages = []; + foreach ($languages as $item) { + $lang = $item->getValue(); + $this->languages[] = self::formatLocale($lang); + } + $this->languages = array_unique($this->languages); + + return $this->languages; + } + + /** + * Strips the locale to only keep the canonicalized language value. + * + * Depending on the $locale value, this method can return values like : + * - language_Script_REGION: "fr_Latn_FR", "zh_Hans_TW" + * - language_Script: "fr_Latn", "zh_Hans" + * - language_REGION: "fr_FR", "zh_TW" + * - language: "fr", "zh" + * + * Invalid locale values are returned as is. + * + * @see https://wikipedia.org/wiki/IETF_language_tag + * @see https://datatracker.ietf.org/doc/html/rfc5646 + */ + private static function formatLocale(string $locale): string + { + [$language, $script, $region] = self::getLanguageComponents($locale); + + return implode('_', array_filter([$language, $script, $region])); + } + + /** + * Returns an array of all possible combinations of the language components. + * + * For instance, if the locale is "fr_Latn_FR", this method will return: + * - "fr_Latn_FR" + * - "fr_Latn" + * - "fr_FR" + * - "fr" + * + * @return string[] + */ + private static function getLanguageCombinations(string $locale): array + { + [$language, $script, $region] = self::getLanguageComponents($locale); + + return array_unique([ + implode('_', array_filter([$language, $script, $region])), + implode('_', array_filter([$language, $script])), + implode('_', array_filter([$language, $region])), + $language, + ]); + } + + /** + * Returns an array with the language components of the locale. + * + * For example: + * - If the locale is "fr_Latn_FR", this method will return "fr", "Latn", "FR" + * - If the locale is "fr_FR", this method will return "fr", null, "FR" + * - If the locale is "zh_Hans", this method will return "zh", "Hans", null + * + * @see https://wikipedia.org/wiki/IETF_language_tag + * @see https://datatracker.ietf.org/doc/html/rfc5646 + * + * @return array{string, string|null, string|null} + */ + private static function getLanguageComponents(string $locale): array + { + $locale = str_replace('_', '-', strtolower($locale)); + $pattern = '/^([a-zA-Z]{2,3}|i-[a-zA-Z]{5,})(?:-([a-zA-Z]{4}))?(?:-([a-zA-Z]{2}))?(?:-(.+))?$/'; + if (!preg_match($pattern, $locale, $matches)) { + return [$locale, null, null]; + } + if (str_starts_with($matches[1], 'i-')) { + // Language not listed in ISO 639 that are not variants + // of any listed language, which can be registered with the + // i-prefix, such as i-cherokee + $matches[1] = substr($matches[1], 2); + } + + return [ + $matches[1], + isset($matches[2]) ? ucfirst(strtolower($matches[2])) : null, + isset($matches[3]) ? strtoupper($matches[3]) : null, + ]; + } + + /** + * Gets a list of charsets acceptable by the client browser in preferable order. + * + * @return string[] + */ + public function getCharsets(): array + { + return $this->charsets ??= array_map('strval', array_keys(RequestHeaderAccept::fromString($this->headers->get('Accept-Charset'))->all())); + } + + /** + * Gets a list of encodings acceptable by the client browser in preferable order. + * + * @return string[] + */ + public function getEncodings(): array + { + return $this->encodings ??= array_map('strval', array_keys(RequestHeaderAccept::fromString($this->headers->get('Accept-Encoding'))->all())); + } + + /** + * Gets a list of content types acceptable by the client browser in preferable order. + * + * @return string[] + */ + public function getAcceptableContentTypes(): array + { + return $this->acceptableContentTypes ??= array_map('strval', array_keys(RequestHeaderAccept::fromString($this->headers->get('Accept'))->all())); + } + + /** + * Returns true if the request is an XMLHttpRequest. + * + * It works if your JavaScript library sets an X-Requested-With HTTP header. + * It is known to work with common JavaScript frameworks: + * + * @see https://wikipedia.org/wiki/List_of_Ajax_frameworks#JavaScript + */ + public function isXmlHttpRequest(): bool + { + return 'XMLHttpRequest' == $this->headers->get('X-Requested-With'); + } + + /** + * Checks whether the client browser prefers safe content or not according to RFC8674. + * + * @see https://tools.ietf.org/html/rfc8674 + */ + public function preferSafeContent(): bool + { + if (isset($this->isSafeContentPreferred)) { + return $this->isSafeContentPreferred; + } + + if (!$this->isSecure()) { + // see https://tools.ietf.org/html/rfc8674#section-3 + return $this->isSafeContentPreferred = false; + } + + return $this->isSafeContentPreferred = RequestHeaderAccept::fromString($this->headers->get('Prefer'))->has('safe'); + } + + /* + * The following methods are derived from code of the Zend Framework (1.10dev - 2010-01-24) + * + * Code subject to the new BSD license (https://framework.zend.com/license). + * + * Copyright (c) 2005-2010 Zend Technologies USA Inc. (https://www.zend.com/) + */ + + protected function prepareRequestUri(): string + { + $requestUri = ''; + + if ($this->isIisRewrite() && '' != $this->server->get('UNENCODED_URL')) { + // IIS7 with URL Rewrite: make sure we get the unencoded URL (double slash problem) + $requestUri = $this->server->get('UNENCODED_URL'); + $this->server->remove('UNENCODED_URL'); + } elseif ($this->server->has('REQUEST_URI')) { + $requestUri = $this->server->get('REQUEST_URI'); + + if ('' !== $requestUri && '/' === $requestUri[0]) { + // To only use path and query remove the fragment. + if (false !== $pos = strpos($requestUri, '#')) { + $requestUri = substr($requestUri, 0, $pos); + } + } else { + // HTTP proxy reqs setup request URI with scheme and host [and port] + the URL path, + // only use URL path. + $uriComponents = parse_url($requestUri); + + if (isset($uriComponents['path'])) { + $requestUri = $uriComponents['path']; + } + + if (isset($uriComponents['query'])) { + $requestUri .= '?'.$uriComponents['query']; + } + } + } elseif ($this->server->has('ORIG_PATH_INFO')) { + // IIS 5.0, PHP as CGI + $requestUri = $this->server->get('ORIG_PATH_INFO'); + if ('' != $this->server->get('QUERY_STRING')) { + $requestUri .= '?'.$this->server->get('QUERY_STRING'); + } + $this->server->remove('ORIG_PATH_INFO'); + } + + // normalize the request URI to ease creating sub-requests from this request + $this->server->set('REQUEST_URI', $requestUri); + + return $requestUri; + } + + /** + * Prepares the base URL. + */ + protected function prepareBaseUrl(): string + { + $filename = basename($this->server->get('SCRIPT_FILENAME', '')); + + if (basename($this->server->get('SCRIPT_NAME', '')) === $filename) { + $baseUrl = $this->server->get('SCRIPT_NAME'); + } elseif (basename($this->server->get('PHP_SELF', '')) === $filename) { + $baseUrl = $this->server->get('PHP_SELF'); + } elseif (basename($this->server->get('ORIG_SCRIPT_NAME', '')) === $filename) { + $baseUrl = $this->server->get('ORIG_SCRIPT_NAME'); // 1and1 shared hosting compatibility + } else { + // Backtrack up the script_filename to find the portion matching + // php_self + $path = $this->server->get('PHP_SELF', ''); + $file = $this->server->get('SCRIPT_FILENAME', ''); + $segs = explode('/', trim($file, '/')); + $segs = array_reverse($segs); + $index = 0; + $last = \count($segs); + $baseUrl = ''; + do { + $seg = $segs[$index]; + $baseUrl = '/'.$seg.$baseUrl; + ++$index; + } while ($last > $index && (false !== $pos = strpos($path, $baseUrl)) && 0 != $pos); + } + + // Does the baseUrl have anything in common with the request_uri? + $requestUri = $this->getRequestUri(); + if ('' !== $requestUri && '/' !== $requestUri[0]) { + $requestUri = '/'.$requestUri; + } + + if ($baseUrl && null !== $prefix = $this->getUrlencodedPrefix($requestUri, $baseUrl)) { + // full $baseUrl matches + return $prefix; + } + + if ($baseUrl && null !== $prefix = $this->getUrlencodedPrefix($requestUri, rtrim(\dirname($baseUrl), '/'.\DIRECTORY_SEPARATOR).'/')) { + // directory portion of $baseUrl matches + return rtrim($prefix, '/'.\DIRECTORY_SEPARATOR); + } + + $truncatedRequestUri = $requestUri; + if (false !== $pos = strpos($requestUri, '?')) { + $truncatedRequestUri = substr($requestUri, 0, $pos); + } + + $basename = basename($baseUrl ?? ''); + if (!$basename || !strpos(rawurldecode($truncatedRequestUri), $basename)) { + // no match whatsoever; set it blank + return ''; + } + + // If using mod_rewrite or ISAPI_Rewrite strip the script filename + // out of baseUrl. $pos !== 0 makes sure it is not matching a value + // from PATH_INFO or QUERY_STRING + if (\strlen($requestUri) >= \strlen($baseUrl) && (false !== $pos = strpos($requestUri, $baseUrl)) && 0 !== $pos) { + $baseUrl = substr($requestUri, 0, $pos + \strlen($baseUrl)); + } + + return rtrim($baseUrl, '/'.\DIRECTORY_SEPARATOR); + } + + /** + * Prepares the base path. + */ + protected function prepareBasePath(): string + { + $baseUrl = $this->getBaseUrl(); + if (!$baseUrl) { + return ''; + } + + $filename = basename($this->server->get('SCRIPT_FILENAME')); + if (basename($baseUrl) === $filename) { + $basePath = \dirname($baseUrl); + } else { + $basePath = $baseUrl; + } + + if ('\\' === \DIRECTORY_SEPARATOR) { + $basePath = str_replace('\\', '/', $basePath); + } + + return rtrim($basePath, '/'); + } + + /** + * Prepares the path info. + */ + protected function preparePathInfo(): string + { + if (null === ($requestUri = $this->getRequestUri())) { + return '/'; + } + + // Remove the query string from REQUEST_URI + if (false !== $pos = strpos($requestUri, '?')) { + $requestUri = substr($requestUri, 0, $pos); + } + if ('' !== $requestUri && '/' !== $requestUri[0]) { + $requestUri = '/'.$requestUri; + } + + if (null === ($baseUrl = $this->getBaseUrlReal())) { + return $requestUri; + } + + $pathInfo = substr($requestUri, \strlen($baseUrl)); + if ('' === $pathInfo) { + // If substr() returns false then PATH_INFO is set to an empty string + return '/'; + } + + return $pathInfo; + } + + /** + * Initializes HTTP request formats. + */ + protected static function initializeFormats(): void + { + static::$formats = [ + 'html' => ['text/html', 'application/xhtml+xml'], + 'txt' => ['text/plain'], + 'js' => ['application/javascript', 'application/x-javascript', 'text/javascript'], + 'css' => ['text/css'], + 'json' => ['application/json', 'application/x-json'], + 'jsonld' => ['application/ld+json'], + 'xml' => ['text/xml', 'application/xml', 'application/x-xml'], + 'rdf' => ['application/rdf+xml'], + 'atom' => ['application/atom+xml'], + 'rss' => ['application/rss+xml'], + 'form' => ['application/x-www-form-urlencoded', 'multipart/form-data'], + ]; + } + + private function setPhpDefaultLocale(string $locale): void + { + // if either the class Locale doesn't exist, or an exception is thrown when + // setting the default locale, the intl module is not installed, and + // the call can be ignored: + try { + if (class_exists(\Locale::class, false)) { + \Locale::setDefault($locale); + } + } catch (\Exception) { + } + } + + /** + * Returns the prefix as encoded in the string when the string starts with + * the given prefix, null otherwise. + */ + private function getUrlencodedPrefix(string $string, string $prefix): ?string + { + if ($this->isIisRewrite()) { + // ISS with UrlRewriteModule might report SCRIPT_NAME/PHP_SELF with wrong case + // see https://github.com/php/php-src/issues/11981 + if (0 !== stripos(rawurldecode($string), $prefix)) { + return null; + } + } elseif (!str_starts_with(rawurldecode($string), $prefix)) { + return null; + } + + $len = \strlen($prefix); + + if (preg_match(\sprintf('#^(%%[[:xdigit:]]{2}|.){%d}#', $len), $string, $match)) { + return $match[0]; + } + + return null; + } + + private static function createRequestFromFactory(array $query = [], array $request = [], array $attributes = [], array $cookies = [], array $files = [], array $server = [], $content = null): static + { + if (self::$requestFactory) { + $request = (self::$requestFactory)($query, $request, $attributes, $cookies, $files, $server, $content); + + if (!$request instanceof self) { + throw new \LogicException('The Request factory must return an instance of Symfony\Component\HttpFoundation\Request.'); + } + + return $request; + } + + return new static($query, $request, $attributes, $cookies, $files, $server, $content); + } + + /** + * Indicates whether this request originated from a trusted proxy. + * + * This can be useful to determine whether or not to trust the + * contents of a proxy-specific header. + */ + public function isFromTrustedProxy(): bool + { + return self::$trustedProxies && IpUtils::checkIp($this->server->get('REMOTE_ADDR', ''), self::$trustedProxies); + } + + /** + * This method is rather heavy because it splits and merges headers, and it's called by many other methods such as + * getPort(), isSecure(), getHost(), getClientIps(), getBaseUrl() etc. Thus, we try to cache the results for + * best performance. + */ + private function getTrustedValues(int $type, ?string $ip = null): array + { + $cacheKey = $type."\0".((self::$trustedHeaderSet & $type) ? $this->headers->get(self::TRUSTED_HEADERS[$type]) : ''); + $cacheKey .= "\0".$ip."\0".$this->headers->get(self::TRUSTED_HEADERS[self::HEADER_FORWARDED]); + + if (isset($this->trustedValuesCache[$cacheKey])) { + return $this->trustedValuesCache[$cacheKey]; + } + + $clientValues = []; + $forwardedValues = []; + + if ((self::$trustedHeaderSet & $type) && $this->headers->has(self::TRUSTED_HEADERS[$type])) { + foreach (explode(',', $this->headers->get(self::TRUSTED_HEADERS[$type])) as $v) { + $clientValues[] = (self::HEADER_X_FORWARDED_PORT === $type ? '0.0.0.0:' : '').trim($v); + } + } + + if ((self::$trustedHeaderSet & self::HEADER_FORWARDED) && (isset(self::FORWARDED_PARAMS[$type])) && $this->headers->has(self::TRUSTED_HEADERS[self::HEADER_FORWARDED])) { + $forwarded = $this->headers->get(self::TRUSTED_HEADERS[self::HEADER_FORWARDED]); + $parts = HeaderUtils::split($forwarded, ',;='); + $param = self::FORWARDED_PARAMS[$type]; + foreach ($parts as $subParts) { + if (null === $v = HeaderUtils::combine($subParts)[$param] ?? null) { + continue; + } + if (self::HEADER_X_FORWARDED_PORT === $type) { + if (str_ends_with($v, ']') || false === $v = strrchr($v, ':')) { + $v = $this->isSecure() ? ':443' : ':80'; + } + $v = '0.0.0.0'.$v; + } + $forwardedValues[] = $v; + } + } + + if (null !== $ip) { + $clientValues = $this->normalizeAndFilterClientIps($clientValues, $ip); + $forwardedValues = $this->normalizeAndFilterClientIps($forwardedValues, $ip); + } + + if ($forwardedValues === $clientValues || !$clientValues) { + return $this->trustedValuesCache[$cacheKey] = $forwardedValues; + } + + if (!$forwardedValues) { + return $this->trustedValuesCache[$cacheKey] = $clientValues; + } + + if (!$this->isForwardedValid) { + return $this->trustedValuesCache[$cacheKey] = null !== $ip ? ['0.0.0.0', $ip] : []; + } + $this->isForwardedValid = false; + + throw new ConflictingHeadersException(\sprintf('The request has both a trusted "%s" header and a trusted "%s" header, conflicting with each other. You should either configure your proxy to remove one of them, or configure your project to distrust the offending one.', self::TRUSTED_HEADERS[self::HEADER_FORWARDED], self::TRUSTED_HEADERS[$type])); + } + + private function normalizeAndFilterClientIps(array $clientIps, string $ip): array + { + if (!$clientIps) { + return []; + } + $clientIps[] = $ip; // Complete the IP chain with the IP the request actually came from + $firstTrustedIp = null; + + foreach ($clientIps as $key => $clientIp) { + if (strpos($clientIp, '.')) { + // Strip :port from IPv4 addresses. This is allowed in Forwarded + // and may occur in X-Forwarded-For. + $i = strpos($clientIp, ':'); + if ($i) { + $clientIps[$key] = $clientIp = substr($clientIp, 0, $i); + } + } elseif (str_starts_with($clientIp, '[')) { + // Strip brackets and :port from IPv6 addresses. + $i = strpos($clientIp, ']', 1); + $clientIps[$key] = $clientIp = substr($clientIp, 1, $i - 1); + } + + if (!filter_var($clientIp, \FILTER_VALIDATE_IP)) { + unset($clientIps[$key]); + + continue; + } + + if (IpUtils::checkIp($clientIp, self::$trustedProxies)) { + unset($clientIps[$key]); + + // Fallback to this when the client IP falls into the range of trusted proxies + $firstTrustedIp ??= $clientIp; + } + } + + // Now the IP chain contains only untrusted proxies and the client IP + return $clientIps ? array_reverse($clientIps) : [$firstTrustedIp]; + } + + /** + * Is this IIS with UrlRewriteModule? + * + * This method consumes, caches and removed the IIS_WasUrlRewritten env var, + * so we don't inherit it to sub-requests. + */ + private function isIisRewrite(): bool + { + if (1 === $this->server->getInt('IIS_WasUrlRewritten')) { + $this->isIisRewrite = true; + $this->server->remove('IIS_WasUrlRewritten'); + } + + return $this->isIisRewrite; + } +} diff --git a/core/lib/Http/Request/RequestFileCollection.php b/core/lib/Http/Request/RequestFileCollection.php new file mode 100644 index 0000000..a4c64cc --- /dev/null +++ b/core/lib/Http/Request/RequestFileCollection.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Request; + +use KTXC\Http\File\UploadedFile; + +/** + * FileBag is a container for uploaded files. + * + * @author Fabien Potencier + * @author Bulat Shakirzyanov + */ +class RequestFileCollection extends RequestParameters +{ + private const FILE_KEYS = ['error', 'full_path', 'name', 'size', 'tmp_name', 'type']; + + /** + * @param array|UploadedFile[] $parameters An array of HTTP files + */ + public function __construct(array $parameters = []) + { + $this->replace($parameters); + } + + public function replace(array $files = []): void + { + $this->parameters = []; + $this->add($files); + } + + public function set(string $key, mixed $value): void + { + if (!\is_array($value) && !$value instanceof UploadedFile) { + throw new \InvalidArgumentException('An uploaded file must be an array or an instance of UploadedFile.'); + } + + parent::set($key, $this->convertFileInformation($value)); + } + + public function add(array $files = []): void + { + foreach ($files as $key => $file) { + $this->set($key, $file); + } + } + + /** + * Converts uploaded files to UploadedFile instances. + * + * @return UploadedFile[]|UploadedFile|null + */ + protected function convertFileInformation(array|UploadedFile $file): array|UploadedFile|null + { + if ($file instanceof UploadedFile) { + return $file; + } + + $file = $this->fixPhpFilesArray($file); + $keys = array_keys($file + ['full_path' => null]); + sort($keys); + + if (self::FILE_KEYS === $keys) { + if (\UPLOAD_ERR_NO_FILE === $file['error']) { + $file = null; + } else { + $file = new UploadedFile($file['tmp_name'], $file['full_path'] ?? $file['name'], $file['type'], $file['error'], false); + } + } else { + $file = array_map(fn ($v) => $v instanceof UploadedFile || \is_array($v) ? $this->convertFileInformation($v) : $v, $file); + if (array_is_list($file)) { + $file = array_filter($file); + } + } + + return $file; + } + + /** + * Fixes a malformed PHP $_FILES array. + * + * PHP has a bug that the format of the $_FILES array differs, depending on + * whether the uploaded file fields had normal field names or array-like + * field names ("normal" vs. "parent[child]"). + * + * This method fixes the array to look like the "normal" $_FILES array. + * + * It's safe to pass an already converted array, in which case this method + * just returns the original array unmodified. + */ + protected function fixPhpFilesArray(array $data): array + { + $keys = array_keys($data + ['full_path' => null]); + sort($keys); + + if (self::FILE_KEYS !== $keys || !isset($data['name']) || !\is_array($data['name'])) { + return $data; + } + + $files = $data; + foreach (self::FILE_KEYS as $k) { + unset($files[$k]); + } + + foreach ($data['name'] as $key => $name) { + $files[$key] = $this->fixPhpFilesArray([ + 'error' => $data['error'][$key], + 'name' => $name, + 'type' => $data['type'][$key], + 'tmp_name' => $data['tmp_name'][$key], + 'size' => $data['size'][$key], + ] + (isset($data['full_path'][$key]) ? [ + 'full_path' => $data['full_path'][$key], + ] : [])); + } + + return $files; + } +} diff --git a/core/lib/Http/Request/RequestHeaderAccept.php b/core/lib/Http/Request/RequestHeaderAccept.php new file mode 100644 index 0000000..47746d3 --- /dev/null +++ b/core/lib/Http/Request/RequestHeaderAccept.php @@ -0,0 +1,154 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Request; + +use KTXC\Http\HeaderUtils; + +// Help opcache.preload discover always-needed symbols +class_exists(RequestHeaderAcceptItem::class); + +/** + * Represents an Accept-* header. + * + * An accept header is compound with a list of items, + * sorted by descending quality. + * + * @author Jean-François Simon + */ +class RequestHeaderAccept +{ + /** + * @var RequestHeaderAcceptItem[] + */ + private array $items = []; + + private bool $sorted = true; + + /** + * @param RequestHeaderAcceptItem[] $items + */ + public function __construct(array $items) + { + foreach ($items as $item) { + $this->add($item); + } + } + + /** + * Builds an AcceptHeader instance from a string. + */ + public static function fromString(?string $headerValue): self + { + $parts = HeaderUtils::split($headerValue ?? '', ',;='); + + return new self(array_map(function ($subParts) { + static $index = 0; + $part = array_shift($subParts); + $attributes = HeaderUtils::combine($subParts); + + $item = new RequestHeaderAcceptItem($part[0], $attributes); + $item->setIndex($index++); + + return $item; + }, $parts)); + } + + /** + * Returns header value's string representation. + */ + public function __toString(): string + { + return implode(',', $this->items); + } + + /** + * Tests if header has given value. + */ + public function has(string $value): bool + { + return isset($this->items[$value]); + } + + /** + * Returns given value's item, if exists. + */ + public function get(string $value): ?RequestHeaderAcceptItem + { + return $this->items[$value] ?? $this->items[explode('/', $value)[0].'/*'] ?? $this->items['*/*'] ?? $this->items['*'] ?? null; + } + + /** + * Adds an item. + * + * @return $this + */ + public function add(RequestHeaderAcceptItem $item): static + { + $this->items[$item->getValue()] = $item; + $this->sorted = false; + + return $this; + } + + /** + * Returns all items. + * + * @return RequestHeaderAcceptItem[] + */ + public function all(): array + { + $this->sort(); + + return $this->items; + } + + /** + * Filters items on their value using given regex. + */ + public function filter(string $pattern): self + { + return new self(array_filter($this->items, fn (RequestHeaderAcceptItem $item) => preg_match($pattern, $item->getValue()))); + } + + /** + * Returns first item. + */ + public function first(): ?RequestHeaderAcceptItem + { + $this->sort(); + + return $this->items ? reset($this->items) : null; + } + + /** + * Sorts items by descending quality. + */ + private function sort(): void + { + if (!$this->sorted) { + uasort($this->items, function (RequestHeaderAcceptItem $a, RequestHeaderAcceptItem $b) { + $qA = $a->getQuality(); + $qB = $b->getQuality(); + + if ($qA === $qB) { + return $a->getIndex() > $b->getIndex() ? 1 : -1; + } + + return $qA > $qB ? -1 : 1; + }); + + $this->sorted = true; + } + } +} diff --git a/core/lib/Http/Request/RequestHeaderAcceptItem.php b/core/lib/Http/Request/RequestHeaderAcceptItem.php new file mode 100644 index 0000000..6455ffe --- /dev/null +++ b/core/lib/Http/Request/RequestHeaderAcceptItem.php @@ -0,0 +1,163 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Request; + +use KTXC\Http\HeaderUtils; + +/** + * Represents an Accept-* header item. + * + * @author Jean-François Simon + */ +class RequestHeaderAcceptItem +{ + private float $quality = 1.0; + private int $index = 0; + private array $attributes = []; + + public function __construct( + private string $value, + array $attributes = [], + ) { + foreach ($attributes as $name => $value) { + $this->setAttribute($name, $value); + } + } + + /** + * Builds an AcceptHeaderInstance instance from a string. + */ + public static function fromString(?string $itemValue): self + { + $parts = HeaderUtils::split($itemValue ?? '', ';='); + + $part = array_shift($parts); + $attributes = HeaderUtils::combine($parts); + + return new self($part[0], $attributes); + } + + /** + * Returns header value's string representation. + */ + public function __toString(): string + { + $string = $this->value.($this->quality < 1 ? ';q='.$this->quality : ''); + if (\count($this->attributes) > 0) { + $string .= '; '.HeaderUtils::toString($this->attributes, ';'); + } + + return $string; + } + + /** + * Set the item value. + * + * @return $this + */ + public function setValue(string $value): static + { + $this->value = $value; + + return $this; + } + + /** + * Returns the item value. + */ + public function getValue(): string + { + return $this->value; + } + + /** + * Set the item quality. + * + * @return $this + */ + public function setQuality(float $quality): static + { + $this->quality = $quality; + + return $this; + } + + /** + * Returns the item quality. + */ + public function getQuality(): float + { + return $this->quality; + } + + /** + * Set the item index. + * + * @return $this + */ + public function setIndex(int $index): static + { + $this->index = $index; + + return $this; + } + + /** + * Returns the item index. + */ + public function getIndex(): int + { + return $this->index; + } + + /** + * Tests if an attribute exists. + */ + public function hasAttribute(string $name): bool + { + return isset($this->attributes[$name]); + } + + /** + * Returns an attribute by its name. + */ + public function getAttribute(string $name, mixed $default = null): mixed + { + return $this->attributes[$name] ?? $default; + } + + /** + * Returns all attributes. + */ + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * Set an attribute. + * + * @return $this + */ + public function setAttribute(string $name, string $value): static + { + if ('q' === $name) { + $this->quality = (float) $value; + } else { + $this->attributes[$name] = $value; + } + + return $this; + } +} diff --git a/core/lib/Http/Request/RequestHeaderParameters.php b/core/lib/Http/Request/RequestHeaderParameters.php new file mode 100644 index 0000000..3f3e4bd --- /dev/null +++ b/core/lib/Http/Request/RequestHeaderParameters.php @@ -0,0 +1,12 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Request; + +use KTXC\Http\Exception\BadRequestException; +use KTXC\Http\Exception\UnexpectedValueException; + +/** + * InputBag is a container for user input values such as $_GET, $_POST, $_REQUEST, and $_COOKIE. + * + * @author Saif Eddin Gmati + */ +final class RequestInputParameters extends RequestParameters +{ + /** + * Returns an input value by name (scalar, Stringable, or array). + * + * Arrays are now allowed. (Previously only scalar values were permitted.) + * No deep validation of array contents is performed here; callers should + * sanitize nested values as needed. + * + * @param string|int|float|bool|array|null $default The default value if the key does not exist + * + * @return string|int|float|bool|array|null + * + * @throws BadRequestException if the stored input value is of an unsupported type + * @throws \InvalidArgumentException if the provided default is of an unsupported type + */ + public function get(string $key, mixed $default = null): string|int|float|bool|array|null + { + if (null !== $default && !\is_scalar($default) && !$default instanceof \Stringable && !\is_array($default)) { + throw new \InvalidArgumentException(\sprintf('Expected a scalar or array value as a 2nd argument to "%s()", "%s" given.', __METHOD__, get_debug_type($default))); + } + + $value = parent::get($key, $this); + + if (null !== $value && $this !== $value && !\is_scalar($value) && !$value instanceof \Stringable && !\is_array($value)) { + throw new BadRequestException(\sprintf('Input value "%s" contains an invalid (non-scalar, non-array, non-Stringable) value.', $key)); + } + + return $this === $value ? $default : $value; + } + + /** + * Replaces the current input values by a new set. + */ + public function replace(array $inputs = []): void + { + $this->parameters = []; + $this->add($inputs); + } + + /** + * Adds input values. + */ + public function add(array $inputs = []): void + { + foreach ($inputs as $input => $value) { + $this->set($input, $value); + } + } + + /** + * Sets an input by name. + * + * @param string|int|float|bool|array|null $value + */ + public function set(string $key, mixed $value): void + { + if (null !== $value && !\is_scalar($value) && !\is_array($value) && !$value instanceof \Stringable) { + throw new \InvalidArgumentException(\sprintf('Expected a scalar, or an array as a 2nd argument to "%s()", "%s" given.', __METHOD__, get_debug_type($value))); + } + + $this->parameters[$key] = $value; + } + + /** + * Returns the parameter value converted to an enum. + * + * @template T of \BackedEnum + * + * @param class-string $class + * @param ?T $default + * + * @return ?T + * + * @psalm-return ($default is null ? T|null : T) + * + * @throws BadRequestException if the input cannot be converted to an enum + */ + public function getEnum(string $key, string $class, ?\BackedEnum $default = null): ?\BackedEnum + { + try { + return parent::getEnum($key, $class, $default); + } catch (UnexpectedValueException $e) { + throw new BadRequestException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Returns the parameter value converted to string. + * + * @throws BadRequestException if the input contains a non-scalar value + */ + public function getString(string $key, string $default = ''): string + { + // Shortcuts the parent method because the validation on scalar is already done in get(). + return (string) $this->get($key, $default); + } + + /** + * @throws BadRequestException if the input value is an array and \FILTER_REQUIRE_ARRAY or \FILTER_FORCE_ARRAY is not set + * @throws BadRequestException if the input value is invalid and \FILTER_NULL_ON_FAILURE is not set + */ + public function filter(string $key, mixed $default = null, int $filter = \FILTER_DEFAULT, mixed $options = []): mixed + { + $value = $this->has($key) ? $this->all()[$key] : $default; + + // Always turn $options into an array - this allows filter_var option shortcuts. + if (!\is_array($options) && $options) { + $options = ['flags' => $options]; + } + + if (\is_array($value) && !(($options['flags'] ?? 0) & (\FILTER_REQUIRE_ARRAY | \FILTER_FORCE_ARRAY))) { + throw new BadRequestException(\sprintf('Input value "%s" contains an array, but "FILTER_REQUIRE_ARRAY" or "FILTER_FORCE_ARRAY" flags were not set.', $key)); + } + + if ((\FILTER_CALLBACK & $filter) && !(($options['options'] ?? null) instanceof \Closure)) { + throw new \InvalidArgumentException(\sprintf('A Closure must be passed to "%s()" when FILTER_CALLBACK is used, "%s" given.', __METHOD__, get_debug_type($options['options'] ?? null))); + } + + $options['flags'] ??= 0; + $nullOnFailure = $options['flags'] & \FILTER_NULL_ON_FAILURE; + $options['flags'] |= \FILTER_NULL_ON_FAILURE; + + $value = filter_var($value, $filter, $options); + + if (null !== $value || $nullOnFailure) { + return $value; + } + + throw new BadRequestException(\sprintf('Input value "%s" is invalid and flag "FILTER_NULL_ON_FAILURE" was not set.', $key)); + } +} diff --git a/core/lib/Http/Request/RequestParameters.php b/core/lib/Http/Request/RequestParameters.php new file mode 100644 index 0000000..2ed484c --- /dev/null +++ b/core/lib/Http/Request/RequestParameters.php @@ -0,0 +1,260 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Request; + +use KTXC\Http\Exception\BadRequestException; +use KTXC\Http\Exception\UnexpectedValueException; + +/** + * ParameterBag is a container for key/value pairs. + * + * @author Fabien Potencier + * + * @implements \IteratorAggregate + */ +class RequestParameters implements \IteratorAggregate, \Countable +{ + public function __construct( + protected array $parameters = [], + ) { + } + + /** + * Returns the parameters. + * + * @param string|null $key The name of the parameter to return or null to get them all + * + * @throws BadRequestException if the value is not an array + */ + public function all(?string $key = null): array + { + if (null === $key) { + return $this->parameters; + } + + if (!\is_array($value = $this->parameters[$key] ?? [])) { + throw new BadRequestException(\sprintf('Unexpected value for parameter "%s": expecting "array", got "%s".', $key, get_debug_type($value))); + } + + return $value; + } + + /** + * Returns the parameter keys. + */ + public function keys(): array + { + return array_keys($this->parameters); + } + + /** + * Replaces the current parameters by a new set. + */ + public function replace(array $parameters = []): void + { + $this->parameters = $parameters; + } + + /** + * Adds parameters. + */ + public function add(array $parameters = []): void + { + $this->parameters = array_replace($this->parameters, $parameters); + } + + public function get(string $key, mixed $default = null): mixed + { + return \array_key_exists($key, $this->parameters) ? $this->parameters[$key] : $default; + } + + public function set(string $key, mixed $value): void + { + $this->parameters[$key] = $value; + } + + /** + * Returns true if the parameter is defined. + */ + public function has(string $key): bool + { + return \array_key_exists($key, $this->parameters); + } + + /** + * Removes a parameter. + */ + public function remove(string $key): void + { + unset($this->parameters[$key]); + } + + /** + * Returns the alphabetic characters of the parameter value. + * + * @throws UnexpectedValueException if the value cannot be converted to string + */ + public function getAlpha(string $key, string $default = ''): string + { + return preg_replace('/[^[:alpha:]]/', '', $this->getString($key, $default)); + } + + /** + * Returns the alphabetic characters and digits of the parameter value. + * + * @throws UnexpectedValueException if the value cannot be converted to string + */ + public function getAlnum(string $key, string $default = ''): string + { + return preg_replace('/[^[:alnum:]]/', '', $this->getString($key, $default)); + } + + /** + * Returns the digits of the parameter value. + * + * @throws UnexpectedValueException if the value cannot be converted to string + */ + public function getDigits(string $key, string $default = ''): string + { + return preg_replace('/[^[:digit:]]/', '', $this->getString($key, $default)); + } + + /** + * Returns the parameter as string. + * + * @throws UnexpectedValueException if the value cannot be converted to string + */ + public function getString(string $key, string $default = ''): string + { + $value = $this->get($key, $default); + if (!\is_scalar($value) && !$value instanceof \Stringable) { + throw new UnexpectedValueException(\sprintf('Parameter value "%s" cannot be converted to "string".', $key)); + } + + return (string) $value; + } + + /** + * Returns the parameter value converted to integer. + * + * @throws UnexpectedValueException if the value cannot be converted to integer + */ + public function getInt(string $key, int $default = 0): int + { + return $this->filter($key, $default, \FILTER_VALIDATE_INT, ['flags' => \FILTER_REQUIRE_SCALAR]); + } + + /** + * Returns the parameter value converted to boolean. + * + * @throws UnexpectedValueException if the value cannot be converted to a boolean + */ + public function getBoolean(string $key, bool $default = false): bool + { + return $this->filter($key, $default, \FILTER_VALIDATE_BOOL, ['flags' => \FILTER_REQUIRE_SCALAR]); + } + + /** + * Returns the parameter value converted to an enum. + * + * @template T of \BackedEnum + * + * @param class-string $class + * @param ?T $default + * + * @return ?T + * + * @psalm-return ($default is null ? T|null : T) + * + * @throws UnexpectedValueException if the parameter value cannot be converted to an enum + */ + public function getEnum(string $key, string $class, ?\BackedEnum $default = null): ?\BackedEnum + { + $value = $this->get($key); + + if (null === $value) { + return $default; + } + + try { + return $class::from($value); + } catch (\ValueError|\TypeError $e) { + throw new UnexpectedValueException(\sprintf('Parameter "%s" cannot be converted to enum: %s.', $key, $e->getMessage()), $e->getCode(), $e); + } + } + + /** + * Filter key. + * + * @param int $filter FILTER_* constant + * @param int|array{flags?: int, options?: array} $options Flags from FILTER_* constants + * + * @see https://php.net/filter-var + * + * @throws UnexpectedValueException if the parameter value is a non-stringable object + * @throws UnexpectedValueException if the parameter value is invalid and \FILTER_NULL_ON_FAILURE is not set + */ + public function filter(string $key, mixed $default = null, int $filter = \FILTER_DEFAULT, mixed $options = []): mixed + { + $value = $this->get($key, $default); + + // Always turn $options into an array - this allows filter_var option shortcuts. + if (!\is_array($options) && $options) { + $options = ['flags' => $options]; + } + + // Add a convenience check for arrays. + if (\is_array($value) && !isset($options['flags'])) { + $options['flags'] = \FILTER_REQUIRE_ARRAY; + } + + if (\is_object($value) && !$value instanceof \Stringable) { + throw new UnexpectedValueException(\sprintf('Parameter value "%s" cannot be filtered.', $key)); + } + + if ((\FILTER_CALLBACK & $filter) && !(($options['options'] ?? null) instanceof \Closure)) { + throw new \InvalidArgumentException(\sprintf('A Closure must be passed to "%s()" when FILTER_CALLBACK is used, "%s" given.', __METHOD__, get_debug_type($options['options'] ?? null))); + } + + $options['flags'] ??= 0; + $nullOnFailure = $options['flags'] & \FILTER_NULL_ON_FAILURE; + $options['flags'] |= \FILTER_NULL_ON_FAILURE; + + $value = filter_var($value, $filter, $options); + + if (null !== $value || $nullOnFailure) { + return $value; + } + + throw new \UnexpectedValueException(\sprintf('Parameter value "%s" is invalid and flag "FILTER_NULL_ON_FAILURE" was not set.', $key)); + } + + /** + * Returns an iterator for parameters. + * + * @return \ArrayIterator + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->parameters); + } + + /** + * Returns the number of parameters. + */ + public function count(): int + { + return \count($this->parameters); + } +} diff --git a/core/lib/Http/Request/RequestServerParameters.php b/core/lib/Http/Request/RequestServerParameters.php new file mode 100644 index 0000000..66ba4b6 --- /dev/null +++ b/core/lib/Http/Request/RequestServerParameters.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Request; + +/** + * ServerBag is a container for HTTP headers from the $_SERVER variable. + * + * @author Fabien Potencier + * @author Bulat Shakirzyanov + * @author Robert Kiss + */ +class RequestServerParameters extends RequestParameters +{ + /** + * Gets the HTTP headers. + */ + public function getHeaders(): array + { + $headers = []; + foreach ($this->parameters as $key => $value) { + if (str_starts_with($key, 'HTTP_')) { + $headers[substr($key, 5)] = $value; + } elseif (\in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'], true) && '' !== $value) { + $headers[$key] = $value; + } + } + + if (isset($this->parameters['PHP_AUTH_USER'])) { + $headers['PHP_AUTH_USER'] = $this->parameters['PHP_AUTH_USER']; + $headers['PHP_AUTH_PW'] = $this->parameters['PHP_AUTH_PW'] ?? ''; + } else { + /* + * php-cgi under Apache does not pass HTTP Basic user/pass to PHP by default + * For this workaround to work, add these lines to your .htaccess file: + * RewriteCond %{HTTP:Authorization} .+ + * RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0] + * + * A sample .htaccess file: + * RewriteEngine On + * RewriteCond %{HTTP:Authorization} .+ + * RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0] + * RewriteCond %{REQUEST_FILENAME} !-f + * RewriteRule ^(.*)$ index.php [QSA,L] + */ + + $authorizationHeader = null; + if (isset($this->parameters['HTTP_AUTHORIZATION'])) { + $authorizationHeader = $this->parameters['HTTP_AUTHORIZATION']; + } elseif (isset($this->parameters['REDIRECT_HTTP_AUTHORIZATION'])) { + $authorizationHeader = $this->parameters['REDIRECT_HTTP_AUTHORIZATION']; + } + + if (null !== $authorizationHeader) { + if (0 === stripos($authorizationHeader, 'basic ')) { + // Decode AUTHORIZATION header into PHP_AUTH_USER and PHP_AUTH_PW when authorization header is basic + $exploded = explode(':', base64_decode(substr($authorizationHeader, 6)), 2); + if (2 == \count($exploded)) { + [$headers['PHP_AUTH_USER'], $headers['PHP_AUTH_PW']] = $exploded; + } + } elseif (empty($this->parameters['PHP_AUTH_DIGEST']) && (0 === stripos($authorizationHeader, 'digest '))) { + // In some circumstances PHP_AUTH_DIGEST needs to be set + $headers['PHP_AUTH_DIGEST'] = $authorizationHeader; + $this->parameters['PHP_AUTH_DIGEST'] = $authorizationHeader; + } elseif (0 === stripos($authorizationHeader, 'bearer ')) { + /* + * XXX: Since there is no PHP_AUTH_BEARER in PHP predefined variables, + * I'll just set $headers['AUTHORIZATION'] here. + * https://php.net/reserved.variables.server + */ + $headers['AUTHORIZATION'] = $authorizationHeader; + } + } + } + + if (isset($headers['AUTHORIZATION'])) { + return $headers; + } + + // PHP_AUTH_USER/PHP_AUTH_PW + if (isset($headers['PHP_AUTH_USER'])) { + $headers['AUTHORIZATION'] = 'Basic '.base64_encode($headers['PHP_AUTH_USER'].':'.($headers['PHP_AUTH_PW'] ?? '')); + } elseif (isset($headers['PHP_AUTH_DIGEST'])) { + $headers['AUTHORIZATION'] = $headers['PHP_AUTH_DIGEST']; + } + + return $headers; + } +} diff --git a/core/lib/Http/Response/FileResponse.php b/core/lib/Http/Response/FileResponse.php new file mode 100644 index 0000000..de3b389 --- /dev/null +++ b/core/lib/Http/Response/FileResponse.php @@ -0,0 +1,78 @@ +filePath = $filePath; + + // Determine content type (very small helper; rely on common extensions) + $mime = self::guessMimeType($filePath) ?? 'application/octet-stream'; + $headers['Content-Type'] = $headers['Content-Type'] ?? $mime; + $headers['Content-Length'] = (string) filesize($filePath); + $headers['Last-Modified'] = gmdate('D, d M Y H:i:s', filemtime($filePath)) . ' GMT'; + $headers['Cache-Control'] = $headers['Cache-Control'] ?? 'public, max-age=60'; + + parent::__construct('', $status, $headers); + + // Defer reading file until sendContent to avoid memory usage. + } + + public function getFilePath(): string + { + return $this->filePath; + } + + public function sendContent(): static + { + // Output file contents directly + readfile($this->filePath); + return $this; + } + + private static function guessMimeType(string $filePath): ?string + { + $ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + return match ($ext) { + 'html', 'htm' => 'text/html; charset=UTF-8', + 'css' => 'text/css; charset=UTF-8', + 'js' => 'application/javascript; charset=UTF-8', + 'json' => 'application/json; charset=UTF-8', + 'png' => 'image/png', + 'jpg', 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'svg' => 'image/svg+xml', + 'txt' => 'text/plain; charset=UTF-8', + 'xml' => 'application/xml; charset=UTF-8', + default => self::finfoMime($filePath), + }; + } + + private static function finfoMime(string $filePath): ?string + { + if (function_exists('finfo_open')) { + $f = finfo_open(FILEINFO_MIME_TYPE); + if ($f) { + $mime = finfo_file($f, $filePath) ?: null; + finfo_close($f); + return $mime; + } + } + return null; + } +} diff --git a/core/lib/Http/Response/JsonResponse.php b/core/lib/Http/Response/JsonResponse.php new file mode 100644 index 0000000..45c04de --- /dev/null +++ b/core/lib/Http/Response/JsonResponse.php @@ -0,0 +1,189 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Response; + +/** + * Response represents an HTTP response in JSON format. + * + * Note that this class does not force the returned JSON content to be an + * object. It is however recommended that you do return an object as it + * protects yourself against XSSI and JSON-JavaScript Hijacking. + * + * @see https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/AJAX_Security_Cheat_Sheet.md#always-return-json-with-an-object-on-the-outside + * + * @author Igor Wiedler + */ +class JsonResponse extends Response +{ + protected mixed $data; + protected ?string $callback = null; + + // Encode <, >, ', &, and " characters in the JSON, making it also safe to be embedded into HTML. + // 15 === JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT + public const DEFAULT_ENCODING_OPTIONS = 15; + + protected int $encodingOptions = self::DEFAULT_ENCODING_OPTIONS; + + /** + * @param bool $json If the data is already a JSON string + */ + public function __construct(mixed $data = null, int $status = 200, array $headers = [], bool $json = false) + { + parent::__construct('', $status, $headers); + + if ($json && !\is_string($data) && !is_numeric($data) && !$data instanceof \Stringable) { + throw new \TypeError(\sprintf('"%s": If $json is set to true, argument $data must be a string or object implementing __toString(), "%s" given.', __METHOD__, get_debug_type($data))); + } + + $data ??= new \ArrayObject(); + + $json ? $this->setJson($data) : $this->setData($data); + } + + /** + * Factory method for chainability. + * + * Example: + * + * return JsonResponse::fromJsonString('{"key": "value"}') + * ->setSharedMaxAge(300); + * + * @param string $data The JSON response string + * @param int $status The response status code (200 "OK" by default) + * @param array $headers An array of response headers + */ + public static function fromJsonString(string $data, int $status = 200, array $headers = []): static + { + return new static($data, $status, $headers, true); + } + + /** + * Sets the JSONP callback. + * + * @param string|null $callback The JSONP callback or null to use none + * + * @return $this + * + * @throws \InvalidArgumentException When the callback name is not valid + */ + public function setCallback(?string $callback): static + { + if (null !== $callback) { + // partially taken from https://geekality.net/2011/08/03/valid-javascript-identifier/ + // partially taken from https://github.com/willdurand/JsonpCallbackValidator + // JsonpCallbackValidator is released under the MIT License. See https://github.com/willdurand/JsonpCallbackValidator/blob/v1.1.0/LICENSE for details. + // (c) William Durand + $pattern = '/^[$_\p{L}][$_\p{L}\p{Mn}\p{Mc}\p{Nd}\p{Pc}\x{200C}\x{200D}]*(?:\[(?:"(?:\\\.|[^"\\\])*"|\'(?:\\\.|[^\'\\\])*\'|\d+)\])*?$/u'; + $reserved = [ + 'break', 'do', 'instanceof', 'typeof', 'case', 'else', 'new', 'var', 'catch', 'finally', 'return', 'void', 'continue', 'for', 'switch', 'while', + 'debugger', 'function', 'this', 'with', 'default', 'if', 'throw', 'delete', 'in', 'try', 'class', 'enum', 'extends', 'super', 'const', 'export', + 'import', 'implements', 'let', 'private', 'public', 'yield', 'interface', 'package', 'protected', 'static', 'null', 'true', 'false', + ]; + $parts = explode('.', $callback); + foreach ($parts as $part) { + if (!preg_match($pattern, $part) || \in_array($part, $reserved, true)) { + throw new \InvalidArgumentException('The callback name is not valid.'); + } + } + } + + $this->callback = $callback; + + return $this->update(); + } + + /** + * Sets a raw string containing a JSON document to be sent. + * + * @return $this + */ + public function setJson(string $json): static + { + $this->data = $json; + + return $this->update(); + } + + /** + * Sets the data to be sent as JSON. + * + * @return $this + * + * @throws \InvalidArgumentException + */ + public function setData(mixed $data = []): static + { + try { + $data = json_encode($data, $this->encodingOptions); + } catch (\Exception $e) { + if ('Exception' === $e::class && str_starts_with($e->getMessage(), 'Failed calling ')) { + throw $e->getPrevious() ?: $e; + } + throw $e; + } + + if (\JSON_THROW_ON_ERROR & $this->encodingOptions) { + return $this->setJson($data); + } + + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(json_last_error_msg()); + } + + return $this->setJson($data); + } + + /** + * Returns options used while encoding data to JSON. + */ + public function getEncodingOptions(): int + { + return $this->encodingOptions; + } + + /** + * Sets options used while encoding data to JSON. + * + * @return $this + */ + public function setEncodingOptions(int $encodingOptions): static + { + $this->encodingOptions = $encodingOptions; + + return $this->setData(json_decode($this->data)); + } + + /** + * Updates the content and headers according to the JSON data and callback. + * + * @return $this + */ + protected function update(): static + { + if (null !== $this->callback) { + // Not using application/javascript for compatibility reasons with older browsers. + $this->headers->set('Content-Type', 'text/javascript'); + + return $this->setContent(\sprintf('/**/%s(%s);', $this->callback, $this->data)); + } + + // Only set the header when there is none or when it equals 'text/javascript' (from a previous update with callback) + // in order to not overwrite a custom definition. + if (!$this->headers->has('Content-Type') || 'text/javascript' === $this->headers->get('Content-Type')) { + $this->headers->set('Content-Type', 'application/json'); + } + + return $this->setContent($this->data); + } +} diff --git a/core/lib/Http/Response/RedirectResponse.php b/core/lib/Http/Response/RedirectResponse.php new file mode 100644 index 0000000..7c6f802 --- /dev/null +++ b/core/lib/Http/Response/RedirectResponse.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Response; + +/** + * RedirectResponse represents an HTTP response doing a redirect. + * + * @author Fabien Potencier + */ +class RedirectResponse extends Response +{ + protected string $targetUrl; + + /** + * Creates a redirect response so that it conforms to the rules defined for a redirect status code. + * + * @param string $url The URL to redirect to. The URL should be a full URL, with schema etc., + * but practically every browser redirects on paths only as well + * @param int $status The HTTP status code (302 "Found" by default) + * @param array $headers The headers (Location is always set to the given URL) + * + * @throws \InvalidArgumentException + * + * @see https://tools.ietf.org/html/rfc2616#section-10.3 + */ + public function __construct(string $url, int $status = 302, array $headers = []) + { + parent::__construct('', $status, $headers); + + $this->setTargetUrl($url); + + if (!$this->isRedirect()) { + throw new \InvalidArgumentException(\sprintf('The HTTP status code is not a redirect ("%s" given).', $status)); + } + + if (301 == $status && !\array_key_exists('cache-control', array_change_key_case($headers, \CASE_LOWER))) { + $this->headers->remove('cache-control'); + } + } + + /** + * Returns the target URL. + */ + public function getTargetUrl(): string + { + return $this->targetUrl; + } + + /** + * Sets the redirect target of this response. + * + * @return $this + * + * @throws \InvalidArgumentException + */ + public function setTargetUrl(string $url): static + { + if ('' === $url) { + throw new \InvalidArgumentException('Cannot redirect to an empty URL.'); + } + + $this->targetUrl = $url; + + $this->setContent( + \sprintf(' + + + + + + Redirecting to %1$s + + + Redirecting to %1$s. + +', htmlspecialchars($url, \ENT_QUOTES, 'UTF-8'))); + + $this->headers->set('Location', $url); + $this->headers->set('Content-Type', 'text/html; charset=utf-8'); + + return $this; + } +} diff --git a/core/lib/Http/Response/Response.php b/core/lib/Http/Response/Response.php new file mode 100644 index 0000000..8ea97e7 --- /dev/null +++ b/core/lib/Http/Response/Response.php @@ -0,0 +1,1336 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Response; + +use KTXC\Http\Request\Request; + +// Help opcache.preload discover always-needed symbols +class_exists(ResponseHeaderParameters::class); + +/** + * Response represents an HTTP response. + * + * @author Fabien Potencier + */ +class Response +{ + public const HTTP_CONTINUE = 100; + public const HTTP_SWITCHING_PROTOCOLS = 101; + public const HTTP_PROCESSING = 102; // RFC2518 + public const HTTP_EARLY_HINTS = 103; // RFC8297 + public const HTTP_OK = 200; + public const HTTP_CREATED = 201; + public const HTTP_ACCEPTED = 202; + public const HTTP_NON_AUTHORITATIVE_INFORMATION = 203; + public const HTTP_NO_CONTENT = 204; + public const HTTP_RESET_CONTENT = 205; + public const HTTP_PARTIAL_CONTENT = 206; + public const HTTP_MULTI_STATUS = 207; // RFC4918 + public const HTTP_ALREADY_REPORTED = 208; // RFC5842 + public const HTTP_IM_USED = 226; // RFC3229 + public const HTTP_MULTIPLE_CHOICES = 300; + public const HTTP_MOVED_PERMANENTLY = 301; + public const HTTP_FOUND = 302; + public const HTTP_SEE_OTHER = 303; + public const HTTP_NOT_MODIFIED = 304; + public const HTTP_USE_PROXY = 305; + public const HTTP_RESERVED = 306; + public const HTTP_TEMPORARY_REDIRECT = 307; + public const HTTP_PERMANENTLY_REDIRECT = 308; // RFC7238 + public const HTTP_BAD_REQUEST = 400; + public const HTTP_UNAUTHORIZED = 401; + public const HTTP_PAYMENT_REQUIRED = 402; + public const HTTP_FORBIDDEN = 403; + public const HTTP_NOT_FOUND = 404; + public const HTTP_METHOD_NOT_ALLOWED = 405; + public const HTTP_NOT_ACCEPTABLE = 406; + public const HTTP_PROXY_AUTHENTICATION_REQUIRED = 407; + public const HTTP_REQUEST_TIMEOUT = 408; + public const HTTP_CONFLICT = 409; + public const HTTP_GONE = 410; + public const HTTP_LENGTH_REQUIRED = 411; + public const HTTP_PRECONDITION_FAILED = 412; + public const HTTP_REQUEST_ENTITY_TOO_LARGE = 413; + public const HTTP_REQUEST_URI_TOO_LONG = 414; + public const HTTP_UNSUPPORTED_MEDIA_TYPE = 415; + public const HTTP_REQUESTED_RANGE_NOT_SATISFIABLE = 416; + public const HTTP_EXPECTATION_FAILED = 417; + public const HTTP_I_AM_A_TEAPOT = 418; // RFC2324 + public const HTTP_MISDIRECTED_REQUEST = 421; // RFC7540 + public const HTTP_UNPROCESSABLE_ENTITY = 422; // RFC4918 + public const HTTP_LOCKED = 423; // RFC4918 + public const HTTP_FAILED_DEPENDENCY = 424; // RFC4918 + public const HTTP_TOO_EARLY = 425; // RFC-ietf-httpbis-replay-04 + public const HTTP_UPGRADE_REQUIRED = 426; // RFC2817 + public const HTTP_PRECONDITION_REQUIRED = 428; // RFC6585 + public const HTTP_TOO_MANY_REQUESTS = 429; // RFC6585 + public const HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE = 431; // RFC6585 + public const HTTP_UNAVAILABLE_FOR_LEGAL_REASONS = 451; // RFC7725 + public const HTTP_INTERNAL_SERVER_ERROR = 500; + public const HTTP_NOT_IMPLEMENTED = 501; + public const HTTP_BAD_GATEWAY = 502; + public const HTTP_SERVICE_UNAVAILABLE = 503; + public const HTTP_GATEWAY_TIMEOUT = 504; + public const HTTP_VERSION_NOT_SUPPORTED = 505; + public const HTTP_VARIANT_ALSO_NEGOTIATES_EXPERIMENTAL = 506; // RFC2295 + public const HTTP_INSUFFICIENT_STORAGE = 507; // RFC4918 + public const HTTP_LOOP_DETECTED = 508; // RFC5842 + public const HTTP_NOT_EXTENDED = 510; // RFC2774 + public const HTTP_NETWORK_AUTHENTICATION_REQUIRED = 511; // RFC6585 + + /** + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + */ + private const HTTP_RESPONSE_CACHE_CONTROL_DIRECTIVES = [ + 'must_revalidate' => false, + 'no_cache' => false, + 'no_store' => false, + 'no_transform' => false, + 'public' => false, + 'private' => false, + 'proxy_revalidate' => false, + 'max_age' => true, + 's_maxage' => true, + 'stale_if_error' => true, // RFC5861 + 'stale_while_revalidate' => true, // RFC5861 + 'immutable' => false, + 'last_modified' => true, + 'etag' => true, + ]; + + /** + * Default security headers applied to all responses. + * Can be overridden by passing headers to the constructor or setting them after. + */ + private const DEFAULT_SECURITY_HEADERS = [ + 'X-Frame-Options' => 'DENY', + 'X-Content-Type-Options' => 'nosniff', + 'X-XSS-Protection' => '1; mode=block', + 'Referrer-Policy' => 'strict-origin-when-cross-origin', + 'Permissions-Policy' => 'geolocation=(), microphone=(), camera=()', + ]; + + public ResponseHeaderParameters $headers; + + protected string $content; + protected string $version; + protected int $statusCode; + protected string $statusText; + protected ?string $charset = null; + + /** + * Status codes translation table. + * + * The list of codes is complete according to the + * {@link https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml Hypertext Transfer Protocol (HTTP) Status Code Registry} + * (last updated 2021-10-01). + * + * Unless otherwise noted, the status code is defined in RFC2616. + * + * @var array + */ + public static array $statusTexts = [ + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', // RFC2518 + 103 => 'Early Hints', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-Status', // RFC4918 + 208 => 'Already Reported', // RFC5842 + 226 => 'IM Used', // RFC3229 + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', // RFC7238 + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Content Too Large', // RFC-ietf-httpbis-semantics + 414 => 'URI Too Long', + 415 => 'Unsupported Media Type', + 416 => 'Range Not Satisfiable', + 417 => 'Expectation Failed', + 418 => 'I\'m a teapot', // RFC2324 + 421 => 'Misdirected Request', // RFC7540 + 422 => 'Unprocessable Content', // RFC-ietf-httpbis-semantics + 423 => 'Locked', // RFC4918 + 424 => 'Failed Dependency', // RFC4918 + 425 => 'Too Early', // RFC-ietf-httpbis-replay-04 + 426 => 'Upgrade Required', // RFC2817 + 428 => 'Precondition Required', // RFC6585 + 429 => 'Too Many Requests', // RFC6585 + 431 => 'Request Header Fields Too Large', // RFC6585 + 451 => 'Unavailable For Legal Reasons', // RFC7725 + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported', + 506 => 'Variant Also Negotiates', // RFC2295 + 507 => 'Insufficient Storage', // RFC4918 + 508 => 'Loop Detected', // RFC5842 + 510 => 'Not Extended', // RFC2774 + 511 => 'Network Authentication Required', // RFC6585 + ]; + + /** + * Tracks headers already sent in informational responses. + */ + private array $sentHeaders; + + /** + * @param int $status The HTTP status code (200 "OK" by default) + * + * @throws \InvalidArgumentException When the HTTP status code is not valid + */ + public function __construct(?string $content = '', int $status = 200, array $headers = []) + { + // Merge default security headers with provided headers (provided headers take precedence) + $headers = array_merge(self::DEFAULT_SECURITY_HEADERS, $headers); + + $this->headers = new ResponseHeaderParameters($headers); + $this->setContent($content); + $this->setStatusCode($status); + $this->setProtocolVersion('1.0'); + } + + /** + * Returns the Response as an HTTP string. + * + * The string representation of the Response is the same as the + * one that will be sent to the client only if the prepare() method + * has been called before. + * + * @see prepare() + */ + public function __toString(): string + { + return + \sprintf('HTTP/%s %s %s', $this->version, $this->statusCode, $this->statusText)."\r\n". + $this->headers."\r\n". + $this->getContent(); + } + + /** + * Clones the current Response instance. + */ + public function __clone() + { + $this->headers = clone $this->headers; + } + + /** + * Prepares the Response before it is sent to the client. + * + * This method tweaks the Response to ensure that it is + * compliant with RFC 2616. Most of the changes are based on + * the Request that is "associated" with this Response. + * + * @return $this + */ + public function prepare(Request $request): static + { + $headers = $this->headers; + + if ($this->isInformational() || $this->isEmpty()) { + $this->setContent(null); + $headers->remove('Content-Type'); + $headers->remove('Content-Length'); + // prevent PHP from sending the Content-Type header based on default_mimetype + ini_set('default_mimetype', ''); + } else { + // Content-type based on the Request + if (!$headers->has('Content-Type')) { + $format = $request->getRequestFormat(null); + if (null !== $format && $mimeType = $request->getMimeType($format)) { + $headers->set('Content-Type', $mimeType); + } + } + + // Fix Content-Type + $charset = $this->charset ?: 'UTF-8'; + if (!$headers->has('Content-Type')) { + $headers->set('Content-Type', 'text/html; charset='.$charset); + } elseif (0 === stripos($headers->get('Content-Type') ?? '', 'text/') && false === stripos($headers->get('Content-Type') ?? '', 'charset')) { + // add the charset + $headers->set('Content-Type', $headers->get('Content-Type').'; charset='.$charset); + } + + // Fix Content-Length + if ($headers->has('Transfer-Encoding')) { + $headers->remove('Content-Length'); + } + + if ($request->isMethod('HEAD')) { + // cf. RFC2616 14.13 + $length = $headers->get('Content-Length'); + $this->setContent(null); + if ($length) { + $headers->set('Content-Length', $length); + } + } + } + + // Fix protocol + if ('HTTP/1.0' != $request->server->get('SERVER_PROTOCOL')) { + $this->setProtocolVersion('1.1'); + } + + // Check if we need to send extra expire info headers + if ('1.0' == $this->getProtocolVersion() && str_contains($headers->get('Cache-Control', ''), 'no-cache')) { + $headers->set('pragma', 'no-cache'); + $headers->set('expires', (string)-1); + } + + $this->ensureIEOverSSLCompatibility($request); + + if ($request->isSecure()) { + foreach ($headers->getCookies() as $cookie) { + $cookie->setSecureDefault(true); + } + } + + return $this; + } + + /** + * Sends HTTP headers. + * + * @param positive-int|null $statusCode The status code to use, override the statusCode property if set and not null + * + * @return $this + */ + public function sendHeaders(?int $statusCode = null): static + { + // headers have already been sent by the developer + if (headers_sent()) { + return $this; + } + + $informationalResponse = $statusCode >= 100 && $statusCode < 200; + if ($informationalResponse && !\function_exists('headers_send')) { + // skip informational responses if not supported by the SAPI + return $this; + } + + // headers + foreach ($this->headers->allPreserveCaseWithoutCookies() as $name => $values) { + // As recommended by RFC 8297, PHP automatically copies headers from previous 103 responses, we need to deal with that if headers changed + $previousValues = $this->sentHeaders[$name] ?? null; + if ($previousValues === $values) { + // Header already sent in a previous response, it will be automatically copied in this response by PHP + continue; + } + + $replace = 0 === strcasecmp($name, 'Content-Type'); + + if (null !== $previousValues && array_diff($previousValues, $values)) { + header_remove($name); + $previousValues = null; + } + + $newValues = null === $previousValues ? $values : array_diff($values, $previousValues); + + foreach ($newValues as $value) { + header($name.': '.$value, $replace, $this->statusCode); + } + + if ($informationalResponse) { + $this->sentHeaders[$name] = $values; + } + } + + // cookies + foreach ($this->headers->getCookies() as $cookie) { + header('Set-Cookie: '.$cookie, false, $this->statusCode); + } + + if ($informationalResponse) { + headers_send($statusCode); + + return $this; + } + + $statusCode ??= $this->statusCode; + + // status + header(\sprintf('HTTP/%s %s %s', $this->version, $statusCode, $this->statusText), true, $statusCode); + + return $this; + } + + /** + * Sends content for the current web response. + * + * @return $this + */ + public function sendContent(): static + { + echo $this->content; + + return $this; + } + + /** + * Sends HTTP headers and content. + * + * @param bool $flush Whether output buffers should be flushed + * + * @return $this + */ + public function send(bool $flush = true): static + { + $this->sendHeaders(); + $this->sendContent(); + + if (!$flush) { + return $this; + } + + if (\function_exists('fastcgi_finish_request')) { + fastcgi_finish_request(); + } elseif (\function_exists('litespeed_finish_request')) { + litespeed_finish_request(); + } elseif (!\in_array(\PHP_SAPI, ['cli', 'phpdbg', 'embed'], true)) { + static::closeOutputBuffers(0, true); + flush(); + } + + return $this; + } + + /** + * Sets the response content. + * + * @return $this + */ + public function setContent(?string $content): static + { + $this->content = $content ?? ''; + + return $this; + } + + /** + * Gets the current response content. + */ + public function getContent(): string|false + { + return $this->content; + } + + /** + * Sets the HTTP protocol version (1.0 or 1.1). + * + * @return $this + * + * @final + */ + public function setProtocolVersion(string $version): static + { + $this->version = $version; + + return $this; + } + + /** + * Gets the HTTP protocol version. + * + * @final + */ + public function getProtocolVersion(): string + { + return $this->version; + } + + /** + * Sets the response status code. + * + * If the status text is null it will be automatically populated for the known + * status codes and left empty otherwise. + * + * @return $this + * + * @throws \InvalidArgumentException When the HTTP status code is not valid + * + * @final + */ + public function setStatusCode(int $code, ?string $text = null): static + { + $this->statusCode = $code; + if ($this->isInvalid()) { + throw new \InvalidArgumentException(\sprintf('The HTTP status code "%s" is not valid.', $code)); + } + + if (null === $text) { + $this->statusText = self::$statusTexts[$code] ?? 'unknown status'; + + return $this; + } + + $this->statusText = $text; + + return $this; + } + + /** + * Retrieves the status code for the current web response. + * + * @final + */ + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * Sets the response charset. + * + * @return $this + * + * @final + */ + public function setCharset(string $charset): static + { + $this->charset = $charset; + + return $this; + } + + /** + * Retrieves the response charset. + * + * @final + */ + public function getCharset(): ?string + { + return $this->charset; + } + + /** + * Returns true if the response may safely be kept in a shared (surrogate) cache. + * + * Responses marked "private" with an explicit Cache-Control directive are + * considered uncacheable. + * + * Responses with neither a freshness lifetime (Expires, max-age) nor cache + * validator (Last-Modified, ETag) are considered uncacheable because there is + * no way to tell when or how to remove them from the cache. + * + * Note that RFC 7231 and RFC 7234 possibly allow for a more permissive implementation, + * for example "status codes that are defined as cacheable by default [...] + * can be reused by a cache with heuristic expiration unless otherwise indicated" + * (https://tools.ietf.org/html/rfc7231#section-6.1) + * + * @final + */ + public function isCacheable(): bool + { + if (!\in_array($this->statusCode, [200, 203, 300, 301, 302, 404, 410])) { + return false; + } + + if ($this->headers->hasCacheControlDirective('no-store') || $this->headers->getCacheControlDirective('private')) { + return false; + } + + return $this->isValidateable() || $this->isFresh(); + } + + /** + * Returns true if the response is "fresh". + * + * Fresh responses may be served from cache without any interaction with the + * origin. A response is considered fresh when it includes a Cache-Control/max-age + * indicator or Expires header and the calculated age is less than the freshness lifetime. + * + * @final + */ + public function isFresh(): bool + { + return $this->getTtl() > 0; + } + + /** + * Returns true if the response includes headers that can be used to validate + * the response with the origin server using a conditional GET request. + * + * @final + */ + public function isValidateable(): bool + { + return $this->headers->has('Last-Modified') || $this->headers->has('ETag'); + } + + /** + * Marks the response as "private". + * + * It makes the response ineligible for serving other clients. + * + * @return $this + * + * @final + */ + public function setPrivate(): static + { + $this->headers->removeCacheControlDirective('public'); + $this->headers->addCacheControlDirective('private'); + + return $this; + } + + /** + * Marks the response as "public". + * + * It makes the response eligible for serving other clients. + * + * @return $this + * + * @final + */ + public function setPublic(): static + { + $this->headers->addCacheControlDirective('public'); + $this->headers->removeCacheControlDirective('private'); + + return $this; + } + + /** + * Marks the response as "immutable". + * + * @return $this + * + * @final + */ + public function setImmutable(bool $immutable = true): static + { + if ($immutable) { + $this->headers->addCacheControlDirective('immutable'); + } else { + $this->headers->removeCacheControlDirective('immutable'); + } + + return $this; + } + + /** + * Returns true if the response is marked as "immutable". + * + * @final + */ + public function isImmutable(): bool + { + return $this->headers->hasCacheControlDirective('immutable'); + } + + /** + * Returns true if the response must be revalidated by shared caches once it has become stale. + * + * This method indicates that the response must not be served stale by a + * cache in any circumstance without first revalidating with the origin. + * When present, the TTL of the response should not be overridden to be + * greater than the value provided by the origin. + * + * @final + */ + public function mustRevalidate(): bool + { + return $this->headers->hasCacheControlDirective('must-revalidate') || $this->headers->hasCacheControlDirective('proxy-revalidate'); + } + + /** + * Returns the Date header as a DateTime instance. + * + * @throws \RuntimeException When the header is not parseable + * + * @final + */ + public function getDate(): ?\DateTimeImmutable + { + return $this->headers->getDate('Date'); + } + + /** + * Sets the Date header. + * + * @return $this + * + * @final + */ + public function setDate(\DateTimeInterface $date): static + { + $date = \DateTimeImmutable::createFromInterface($date); + $date = $date->setTimezone(new \DateTimeZone('UTC')); + $this->headers->set('Date', $date->format('D, d M Y H:i:s').' GMT'); + + return $this; + } + + /** + * Returns the age of the response in seconds. + * + * @final + */ + public function getAge(): int + { + if (null !== $age = $this->headers->get('Age')) { + return (int) $age; + } + + return max(time() - (int) $this->getDate()->format('U'), 0); + } + + /** + * Marks the response stale by setting the Age header to be equal to the maximum age of the response. + * + * @return $this + */ + public function expire(): static + { + if ($this->isFresh()) { + $this->headers->set('Age', $this->getMaxAge()); + $this->headers->remove('Expires'); + } + + return $this; + } + + /** + * Returns the value of the Expires header as a DateTime instance. + * + * @final + */ + public function getExpires(): ?\DateTimeImmutable + { + try { + return $this->headers->getDate('Expires'); + } catch (\RuntimeException) { + // according to RFC 2616 invalid date formats (e.g. "0" and "-1") must be treated as in the past + return \DateTimeImmutable::createFromFormat('U', (string)(time() - 172800)); + } + } + + /** + * Sets the Expires HTTP header with a DateTime instance. + * + * Passing null as value will remove the header. + * + * @return $this + * + * @final + */ + public function setExpires(?\DateTimeInterface $date): static + { + if (null === $date) { + $this->headers->remove('Expires'); + + return $this; + } + + $date = \DateTimeImmutable::createFromInterface($date); + $date = $date->setTimezone(new \DateTimeZone('UTC')); + $this->headers->set('Expires', $date->format('D, d M Y H:i:s').' GMT'); + + return $this; + } + + /** + * Returns the number of seconds after the time specified in the response's Date + * header when the response should no longer be considered fresh. + * + * First, it checks for a s-maxage directive, then a max-age directive, and then it falls + * back on an expires header. It returns null when no maximum age can be established. + * + * @final + */ + public function getMaxAge(): ?int + { + if ($this->headers->hasCacheControlDirective('s-maxage')) { + return (int) $this->headers->getCacheControlDirective('s-maxage'); + } + + if ($this->headers->hasCacheControlDirective('max-age')) { + return (int) $this->headers->getCacheControlDirective('max-age'); + } + + if (null !== $expires = $this->getExpires()) { + $maxAge = (int) $expires->format('U') - (int) $this->getDate()->format('U'); + + return max($maxAge, 0); + } + + return null; + } + + /** + * Sets the number of seconds after which the response should no longer be considered fresh. + * + * This method sets the Cache-Control max-age directive. + * + * @return $this + * + * @final + */ + public function setMaxAge(int $value): static + { + $this->headers->addCacheControlDirective('max-age', (string)$value); + + return $this; + } + + /** + * Sets the number of seconds after which the response should no longer be returned by shared caches when backend is down. + * + * This method sets the Cache-Control stale-if-error directive. + * + * @return $this + * + * @final + */ + public function setStaleIfError(int $value): static + { + $this->headers->addCacheControlDirective('stale-if-error', (string)$value); + + return $this; + } + + /** + * Sets the number of seconds after which the response should no longer return stale content by shared caches. + * + * This method sets the Cache-Control stale-while-revalidate directive. + * + * @return $this + * + * @final + */ + public function setStaleWhileRevalidate(int $value): static + { + $this->headers->addCacheControlDirective('stale-while-revalidate', (string)$value); + + return $this; + } + + /** + * Sets the number of seconds after which the response should no longer be considered fresh by shared caches. + * + * This method sets the Cache-Control s-maxage directive. + * + * @return $this + * + * @final + */ + public function setSharedMaxAge(int $value): static + { + $this->setPublic(); + $this->headers->addCacheControlDirective('s-maxage', (string)$value); + + return $this; + } + + /** + * Returns the response's time-to-live in seconds. + * + * It returns null when no freshness information is present in the response. + * + * When the response's TTL is 0, the response may not be served from cache without first + * revalidating with the origin. + * + * @final + */ + public function getTtl(): ?int + { + $maxAge = $this->getMaxAge(); + + return null !== $maxAge ? max($maxAge - $this->getAge(), 0) : null; + } + + /** + * Sets the response's time-to-live for shared caches in seconds. + * + * This method adjusts the Cache-Control/s-maxage directive. + * + * @return $this + * + * @final + */ + public function setTtl(int $seconds): static + { + $this->setSharedMaxAge($this->getAge() + $seconds); + + return $this; + } + + /** + * Sets the response's time-to-live for private/client caches in seconds. + * + * This method adjusts the Cache-Control/max-age directive. + * + * @return $this + * + * @final + */ + public function setClientTtl(int $seconds): static + { + $this->setMaxAge($this->getAge() + $seconds); + + return $this; + } + + /** + * Returns the Last-Modified HTTP header as a DateTime instance. + * + * @throws \RuntimeException When the HTTP header is not parseable + * + * @final + */ + public function getLastModified(): ?\DateTimeImmutable + { + return $this->headers->getDate('Last-Modified'); + } + + /** + * Sets the Last-Modified HTTP header with a DateTime instance. + * + * Passing null as value will remove the header. + * + * @return $this + * + * @final + */ + public function setLastModified(?\DateTimeInterface $date): static + { + if (null === $date) { + $this->headers->remove('Last-Modified'); + + return $this; + } + + $date = \DateTimeImmutable::createFromInterface($date); + $date = $date->setTimezone(new \DateTimeZone('UTC')); + $this->headers->set('Last-Modified', $date->format('D, d M Y H:i:s').' GMT'); + + return $this; + } + + /** + * Returns the literal value of the ETag HTTP header. + * + * @final + */ + public function getEtag(): ?string + { + return $this->headers->get('ETag'); + } + + /** + * Sets the ETag value. + * + * @param string|null $etag The ETag unique identifier or null to remove the header + * @param bool $weak Whether you want a weak ETag or not + * + * @return $this + * + * @final + */ + public function setEtag(?string $etag, bool $weak = false): static + { + if (null === $etag) { + $this->headers->remove('Etag'); + } else { + if (!str_starts_with($etag, '"')) { + $etag = '"'.$etag.'"'; + } + + $this->headers->set('ETag', (true === $weak ? 'W/' : '').$etag); + } + + return $this; + } + + /** + * Sets the response's cache headers (validation and/or expiration). + * + * Available options are: must_revalidate, no_cache, no_store, no_transform, public, private, proxy_revalidate, max_age, s_maxage, immutable, last_modified and etag. + * + * @return $this + * + * @throws \InvalidArgumentException + * + * @final + */ + public function setCache(array $options): static + { + if ($diff = array_diff(array_keys($options), array_keys(self::HTTP_RESPONSE_CACHE_CONTROL_DIRECTIVES))) { + throw new \InvalidArgumentException(\sprintf('Response does not support the following options: "%s".', implode('", "', $diff))); + } + + if (isset($options['etag'])) { + $this->setEtag($options['etag']); + } + + if (isset($options['last_modified'])) { + $this->setLastModified($options['last_modified']); + } + + if (isset($options['max_age'])) { + $this->setMaxAge($options['max_age']); + } + + if (isset($options['s_maxage'])) { + $this->setSharedMaxAge($options['s_maxage']); + } + + if (isset($options['stale_while_revalidate'])) { + $this->setStaleWhileRevalidate($options['stale_while_revalidate']); + } + + if (isset($options['stale_if_error'])) { + $this->setStaleIfError($options['stale_if_error']); + } + + foreach (self::HTTP_RESPONSE_CACHE_CONTROL_DIRECTIVES as $directive => $hasValue) { + if (!$hasValue && isset($options[$directive])) { + if ($options[$directive]) { + $this->headers->addCacheControlDirective(str_replace('_', '-', $directive)); + } else { + $this->headers->removeCacheControlDirective(str_replace('_', '-', $directive)); + } + } + } + + if (isset($options['public'])) { + if ($options['public']) { + $this->setPublic(); + } else { + $this->setPrivate(); + } + } + + if (isset($options['private'])) { + if ($options['private']) { + $this->setPrivate(); + } else { + $this->setPublic(); + } + } + + return $this; + } + + /** + * Modifies the response so that it conforms to the rules defined for a 304 status code. + * + * This sets the status, removes the body, and discards any headers + * that MUST NOT be included in 304 responses. + * + * @return $this + * + * @see https://tools.ietf.org/html/rfc2616#section-10.3.5 + * + * @final + */ + public function setNotModified(): static + { + $this->setStatusCode(304); + $this->setContent(null); + + // remove headers that MUST NOT be included with 304 Not Modified responses + foreach (['Allow', 'Content-Encoding', 'Content-Language', 'Content-Length', 'Content-MD5', 'Content-Type', 'Last-Modified'] as $header) { + $this->headers->remove($header); + } + + return $this; + } + + /** + * Returns true if the response includes a Vary header. + * + * @final + */ + public function hasVary(): bool + { + return null !== $this->headers->get('Vary'); + } + + /** + * Returns an array of header names given in the Vary header. + * + * @final + */ + public function getVary(): array + { + if (!$vary = $this->headers->all('Vary')) { + return []; + } + + $ret = []; + foreach ($vary as $item) { + $ret[] = preg_split('/[\s,]+/', $item); + } + + return array_merge([], ...$ret); + } + + /** + * Sets the Vary header. + * + * @param bool $replace Whether to replace the actual value or not (true by default) + * + * @return $this + * + * @final + */ + public function setVary(string|array $headers, bool $replace = true): static + { + $this->headers->set('Vary', $headers, $replace); + + return $this; + } + + /** + * Determines if the Response validators (ETag, Last-Modified) match + * a conditional value specified in the Request. + * + * If the Response is not modified, it sets the status code to 304 and + * removes the actual content by calling the setNotModified() method. + * + * @final + */ + public function isNotModified(Request $request): bool + { + if (!$request->isMethodCacheable()) { + return false; + } + + $notModified = false; + $lastModified = $this->headers->get('Last-Modified'); + $modifiedSince = $request->headers->get('If-Modified-Since'); + + if (($ifNoneMatchEtags = $request->getETags()) && (null !== $etag = $this->getEtag())) { + if (0 == strncmp($etag, 'W/', 2)) { + $etag = substr($etag, 2); + } + + // Use weak comparison as per https://tools.ietf.org/html/rfc7232#section-3.2. + foreach ($ifNoneMatchEtags as $ifNoneMatchEtag) { + if (0 == strncmp($ifNoneMatchEtag, 'W/', 2)) { + $ifNoneMatchEtag = substr($ifNoneMatchEtag, 2); + } + + if ($ifNoneMatchEtag === $etag || '*' === $ifNoneMatchEtag) { + $notModified = true; + break; + } + } + } + // Only do If-Modified-Since date comparison when If-None-Match is not present as per https://tools.ietf.org/html/rfc7232#section-3.3. + elseif ($modifiedSince && $lastModified) { + $notModified = strtotime($modifiedSince) >= strtotime($lastModified); + } + + if ($notModified) { + $this->setNotModified(); + } + + return $notModified; + } + + /** + * Is response invalid? + * + * @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html + * + * @final + */ + public function isInvalid(): bool + { + return $this->statusCode < 100 || $this->statusCode >= 600; + } + + /** + * Is response informative? + * + * @final + */ + public function isInformational(): bool + { + return $this->statusCode >= 100 && $this->statusCode < 200; + } + + /** + * Is response successful? + * + * @final + */ + public function isSuccessful(): bool + { + return $this->statusCode >= 200 && $this->statusCode < 300; + } + + /** + * Is the response a redirect? + * + * @final + */ + public function isRedirection(): bool + { + return $this->statusCode >= 300 && $this->statusCode < 400; + } + + /** + * Is there a client error? + * + * @final + */ + public function isClientError(): bool + { + return $this->statusCode >= 400 && $this->statusCode < 500; + } + + /** + * Was there a server side error? + * + * @final + */ + public function isServerError(): bool + { + return $this->statusCode >= 500 && $this->statusCode < 600; + } + + /** + * Is the response OK? + * + * @final + */ + public function isOk(): bool + { + return 200 === $this->statusCode; + } + + /** + * Is the response forbidden? + * + * @final + */ + public function isForbidden(): bool + { + return 403 === $this->statusCode; + } + + /** + * Is the response a not found error? + * + * @final + */ + public function isNotFound(): bool + { + return 404 === $this->statusCode; + } + + /** + * Is the response a redirect of some form? + * + * @final + */ + public function isRedirect(?string $location = null): bool + { + return \in_array($this->statusCode, [201, 301, 302, 303, 307, 308]) && (null === $location ?: $location == $this->headers->get('Location')); + } + + /** + * Is the response empty? + * + * @final + */ + public function isEmpty(): bool + { + return \in_array($this->statusCode, [204, 304]); + } + + /** + * Cleans or flushes output buffers up to target level. + * + * Resulting level can be greater than target level if a non-removable buffer has been encountered. + * + * @final + */ + public static function closeOutputBuffers(int $targetLevel, bool $flush): void + { + $status = ob_get_status(true); + $level = \count($status); + $flags = \PHP_OUTPUT_HANDLER_REMOVABLE | ($flush ? \PHP_OUTPUT_HANDLER_FLUSHABLE : \PHP_OUTPUT_HANDLER_CLEANABLE); + + while ($level-- > $targetLevel && ($s = $status[$level]) && (!isset($s['del']) ? !isset($s['flags']) || ($s['flags'] & $flags) === $flags : $s['del'])) { + if ($flush) { + ob_end_flush(); + } else { + ob_end_clean(); + } + } + } + + /** + * Marks a response as safe according to RFC8674. + * + * @see https://tools.ietf.org/html/rfc8674 + */ + public function setContentSafe(bool $safe = true): void + { + if ($safe) { + $this->headers->set('Preference-Applied', 'safe'); + } elseif ('safe' === $this->headers->get('Preference-Applied')) { + $this->headers->remove('Preference-Applied'); + } + + $this->setVary('Prefer', false); + } + + /** + * Checks if we need to remove Cache-Control for SSL encrypted downloads when using IE < 9. + * + * @see http://support.microsoft.com/kb/323308 + * + * @final + */ + protected function ensureIEOverSSLCompatibility(Request $request): void + { + if (false !== stripos($this->headers->get('Content-Disposition') ?? '', 'attachment') && 1 == preg_match('/MSIE (.*?);/i', $request->server->get('HTTP_USER_AGENT') ?? '', $match) && true === $request->isSecure()) { + if ((int) preg_replace('/(MSIE )(.*?);/', '$2', $match[0]) < 9) { + $this->headers->remove('Cache-Control'); + } + } + } +} diff --git a/core/lib/Http/Response/ResponseHeaderParameters.php b/core/lib/Http/Response/ResponseHeaderParameters.php new file mode 100644 index 0000000..155b3ec --- /dev/null +++ b/core/lib/Http/Response/ResponseHeaderParameters.php @@ -0,0 +1,275 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Response; + +use KTXC\Http\Cookie; +use KTXC\Http\HeaderParameters; +use KTXC\Http\HeaderUtils; + +/** + * ResponseHeaderBag is a container for Response HTTP headers. + * + * @author Fabien Potencier + */ +class ResponseHeaderParameters extends HeaderParameters +{ + public const COOKIES_FLAT = 'flat'; + public const COOKIES_ARRAY = 'array'; + + public const DISPOSITION_ATTACHMENT = 'attachment'; + public const DISPOSITION_INLINE = 'inline'; + + protected array $computedCacheControl = []; + protected array $cookies = []; + protected array $headerNames = []; + + public function __construct(array $headers = []) + { + parent::__construct($headers); + + if (!isset($this->headers['cache-control'])) { + $this->set('Cache-Control', ''); + } + + /* RFC2616 - 14.18 says all Responses need to have a Date */ + if (!isset($this->headers['date'])) { + $this->initDate(); + } + } + + /** + * Returns the headers, with original capitalizations. + */ + public function allPreserveCase(): array + { + $headers = []; + foreach ($this->all() as $name => $value) { + $headers[$this->headerNames[$name] ?? $name] = $value; + } + + return $headers; + } + + public function allPreserveCaseWithoutCookies(): array + { + $headers = $this->allPreserveCase(); + if (isset($this->headerNames['set-cookie'])) { + unset($headers[$this->headerNames['set-cookie']]); + } + + return $headers; + } + + public function replace(array $headers = []): void + { + $this->headerNames = []; + + parent::replace($headers); + + if (!isset($this->headers['cache-control'])) { + $this->set('Cache-Control', ''); + } + + if (!isset($this->headers['date'])) { + $this->initDate(); + } + } + + public function all(?string $key = null): array + { + $headers = parent::all(); + + if (null !== $key) { + $key = strtr($key, self::UPPER, self::LOWER); + + return 'set-cookie' !== $key ? $headers[$key] ?? [] : array_map('strval', $this->getCookies()); + } + + foreach ($this->getCookies() as $cookie) { + $headers['set-cookie'][] = (string) $cookie; + } + + return $headers; + } + + public function set(string $key, string|array|null $values, bool $replace = true): void + { + $uniqueKey = strtr($key, self::UPPER, self::LOWER); + + if ('set-cookie' === $uniqueKey) { + if ($replace) { + $this->cookies = []; + } + foreach ((array) $values as $cookie) { + $this->setCookie(Cookie::fromString($cookie)); + } + $this->headerNames[$uniqueKey] = $key; + + return; + } + + $this->headerNames[$uniqueKey] = $key; + + parent::set($key, $values, $replace); + + // ensure the cache-control header has sensible defaults + if (\in_array($uniqueKey, ['cache-control', 'etag', 'last-modified', 'expires'], true) && '' !== $computed = $this->computeCacheControlValue()) { + $this->headers['cache-control'] = [$computed]; + $this->headerNames['cache-control'] = 'Cache-Control'; + $this->computedCacheControl = $this->parseCacheControl($computed); + } + } + + public function remove(string $key): void + { + $uniqueKey = strtr($key, self::UPPER, self::LOWER); + unset($this->headerNames[$uniqueKey]); + + if ('set-cookie' === $uniqueKey) { + $this->cookies = []; + + return; + } + + parent::remove($key); + + if ('cache-control' === $uniqueKey) { + $this->computedCacheControl = []; + } + + if ('date' === $uniqueKey) { + $this->initDate(); + } + } + + public function hasCacheControlDirective(string $key): bool + { + return \array_key_exists($key, $this->computedCacheControl); + } + + public function getCacheControlDirective(string $key): bool|string|null + { + return $this->computedCacheControl[$key] ?? null; + } + + public function setCookie(Cookie $cookie): void + { + $this->cookies[$cookie->getDomain()][$cookie->getPath()][$cookie->getName()] = $cookie; + $this->headerNames['set-cookie'] = 'Set-Cookie'; + } + + /** + * Removes a cookie from the array, but does not unset it in the browser. + */ + public function removeCookie(string $name, ?string $path = '/', ?string $domain = null): void + { + $path ??= '/'; + + unset($this->cookies[$domain][$path][$name]); + + if (empty($this->cookies[$domain][$path])) { + unset($this->cookies[$domain][$path]); + + if (empty($this->cookies[$domain])) { + unset($this->cookies[$domain]); + } + } + + if (!$this->cookies) { + unset($this->headerNames['set-cookie']); + } + } + + /** + * Returns an array with all cookies. + * + * @return Cookie[] + * + * @throws \InvalidArgumentException When the $format is invalid + */ + public function getCookies(string $format = self::COOKIES_FLAT): array + { + if (!\in_array($format, [self::COOKIES_FLAT, self::COOKIES_ARRAY])) { + throw new \InvalidArgumentException(\sprintf('Format "%s" invalid (%s).', $format, implode(', ', [self::COOKIES_FLAT, self::COOKIES_ARRAY]))); + } + + if (self::COOKIES_ARRAY === $format) { + return $this->cookies; + } + + $flattenedCookies = []; + foreach ($this->cookies as $path) { + foreach ($path as $cookies) { + foreach ($cookies as $cookie) { + $flattenedCookies[] = $cookie; + } + } + } + + return $flattenedCookies; + } + + /** + * Clears a cookie in the browser. + * + * @param bool $partitioned + */ + public function clearCookie(string $name, ?string $path = '/', ?string $domain = null, bool $secure = false, bool $httpOnly = true, ?string $sameSite = null /* , bool $partitioned = false */): void + { + $partitioned = 6 < \func_num_args() ? func_get_arg(6) : false; + + $this->setCookie(new Cookie($name, null, 1, $path, $domain, $secure, $httpOnly, false, $sameSite, $partitioned)); + } + + /** + * @see HeaderUtils::makeDisposition() + */ + public function makeDisposition(string $disposition, string $filename, string $filenameFallback = ''): string + { + return HeaderUtils::makeDisposition($disposition, $filename, $filenameFallback); + } + + /** + * Returns the calculated value of the cache-control header. + * + * This considers several other headers and calculates or modifies the + * cache-control header to a sensible, conservative value. + */ + protected function computeCacheControlValue(): string + { + if (!$this->cacheControl) { + if ($this->has('Last-Modified') || $this->has('Expires')) { + return 'private, must-revalidate'; // allows for heuristic expiration (RFC 7234 Section 4.2.2) in the case of "Last-Modified" + } + + // conservative by default + return 'no-cache, private'; + } + + $header = $this->getCacheControlHeader(); + if (isset($this->cacheControl['public']) || isset($this->cacheControl['private'])) { + return $header; + } + + // public if s-maxage is defined, private otherwise + if (!isset($this->cacheControl['s-maxage'])) { + return $header.', private'; + } + + return $header; + } + + private function initDate(): void + { + $this->set('Date', gmdate('D, d M Y H:i:s').' GMT'); + } +} diff --git a/core/lib/Http/Response/StreamedJsonResponse.php b/core/lib/Http/Response/StreamedJsonResponse.php new file mode 100644 index 0000000..225ffbf --- /dev/null +++ b/core/lib/Http/Response/StreamedJsonResponse.php @@ -0,0 +1,164 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Response; + +/** + * StreamedJsonResponse represents a streamed HTTP response for JSON. + * + * A StreamedJsonResponse uses a structure and generics to create an + * efficient resource-saving JSON response. + * + * It is recommended to use flush() function after a specific number of items to directly stream the data. + * + * @see flush() + * + * @author Alexander Schranz + * + * Example usage: + * + * function loadArticles(): \Generator + * // some streamed loading + * yield ['title' => 'Article 1']; + * yield ['title' => 'Article 2']; + * yield ['title' => 'Article 3']; + * // recommended to use flush() after every specific number of items + * }), + * + * $response = new StreamedJsonResponse( + * // json structure with generators in which will be streamed + * [ + * '_embedded' => [ + * 'articles' => loadArticles(), // any generator which you want to stream as list of data + * ], + * ], + * ); + */ +class StreamedJsonResponse extends StreamedResponse +{ + private const PLACEHOLDER = '__symfony_json__'; + + /** + * @param mixed[] $data JSON Data containing PHP generators which will be streamed as list of data or a Generator + * @param int $status The HTTP status code (200 "OK" by default) + * @param array $headers An array of HTTP headers + * @param int $encodingOptions Flags for the json_encode() function + */ + public function __construct( + private readonly iterable $data, + int $status = 200, + array $headers = [], + private int $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS, + ) { + parent::__construct($this->stream(...), $status, $headers); + + if (!$this->headers->get('Content-Type')) { + $this->headers->set('Content-Type', 'application/json'); + } + } + + private function stream(): void + { + $jsonEncodingOptions = \JSON_THROW_ON_ERROR | $this->encodingOptions; + $keyEncodingOptions = $jsonEncodingOptions & ~\JSON_NUMERIC_CHECK; + + $this->streamData($this->data, $jsonEncodingOptions, $keyEncodingOptions); + } + + private function streamData(mixed $data, int $jsonEncodingOptions, int $keyEncodingOptions): void + { + if (\is_array($data)) { + $this->streamArray($data, $jsonEncodingOptions, $keyEncodingOptions); + + return; + } + + if (is_iterable($data) && !$data instanceof \JsonSerializable) { + $this->streamIterable($data, $jsonEncodingOptions, $keyEncodingOptions); + + return; + } + + echo json_encode($data, $jsonEncodingOptions); + } + + private function streamArray(array $data, int $jsonEncodingOptions, int $keyEncodingOptions): void + { + $generators = []; + + array_walk_recursive($data, function (&$item, $key) use (&$generators) { + if (self::PLACEHOLDER === $key) { + // if the placeholder is already in the structure it should be replaced with a new one that explode + // works like expected for the structure + $generators[] = $key; + } + + // generators should be used but for better DX all kind of Traversable and objects are supported + if (\is_object($item)) { + $generators[] = $item; + $item = self::PLACEHOLDER; + } elseif (self::PLACEHOLDER === $item) { + // if the placeholder is already in the structure it should be replaced with a new one that explode + // works like expected for the structure + $generators[] = $item; + } + }); + + $jsonParts = explode('"'.self::PLACEHOLDER.'"', json_encode($data, $jsonEncodingOptions)); + + foreach ($generators as $index => $generator) { + // send first and between parts of the structure + echo $jsonParts[$index]; + + $this->streamData($generator, $jsonEncodingOptions, $keyEncodingOptions); + } + + // send last part of the structure + echo $jsonParts[array_key_last($jsonParts)]; + } + + private function streamIterable(iterable $iterable, int $jsonEncodingOptions, int $keyEncodingOptions): void + { + $isFirstItem = true; + $startTag = '['; + + foreach ($iterable as $key => $item) { + if ($isFirstItem) { + $isFirstItem = false; + // depending on the first elements key the generator is detected as a list or map + // we can not check for a whole list or map because that would hurt the performance + // of the streamed response which is the main goal of this response class + if (0 !== $key) { + $startTag = '{'; + } + + echo $startTag; + } else { + // if not first element of the generic, a separator is required between the elements + echo ','; + } + + if ('{' === $startTag) { + echo json_encode((string) $key, $keyEncodingOptions).':'; + } + + $this->streamData($item, $jsonEncodingOptions, $keyEncodingOptions); + } + + if ($isFirstItem) { // indicates that the generator was empty + echo '['; + } + + echo '[' === $startTag ? ']' : '}'; + } +} diff --git a/core/lib/Http/Response/StreamedResponse.php b/core/lib/Http/Response/StreamedResponse.php new file mode 100644 index 0000000..15f926e --- /dev/null +++ b/core/lib/Http/Response/StreamedResponse.php @@ -0,0 +1,152 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Response; + +/** + * StreamedResponse represents a streamed HTTP response. + * + * A StreamedResponse uses a callback or an iterable of strings for its content. + * + * The callback should use the standard PHP functions like echo + * to stream the response back to the client. The flush() function + * can also be used if needed. + * + * @see flush() + * + * @author Fabien Potencier + */ +class StreamedResponse extends Response +{ + protected ?\Closure $callback = null; + protected bool $streamed = false; + + private bool $headersSent = false; + + /** + * @param callable|iterable|null $callbackOrChunks + * @param int $status The HTTP status code (200 "OK" by default) + */ + public function __construct(callable|iterable|null $callbackOrChunks = null, int $status = 200, array $headers = []) + { + parent::__construct(null, $status, $headers); + + if (\is_callable($callbackOrChunks)) { + $this->setCallback($callbackOrChunks); + } elseif ($callbackOrChunks) { + $this->setChunks($callbackOrChunks); + } + $this->streamed = false; + $this->headersSent = false; + } + + /** + * @param iterable $chunks + */ + public function setChunks(iterable $chunks): static + { + $this->callback = static function () use ($chunks): void { + foreach ($chunks as $chunk) { + echo $chunk; + @ob_flush(); + flush(); + } + }; + + return $this; + } + + /** + * Sets the PHP callback associated with this Response. + * + * @return $this + */ + public function setCallback(callable $callback): static + { + $this->callback = $callback(...); + + return $this; + } + + public function getCallback(): ?\Closure + { + if (!isset($this->callback)) { + return null; + } + + return ($this->callback)(...); + } + + /** + * This method only sends the headers once. + * + * @param positive-int|null $statusCode The status code to use, override the statusCode property if set and not null + * + * @return $this + */ + public function sendHeaders(?int $statusCode = null): static + { + if ($this->headersSent) { + return $this; + } + + if ($statusCode < 100 || $statusCode >= 200) { + $this->headersSent = true; + } + + return parent::sendHeaders($statusCode); + } + + /** + * This method only sends the content once. + * + * @return $this + */ + public function sendContent(): static + { + if ($this->streamed) { + return $this; + } + + $this->streamed = true; + + if (!isset($this->callback)) { + throw new \LogicException('The Response callback must be set.'); + } + + ($this->callback)(); + + return $this; + } + + /** + * @return $this + * + * @throws \LogicException when the content is not null + */ + public function setContent(?string $content): static + { + if (null !== $content) { + throw new \LogicException('The content cannot be set on a StreamedResponse instance.'); + } + + $this->streamed = true; + + return $this; + } + + public function getContent(): string|false + { + return false; + } +} diff --git a/core/lib/Http/Session/SessionInterface.php b/core/lib/Http/Session/SessionInterface.php new file mode 100644 index 0000000..b99c033 --- /dev/null +++ b/core/lib/Http/Session/SessionInterface.php @@ -0,0 +1,148 @@ + Attributes + */ + public function all(): array; + + /** + * Sets attributes. + * + * @param array $attributes Attributes + */ + public function replace(array $attributes): void; + + /** + * Removes an attribute. + * + * @param string $name The attribute name + * + * @return mixed The removed value or null when it does not exist + */ + public function remove(string $name): mixed; + + /** + * Clears all attributes. + */ + public function clear(): void; + + /** + * Checks if the session was started. + * + * @return bool True if started, false otherwise + */ + public function isStarted(): bool; +} diff --git a/core/lib/Injection/Builder.php b/core/lib/Injection/Builder.php new file mode 100644 index 0000000..0a9d544 --- /dev/null +++ b/core/lib/Injection/Builder.php @@ -0,0 +1,5 @@ +config = $config; + + if ($projectDir !== null) { + $this->projectDir = $projectDir; + } + } + + public function __clone() + { + $this->initialized = false; + $this->booted = false; + $this->container = null; + } + + private function initialize(): void + { + + if ($this->debug) { + $this->startTime = microtime(true); + } + if ($this->debug && !isset($_ENV['SHELL_VERBOSITY']) && !isset($_SERVER['SHELL_VERBOSITY'])) { + if (\function_exists('putenv')) { + putenv('SHELL_VERBOSITY=3'); + } + $_ENV['SHELL_VERBOSITY'] = 3; + $_SERVER['SHELL_VERBOSITY'] = 3; + } + + // Create logger with config support + $logDir = $this->config['log.directory'] ?? $this->getLogDir(); + $logChannel = $this->config['log.channel'] ?? 'app'; + $this->logger = new FileLogger($logDir, $logChannel); + + $this->initializeErrorHandlers(); + + $container = $this->initializeContainer(); + + $this->container = $container; + $this->initialized = true; + } + + /** + * Set up global error and exception handlers + */ + protected function initializeErrorHandlers(): void + { + // Convert PHP errors to exceptions + set_error_handler(function ($errno, $errstr, $errfile, $errline) { + // Don't throw exception if error reporting is turned off + if (!(error_reporting() & $errno)) { + return false; + } + + $message = sprintf( + "PHP Error [%d]: %s in %s:%d", + $errno, + $errstr, + $errfile, + $errline + ); + + $this->logger->error($message, ['errno' => $errno, 'file' => $errfile, 'line' => $errline]); + + // Throw exception for fatal errors + if ($errno === E_ERROR || $errno === E_CORE_ERROR || $errno === E_COMPILE_ERROR || $errno === E_USER_ERROR) { + throw new \ErrorException($errstr, 0, $errno, $errfile, $errline); + } + + return true; + }); + + // Handle uncaught exceptions + set_exception_handler(function (\Throwable $exception) { + $this->logger->error('Exception caught: ' . $exception->getMessage(), [ + 'exception' => $exception, + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => $exception->getTraceAsString(), + ]); + + if ($this->debug) { + echo '
Uncaught Exception: ' . $exception . '
'; + } else { + echo 'An unexpected error occurred. Please try again later.'; + } + + exit(1); + }); + + // Handle fatal errors + register_shutdown_function(function () { + $error = error_get_last(); + if ($error !== null && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE])) { + $message = sprintf( + "Fatal Error [%d]: %s in %s:%d", + $error['type'], + $error['message'], + $error['file'], + $error['line'] + ); + + $this->logger->error($message, $error); + + if ($this->debug) { + echo '
' . $message . '
'; + } else { + echo 'A fatal error occurred. Please try again later.'; + } + } + }); + } + + public function boot(): void + { + if (!$this->initialized) { + $this->initialize(); + } + + if (!$this->booted) { + /** @var ModuleManager $moduleManager */ + $moduleManager = $this->container->get(ModuleManager::class); + $moduleManager->modulesBoot(); + + // Build middleware pipeline + $this->pipeline = $this->buildMiddlewarePipeline(); + + $this->booted = true; + } + } + + public function reboot(): void + { + $this->shutdown(); + $this->boot(); + } + + public function shutdown(): void + { + if (false === $this->initialized) { + return; + } + + $this->initialized = false; + $this->booted = false; + $this->container = null; + } + + public function handle(Request $request): Response + { + if (!$this->booted) { + $this->boot(); + } + + // Use middleware pipeline to handle the request + return $this->pipeline->handle($request); + } + + /** + * Build the middleware pipeline + */ + protected function buildMiddlewarePipeline(): MiddlewarePipeline + { + $pipeline = new MiddlewarePipeline($this->container); + + // Register middleware in execution order + $pipeline->pipe(TenantMiddleware::class); + $pipeline->pipe(FirewallMiddleware::class); + $pipeline->pipe(AuthenticationMiddleware::class); + $pipeline->pipe(RouterMiddleware::class); + + return $pipeline; + } + + /** + * Process deferred events at the end of the request + */ + public function processEvents(): void + { + try { + if ($this->container && $this->container->has(EventBus::class)) { + /** @var EventBus $eventBus */ + $eventBus = $this->container->get(EventBus::class); + $eventBus->processDeferred(); + } + } catch (\Throwable $e) { + error_log('Event processing error: ' . $e->getMessage()); + } + } + + /** + * Returns the kernel parameters. + * + * @return array + */ + protected function parameters(): array + { + return [ + 'kernel.project_dir' => realpath($this->folderRoot()) ?: $this->folderRoot(), + 'kernel.environment' => $this->environment, + 'kernel.runtime_environment' => '%env(default:kernel.environment:APP_RUNTIME_ENV)%', + 'kernel.runtime_mode' => '%env(query_string:default:container.runtime_mode:APP_RUNTIME_MODE)%', + 'kernel.runtime_mode.web' => '%env(bool:default::key:web:default:kernel.runtime_mode:)%', + 'kernel.runtime_mode.cli' => '%env(not:default:kernel.runtime_mode.web:)%', + 'kernel.runtime_mode.worker' => '%env(bool:default::key:worker:default:kernel.runtime_mode:)%', + 'kernel.debug' => $this->debug, + 'kernel.build_dir' => realpath($this->getBuildDir()) ?: $this->getBuildDir(), + 'kernel.cache_dir' => realpath($this->getCacheDir()) ?: $this->getCacheDir(), + 'kernel.logs_dir' => realpath($this->getLogDir()) ?: $this->getLogDir(), + 'kernel.charset' => $this->getCharset(), + ]; + } + + public function environment(): string + { + return $this->environment; + } + + public function debug(): bool + { + return $this->debug; + } + + public function container(): ContainerInterface + { + if (!$this->container) { + throw new \LogicException('Cannot retrieve the container from a non-booted kernel.'); + } + + return $this->container; + } + + public function getStartTime(): float + { + return $this->debug && null !== $this->startTime ? $this->startTime : -\INF; + } + + /** + * Gets the application root dir (path of the project's composer file). + */ + public function folderRoot(): string + { + if (!isset($this->projectDir)) { + $r = new \ReflectionObject($this); + + if (!is_file($dir = $r->getFileName())) { + throw new \LogicException(\sprintf('Cannot auto-detect project dir for kernel of class "%s".', $r->name)); + } + + $dir = $rootDir = \dirname($dir); + while (!is_file($dir.'/composer.json')) { + if ($dir === \dirname($dir)) { + return $this->projectDir = $rootDir; + } + $dir = \dirname($dir); + } + $this->projectDir = $dir; + } + + return $this->projectDir; + } + + + /** + * Gets the path to the configuration directory. + */ + private function getConfigDir(): string + { + return $this->folderRoot().'/config'; + } + + public function getCacheDir(): string + { + return $this->folderRoot().'/var/cache/'.$this->environment; + } + + public function getBuildDir(): string + { + return $this->getCacheDir(); + } + + public function getLogDir(): string + { + return $this->folderRoot().'/var/log'; + } + + public function getCharset(): string + { + return 'UTF-8'; + } + + /** + * Initializes the service container + */ + protected function initializeContainer(): Container + { + $container = $this->buildContainer(); + $container->set('kernel', $this); + + return $container; + } + + /** + * Builds the service container. + * + * @throws \RuntimeException + */ + protected function buildContainer(): Container + { + $builder = new Builder(Container::class); + $builder->useAutowiring(true); + $builder->useAttributes(true); + $builder->addDefinitions($this->parameters()); + $builder->addDefinitions($this->config); + + $this->configureContainer($builder); + + return $builder->build(); + } + + protected function configureContainer(Builder $builder): void + { + // Service definitions + $projectDir = $this->folderRoot(); + $moduleDir = $projectDir . '/modules'; + $environment = $this->environment; + + $builder->addDefinitions([ + + // Provide primitives for injection + 'rootDir' => \DI\value($projectDir), + 'moduleDir' => \DI\value($moduleDir), + 'environment' => \DI\value($environment), + + // IMPORTANT: ensure Container::class resolves to the *current* container instance. + // Without this alias, PHP-DI will happily autowire a new empty Container when asked + Container::class => \DI\get(ContainerInterface::class), + + // Use the kernel's logger instance + LoggerInterface::class => \DI\value($this->logger), + + // EventBus as singleton for consistent event handling + EventBus::class => \DI\create(EventBus::class), + // Ephemeral Cache - for short-lived data (sessions, rate limits, challenges) + EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) { + $storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file'; + + $storeMap = [ + 'file' => FileEphemeralCache::class, + // 'redis' => RedisEphemeralCache::class, + ]; + + $storeClass = $storeMap[$storeType] ?? $storeType; + + if (!class_exists($storeClass)) { + throw new \RuntimeException("Ephemeral cache store not found: {$storeClass}"); + } + + $cache = new $storeClass($projectDir); + + // Set tenant/user context if available + if ($c->has(SessionTenant::class)) { + $tenant = $c->get(SessionTenant::class); + $cache->setTenantContext($tenant->identifier()); + } + if ($c->has(SessionIdentity::class)) { + $identity = $c->get(SessionIdentity::class); + $cache->setUserContext($identity->identifier()); + } + + return $cache; + }, + // Persistent Cache - for long-lived data (routes, modules, compiled configs) + PersistentCacheInterface::class => function(ContainerInterface $c) use ($projectDir) { + $storeType = $c->has('cache.persistent') ? $c->get('cache.persistent') : 'file'; + + $storeMap = [ + 'file' => FilePersistentCache::class, + // 'database' => DatabasePersistentCache::class, + ]; + + $storeClass = $storeMap[$storeType] ?? $storeType; + + if (!class_exists($storeClass)) { + throw new \RuntimeException("Persistent cache store not found: {$storeClass}"); + } + + $cache = new $storeClass($projectDir); + + // Set tenant/user context if available + if ($c->has(SessionTenant::class)) { + $tenant = $c->get(SessionTenant::class); + $cache->setTenantContext($tenant->identifier()); + } + if ($c->has(SessionIdentity::class)) { + $identity = $c->get(SessionIdentity::class); + $cache->setUserContext($identity->identifier()); + } + + return $cache; + }, + // Blob Cache - for binary/media data (previews, thumbnails) + BlobCacheInterface::class => function(ContainerInterface $c) use ($projectDir) { + $storeType = $c->has('cache.blob') ? $c->get('cache.blob') : 'file'; + + $storeMap = [ + 'file' => FileBlobCache::class, + // 's3' => S3BlobCache::class, + ]; + + $storeClass = $storeMap[$storeType] ?? $storeType; + + if (!class_exists($storeClass)) { + throw new \RuntimeException("Blob cache store not found: {$storeClass}"); + } + + $cache = new $storeClass($projectDir); + + // Set tenant/user context if available + if ($c->has(SessionTenant::class)) { + $tenant = $c->get(SessionTenant::class); + $cache->setTenantContext($tenant->identifier()); + } + if ($c->has(SessionIdentity::class)) { + $identity = $c->get(SessionIdentity::class); + $cache->setUserContext($identity->identifier()); + } + + return $cache; + }, + ]); + } + +} diff --git a/core/lib/Logger/FileLogger.php b/core/lib/Logger/FileLogger.php new file mode 100644 index 0000000..b3d2988 --- /dev/null +++ b/core/lib/Logger/FileLogger.php @@ -0,0 +1,125 @@ +useMicroseconds = $useMicroseconds; + $this->channel = $channel; + if (!is_dir($logDir)) { + @mkdir($logDir, 0775, true); + } + $this->logFile = rtrim($logDir, '/').'/'.$channel.'.log'; + } + + public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); } + public function alert($message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); } + public function critical($message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); } + public function error($message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); } + public function warning($message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); } + public function notice($message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); } + public function info($message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); } + public function debug($message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); } + + public function log($level, $message, array $context = []): void + { + $timestamp = $this->formatTimestamp(); + $interpolated = $this->interpolate((string)$message, $context); + $payload = [ + 'time' => $timestamp, + 'level' => strtolower((string)$level), + 'channel' => $this->channel, + 'message' => $interpolated, + 'context' => $this->sanitizeContext($context), + ]; + $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json === false) { + // Fallback stringify if encoding fails (should be rare) + $json = json_encode([ + 'time' => $timestamp, + 'level' => strtolower((string)$level), + 'channel' => $this->channel, + 'message' => $interpolated, + 'context_error' => 'failed to encode context: '.json_last_error_msg(), + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{"error":"logging failure"}'; + } + $this->write($json); + } + + private function formatTimestamp(): string + { + if ($this->useMicroseconds) { + $dt = \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true))); + return $dt?->format('Y-m-d H:i:s.u') ?? date('Y-m-d H:i:s'); + } + return date('Y-m-d H:i:s'); + } + + private function interpolate(string $message, array $context): string + { + if (!str_contains($message, '{')) { + return $message; + } + $replace = []; + foreach ($context as $key => $val) { + if (is_array($val) || is_object($val)) { + continue; // don't inline complex values + } + $replace['{'.$key.'}'] = (string)$val; + } + return strtr($message, $replace); + } + + private function sanitizeContext(array $context): array + { + if (empty($context)) { return []; } + $clean = []; + foreach ($context as $k => $v) { + if ($v instanceof \Throwable) { + $clean[$k] = [ + 'type' => get_class($v), + 'message' => $v->getMessage(), + 'code' => $v->getCode(), + 'file' => $v->getFile(), + 'line' => $v->getLine(), + 'trace' => explode("\n", $v->getTraceAsString()), + ]; + } elseif (is_resource($v)) { + $clean[$k] = 'resource('.get_resource_type($v).')'; + } elseif (is_object($v)) { + // Try to extract serializable data + if (method_exists($v, '__toString')) { + $clean[$k] = (string)$v; + } else { + $clean[$k] = ['object' => get_class($v)]; + } + } else { + $clean[$k] = $v; + } + } + return $clean; + } + + private function write(string $line): void + { + $line = rtrim($line)."\n"; // newline-delimited JSON (JSONL) + @file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX); + } +} diff --git a/core/lib/Models/Firewall/FirewallLogObject.php b/core/lib/Models/Firewall/FirewallLogObject.php new file mode 100644 index 0000000..43dbdea --- /dev/null +++ b/core/lib/Models/Firewall/FirewallLogObject.php @@ -0,0 +1,255 @@ +id = $data['_id'] !== null ? (string)$data['_id'] : null; + } elseif (array_key_exists('id', $data)) { + $this->id = $data['id'] !== null ? (string)$data['id'] : null; + } + + if (array_key_exists('tenantId', $data)) { + $this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null; + } + if (array_key_exists('ipAddress', $data)) { + $this->ipAddress = $data['ipAddress'] !== null ? (string)$data['ipAddress'] : null; + } + if (array_key_exists('deviceFingerprint', $data)) { + $this->deviceFingerprint = $data['deviceFingerprint'] !== null ? (string)$data['deviceFingerprint'] : null; + } + if (array_key_exists('userAgent', $data)) { + $this->userAgent = $data['userAgent'] !== null ? (string)$data['userAgent'] : null; + } + if (array_key_exists('requestPath', $data)) { + $this->requestPath = $data['requestPath'] !== null ? (string)$data['requestPath'] : null; + } + if (array_key_exists('requestMethod', $data)) { + $this->requestMethod = $data['requestMethod'] !== null ? (string)$data['requestMethod'] : null; + } + if (array_key_exists('eventType', $data)) { + $this->eventType = $data['eventType'] !== null ? (string)$data['eventType'] : null; + } + if (array_key_exists('result', $data)) { + $this->result = $data['result'] !== null ? (string)$data['result'] : null; + } + if (array_key_exists('ruleId', $data)) { + $this->ruleId = $data['ruleId'] !== null ? (string)$data['ruleId'] : null; + } + if (array_key_exists('identityId', $data)) { + $this->identityId = $data['identityId'] !== null ? (string)$data['identityId'] : null; + } + if (array_key_exists('timestamp', $data)) { + $this->timestamp = $data['timestamp'] !== null + ? new \DateTimeImmutable($data['timestamp']) + : null; + } + if (array_key_exists('metadata', $data)) { + $this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null; + } + + return $this; + } + + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'tenantId' => $this->tenantId, + 'ipAddress' => $this->ipAddress, + 'deviceFingerprint' => $this->deviceFingerprint, + 'userAgent' => $this->userAgent, + 'requestPath' => $this->requestPath, + 'requestMethod' => $this->requestMethod, + 'eventType' => $this->eventType, + 'result' => $this->result, + 'ruleId' => $this->ruleId, + 'identityId' => $this->identityId, + 'timestamp' => $this->timestamp?->format(\DateTimeInterface::ATOM), + 'metadata' => $this->metadata, + ]; + } + + // Getters and setters + + public function getId(): ?string + { + return $this->id; + } + + public function setId(?string $id): self + { + $this->id = $id; + return $this; + } + + public function getTenantId(): ?string + { + return $this->tenantId; + } + + public function setTenantId(?string $tenantId): self + { + $this->tenantId = $tenantId; + return $this; + } + + public function getIpAddress(): ?string + { + return $this->ipAddress; + } + + public function setIpAddress(?string $ipAddress): self + { + $this->ipAddress = $ipAddress; + return $this; + } + + public function getDeviceFingerprint(): ?string + { + return $this->deviceFingerprint; + } + + public function setDeviceFingerprint(?string $deviceFingerprint): self + { + $this->deviceFingerprint = $deviceFingerprint; + return $this; + } + + public function getUserAgent(): ?string + { + return $this->userAgent; + } + + public function setUserAgent(?string $userAgent): self + { + $this->userAgent = $userAgent; + return $this; + } + + public function getRequestPath(): ?string + { + return $this->requestPath; + } + + public function setRequestPath(?string $requestPath): self + { + $this->requestPath = $requestPath; + return $this; + } + + public function getRequestMethod(): ?string + { + return $this->requestMethod; + } + + public function setRequestMethod(?string $requestMethod): self + { + $this->requestMethod = $requestMethod; + return $this; + } + + public function getEventType(): ?string + { + return $this->eventType; + } + + public function setEventType(?string $eventType): self + { + $this->eventType = $eventType; + return $this; + } + + public function getResult(): ?string + { + return $this->result; + } + + public function setResult(?string $result): self + { + $this->result = $result; + return $this; + } + + public function getRuleId(): ?string + { + return $this->ruleId; + } + + public function setRuleId(?string $ruleId): self + { + $this->ruleId = $ruleId; + return $this; + } + + public function getIdentityId(): ?string + { + return $this->identityId; + } + + public function setIdentityId(?string $identityId): self + { + $this->identityId = $identityId; + return $this; + } + + public function getTimestamp(): ?\DateTimeImmutable + { + return $this->timestamp; + } + + public function setTimestamp(?\DateTimeImmutable $timestamp): self + { + $this->timestamp = $timestamp; + return $this; + } + + public function getMetadata(): ?array + { + return $this->metadata; + } + + public function setMetadata(?array $metadata): self + { + $this->metadata = $metadata; + return $this; + } +} diff --git a/core/lib/Models/Firewall/FirewallRuleObject.php b/core/lib/Models/Firewall/FirewallRuleObject.php new file mode 100644 index 0000000..3636242 --- /dev/null +++ b/core/lib/Models/Firewall/FirewallRuleObject.php @@ -0,0 +1,241 @@ +id = $data['_id'] !== null ? (string)$data['_id'] : null; + } elseif (array_key_exists('id', $data)) { + $this->id = $data['id'] !== null ? (string)$data['id'] : null; + } + + if (array_key_exists('tenantId', $data)) { + $this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null; + } + if (array_key_exists('type', $data)) { + $this->type = $data['type'] !== null ? (string)$data['type'] : null; + } + if (array_key_exists('action', $data)) { + $this->action = $data['action'] !== null ? (string)$data['action'] : null; + } + if (array_key_exists('value', $data)) { + $this->value = $data['value'] !== null ? (string)$data['value'] : null; + } + if (array_key_exists('reason', $data)) { + $this->reason = $data['reason'] !== null ? (string)$data['reason'] : null; + } + if (array_key_exists('createdBy', $data)) { + $this->createdBy = $data['createdBy'] !== null ? (string)$data['createdBy'] : null; + } + if (array_key_exists('createdAt', $data)) { + $this->createdAt = $data['createdAt'] !== null + ? new \DateTimeImmutable($data['createdAt']) + : null; + } + if (array_key_exists('expiresAt', $data)) { + $this->expiresAt = $data['expiresAt'] !== null + ? new \DateTimeImmutable($data['expiresAt']) + : null; + } + if (array_key_exists('enabled', $data)) { + $this->enabled = (bool)$data['enabled']; + } + if (array_key_exists('metadata', $data)) { + $this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null; + } + + return $this; + } + + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'tenantId' => $this->tenantId, + 'type' => $this->type, + 'action' => $this->action, + 'value' => $this->value, + 'reason' => $this->reason, + 'createdBy' => $this->createdBy, + 'createdAt' => $this->createdAt?->format(\DateTimeInterface::ATOM), + 'expiresAt' => $this->expiresAt?->format(\DateTimeInterface::ATOM), + 'enabled' => $this->enabled, + 'metadata' => $this->metadata, + ]; + } + + /** + * Check if this rule has expired + */ + public function isExpired(): bool + { + if ($this->expiresAt === null) { + return false; + } + return $this->expiresAt < new \DateTimeImmutable(); + } + + /** + * Check if this rule is currently active (enabled and not expired) + */ + public function isActive(): bool + { + return $this->enabled && !$this->isExpired(); + } + + // Getters and setters + + public function getId(): ?string + { + return $this->id; + } + + public function setId(?string $id): self + { + $this->id = $id; + return $this; + } + + public function getTenantId(): ?string + { + return $this->tenantId; + } + + public function setTenantId(?string $tenantId): self + { + $this->tenantId = $tenantId; + return $this; + } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(?string $type): self + { + $this->type = $type; + return $this; + } + + public function getAction(): ?string + { + return $this->action; + } + + public function setAction(?string $action): self + { + $this->action = $action; + return $this; + } + + public function getValue(): ?string + { + return $this->value; + } + + public function setValue(?string $value): self + { + $this->value = $value; + return $this; + } + + public function getReason(): ?string + { + return $this->reason; + } + + public function setReason(?string $reason): self + { + $this->reason = $reason; + return $this; + } + + public function getCreatedBy(): ?string + { + return $this->createdBy; + } + + public function setCreatedBy(?string $createdBy): self + { + $this->createdBy = $createdBy; + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(?\DateTimeImmutable $createdAt): self + { + $this->createdAt = $createdAt; + return $this; + } + + public function getExpiresAt(): ?\DateTimeImmutable + { + return $this->expiresAt; + } + + public function setExpiresAt(?\DateTimeImmutable $expiresAt): self + { + $this->expiresAt = $expiresAt; + return $this; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function setEnabled(bool $enabled): self + { + $this->enabled = $enabled; + return $this; + } + + public function getMetadata(): ?array + { + return $this->metadata; + } + + public function setMetadata(?array $metadata): self + { + $this->metadata = $metadata; + return $this; + } +} diff --git a/core/lib/Models/Identity/User.php b/core/lib/Models/Identity/User.php new file mode 100644 index 0000000..1d3e252 --- /dev/null +++ b/core/lib/Models/Identity/User.php @@ -0,0 +1,156 @@ +id = $data['uid'] ?? null; // 'uid' maps to 'id' + $this->identity = $data['identity'] ?? null; + $this->label = $data['label'] ?? null; + $this->roles = (array)($data['roles'] ?? []); + $this->enabled = $data['enabled'] ?? null; + $this->provider = $data['provider'] ?? null; + $this->externalSubject = $data['external_subject'] ?? null; + $this->initialLogin = $data['initial_login'] ?? null; + $this->recentLogin = $data['recent_login'] ?? null; + $this->permissions = (array)($data['permissions'] ?? []); + } + + if ($source === 'jwt') { + $this->id = $data['identifier'] ?? null; + $this->identity = $data['identity'] ?? null; + $this->label = $data['label'] ?? null; + $this->roles = (array)($data['role'] ?? []); + $this->permissions = (array)($data['permissions'] ?? []); + $this->enabled = true; + } + + if ($source === 'external') { + $this->identity = $data['identity'] ?? null; + $this->label = $data['label'] ?? null; + $this->externalSubject = $data['external_subject'] ?? null; + $this->provider = $data['provider'] ?? null; + } + + } + + public function getId(): ?string + { + return $this->id; + } + + public function setId(string $value): void + { + $this->id = $value; + } + + public function getIdentity(): ?string + { + return $this->identity; + } + + public function setIdentity(string $value): void + { + $this->identity = $value; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(?string $value): void + { + $this->label = $value; + } + + public function getRoles(): array + { + return $this->roles; + } + + public function setRoles(array $values): void + { + $this->roles = $values; + } + + public function getEnabled(): ?bool + { + return $this->enabled; + } + + public function setEnabled(?bool $value): void + { + $this->enabled = $value; + } + + public function getProvider(): ?string + { + return $this->provider; + } + + public function setProvider(?string $value): void + { + $this->provider = $value; + } + + public function getExternalSubject(): ?string + { + return $this->externalSubject; + } + + public function setExternalSubject(?string $value): void + { + $this->externalSubject = $value; + } + + public function getInitialLogin(): ?int + { + return $this->initialLogin; + } + + public function setInitialLogin(?int $value): void + { + $this->initialLogin = $value; + } + + public function getRecentLogin(): ?int + { + return $this->recentLogin; + } + + public function setRecentLogin(?int $value): void + { + $this->recentLogin = $value; + } + + public function getPermissions(): array + { + return $this->permissions; + } + + public function setPermissions(array $permissions): void + { + $this->permissions = $permissions; + } + + public function hasPermission(string $permission): bool + { + return in_array($permission, $this->permissions, true); + } + +} diff --git a/core/lib/Models/Tenant/DomainCollection.php b/core/lib/Models/Tenant/DomainCollection.php new file mode 100644 index 0000000..2a241cf --- /dev/null +++ b/core/lib/Models/Tenant/DomainCollection.php @@ -0,0 +1,13 @@ +providers; + } + + public function methodsMinimal(): int { + return $this->methodsMinimal; + } +} diff --git a/core/lib/Models/Tenant/TenantCollection.php b/core/lib/Models/Tenant/TenantCollection.php new file mode 100644 index 0000000..0d56dda --- /dev/null +++ b/core/lib/Models/Tenant/TenantCollection.php @@ -0,0 +1,13 @@ +authentication = new TenantAuthentication(); + $this->security = new TenantSecurity(); + } + + public function authentication(): TenantAuthentication { + return $this->authentication; + } + + public function security(): TenantSecurity { + return $this->security; + } + +} diff --git a/core/lib/Models/Tenant/TenantObject.php b/core/lib/Models/Tenant/TenantObject.php new file mode 100644 index 0000000..50373d5 --- /dev/null +++ b/core/lib/Models/Tenant/TenantObject.php @@ -0,0 +1,148 @@ +id = $data['_id'] !== null ? (string)$data['_id'] : null; + elseif (array_key_exists('id', $data)) $this->id = $data['id'] !== null ? (string)$data['id'] : null; + if (array_key_exists('identifier', $data)) $this->identifier = $data['identifier'] !== null ? (string)$data['identifier'] : null; + if (array_key_exists('enabled', $data)) $this->enabled = $data['enabled'] !== null ? (bool)$data['enabled'] : null; + if (array_key_exists('label', $data)) $this->label = $data['label'] !== null ? (string)$data['label'] : null; + if (array_key_exists('description', $data)) $this->description = $data['description'] !== null ? (string)$data['description'] : null; + if (array_key_exists('domains', $data)) { + $this->domains = (new DomainCollection((array)$data['domains'])); + } + if (array_key_exists('configuration', $data)) { + $this->configuration = (new TenantConfiguration)->jsonDeserialize($data['configuration']); + } + return $this; + } + + /** + * Serialize to JSON-friendly structure. + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'identifier' => $this->identifier, + 'enabled' => $this->enabled, + 'label' => $this->label, + 'description' => $this->description, + 'domains' => $this->domains, + 'configuration' => $this->configuration, + ]; + } + + public function getId(): ?string + { + return $this->id; + } + + public function setId(string $value): self + { + $this->id = $value; + return $this; + } + + public function getIdentifier(): ?string + { + return $this->identifier; + } + + public function setIdentifier(string $value): self + { + $this->identifier = $value; + return $this; + } + + public function getEnabled(): bool + { + return $this->enabled; + } + + public function setEnabled(bool $value): self + { + $this->enabled = $value; + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $value): self + { + $this->label = $value; + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(string $value): self + { + $this->description = $value; + return $this; + } + + public function getDomains(): ?DomainCollection + { + return $this->domains; + } + + public function setDomains(DomainCollection $value): self + { + $this->domains = $value; + return $this; + } + + public function getConfiguration(): TenantConfiguration + { + return $this->configuration; + } + + public function setConfiguration(TenantConfiguration $value): self + { + $this->configuration = $value; + return $this; + } + + public function getSettings(): array + { + return $this->configuration['settings'] ?? []; + } + + public function setSettings(array $value): self + { + $this->configuration['settings'] = $value; + return $this; + } + +} diff --git a/core/lib/Models/Tenant/TenantSecurity.php b/core/lib/Models/Tenant/TenantSecurity.php new file mode 100644 index 0000000..e85e429 --- /dev/null +++ b/core/lib/Models/Tenant/TenantSecurity.php @@ -0,0 +1,22 @@ +code = uniqid(); + } + + public function code(): string { + return $this->code; + } +} diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php new file mode 100644 index 0000000..f9a29d1 --- /dev/null +++ b/core/lib/Module/Module.php @@ -0,0 +1,112 @@ + [ + 'label' => 'Read Own Profile', + 'description' => 'View own user profile information', + 'group' => 'User Profile' + ], + 'user.profile.update' => [ + 'label' => 'Update Own Profile', + 'description' => 'Edit own user profile information', + 'group' => 'User Profile' + ], + 'user.settings.read' => [ + 'label' => 'Read Own Settings', + 'description' => 'View own user settings', + 'group' => 'User Settings' + ], + 'user.settings.update' => [ + 'label' => 'Update Own Settings', + 'description' => 'Edit own user settings', + 'group' => 'User Settings' + ], + + // Module Management + 'module_manager.modules.view' => [ + 'label' => 'View Modules', + 'description' => 'View list of installed and available modules', + 'group' => 'Module Management' + ], + 'module_manager.modules.manage' => [ + 'label' => 'Manage Modules', + 'description' => 'Install, uninstall, enable, and disable modules', + 'group' => 'Module Management' + ], + 'module_manager.modules.*' => [ + 'label' => 'Full Module Management', + 'description' => 'All module management operations', + 'group' => 'Module Management' + ], + + // System Administration + 'system.admin' => [ + 'label' => 'System Administrator', + 'description' => 'Full system access (superuser)', + 'group' => 'System Administration' + ], + '*' => [ + 'label' => 'All Permissions', + 'description' => 'Grants access to all features and operations', + 'group' => 'System Administration' + ], + ]; + } + + public function registerCI(): array + { + return [ + \KTXC\Console\ModuleListCommand::class, + \KTXC\Console\ModuleEnableCommand::class, + \KTXC\Console\ModuleDisableCommand::class, + ]; + } + + public function registerBI(): array + { + return []; + } +} diff --git a/core/lib/Module/ModuleAutoloader.php b/core/lib/Module/ModuleAutoloader.php new file mode 100644 index 0000000..e2d5816 --- /dev/null +++ b/core/lib/Module/ModuleAutoloader.php @@ -0,0 +1,195 @@ +modulesRoot = rtrim($modulesRoot, '/'); + } + + /** + * Register the autoloader + */ + public function register(): void + { + spl_autoload_register([$this, 'loadClass']); + } + + /** + * Unregister the autoloader + */ + public function unregister(): void + { + spl_autoload_unregister([$this, 'loadClass']); + } + + /** + * Scan the modules directory and build a map of namespaces to folder paths + * This is called lazily on the first KTXM class request + */ + private function scan(): void + { + if ($this->scanned) { + return; + } + + $this->namespaceMap = []; + + if (!is_dir($this->modulesRoot)) { + $this->scanned = true; + return; + } + + $moduleDirs = glob($this->modulesRoot . '/*', GLOB_ONLYDIR); + foreach ($moduleDirs as $moduleDir) { + $moduleFile = $moduleDir . '/lib/Module.php'; + if (!file_exists($moduleFile)) { + continue; + } + + // Extract the namespace from Module.php + $namespace = $this->extractNamespace($moduleFile); + if ($namespace) { + $this->namespaceMap[$namespace] = basename($moduleDir); + } + } + + // Register module namespaces with Composer ClassLoader + $composerLoader = \KTXC\Application::getComposerLoader(); + if ($composerLoader !== null) { + foreach ($this->namespaceMap as $namespace => $folderName) { + $composerLoader->addPsr4( + 'KTXM\\' . $namespace . '\\', + $this->modulesRoot . '/' . $folderName . '/lib/' + ); + } + } + + $this->scanned = true; + } + + /** + * Load a class by its fully qualified name + * + * @param string $className Fully qualified class name (e.g., KTXM\ContactsManager\Module) + * @return bool True if the class was loaded, false otherwise + */ + public function loadClass(string $className): bool + { + try { + // Only handle classes in the KTXM namespace + if (!str_starts_with($className, 'KTXM\\')) { + return false; + } + + // Extract the namespace segment (e.g., ContactsManager from KTXM\ContactsManager\Module) + $parts = explode('\\', $className); + if (count($parts) < 2) { + $this->logError("Invalid class name format: $className (expected at least 2 namespace parts)"); + return false; + } + + $namespaceSegment = $parts[1]; + + // Check if we already have a mapping for this namespace + if (!isset($this->namespaceMap[$namespaceSegment])) { + // Scan only if we haven't scanned yet (this happens once, on first module access) + if (!$this->scanned) { + $this->scan(); + + // Check again after scanning + if (!isset($this->namespaceMap[$namespaceSegment])) { + $this->logError("No module found for namespace segment: $namespaceSegment (class: $className)"); + return false; + } + } else { + $this->logError("Module not found after scan: $namespaceSegment (class: $className)"); + return false; + } + } + + $folderName = $this->namespaceMap[$namespaceSegment]; + + // Reconstruct the relative path + // KTXM\ContactsManager\Module -> contacts_manager/lib/Module.php + // KTXM\ContactsManager\Something -> contacts_manager/lib/Something.php + $relativePath = 'lib/' . implode('/', array_slice($parts, 2)) . '.php'; + $filePath = $this->modulesRoot . '/' . $folderName . '/' . $relativePath; + + if (file_exists($filePath)) { + require_once $filePath; + return true; + } + + $this->logError("File not found for class $className at path: $filePath"); + return false; + + } catch (\Throwable $e) { + $this->logError("Exception in ModuleAutoloader while loading $className: " . $e->getMessage(), [ + 'exception' => $e, + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); + return false; + } + } + + /** + * Log an error from the autoloader + * + * @param string $message Error message + * @param array $context Additional context + */ + private function logError(string $message, array $context = []): void + { + // Log to PHP error log + error_log('[ModuleAutoloader] ' . $message); + + if (!empty($context)) { + error_log('[ModuleAutoloader Context] ' . json_encode($context)); + } + } + + /** + * Extract namespace from a Module.php file + * + * @param string $filePath Path to the Module.php file (at modules/{handle}/lib/Module.php) + * @return string|null The namespace segment (e.g., 'ContactsManager') + */ + private function extractNamespace(string $filePath): ?string + { + if (!file_exists($filePath)) { + return null; + } + + $content = file_get_contents($filePath); + if ($content === false) { + return null; + } + + // Match namespace declaration: namespace KTXM\; + if (preg_match('/^\s*namespace\s+KTXM\\\\([a-zA-Z0-9_]+)\s*;/m', $content, $matches)) { + return $matches[1]; + } + + return null; + } +} diff --git a/core/lib/Module/ModuleCollection.php b/core/lib/Module/ModuleCollection.php new file mode 100644 index 0000000..24a0502 --- /dev/null +++ b/core/lib/Module/ModuleCollection.php @@ -0,0 +1,23 @@ + $item) { + $result[$key] = $item; + } + return $result; + } +} diff --git a/core/lib/Module/ModuleManager.php b/core/lib/Module/ModuleManager.php new file mode 100644 index 0000000..653a4a1 --- /dev/null +++ b/core/lib/Module/ModuleManager.php @@ -0,0 +1,595 @@ +serverRoot = $rootDir; + } + + /** + * List all modules as unified Module objects + * + * @param bool $installedOnly If true, only return modules that are in the database + * @param bool $enabledOnly If true, only return modules that are enabled (implies installedOnly) + * @return Module[] + */ + public function list(bool $installedOnly = true, $enabledOnly = true): ModuleCollection + { + $modules = New ModuleCollection(); + + // Always include core module + $coreModule = $this->coreModule(); + if ($coreModule) { + $modules['core'] = new ModuleObject($coreModule, null); + } + + // load all modules from store + $entries = $this->repository->list(); + foreach ($entries as $entry) { + if ($enabledOnly && !$entry->getEnabled()) { + continue; // Skip disabled modules if filtering for enabled only + } + // instance module + $handle = $entry->getHandle(); + if (isset($this->moduleInstances[$entry->getHandle()])) { + $modules[$handle] = new ModuleObject($this->moduleInstances[$handle], $entry); + } else { + $moduleInstance = $this->moduleInstance($handle, $entry->getNamespace()); + $modules[$handle] = new ModuleObject($moduleInstance, $entry); + $this->moduleInstances[$handle] = $moduleInstance; + } + } + // load all modules from filesystem + if ($installedOnly === false) { + $discovered = $this->modulesDiscover(); + foreach ($discovered as $moduleInstance) { + $handle = $moduleInstance->handle(); + if (!isset($modules[$handle])) { + $modules[$handle] = new ModuleObject($moduleInstance, null); + } + } + } + + return $modules; + } + + public function install(string $handle): void + { + // First, try to find the module by scanning the filesystem + $modulesDir = $this->serverRoot . '/modules'; + $namespace = null; + + // Scan for the module by checking if handle matches any folder or module's handle() method + if (is_dir($modulesDir)) { + $moduleDirs = glob($modulesDir . '/*', GLOB_ONLYDIR); + foreach ($moduleDirs as $moduleDir) { + $testModuleFile = $moduleDir . '/lib/Module.php'; + if (!file_exists($testModuleFile)) { + continue; + } + + // Extract namespace from the Module.php file + $testNamespace = $this->extractNamespaceFromFile($testModuleFile); + if (!$testNamespace) { + continue; + } + + // Try to instantiate with a temporary handle to check if it matches + $folderName = basename($moduleDir); + $testInstance = $this->moduleInstance($folderName, $testNamespace); + + if ($testInstance && $testInstance->handle() === $handle) { + $namespace = $testNamespace; + break; + } + } + } + + if (!$namespace) { + $this->logger->error('Module not found for installation', ['handle' => $handle]); + return; + } + + $moduleInstance = $this->moduleInstance($handle, $namespace); + if (!$moduleInstance) { + return; + } + + try { + $moduleInstance->install(); + } catch (Exception $e) { + $this->logger->error('Module installation failed: ' . $handle, [ + 'exception' => [ + 'code' => $e->getCode(), + 'message' => $e->getMessage(), + ] + ]); + return; + } + + $module = new ModuleEntry(); + $module->setHandle($handle); + $module->setVersion($moduleInstance->version()); + $module->setEnabled(false); + $module->setInstalled(true); + // Store the namespace we found + $module->setNamespace($namespace); + $this->repository->deposit($module); + } + + public function uninstall(string $handle): void + { + $moduleEntry = $this->repository->fetch($handle); + if (!$moduleEntry || !$moduleEntry->getInstalled()) { + $this->logger->warning('Attempted to uninstall non-installed module: ' . $handle); + throw new Exception('Module not installed: ' . $handle); + } + + $moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace()); + if (!$moduleInstance) { + return; + } + + try { + $moduleInstance->uninstall(); + } catch (Exception $e) { + $this->logger->error('Module uninstallation failed: ' . $moduleEntry->getHandle(), [ + 'exception' => [ + 'code' => $e->getCode(), + 'message' => $e->getMessage(), + ] + ]); + return; + } + + $this->repository->destroy($moduleEntry); + } + + public function enable(string $handle): void + { + $moduleEntry = $this->repository->fetch($handle); + if (!$moduleEntry || !$moduleEntry->getInstalled()) { + $this->logger->warning('Attempted to uninstall non-installed module: ' . $handle); + throw new Exception('Module not installed: ' . $handle); + } + + $moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace()); + if (!$moduleInstance) { + return; + } + + try { + $moduleInstance->enable(); + } catch (Exception $e) { + $this->logger->error('Module enabling failed: ' . $moduleEntry->getHandle(), [ + 'exception' => [ + 'code' => $e->getCode(), + 'message' => $e->getMessage(), + ] + ]); + return; + } + + $moduleEntry->setEnabled(true); + $this->repository->deposit($moduleEntry); + } + + public function disable(string $handle): void + { + $moduleEntry = $this->repository->fetch($handle); + if (!$moduleEntry || !$moduleEntry->getInstalled()) { + $this->logger->warning('Attempted to uninstall non-installed module: ' . $handle); + throw new Exception('Module not installed: ' . $handle); + } + + $moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace()); + if (!$moduleInstance) { + return; + } + + try { + $moduleInstance->disable(); + } catch (Exception $e) { + $this->logger->error('Module disabling failed: ' . $moduleEntry->getHandle(), [ + 'exception' => [ + 'code' => $e->getCode(), + 'message' => $e->getMessage(), + ] + ]); + return; + } + + $moduleEntry->setEnabled(false); + $this->repository->deposit($moduleEntry); + } + + public function upgrade(string $handle): void + { + $moduleEntry = $this->repository->fetch($handle); + if (!$moduleEntry || !$moduleEntry->getInstalled()) { + $this->logger->warning('Attempted to uninstall non-installed module: ' . $handle); + throw new Exception('Module not installed: ' . $handle); + } + + $moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace()); + if (!$moduleInstance) { + return; + } + + try { + $moduleInstance->upgrade(); + } catch (Exception $e) { + $this->logger->error('Module upgrade failed: ' . $moduleEntry->getHandle(), [ + 'exception' => [ + 'code' => $e->getCode(), + 'message' => $e->getMessage(), + ] + ]); + return; + } + + $moduleEntry->setVersion($moduleInstance->version()); + $this->repository->deposit($moduleEntry); + } + + /** + * Boot all enabled modules (must be called after container is ready). + */ + public function modulesBoot(): void + { + // Only load modules that are enabled in the database + $modules = $this->list(); + $this->logger->debug('Booting enabled modules', ['count' => count($modules)]); + foreach ($modules as $module) { + $handle = $module->handle(); + try { + $module->boot(); + $this->logger->debug('Module booted', ['handle' => $handle]); + } catch (Exception $e) { + $this->logger->error('Module boot failed: ' . $handle, [ + 'exception' => $e, + 'message' => $e->getMessage(), + 'code' => $e->getCode(), + ]); + } + } + } + + /** + * Scan filesystem for module directories and return module instances + * + * @return array Map of handle => ModuleInstanceInterface + */ + private function modulesDiscover(): array + { + $modules = []; + $modulesDir = $this->serverRoot . '/modules'; + + if (!is_dir($modulesDir)) { + return $modules; + } + + // Get list of installed module handles to skip + $installedHandles = []; + foreach ($this->repository->list() as $entry) { + $installedHandles[] = $entry->getHandle(); + } + + // Scan for module directories + $moduleDirs = glob($modulesDir . '/*', GLOB_ONLYDIR); + foreach ($moduleDirs as $moduleDir) { + $moduleFile = $moduleDir . '/lib/Module.php'; + if (!file_exists($moduleFile)) { + continue; + } + + // Extract namespace from the Module.php file + $namespace = $this->extractNamespaceFromFile($moduleFile); + if (!$namespace) { + $this->logger->warning('Could not extract namespace from Module.php', [ + 'file' => $moduleFile + ]); + continue; + } + + // Use the folder name as a temporary handle to instantiate the module + $folderName = basename($moduleDir); + $moduleInstance = $this->moduleInstance($folderName, $namespace); + if (!$moduleInstance) { + continue; + } + + // Get the actual handle from the module instance + $handle = $moduleInstance->handle(); + + // Skip if already installed + if (in_array($handle, $installedHandles)) { + continue; + } + + // Re-cache with the correct handle if different from folder name + if ($handle !== $folderName) { + unset($this->moduleInstances[$folderName]); + $this->moduleInstances[$handle] = $moduleInstance; + } + + $modules[$handle] = $moduleInstance; + } + + return $modules; + } + + public function moduleInstance(string $handle, ?string $namespace = null): ?ModuleInstanceInterface + { + // Load module's vendor autoloader if it exists + $this->loadModuleVendor($handle); + + // Return from cache if already instantiated + if (isset($this->moduleInstances[$handle])) { + return $this->moduleInstances[$handle]; + } + + // Determine the namespace segment + // If namespace is provided, use it; otherwise derive from handle + $nsSegment = $namespace ?: $this->studly($handle); + + $className = 'KTXM\\' . $nsSegment . '\\Module'; + + if (!class_exists($className)) { + $this->logger->error('Module class not found', [ + 'handle' => $handle, + 'namespace' => $namespace, + 'resolved' => $className + ]); + return null; + } + + if (!in_array(ModuleInstanceInterface::class, class_implements($className))) { + $this->logger->error('Module class does not implement ModuleInstanceInterface', [ + 'class' => $className + ]); + return null; + } + + try { + $module = $this->moduleLoad($className); + } catch (Exception $e) { + $this->logger->error('Failed to lazily create module instance', [ + 'handle' => $handle, + 'namespace' => $namespace, + 'exception' => $e->getMessage() + ]); + return null; + } + + // Cache by handle + if ($module) { + $this->moduleInstances[$handle] = $module; + } + + return $module; + } + + private function moduleLoad(string $className): ?ModuleInstanceInterface + { + try { + // Use reflection to check constructor requirements + $reflectionClass = new ReflectionClass($className); + $constructor = $reflectionClass->getConstructor(); + + if (!$constructor || $constructor->getNumberOfRequiredParameters() === 0) { + // Simple instantiation for modules without dependencies + return new $className(); + } + + // For modules with dependencies, try to resolve them from the container + $parameters = $constructor->getParameters(); + $args = []; + + foreach ($parameters as $parameter) { + $type = $parameter->getType(); + if ($type && !$type->isBuiltin()) { + $typeName = $type->getName(); + + // Try to get service from container + if ($this->container->has($typeName)) { + $args[] = $this->container->get($typeName); + } elseif ($parameter->isDefaultValueAvailable()) { + $args[] = $parameter->getDefaultValue(); + } else { + // Cannot resolve dependency + $this->logger->warning('Cannot resolve dependency for module: ' . $className, [ + 'dependency' => $typeName + ]); + return null; + } + } elseif ($parameter->isDefaultValueAvailable()) { + $args[] = $parameter->getDefaultValue(); + } else { + // Cannot resolve primitive dependency + return null; + } + } + + return $reflectionClass->newInstanceArgs($args); + + } catch (Exception $e) { + $this->logger->error('Failed to instantiate module: ' . $className, [ + 'exception' => $e->getMessage() + ]); + return null; + } + } + + /** + * Load a module's vendor autoloader if it has dependencies + * + * @param string $handle Module handle + * @throws Exception If module has dependencies but vendor directory is missing + */ + private function loadModuleVendor(string $handle): void + { + $moduleDir = $this->serverRoot . '/modules/' . $handle; + $composerJson = $moduleDir . '/composer.json'; + $vendorAutoload = $moduleDir . '/lib/vendor/autoload.php'; + + // Check if module has a composer.json with dependencies + if (file_exists($composerJson)) { + $composerData = json_decode(file_get_contents($composerJson), true); + $hasDependencies = !empty($composerData['require']) && count($composerData['require']) > 1; // More than just PHP + + if ($hasDependencies) { + if (file_exists($vendorAutoload)) { + require_once $vendorAutoload; + $this->logger->debug("Loaded vendor autoloader for module: {$handle}"); + } else { + throw new Exception( + "Module '{$handle}' declares dependencies in composer.json but vendor directory is missing. " + . "Run 'composer install' in {$moduleDir}" + ); + } + } + } + } + + private function studly(string $value): string + { + $value = str_replace(['-', '_'], ' ', strtolower($value)); + $value = ucwords($value); + return str_replace(' ', '', $value); + } + + /** + * Extract the PHP namespace from a Module.php file by parsing its contents + * + * @param string $moduleFilePath Absolute path to the Module.php file (located at /modules/{handle}/lib/Module.php) + * @return string|null The namespace segment (e.g., 'ContactsManager' from 'KTXM\ContactsManager') + */ + private function extractNamespaceFromFile(string $moduleFilePath): ?string + { + if (!file_exists($moduleFilePath)) { + return null; + } + + $content = file_get_contents($moduleFilePath); + if ($content === false) { + return null; + } + + // Match namespace declaration: namespace KTXM\; + if (preg_match('/^\s*namespace\s+KTXM\\\\([a-zA-Z0-9_]+)\s*;/m', $content, $matches)) { + return $matches[1]; + } + + return null; + } + + /** + * Get all available permissions from all modules + * + * @return array Grouped permissions with metadata + */ + public function availablePermissions(): array + { + $permissions = []; + + foreach ($this->list() as $module) { + $modulePermissions = $module->permissions(); + + foreach ($modulePermissions as $permission => $meta) { + $permissions[$permission] = array_merge($meta, [ + 'module' => $module->handle() + ]); + } + } + + // Group by category + $grouped = []; + foreach ($permissions as $permission => $meta) { + $group = $meta['group'] ?? 'Other'; + + if (!isset($grouped[$group])) { + $grouped[$group] = []; + } + + $grouped[$group][$permission] = $meta; + } + + // Sort groups alphabetically + ksort($grouped); + + return $grouped; + } + + /** + * Validate if a permission exists + */ + public function permissionExists(string $permission): bool + { + foreach ($this->list() as $module) { + $modulePermissions = $module->permissions(); + + // Exact match + if (isset($modulePermissions[$permission])) { + return true; + } + + // Wildcard match (e.g., user_manager.users.create matches user_manager.users.*) + foreach (array_keys($modulePermissions) as $registered) { + if (str_ends_with($registered, '.*')) { + $prefix = substr($registered, 0, -2); + if (str_starts_with($permission, $prefix . '.')) { + return true; + } + } + } + } + + return false; + } + /** + * Get the core module instance + */ + private function coreModule(): ?\KTXF\Module\ModuleInstanceInterface + { + if (isset($this->moduleInstances['core'])) { + return $this->moduleInstances['core']; + } + + try { + $coreModuleClass = \KTXC\Module\Module::class; + if (!class_exists($coreModuleClass)) { + return null; + } + + $instance = $this->container->get($coreModuleClass); + $this->moduleInstances['core'] = $instance; + return $instance; + } catch (\Throwable $e) { + $this->logger->error('Failed to load core module', [ + 'error' => $e->getMessage() + ]); + return null; + } + } +} diff --git a/core/lib/Module/ModuleObject.php b/core/lib/Module/ModuleObject.php new file mode 100644 index 0000000..7257a77 --- /dev/null +++ b/core/lib/Module/ModuleObject.php @@ -0,0 +1,179 @@ +instance = $instance; + $this->entry = $entry; + } + + // ===== Serialization ===== + + public function jsonSerialize(): array + { + return [ + 'id' => $this->id(), + 'handle' => $this->handle(), + 'version' => $this->version(), + 'namespace' => $this->namespace(), + 'installed' => $this->installed(), + 'enabled' => $this->enabled(), + 'needsUpgrade' => $this->needsUpgrade(), + ]; + } + + // ===== State from ModuleEntry (database) ===== + + public function id(): ?string + { + return $this->entry?->getId(); + } + + public function installed(): bool + { + return $this->entry?->getInstalled() ?? false; + } + + public function enabled(): bool + { + return $this->entry?->getEnabled() ?? false; + } + + // ===== Information from ModuleInterface (filesystem) ===== + + public function handle(): string + { + if ($this->instance) { + return $this->instance->handle(); + } + if ($this->entry) { + return $this->entry->getHandle(); + } + throw new \RuntimeException('Module has neither instance nor entry'); + } + + public function namespace(): ?string + { + if ($this->entry) { + return $this->entry->getNamespace(); + } + if ($this->instance) { + // Extract namespace from class name + $className = get_class($this->instance); + $parts = explode('\\', $className); + if (count($parts) >= 2 && $parts[0] === 'KTXM') { + return $parts[1]; + } + } + return null; + } + + public function version(): string + { + // Prefer current version from filesystem + if ($this->instance) { + return $this->instance->version(); + } + // Fallback to stored version + if ($this->entry) { + return $this->entry->getVersion(); + } + return '0.0.0'; + } + + public function permissions(): array + { + return $this->instance?->permissions() ?? []; + } + + // ===== Computed properties ===== + + public function needsUpgrade(): bool + { + if (!$this->instance || !$this->entry || !$this->installed()) { + return false; + } + $currentVersion = $this->instance->version(); + $storedVersion = $this->entry->getVersion(); + return version_compare($currentVersion, $storedVersion, '>'); + } + + // ===== Access to underlying objects ===== + + public function instance(): ?ModuleInstanceInterface + { + return $this->instance; + } + + public function entry(): ?ModuleEntry + { + return $this->entry; + } + + // ===== Lifecycle methods (delegate to instance) ===== + + public function boot(): void + { + $this->instance?->boot(); + } + + public function install(): void + { + $this->instance?->install(); + } + + public function uninstall(): void + { + $this->instance?->uninstall(); + } + + public function enable(): void + { + $this->instance?->enable(); + } + + public function disable(): void + { + $this->instance?->disable(); + } + + public function upgrade(): void + { + $this->instance?->upgrade(); + } + + public function registerBI(): array | null + { + if ($this->instance instanceof ModuleBrowserInterface) { + return $this->instance->registerBI(); + } + return null; + } + + public function registerCI(): array | null + { + if ($this->instance instanceof ModuleConsoleInterface) { + return $this->instance->registerCI(); + } + return null; + } + +} diff --git a/core/lib/Module/Store/ModuleEntry.php b/core/lib/Module/Store/ModuleEntry.php new file mode 100644 index 0000000..04b9a2e --- /dev/null +++ b/core/lib/Module/Store/ModuleEntry.php @@ -0,0 +1,119 @@ +id = $data['_id'] !== null ? (string)$data['_id'] : null; + elseif (array_key_exists('id', $data)) $this->id = $data['id'] !== null ? (string)$data['id'] : null; + if (array_key_exists('namespace', $data)) $this->namespace = $data['namespace'] !== null ? (string)$data['namespace'] : null; + if (array_key_exists('handle', $data)) $this->handle = $data['handle'] !== null ? (string)$data['handle'] : null; + if (array_key_exists('installed', $data)) $this->installed = (bool)$data['installed']; + if (array_key_exists('enabled', $data)) $this->enabled = (bool)$data['enabled']; + if (array_key_exists('version', $data)) $this->version = (string)$data['version']; + + return $this; + } + + /** + * Serialize to JSON-friendly structure. + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'namespace' => $this->namespace, + 'handle' => $this->handle, + 'installed' => $this->installed, + 'enabled' => $this->enabled, + 'version' => $this->version, + ]; + } + + public function getId(): ?string + { + return $this->id; + } + + public function setId(string $value): self + { + $this->id = $value; + return $this; + } + + public function getNamespace(): ?string + { + return $this->namespace; + } + + public function setNamespace(string $value): self + { + $this->namespace = $value; + return $this; + } + + public function getHandle(): ?string + { + return $this->handle; + } + + public function setHandle(string $value): self + { + $this->handle = $value; + return $this; + } + + public function getInstalled(): bool + { + return $this->installed; + } + + public function setInstalled(bool $value): self + { + $this->installed = $value; + return $this; + } + + public function getEnabled(): bool + { + return $this->enabled; + } + + public function setEnabled(bool $value): self + { + $this->enabled = $value; + return $this; + } + + public function getVersion(): string + { + return $this->version; + } + + public function setVersion(string $value): self + { + $this->version = $value; + return $this; + } +} diff --git a/core/lib/Module/Store/ModuleStore.php b/core/lib/Module/Store/ModuleStore.php new file mode 100644 index 0000000..749cf3a --- /dev/null +++ b/core/lib/Module/Store/ModuleStore.php @@ -0,0 +1,66 @@ +dataStore->selectCollection(self::COLLECTION_NAME)->find(['enabled' => true, 'installed' => true]); + $modules = []; + foreach ($cursor as $entry) { + $entity = new ModuleEntry(); + $entity->jsonDeserialize((array)$entry); + $modules[$entity->getId()] = $entity; + } + return $modules; + } + + public function fetch(string $handle): ?ModuleEntry + { + $entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(['handle' => $handle]); + if (!$entry) { return null; } + return (new ModuleEntry())->jsonDeserialize((array)$entry); + } + + public function deposit(ModuleEntry $entry): ?ModuleEntry + { + if ($entry->getId()) { + return $this->update($entry); + } else { + return $this->create($entry); + } + } + + private function create(ModuleEntry $entry): ?ModuleEntry + { + $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($entry->jsonSerialize()); + $entry->setId((string)$result->getInsertedId()); + return $entry; + } + + private function update(ModuleEntry $entry): ?ModuleEntry + { + $id = $entry->getId(); + if (!$id) { return null; } + $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]); + return $entry; + } + + public function destroy(ModuleEntry $entry): void + { + $id = $entry->getId(); + if (!$id) { return; } + $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]); + } + +} diff --git a/core/lib/Resource/ProviderManager.php b/core/lib/Resource/ProviderManager.php new file mode 100644 index 0000000..ed4e08b --- /dev/null +++ b/core/lib/Resource/ProviderManager.php @@ -0,0 +1,89 @@ +registeredProviders[$type][$identifier] = $class; + } + + /** + * Unregister a provider + */ + public function unregister(string $type, string $identifier): void + { + unset($this->registeredProviders[$type][$identifier]); + unset($this->resolvedProviders[$type][$identifier]); + } + + /** + * Resolve a provider by ID + */ + public function resolve(string $type, string $identifier): ?ProviderInterface + { + if (isset($this->resolvedProviders[$type][$identifier])) { + return $this->resolvedProviders[$type][$identifier]; + } + + if (!isset($this->registeredProviders[$type][$identifier])) { + return null; + } + + try { + $provider = $this->container->get($this->registeredProviders[$type][$identifier]); + $this->resolvedProviders[$type][$identifier] = $provider; + return $provider; + } catch (\Exception $e) { + error_log("Failed to resolve provider {$identifier}: " . $e->getMessage()); + return null; + } + } + + /** + * Resolve multiple providers + * + * @param array|null $filter Optional list of provider IDs to return + * @return array + */ + public function providers(string $type, ?array $filter = null): array + { + $requestedProviders = $filter ?? array_keys($this->registeredProviders[$type] ?? []); + $result = []; + + foreach ($requestedProviders as $identifier) { + $provider = $this->resolve($type, $identifier); + if ($provider !== null) { + $result[$identifier] = $provider; + } + } + + return $result; + } + +} diff --git a/core/lib/Routing/Route.php b/core/lib/Routing/Route.php new file mode 100644 index 0000000..877aeae --- /dev/null +++ b/core/lib/Routing/Route.php @@ -0,0 +1,30 @@ + Route parameters extracted from path */ + public array $params = []; + + public function __construct( + public readonly string $name, + public readonly string $method, + public readonly string $path, + public readonly bool $authenticated, + public readonly string $className, + public readonly string $classMethodName, + public readonly array $classMethodParameters = [], + public readonly array $permissions = [], + ) {} + + public function withParams(array $params): self + { + $clone = clone $this; + $clone->params = $params; + return $clone; + } +} diff --git a/core/lib/Routing/Router.php b/core/lib/Routing/Router.php new file mode 100644 index 0000000..7bdddfc --- /dev/null +++ b/core/lib/Routing/Router.php @@ -0,0 +1,244 @@ +> */ + private array $routes = []; // [method][path] => Route + private bool $initialized = false; + private string $cacheFile; + + public function __construct( + private readonly LoggerInterface $logger, + private readonly ModuleManager $moduleManager, + Container $container, + #[Inject('rootDir')] private readonly string $rootDir, + #[Inject('moduleDir')] private readonly string $moduleDir, + #[Inject('environment')] private readonly string $environment + ) + { + $this->container = $container; + $this->cacheFile = $rootDir . '/var/cache/routes.cache.php'; + } + + private function initialize(): void + { + // load cached routes in production + if ($this->environment === 'prod' && file_exists($this->cacheFile)) { + $data = include $this->cacheFile; + if (is_array($data)) { + $this->routes = $data; + $this->initialized = true; + return; + } + } + // otherwise scan for routes + $this->scan(); + $this->initialized = true; + // write cache + $dir = dirname($this->cacheFile); + if (!is_dir($dir)) @mkdir($dir, 0775, true); + file_put_contents($this->cacheFile, 'routes, true) . ';'); + } + + + private function scan(): void + { + // load core controllers + foreach (glob($this->rootDir . '/core/lib/Controllers/*.php') as $file) { + $this->extract($file); + } + + // load module controllers + foreach ($this->moduleManager->list(true, true) as $module) { + $path = $this->moduleDir . '/' . $module->handle() . '/lib/Controllers'; + if (is_dir($path)) { + foreach (glob($path . '/*.php') as $file) { + $this->extract($file, '/m/' . $module->handle()); + } + } + } + } + + private function extract(string $file, string $routePrefix = ''): void + { + $contents = file_get_contents($file); + if ($contents === false) return; + // extract namespace + if (!preg_match('#namespace\\s+([^;]+);#', $contents, $nsM)) return; + $ns = trim($nsM[1]); + // extract class names + if (!preg_match_all('#class\\s+(\\w+)#', $contents, $cM)) return; + foreach ($cM[1] as $class) { + $fqcn = $ns . '\\' . $class; + try { + if (!class_exists($fqcn)) { + continue; + } + require_once $file; + $reflectionClass = new ReflectionClass($fqcn); + if ($reflectionClass->isAbstract()) continue; + foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectionMethod) { + $attributes = array_merge( + $reflectionMethod->getAttributes(AnonymousRoute::class), + $reflectionMethod->getAttributes(AuthenticatedRoute::class) + ); + foreach ($attributes as $attribute) { + $route = $attribute->newInstance(); + $httpPath = $routePrefix . $route->path; + foreach ($route->methods as $httpMethod) { + $this->routes[$httpMethod][$httpPath] = new Route( + method: $httpMethod, + path: $httpPath, + name: $route->name, + authenticated: $route instanceof AuthenticatedRoute, + className: $reflectionClass->getName(), + classMethodName: $reflectionMethod->getName(), + classMethodParameters: $reflectionMethod->getParameters(), + permissions: $route instanceof AuthenticatedRoute ? $route->permissions : [], + ); + } + } + } + } catch (\Throwable $e) { + $this->logger->error('Route collection failed', ['file' => $file, 'error' => $e->getMessage()]); + } + } + } + + /** + * Match a Request to a Route, or return null if no match. + * Supports exact matches and simple {param} patterns. + * Prioritizes: 1) exact matches, 2) specific patterns, 3) catch-all patterns + */ + public function match(Request $request): ?Route + { + if (!$this->initialized) { + $this->initialize(); + } + $method = $request->getMethod(); + $path = $request->getPathInfo(); + // Exact match first + if (isset($this->routes[$method][$path])) { + return $this->routes[$method][$path]; + } + // Pattern matching - separate catch-all from specific patterns + $specificPatterns = []; + $catchAllPattern = null; + foreach ($this->routes[$method] ?? [] as $routePath => $routeObj) { + if (str_contains($routePath, '{')) { + // Check if this is a catch-all pattern (e.g., /{path}) + if (preg_match('#^/\{[^/]+\}$#', $routePath)) { + $catchAllPattern = [$routePath, $routeObj]; + } else { + $specificPatterns[] = [$routePath, $routeObj]; + } + } + } + // Try specific patterns first + foreach ($specificPatterns as [$routePath, $routeObj]) { + $pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $routePath); + $pattern = '#^' . $pattern . '$#'; + if (preg_match($pattern, $path, $m)) { + $params = []; + foreach ($m as $k => $v) { + if (is_string($k)) { $params[$k] = $v; } + } + return $routeObj->withParams($params); + } + } + // Try catch-all pattern last + if ($catchAllPattern !== null) { + [$routePath, $routeObj] = $catchAllPattern; + $pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>.*)', $routePath); + $pattern = '#^' . $pattern . '$#'; + if (preg_match($pattern, $path, $m)) { + $params = []; + foreach ($m as $k => $v) { + if (is_string($k)) { $params[$k] = $v; } + } + return $routeObj->withParams($params); + } + } + return null; + } + + /** + * Dispatch a matched route meta and return a Response (or null if controller does not return one). + * Performs light argument resolution: Request object, route params, body fields, full body for array params. + */ + public function dispatch(Route $route, Request $request): ?Response + { + // extract controller and method + $routeControllerName = $route->className; + $routeControllerMethod = $route->classMethodName; + $routeControllerParameters = $route->classMethodParameters; + // instantiate controller + if ($this->container->has($routeControllerName)) { + $instance = $this->container->get($routeControllerName); + } else { + $instance = new $routeControllerName(); + } + try { + $requestParameters = $request->getPayload(); + } catch (\Throwable) { + // ignore payload errors + } + $reflectionMethod = new \ReflectionMethod($routeControllerName, $routeControllerMethod); + $routeParams = $route->params ?? []; + $callArgs = []; + foreach ($reflectionMethod->getParameters() as $reflectionParameter) { + $reflectionParameterName = $reflectionParameter->getName(); + $reflectionParameterType = $reflectionParameter->getType(); + // if parameter matches request class, use current request + if ($reflectionParameterType && $reflectionParameterType instanceof \ReflectionNamedType && !$reflectionParameterType->isBuiltin() && is_a($reflectionParameterType->getName(), Request::class, true)) { + $callArgs[] = $request; + continue; + } + // if method parameter matches a route path param, use that (highest priority) + if (array_key_exists($reflectionParameterName, $routeParams)) { + $callArgs[] = $routeParams[$reflectionParameterName]; + continue; + } + // if method parameter matches a request param, use that + if ($requestParameters->has($reflectionParameterName)) { + // if parameter is a class implementing JsonDeserializable, call jsonDeserialize on it + if ($reflectionParameterType && $reflectionParameterType instanceof \ReflectionNamedType && !$reflectionParameterType->isBuiltin() && is_a($reflectionParameterType->getName(), JsonDeserializable::class, true)) { + $type = $reflectionParameterType->getName(); + $object = new $type(); + if ($object instanceof JsonDeserializable) { + $object->jsonDeserialize($requestParameters->get($reflectionParameterName)); + $callArgs[] = $object; + continue; + } + } + // otherwise, use the raw value + $callArgs[] = $requestParameters->get($reflectionParameterName); + continue; + } + // if method parameter did not match, but has a default value, use that + if ($reflectionParameter->isDefaultValueAvailable()) { + $callArgs[] = $reflectionParameter->getDefaultValue(); + continue; + } + $callArgs[] = null; + } + $result = $instance->$routeControllerMethod(...$callArgs); + return $result instanceof Response ? $result : null; + } + +} diff --git a/core/lib/Security/Authentication/AuthenticationRequest.php b/core/lib/Security/Authentication/AuthenticationRequest.php new file mode 100644 index 0000000..040c3e1 --- /dev/null +++ b/core/lib/Security/Authentication/AuthenticationRequest.php @@ -0,0 +1,180 @@ + $allDevices], + ); + } +} diff --git a/core/lib/Security/Authentication/AuthenticationResponse.php b/core/lib/Security/Authentication/AuthenticationResponse.php new file mode 100644 index 0000000..63a8074 --- /dev/null +++ b/core/lib/Security/Authentication/AuthenticationResponse.php @@ -0,0 +1,272 @@ + $identity] : null, + ); + } + + // ========================================================================= + // Status Checks + // ========================================================================= + + public function isSuccess(): bool + { + return $this->status === self::STATUS_SUCCESS; + } + + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + public function isRedirect(): bool + { + return $this->status === self::STATUS_REDIRECT; + } + + public function isFailed(): bool + { + return $this->status === self::STATUS_FAILED; + } + + public function hasTokens(): bool + { + return $this->tokens !== null && !empty($this->tokens); + } + + // ========================================================================= + // Serialization + // ========================================================================= + + /** + * Convert to array for JSON response + */ + public function toArray(): array + { + $result = ['status' => $this->status]; + + if ($this->sessionId !== null) { + $result['session'] = $this->sessionId; + } + + if ($this->sessionState !== null) { + $result['state'] = $this->sessionState; + } + + if ($this->user !== null) { + $result['user'] = $this->user; + } + + if ($this->methods !== null) { + $result['methods'] = $this->methods; + } + + if ($this->challenge !== null) { + $result['challenge'] = $this->challenge; + } + + if ($this->redirectUrl !== null) { + $result['redirect_url'] = $this->redirectUrl; + } + + if ($this->returnUrl !== null) { + $result['return_url'] = $this->returnUrl; + } + + if ($this->errorCode !== null) { + $result['error_code'] = $this->errorCode; + } + + if ($this->errorMessage !== null) { + $result['error'] = $this->errorMessage; + } + + return $result; + } +} diff --git a/core/lib/Security/AuthenticationManager.php b/core/lib/Security/AuthenticationManager.php new file mode 100644 index 0000000..89dbb16 --- /dev/null +++ b/core/lib/Security/AuthenticationManager.php @@ -0,0 +1,806 @@ +securityCode = $this->tenant->configuration()->security()->code(); + } + + // ========================================================================= + // Main Entry Point + // ========================================================================= + + /** + * Handle an authentication request + */ + public function handle(AuthenticationRequest $request): AuthenticationResponse + { + return match ($request->action) { + AuthenticationRequest::ACTION_START => $this->handleStart(), + AuthenticationRequest::ACTION_IDENTIFY => $this->handleIdentify($request), + AuthenticationRequest::ACTION_VERIFY => $this->handleVerify($request), + AuthenticationRequest::ACTION_CHALLENGE => $this->handleChallenge($request), + AuthenticationRequest::ACTION_REDIRECT => $this->handleRedirect($request), + AuthenticationRequest::ACTION_CALLBACK => $this->handleCallback($request), + AuthenticationRequest::ACTION_STATUS => $this->handleStatus($request), + AuthenticationRequest::ACTION_CANCEL => $this->handleCancel($request), + AuthenticationRequest::ACTION_REFRESH => $this->handleRefresh($request), + AuthenticationRequest::ACTION_LOGOUT => $this->handleLogout($request), + default => AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_REQUEST, + 'Unknown action', + 400 + ), + }; + } + + // ========================================================================= + // Action Handlers + // ========================================================================= + + /** + * Start a new authentication session + */ + private function handleStart(): AuthenticationResponse + { + $methods = $this->methodsConfigured(); + + $session = AuthenticationSession::create( + $this->tenant->identifier(), + AuthenticationSession::STATE_FRESH + ); + + $this->saveSession($session); + + return AuthenticationResponse::started($session->id, $methods); + } + + /** + * Identify user (identity-first flow) + */ + private function handleIdentify(AuthenticationRequest $request): AuthenticationResponse + { + $session = $this->retrieveSession($request->sessionId); + + if ($session === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_SESSION_EXPIRED, + 'Invalid or expired session', + 401 + ); + } + + // Return all tenant methods to prevent enumeration + // Filter to non-redirect methods since redirects don't need identity first + $methods = $this->methodsConfigured(); + $methods = array_values(array_filter($methods, fn($m) => $m['method'] !== 'redirect')); + $require = $this->tenant->configuration()->authentication()->methodsMinimal(); + + // Store identity in session without validating to prevent enumeration + $session->setMethods(array_column($methods, 'id'), $require); + $session->setIdentity($request->identity); + $this->saveSession($session); + + return AuthenticationResponse::identified($session->id, $session->state(), $methods); + } + + /** + * Verify credentials or challenge response + */ + private function handleVerify(AuthenticationRequest $request): AuthenticationResponse + { + $session = $this->retrieveSession($request->sessionId); + + if ($session === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_SESSION_EXPIRED, + 'Invalid or expired session', + 401 + ); + } + + if (empty($session->userIdentity)) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_SESSION, + 'Identity is required', + 400 + ); + } + + $method = $request->method; + + if (!$session->methodEligible($method)) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_REQUEST, + 'Method not available', + 400 + ); + } + + $provider = $this->providerManager->resolve('authentication', $method); + if (!$provider instanceof AuthenticationProviderInterface) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_PROVIDER, + 'Provider not available', + 400 + ); + } + + // Build provider context + $context = $this->buildProviderContext($session, $method); + + // Call appropriate provider method based on provider type + $providerMethod = $provider->method(); + + if ($providerMethod === AuthenticationProviderInterface::METHOD_CREDENTIAL) { + $result = $provider->verify($context, $request->secret); + } elseif ($providerMethod === AuthenticationProviderInterface::METHOD_CHALLENGE) { + $result = $provider->verifyChallenge($context, $request->secret); + } else { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_PROVIDER, + 'Provider cannot be used for direct verification', + 400 + ); + } + + // Store any session data from provider + if (!empty($result->sessionData)) { + $session->setMeta("provider:{$method}", $result->sessionData); + } + + if (!$result->isSuccess()) { + $this->saveSession($session); + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_CREDENTIALS, + 'Authentication failed. If you haven\'t set up this method, try another option.', + 401 + ); + } + + // Resolve user if not yet set + if ($session->userIdentifier === null) { + $user = $this->userService->fetchByIdentity($session->userIdentity); + if ($user === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_USER_NOT_FOUND, + 'User not found', + 401 + ); + } + $session->userIdentifier = $user->getId(); + } + + // Mark method complete + $session->methodCompleted($method); + $this->saveSession($session); + + // Check if all required factors are complete + if ($session->state() !== AuthenticationSession::STATE_COMPLETE) { + $remainingMethods = $this->methodsConfigured($session->methodsCompleted); + // Filter out redirect methods - they can't be used as secondary factors + $remainingMethods = array_values(array_filter( + $remainingMethods, + fn($m) => $m['method'] !== 'redirect' + )); + return AuthenticationResponse::pending($session->id, $remainingMethods); + } + + // Authentication complete - issue tokens + return $this->completeAuthentication($session); + } + + /** + * Begin a challenge (SMS, email, TOTP preparation) + */ + private function handleChallenge(AuthenticationRequest $request): AuthenticationResponse + { + $session = $this->retrieveSession($request->sessionId); + + if ($session === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_SESSION_EXPIRED, + 'Invalid or expired session', + 401 + ); + } + + $method = $request->method; + + // Resolve user identifier if needed + if ($session->userIdentifier === null && $session->userIdentity) { + $user = $this->userService->fetchByIdentity($session->userIdentity); + if ($user) { + $session->userIdentifier = $user->getId(); + $this->saveSession($session); + } + } + + $provider = $this->providerManager->resolve('authentication', $method); + if (!$provider instanceof AuthenticationProviderInterface) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_PROVIDER, + 'Provider not available', + 400 + ); + } + + $context = $this->buildProviderContext($session, $method); + $result = $provider->beginChallenge($context); + + // Store any session data from provider + if (!empty($result->sessionData)) { + $session->setMeta("provider:{$method}", $result->sessionData); + $this->saveSession($session); + } + + if ($result->isChallenge()) { + return AuthenticationResponse::challenge( + $session->id, + $result->getClientData('challenge', []) + ); + } + + if ($result->isFailed()) { + // Generic error to prevent enumeration + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_CREDENTIALS, + 'Authentication failed. If you haven\'t set up this method, try another option.', + 401 + ); + } + + // Unexpected result + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INTERNAL, + 'Unexpected provider response', + 500 + ); + } + + /** + * Begin redirect-based authentication (OIDC/SAML) + */ + private function handleRedirect(AuthenticationRequest $request): AuthenticationResponse + { + $session = $this->retrieveSession($request->sessionId); + + if ($session === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_SESSION_EXPIRED, + 'Invalid or expired session', + 401 + ); + } + + $method = $request->method; + + $provider = $this->providerManager->resolve('authentication', $method); + if (!$provider instanceof AuthenticationProviderInterface) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_PROVIDER, + 'Provider not available', + 400 + ); + } + + if ($provider->method() !== AuthenticationProviderInterface::METHOD_REDIRECT) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_PROVIDER, + 'Provider does not support redirect authentication', + 400 + ); + } + + $context = $this->buildProviderContext($session, $method); + $result = $provider->beginRedirect($context, $request->callbackUrl, $request->returnUrl); + + if ($result->isFailed()) { + return AuthenticationResponse::failed( + $result->errorCode ?? AuthenticationResponse::ERROR_INTERNAL, + $result->errorMessage ?? 'Failed to initiate redirect authentication', + 500 + ); + } + + // Store provider session data (state, nonce, etc.) + $session->setMeta("provider:{$method}", $result->sessionData); + $session->setMeta('redirect_method', $method); + $this->saveSession($session); + + return AuthenticationResponse::redirect( + $session->id, + $result->getClientData('redirect_url') + ); + } + + /** + * Complete redirect-based authentication (callback from IdP) + */ + private function handleCallback(AuthenticationRequest $request): AuthenticationResponse + { + $session = $this->retrieveSession($request->sessionId); + + if ($session === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_SESSION_EXPIRED, + 'Invalid or expired session', + 401 + ); + } + + $method = $request->method; + $expectedMethod = $session->getMeta('redirect_method'); + + if ($expectedMethod !== $method) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_SESSION, + 'Provider mismatch', + 400 + ); + } + + $provider = $this->providerManager->resolve('authentication', $method); + if (!$provider instanceof AuthenticationProviderInterface) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_PROVIDER, + 'Provider not available', + 400 + ); + } + + $context = $this->buildProviderContext($session, $method); + $result = $provider->completeRedirect($context, $request->params); + + if ($result->isFailed()) { + $this->deleteSession($session->id); + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_CREDENTIALS, + $result->errorMessage ?? 'Authentication failed', + 401 + ); + } + + // Provider has already provisioned the user - just get user identifier + $userIdentifier = $result->identity['user_identifier'] ?? null; + + if (!$userIdentifier) { + $this->deleteSession($session->id); + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INTERNAL, + 'User provisioning failed', + 500 + ); + } + + // Load user + $userData = $this->userService->fetchByIdentifier($userIdentifier); + if (!$userData) { + $this->deleteSession($session->id); + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_USER_NOT_FOUND, + 'User not found after provisioning', + 401 + ); + } + + $user = new User(); + $user->populate($userData, 'users'); + + // Set user in session + $session->userIdentifier = $user->getId(); + $session->userIdentity = $user->getIdentity(); + $session->methodCompleted($method); + + // Check if MFA is required + $require = $this->tenant->configuration()->authentication()->methodsMinimal(); + if ($require > 1) { + $remainingMethods = $this->methodsConfigured([$method]); + // Filter out redirect methods - they can't be used as secondary factors + $remainingMethods = array_values(array_filter( + $remainingMethods, + fn($m) => $m['method'] !== 'redirect' + )); + $session->setMethods(array_column($remainingMethods, 'id'), $require); + $this->saveSession($session); + + return AuthenticationResponse::pending($session->id, $remainingMethods); + } + + // Authentication complete + return $this->completeAuthentication($session); + } + + /** + * Get session status + */ + private function handleStatus(AuthenticationRequest $request): AuthenticationResponse + { + $session = $this->retrieveSession($request->sessionId); + + if ($session === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_SESSION_EXPIRED, + 'Session not found or expired', + 404 + ); + } + + $methods = $this->methodsConfigured($session->methodsCompleted); + + return AuthenticationResponse::status( + $session->id, + $session->state(), + $methods, + $session->userIdentity + ); + } + + /** + * Cancel session + */ + private function handleCancel(AuthenticationRequest $request): AuthenticationResponse + { + if ($request->sessionId) { + $this->deleteSession($request->sessionId); + } + + return AuthenticationResponse::cancelled(); + } + + /** + * Refresh access token + */ + private function handleRefresh(AuthenticationRequest $request): AuthenticationResponse + { + $payload = $this->tokenService->validateToken($request->token, $this->securityCode); + + if (!$payload) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_CREDENTIALS, + 'Invalid or expired refresh token', + 401 + ); + } + + if (($payload['type'] ?? null) !== 'refresh') { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_CREDENTIALS, + 'Invalid token type', + 401 + ); + } + + $identifier = $payload['identifier'] ?? null; + $userData = $this->userService->fetchByIdentifier($identifier); + + if ($userData === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_USER_NOT_FOUND, + 'User not found', + 401 + ); + } + + $user = new User(); + $user->populate($userData, 'users'); + + $accessToken = $this->tokenService->createToken( + [ + 'tenant' => $this->tenant->identifier(), + 'identifier' => $user->getId(), + 'identity' => $user->getIdentity(), + 'label' => $user->getLabel(), + 'permissions' => $user->getPermissions(), + 'mfa_verified' => true, + ], + $this->securityCode, + 900 + ); + + return AuthenticationResponse::success( + $this->buildUserData($user), + ['access' => $accessToken] + ); + } + + /** + * Logout + */ + private function handleLogout(AuthenticationRequest $request): AuthenticationResponse + { + $allDevices = $request->params['all_devices'] ?? false; + + if ($request->token) { + $payload = $this->tokenService->validateToken($request->token, $this->securityCode); + + if ($payload) { + if ($allDevices && isset($payload['identity'])) { + $this->tokenService->blacklistUserTokensBefore($payload['identity'], time()); + } elseif (isset($payload['jti'], $payload['exp'])) { + $this->tokenService->blacklist($payload['jti'], $payload['exp']); + } + } + } + + return AuthenticationResponse::cancelled(); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * Build provider context from session + */ + private function buildProviderContext(AuthenticationSession $session, string $method): ProviderContext + { + return new ProviderContext( + tenantId: $session->tenantIdentifier, + userIdentifier: $session->userIdentifier, + userIdentity: $session->userIdentity, + metadata: $session->getMeta("provider:{$method}") ?? [], + config: $this->getProviderConfig($method), + ); + } + + /** + * Get provider configuration + */ + private function getProviderConfig(string $method): array + { + $providers = $this->tenant->configuration()->authentication()->providers(); + return $providers[$method]['config'] ?? []; + } + + /** + * Complete authentication and issue tokens + */ + private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse + { + $userData = $this->userService->fetchByIdentifier($session->userIdentifier); + + if ($userData === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_USER_NOT_FOUND, + 'User not found', + 401 + ); + } + + $user = new User(); + $user->populate($userData, 'users'); + + $tokens = $this->createTokens($user, count($session->methodsCompleted) > 1); + + $this->deleteSession($session->id); + + return AuthenticationResponse::success( + $this->buildUserData($user), + $tokens + ); + } + + /** + * Build user data for response + */ + private function buildUserData(User $user): array + { + return [ + 'identifier' => $user->getId(), + 'identity' => $user->getIdentity(), + 'label' => $user->getLabel(), + 'permissions' => $user->getPermissions(), + ]; + } + + /** + * Get configured authentication methods + */ + private function methodsConfigured(array $methodsCompleted = []): array + { + $tenantProviders = $this->tenant->configuration()->authentication()->providers(); + $methods = []; + + foreach ($tenantProviders as $providerId => $providerConfiguration) { + if (!($providerConfiguration['enabled'] ?? false)) { + continue; + } + + if (in_array($providerId, $methodsCompleted, true)) { + continue; + } + + $provider = $this->providerManager->resolve('authentication', $providerId); + if (!$provider instanceof AuthenticationProviderInterface) { + continue; + } + + $methods[] = [ + 'id' => $providerId, + 'method' => $provider->method(), + 'label' => $providerConfiguration['label'] ?? $provider->label(), + 'icon' => $providerConfiguration['icon'] ?? $provider->icon() ?? null, + ]; + } + + return $methods; + } + + /** + * Create JWT tokens + */ + private function createTokens(User $user, bool $mfaVerified = false): array + { + $payload = [ + 'tenant' => $this->tenant->identifier(), + 'identifier' => $user->getId(), + 'identity' => $user->getIdentity(), + 'label' => $user->getLabel(), + 'permissions' => $user->getPermissions(), + 'mfa_verified' => $mfaVerified, + ]; + + return [ + 'access' => $this->tokenService->createToken($payload, $this->securityCode, 900), + 'refresh' => $this->tokenService->createToken( + [ + 'tenant' => $payload['tenant'], + 'identifier' => $payload['identifier'], + 'identity' => $payload['identity'], + 'type' => 'refresh', + ], + $this->securityCode, + 604800 + ), + ]; + } + + /** + * Find or provision user from external identity + */ + private function findOrProvisionUser( + string $providerId, + array $identity, + array $providerConfig + ): ?User { + $userIdentity = $identity['email'] ?? $identity['identity'] ?? null; + $externalSubject = $identity['subject'] ?? $identity['sub'] ?? null; + $attributes = $identity['attributes'] ?? []; + $attributes['identity'] = $userIdentity; + $attributes['external_subject'] = $externalSubject; + + /* + // Try to find by external subject first + if ($externalSubject) { + $user = $this->provisioningService->findByExternalIdentity($providerId, $externalSubject); + if ($user) { + $this->provisioningService->syncProfile( + $user, + $attributes, + $providerConfig['attribute_map'] ?? [] + ); + return $user; + } + } + + // Try to find by identity + if ($userIdentity) { + $existingUser = $this->userService->fetchByIdentity($userIdentity); + if ($existingUser) { + if ($existingUser->getProvider() === $providerId) { + if ($externalSubject) { + $this->provisioningService->linkExternalIdentity( + $existingUser, + $providerId, + $externalSubject, + $attributes + ); + } + $this->provisioningService->syncProfile( + $existingUser, + $attributes, + $providerConfig['attribute_map'] ?? [] + ); + return $existingUser; + } + return null; + } + } + + // Auto-provision if enabled + if ($this->provisioningService->isAutoProvisioningEnabled($providerId)) { + return $this->provisioningService->provisionUser( + $providerId, + $attributes, + $providerConfig + ); + } + */ + + return null; + } + + // ========================================================================= + // Session Cache Helpers + // ========================================================================= + + /** + * Retrieve authentication session from cache + */ + private function retrieveSession(?string $sessionId): ?AuthenticationSession + { + if (empty($sessionId)) { + return null; + } + + $data = $this->cache->get($sessionId, CacheScope::Tenant, self::CACHE_USAGE); + + if ($data === null) { + return null; + } + + if ($data instanceof AuthenticationSession) { + if ($data->isExpired()) { + $this->deleteSession($sessionId); + return null; + } + return $data; + } + + return null; + } + + /** + * Save authentication session to cache + */ + private function saveSession(AuthenticationSession $session): bool + { + $ttl = $session->expiresAt > 0 ? $session->expiresAt - time() : AuthenticationSession::DEFAULT_TTL; + + return $this->cache->set( + $session->id, + $session, + CacheScope::Tenant, + self::CACHE_USAGE, + max($ttl, 60) + ); + } + + /** + * Delete authentication session from cache + */ + private function deleteSession(string $sessionId): bool + { + return $this->cache->delete($sessionId, CacheScope::Tenant, self::CACHE_USAGE); + } +} diff --git a/core/lib/Security/Authorization/PermissionChecker.php b/core/lib/Security/Authorization/PermissionChecker.php new file mode 100644 index 0000000..690b0f0 --- /dev/null +++ b/core/lib/Security/Authorization/PermissionChecker.php @@ -0,0 +1,124 @@ +sessionIdentity->identity(); + + if (!$identity) { + return false; + } + + // Get user permissions from identity + $userPermissions = $identity->getPermissions() ?? []; + + // Super admin bypass - check for admin role + $roles = $identity->getRoles() ?? []; + if (in_array('admin', $roles) || in_array('system.admin', $roles)) { + return true; + } + + // Exact match + if (in_array($permission, $userPermissions)) { + return true; + } + + // Wildcard match: user_manager.users.* allows user_manager.users.create + foreach ($userPermissions as $userPerm) { + if (str_ends_with($userPerm, '.*')) { + $prefix = substr($userPerm, 0, -2); + if (str_starts_with($permission, $prefix . '.')) { + return true; + } + } + } + + // Full wildcard: * grants all permissions + if (in_array('*', $userPermissions)) { + return true; + } + + return false; + } + + /** + * Check if user has ANY of the permissions (OR logic) + * + * @param array $permissions Array of permissions to check + * @param mixed $resource Optional resource for resource-based permissions + * @return bool + */ + public function canAny(array $permissions, mixed $resource = null): bool + { + if (empty($permissions)) { + return true; // No permissions required + } + + foreach ($permissions as $permission) { + if ($this->can($permission, $resource)) { + return true; + } + } + + return false; + } + + /** + * Check if user has ALL permissions (AND logic) + * + * @param array $permissions Array of permissions to check + * @param mixed $resource Optional resource for resource-based permissions + * @return bool + */ + public function canAll(array $permissions, mixed $resource = null): bool + { + if (empty($permissions)) { + return true; // No permissions required + } + + foreach ($permissions as $permission) { + if (!$this->can($permission, $resource)) { + return false; + } + } + + return true; + } + + /** + * Get all permissions for the current user + * + * @return array + */ + public function getUserPermissions(): array + { + $identity = $this->sessionIdentity->identity(); + + if (!$identity) { + return []; + } + + return $identity->getPermissions() ?? []; + } +} diff --git a/core/lib/Server.php b/core/lib/Server.php new file mode 100644 index 0000000..b302184 --- /dev/null +++ b/core/lib/Server.php @@ -0,0 +1,92 @@ +run(); + } + + /** + * @deprecated Use Application::getInstance()->environment() + */ + public static function environment(): string { + return self::app()->environment(); + } + + /** + * @deprecated Use Application::getInstance()->debug() + */ + public static function debug(): bool { + return self::app()->debug(); + } + + /** + * @deprecated Use Application::getInstance()->kernel() + */ + public static function runtimeKernel(): Kernel { + return self::app()->kernel(); + } + + /** + * @deprecated Use Application::getInstance()->container() + */ + public static function runtimeContainer(): Container { + return self::app()->container(); + } + + /** + * @deprecated Use Application::getInstance()->rootDir() + */ + public static function runtimeRootLocation(): string { + return self::app()->rootDir(); + } + + /** + * @deprecated Use Application::getInstance()->moduleDir() + */ + public static function runtimeModuleLocation(): string { + return self::app()->moduleDir(); + } + + /** + * @deprecated Use Application::setComposerLoader() + */ + public static function setComposerLoader($loader): void { + Application::setComposerLoader($loader); + } + + /** + * @deprecated Use Application::getComposerLoader() + */ + public static function getComposerLoader() { + return Application::getComposerLoader(); + } + + private static function app(): Application + { + throw new \RuntimeException( + 'Server class is deprecated and no longer functional. ' . + 'Update your code to use Application class with proper dependency injection. ' . + 'See the migration guide for details.' + ); + } + +} diff --git a/core/lib/Service/ConfigurationService.php b/core/lib/Service/ConfigurationService.php new file mode 100644 index 0000000..a5ea6b8 --- /dev/null +++ b/core/lib/Service/ConfigurationService.php @@ -0,0 +1,228 @@ +collection = $store->selectCollection(self::TABLE_NAME); + $this->collection->createIndex(['did' => 1, 'path' => 1, 'key' => 1], ['unique' => true]); + } + + /** + * Get a configuration value by path and key + */ + public function get(string $path, string $key, mixed $default = null, ?string $tenant = null): mixed + { + if ($tenant === null && !$this->tenant->isConfigured()) { + throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); + } elseif ($tenant === null) { + $tenant = $this->tenant->identifier(); + } + + $doc = $this->collection->findOne(['did' => $tenant, 'path' => $path, 'key' => $key]); + if (!$doc) { return $default; } + $value = $doc['value'] ?? ($doc['default'] ?? null); + if ($value === null) { return $default; } + return $this->convertFromDatabase((string)$value, (int)$doc['type']); + } + + /** + * Set a configuration value + */ + public function set(string $path, string $key, mixed $value, mixed $default = null, ?string $tenant = null): bool + { + if ($tenant === null && !$this->tenant->isConfigured()) { + throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); + } elseif ($tenant === null) { + $tenant = $this->tenant->identifier(); + } + + $type = $this->determineType($value); + $serializedValue = $this->convertToDatabase($value, $type); + $serializedDefault = $default !== null ? $this->convertToDatabase($default, $type) : null; + $this->collection->updateOne( + ['did' => $tenant, 'path' => $path, 'key' => $key], + ['$set' => [ + 'did' => $tenant, + 'path' => $path, + 'key' => $key, + 'value' => $serializedValue, + 'type' => $type, + 'default' => $serializedDefault, + 'updated_at' => $this->bsonUtcDateTime() + ], '$setOnInsert' => [ 'created_at' => $this->bsonUtcDateTime() ]], + ['upsert' => true] + ); + return true; + } + + /** + * Get all configuration values for a specific path + */ + public function getByPath(?string $path = null, bool $subset = false, ?string $tenant = null): array + { + if ($tenant === null && !$this->tenant->isConfigured()) { + throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); + } elseif ($tenant === null) { + $tenant = $this->tenant->identifier(); + } + + $filter = ['did' => $tenant]; + if ($path !== null) { + if ($subset) { + $filter['$or'] = [ + ['path' => $path], + ['path' => ['$regex' => '^' . preg_quote($path, '/') . '/']] + ]; + } else { + $filter['path'] = $path; + } + } + $cursor = $this->collection->find($filter); + $configurations = []; + foreach ($cursor as $doc) { + $value = $doc['value'] ?? ($doc['default'] ?? null); + $convertedValue = $value !== null ? $this->convertFromDatabase((string)$value, (int)$doc['type']) : null; + $configurations[$doc['path']] = [$doc['key'] => $convertedValue]; + } + return $configurations; + } + + /** + * Delete a configuration value + */ + public function delete(string $path, string $key, ?string $tenant = null): bool + { + if ($tenant === null && !$this->tenant->isConfigured()) { + throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); + } elseif ($tenant === null) { + $tenant = $this->tenant->identifier(); + } + + $this->collection->deleteOne(['did' => $tenant, 'path' => $path, 'key' => $key]); + return true; + } + + /** + * Delete all configuration values for a specific path + */ + public function deleteByPath(string $path, bool $includeSubPaths = false, ?string $tenant = null): bool + { + if ($tenant === null && !$this->tenant->isConfigured()) { + throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); + } elseif ($tenant === null) { + $tenant = $this->tenant->identifier(); + } + + $filter = ['did' => $tenant]; + if ($includeSubPaths) { + $filter['$or'] = [ + ['path' => $path], + ['path' => ['$regex' => '^' . preg_quote($path, '/') . '/']] + ]; + } else { + $filter['path'] = $path; + } + $this->collection->deleteMany($filter); + return true; + } + + /** + * Check if a configuration exists + */ + public function exists(string $path, string $key, ?string $tenant = null): bool + { + if ($tenant === null && !$this->tenant->isConfigured()) { + throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); + } elseif ($tenant === null) { + $tenant = $this->tenant->identifier(); + } + + return $this->collection->countDocuments(['did' => $tenant, 'path' => $path, 'key' => $key]) > 0; + } + + /** + * Determine the type of a PHP value + */ + private function determineType(mixed $value): int + { + return match (true) { + is_null($value) => self::TYPE_NULL, + is_bool($value) => self::TYPE_BOOLEAN, + is_int($value) => self::TYPE_INTEGER, + is_float($value) => self::TYPE_FLOAT, + is_array($value) => self::TYPE_ARRAY, + is_string($value) && $this->isJson($value) => self::TYPE_JSON, + default => self::TYPE_STRING + }; + } + + /** + * Convert a PHP value to database format + */ + private function convertToDatabase(mixed $value, int $type): string + { + return match ($type) { + self::TYPE_NULL => '', + self::TYPE_BOOLEAN => $value ? '1' : '0', + self::TYPE_INTEGER => (string)$value, + self::TYPE_FLOAT => (string)$value, + self::TYPE_ARRAY, self::TYPE_JSON => json_encode($value), + default => (string)$value + }; + } + + /** + * Convert a database value to PHP format + */ + private function convertFromDatabase(string $value, int $type): mixed + { + return match ($type) { + self::TYPE_NULL => null, + self::TYPE_BOOLEAN => $value === '1', + self::TYPE_INTEGER => (int)$value, + self::TYPE_FLOAT => (float)$value, + self::TYPE_ARRAY, self::TYPE_JSON => json_decode($value, true), + default => $value + }; + } + + /** + * Check if a string is valid JSON + */ + private function isJson(string $string): bool + { + json_decode($string); + return json_last_error() === JSON_ERROR_NONE; + } + /** + * Create a UTCDateTime for timestamp fields + */ + private function bsonUtcDateTime(): UTCDateTime + { + return UTCDateTime::now(); + } +} diff --git a/core/lib/Service/FirewallService.php b/core/lib/Service/FirewallService.php new file mode 100644 index 0000000..da872f5 --- /dev/null +++ b/core/lib/Service/FirewallService.php @@ -0,0 +1,630 @@ +eventBus->subscribe( + SecurityEvent::AUTH_FAILURE, + [$this, 'handleAuthFailure'], + 100 // High priority + ); + + // Log all security events asynchronously + $this->eventBus->subscribeAsync( + SecurityEvent::AUTH_FAILURE, + [$this, 'logSecurityEvent'] + ); + $this->eventBus->subscribeAsync( + SecurityEvent::AUTH_SUCCESS, + [$this, 'logSecurityEvent'] + ); + $this->eventBus->subscribeAsync( + SecurityEvent::ACCESS_DENIED, + [$this, 'logSecurityEvent'] + ); + $this->eventBus->subscribeAsync( + SecurityEvent::BRUTE_FORCE_DETECTED, + [$this, 'logSecurityEvent'] + ); + } + + /** + * Check firewall rules for a request + * Returns a Response if blocked, null if allowed + */ + public function authorized(Request $request): bool + { + $ipAddress = $request->getClientIp() ?? '0.0.0.0'; + $deviceFingerprint = $request->headers->get('X-Device-Fingerprint'); + + $result = $this->analyze($ipAddress, $deviceFingerprint); + + if ($result->isBlocked()) { + return false; + } + + return true; + } + + /** + * Check if a request is allowed based on IP and device fingerprint + */ + public function analyze( + string $ipAddress, + ?string $deviceFingerprint = null + ): FirewallAnalyzeResult { + // Check if firewall is enabled for this tenant + if (!$this->isEnabled()) { + return new FirewallAnalyzeResult(true); + } + + $tenantId = $this->tenant->identifier(); + if (!$tenantId) { + return new FirewallAnalyzeResult(true); + } + + $rules = $this->getActiveRules(); + + // First check for explicit allow rules (whitelist takes precedence) + foreach ($rules as $rule) { + if ($rule->getAction() !== FirewallRuleObject::ACTION_ALLOW) { + continue; + } + + if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) { + return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed'); + } + } + + // Then check for block rules + foreach ($rules as $rule) { + if ($rule->getAction() !== FirewallRuleObject::ACTION_BLOCK) { + continue; + } + + if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) { + $this->publishAccessDenied($ipAddress, $deviceFingerprint, $rule); + return new FirewallAnalyzeResult(false, $rule->getId(), $rule->getReason()); + } + } + + return new FirewallAnalyzeResult(true); + } + + /** + * Check if a rule matches the request + */ + private function ruleMatchesRequest( + FirewallRuleObject $rule, + string $ipAddress, + ?string $deviceFingerprint + ): bool { + $type = $rule->getType(); + $value = $rule->getValue(); + + return match ($type) { + FirewallRuleObject::TYPE_IP => $ipAddress === $value, + FirewallRuleObject::TYPE_IP_RANGE => IpUtils::checkIp($ipAddress, $value), + FirewallRuleObject::TYPE_DEVICE => $deviceFingerprint !== null && $deviceFingerprint === $value, + default => false, + }; + } + + /** + * Handle authentication failure event + */ + public function handleAuthFailure(SecurityEvent $event): void + { + $ipAddress = $event->getIpAddress(); + $tenantId = $event->getTenantId() ?? $this->tenant->identifier(); + + if (!$ipAddress || !$tenantId) { + return; + } + + // Check for brute force + $windowSeconds = $this->getConfig( + self::CONFIG_FAILURE_WINDOW, + self::DEFAULT_AUTH_FAILURE_WINDOW + ); + $maxFailures = $this->getConfig( + self::CONFIG_MAX_FAILURES, + self::DEFAULT_MAX_AUTH_FAILURES + ); + + $failureCount = $this->store->countRecentFailures( + $tenantId, + $ipAddress, + $windowSeconds + ); + + // Include current failure in count + $failureCount++; + + if ($failureCount >= $maxFailures) { + $this->handleBruteForce($ipAddress, $failureCount, $windowSeconds); + } + } + + /** + * Handle detected brute force attack + */ + private function handleBruteForce( + string $ipAddress, + int $failureCount, + int $windowSeconds + ): void { + // Publish brute force event + $event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds); + $event->setTenantId($this->tenant->identifier()); + $this->eventBus->publish($event); + + // Auto-block the IP + $blockDuration = $this->getConfig( + self::CONFIG_AUTO_BLOCK_DURATION, + self::DEFAULT_AUTO_BLOCK_DURATION + ); + + $this->blockIp( + $ipAddress, + sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds), + null, // System-created + $blockDuration + ); + } + + /** + * Log security event to firewall logs + */ + public function logSecurityEvent(SecurityEvent $event): void + { + $tenantId = $event->getTenantId() ?? $this->tenant->identifier(); + if (!$tenantId) { + return; + } + + $log = new FirewallLogObject(); + $log->setTenantId($tenantId) + ->setIpAddress($event->getIpAddress()) + ->setDeviceFingerprint($event->getDeviceFingerprint()) + ->setUserAgent($event->getUserAgent()) + ->setRequestPath($event->getRequestPath()) + ->setRequestMethod($event->getRequestMethod()) + ->setEventType($this->mapEventToLogType($event->getName())) + ->setResult($this->mapEventToResult($event->getName())) + ->setIdentityId($event->getUserId()) + ->setTimestamp(new \DateTimeImmutable()) + ->setMetadata($event->getData()); + + $this->store->createLog($log); + } + + /** + * Map security event name to log event type + */ + private function mapEventToLogType(string $eventName): string + { + return match ($eventName) { + SecurityEvent::AUTH_FAILURE => FirewallLogObject::EVENT_AUTH_FAILURE, + SecurityEvent::AUTH_SUCCESS => FirewallLogObject::EVENT_ACCESS_CHECK, + SecurityEvent::BRUTE_FORCE_DETECTED => FirewallLogObject::EVENT_BRUTE_FORCE, + SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT, + SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH, + SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS, + default => FirewallLogObject::EVENT_ACCESS_CHECK, + }; + } + + /** + * Map security event to result + */ + private function mapEventToResult(string $eventName): string + { + return match ($eventName) { + SecurityEvent::AUTH_SUCCESS, + SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED, + default => FirewallLogObject::RESULT_BLOCKED, + }; + } + + /** + * Publish access denied event + */ + private function publishAccessDenied( + string $ipAddress, + ?string $deviceFingerprint, + FirewallRuleObject $rule + ): void { + $event = SecurityEvent::accessDenied( + $ipAddress, + $deviceFingerprint, + $rule->getId(), + $rule->getReason() + ); + $event->setTenantId($this->tenant->identifier()); + $this->eventBus->publish($event); + } + + // ======================================== + // Rule Management + // ======================================== + + /** + * Block an IP address + */ + public function blockIp( + string $ipAddress, + ?string $reason = null, + ?string $createdBy = null, + ?int $durationSeconds = null + ): FirewallRuleObject { + $tenantId = $this->tenant->identifier(); + if (!$tenantId) { + throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); + } + + // Check if already blocked + $existing = $this->store->findExactIpRule( + $tenantId, + $ipAddress, + FirewallRuleObject::ACTION_BLOCK + ); + + if ($existing) { + return $existing; + } + + $rule = new FirewallRuleObject(); + $rule->setTenantId($tenantId) + ->setType(FirewallRuleObject::TYPE_IP) + ->setAction(FirewallRuleObject::ACTION_BLOCK) + ->setValue($ipAddress) + ->setReason($reason ?? 'Blocked by administrator') + ->setCreatedBy($createdBy) + ->setCreatedAt(new \DateTimeImmutable()) + ->setEnabled(true); + + if ($durationSeconds !== null) { + $rule->setExpiresAt( + (new \DateTimeImmutable())->modify("+{$durationSeconds} seconds") + ); + } + + $this->store->depositRule($rule); + $this->clearRulesCache(); + + // Publish event + $event = new SecurityEvent(SecurityEvent::IP_BLOCKED, ['ip' => $ipAddress, 'reason' => $reason]); + $event->setIpAddress($ipAddress) + ->setReason($reason) + ->setTenantId($tenantId); + $this->eventBus->publish($event); + + return $rule; + } + + /** + * Allow an IP address (whitelist) + */ + public function allowIp( + string $ipAddress, + ?string $reason = null, + ?string $createdBy = null + ): FirewallRuleObject { + $tenantId = $this->tenant->identifier(); + if (!$tenantId) { + throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); + } + + $rule = new FirewallRuleObject(); + $rule->setTenantId($tenantId) + ->setType(FirewallRuleObject::TYPE_IP) + ->setAction(FirewallRuleObject::ACTION_ALLOW) + ->setValue($ipAddress) + ->setReason($reason ?? 'Allowed by administrator') + ->setCreatedBy($createdBy) + ->setCreatedAt(new \DateTimeImmutable()) + ->setEnabled(true); + + $this->store->depositRule($rule); + $this->clearRulesCache(); + + // Publish event + $event = new SecurityEvent(SecurityEvent::IP_ALLOWED, ['ip' => $ipAddress, 'reason' => $reason]); + $event->setIpAddress($ipAddress) + ->setReason($reason) + ->setTenantId($tenantId); + $this->eventBus->publish($event); + + return $rule; + } + + /** + * Block an IP range (CIDR notation) + */ + public function blockIpRange( + string $cidr, + ?string $reason = null, + ?string $createdBy = null + ): FirewallRuleObject { + $tenantId = $this->tenant->identifier(); + if (!$tenantId) { + throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); + } + + $rule = new FirewallRuleObject(); + $rule->setTenantId($tenantId) + ->setType(FirewallRuleObject::TYPE_IP_RANGE) + ->setAction(FirewallRuleObject::ACTION_BLOCK) + ->setValue($cidr) + ->setReason($reason ?? 'Range blocked by administrator') + ->setCreatedBy($createdBy) + ->setCreatedAt(new \DateTimeImmutable()) + ->setEnabled(true); + + $this->store->depositRule($rule); + $this->clearRulesCache(); + + return $rule; + } + + /** + * Block a device fingerprint + */ + public function blockDevice( + string $fingerprint, + ?string $reason = null, + ?string $createdBy = null, + ?int $durationSeconds = null + ): FirewallRuleObject { + $tenantId = $this->tenant->identifier(); + if (!$tenantId) { + throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); + } + + $rule = new FirewallRuleObject(); + $rule->setTenantId($tenantId) + ->setType(FirewallRuleObject::TYPE_DEVICE) + ->setAction(FirewallRuleObject::ACTION_BLOCK) + ->setValue($fingerprint) + ->setReason($reason ?? 'Device blocked by administrator') + ->setCreatedBy($createdBy) + ->setCreatedAt(new \DateTimeImmutable()) + ->setEnabled(true); + + if ($durationSeconds !== null) { + $rule->setExpiresAt( + (new \DateTimeImmutable())->modify("+{$durationSeconds} seconds") + ); + } + + $this->store->depositRule($rule); + $this->clearRulesCache(); + + // Publish event + $event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]); + $event->setDeviceFingerprint($fingerprint) + ->setReason($reason) + ->setTenantId($tenantId); + $this->eventBus->publish($event); + + return $rule; + } + + /** + * Remove a rule by ID + */ + public function removeRule(string $ruleId): bool + { + $rule = $this->store->fetchRule($ruleId); + if (!$rule) { + return false; + } + + // Verify tenant ownership + if ($rule->getTenantId() !== $this->tenant->identifier()) { + return false; + } + + $this->store->destroyRule($rule); + $this->clearRulesCache(); + + return true; + } + + /** + * Disable a rule (soft delete) + */ + public function disableRule(string $ruleId): bool + { + $rule = $this->store->fetchRule($ruleId); + if (!$rule) { + return false; + } + + // Verify tenant ownership + if ($rule->getTenantId() !== $this->tenant->identifier()) { + return false; + } + + $rule->setEnabled(false); + $this->store->depositRule($rule); + $this->clearRulesCache(); + + return true; + } + + /** + * Get all rules for current tenant + */ + public function listRules(bool $activeOnly = true): array + { + $tenantId = $this->tenant->identifier(); + if (!$tenantId) { + return []; + } + + return $this->store->listRules($tenantId, $activeOnly); + } + + /** + * Get firewall logs for current tenant + */ + public function getLogs( + ?string $ipAddress = null, + ?string $eventType = null, + ?string $result = null, + int $limit = 100 + ): array { + $tenantId = $this->tenant->identifier(); + if (!$tenantId) { + return []; + } + + return $this->store->listLogs($tenantId, $ipAddress, $eventType, $result, $limit); + } + + /** + * Get blocked requests count + */ + public function getBlockedCount(?\DateTimeImmutable $since = null): int + { + $tenantId = $this->tenant->identifier(); + if (!$tenantId) { + return 0; + } + + return $this->store->countBlockedRequests($tenantId, $since); + } + + // ======================================== + // Helpers + // ======================================== + + /** + * Check if firewall is enabled for current tenant + */ + private function isEnabled(): bool + { + return (bool) $this->getConfig(self::CONFIG_ENABLED, true); + } + + /** + * Get configuration value + */ + private function getConfig(string $key, mixed $default = null): mixed + { + $config = $this->tenant->configuration(); + $parts = explode('.', $key); + + foreach ($parts as $part) { + if (!is_array($config) || !array_key_exists($part, $config)) { + return $default; + } + $config = $config[$part]; + } + + return $config; + } + + /** + * Get active rules (cached) + * @return FirewallRuleObject[] + */ + private function getActiveRules(): array + { + if ($this->rulesCache === null) { + $tenantId = $this->tenant->identifier(); + $this->rulesCache = $tenantId + ? $this->store->listRules($tenantId, true) + : []; + } + return $this->rulesCache; + } + + /** + * Clear rules cache + */ + private function clearRulesCache(): void + { + $this->rulesCache = null; + } + + /** + * Cleanup maintenance tasks + */ + public function cleanup(): array + { + $expiredRules = $this->store->cleanupExpiredRules(); + $oldLogs = $this->store->cleanupOldLogs(30); + + return [ + 'expiredRules' => $expiredRules, + 'oldLogs' => $oldLogs, + ]; + } +} + +/** + * Result of a firewall check + */ +class FirewallAnalyzeResult +{ + public function __construct( + public readonly bool $allowed, + public readonly ?string $ruleId = null, + public readonly ?string $reason = null + ) {} + + public function isAllowed(): bool + { + return $this->allowed; + } + + public function isBlocked(): bool + { + return !$this->allowed; + } +} diff --git a/core/lib/Service/SecurityService.php b/core/lib/Service/SecurityService.php new file mode 100644 index 0000000..beaba8d --- /dev/null +++ b/core/lib/Service/SecurityService.php @@ -0,0 +1,145 @@ +securityCode = $this->sessionTenant->configuration()->security()->code(); + } + + /** + * Authenticate a request and return the user if valid + * + * @param Request $request The HTTP request to authenticate + * @return User|null The authenticated user, or null if not authenticated + */ + public function authenticate(Request $request): ?User + { + $authorization = $request->headers->get('Authorization'); + $cookieToken = $request->cookies->get('accessToken'); + + // Cookie token takes precedence + if ($cookieToken) { + return $this->authenticateJWT($cookieToken); + } + + if ($authorization) { + if (str_starts_with($authorization, 'Bearer ')) { + $token = substr($authorization, 7); + return $this->authenticateBearer($token); + } + + if (str_starts_with($authorization, 'Basic ')) { + $decoded = base64_decode(substr($authorization, 6) ?: '', true); + if ($decoded !== false) { + [$identity, $secret] = array_pad(explode(':', $decoded, 2), 2, null); + if ($identity !== null && $secret !== null) { + return $this->authenticateBasic($identity, $secret); + } + } + } + } + + return null; + } + + /** + * Authenticate JWT token from cookie or header + */ + public function authenticateJWT(string $token): ?User + { + $payload = $this->tokenService->validateToken($token, $this->securityCode); + + if (!$payload) { + return null; + } + + // Verify user still exists + if ($this->userService->fetchByIdentifier($payload['identifier']) === null) { + return null; + } + + $user = new User(); + $user->populate($payload, 'jwt'); + + return $user; + } + + /** + * Authenticate Bearer token + */ + public function authenticateBearer(string $token): ?User + { + return $this->authenticateJWT($token); + } + + /** + * Authenticate HTTP Basic header (for API access) + * Note: This is for request authentication, not login + */ + private function authenticateBasic(string $identity, string $credentials): ?User + { + // For Basic auth headers, we need to validate against the provider + // This is a simplified flow for API access + $providers = $this->providerManager->providers(AuthenticationProviderInterface::TYPE_AUTHENTICATION); + if ($providers === []) { + return null; + } + + foreach ($providers as $provider) { + if ($provider instanceof AuthenticationProviderInterface === false) { + continue; + } + if ($provider->method() !== AuthenticationProviderInterface::METHOD_CREDENTIAL) { + continue; + } + $context = new \KTXF\Security\Authentication\ProviderContext( + tenantId: $this->sessionTenant->identifier(), + userIdentity: $identity, + ); + $result = $provider->verify($context, $credentials); + + if ($result->isSuccess()) { + break; + } + } + + if (isset($result) && $result->isSuccess()) { + return $this->userService->fetchByIdentity($identity); + } + + return null; + } + + /** + * Extract token claims (for logout to get jti/exp) + */ + public function extractTokenClaims(string $token): ?array + { + return $this->tokenService->validateToken($token, $this->securityCode, checkBlacklist: false); + } +} diff --git a/core/lib/Service/TenantService.php b/core/lib/Service/TenantService.php new file mode 100644 index 0000000..f8d9a3e --- /dev/null +++ b/core/lib/Service/TenantService.php @@ -0,0 +1,19 @@ +store->fetchByDomain($domain); + } + +} diff --git a/core/lib/Service/TokenService.php b/core/lib/Service/TokenService.php new file mode 100644 index 0000000..ef22868 --- /dev/null +++ b/core/lib/Service/TokenService.php @@ -0,0 +1,309 @@ + 'JWT', + 'alg' => $this->algorithm + ]; + + $payload['iat'] = time(); // Issued at + $payload['exp'] = time() + $expirationTime; // Expiration + + // Add JWT ID for token identification and revocation support + $payload['jti'] = $jti ?? $this->generateJti(); + + $headerEncoded = $this->base64UrlEncode(json_encode($header)); + $payloadEncoded = $this->base64UrlEncode(json_encode($payload)); + + $signature = $this->createSignature($headerEncoded . '.' . $payloadEncoded, $secretKey); + + return $headerEncoded . '.' . $payloadEncoded . '.' . $signature; + } + + // ========================================================================= + // Token Validation + // ========================================================================= + + /** + * Validate a JWT token and return its payload + * + * @param string $token The JWT token to validate + * @param string $secretKey The secret key for verification + * @param bool $checkBlacklist Whether to check the blacklist (default: true) + * @return array|null The token payload if valid, null otherwise + */ + public function validateToken(string $token, string $secretKey, bool $checkBlacklist = true): ?array + { + $parts = explode('.', $token); + + if (count($parts) !== 3) { + return null; + } + + [$headerEncoded, $payloadEncoded, $signature] = $parts; + + // Decode and validate header first + $header = json_decode($this->base64UrlDecode($headerEncoded), true); + + if (!$header) { + return null; + } + + // SECURITY: Validate algorithm to prevent "none" algorithm and algorithm switching attacks + if (!isset($header['alg']) || !in_array($header['alg'], self::ALLOWED_ALGORITHMS, true)) { + return null; // Reject tokens with unexpected algorithms + } + + // Verify signature using our expected algorithm (not the one in the header) + $expectedSignature = $this->createSignature($headerEncoded . '.' . $payloadEncoded, $secretKey); + if (!hash_equals($signature, $expectedSignature)) { + return null; + } + + // Decode payload + $payload = json_decode($this->base64UrlDecode($payloadEncoded), true); + + if (!$payload) { + return null; + } + + // Check expiration + if (isset($payload['exp']) && $payload['exp'] < time()) { + return null; // Token expired + } + + // Check blacklist if enabled + if ($checkBlacklist) { + // Check if this specific token has been blacklisted (by jti) + if (isset($payload['jti']) && $this->isBlacklisted($payload['jti'])) { + return null; + } + + // Check if user's tokens have been globally invalidated + if (isset($payload['identity'], $payload['iat'])) { + if ($this->isUserTokenBlacklisted($payload['identity'], $payload['iat'])) { + return null; + } + } + } + + return $payload; + } + + /** + * Refresh a token by creating a new one with fresh timestamps + * + * @param string $token The token to refresh + * @param string $secretKey The secret key + * @return string|null The new token, or null if original was invalid + */ + public function refreshToken(string $token, string $secretKey): ?string + { + $payload = $this->validateToken($token, $secretKey); + + if (!$payload) { + return null; + } + + // Remove old timestamps and jti (new token gets new jti) + unset($payload['iat'], $payload['exp'], $payload['jti']); + + // Create new token with fresh timestamps and new jti + return $this->createToken($payload, $secretKey); + } + + // ========================================================================= + // Token Blacklisting + // ========================================================================= + + /** + * Add a token to the blacklist (revoke it) + * + * @param string $jti The JWT ID to blacklist + * @param int $expiresAt Unix timestamp when the token expires (for cleanup) + */ + public function blacklist(string $jti, int $expiresAt): void + { + $ttl = max($expiresAt - time(), 60); // Minimum 60 seconds + $this->cache->set( + $this->getTokenCacheKey($jti), + $expiresAt, + CacheScope::Tenant, + self::CACHE_USAGE_BLACKLIST, + $ttl + ); + } + + /** + * Check if a token is blacklisted + * + * @param string $jti The JWT ID to check + * @return bool True if blacklisted, false otherwise + */ + public function isBlacklisted(string $jti): bool + { + return $this->cache->has( + $this->getTokenCacheKey($jti), + CacheScope::Tenant, + self::CACHE_USAGE_BLACKLIST + ); + } + + /** + * Remove a token from the blacklist + * + * @param string $jti The JWT ID to remove + */ + public function unblacklist(string $jti): void + { + $this->cache->delete( + $this->getTokenCacheKey($jti), + CacheScope::Tenant, + self::CACHE_USAGE_BLACKLIST + ); + } + + /** + * Blacklist all tokens for a user issued before a timestamp + * Used for "logout all devices" functionality + * + * @param string $identity User identity + * @param int $beforeTimestamp Tokens issued before this time are invalid + */ + public function blacklistUserTokensBefore(string $identity, int $beforeTimestamp): void + { + // Store for 30 days (longer than any token lifetime) + $this->cache->set( + $this->getUserCacheKey($identity), + $beforeTimestamp, + CacheScope::Tenant, + self::CACHE_USAGE_USER_BLACKLIST, + 2592000 // 30 days + ); + } + + /** + * Check if a user's token was issued before the blacklist timestamp + * + * @param string $identity User identity + * @param int $issuedAt Token's iat claim + * @return bool True if token should be rejected + */ + public function isUserTokenBlacklisted(string $identity, int $issuedAt): bool + { + $blacklistBefore = $this->cache->get( + $this->getUserCacheKey($identity), + CacheScope::Tenant, + self::CACHE_USAGE_USER_BLACKLIST + ); + + if ($blacklistBefore === null) { + return false; + } + + return $issuedAt < (int) $blacklistBefore; + } + + /** + * Clear user's "logout all devices" blacklist + * + * @param string $identity User identity + */ + public function clearUserBlacklist(string $identity): void + { + $this->cache->delete( + $this->getUserCacheKey($identity), + CacheScope::Tenant, + self::CACHE_USAGE_USER_BLACKLIST + ); + } + + // ========================================================================= + // Private Helpers + // ========================================================================= + + private function createSignature(string $data, string $secretKey): string + { + $signature = hash_hmac('sha256', $data, $secretKey, true); + return $this->base64UrlEncode($signature); + } + + private function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + private function base64UrlDecode(string $data): string + { + return base64_decode(str_pad(strtr($data, '-_', '+/'), strlen($data) % 4, '=', STR_PAD_RIGHT)); + } + + /** + * Generate cache key for token blacklist + */ + private function getTokenCacheKey(string $jti): string + { + return 'jti_' . hash('sha256', $jti); + } + + /** + * Generate cache key for user blacklist + */ + private function getUserCacheKey(string $identity): string + { + return 'user_' . hash('sha256', $identity); + } +} diff --git a/core/lib/Service/UserAccountsService.php b/core/lib/Service/UserAccountsService.php new file mode 100644 index 0000000..70ec62d --- /dev/null +++ b/core/lib/Service/UserAccountsService.php @@ -0,0 +1,179 @@ +userStore->listUsers($this->tenantIdentity->identifier(), $filters); + + // Remove sensitive data + foreach ($users as &$user) { + unset($user['settings']); + } + + return $users; + } + + public function fetchByIdentity(string $identifier): User | null + { + $data = $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier); + if (!$data) { + return null; + } + + $user = new User(); + $user->populate($data, 'users'); + return $user; + } + + public function fetchByIdentifier(string $identifier): array | null + { + return $this->userStore->fetchByIdentifier($this->tenantIdentity->identifier(), $identifier); + } + + public function fetchByIdentityRaw(string $identifier): array | null + { + return $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier); + } + + public function fetchByProviderSubject(string $provider, string $subject): ?array + { + return $this->userStore->fetchByProviderSubject($this->tenantIdentity->identifier(), $provider, $subject); + } + + public function createUser(array $userData): array + { + return $this->userStore->createUser($this->tenantIdentity->identifier(), $userData); + } + + public function updateUser(string $uid, array $updates): bool + { + return $this->userStore->updateUser($this->tenantIdentity->identifier(), $uid, $updates); + } + + public function deleteUser(string $uid): bool + { + return $this->userStore->deleteUser($this->tenantIdentity->identifier(), $uid); + } + + // ========================================================================= + // Profile Operations + // ========================================================================= + + public function fetchProfile(string $uid): ?array + { + return $this->userStore->fetchProfile($this->tenantIdentity->identifier(), $uid); + } + + public function storeProfile(string $uid, array $profileFields): bool + { + // Get managed fields to filter out read-only fields + $user = $this->fetchByIdentifier($uid); + if (!$user) { + return false; + } + + $managedFields = $user['provider_managed_fields'] ?? []; + $editableFields = []; + + // Only include fields that are not managed by provider + foreach ($profileFields as $field => $value) { + if (!in_array($field, $managedFields)) { + $editableFields[$field] = $value; + } + } + + if (empty($editableFields)) { + return false; + } + + return $this->userStore->storeProfile($this->tenantIdentity->identifier(), $uid, $editableFields); + } + + // ========================================================================= + // Settings Operations + // ========================================================================= + + public function fetchSettings(array $settings = []): array | null + { + return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings); + } + + public function storeSettings(array $settings): bool + { + return $this->userStore->storeSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * Check if a profile field is editable by the user + * + * @param string $uid User identifier + * @param string $field Profile field name + * @return bool True if field is editable, false if managed by provider + */ + public function isFieldEditable(string $uid, string $field): bool + { + $user = $this->fetchByIdentifier($uid); + if (!$user) { + return false; + } + + $managedFields = $user['provider_managed_fields'] ?? []; + return !in_array($field, $managedFields); + } + + /** + * Get editable fields for a user + * + * @param string $uid User identifier + * @return array Array with field => ['value' => ..., 'editable' => bool, 'provider' => ...] + */ + public function getEditableFields(string $uid): array + { + $user = $this->fetchByIdentifier($uid); + if (!$user || !isset($user['profile'])) { + return []; + } + + $managedFields = $user['provider_managed_fields'] ?? []; + $provider = $user['provider'] ?? null; + $editable = []; + + foreach ($user['profile'] as $field => $value) { + $editable[$field] = [ + 'value' => $value, + 'editable' => !in_array($field, $managedFields), + 'provider' => in_array($field, $managedFields) ? $provider : null, + ]; + } + + return $editable; + } + +} diff --git a/core/lib/Service/UserRolesService.php b/core/lib/Service/UserRolesService.php new file mode 100644 index 0000000..7383f93 --- /dev/null +++ b/core/lib/Service/UserRolesService.php @@ -0,0 +1,143 @@ +roleStore->listRoles($this->tenantIdentity->identifier()); + } + + /** + * Get role by ID + */ + public function getRole(string $rid): ?array + { + return $this->roleStore->fetchByRid($this->tenantIdentity->identifier(), $rid); + } + + /** + * Create a new role + */ + public function createRole(array $roleData): array + { + $this->validateRoleData($roleData); + + $this->logger->info('Creating role', [ + 'tenant' => $this->tenantIdentity->identifier(), + 'label' => $roleData['label'] ?? 'Unnamed' + ]); + + return $this->roleStore->createRole($this->tenantIdentity->identifier(), $roleData); + } + + /** + * Update existing role + */ + public function updateRole(string $rid, array $updates): bool + { + // Verify role exists and is not system role + $role = $this->getRole($rid); + if (!$role) { + throw new \InvalidArgumentException('Role not found'); + } + + if ($role['system'] ?? false) { + throw new \InvalidArgumentException('Cannot modify system roles'); + } + + $this->validateRoleData($updates, false); + + $this->logger->info('Updating role', [ + 'tenant' => $this->tenantIdentity->identifier(), + 'rid' => $rid + ]); + + return $this->roleStore->updateRole($this->tenantIdentity->identifier(), $rid, $updates); + } + + /** + * Delete a role + */ + public function deleteRole(string $rid): bool + { + // Verify role exists and is not system role + $role = $this->getRole($rid); + if (!$role) { + throw new \InvalidArgumentException('Role not found'); + } + + if ($role['system'] ?? false) { + throw new \InvalidArgumentException('Cannot delete system roles'); + } + + // Check if role is assigned to users + $userCount = $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid); + if ($userCount > 0) { + throw new \InvalidArgumentException("Cannot delete role assigned to {$userCount} user(s)"); + } + + $this->logger->info('Deleting role', [ + 'tenant' => $this->tenantIdentity->identifier(), + 'rid' => $rid + ]); + + return $this->roleStore->deleteRole($this->tenantIdentity->identifier(), $rid); + } + + /** + * Get user count for a role + */ + public function getRoleUserCount(string $rid): int + { + return $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid); + } + + /** + * Get all available permissions from modules + * Grouped by category with metadata + */ + public function availablePermissions(): array + { + return $this->roleStore->availablePermissions(); + } + + // ========================================================================= + // Validation + // ========================================================================= + + /** + * Validate role data + */ + private function validateRoleData(array $data, bool $isCreate = true): void + { + if ($isCreate && empty($data['label'])) { + throw new \InvalidArgumentException('Role label is required'); + } + + if (isset($data['permissions']) && !is_array($data['permissions'])) { + throw new \InvalidArgumentException('Permissions must be an array'); + } + } +} diff --git a/core/lib/SessionIdentity.php b/core/lib/SessionIdentity.php new file mode 100644 index 0000000..63b7374 --- /dev/null +++ b/core/lib/SessionIdentity.php @@ -0,0 +1,94 @@ +identityLock) { + throw new \RuntimeException('Identity is already locked and cannot be changed.'); + } + + $this->identityData = $identity; + $this->identityLock = $lock; + } + + public function identity(): ?User + { + return $this->identityData; + } + + public function identifier(): ?string + { + return $this->identityData?->getId(); + } + + public function label(): ?string + { + return $this->identityData?->getLabel(); + } + + public function mailAddress(): ?string + { + return $this->identityData?->getEmail(); + } + + public function nameFirst(): ?string + { + return $this->identityData?->getFirstName(); + } + + public function nameLast(): ?string + { + return $this->identityData?->getLastName(); + } + + public function permissions(): array + { + return $this->identityData?->getPermissions() ?? []; + } + + public function roles(): array + { + return $this->identityData?->getRoles() ?? []; + } + + public function hasPermission(string $permission): bool + { + $permissions = $this->permissions(); + + // Exact match + if (in_array($permission, $permissions)) { + return true; + } + + // Wildcard match + foreach ($permissions as $userPerm) { + if (str_ends_with($userPerm, '.*')) { + $prefix = substr($userPerm, 0, -2); + if (str_starts_with($permission, $prefix . '.')) { + return true; + } + } + } + + // Full wildcard + if (in_array('*', $permissions)) { + return true; + } + + return false; + } + + public function hasRole(string $role): bool + { + return in_array($role, $this->roles()); + } + +} diff --git a/core/lib/SessionTenant.php b/core/lib/SessionTenant.php new file mode 100644 index 0000000..7400c0e --- /dev/null +++ b/core/lib/SessionTenant.php @@ -0,0 +1,125 @@ +configured) { + return; + } + $tenant = $this->tenantService->fetchByDomain($domain); + if ($tenant) { + $this->domain = $domain; + $this->tenant = $tenant; + $this->configured = true; + } else { + $this->domain = null; + $this->tenant = null; + $this->configured = false; + } + } + + /** + * Is the tenant configured + */ + public function configured(): bool + { + return $this->configured; + } + + /** + * Is the tenant enabled + */ + public function enabled(): bool + { + return $this->tenant?->getEnabled() ?? false; + } + + /** + * Current tenant domain + */ + public function domain(): ?string + { + return $this->domain; + } + + /** + * Current tenant identifier + */ + public function identifier(): ?string + { + return $this->tenant?->getIdentifier(); + } + + /** + * Current tenant label + */ + public function label(): ?string + { + return $this->tenant?->getLabel(); + } + + /** + * Current tenant configuration + */ + public function configuration(): TenantConfiguration + { + return $this->tenant?->getConfiguration(); + } + + /** + * Current tenant settings + */ + public function settings(): array + { + return $this->tenant?->getSettings() ?? []; + } + + /** + * Get all identity providers configuration for this tenant + * @return array Map of provider ID to provider config + */ + public function identityProviders(): array + { + return $this->tenant?->getConfiguration()['identity']['providers'] ?? []; + } + + /** + * Get configuration for a specific identity provider + * + * @param string $providerId Provider identifier (e.g., 'default', 'oidc') + * @return array|null Provider configuration or null if not found + */ + public function identityProviderConfig(string $providerId): ?array + { + $providers = $this->identityProviders(); + return $providers[$providerId] ?? null; + } + + /** + * Check if an identity provider is enabled for this tenant + */ + public function isIdentityProviderEnabled(string $providerId): bool + { + $config = $this->identityProviderConfig($providerId); + return $config !== null && ($config['enabled'] ?? false); + } +} diff --git a/core/lib/Stores/FirewallStore.php b/core/lib/Stores/FirewallStore.php new file mode 100644 index 0000000..728bea8 --- /dev/null +++ b/core/lib/Stores/FirewallStore.php @@ -0,0 +1,309 @@ + $tenantId]; + + if ($activeOnly) { + $filter['enabled'] = true; + $filter['$or'] = [ + ['expiresAt' => null], + ['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]] + ]; + } + + $cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter); + $list = []; + + foreach ($cursor as $entry) { + $rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry); + $list[] = $rule; + } + + return $list; + } + + /** + * Find rules by IP address + */ + public function findRulesByIp(string $tenantId, string $ipAddress): array + { + $filter = [ + 'tenantId' => $tenantId, + 'type' => ['$in' => [FirewallRuleObject::TYPE_IP, FirewallRuleObject::TYPE_IP_RANGE]], + 'enabled' => true, + '$or' => [ + ['expiresAt' => null], + ['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]] + ] + ]; + + $cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter); + $list = []; + + foreach ($cursor as $entry) { + $rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry); + $list[] = $rule; + } + + return $list; + } + + /** + * Find rules by device fingerprint + */ + public function findRulesByDevice(string $tenantId, string $deviceFingerprint): array + { + $filter = [ + 'tenantId' => $tenantId, + 'type' => FirewallRuleObject::TYPE_DEVICE, + 'value' => $deviceFingerprint, + 'enabled' => true, + '$or' => [ + ['expiresAt' => null], + ['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]] + ] + ]; + + $cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter); + $list = []; + + foreach ($cursor as $entry) { + $rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry); + $list[] = $rule; + } + + return $list; + } + + /** + * Fetch a specific rule by ID + */ + public function fetchRule(string $id): ?FirewallRuleObject + { + $entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(['_id' => $id]); + if (!$entry) { + return null; + } + return (new FirewallRuleObject())->jsonDeserialize((array)$entry); + } + + /** + * Check if exact IP rule exists + */ + public function findExactIpRule(string $tenantId, string $ipAddress, string $action): ?FirewallRuleObject + { + $entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne([ + 'tenantId' => $tenantId, + 'type' => FirewallRuleObject::TYPE_IP, + 'value' => $ipAddress, + 'action' => $action, + 'enabled' => true, + ]); + + if (!$entry) { + return null; + } + return (new FirewallRuleObject())->jsonDeserialize((array)$entry); + } + + /** + * Create or update a rule + */ + public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject + { + if ($rule->getId()) { + return $this->updateRule($rule); + } else { + return $this->createRule($rule); + } + } + + private function createRule(FirewallRuleObject $rule): ?FirewallRuleObject + { + $data = $rule->jsonSerialize(); + unset($data['id']); // Remove id for insert + + $result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->insertOne($data); + $rule->setId((string)$result->getInsertedId()); + return $rule; + } + + private function updateRule(FirewallRuleObject $rule): ?FirewallRuleObject + { + $id = $rule->getId(); + if (!$id) { + return null; + } + + $data = $rule->jsonSerialize(); + unset($data['id']); + + $this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne( + ['_id' => $id], + ['$set' => $data] + ); + return $rule; + } + + /** + * Delete a rule + */ + public function destroyRule(FirewallRuleObject $rule): void + { + $id = $rule->getId(); + if (!$id) { + return; + } + $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(['_id' => $id]); + } + + /** + * Delete expired rules + */ + public function cleanupExpiredRules(): int + { + $result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteMany([ + 'expiresAt' => ['$lt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)], + 'expiresAt' => ['$ne' => null] + ]); + + return $result->getDeletedCount(); + } + + // ======================================== + // Log Operations + // ======================================== + + /** + * Log a firewall event + */ + public function createLog(FirewallLogObject $log): FirewallLogObject + { + $data = $log->jsonSerialize(); + unset($data['id']); + + $result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->insertOne($data); + $log->setId((string)$result->getInsertedId()); + return $log; + } + + /** + * Get logs for a tenant with optional filters + */ + public function listLogs( + string $tenantId, + ?string $ipAddress = null, + ?string $eventType = null, + ?string $result = null, + int $limit = 100, + int $offset = 0 + ): array { + $filter = ['tenantId' => $tenantId]; + + if ($ipAddress !== null) { + $filter['ipAddress'] = $ipAddress; + } + if ($eventType !== null) { + $filter['eventType'] = $eventType; + } + if ($result !== null) { + $filter['result'] = $result; + } + + $cursor = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->find( + $filter, + [ + 'sort' => ['timestamp' => -1], + 'limit' => $limit, + 'skip' => $offset + ] + ); + + $list = []; + foreach ($cursor as $entry) { + $log = (new FirewallLogObject())->jsonDeserialize((array)$entry); + $list[] = $log; + } + + return $list; + } + + /** + * Count recent failures from an IP within a time window + */ + public function countRecentFailures( + string $tenantId, + string $ipAddress, + int $windowSeconds = 300 + ): int { + $since = (new \DateTimeImmutable())->modify("-{$windowSeconds} seconds"); + + return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments([ + 'tenantId' => $tenantId, + 'ipAddress' => $ipAddress, + 'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE, + 'timestamp' => ['$gte' => $since->format(\DateTimeInterface::ATOM)] + ]); + } + + /** + * Get blocked requests count for dashboard + */ + public function countBlockedRequests( + string $tenantId, + ?\DateTimeImmutable $since = null + ): int { + $filter = [ + 'tenantId' => $tenantId, + 'result' => FirewallLogObject::RESULT_BLOCKED + ]; + + if ($since !== null) { + $filter['timestamp'] = ['$gte' => $since->format(\DateTimeInterface::ATOM)]; + } + + return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter); + } + + /** + * Clean up old logs + */ + public function cleanupOldLogs(int $daysToKeep = 30): int + { + $cutoff = (new \DateTimeImmutable())->modify("-{$daysToKeep} days"); + + $result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->deleteMany([ + 'timestamp' => ['$lt' => $cutoff->format(\DateTimeInterface::ATOM)] + ]); + + return $result->getDeletedCount(); + } +} diff --git a/core/lib/Stores/TenantStore.php b/core/lib/Stores/TenantStore.php new file mode 100644 index 0000000..a13b2dc --- /dev/null +++ b/core/lib/Stores/TenantStore.php @@ -0,0 +1,75 @@ +dataStore->selectCollection(self::COLLECTION_NAME)->find(); + $list = []; + foreach ($cursor as $entry) { + $entry = (new TenantObject())->jsonDeserialize((array)$entry); + $list[$entry->getId()] = $entry; + } + return $list; + } + + public function fetch(string $identifier): ?TenantObject + { + $entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(['identifier' => $identifier]); + if (!$entry) { return null; } + return (new TenantObject())->jsonDeserialize((array)$entry); + } + + public function fetchByDomain(string $domain): ?TenantObject + { + $entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(['domains' => $domain]); + if (!$entry) { return null; } + $entity = new TenantObject(); + $entity->jsonDeserialize((array)$entry); + return $entity; + } + + public function deposit(TenantObject $entry): ?TenantObject + { + if ($entry->getId()) { + return $this->update($entry); + } else { + return $this->create($entry); + } + } + + private function create(TenantObject $entry): ?TenantObject + { + $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($entry->jsonSerialize()); + $entry->setId((string)$result->getInsertedId()); + return $entry; + } + + private function update(TenantObject $entry): ?TenantObject + { + $id = $entry->getId(); + if (!$id) { return null; } + $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]); + return $entry; + } + + public function destroy(TenantObject $entry): void + { + $id = $entry->getId(); + if (!$id) { return; } + $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]); + } + +} \ No newline at end of file diff --git a/core/lib/Stores/UserAccountsStore.php b/core/lib/Stores/UserAccountsStore.php new file mode 100644 index 0000000..bda6f8f --- /dev/null +++ b/core/lib/Stores/UserAccountsStore.php @@ -0,0 +1,297 @@ + $tenant]; + + if (isset($filters['enabled'])) { + $filter['enabled'] = (bool)$filters['enabled']; + } + + if (isset($filters['role'])) { + $filter['roles'] = $filters['role']; + } + + // Fetch users with aggregated role data + $pipeline = [ + ['$match' => $filter], + [ + '$lookup' => [ + 'from' => 'user_roles', + 'localField' => 'roles', + 'foreignField' => 'rid', + 'as' => 'role_details' + ] + ], + [ + '$addFields' => [ + 'permissions' => [ + '$reduce' => [ + 'input' => [ + '$map' => [ + 'input' => '$role_details', + 'as' => 'r', + 'in' => ['$ifNull' => ['$$r.permissions', []]] + ] + ], + 'initialValue' => [], + 'in' => ['$setUnion' => ['$$value', '$$this']] + ] + ] + ] + ], + ['$unset' => 'role_details'], + ['$sort' => ['label' => 1]] + ]; + + $cursor = $this->store->selectCollection('user_accounts')->aggregate($pipeline); + + $users = []; + foreach ($cursor as $entry) { + $users[] = (array)$entry; + } + + return $users; + } + + public function fetchByIdentity(string $tenant, string $identity): array | null + { + + $pipeline = [ + [ + '$match' => [ + 'tid' => $tenant, + 'identity' => $identity + ] + ], + [ + '$lookup' => [ + 'from' => 'user_roles', + 'localField' => 'roles', // Array field in `users` + 'foreignField' => 'rid', // Scalar field in `user_roles` + 'as' => 'role_details' + ] + ], + // Add flattened, deduplicated permissions while preserving all original user fields + [ + '$addFields' => [ + 'permissions' => [ + '$reduce' => [ + 'input' => [ + '$map' => [ + 'input' => '$role_details', + 'as' => 'r', + 'in' => [ '$ifNull' => ['$$r.permissions', []] ] + ] + ], + 'initialValue' => [], + 'in' => [ '$setUnion' => ['$$value', '$$this'] ] + ] + ] + ] + ], + // Optionally remove expanded role documents from output + [ '$unset' => 'role_details' ] + ]; + + $entry = $this->store->selectCollection('user_accounts')->aggregate($pipeline)->toArray()[0] ?? null; + if (!$entry) { return null; } + return (array)$entry; + } + + public function fetchByIdentifier(string $tenant, string $identifier): array | null + { + $pipeline = [ + [ + '$match' => [ + 'tid' => $tenant, + 'uid' => $identifier + ] + ], + [ + '$lookup' => [ + 'from' => 'user_roles', + 'localField' => 'roles', + 'foreignField' => 'rid', + 'as' => 'role_details' + ] + ], + [ + '$addFields' => [ + 'permissions' => [ + '$reduce' => [ + 'input' => [ + '$map' => [ + 'input' => '$role_details', + 'as' => 'r', + 'in' => [ '$ifNull' => ['$$r.permissions', []] ] + ] + ], + 'initialValue' => [], + 'in' => [ '$setUnion' => ['$$value', '$$this'] ] + ] + ] + ] + ], + [ '$unset' => 'role_details' ] + ]; + + $entry = $this->store->selectCollection('user_accounts')->aggregate($pipeline)->toArray()[0] ?? null; + if (!$entry) { return null; } + return (array)$entry; + } + + public function fetchByProviderSubject(string $tenant, string $provider, string $subject): array | null + { + $entry = $this->store->selectCollection('user_accounts')->findOne([ + 'tid' => $tenant, + 'provider' => $provider, + 'provider_subject' => $subject + ]); + if (!$entry) { return null; } + return (array)$entry; + } + + public function createUser(string $tenant, array $userData): array + { + $userData['tid'] = $tenant; + $userData['uid'] = $userData['uid'] ?? UUID::v4(); + $userData['enabled'] = $userData['enabled'] ?? true; + $userData['roles'] = $userData['roles'] ?? []; + $userData['profile'] = $userData['profile'] ?? []; + $userData['settings'] = $userData['settings'] ?? []; + + $this->store->selectCollection('user_accounts')->insertOne($userData); + + return $this->fetchByIdentifier($tenant, $userData['uid']); + } + + public function updateUser(string $tenant, string $uid, array $updates): bool + { + $result = $this->store->selectCollection('user_accounts')->updateOne( + ['tid' => $tenant, 'uid' => $uid], + ['$set' => $updates] + ); + + return $result->getModifiedCount() > 0; + } + + public function deleteUser(string $tenant, string $uid): bool + { + $result = $this->store->selectCollection('user_accounts')->deleteOne([ + 'tid' => $tenant, + 'uid' => $uid + ]); + + return $result->getDeletedCount() > 0; + } + + // ========================================================================= + // Profile Operations + // ========================================================================= + + public function fetchProfile(string $tenant, string $uid): ?array + { + $user = $this->store->selectCollection('user_accounts')->findOne( + ['tid' => $tenant, 'uid' => $uid], + ['projection' => ['profile' => 1, 'provider_managed_fields' => 1]] + ); + + if (!$user) { + return null; + } + + return [ + 'profile' => $user['profile'] ?? [], + 'provider_managed_fields' => $user['provider_managed_fields'] ?? [], + ]; + } + + public function storeProfile(string $tenant, string $uid, array $profileFields): bool + { + if (empty($profileFields)) { + return false; + } + + $updates = []; + foreach ($profileFields as $key => $value) { + $updates["profile.{$key}"] = $value; + } + + $result = $this->store->selectCollection('user_accounts')->updateOne( + ['tid' => $tenant, 'uid' => $uid], + ['$set' => $updates] + ); + + return $result->getModifiedCount() > 0; + } + + // ========================================================================= + // Settings Operations + // ========================================================================= + + public function fetchSettings(string $tenant, string $uid, array $settings = []): ?array + { + // Only fetch the settings field from the database + $user = $this->store->selectCollection('user_accounts')->findOne( + ['tid' => $tenant, 'uid' => $uid], + ['projection' => ['settings' => 1]] + ); + + if (!$user) { + return null; + } + + $userSettings = $user['settings'] ?? []; + + if (empty($settings)) { + return $userSettings; + } + + $result = []; + foreach ($settings as $key) { + $result[$key] = $userSettings[$key] ?? null; + } + return $result; + } + + public function storeSettings(string $tenant, string $uid, array $settings): bool + { + if (empty($settings)) { + return false; + } + + $updates = []; + foreach ($settings as $key => $value) { + $updates["settings.{$key}"] = $value; + } + + $result = $this->store->selectCollection('user_accounts')->updateOne( + ['tid' => $tenant, 'uid' => $uid], + ['$set' => $updates] + ); + + // Return true if document was matched (exists), even if not modified + return $result->getMatchedCount() > 0; + } + +} \ No newline at end of file diff --git a/core/lib/Stores/UserRolesStore.php b/core/lib/Stores/UserRolesStore.php new file mode 100644 index 0000000..fa90f17 --- /dev/null +++ b/core/lib/Stores/UserRolesStore.php @@ -0,0 +1,142 @@ +store->selectCollection(self::COLLECTION_NAME)->find( + ['tid' => $tenant], + ['sort' => ['label' => 1]] + ); + + $roles = []; + foreach ($cursor as $entry) { + $role = (array)$entry; + // Ensure permissions is an array + if (isset($role['permissions'])) { + $role['permissions'] = (array)$role['permissions']; + } + $roles[] = $role; + } + + return $roles; + } + + /** + * Fetch role by tenant and role ID + */ + public function fetchByRid(string $tenant, string $rid): ?array + { + $entry = $this->store->selectCollection(self::COLLECTION_NAME)->findOne([ + 'tid' => $tenant, + 'rid' => $rid + ]); + + if (!$entry) { + return null; + } + + return (array)$entry; + } + + /** + * Create a new role + */ + public function createRole(string $tenant, array $roleData): array + { + $roleData['tid'] = $tenant; + $roleData['rid'] = $roleData['rid'] ?? UUID::v4(); + $roleData['label'] = $roleData['label'] ?? 'Unnamed Role'; + $roleData['description'] = $roleData['description'] ?? ''; + $roleData['permissions'] = $roleData['permissions'] ?? []; + $roleData['system'] = $roleData['system'] ?? false; + + $this->store->selectCollection(self::COLLECTION_NAME)->insertOne($roleData); + + return $this->fetchByRid($tenant, $roleData['rid']); + } + + /** + * Update an existing role + */ + public function updateRole(string $tenant, string $rid, array $updates): bool + { + // Prevent updating system flag + unset($updates['tid'], $updates['rid'], $updates['system']); + + if (empty($updates)) { + return false; + } + + $result = $this->store->selectCollection(self::COLLECTION_NAME)->updateOne( + ['tid' => $tenant, 'rid' => $rid], + ['$set' => $updates] + ); + + return $result->getModifiedCount() > 0; + } + + /** + * Delete a role + */ + public function deleteRole(string $tenant, string $rid): bool + { + // Check if role is system role + $role = $this->fetchByRid($tenant, $rid); + if (!$role || ($role['system'] ?? false)) { + return false; // Cannot delete system roles + } + + $result = $this->store->selectCollection(self::COLLECTION_NAME)->deleteOne([ + 'tid' => $tenant, + 'rid' => $rid + ]); + + return $result->getDeletedCount() > 0; + } + + /** + * Count users assigned to a role + */ + public function countUsersInRole(string $tenant, string $rid): int + { + $count = $this->store->selectCollection('user_accounts')->countDocuments([ + 'tid' => $tenant, + 'roles' => $rid + ]); + + return (int)$count; + } + + /** + * Get all available permissions from modules + * Grouped by category with metadata + */ + public function availablePermissions(): array + { + return $this->moduleManager->availablePermissions(); + } +} diff --git a/core/lib/index.php b/core/lib/index.php new file mode 100644 index 0000000..cae7a9d --- /dev/null +++ b/core/lib/index.php @@ -0,0 +1,22 @@ +moduleDir()); +$moduleAutoloader->register(); + +$app->run(); \ No newline at end of file diff --git a/core/src/App.vue b/core/src/App.vue new file mode 100644 index 0000000..52fe0f4 --- /dev/null +++ b/core/src/App.vue @@ -0,0 +1,20 @@ + + + diff --git a/core/src/assets/images/favicon.svg b/core/src/assets/images/favicon.svg new file mode 100644 index 0000000..7cae276 --- /dev/null +++ b/core/src/assets/images/favicon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/core/src/assets/images/maintenance/Error404.png b/core/src/assets/images/maintenance/Error404.png new file mode 100644 index 0000000000000000000000000000000000000000..494ed124d3de6afcefd7ab5d5316760a7e3af04f GIT binary patch literal 26107 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

c~`5+U6wfn2n%OXSa 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 FO#pzDf0qCN literal 0 HcmV?d00001 diff --git a/core/src/assets/images/maintenance/TwoCone.png b/core/src/assets/images/maintenance/TwoCone.png new file mode 100644 index 0000000000000000000000000000000000000000..e86b9650187e119bfba35574b742f37b2b42bb04 GIT binary patch literal 10764 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/src/assets/images/users/avatar-1.png b/core/src/assets/images/users/avatar-1.png new file mode 100644 index 0000000000000000000000000000000000000000..6a2938ad240c4e4c7177444d5a1bbbd17bb28b6e GIT binary patch literal 2455 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 literal 0 HcmV?d00001 diff --git a/core/src/assets/images/users/avatar-3.png b/core/src/assets/images/users/avatar-3.png new file mode 100644 index 0000000000000000000000000000000000000000..39fba7df8a0d468135f00b7a989cb6b2e1d1b990 GIT binary patch literal 2174 zcmV-^2!Z#BP)V z5fKd&7%?CeGa?o;8X++^J1HGEF&RHBATl#DLM|djFeFDZCM!HdM>ZfzG$%?l zDNHsgKR+T)H!Dy%EmS%yQadeFJTF*3GFw3~TS7EmLo-KHCtgK2WkoSzM>$JcDrHJL zUQR=2OFd{!Kx$7xY)?ILPdRQ>MrK?%Zdo@^VsUa=OL$mCepx_lVq1q=Kzd$IT6Hyt zUrT;vQjTClgk(~YVnAPhI*?^bVuU-4Ygd?RO_Og{YI}xfj6s!iOP6q0m33H`bYGWs zVw-hdhDpng|xoJXR4W1xO(mV$GcfpVjRT%~|$ zmWx=fe_wN=Nr#tGse)*o9DWn>ARi7#>?rvpXI)x>b;)py`SO1 zsq4O>?7yPwz@_cLqwc|^?ZT$;!=~xQvCz%R@WrU|#i{eitMtgN^~tRD%CFbd()Y`- z_{^}|)zSCOviZ%j`OdTW&$If^wfWGr`_Z=h(zoQ}-zK|Br2qg0JV``BR9M69ms^Nc zRT#(r>$dmV=Q3wz9378GQS*|wM$0kKYRU>q@t-;Sj9V+1m3Hbh8peCin(Jj3# z0)8G!s)QdTr0~^TMCnS9#c$|s32=GP_W>!9?S=#lP=!{))H?d}xfTHCumb?hU)iOU zPy*JHrD_z;%nbGKmd%xTbqWjuYGntPTnQ;Xj|(PLknQgAuC^fM!wP^!+5@{0u>x?! z0G0WpIUpVfT+JW@>)hP3uyB?*2mI-{DWISM1E&F^8zDyoZ8-q1IMNhw(rH4iX4{CI zAq&6}oVA)m1Igj0e_;gB91NayRsqC7mdKF>419m8DIkQ^3TT()$T@%jz!G9XlDL{^ zK?f3mw<`9H2Ys=eZcBKGoz*ZvRGu=jgF(CL$1@jk36JI!3(^YZ+IAbcIqt5n>Iv2@g5I3v=Z!4wa6}~p5U&^n@gc7hO zrWDC;EGFL5_9s3(AF}rSXLJ%=S+*HSY1x(N9KO8Nh zT#b-kJ319EY$D~jtlGB?`^h3XW;qyVMSo}CrT+0r6z!6M2oE;|#8I@KPHkGd#eeTu z+*2dQE<2xY2GY^{))3@ zv#@?MQ7O7#Ur8#ZG!lIAqQVt`gM*uo4(3OuO;jG!M2qY8LMgnutzB3mzyhj0|EDsX zamK)@FbzjQ6@=Awl~`Q8)>pz-;lhC;Ilr3+V4N{7z&YUNzQvxd2QZ;h9N}?IpolTM?{!oY%X20KX z=yDN2ZbwVg`puK-@iX~NNkC?Cn}9oKrvD@09}6mvBfFnLxBvhE07*qoM6N<$f(<(} A=Kufz literal 0 HcmV?d00001 diff --git a/core/src/assets/images/users/avatar-4.png b/core/src/assets/images/users/avatar-4.png new file mode 100644 index 0000000000000000000000000000000000000000..4d5310f90c0a45d8ea91e0aa1c7e7d0ef4a1d713 GIT binary patch literal 2152 zcmV-u2$%PXP)h;goue!QEA#+`@2 zppM6=m({J8tmfUa@8%~aCN3o)G$$cDEhRECGEhA-K|?}BL_|wQLt#fZP)tf_SW$mi zL4{vQkYY%SY+R6ST$XcXsC{R-k9fM7hsL9k&8(Z)v76Miqo?23Hz_4AFD^znFkVD7 zR7gU3SVe$cNQ7utl5t>{cxG>niK&Hhtc-idmxaotj*!5)+PJ3b!>;DXw(HEiujt?N z&bjW;z_0A%@YBOFD=0cKDo;5rK}bhLOiNl;Q*%;2c~wA-Uq^{yN{Vn|hIeV0a$Ssn zai@oNnvH>}ig>e&cC?d#wU&f~t*5@8jLN5$(WsQ8+tcmFvGvit_uI@K8W&3|B}6kT zRz5LTO-O1_LQPs(Ut3pnSV&@DT~%jeabsI@XJLtCP>p6%gKc1VbZ?J#XPtLroq=+3 zl8%*!f2V?OjgNw|kA1_EftIPBmad}0pp2TeslBV9&!v!?ys^@&meH`D$F--L&c@fg zt>C$(pViLeyQSa0tl+`1<-)Mk%*d$X+UC!~#^vGh*vkFc#xE5SB_bd=K|n}BI#xnA zTR}2PQ&VnEJ9u7Ffnrf$dv}&>RA+#Ee|U0|7xs!mV zsF%E@mBp=}uehnyv7WoUtf9xf&APCo&cxEgyu8l7r`XQ6*vzuv)$Yl()zi`I)5Y%T z=Q14{K_41JAR96{I6XNuT{SOhI5K`lJY7{wlvhq(a&UQSU|e@}dTe8;YhY@JgN=xV zvwd*DhxvPu%zK&To?pVwPFn0;Wzyjzt!48jkL`w8RsO#g?zQy01w8c-GeyUH}MUAV7e; zB<-N7Uis)t003f4kPzlbqzT5BT)ydDJ@Wg`c-dl%p)d+%fI^|nk;WL1d?w3O=X?cO zlQ%FhGc$l3NTe|cSih&^Jddov0-&*pIzJb}08(fSy48?|vxWpnBuXQguE7KYcw)13 zD!`LOwuK5ns`E1fV~~t3*JYAd+16f~4$S^9@Uq6GX>Y=`0_UmeU`C)`1VFYXozPyw zL3gMF=mu1Rxf%gVBf#Q_Y1;BmKd^CZB9X|(k*sZzW@>?Ek7~gZ_#P~>Dx3+1)1c8t zbQ=dLqh_K zgfW5M2QREj06WDaC(uT;`0A>7D+Zq7FYqaIqp6!QawdV(GmK@yR)BEaMDP#_Tr4eJ z7S_&D5y%jfxvC0bVF6E-z{U|?&|$M6-fdx(kDH~N{~Qg0{_$@8hJ;uwG(($DoL~hV zboBygZa(obRdZg0aoPnhQAAR4)09U zZ|Pcgwni8jN1$(l6};aDtPMVYlClfusg?|-~9yV$0${x>+`t{D#RuSPlL7C< zq;=2!a)KB5Ex((aS6EzJJm|fFKh^u)N@*CiNi0eVShE&b7Pz{T$$tQI2&PA$q%ZPq ze)O{M<)F7n)>BZhv2rXgFE2N{tA+B7H(r=>h$o@K{kH!xXfIjo=Zi51eMP(jIXyYs ze0LNV78d5^j_qHSaIt}6Z^yd>C$P9Z)EZ(pB(7V}3FQ~`_U0)4)=l*F^-UHR?l4~_ zY2ZBBANc_GmYuH4cFK66p`kl7BMYXc3cAx5E&pv|a<F(nXw*;Ff=19ryr#4>x#` z%$%H_=RJ`Uo|3O@?dGhkoLjlP66M!mzahBk#Xozs{Jo8&XY!fM?5I8WGwmc236Hru zeTYlh$ju0`vky+$_G+(|0uT>Vhr=Tj^04rTsHg}Q}92XQI7!n>C79ScHCmIbQ92hMa6e1oPBOeqBOod!A}uE)I3*P`Cm=5>Crch3H7p}CEh;)JAT%&7SS2GkG%!0gD@`#c zJUBH*HYPneFGn~rKRY*MEiFMkIYU1?SUDp`Kr~T2FGN5+Uo|vOK`3!BFh@f`Z8S7g zLODuCLs>;EPe?m@I5<;HK5IoRU`RWJIXPxZHIp|vXi!3bMm%#*HE32dU|2+YQZ#E< zMyWkJc2`1XUQCWnNQ788cUw$#VLG`!Kx<@8fL%p;UrdTzJg!MXX=i7AXFb3}M{#OX zgknvNU_hZ%P>EwtgK9l@Y*db9M6^&!hi6igW<`2#WQJ;5jcHYsYD&UXPorf^sbWl- zYf6-DQj2p@m2O&_ZcT%DW14SPo^VghS6rxVRiAQGoOe>3bzq`(RhD>Wq;*w^fn<$- za-n!#kAYsMcvsS3X2@q=rg~kedRn4=XS8@aI}_frkr`ek#E14TEUZX$&Yl=i*~t|f5Vh?!k1{dnsdXKY{Hg!yPjjCpq#&$ zfX0_}#+Z4@nRv;XddQo8!JUV{qII#SfXkbD%A#)0o_)=qf5NAG&Y^+Iq=C_)fY+Xe z(4&OXqk__ zu$A4glhC@0-?Ek9vzNZTz01Fn+P!_?wVL9!nAyCN%phs%bD8Fp5f4*< z@#3rVp#^{^!;DZkuUraR77mbP07nMY#B*uF} zq8Jqvuhj|_3#Fx$cDncL%$ajDgC-_kc9!zugY$HfIp=rI`TpPSXbjHpL-0S~yaA-` zU!??K<+k*QiOwA$YHz*mP(XT7U~~Bc+s%KRrTv>9uuq>miYy&~6%9a)p_NZxshtC$ zwezTL;b25drWt9LF*jelzH@-sJ#N9-^-!bH2zHRkTk!Evrz(g?zScO1vqNr0a0ywa z8HGKnLx6l_Z%Z(b3n4_)urP(FMTnsveo1wv!t49=x`7ZjG{Qgw+O!D(_gnCUy{i)& z)Hb?)gXy|q5&#o}z=TOI8Y~L2!?C|i#*h6SuItT&ZbZ~{rh}e2_I%-%%YaT!TL%CUVndcEbP?crA zH$zYGthjhv3>g+NNG!Vry-4~@2mK`C81RE$Jgv(s_ zqrDUhnK~Bc2fTV=nji@>P%t>Q1{ejwxb0q3BdRFE07}2~k87&H50&DeevhKz4L+r= z8wMyrA0SO~Av~o3g8(I!lmI?#vM|r=Cjg?gN4~Bm*6UlG2l4NvWb?fcYN0ia=`JFR$DuiYK-ZkbxZmm@gzS8M8wG zs%(pV)r(l^!6KuduT5(J%I6-HocS(jNv%DA&I6tpchbCPAPq)#CvTbnTn1#x1qjs} zk`F$Zu#bGW>cEDiGhV0@&`UG#9jQ{TglGs(Eu~hrH=>%vzQ6qC$F4Y#-;`fL2+yAy zdsmX~R65%wIZXmw7otLX60VuUzr4U9oug*F32dMrJlC6Oe==<0I zu{23w4zL!7 zITDV<(3(qbIMFzCS;o{NN&iRJ=Rp8vw)KK>)!koQgWCdponYW3muVp-JSFX0N1>KK zQ2NFw!P)&XpvF^k90;aI_sjIU`qaS194N3R7jN8CJ>1%ThKdK!AM;smy>L z7ECRdHi&dviNk(h0gasLwK3>iGr+CVL|=NOtt)GmpQ<$r)nXB1JvS9TLfIOcnk|+Z zLiui`3aLIL)?UVRvA5NT3S6A*w&3<<^;3ogvlY810gY;b##h6d5I^Xe&hcuDWC!;y zWFqEeFb`Q%O!X)MdB^ef__{<`VX05O(Y&C0F!9MVknAEl0bZ%?7)*k{`0MF$=z8#e zGo&_}aqC+`^eBzcR=%>}I5XbGd(yI5nLRx@r}*^tXh=eV=;#j(G8`!!s2w{dkSK7vU-ad-XByR zh5oF$_!WzIbnx4n?za#H&{?BguheEvPynJdvT^vHkxn{QV+R~tZt1|vj#G0Nr-O1vh?L^1 zjl&%_n)0)ElZ02i^;bXq$sbSz9FoUk9rns-%et{r?1DiPZu=d8aZs)I#5(Sk|4b3N zvr`O=n8=M;=l)(u+;w&%jHHC;EjafRz~WFKhXI0jpVXJ~V#z!XfP*59){>_ZdqC1VyJ=E3As1 e2jIW@a{UeKCB>ti=1Tqm0000ItHP)+ksk4kZBz9HXSF?(+EZ_Wr%V#WjBa^Y;9# zuCuDDvE1eK&(YSY!S(U>{qOYsE_?rHoBtF}%JTO7DtP}TZU5KW-fy4(@ALUEdjD>p z|G?4s^7j0bxc?SY)Z^#tI)VQhTH4dr+sWAY@bvr~VCE!l{LkI{fvW%T^!elF>+$vc zC~^NAVe=bd^Yi!q?(y~S^#Aen_a|}x@%8^lfA#bA`swWPT?t+2Y%KTl~-V2M~~hLoVY%+=`D-R|h???Y2|f{?bQvCqiRv!k!atF_DK@ayaD^Z5JyVR4^Aga1y8|Cpq|oT$jNzudRL)W68z zQDc-_Ymi=SiD`C{Xm^~9nYzy1{`2_%KvRfxf}nkgp{=;syS~J_#p2-V|6gKgSZbO( zeE(>9rEq?je1C;lkpG^k!OGO^#^lK0;^^e(>^VnfM_P_@fvA#}n{%T7tFE+?w*J}N z;q3GBo1UY!x4pK_{KMA%-|XXQZE~loudlPX!Nttg>ey|4tA&Y5=OH8nXuK|@4DNJ&deO;J~1U}9@)Y;bpXczu0> zjE#+wlb4&Gp`op>>;ZM$|mXTSfw2>}$X!ftdYAMd%z zP4Jx0{pR=k{qM~Q$$$X^1`HT5V8DO@0|pEjFkrxd0Rsk}9gHnkI@pq)A;@G)Yo{%q z{;rm$#-wKhV=EW;Z8U$t)7jC%VU4|=t?f!1Yb&dzmX>6)nc35KB4$>0F59SM*|&4& z&K)~;Y(uyswrp{AadvWau(!1#lk|`2QhVoZkT7)u`^G>=_-L>v=^3)k8i<(g&kAn6ffvbBTjHYSrw^`ZGUVbb@bb>63wwj=2aV@q2HcLw62(z>8^($*xs zU}|OSRysLQE8b*iPMB`z=VOukwffuwhmh{o93-jx{~JjPj; zbtWf1KBEPTHervJJSsISW6uNB7UbgVieb(&|WRE8j{ zs!CZ(l6EB#jPV+gPFhx(m($;`Xf98p`ZqNp>0||N9JFUo0oI4m zf7p)hqr-(nG%LTrm8IFrIJqJ$qgfpxV(w$|s>(&E4;4XRjMcT0P8CH&;El*+Y6c0` zhfXA-A!9AbZ;h+#|0%5bOgb7KGG$zyA^|mIQhX4yhMl5{8eA@yV~yAhjTo1gmzbC@ zqtkqR97%-ZjA9o5)L)m_&WESL)p_-bFvY3-Bot8Zy5;pHHXJ zXnI%>Z5djsTXItS`x^?9qhh~lt?-&RS^}2k;A9U;AOE~o{*4GSC^BMQ(s@7$AIY}9W49k z1rm`MzdLvtNyo-jMlzYq;K+(*aa3w;qCkXQFG<_g`DYT*N>WXw3*%~4a=ADoBUPb@ zhl+_0lJh`1^mb|KDiV>r@Lu39RL?>Ylfh5QuN6m$YZH_Bw5BFHRR@#FKo255pU2}x zCPO<$B&7?ol<8C?-F5izJA~eQz3@TcFm`DdoL$}^4vSJHR?FynY1HJrWK0*v_5^et z2!BWOq4_kH<>sy>TKdp&SmAgLQBmTC#K>TXm6vr?l`0k>!wQv3RnyUME;j8bT-6mQ zl~s5zMkJ$d6w+xxW2ru*8=;sZ8)_?(_)ye5L2Ru!HQ`*A5S2yTk<`@G@TgG~(28QY zrWxyU3Xt^W!2#0(D%|K)#pkgkS$XtlroqPS_}8?rD?e2 z9q<+L(t(#jTG&~V){esE3o1Hdj;PYI(U|TG$2e9~@-_5gd4(GFVr3R|BWgz;)`$lK z_V3q&^g$-7XFg0fUUx;w*%(!4MZ}MERMKfGl~{ZP2pj`aO{T z0TneU3MxusV$Pnc$fh;jyGNs=@{sbgy9Mo)>Waj~qd;03wBxyRoed=&H7c=6fTZv0 zM|$uwDr$91OiV{wyAX<%nXXQY)D|y*?{(0|a$7&rfx94R=Fyt8N*)!IsI6b0J=U2G z!VE!cWxGJo-JOjV6Ie~qtrnd&o-#d29|~r|RAXoY4emS^t4>BEBfBO>%dxXH9VMO6 zj%lsbanAHoJxK>PQgyYkm(I%;-77{lEmg;U-C0sn($UdTQ*-w0F+@zPbX3?MV4l>A z^x)v&!O-y>KQ2Aa)SLsAA(ZhVQ9rg4ngiF7l>F4L?4cmk8%e{SxI+?OQrTvifq~vz(Zj>@7LLEow zNpHfXy&p<$+1=rT4`=u716`$rO0R{SAE8~^)zx+2Q4o4N{?0?Bb+!FaGW{|$bGI%c z5yv~3e%{kSz7)x>~V8FO9upO{N>z-F$b7E8;NVQ zrzX8>I?4k{FV?V7_hcc?f(7R0AdY!!wN0K(+SGcb8Cu7uMf$4^FaPR+y!hga7T^!s z=2+aLSwD%i@lq!*F9$NLT6gE5b;9yy4Y1yz~=z1R)d-?QP_49Z1q zS1%M%xcuG!`0PIZj_tQ1FAy)i^7`8!e)8$3pM3cCOB0xnhS?}HrbAcz zyFo9^=w=TmqzEY&wGBup9*(P5uXCet(HsB1LeYE@ZQq?3N&#g~2#dq^baisDwYITy zblL73?F;|$b0UAWWVam|_4H6sF-P}ZYb*Nv;}we+TUabvy$Q~TRiueoGH~L12?aU# zT^tzk1-NV|e91^OdQRE2ltduLHg2H8<$&|~o4#xgXVV{+SRjzL2Me$HE3A~$SKErP zm?htzmT(|i$sJa4%8&#pv%S_4{mWxZM=uTs#a!6KE_PeD%tFVCO_(&R=O(4Mmy5-G z{q(gI@JzXOofR!uwjKpt_)QN5s|=Rp)@cbF z#XKO1W?lT2jg(pI$^VmE)kkYPIJ>#IIKH{WVggInd11o^oGBEx1dEy7H#C$|+}GCY zi*?{?G}W|Icm|Q?MyB)T%~`N;;o|#v(+#XqX6BLm;Ur@~?59k^oj1Yrr8 z^*oW%8ub|_CS&iPXU?28d(KiP7FHWqz9|ID^M%n|c5&fNwx$D@>6B;BBsAxTpUAwo z+(4R5IgdnPwJ9!+=J;aHZok(UFE*bua|Y?D!ia3|Lcz)*I{G}P*w=Hrhlj`Z?Oq=* ze_D-k%xoN7wruzG^z?9Dzk0>;Wy_W=U$J5dGF~wE=`_YMC0kirTU*YXHGB4)IdkWl zL)#+b=Sa^IM)2+2Q=G8@0|pEjFkryI|2O{yJ{(*QEb5|r00000NkvXXu0mjf0A?>c literal 0 HcmV?d00001 diff --git a/core/src/components/shared/BaseBreadcrumb.vue b/core/src/components/shared/BaseBreadcrumb.vue new file mode 100644 index 0000000..092d1af --- /dev/null +++ b/core/src/components/shared/BaseBreadcrumb.vue @@ -0,0 +1,16 @@ + + + \ No newline at end of file diff --git a/core/src/components/shared/UiParentCard.vue b/core/src/components/shared/UiParentCard.vue new file mode 100644 index 0000000..e890562 --- /dev/null +++ b/core/src/components/shared/UiParentCard.vue @@ -0,0 +1,16 @@ + + + \ No newline at end of file diff --git a/core/src/composables/index.ts b/core/src/composables/index.ts new file mode 100644 index 0000000..700784e --- /dev/null +++ b/core/src/composables/index.ts @@ -0,0 +1,6 @@ +/** + * Core composables - reusable composition functions + */ + +export { useClipboard } from './useClipboard' +export { useUser } from './useUser' diff --git a/core/src/composables/useClipboard.ts b/core/src/composables/useClipboard.ts new file mode 100644 index 0000000..905df56 --- /dev/null +++ b/core/src/composables/useClipboard.ts @@ -0,0 +1,45 @@ +import { ref } from 'vue' + +/** + * Composable for clipboard operations with visual feedback + * + * @param timeout - Duration in ms to show success state (default: 2000) + * @returns Object containing copiedKey ref and copyToClipboard function + * + * @example + * ```vue + * + * + * + * ``` + */ +export function useClipboard(timeout = 2000) { + const copiedKey = ref(null) + + const copyToClipboard = async (text: string, key: T): Promise => { + try { + await navigator.clipboard.writeText(text) + copiedKey.value = key + setTimeout(() => { copiedKey.value = null }, timeout) + return true + } catch (err) { + console.error('Failed to copy to clipboard:', err) + return false + } + } + + return { + copiedKey, + copyToClipboard + } +} diff --git a/core/src/composables/useSnackbar.ts b/core/src/composables/useSnackbar.ts new file mode 100644 index 0000000..5b4a3c9 --- /dev/null +++ b/core/src/composables/useSnackbar.ts @@ -0,0 +1,38 @@ +/** + * Simple snackbar/toast notification composable + * Uses Vuetify's snackbar component + */ +import { ref } from 'vue' + +export interface SnackbarOptions { + message: string + color?: 'success' | 'error' | 'warning' | 'info' | string + timeout?: number +} + +const snackbarVisible = ref(false) +const snackbarMessage = ref('') +const snackbarColor = ref('info') +const snackbarTimeout = ref(3000) + +export function useSnackbar() { + const showSnackbar = (options: SnackbarOptions) => { + snackbarMessage.value = options.message + snackbarColor.value = options.color || 'info' + snackbarTimeout.value = options.timeout || 3000 + snackbarVisible.value = true + } + + const hideSnackbar = () => { + snackbarVisible.value = false + } + + return { + snackbarVisible, + snackbarMessage, + snackbarColor, + snackbarTimeout, + showSnackbar, + hideSnackbar, + } +} diff --git a/core/src/composables/useUser.ts b/core/src/composables/useUser.ts new file mode 100644 index 0000000..6af289a --- /dev/null +++ b/core/src/composables/useUser.ts @@ -0,0 +1,103 @@ +import { computed } from 'vue'; +import { useUserStore } from '@KTXC/stores/userStore'; + +/** + * Composable for accessing user authentication, profile, and settings + * Provides a clean API for modules to access user data + */ +export function useUser() { + const store = useUserStore(); + + // ========================================================================= + // Authentication + // ========================================================================= + + const isAuthenticated = computed(() => store.isAuthenticated); + const identifier = computed(() => store.identifier); + const identity = computed(() => store.identity); + const label = computed(() => store.label); + const roles = computed(() => store.roles); + const permissions = computed(() => store.permissions); + + const hasPermission = (permission: string): boolean => { + return store.hasPermission(permission); + }; + + const hasAnyPermission = (perms: string[]): boolean => { + return store.hasAnyPermission(perms); + }; + + const hasAllPermissions = (perms: string[]): boolean => { + return store.hasAllPermissions(perms); + }; + + const hasRole = (role: string): boolean => { + return store.hasRole(role); + }; + + const logout = async (): Promise => { + await store.logout(); + }; + + // ========================================================================= + // Profile + // ========================================================================= + + const profile = computed(() => store.profileFields); + const editableProfile = computed(() => store.editableProfileFields); + const managedProfile = computed(() => store.managedProfileFields); + + const getProfile = (key: string): any => { + return store.getProfileField(key); + }; + + const setProfile = (key: string, value: any): boolean => { + return store.setProfileField(key, value); + }; + + const isProfileEditable = (key: string): boolean => { + return store.isProfileFieldEditable(key); + }; + + // ========================================================================= + // Settings + // ========================================================================= + + const settings = computed(() => store.settings); + + const getSetting = (key: string): any => { + return store.getSetting(key); + }; + + const setSetting = (key: string, value: any): void => { + store.setSetting(key, value); + }; + + return { + // Auth + isAuthenticated, + identifier, + identity, + label, + roles, + permissions, + hasPermission, + hasAnyPermission, + hasAllPermissions, + hasRole, + logout, + + // Profile + profile, + editableProfile, + managedProfile, + getProfile, + setProfile, + isProfileEditable, + + // Settings + settings, + getSetting, + setSetting, + }; +} diff --git a/core/src/config.ts b/core/src/config.ts new file mode 100644 index 0000000..63d4192 --- /dev/null +++ b/core/src/config.ts @@ -0,0 +1,15 @@ +export type ConfigProps = { + Sidebar_drawer: boolean; + mini_sidebar: boolean; + actTheme: string; + fontTheme: string; +}; + +const config: ConfigProps = { + Sidebar_drawer: true, + mini_sidebar: false, + actTheme: 'light', + fontTheme: 'Public sans' +}; + +export default config; diff --git a/core/src/layouts/blank/BlankLayout.vue b/core/src/layouts/blank/BlankLayout.vue new file mode 100644 index 0000000..d884df5 --- /dev/null +++ b/core/src/layouts/blank/BlankLayout.vue @@ -0,0 +1,9 @@ + + + diff --git a/core/src/layouts/footer/LayoutFooter.vue b/core/src/layouts/footer/LayoutFooter.vue new file mode 100644 index 0000000..4fa684d --- /dev/null +++ b/core/src/layouts/footer/LayoutFooter.vue @@ -0,0 +1,29 @@ + + diff --git a/core/src/layouts/header/LayoutHeader.vue b/core/src/layouts/header/LayoutHeader.vue new file mode 100644 index 0000000..70f2688 --- /dev/null +++ b/core/src/layouts/header/LayoutHeader.vue @@ -0,0 +1,105 @@ + + + diff --git a/core/src/layouts/header/NotificationDD.vue b/core/src/layouts/header/NotificationDD.vue new file mode 100644 index 0000000..a426ce3 --- /dev/null +++ b/core/src/layouts/header/NotificationDD.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/core/src/layouts/header/SearchBarPanel.vue b/core/src/layouts/header/SearchBarPanel.vue new file mode 100644 index 0000000..c7c9daf --- /dev/null +++ b/core/src/layouts/header/SearchBarPanel.vue @@ -0,0 +1,13 @@ + + + diff --git a/core/src/layouts/logo/LogoDark.vue b/core/src/layouts/logo/LogoDark.vue new file mode 100644 index 0000000..f91b360 --- /dev/null +++ b/core/src/layouts/logo/LogoDark.vue @@ -0,0 +1,42 @@ + + diff --git a/core/src/layouts/menus/LayoutSystemMenu.vue b/core/src/layouts/menus/LayoutSystemMenu.vue new file mode 100644 index 0000000..d71ea8a --- /dev/null +++ b/core/src/layouts/menus/LayoutSystemMenu.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/core/src/layouts/menus/LayoutSystemMenuGroupDynamic.vue b/core/src/layouts/menus/LayoutSystemMenuGroupDynamic.vue new file mode 100644 index 0000000..af1dc4c --- /dev/null +++ b/core/src/layouts/menus/LayoutSystemMenuGroupDynamic.vue @@ -0,0 +1,42 @@ + + + + diff --git a/core/src/layouts/menus/LayoutSystemMenuGroupStatic.vue b/core/src/layouts/menus/LayoutSystemMenuGroupStatic.vue new file mode 100644 index 0000000..86518a9 --- /dev/null +++ b/core/src/layouts/menus/LayoutSystemMenuGroupStatic.vue @@ -0,0 +1,15 @@ + + + diff --git a/core/src/layouts/menus/LayoutSystemMenuItem.vue b/core/src/layouts/menus/LayoutSystemMenuItem.vue new file mode 100644 index 0000000..a02ed6f --- /dev/null +++ b/core/src/layouts/menus/LayoutSystemMenuItem.vue @@ -0,0 +1,29 @@ + + + diff --git a/core/src/layouts/menus/LayoutUserMenu.vue b/core/src/layouts/menus/LayoutUserMenu.vue new file mode 100644 index 0000000..458bd83 --- /dev/null +++ b/core/src/layouts/menus/LayoutUserMenu.vue @@ -0,0 +1,110 @@ + + + diff --git a/core/src/models/userProfile.ts b/core/src/models/userProfile.ts new file mode 100644 index 0000000..cca30e1 --- /dev/null +++ b/core/src/models/userProfile.ts @@ -0,0 +1,98 @@ +/** + * User Profile Model + */ + +import type { ProfileFieldInterface, UserProfileInterface } from '@KTXC/types/user/userProfileTypes'; + +export class UserProfile { + private _data: UserProfileInterface; + + constructor(data: UserProfileInterface = {}) { + this._data = data; + } + + fromJson(data: UserProfileInterface): UserProfile { + this._data = data; + return this; + } + + toJson(): UserProfileInterface { + return { ...this._data }; + } + + clone(): UserProfile { + return new UserProfile(JSON.parse(JSON.stringify(this._data))); + } + + /** + * Get a profile field value + */ + get(key: string): any { + return this._data[key]?.value ?? null; + } + + /** + * Set a profile field value (only if editable) + */ + set(key: string, value: any): boolean { + if (!this._data[key]) { + console.warn(`Profile field "${key}" does not exist`); + return false; + } + + if (!this._data[key].editable) { + console.warn(`Profile field "${key}" is managed by ${this._data[key].provider} and cannot be edited`); + return false; + } + + this._data[key].value = value; + return true; + } + + /** + * Check if a field is editable + */ + isEditable(key: string): boolean { + return this._data[key]?.editable ?? false; + } + + /** + * Get field metadata + */ + getField(key: string): ProfileFieldInterface | null { + return this._data[key] ?? null; + } + + /** + * Get all fields + */ + get fields(): UserProfileInterface { + return this._data; + } + + /** + * Get only editable fields + */ + get editableFields(): UserProfileInterface { + return Object.entries(this._data) + .filter(([_, field]) => (field as ProfileFieldInterface).editable) + .reduce((acc, [key, field]) => ({ ...acc, [key]: field }), {} as UserProfileInterface); + } + + /** + * Get only managed (non-editable) fields + */ + get managedFields(): UserProfileInterface { + return Object.entries(this._data) + .filter(([_, field]) => !(field as ProfileFieldInterface).editable) + .reduce((acc, [key, field]) => ({ ...acc, [key]: field }), {} as UserProfileInterface); + } + + /** + * Get provider managing this profile + */ + get provider(): string | null { + const managedField = Object.values(this._data).find(f => !(f as ProfileFieldInterface).editable); + return (managedField as ProfileFieldInterface)?.provider ?? null; + } +} diff --git a/core/src/models/userSettings.ts b/core/src/models/userSettings.ts new file mode 100644 index 0000000..a7e17c5 --- /dev/null +++ b/core/src/models/userSettings.ts @@ -0,0 +1,73 @@ +/** + * User Settings Model + */ + +import type { UserSettingsInterface } from '@KTXC/types/user/userSettingsTypes'; + +export class UserSettings { + private _data: UserSettingsInterface; + + constructor(data: UserSettingsInterface = {}) { + this._data = data; + } + + fromJson(data: UserSettingsInterface): UserSettings { + this._data = data; + return this; + } + + toJson(): UserSettingsInterface { + return { ...this._data }; + } + + clone(): UserSettings { + return new UserSettings(JSON.parse(JSON.stringify(this._data))); + } + + /** + * Get a setting value + */ + get(key: string): any { + return this._data[key] ?? null; + } + + /** + * Set a setting value + */ + set(key: string, value: any): void { + this._data[key] = value; + } + + /** + * Check if a setting exists + */ + has(key: string): boolean { + return key in this._data; + } + + /** + * Get all settings + */ + get all(): UserSettingsInterface { + return this._data; + } + + /** + * Get multiple settings + */ + getMany(keys: string[]): Record { + return keys.reduce((acc, key) => ({ + ...acc, + [key]: this._data[key] ?? null + }), {}); + } + + /** + * Set multiple settings + */ + setMany(settings: Record): void { + Object.entries(settings).forEach(([key, value]) => { + this._data[key] = value; + }); + } +} diff --git a/core/src/plugins/vuetify/defaults.ts b/core/src/plugins/vuetify/defaults.ts new file mode 100644 index 0000000..c8bdfda --- /dev/null +++ b/core/src/plugins/vuetify/defaults.ts @@ -0,0 +1,148 @@ +export default { + IconBtn: { + icon: true, + color: 'default', + variant: 'text', + }, + VAlert: { + VBtn: { + color: undefined, + }, + }, + VAvatar: { + // ℹ️ Remove after next release + variant: 'flat', + }, + VBadge: { + // set v-badge default color to primary + color: 'primary', + }, + VBtn: { + // set v-btn default color to primary + color: 'primary', + style: 'text-transform: none;', + }, + VCard: { + rounded: 'lg', + elevation: 2, + }, + VChip: { + elevation: 0, + }, + VMenu: { + offset: '2px', + }, + VPagination: { + density: 'comfortable', + showFirstLastPage: true, + variant: 'tonal', + }, + VTabs: { + // set v-tabs default color to primary + color: 'primary', + VSlideGroup: { + showArrows: true, + }, + }, + VTooltip: { + // set v-tooltip default location to top + location: 'top', + }, + VCheckboxBtn: { + color: 'primary', + }, + VCheckbox: { + // set v-checkbox default color to primary + color: 'primary', + density: 'comfortable', + hideDetails: 'auto', + }, + VRadioGroup: { + color: 'primary', + density: 'comfortable', + hideDetails: 'auto', + }, + VRadio: { + density: 'comfortable', + hideDetails: 'auto', + }, + VSelect: { + variant: 'outlined', + color: 'primary', + hideDetails: 'auto', + density: 'comfortable', + }, + VRangeSlider: { + // set v-range-slider default color to primary + color: 'primary', + thumbLabel: true, + hideDetails: 'auto', + trackSize: 6, + thumbSize: 22, + elevation: 4, + }, + VRating: { + // set v-rating default color to primary + activeColor: 'warning', + color: 'disabled', + }, + VProgressCircular: { + // set v-progress-circular default color to primary + color: 'primary', + }, + VProgressLinear: { + color: 'primary', + }, + VSlider: { + // set v-slider default color to primary + color: 'primary', + trackSize: 6, + hideDetails: 'auto', + thumbSize: 22, + elevation: 4, + }, + VSnackbar: { + VBtn: { + size: 'small', + }, + }, + VTextField: { + variant: 'outlined', + density: 'comfortable', + color: 'primary', + hideDetails: 'auto', + }, + VAutocomplete: { + variant: 'outlined', + color: 'primary', + density: 'comfortable', + hideDetails: 'auto', + }, + VCombobox: { + variant: 'outlined', + color: 'primary', + hideDetails: 'auto', + density: 'comfortable', + }, + VFileInput: { + variant: 'outlined', + color: 'primary', + hideDetails: 'auto', + density: 'comfortable', + }, + VTextarea: { + variant: 'outlined', + color: 'primary', + hideDetails: 'auto', + density: 'comfortable', + }, + VSwitch: { + // set v-switch default color to primary + inset: true, + color: 'primary', + hideDetails: 'auto', + }, + VNavigationDrawer: { + touchless: true, + }, +} diff --git a/core/src/plugins/vuetify/icons.ts b/core/src/plugins/vuetify/icons.ts new file mode 100644 index 0000000..9999b07 --- /dev/null +++ b/core/src/plugins/vuetify/icons.ts @@ -0,0 +1,14 @@ +import type { IconAliases } from 'vuetify' +import { aliases as mdiAliases, mdi } from 'vuetify/iconsets/mdi' + +const aliases: Partial = { + ...mdiAliases, +} + +export const icons = { + defaultSet: 'mdi', + aliases, + sets: { + mdi, + }, +} diff --git a/core/src/plugins/vuetify/index.ts b/core/src/plugins/vuetify/index.ts new file mode 100644 index 0000000..2dcf8b5 --- /dev/null +++ b/core/src/plugins/vuetify/index.ts @@ -0,0 +1,24 @@ +import { createVuetify } from 'vuetify' +import { VBtn } from 'vuetify/components/VBtn' +import * as components from 'vuetify/components' +import * as directives from 'vuetify/directives' +import defaults from './defaults' +import { icons } from './icons' +import { themes } from './theme' + +// Styles +import 'vuetify/styles' + +export default createVuetify({ + components, + directives, + aliases: { + IconBtn: VBtn, + }, + defaults, + icons, + theme: { + defaultTheme: 'light', + themes, + }, +}) diff --git a/core/src/plugins/vuetify/theme.ts b/core/src/plugins/vuetify/theme.ts new file mode 100644 index 0000000..16aec75 --- /dev/null +++ b/core/src/plugins/vuetify/theme.ts @@ -0,0 +1,144 @@ +import type { ThemeDefinition } from 'vuetify' + +export const staticPrimaryColor = '#6366F1' +export const staticPrimaryDarkenColor = '#4F46E5' + +export const themes: Record = { + light: { + dark: false, + colors: { + // Primary brand colors - Modern indigo + 'primary': staticPrimaryColor, + 'on-primary': '#FFFFFF', + 'primary-darken-1': staticPrimaryDarkenColor, + 'primary-lighten-1': '#818CF8', + + // Secondary - Purple accent + 'secondary': '#8B5CF6', + 'secondary-darken-1': '#7C3AED', + 'secondary-lighten-1': '#A78BFA', + 'on-secondary': '#FFFFFF', + + // Semantic colors + 'success': '#10B981', + 'on-success': '#FFFFFF', + 'info': '#3B82F6', + 'on-info': '#FFFFFF', + 'warning': '#F59E0B', + 'on-warning': '#FFFFFF', + 'error': '#EF4444', + 'on-error': '#FFFFFF', + + // Surface & backgrounds + 'background': '#F8FAFC', + 'on-background': '#0F172A', + 'surface': '#FFFFFF', + 'on-surface': '#0F172A', + 'surface-bright': '#FFFFFF', + 'surface-variant': '#F1F5F9', + 'on-surface-variant': '#64748B', + + // Grey scale + 'grey': '#64748B', + 'grey-darken-1': '#475569', + 'grey-lighten-1': '#94A3B8', + 'grey-lighten-2': '#CBD5E1', + 'grey-lighten-3': '#E2E8F0', + 'grey-lighten-4': '#F1F5F9', + 'grey-lighten-5': '#F8FAFC', + + // Component specific + 'perfect-scrollbar-thumb': '#CBD5E1', + 'track-bg': '#F1F5F9', + }, + + variables: { + 'border-color': '#E2E8F0', + 'border-opacity': 0.12, + 'high-emphasis-opacity': 0.87, + 'medium-emphasis-opacity': 0.60, + 'disabled-opacity': 0.38, + 'idle-opacity': 0.04, + 'hover-opacity': 0.04, + 'focus-opacity': 0.12, + 'selected-opacity': 0.08, + 'activated-opacity': 0.12, + 'pressed-opacity': 0.12, + 'dragged-opacity': 0.08, + 'theme-kbd': '#212529', + 'theme-on-kbd': '#FFFFFF', + 'theme-code': '#F5F5F5', + 'theme-on-code': '#000000', + }, + }, + + dark: { + dark: true, + colors: { + // Primary brand colors - Lighter shades for dark mode + 'primary': '#818CF8', + 'on-primary': '#FFFFFF', + 'primary-darken-1': '#6366F1', + 'primary-lighten-1': '#A5B4FC', + + // Secondary - Purple accent + 'secondary': '#A78BFA', + 'secondary-darken-1': '#8B5CF6', + 'secondary-lighten-1': '#C4B5FD', + 'on-secondary': '#FFFFFF', + + // Semantic colors - Adjusted for dark mode + 'success': '#34D399', + 'on-success': '#FFFFFF', + 'info': '#60A5FA', + 'on-info': '#FFFFFF', + 'warning': '#FBBF24', + 'on-warning': '#FFFFFF', + 'error': '#F87171', + 'on-error': '#FFFFFF', + + // Surface & backgrounds - Dark slate palette + 'background': '#0F172A', + 'on-background': '#F1F5F9', + 'surface': '#1E293B', + 'on-surface': '#F1F5F9', + 'surface-bright': '#334155', + 'surface-variant': '#1E293B', + 'on-surface-variant': '#94A3B8', + + // Grey scale + 'grey': '#94A3B8', + 'grey-darken-1': '#CBD5E1', + 'grey-lighten-1': '#64748B', + 'grey-lighten-2': '#475569', + 'grey-lighten-3': '#334155', + 'grey-lighten-4': '#1E293B', + 'grey-lighten-5': '#0F172A', + + // Component specific + 'perfect-scrollbar-thumb': '#475569', + 'track-bg': '#334155', + }, + + variables: { + 'border-color': '#334155', + 'border-opacity': 0.12, + 'high-emphasis-opacity': 0.87, + 'medium-emphasis-opacity': 0.60, + 'disabled-opacity': 0.38, + 'idle-opacity': 0.10, + 'hover-opacity': 0.08, + 'focus-opacity': 0.12, + 'selected-opacity': 0.12, + 'activated-opacity': 0.16, + 'pressed-opacity': 0.14, + 'dragged-opacity': 0.10, + 'theme-kbd': '#212529', + 'theme-on-kbd': '#FFFFFF', + 'theme-code': '#343434', + 'theme-on-code': '#CCCCCC', + }, + }, +} + +export default themes diff --git a/core/src/private.html b/core/src/private.html new file mode 100644 index 0000000..48948a2 --- /dev/null +++ b/core/src/private.html @@ -0,0 +1,23 @@ + + + + + + + + K-Trix + + +

+ + + diff --git a/core/src/private.ts b/core/src/private.ts new file mode 100644 index 0000000..4b94c30 --- /dev/null +++ b/core/src/private.ts @@ -0,0 +1,72 @@ +import * as Vue from 'vue' +import * as VueRouterLib from 'vue-router' +import * as PiniaLib from 'pinia' +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar' +import { useModuleStore } from '@KTXC/stores/moduleStore' +import { useTenantStore } from '@KTXC/stores/tenantStore' +import { useUserStore } from '@KTXC/stores/userStore' +import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper' +import { initializeModules } from '@KTXC/utils/modules' +import App from './App.vue' +import router from './router' +import vuetify from './plugins/vuetify/index' +import '@KTXC/scss/style.scss' + +// Material Design Icons (Vuetify mdi icon set) +import '@mdi/font/css/materialdesignicons.min.css' + +// google-fonts +import '@fontsource/public-sans/index.css' + +const app = createApp(App) +const pinia = createPinia() +app.use(pinia) +app.use(PerfectScrollbarPlugin) +app.use(vuetify) + +// Note: Router is registered AFTER modules are loaded to prevent premature route matching + +const globalWindow = window as typeof window & { + [key: string]: unknown +} + +globalWindow.Vue = Vue +globalWindow.vue = Vue +globalWindow.VueRouter = VueRouterLib +globalWindow.Pinia = PiniaLib as unknown + +// Bootstrap initial private UI state (modules, tenant, user) before mounting +(async () => { + const moduleStore = useModuleStore(); + const tenantStore = useTenantStore(); + const userStore = useUserStore(); + + try { + const payload = await fetchWrapper.get('/init'); + moduleStore.init(payload?.modules ?? {}); + tenantStore.init(payload?.tenant ?? null); + userStore.init(payload?.user ?? {}); + + // Initialize registered modules (following reference app's bootstrap pattern) + await initializeModules(app); + + // Add 404 catch-all route AFTER all modules are loaded + // This ensures module routes are registered before the catch-all + router.addRoute({ + name: 'NotFound', + path: '/:pathMatch(.*)*', + component: () => import('@KTXC/views/pages/maintenance/error/Error404Page.vue') + }); + + // Register router AFTER modules are loaded + app.use(router); + + await router.isReady(); + // Home redirect handled by router beforeEnter + app.mount('#app'); + } catch (e) { + console.error('Bootstrap failed:', e); + } +})(); diff --git a/core/src/public.html b/core/src/public.html new file mode 100644 index 0000000..b3d8bdb --- /dev/null +++ b/core/src/public.html @@ -0,0 +1,13 @@ + + + + + + + Ktrix Cloud + + +
+ + + diff --git a/core/src/public.ts b/core/src/public.ts new file mode 100644 index 0000000..fff41d0 --- /dev/null +++ b/core/src/public.ts @@ -0,0 +1,39 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import App from './App.vue'; +import { router } from './router'; +import vuetify from './plugins/vuetify/index'; +import '@KTXC/scss/style.scss'; + +// Material Design Icons (Vuetify mdi icon set) +import '@mdi/font/css/materialdesignicons.min.css'; + +// google-fonts +import '@fontsource/public-sans/400.css'; +import '@fontsource/public-sans/500.css'; +import '@fontsource/public-sans/600.css'; +import '@fontsource/public-sans/700.css'; + +// The public app is served when the user has no valid server session. +// Clear any stale identity data from localStorage to ensure the client +// state matches the server's determination that the user is unauthenticated. +//localStorage.removeItem('identityStore.self'); + +const app = createApp(App); +const pinia = createPinia(); +app.use(pinia); +app.use(router); +app.use(vuetify); + +// Wait for router to be ready, then ensure we're on a public route +//router.isReady().then(() => { + // If the current route requires auth, redirect to login + // This handles the case where user navigates to / with an expired session + //const currentRoute = router.currentRoute.value; + //const requiresAuth = currentRoute.matched.some(record => record.meta?.requiresAuth); + //if (requiresAuth || currentRoute.path === '/') { + // router.replace('/login'); + //} +//}); + +app.mount('#app'); diff --git a/core/src/router/index.ts b/core/src/router/index.ts new file mode 100644 index 0000000..492c518 --- /dev/null +++ b/core/src/router/index.ts @@ -0,0 +1,116 @@ +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'; +import { useUserStore } from '@KTXC/stores/userStore'; +import { useLayoutStore } from '@KTXC/stores/layoutStore'; +import BlankLayout from '@KTXC/layouts/blank/BlankLayout.vue'; +import PrivateLayout from '@KTXC/views/PrivateLayout.vue'; +import { useIntegrationStore } from '@KTXC/stores/integrationStore'; + +const routes: RouteRecordRaw[] = [ + // Public login route + { + name: 'login', + path: '/login', + meta: { requiresAuth: false }, + component: BlankLayout, + children: [ + { + path: '', + component: () => import('@KTXC/views/authentication/LoginPage.vue') + } + ] + }, + // Logout performs action then redirects + { + name: 'logout', + path: '/logout', + meta: { requiresAuth: true }, + component: BlankLayout, + beforeEnter: async () => { + const userStore = useUserStore(); + await userStore.logout(); + return false; + } + }, + // Private area (shell layout). Module routes under /m/{namespace} are added at runtime. + { + name: 'private', + path: '/', + component: PrivateLayout, + meta: { requiresAuth: true }, + children: [ + // Index redirects to the first available module route (if any) + { + name: 'home', + path: '', + meta: { requiresAuth: true }, + component: BlankLayout, + beforeEnter: (to, from, next) => { + const integrationStore = useIntegrationStore(); + const userStore = useUserStore(); + + // Treat preference as a route name (e.g., "samples.overview") + const preferredRouteName = userStore.getSetting('default_module'); + if (preferredRouteName) { + // If a route with this name exists, go there + try { + // using router variable at runtime: + // eslint-disable-next-line @typescript-eslint/no-use-before-define + const exists = router.getRoutes().some(r => r.name === preferredRouteName); + if (exists) return next({ name: preferredRouteName }); + } catch {} + } + + // Get first available menu item from app_menu + const entries = integrationStore.getPoint('app_menu'); + for (const entry of entries) { + // Check if it's a group with items + if ('items' in entry && entry.items.length > 0) { + const first = entry.items[0]?.to; + if (first) return next(first); + } + // Or a standalone item + if ('to' in entry && entry.to) { + return next(entry.to); + } + } + return next(); + } + } + ] + } +]; + +export const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes +}); + +router.beforeEach(async (to, from, next) => { + const userStore = useUserStore(); + + const authRequired = to.matched.some((record) => record.meta?.requiresAuth); + + if (authRequired && !userStore.isAuthenticated && to.path !== '/login') { + userStore.returnUrl = to.fullPath; + return next('/login'); + } + + if (userStore.isAuthenticated && to.path === '/login') { + const dest = userStore.returnUrl && userStore.returnUrl !== '/' ? userStore.returnUrl : '/'; + return next(dest); + } + + return next(); +}); + +router.beforeEach(() => { + const layoutStore = useLayoutStore(); + layoutStore.isLoading = true; +}); + +router.afterEach(() => { + const layoutStore = useLayoutStore(); + layoutStore.isLoading = false; +}); + +export default router \ No newline at end of file diff --git a/core/src/scss/_override.scss b/core/src/scss/_override.scss new file mode 100644 index 0000000..df5b1d3 --- /dev/null +++ b/core/src/scss/_override.scss @@ -0,0 +1,115 @@ +html { + .bg-success, + .bg-info, + .bg-warning { + color: white !important; + } +} + +.v-row + .v-row { + margin-top: 0px; +} + +.v-divider { + opacity: 1; + border-color: rgb(var(--v-theme-borderLight)); +} + +.v-table > .v-table__wrapper > table > thead > tr > th { + color: inherit; +} + +.border-blue-right { + border-right: 1px solid rgba(var(--v-theme-borderLight), 0.36); +} + +.link-hover { + text-decoration: unset; + &:hover { + text-decoration: underline; + } +} + +.v-selection-control { + flex: unset; +} + +.customizer-btn .icon { + animation: progress-circular-rotate 1.4s linear infinite; + transform-origin: center center; + transition: all 0.2s ease-in-out; +} + +.no-spacer { + .v-list-item__spacer { + display: none !important; + } +} + +@keyframes progress-circular-rotate { + 100% { + transform: rotate(270deg); + } +} + +header { + &.v-toolbar--border { + border-color: rgb(var(--v-theme-borderLight)); + } +} + +.v-toolbar { + &.v-app-bar { + border-bottom: 1px solid rgba(var(--v-theme-borderLight), 0.8); + } +} + +.v-sheet--border { + border: 1px solid rgba(var(--v-theme-borderLight), 0.8); +} + +// table css +.v-table { + &.v-table--hover { + > .v-table__wrapper { + > table { + > tbody { + > tr { + &:hover { + td { + background: rgb(var(--v-theme-gray100)); + } + } + } + } + } + } + } +} + +// accordion page css +.v-expansion-panel { + border: 1px solid rgb(var(--v-theme-borderLight)); + &:not(:first-child) { + margin-top: -1px; + } + .v-expansion-panel-text__wrapper { + border-top: 1px solid rgb(var(--v-theme-borderLight)); + padding: 16px 24px; + } + &.v-expansion-panel--active { + .v-expansion-panel-title--active { + .v-expansion-panel-title__overlay { + background-color: rgb(var(--v-theme-gray100)); + } + } + } +} +.v-expansion-panel--active { + > .v-expansion-panel-title { + min-height: unset; + } +} +.v-expansion-panel--disabled .v-expansion-panel-title { + color: rgba(var(--v-theme-on-surface), 0.15); +} diff --git a/core/src/scss/_variables.scss b/core/src/scss/_variables.scss new file mode 100644 index 0000000..3f2ae89 --- /dev/null +++ b/core/src/scss/_variables.scss @@ -0,0 +1,140 @@ +@use 'sass:math'; +@use 'sass:map'; +@use 'sass:meta'; +@use 'vuetify/lib/styles/tools/functions' as *; + +// This will false all colors which is not necessory for theme +$color-pack: false; + +// Global font size and border radius +$font-size-root: 1rem; +$border-radius-root: 4px; +$body-font-family: 'Public sans', sans-serif !default; +$heading-font-family: $body-font-family !default; +$btn-font-weight: 400 !default; +$btn-letter-spacing: 0 !default; + +// Global Radius as per breakeven point +$rounded: () !default; +$rounded: map-deep-merge( + ( + 0: 0, + 'sm': $border-radius-root * 0.5, + null: $border-radius-root, + 'md': $border-radius-root * 1, + 'lg': $border-radius-root * 2, + 'xl': $border-radius-root * 6, + 'pill': 9999px, + 'circle': 50%, + 'shaped': $border-radius-root * 6 0 + ), + $rounded +); +// Global Typography +$typography: () !default; +$typography: map-deep-merge( + ( + 'h1': ( + 'size': 2.375rem, + 'weight': 600, + 'line-height': 1.21, + 'font-family': inherit + ), + 'h2': ( + 'size': 1.875rem, + 'weight': 600, + 'line-height': 1.27, + 'font-family': inherit + ), + 'h3': ( + 'size': 1.5rem, + 'weight': 600, + 'line-height': 1.33, + 'font-family': inherit + ), + 'h4': ( + 'size': 1.25rem, + 'weight': 600, + 'line-height': 1.4, + 'font-family': inherit + ), + 'h5': ( + 'size': 1rem, + 'weight': 600, + 'line-height': 1.5, + 'font-family': inherit + ), + 'h6': ( + 'size': 0.875rem, + 'weight': 400, + 'line-height': 1.57, + 'font-family': inherit + ), + 'subtitle-1': ( + 'size': 0.875rem, + 'weight': 600, + 'line-height': 1.57, + 'font-family': inherit + ), + 'subtitle-2': ( + 'size': 0.75rem, + 'weight': 500, + 'line-height': 1.66, + 'font-family': inherit + ), + 'body-1': ( + 'size': 0.875rem, + 'weight': 400, + 'line-height': 1.57, + 'font-family': inherit + ), + 'body-2': ( + 'size': 0.75rem, + 'weight': 400, + 'line-height': 1.66, + 'font-family': inherit + ), + 'button': ( + 'size': 0.875rem, + 'weight': 500, + 'font-family': inherit, + 'text-transform': uppercase + ), + 'caption': ( + 'size': 0.75rem, + 'weight': 400, + 'letter-spacing': 0, + 'font-family': inherit + ), + 'overline': ( + 'size': 0.75rem, + 'weight': 500, + 'font-family': inherit, + 'line-height': 1.67, + 'letter-spacing': 0, + 'text-transform': uppercase + ) + ), + $typography +); + +// Custom Variables +// colors +$white: #fff !default; + +// cards +$card-item-spacer-xy: 20px !default; +$card-text-spacer: 20px !default; +$card-title-size: 16px !default; + +// Global Shadow +$box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.08); + +$theme-colors: ( + primary: var(--v-theme-primary), + secondary: var(--v-theme-secondary), + success: var(--v-theme-success), + info: var(--v-theme-info), + warning: var(--v-theme-warning), + error: var(--v-theme-error) +); diff --git a/core/src/scss/components/_VAlert.scss b/core/src/scss/components/_VAlert.scss new file mode 100644 index 0000000..64c8eba --- /dev/null +++ b/core/src/scss/components/_VAlert.scss @@ -0,0 +1,37 @@ +.single-line-alert { + .v-alert__close, + .v-alert__prepend { + align-self: center !important; + } +} + +.v-alert__prepend { + align-self: center; +} + +.v-alert--variant-tonal { + &.with-border { + @each $color, $value in $theme-colors { + &.text-#{$color} { + border: 1px solid rgba(#{$value}, 0.3); + } + } + } +} + +@media (max-width: 500px) { + .single-line-alert { + display: flex; + flex-wrap: wrap; + .v-alert__append { + margin-inline-start: 0px; + } + .v-alert__close { + margin-left: auto; + } + .v-alert__content { + width: 100%; + margin-top: 5px; + } + } +} diff --git a/core/src/scss/components/_VBadge.scss b/core/src/scss/components/_VBadge.scss new file mode 100644 index 0000000..a9871c4 --- /dev/null +++ b/core/src/scss/components/_VBadge.scss @@ -0,0 +1,11 @@ +.v-badge__badge { + min-width: 16px; + height: 16px; + padding: 4px; +} +.v-badge--dot { + .v-badge__badge { + height: 8px; + width: 8px; + } +} diff --git a/core/src/scss/components/_VBreadcrumb.scss b/core/src/scss/components/_VBreadcrumb.scss new file mode 100644 index 0000000..f1ebcff --- /dev/null +++ b/core/src/scss/components/_VBreadcrumb.scss @@ -0,0 +1,32 @@ +.v-breadcrumbs-item--link { + color: rgb(var(--v-theme-lightText)); +} +.v-breadcrumbs { + .v-breadcrumbs-item--disabled { + --v-disabled-opacity: 1; + .v-breadcrumbs-item--link { + color: rgb(var(--v-theme-darkText)); + } + } + .v-breadcrumbs-divider { + color: rgb(var(--v-theme-lightText)); + } +} + +.breadcrumb-with-title { + .v-toolbar__content { + height: unset !important; + padding: 20px 0; + } + .v-breadcrumbs__prepend { + svg { + vertical-align: -3px; + } + } +} + +.breadcrumb-height { + .v-toolbar__content { + height: unset !important; + } +} diff --git a/core/src/scss/components/_VButtons.scss b/core/src/scss/components/_VButtons.scss new file mode 100644 index 0000000..c4ef964 --- /dev/null +++ b/core/src/scss/components/_VButtons.scss @@ -0,0 +1,68 @@ +// +// Light Buttons +// + +.v-btn { + &.bg-lightprimary { + &:hover, + &:active, + &:focus { + background-color: rgb(var(--v-theme-primary)) !important; + color: $white !important; + } + } + &.bg-lightsecondary { + &:hover, + &:active, + &:focus { + background-color: rgb(var(--v-theme-secondary)) !important; + color: $white !important; + } + } + &.text-facebook { + &:hover, + &:active, + &:focus { + background-color: rgb(var(--v-theme-facebook)) !important; + color: $white !important; + } + } + &.text-twitter { + &:hover, + &:active, + &:focus { + background-color: rgb(var(--v-theme-twitter)) !important; + color: $white !important; + } + } + &.text-linkedin { + &:hover, + &:active, + &:focus { + background-color: rgb(var(--v-theme-linkedin)) !important; + color: $white !important; + } + } +} + +.v-btn { + text-transform: capitalize; + letter-spacing: $btn-letter-spacing; + font-weight: 400; +} +.v-btn--icon.v-btn--density-default { + width: calc(var(--v-btn-height) + 6px); + height: calc(var(--v-btn-height) + 6px); +} + +.v-btn-group .v-btn { + height: inherit !important; +} + +.v-btn-group { + border-color: rgba(var(--v-border-color), 1); +} + +.v-btn-group--divided .v-btn:not(:last-child) { + border-inline-end-color: rgba(var(--v-border-color), 1); +} diff --git a/core/src/scss/components/_VCard.scss b/core/src/scss/components/_VCard.scss new file mode 100644 index 0000000..b0e30b7 --- /dev/null +++ b/core/src/scss/components/_VCard.scss @@ -0,0 +1,40 @@ +// Outline Card +.v-card--variant-outlined { + border-color: rgba(var(--v-theme-borderLight), 1); + .v-divider { + border-color: rgba(var(--v-theme-borderLight), 0.8); + } +} + +.v-card-text { + padding: $card-text-spacer; +} + +.v-card-actions { + padding: 14px $card-text-spacer 14px; +} + +.v-card { + overflow: visible; + .v-card-title { + &.text-h6 { + font-weight: 600; + line-height: 1.57; + } + } +} + +.v-card-item { + padding: $card-item-spacer-xy; +} + +.v-card-subtitle { + font-size: 0.75rem; +} + +.title-card { + .v-card-text { + background-color: rgb(var(--v-theme-background)); + border: 1px solid rgba(var(--v-theme-borderLight), 1); + } +} diff --git a/core/src/scss/components/_VField.scss b/core/src/scss/components/_VField.scss new file mode 100644 index 0000000..97352ac --- /dev/null +++ b/core/src/scss/components/_VField.scss @@ -0,0 +1,9 @@ +.v-field--variant-outlined .v-field__outline__start.v-locale--is-ltr, +.v-locale--is-ltr .v-field--variant-outlined .v-field__outline__start { + border-radius: $border-radius-root 0 0 $border-radius-root; +} + +.v-field--variant-outlined .v-field__outline__end.v-locale--is-ltr, +.v-locale--is-ltr .v-field--variant-outlined .v-field__outline__end { + border-radius: 0 $border-radius-root $border-radius-root 0; +} diff --git a/core/src/scss/components/_VInput.scss b/core/src/scss/components/_VInput.scss new file mode 100644 index 0000000..976f726 --- /dev/null +++ b/core/src/scss/components/_VInput.scss @@ -0,0 +1,55 @@ +.v-input--density-default:not(.v-autocomplete--multiple), +.v-field--variant-solo, +.v-field--variant-filled { + --v-input-control-height: 39px; + --v-input-padding-top: 2px; + input.v-field__input { + padding-bottom: 2px; + } + .v-field__input { + padding-bottom: 2px; + } + textarea { + padding-top: 11px; + } +} +.v-input--density-default { + .v-field__input { + min-height: 41px; + } +} +.v-field--variant-outlined { + &.v-field--focused { + .v-field__outline { + --v-field-border-width: 1px; + } + } +} +.v-input { + .v-input__details { + padding-inline: 0; + } +} + +.v-input--density-comfortable { + --v-input-control-height: 56px; + --v-input-padding-top: 17px; +} +.v-label { + font-size: 0.875rem; + --v-medium-emphasis-opacity: 0.8; +} +.v-switch .v-label, +.v-checkbox .v-label { + opacity: 1; +} + +textarea.v-field__input { + font-size: 14px; +} + +.textarea-input { + .v-label { + top: 15px; + } +} diff --git a/core/src/scss/components/_VList.scss b/core/src/scss/components/_VList.scss new file mode 100644 index 0000000..0e6cd11 --- /dev/null +++ b/core/src/scss/components/_VList.scss @@ -0,0 +1,47 @@ +.v-list-item { + &.v-list-item--border { + border-color: rgb(var(--v-border-color)); + border-width: 0 0 1px 0; + &:last-child { + border-width: 0; + } + } + &.v-list-item--variant-tonal { + background: rgb(var(--v-theme-gray100)); + .v-list-item__underlay { + background: transparent; + } + } + &:last-child { + .v-list-item__content { + .v-divider--inset { + display: none; + } + } + } +} +.v-list { + &[aria-busy='true'] { + cursor: context-menu; + } +} +.v-list-group__items { + .v-list-item { + padding-inline-start: 40px !important; + } +} + +.v-list-item__content { + .v-divider--inset:not(.v-divider--vertical) { + max-width: 100%; + margin-inline-start: 0; + } +} + +.v-list--border { + .v-list-item { + + .v-list-item { + border-top: 1px solid rgb(var(--v-theme-borderLight)); + } + } +} diff --git a/core/src/scss/components/_VNavigationDrawer.scss b/core/src/scss/components/_VNavigationDrawer.scss new file mode 100644 index 0000000..9994ae9 --- /dev/null +++ b/core/src/scss/components/_VNavigationDrawer.scss @@ -0,0 +1,3 @@ +.v-navigation-drawer__scrim.fade-transition-leave-to { + display: none; +} diff --git a/core/src/scss/components/_VShadow.scss b/core/src/scss/components/_VShadow.scss new file mode 100644 index 0000000..647b098 --- /dev/null +++ b/core/src/scss/components/_VShadow.scss @@ -0,0 +1,20 @@ +.elevation-24 { + box-shadow: $box-shadow !important; +} + +.v-menu { + > .v-overlay__content { + > .v-sheet { + box-shadow: $box-shadow; + } + } +} + +@each $color, $value in $theme-colors { + .#{$color}-shadow { + box-shadow: 0 14px 12px rgba(#{$value}, 0.2); + &:hover { + box-shadow: none; + } + } +} diff --git a/core/src/scss/components/_VTextField.scss b/core/src/scss/components/_VTextField.scss new file mode 100644 index 0000000..153b10c --- /dev/null +++ b/core/src/scss/components/_VTextField.scss @@ -0,0 +1,18 @@ +.v-text-field input { + font-size: 0.875rem; +} + +.v-field__outline { + color: rgb(var(--v-theme-inputBorder)); +} +.inputWithbg { + .v-field--variant-outlined { + background-color: rgba(0, 0, 0, 0.025); + } +} + +.v-select { + .v-field { + font-size: 0.875rem; + } +} diff --git a/core/src/scss/components/_VTextarea.scss b/core/src/scss/components/_VTextarea.scss new file mode 100644 index 0000000..b611c41 --- /dev/null +++ b/core/src/scss/components/_VTextarea.scss @@ -0,0 +1,7 @@ +.v-textarea input { + font-size: 0.875rem; + font-weight: 500; + &::placeholder { + color: rgba(0, 0, 0, 0.38); + } +} diff --git a/core/src/scss/layout/_container.scss b/core/src/scss/layout/_container.scss new file mode 100644 index 0000000..b42a0c5 --- /dev/null +++ b/core/src/scss/layout/_container.scss @@ -0,0 +1,146 @@ +html { + overflow-y: auto; +} +.horizontalLayout { + .page-wrapper { + .v-container { + padding-top: 20px; + } + } +} +.spacer { + padding: 100px 0; + @media (max-width: 1264px) { + padding: 72px 0; + } +} +@media (max-width: 800px) { + .spacer { + padding: 40px 0; + } +} + +.cursor-pointer { + cursor: pointer; +} +.page-wrapper { + background: rgb(var(--v-theme-containerBg)); + display: flex; + flex-direction: column; + height: calc(100vh - 60px); + overflow: hidden; + + .page-content-container { + flex: 1; + overflow: hidden; + min-height: 0; + display: flex; + flex-direction: column; + padding: 15px; + @media (max-width: 1550px) { + max-width: 100%; + } + @media (min-width: 768px) { + padding-inline: 40px; + } + } + + .page-footer-container { + flex-shrink: 0; + } + + .v-container { + padding: 15px; + @media (max-width: 1550px) { + max-width: 100%; + } + @media (min-width: 768px) { + padding-inline: 40px; + } + } +} +.maxWidth { + max-width: 1200px; + margin: 0 auto; +} +$sizes: ( + 'display-1': 44px, + 'display-2': 40px, + 'display-3': 30px, + 'h1': 36px, + 'h2': 30px, + 'h3': 21px, + 'h4': 18px, + 'h5': 16px, + 'h6': 14px, + 'text-8': 8px, + 'text-10': 10px, + 'text-13': 13px, + 'text-18': 18px, + 'text-20': 20px, + 'text-24': 24px, + 'body-text-1': 10px +); + +@each $pixel, $size in $sizes { + .#{$pixel} { + font-size: $size; + line-height: $size + 10; + } +} + +.customizer-btn { + .icon { + animation: progress-circular-rotate 1.4s linear infinite; + transform-origin: center center; + transition: all 0.2s ease-in-out; + } +} +.fixed-width { + max-width: 1300px; +} +.ga-2 { + gap: 8px; +} + +// font family +body { + font-family: 'Public Sans', sans-serif; + .Roboto { + font-family: 'Roboto', sans-serif !important; + } + + .Poppins { + font-family: 'Poppins', sans-serif !important; + } + + .Inter { + font-family: 'Inter', sans-serif !important; + } + + .Public { + font-family: 'Public sans', sans-serif !important; + } +} + +@keyframes slideY { + 0%, + 50%, + 100% { + transform: translateY(0px); + } + 25% { + transform: translateY(-10px); + } + 75% { + transform: translateY(10px); + } +} + +.link { + color: rgb(var(--v-theme-lightText)); + text-decoration: none; + &:hover { + color: rgb(var(--v-theme-primary)); + } +} diff --git a/core/src/scss/layout/_footer.scss b/core/src/scss/layout/_footer.scss new file mode 100644 index 0000000..67f478d --- /dev/null +++ b/core/src/scss/layout/_footer.scss @@ -0,0 +1,25 @@ +.v-footer { + background: rgb(var(--v-theme-containerbg)); + padding: 24px 16px 0px; + margin-top: auto; + position: unset; + a { + text-decoration: unset; + &:hover { + text-decoration: underline; + } + } +} + +@media (max-width: 475px) { + .footer { + text-align: center; + .v-col-6 { + flex: 0 0 100%; + max-width: 100%; + &.text-right { + text-align: center !important; + } + } + } +} diff --git a/core/src/scss/layout/_sidebar.scss b/core/src/scss/layout/_sidebar.scss new file mode 100644 index 0000000..e9af48c --- /dev/null +++ b/core/src/scss/layout/_sidebar.scss @@ -0,0 +1,165 @@ +/*This is for the logo*/ +.leftSidebar { + border: 0px; + box-shadow: none !important; + border-right: 1px solid rgba(var(--v-theme-borderLight), 0.8); + .logo { + padding-left: 7px; + } +} +/*This is for the Vertical sidebar*/ +.scrollnavbar { + height: calc(100vh - 110px); + .smallCap { + padding: 0px 0 0 20px !important; + } + .v-list { + color: rgb(var(--v-theme-lightText)); + padding: 0; + .v-list-item--one-line { + &.v-list-item--active { + border-right: 2px solid rgb(var(--v-theme-primary)); + } + } + .v-list-group { + .v-list-item--one-line { + &.v-list-item--active.v-list-item--link { + border-right: 2px solid rgb(var(--v-theme-primary)); + } + &.v-list-item--active.v-list-group__header { + border-right: none; + background: transparent; + } + } + .v-list-group__items { + .v-list-item--link, + .v-list-item { + .v-list-item__prepend { + margin-inline-end: 1px; + } + } + } + } + .v-list-item--variant-plain, + .v-list-item--variant-outlined, + .v-list-item--variant-text, + .v-list-item--variant-tonal { + color: rgb(var(--v-theme-darkText)); + } + } + /*General Menu css*/ + .v-list-group__items .v-list-item, + .v-list-item { + border-radius: 0; + padding-inline-start: calc(20px + var(--indent-padding) / 2) !important; + .v-list-item__prepend { + margin-inline-end: 13px; + } + .v-list-item__append { + font-size: 0.875rem; + .v-icon { + margin-inline-start: 13px; + } + > .v-icon { + --v-medium-emphasis-opacity: 0.8; + } + } + .v-list-item-title { + font-size: 0.875rem; + color: rgb(var(--v-theme-darkText)); + } + &.v-list-item--active { + .v-list-item-title { + color: rgb(var(--v-theme-primary)); + } + } + } + /*This is for the dropdown*/ + .v-list { + .v-list-item--active { + .v-list-item-title { + font-weight: 500; + } + } + .sidebarchip .v-icon { + margin-inline-start: -3px; + } + .v-list-group { + .v-list-item:focus-visible > .v-list-item__overlay { + opacity: 0; + } + } + > .v-list-group { + position: relative; + > .v-list-item--active, + > .v-list-item:hover { + background: rgb(var(--v-theme-primary), 0.05); + } + } + } +} +.v-navigation-drawer--rail { + .scrollnavbar .v-list .v-list-group__items, + .hide-menu { + opacity: 0; + } + .scrollnavbar { + .v-list-item { + .v-list-item__prepend { + margin-left: 8px; + .anticon { + svg { + width: 20px; + height: 20px; + } + } + } + } + .v-list-group__items .v-list-item, + .v-list-item { + padding-inline-start: calc(12px + var(--indent-padding) / 2) !important; + } + .ExtraBox { + display: none; + } + } + .sidebar-user { + margin-left: -6px; + } + .leftPadding { + margin-left: 0px; + } + &.leftSidebar { + .v-list-subheader { + display: none; + } + .v-navigation-drawer__content { + .pa-5 { + padding-left: 10px !important; + .logo { + padding-left: 0; + } + } + } + } +} +@media only screen and (min-width: 1170px) { + .mini-sidebar { + .logo { + width: 40px; + overflow: hidden; + } + .leftSidebar:hover { + box-shadow: $box-shadow !important; + } + .v-navigation-drawer--expand-on-hover:hover { + .logo { + width: 100%; + } + .v-list .v-list-group__items, + .hide-menu { + opacity: 1; + } + } + } +} diff --git a/core/src/scss/layout/_topbar.scss b/core/src/scss/layout/_topbar.scss new file mode 100644 index 0000000..6d150f7 --- /dev/null +++ b/core/src/scss/layout/_topbar.scss @@ -0,0 +1,41 @@ +.profileBtn { + height: 44px !important; + margin: 0 20px 0 10px !important; + padding: 0 6px; + .v-avatar { + width: 32px; + height: 32px; + img { + width: 32px; + height: 32px; + } + } +} + +@media (max-width: 600px) { + .profileBtn { + min-width: 42px; + margin: 0 12px 0 0 !important; + .v-avatar { + width: 24px; + height: 24px; + img { + width: 24px; + height: 24px; + } + } + } +} + +@media (max-width: 460px) { + .notification-dropdown { + width: 332px !important; + .v-list-item__content { + .d-inline-flex { + .text-caption { + min-width: 50px; + } + } + } + } +} diff --git a/core/src/scss/style.scss b/core/src/scss/style.scss new file mode 100644 index 0000000..7d95dbc --- /dev/null +++ b/core/src/scss/style.scss @@ -0,0 +1,9 @@ +@import './variables'; +@import 'vuetify/styles/main.sass'; +@import './override'; +@import './layout/container'; +@import './layout/sidebar'; +@import './layout/footer'; +@import './layout/topbar'; + +@import 'vue3-perfect-scrollbar/style.css'; diff --git a/core/src/services/authenticationService.ts b/core/src/services/authenticationService.ts new file mode 100644 index 0000000..c5a51e5 --- /dev/null +++ b/core/src/services/authenticationService.ts @@ -0,0 +1,100 @@ +import type { ChallengeResponse, IdentifyResponse, RedirectResponse, SessionStatus, StartResponse, VerifyResponse } from '@KTXC/types/authenticationTypes'; +import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper'; + +export const authenticationService = { + /** + * Initialize authentication - get session and available methods + */ + async start(): Promise { + return fetchWrapper.get('/auth/start', undefined, { skipLogoutOnError: true }); + }, + + /** + * Identify user - stores identity in session for identity-first flow + * Returns tenant-wide methods (no user-specific filtering to prevent enumeration) + * + * @param session - Session ID from start + * @param identity - User identity (email/username) + */ + async identify(session: string, identity: string): Promise { + return fetchWrapper.post('/auth/identify', { + session, + identity, + }, { skipLogoutOnError: true }); + }, + + /** + * Verify a factor (primary or secondary) + * + * @param session - Session ID from init + * @param method - Provider/method ID (e.g., 'default', 'totp') + * @param response - User's response (password, code, etc.) + * @param identity - User identity for credential-based auth (email/username) + */ + async verify( + session: string, + method: string, + response: string, + identity?: string + ): Promise { + return fetchWrapper.post('/auth/verify', { + session, + method, + response, + ...(identity && { identity }), + }, { autoRetry: false, skipLogoutOnError: true }); + }, + + /** + * Begin redirect-based authentication (OIDC/SAML) + */ + async beginRedirect( + session: string, + method: string, + returnUrl: string = '/' + ): Promise { + return fetchWrapper.post('/auth/redirect', { + session, + method, + return_url: returnUrl, + }, { skipLogoutOnError: true }); + }, + + /** + * Start a challenge for methods that require it (SMS, email, TOTP) + */ + async beginChallenge(session: string, method: string): Promise { + return fetchWrapper.post('/auth/challenge', { + session, + method, + }, { skipLogoutOnError: true }); + }, + + /** + * Get current session status + */ + async getStatus(session: string): Promise { + return fetchWrapper.get(`/auth/status?session=${encodeURIComponent(session)}`, undefined, { skipLogoutOnError: true }); + }, + + /** + * Cancel authentication session + */ + async cancelSession(session: string): Promise { + await fetchWrapper.delete(`/auth/session?session=${encodeURIComponent(session)}`); + }, + + /** + * Refresh access token + */ + async refresh(): Promise { + await fetchWrapper.post('/auth/refresh', {}); + }, + + /** + * Logout + */ + async logout(): Promise { + await fetchWrapper.post('/auth/logout', {}); + }, +}; diff --git a/core/src/services/user/index.ts b/core/src/services/user/index.ts new file mode 100644 index 0000000..84d8947 --- /dev/null +++ b/core/src/services/user/index.ts @@ -0,0 +1 @@ +export { userService } from './userService'; diff --git a/core/src/services/user/userService.ts b/core/src/services/user/userService.ts new file mode 100644 index 0000000..58c713a --- /dev/null +++ b/core/src/services/user/userService.ts @@ -0,0 +1,153 @@ +import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper'; + +/** + * User Service + * Provides methods for managing user profile and settings with batched updates + */ + +// Pending updates for profile and settings +let pendingProfileUpdates: Record = {}; +let pendingSettingsUpdates: Record = {}; +let profileUpdateTimer: ReturnType | null = null; +let settingsUpdateTimer: ReturnType | null = null; + +// Default debounce delay in milliseconds +const DEBOUNCE_DELAY = 500; + +export const userService = { + /** + * Update profile field(s) with debouncing + * Multiple calls within the debounce window will be batched into a single request + */ + updateProfile(fields: Record): Promise { + return new Promise((resolve, reject) => { + // Merge new fields with pending updates + Object.assign(pendingProfileUpdates, fields); + + // Clear existing timer + if (profileUpdateTimer) { + clearTimeout(profileUpdateTimer); + } + + // Set new timer to batch updates + profileUpdateTimer = setTimeout(async () => { + const updates = { ...pendingProfileUpdates }; + pendingProfileUpdates = {}; + profileUpdateTimer = null; + + try { + await fetchWrapper.put('/user/profile', { data: updates }); + resolve(); + } catch (error) { + reject(error); + } + }, DEBOUNCE_DELAY); + }); + }, + + /** + * Update profile field(s) immediately without debouncing + */ + async updateProfileImmediate(fields: Record): Promise { + // Cancel pending debounced update + if (profileUpdateTimer) { + clearTimeout(profileUpdateTimer); + profileUpdateTimer = null; + } + + // Merge with pending updates and send immediately + const updates = { ...pendingProfileUpdates, ...fields }; + pendingProfileUpdates = {}; + + return fetchWrapper.put('/user/profile', { data: updates }); + }, + + /** + * Flush any pending profile updates immediately + */ + async flushProfileUpdates(): Promise { + if (profileUpdateTimer) { + clearTimeout(profileUpdateTimer); + profileUpdateTimer = null; + } + + if (Object.keys(pendingProfileUpdates).length > 0) { + const updates = { ...pendingProfileUpdates }; + pendingProfileUpdates = {}; + return fetchWrapper.put('/user/profile', { data: updates }); + } + }, + + /** + * Update setting(s) with debouncing + * Multiple calls within the debounce window will be batched into a single request + */ + updateSettings(settings: Record): Promise { + return new Promise((resolve, reject) => { + // Merge new settings with pending updates + Object.assign(pendingSettingsUpdates, settings); + + // Clear existing timer + if (settingsUpdateTimer) { + clearTimeout(settingsUpdateTimer); + } + + // Set new timer to batch updates + settingsUpdateTimer = setTimeout(async () => { + const updates = { ...pendingSettingsUpdates }; + pendingSettingsUpdates = {}; + settingsUpdateTimer = null; + + try { + await fetchWrapper.put('/user/settings', { data: updates }); + resolve(); + } catch (error) { + reject(error); + } + }, DEBOUNCE_DELAY); + }); + }, + + /** + * Update setting(s) immediately without debouncing + */ + async updateSettingsImmediate(settings: Record): Promise { + // Cancel pending debounced update + if (settingsUpdateTimer) { + clearTimeout(settingsUpdateTimer); + settingsUpdateTimer = null; + } + + // Merge with pending updates and send immediately + const updates = { ...pendingSettingsUpdates, ...settings }; + pendingSettingsUpdates = {}; + + return fetchWrapper.put('/user/settings', { data: updates }); + }, + + /** + * Flush any pending settings updates immediately + */ + async flushSettingsUpdates(): Promise { + if (settingsUpdateTimer) { + clearTimeout(settingsUpdateTimer); + settingsUpdateTimer = null; + } + + if (Object.keys(pendingSettingsUpdates).length > 0) { + const updates = { ...pendingSettingsUpdates }; + pendingSettingsUpdates = {}; + return fetchWrapper.put('/user/settings', { data: updates }); + } + }, + + /** + * Flush all pending updates (profile and settings) + */ + async flushAll(): Promise { + await Promise.all([ + this.flushProfileUpdates(), + this.flushSettingsUpdates(), + ]); + }, +}; diff --git a/core/src/shims-vue.d.ts b/core/src/shims-vue.d.ts new file mode 100644 index 0000000..64c3fd9 --- /dev/null +++ b/core/src/shims-vue.d.ts @@ -0,0 +1,5 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + const component: DefineComponent<{}, {}, any>; + export default component; +} diff --git a/core/src/stores/integrationStore.ts b/core/src/stores/integrationStore.ts new file mode 100644 index 0000000..f2d3c59 --- /dev/null +++ b/core/src/stores/integrationStore.ts @@ -0,0 +1,183 @@ +import { defineStore } from 'pinia'; +import type { + IntegrationPointType, + IntegrationPoint, + IntegrationEntry, + IntegrationItem, + IntegrationGroup +} from '@KTXC/types/integrationTypes'; + +export const useIntegrationStore = defineStore('integrationStore', { + state: () => ({ + points: new Map(), + }), + + actions: { + // Ensure an integration point exists + ensurePoint(pointType: IntegrationPointType): void { + if (!this.points.has(pointType)) { + this.points.set(pointType, { items: new Map() }); + } + }, + + // Register a single item to an integration point + registerItem(pointType: IntegrationPointType, item: IntegrationItem): void { + this.ensurePoint(pointType); + const point = this.points.get(pointType)!; + point.items.set(item.id, item); + }, + + // Register a group to an integration point + registerGroup(pointType: IntegrationPointType, group: IntegrationGroup): void { + this.ensurePoint(pointType); + const point = this.points.get(pointType)!; + point.items.set(group.id, group); + }, + + // Bulk register from module integrations + registerModuleIntegrations( + moduleHandle: string, + integrations: Record + ): void { + for (const [pointType, entries] of Object.entries(integrations)) { + if (!entries || !Array.isArray(entries)) continue; + + entries.forEach((entry: any) => { + const prefixedEntry = this.prefixEntry(moduleHandle, entry); + + if (entry.type === 'group' || ('items' in entry && Array.isArray(entry.items))) { + this.registerGroup(pointType as IntegrationPointType, prefixedEntry as IntegrationGroup); + } else { + this.registerItem(pointType as IntegrationPointType, prefixedEntry as IntegrationItem); + } + }); + } + }, + + // Prefix IDs and paths with module handle + prefixEntry(moduleHandle: string, entry: any): IntegrationEntry { + const prefixed: any = { + ...entry, + id: entry.id ? `${moduleHandle}.${entry.id}` : `${moduleHandle}.${this.randomID()}`, + moduleHandle, + }; + + // Remove 'type' field as it's only used for module-side disambiguation + delete prefixed.type; + + // Prefix internal paths + if (entry.path) { + prefixed.to = `/m/${moduleHandle}${entry.path}`; + delete prefixed.path; + } else if (entry.to && entry.toType !== 'external') { + prefixed.to = `/m/${moduleHandle}${entry.to}`; + } + + // Recursively prefix items in groups + if (entry.items && Array.isArray(entry.items)) { + prefixed.items = entry.items.map((item: any) => this.prefixEntry(moduleHandle, item)); + } + + return prefixed; + }, + + // Update a specific item (useful for badges, visibility, etc.) + updateItem( + pointType: IntegrationPointType, + itemId: string, + updates: Partial + ): void { + const point = this.points.get(pointType); + if (!point) return; + + const item = point.items.get(itemId); + if (item && !('items' in item)) { + point.items.set(itemId, { ...item, ...updates }); + } + }, + + // Update badge for notification icons + updateBadge( + pointType: IntegrationPointType, + itemId: string, + badge: string | number | null, + badgeColor?: string + ): void { + this.updateItem(pointType, itemId, { badge, badgeColor }); + }, + + // Toggle visibility + setVisibility( + pointType: IntegrationPointType, + itemId: string, + visible: boolean + ): void { + this.updateItem(pointType, itemId, { visible }); + }, + + // Remove an item + unregisterItem(pointType: IntegrationPointType, itemId: string): void { + const point = this.points.get(pointType); + if (point) { + point.items.delete(itemId); + } + }, + + // Remove all items from a module + unregisterModule(moduleHandle: string): void { + this.points.forEach((point) => { + point.items.forEach((entry, id) => { + if (entry.moduleHandle === moduleHandle) { + point.items.delete(id); + } + }); + }); + }, + + reset(): void { + this.points.clear(); + }, + + randomID(length = 8): string { + return Math.random().toString(36).substring(2, 2 + length); + }, + }, + + getters: { + // Get all entries for an integration point, sorted by priority + getPoint: (state) => (pointType: IntegrationPointType): IntegrationEntry[] => { + const point = state.points.get(pointType); + if (!point) return []; + + return Array.from(point.items.values()) + .filter(entry => entry.visible !== false) + .sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100)); + }, + + // Get items only (no groups) + getItems: (state) => (pointType: IntegrationPointType): IntegrationItem[] => { + const point = state.points.get(pointType); + if (!point) return []; + + return Array.from(point.items.values()) + .filter((entry): entry is IntegrationItem => !('items' in entry) && entry.visible !== false) + .sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100)); + }, + + // Get groups only + getGroups: (state) => (pointType: IntegrationPointType): IntegrationGroup[] => { + const point = state.points.get(pointType); + if (!point) return []; + + return Array.from(point.items.values()) + .filter((entry): entry is IntegrationGroup => 'items' in entry && entry.visible !== false) + .sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100)); + }, + + // Get a specific item by ID + getItemById: (state) => (pointType: IntegrationPointType, itemId: string): IntegrationEntry | undefined => { + const point = state.points.get(pointType); + return point?.items.get(itemId); + }, + }, +}); diff --git a/core/src/stores/layoutStore.ts b/core/src/stores/layoutStore.ts new file mode 100644 index 0000000..32ec5a4 --- /dev/null +++ b/core/src/stores/layoutStore.ts @@ -0,0 +1,85 @@ +import { ref, watch } from 'vue'; +import { defineStore } from 'pinia'; +import config from '@KTXC/config'; +import { useUserStore } from './userStore'; + +export type MenuMode = 'apps' | 'user-settings' | 'admin-settings'; + +export const useLayoutStore = defineStore('layout', () => { + // Loading state + const isLoading = ref(false); + + // Sidebar state - initialize from settings or config + const userStore = useUserStore(); + const sidebarDrawer = ref(userStore.getSetting('sidebar_drawer') ?? config.Sidebar_drawer); + const miniSidebar = ref(userStore.getSetting('mini_sidebar') ?? config.mini_sidebar); + const menuMode = ref('apps'); + + // Theme state - initialize from settings or config + const theme = ref(userStore.getSetting('theme') ?? config.actTheme); + const font = ref(userStore.getSetting('font') ?? config.fontTheme); + + // Watch and sync sidebar state to settings + watch(sidebarDrawer, (value) => { + userStore.setSetting('sidebar_drawer', value); + }); + + watch(miniSidebar, (value) => { + userStore.setSetting('mini_sidebar', value); + }); + + watch(theme, (value) => { + userStore.setSetting('theme', value); + }); + + watch(font, (value) => { + userStore.setSetting('font', value); + }); + + // Actions + function toggleSidebarDrawer() { + sidebarDrawer.value = !sidebarDrawer.value; + } + + function setMiniSidebar(value: boolean) { + miniSidebar.value = value; + } + + function setTheme(value: string) { + theme.value = value; + } + + function setFont(value: string) { + font.value = value; + } + + function setMenuMode(value: MenuMode) { + menuMode.value = value; + } + + function toggleMenuMode() { + // Cycle through: apps -> user-settings -> admin-settings -> apps + const modes: MenuMode[] = ['apps', 'user-settings', 'admin-settings']; + const currentIndex = modes.indexOf(menuMode.value); + const nextIndex = (currentIndex + 1) % modes.length; + menuMode.value = modes[nextIndex]; + } + + return { + // State + isLoading, + sidebarDrawer, + miniSidebar, + menuMode, + theme, + font, + + // Actions + toggleSidebarDrawer, + setMiniSidebar, + setMenuMode, + toggleMenuMode, + setTheme, + setFont + }; +}); diff --git a/core/src/stores/moduleStore.ts b/core/src/stores/moduleStore.ts new file mode 100644 index 0000000..7eaa6a9 --- /dev/null +++ b/core/src/stores/moduleStore.ts @@ -0,0 +1,34 @@ +import { defineStore } from 'pinia'; +import type { ModuleCollection, ModuleObject } from '@KTXC/types/moduleTypes'; + +export const useModuleStore = defineStore('moduleStore', { + state: () => ({ + modules: {} as ModuleCollection, + }), + actions: { + init(data: ModuleCollection) { + this.modules = data ?? {}; + }, + markBooted(ns: string) { + const targetNs = String(ns).toLowerCase(); + Object.keys(this.modules).forEach((key) => { + const mod = this.modules[key] as ModuleObject; + if (!mod) return; + if (String(mod.namespace || mod.handle).toLowerCase() === targetNs) { + mod.booted = true; + } + }); + }, + reset() { + this.modules = {}; + }, + }, + getters: { + has: (state) => (handleOrNamespace: string) => { + const target = String(handleOrNamespace).toLowerCase(); + return Object.values(state.modules).some( + (mod) => mod && (String(mod.handle).toLowerCase() === target || String(mod.namespace).toLowerCase() === target) + ); + }, + }, +}); diff --git a/core/src/stores/tenantStore.ts b/core/src/stores/tenantStore.ts new file mode 100644 index 0000000..4c96706 --- /dev/null +++ b/core/src/stores/tenantStore.ts @@ -0,0 +1,27 @@ +import { defineStore } from 'pinia'; + +export interface TenantState { + id: string | null; + domain: string | null; + label: string | null; +} + +export const useTenantStore = defineStore('tenantStore', { + state: () => ({ + tenant: null as TenantState | null, + }), + actions: { + init(tenant: Partial | null) { + this.tenant = tenant + ? { + id: tenant.id ?? null, + domain: tenant.domain ?? null, + label: tenant.label ?? null, + } + : null; + }, + reset() { + this.tenant = null; + }, + }, +}); diff --git a/core/src/stores/userStore.ts b/core/src/stores/userStore.ts new file mode 100644 index 0000000..d7311a1 --- /dev/null +++ b/core/src/stores/userStore.ts @@ -0,0 +1,275 @@ +import { defineStore } from 'pinia'; +import { ref, computed } from 'vue'; +import { router } from '@KTXC/router'; +import { authenticationService } from '@KTXC/services/authenticationService'; +import { userService } from '@KTXC/services/user/userService'; +import type { AuthenticatedUser } from '@KTXC/types/authenticationTypes'; +import type { UserProfileInterface } from '@KTXC/types/user/userProfileTypes'; +import type { UserSettingsInterface } from '@KTXC/types/user/userSettingsTypes'; +import { UserProfile } from '@KTXC/models/userProfile'; +import { UserSettings } from '@KTXC/models/userSettings'; + +const STORAGE_KEY = 'userStore.auth'; + +// Flush pending updates before page unload +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => { + userService.flushAll(); + }); +} + +export const useUserStore = defineStore('userStore', () => { + // ========================================================================= + // State + // ========================================================================= + + const auth = ref( + localStorage.getItem(STORAGE_KEY) + ? (JSON.parse(localStorage.getItem(STORAGE_KEY)!) as AuthenticatedUser) + : null + ); + + const profile = ref(new UserProfile()); + const settings = ref(new UserSettings()); + const returnUrl = ref(null); + + // ========================================================================= + // Authentication Getters + // ========================================================================= + + const isAuthenticated = computed(() => auth.value !== null); + const identifier = computed(() => auth.value?.identifier ?? null); + const identity = computed(() => auth.value?.identity ?? null); + const label = computed(() => auth.value?.label ?? null); + const roles = computed(() => auth.value?.roles ?? []); + const permissions = computed(() => auth.value?.permissions ?? []); + + // ========================================================================= + // Profile Getters + // ========================================================================= + + const profileFields = computed(() => profile.value.fields); + + const editableProfileFields = computed(() => profile.value.editableFields); + + const managedProfileFields = computed(() => profile.value.managedFields); + + // ========================================================================= + // Authentication Actions + // ========================================================================= + + function setAuth(authUser: AuthenticatedUser): void { + auth.value = authUser; + localStorage.setItem(STORAGE_KEY, JSON.stringify(authUser)); + } + + function clearAuth(): void { + auth.value = null; + localStorage.removeItem(STORAGE_KEY); + } + + async function logout(): Promise { + try { + // Flush any pending profile/settings updates before logout + await userService.flushAll(); + + await authenticationService.logout(); + } catch (error) { + console.warn('Logout request failed, clearing local state:', error); + } finally { + clearAuth(); + clearProfile(); + clearSettings(); + router.push('/login'); + } + } + + async function refreshToken(): Promise { + try { + await authenticationService.refresh(); + return true; + } catch (error) { + await logout(); + return false; + } + } + + // ========================================================================= + // Profile Actions + // ========================================================================= + + function initProfile(profileData: UserProfileInterface): void { + profile.value = new UserProfile(profileData); + } + + function clearProfile(): void { + profile.value = new UserProfile(); + } + + function getProfileField(key: string): any { + return profile.value.get(key); + } + + function setProfileField(key: string, value: any): boolean { + const success = profile.value.set(key, value); + if (success) { + // Debounced update to backend + userService.updateProfile({ [key]: value }).catch(error => { + console.error('Failed to update profile:', error); + }); + } + return success; + } + + function isProfileFieldEditable(key: string): boolean { + return profile.value.isEditable(key); + } + + // ========================================================================= + // Settings Actions + // ========================================================================= + + function initSettings(settingsData: UserSettingsInterface): void { + settings.value = new UserSettings(settingsData); + } + + function clearSettings(): void { + settings.value = new UserSettings(); + } + + function getSetting(key: string): any { + return settings.value.get(key); + } + + function setSetting(key: string, value: any): void { + settings.value.set(key, value); + // Debounced update to backend + userService.updateSettings({ [key]: value }).catch(error => { + console.error('Failed to update setting:', error); + }); + } + + // ========================================================================= + // Permission Checking + // ========================================================================= + + /** + * Check if user has a specific permission + * Supports wildcards: user_manager.users.* matches all user actions + */ + function hasPermission(permission: string): boolean { + const userPermissions = permissions.value; + + // Exact match + if (userPermissions.includes(permission)) { + return true; + } + + // Wildcard match + for (const userPerm of userPermissions) { + if (userPerm.endsWith('.*')) { + const prefix = userPerm.slice(0, -2); + if (permission.startsWith(prefix + '.')) { + return true; + } + } + } + + // Full wildcard + if (userPermissions.includes('*')) { + return true; + } + + return false; + } + + /** + * Check if user has ANY of the permissions (OR logic) + */ + function hasAnyPermission(perms: string[]): boolean { + return perms.some(p => hasPermission(p)); + } + + /** + * Check if user has ALL permissions (AND logic) + */ + function hasAllPermissions(perms: string[]): boolean { + return perms.every(p => hasPermission(p)); + } + + /** + * Check if user has a specific role + */ + function hasRole(role: string): boolean { + return roles.value.includes(role); + } + + // ========================================================================= + // Initialize from /init endpoint + // ========================================================================= + + function init(userData: { + auth?: AuthenticatedUser; + profile?: UserProfileInterface; + settings?: UserSettingsInterface + }): void { + if (userData.auth) { + setAuth(userData.auth); + } + if (userData.profile) { + initProfile(userData.profile); + } + if (userData.settings) { + initSettings(userData.settings); + } + } + + return { + // State + auth, + profile, + settings, + returnUrl, + + // Auth getters + isAuthenticated, + identifier, + identity, + label, + roles, + permissions, + + // Profile getters + profileFields, + editableProfileFields, + managedProfileFields, + + // Auth actions + setAuth, + clearAuth, + logout, + refreshToken, + + // Profile actions + initProfile, + clearProfile, + getProfileField, + setProfileField, + isProfileFieldEditable, + + // Settings actions + initSettings, + clearSettings, + getSetting, + setSetting, + + // Permission actions + hasPermission, + hasAnyPermission, + hasAllPermissions, + hasRole, + + // Init + init, + }; +}); diff --git a/core/src/types/authenticationTypes.ts b/core/src/types/authenticationTypes.ts new file mode 100644 index 0000000..2da9cb8 --- /dev/null +++ b/core/src/types/authenticationTypes.ts @@ -0,0 +1,87 @@ +/** + * Authentication Method from provider + */ +export interface AuthenticationMethod { + id: string; + method: 'credential' | 'redirect' | 'challenge'; + label: string; + icon?: string; +} + +/** + * Authenticated user info + */ +export interface AuthenticatedUser { + identifier: string; + identity: string; + label: string; + roles?: string[]; + permissions?: string[]; +} + +/** + * Start response from /auth/start + */ +export interface StartResponse { + status: 'success'; + session: string; + methods: AuthenticationMethod[]; +} + +/** + * Identify response from /auth/identify + */ +export interface IdentifyResponse { + status: 'success'; + session: string; + state: string; + methods: AuthenticationMethod[]; +} + +/** + * Verify response from /auth/verify + */ +export interface VerifyResponse { + status: 'success' | 'pending'; + user?: AuthenticatedUser; + session?: string; + methods?: AuthenticationMethod[]; + message?: string; + error?: string; + error_code?: string; +} + +/** + * Redirect response from /auth/redirect + */ +export interface RedirectResponse { + status: 'redirect'; + redirect_url: string; +} + +/** + * Challenge response from /auth/challenge + */ +export interface ChallengeResponse { + status: 'challenge'; + session: string; + challenge?: { + type?: string; + message?: string; + digits?: number; + [key: string]: unknown; + }; +} + +/** + * Session status from /auth/status + */ +export interface SessionStatus { + status: 'success'; + session: string; + state: string; + methods: AuthenticationMethod[]; + user?: { + identity?: string; + }; +} \ No newline at end of file diff --git a/core/src/types/env.d.ts b/core/src/types/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/core/src/types/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/core/src/types/integrationTypes.ts b/core/src/types/integrationTypes.ts new file mode 100644 index 0000000..fbdf0c3 --- /dev/null +++ b/core/src/types/integrationTypes.ts @@ -0,0 +1,45 @@ +// Integration point types - extensible via string +export type IntegrationPointType = + | 'app_menu' // Main applications menu (calendar, contacts, etc.) + | 'admin_settings_menu' // Admin-only settings menu + | 'user_settings_menu' // User's personal settings menu + | 'profile_menu' // Top-right profile dropdown (quick actions) + | string; // Allow custom integration points + +export type IntegrationGroupStyle = 'none' | 'static' | 'dynamic' | null; + +export interface IntegrationItem { + id: string; + moduleHandle: string; + priority?: number; + label?: string; + caption?: string; + icon?: string; + to?: string; + toType?: 'internal' | 'external'; + component?: () => Promise; + badge?: string | number | null; + badgeColor?: string; + visible?: boolean; + disabled?: boolean; + meta?: Record; +} + +export interface IntegrationGroup { + id: string; + moduleHandle: string; + priority?: number; + label?: string; + caption?: string; + icon?: string; + style?: IntegrationGroupStyle; + items: IntegrationItem[]; + visible?: boolean; + meta?: Record; +} + +export type IntegrationEntry = IntegrationItem | IntegrationGroup; + +export interface IntegrationPoint { + items: Map; +} \ No newline at end of file diff --git a/core/src/types/layouts/layoutSystemMenu.ts b/core/src/types/layouts/layoutSystemMenu.ts new file mode 100644 index 0000000..9ea723f --- /dev/null +++ b/core/src/types/layouts/layoutSystemMenu.ts @@ -0,0 +1,30 @@ +export interface LayoutSystemMenuItem { + header?: string; + title?: string; + icon?: object; + to?: string | object; + href?: string; + target?: string; + divider?: boolean; + children?: LayoutSystemMenuItem[]; + badgeContent?: string | number; + badgeColor?: string; + badgeVariant?: string; + badgeIcon?: string; + chip?: string | number; + chipColor?: string; + chipVariant?: string; + chipIcon?: string; + disabled?: boolean; + type?: string; + subCaption?: string; + meta?: { [key: string]: any }; +} + +export interface LayoutSystemMenuGroup { + handle: string; + style: string; + label: string; + icon: object; + items: LayoutSystemMenuItem[]; +} \ No newline at end of file diff --git a/core/src/types/moduleTypes.ts b/core/src/types/moduleTypes.ts new file mode 100644 index 0000000..13bd7ef --- /dev/null +++ b/core/src/types/moduleTypes.ts @@ -0,0 +1,67 @@ +import type { IntegrationGroupStyle } from './integrationTypes'; + +export interface ModuleObject { + handle: string; + namespace: string; + version: string; + label: string; + author?: string; + description?: string; + boot?: string; // relative path like "js/Dashboard.js" + booted?: boolean; // set true once the module plugin is loaded +} + +export type ModuleCollection = Record; + +// Module integration types + +/** + * Base interface for module integrations + */ +export interface ModuleIntegrationBase { + id: string; + [key: string]: any; +} + +/** + * UI Navigation integration item + * Represents a single clickable entry in a menu or navigation structure + */ +export interface ModuleIntegrationItem extends ModuleIntegrationBase { + type?: 'item'; + label: string; + caption?: string; + icon?: string; + path?: string; + to?: string; + toType?: 'internal' | 'external'; + priority?: number; + component?: () => Promise; + visible?: boolean; + disabled?: boolean; + meta?: Record; +} + +/** + * UI Navigation integration group + * Represents a group of related navigation items, potentially with its own label and icon + */ +export interface ModuleIntegrationGroup extends ModuleIntegrationBase { + type: 'group'; + label?: string; + caption?: string; + icon?: string; + style?: IntegrationGroupStyle; + priority?: number; + items: ModuleIntegrationItem[]; + meta?: Record; +} + +export type ModuleIntegrationEntry = ModuleIntegrationItem | ModuleIntegrationGroup; + +export interface ModuleIntegrations { + system_menu?: ModuleIntegrationEntry[]; + user_menu?: ModuleIntegrationEntry[]; + + [key: string]: ModuleIntegrationEntry[] | ModuleIntegrationBase[] | undefined; +} diff --git a/core/src/types/user/userProfileTypes.ts b/core/src/types/user/userProfileTypes.ts new file mode 100644 index 0000000..8d464a1 --- /dev/null +++ b/core/src/types/user/userProfileTypes.ts @@ -0,0 +1,13 @@ +/** + * User Profile Types + */ + +export interface ProfileFieldInterface { + value: any; + editable: boolean; + provider: string | null; +} + +export interface UserProfileInterface { + [key: string]: ProfileFieldInterface; +} diff --git a/core/src/types/user/userSettingsTypes.ts b/core/src/types/user/userSettingsTypes.ts new file mode 100644 index 0000000..f99c778 --- /dev/null +++ b/core/src/types/user/userSettingsTypes.ts @@ -0,0 +1,7 @@ +/** + * User Settings Types + */ + +export interface UserSettingsInterface { + [key: string]: any; +} diff --git a/core/src/utils/helpers/fetch-wrapper-core.ts b/core/src/utils/helpers/fetch-wrapper-core.ts new file mode 100644 index 0000000..578219d --- /dev/null +++ b/core/src/utils/helpers/fetch-wrapper-core.ts @@ -0,0 +1,161 @@ +/** + * Core fetch wrapper - reusable across modules + * Does not depend on stores to avoid bundling issues in library builds + */ + +export interface FetchWrapperOptions { + /** + * Optional callback to handle logout on auth failure + * If not provided, only logs error without redirecting + */ + onLogout?: () => void | Promise; + /** + * Enable automatic retry of failed requests after token refresh + * @default true + */ + autoRetry?: boolean; +} + +// Mutex to prevent multiple simultaneous refresh attempts +class RefreshMutex { + private promise: Promise | null = null; + + async acquire(): Promise { + if (this.promise) { + return this.promise; + } + + this.promise = this.performRefresh(); + const result = await this.promise; + this.promise = null; + return result; + } + + private async performRefresh(): Promise { + try { + const response = await fetch('/security/refresh', { + method: 'POST', + credentials: 'include' + }); + + return response.ok; + } catch (error) { + console.error('Token refresh failed:', error); + return false; + } + } +} + +const tokenRefreshMutex = new RefreshMutex(); + +export interface RequestCallOptions { + /** + * Override autoRetry for this specific request + * @default true + */ + autoRetry?: boolean; + /** + * Skip calling onLogout callback on 401/403 errors + * Useful for authentication endpoints where 401 means invalid credentials, not session expiry + * @default false + */ + skipLogoutOnError?: boolean; +} + +export function createFetchWrapper(options: FetchWrapperOptions = {}) { + const { autoRetry: defaultAutoRetry = true } = options; + + return { + get: request('GET', options, defaultAutoRetry), + post: request('POST', options, defaultAutoRetry), + put: request('PUT', options, defaultAutoRetry), + delete: request('DELETE', options, defaultAutoRetry) + }; +} + +interface RequestOptions { + method: string; + headers: Record; + body?: string; + credentials: 'include'; +} + +function request(method: string, options: FetchWrapperOptions, defaultAutoRetry: boolean) { + return async (url: string, body?: object, callOptions?: RequestCallOptions): Promise => { + const autoRetry = callOptions?.autoRetry ?? defaultAutoRetry; + + const requestOptions: RequestOptions = { + method, + headers: getHeaders(url), + credentials: 'include' + }; + + if (body) { + requestOptions.headers['Content-Type'] = 'application/json'; + requestOptions.body = JSON.stringify(body); + } + + try { + const response = await fetch(url, requestOptions); + + if (response.status === 401 && autoRetry) { + // Try to refresh the token + const refreshSuccess = await tokenRefreshMutex.acquire(); + + if (refreshSuccess) { + // Retry the original request with the new token + const retryResponse = await fetch(url, requestOptions); + return handleResponse(retryResponse, options, callOptions?.skipLogoutOnError); + } + } + + return handleResponse(response, options, callOptions?.skipLogoutOnError); + } catch (error) { + console.error('API error:', error); + throw error; + } + }; +} + +function getHeaders(_url: string): Record { + const headers: Record = {}; + + // Add CSRF token if available + const csrfToken = getCsrfTokenFromCookie(); + if (csrfToken) { + headers['X-CSRF-TOKEN'] = csrfToken; + } + + return headers; +} + +function getCsrfTokenFromCookie(): string | null { + if (typeof document === 'undefined') return null; + + const csrfCookie = document.cookie + .split('; ') + .find(row => row.startsWith('X-CSRF-TOKEN=')); + + return csrfCookie ? csrfCookie.split('=')[1] : null; +} + +async function handleResponse(response: Response, options: FetchWrapperOptions, skipLogoutOnError?: boolean): Promise { + const text = await response.text(); + const data = text && JSON.parse(text); + + if (!response.ok) { + if ([401, 403].includes(response.status) && !skipLogoutOnError) { + // Call logout callback if provided + if (options.onLogout) { + await options.onLogout(); + } else { + console.error('Authentication failed. Please log in again.'); + } + } + + const error: string = (data && data.message) || response.statusText; + throw new Error(error); + } + + return data; +} diff --git a/core/src/utils/helpers/fetch-wrapper.ts b/core/src/utils/helpers/fetch-wrapper.ts new file mode 100644 index 0000000..4e92c39 --- /dev/null +++ b/core/src/utils/helpers/fetch-wrapper.ts @@ -0,0 +1,10 @@ +import { useUserStore } from '@KTXC/stores/userStore'; +import { createFetchWrapper } from './fetch-wrapper-core'; + +// Create fetch wrapper with user store logout callback +export const fetchWrapper = createFetchWrapper({ + onLogout: () => { + const { logout } = useUserStore(); + logout(); + } +}); diff --git a/core/src/utils/helpers/shared.ts b/core/src/utils/helpers/shared.ts new file mode 100644 index 0000000..1889e05 --- /dev/null +++ b/core/src/utils/helpers/shared.ts @@ -0,0 +1,6 @@ +/** + * Shared utilities entry point for external modules + * This file is built separately and exposed via import map + */ + +export { createFetchWrapper, type FetchWrapperOptions } from './fetch-wrapper-core'; diff --git a/core/src/utils/modules.ts b/core/src/utils/modules.ts new file mode 100644 index 0000000..5bcaa19 --- /dev/null +++ b/core/src/utils/modules.ts @@ -0,0 +1,107 @@ +import type { App } from 'vue'; +import { router } from '@KTXC/router'; +import { useModuleStore } from '@KTXC/stores/moduleStore'; +import { useIntegrationStore } from '@KTXC/stores/integrationStore'; + +function installModuleCSS(moduleHandle: string, cssPaths: string | string[]): void { + const cssFiles = Array.isArray(cssPaths) ? cssPaths : [cssPaths]; + cssFiles.forEach((cssFile: string) => { + const cssPath = `/modules/${moduleHandle}/${cssFile}`; + const existingLink = document.querySelector(`link[href="${cssPath}"]`); + if (!existingLink) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = cssPath; + link.onload = () => { + console.log(`Module Loader - Loaded CSS for ${moduleHandle}: ${cssFile}`); + }; + link.onerror = () => { + console.error(`Module Loader - Failed to load CSS for ${moduleHandle}: ${cssPath}`); + }; + document.head.appendChild(link); + } + }); +} + +function installModuleRoutes(moduleHandle: string, routes: any[]): void { + routes.forEach((route: any) => { + // Prefix route name with module handle for safety + const prefixedRoute = { + ...route, + path: `/m/${moduleHandle}${route.path}` + }; + // Prefix the route name if it exists + if (route.name) { + prefixedRoute.name = `${moduleHandle}.${route.name}`; + } + // Recursively prefix child route names + if (route.children && Array.isArray(route.children)) { + prefixedRoute.children = route.children.map((child: any) => ({ + ...child, + name: child.name ? `${moduleHandle}.${child.name}` : undefined + })); + } + + router.addRoute('private', prefixedRoute); + }); +} + +function installModuleIntegrations( + moduleHandle: string, + integrations: Record +): void { + const integrationStore = useIntegrationStore(); + integrationStore.registerModuleIntegrations(moduleHandle, integrations); +} + +export async function initializeModules(app: App): Promise { + const moduleStore = useModuleStore(); + + // First, dynamically load modules based on moduleStore boot paths + const availableModules = moduleStore.modules; + const loadPromises: Promise[] = []; + + for (const [moduleId, moduleInfo] of Object.entries(availableModules)) { + if (!moduleInfo) { + console.warn(`Module ${moduleId} has no configuration, skipping`); + continue; + } + if (moduleInfo.handle && moduleInfo.boot && !moduleInfo.booted) { + const moduleHandle = moduleInfo.handle; + const moduleUrl = `/modules/${moduleInfo.handle}/${moduleInfo.boot}`; + console.log(`Module Loader - Loading ${moduleInfo.handle} from ${moduleUrl}`); + + const loadPromise = import(/* @vite-ignore */ moduleUrl) + .then((module) => { + // Load CSS if module explicitly exports css path(s) + if (module.css) { + installModuleCSS(moduleInfo.handle, module.css); + } + // install module + console.log(`Module Loader - Installing ${moduleInfo.handle}`); + if (module.default && typeof module.default.install === 'function') { + app.use(module.default); + } + // prefix routes with /m/{moduleHandle} + console.log(`Module Loader - Installing Routes ${moduleInfo.handle}`); + if (module.routes) { + installModuleRoutes(moduleHandle, module.routes); + } + // register integrations + console.log(`Module Loader - Installing Integrations ${moduleInfo.handle}`); + if (module.integrations) { + installModuleIntegrations(moduleHandle, module.integrations); + } + }) + .catch((error) => { + console.error(`Failed to load module ${moduleId} from ${moduleUrl}:`, error); + }); + loadPromises.push(loadPromise); + } else if (!moduleInfo.boot) { + console.warn(`No boot path specified for module: ${moduleId}`); + } + } + + // Wait for all dynamic loading to complete + await Promise.all(loadPromises); +} \ No newline at end of file diff --git a/core/src/views/PrivateLayout.vue b/core/src/views/PrivateLayout.vue new file mode 100644 index 0000000..7237b4c --- /dev/null +++ b/core/src/views/PrivateLayout.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/core/src/views/PublicLayout.vue b/core/src/views/PublicLayout.vue new file mode 100644 index 0000000..b174a3e --- /dev/null +++ b/core/src/views/PublicLayout.vue @@ -0,0 +1,26 @@ + + + diff --git a/core/src/views/authentication/AuthFooter.vue b/core/src/views/authentication/AuthFooter.vue new file mode 100644 index 0000000..72c9440 --- /dev/null +++ b/core/src/views/authentication/AuthFooter.vue @@ -0,0 +1,38 @@ + + diff --git a/core/src/views/authentication/AuthLogin.vue b/core/src/views/authentication/AuthLogin.vue new file mode 100644 index 0000000..67c9ac5 --- /dev/null +++ b/core/src/views/authentication/AuthLogin.vue @@ -0,0 +1,586 @@ + + + + + diff --git a/core/src/views/authentication/LoginPage.vue b/core/src/views/authentication/LoginPage.vue new file mode 100644 index 0000000..27db103 --- /dev/null +++ b/core/src/views/authentication/LoginPage.vue @@ -0,0 +1,53 @@ + + + + diff --git a/core/src/views/pages/maintenance/error/Error404Page.vue b/core/src/views/pages/maintenance/error/Error404Page.vue new file mode 100644 index 0000000..f0a0324 --- /dev/null +++ b/core/src/views/pages/maintenance/error/Error404Page.vue @@ -0,0 +1,50 @@ + + + + diff --git a/core/src/views/pages/maintenance/error/Error500Page.vue b/core/src/views/pages/maintenance/error/Error500Page.vue new file mode 100644 index 0000000..8667a54 --- /dev/null +++ b/core/src/views/pages/maintenance/error/Error500Page.vue @@ -0,0 +1,28 @@ + + + + diff --git a/deploy/nginx/vhost-dev.conf b/deploy/nginx/vhost-dev.conf new file mode 100644 index 0000000..841db4b --- /dev/null +++ b/deploy/nginx/vhost-dev.conf @@ -0,0 +1,125 @@ +# HTTP to HTTPS redirect +server { + listen *:80; + listen [::]:80; + server_name ktrix; + return 301 https://$server_name$request_uri; +} + +server { + listen *:443 ssl http2; + listen [::]:443 ssl http2; + #listen *:443 quic reuseport; + #listen [::]:443 quic reuseport; + + #http2 on; + + server_name ktrix; + + ### SSL Configuration ### + + # SSL Certificates + ssl_certificate /etc/ssl/certs/localhost.crt; + ssl_certificate_key /etc/ssl/private/localhost.key; + + # SSL Protocols and Ciphers + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_dhparam /etc/ssl/certs/dhparam.pem; # openssl dhparam -out /etc/ssl/certs/dhparam.pem 2048 + + # SSL Sessions + ssl_session_timeout 60m; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + + ### Logging Configuration ### + error_log /var/log/nginx/ktrix-error.log; + access_log /var/log/nginx/ktrix-access.log; + + ### Upload Configuration ### + client_max_body_size 1024M; + + ### Site Configuration ### + root /var/www/ktrix/main/public; + index index.html; + + # Serve index.html for root path only + #location = / { + # try_files /index.html =404; + #} + + # Serve module static assets directly from module folders + # URL: /modules//static/... -> FS: /var/www/ktrix/main/modules//static/... + # Note: Linux is case-sensitive; ensure URL module casing matches folder name + location ~ ^/modules/([^/]+)/static/(.*)$ { + alias /var/www/ktrix/main/modules/$1/static/$2; + expires 10m; + add_header Cache-Control "public, immutable"; + access_log on; + types { + text/css css; + application/javascript js; + application/javascript mjs; + image/svg+xml svg; + image/gif gif; + image/png png; + image/jpeg jpg; + image/jpeg jpeg; + image/x-icon ico; + font/woff woff; + font/woff2 woff2; + font/ttf ttf; + application/vnd.ms-fontobject eot; + application/json map; + } + } + + # Handle asset files (css, js, images, etc.) - serve directly if they exist + location ~* \.(css|js|mjs|svg|gif|png|jpg|jpeg|ico|woff|woff2|ttf|eot|map)$ { + try_files $uri =404; + expires 1m; + add_header Cache-Control "public, immutable"; + types { + text/css css; + application/javascript js; + application/javascript mjs; + image/svg+xml svg; + image/gif gif; + image/png png; + image/jpeg jpg; + image/jpeg jpeg; + image/x-icon ico; + font/woff woff; + font/woff2 woff2; + font/ttf ttf; + application/vnd.ms-fontobject eot; + application/json map; + } + } + + # All other URLs should be handled by index.php + location / { + try_files $uri @php; + } + + # Named location for PHP handling + location @php { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $realpath_root/index.php; + fastcgi_param DOCUMENT_ROOT $realpath_root; + fastcgi_param SCRIPT_NAME /index.php; + fastcgi_param REQUEST_URI $uri?$args; + fastcgi_pass fpm; + } + + # return 404 for all other php files not matching the front controller + # this prevents access to other php files you don't want to be accessible. + location ~ \.php$ { + return 404; + } + + # Optional: Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; +} diff --git a/deploy/nginx/vhost.conf b/deploy/nginx/vhost.conf new file mode 100644 index 0000000..cebfaef --- /dev/null +++ b/deploy/nginx/vhost.conf @@ -0,0 +1,83 @@ +# HTTP to HTTPS redirect +server { + listen *:80; + listen [::]:80; + server_name ktrix; + return 301 https://$server_name$request_uri; +} + +server { + listen *:443 ssl http2; + listen [::]:443 ssl http2; + #listen *:443 quic reuseport; + #listen [::]:443 quic reuseport; + + #http2 on; + + server_name ktrix; + + ### SSL Configuration ### + + # SSL Certificates + ssl_certificate /etc/ssl/certs/localhost.crt; + ssl_certificate_key /etc/ssl/private/localhost.key; + + # SSL Protocols and Ciphers + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_dhparam /etc/ssl/certs/dhparam.pem; # openssl dhparam -out /etc/ssl/certs/dhparam.pem 2048 + + # SSL Sessions + ssl_session_timeout 60m; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + + ### Logging Configuration ### + error_log /var/log/nginx/ktrix-error.log; + access_log /var/log/nginx/ktrix-access.log; + + ### Upload Configuration ### + client_max_body_size 1024M; + + ### Site Configuration ### + root /var/www/ktrix/main/public; + index index.html; + + # Serve index.html for root path only + location = / { + try_files /index.html =404; + } + + # Handle asset files (css, js, images, etc.) - serve directly if they exist + location ~* \.(css|js|svg|gif|png|jpg|jpeg|ico|woff|woff2|ttf|eot|map)$ { + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # All other URLs should be handled by index.php + location / { + try_files $uri @php; + } + + # Named location for PHP handling + location @php { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $realpath_root/index.php; + fastcgi_param DOCUMENT_ROOT $realpath_root; + fastcgi_param SCRIPT_NAME /index.php; + fastcgi_param REQUEST_URI $uri?$args; + fastcgi_pass fpm; + } + + # return 404 for all other php files not matching the front controller + # this prevents access to other php files you don't want to be accessible. + location ~ \.php$ { + return 404; + } + + # Optional: Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; +} diff --git a/deploy/supervisor/mail-daemon.conf b/deploy/supervisor/mail-daemon.conf new file mode 100644 index 0000000..3542a76 --- /dev/null +++ b/deploy/supervisor/mail-daemon.conf @@ -0,0 +1,22 @@ +[program:ktrix-mail-daemon] +command=/usr/bin/php /var/www/ktrix/main/bin/console mail:queue:daemon +directory=/var/www/ktrix/main +user=www-data +numprocs=1 +autostart=true +autorestart=true +startsecs=5 +startretries=3 +exitcodes=0 +stopsignal=TERM +stopwaitsecs=30 +stopasgroup=true +killasgroup=true +redirect_stderr=true +stdout_logfile=/var/www/ktrix/main/var/log/mail-daemon.log +stdout_logfile_maxbytes=10MB +stdout_logfile_backups=5 +environment=PHP_INI_SCAN_DIR="/etc/php/8.2/cli/conf.d" + +; Process name for easier identification +process_name=%(program_name)s_%(process_num)02d diff --git a/deploy/systemd/mail-daemon.service b/deploy/systemd/mail-daemon.service new file mode 100644 index 0000000..a2028ab --- /dev/null +++ b/deploy/systemd/mail-daemon.service @@ -0,0 +1,30 @@ +[Unit] +Description=Ktrix Mail Queue Daemon +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/var/www/ktrix/main +ExecStart=/usr/bin/php bin/console mail:queue:daemon +ExecReload=/bin/kill -HUP $MAINPID +Restart=always +RestartSec=5 +StandardOutput=append:/var/www/ktrix/main/var/log/mail-daemon.log +StandardError=append:/var/www/ktrix/main/var/log/mail-daemon.log + +# Process management +KillMode=process +KillSignal=SIGTERM +TimeoutStopSec=30 + +# Security hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/www/ktrix/main/storage +ReadWritePaths=/var/www/ktrix/main/var + +[Install] +WantedBy=multi-user.target diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..7ed7a96 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,18 @@ +import globals from 'globals'; +import pluginJs from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import pluginVue from 'eslint-plugin-vue'; + +export default [ + { + languageOptions: { + globals: globals.browser, + parserOptions: { + parser: '@typescript-eslint/parser' + } + } + }, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + ...pluginVue.configs['flat/essential'] +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cd08f54 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5307 @@ +{ + "name": "ktrix", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ktrix", + "version": "0.0.1", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@fontsource/inter": "5.1.0", + "@fontsource/poppins": "5.1.0", + "@fontsource/public-sans": "5.1.1", + "@fontsource/roboto": "5.1.0", + "@mdi/font": "7.4.47", + "@tsconfig/node20": "20.1.4", + "@typescript-eslint/parser": "^8.18.2", + "@vue/compiler-sfc": "^3.5.16", + "dompurify": "^3.3.1", + "pinia": "2.3.0", + "vee-validate": "^4.15.1", + "vite-plugin-vuetify": "2.0.4", + "vue": "3.5.13", + "vue-router": "4.5.0", + "vue3-perfect-scrollbar": "2.0.0", + "vuetify": "3.7.6" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/dompurify": "^3.0.5", + "@types/node": "22.10.2", + "@vitejs/plugin-vue": "5.2.1", + "@vue/eslint-config-prettier": "10.1.0", + "@vue/tsconfig": "0.7.0", + "eslint": "9.17.0", + "eslint-plugin-vue": "9.32.0", + "prettier": "3.4.2", + "sass": "1.77.1", + "sass-loader": "16.0.4", + "typescript": "5.7.2", + "typescript-eslint": "^8.18.2", + "vite": "6.0.6", + "vite-plugin-static-copy": "^3.1.2", + "vue-cli-plugin-vuetify": "2.5.8", + "vue-tsc": "^2.2.10" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", + "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/core": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.1.tgz", + "integrity": "sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", + "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", + "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.13.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", + "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fontsource/inter": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.1.0.tgz", + "integrity": "sha512-zKZR3kf1G0noIes1frLfOHP5EXVVm0M7sV/l9f/AaYf+M/DId35FO4LkigWjqWYjTJZGgplhdv4cB+ssvCqr5A==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource/poppins": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fontsource/poppins/-/poppins-5.1.0.tgz", + "integrity": "sha512-tpLXlnNi2fwQjiipvuj4uNFHCdoLA8izRsKdoexZuEzjx0r/g1aKLf4ta6lFgF7L+/+AFdmaXFlUwwvmDzYH+g==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource/public-sans": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fontsource/public-sans/-/public-sans-5.1.1.tgz", + "integrity": "sha512-BEJEc9kpLBowHLqeOlex1lMJPZ/6mzKn3ArhbvWY9dvMcjSqH7jzJyTN44j0H78FTOrVjIW1g4A8nDdbx8VV5Q==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource/roboto": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.1.0.tgz", + "integrity": "sha512-cFRRC1s6RqPygeZ8Uw/acwVHqih8Czjt6Q0MwoUoDe9U3m4dH1HmNDRBZyqlMSFwgNAUKgFImncKdmDHyKpwdg==", + "license": "Apache-2.0" + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mdi/font": { + "version": "7.4.47", + "resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz", + "integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==", + "license": "Apache-2.0" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.2.tgz", + "integrity": "sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.2.tgz", + "integrity": "sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.2.tgz", + "integrity": "sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.2.tgz", + "integrity": "sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.2.tgz", + "integrity": "sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.2.tgz", + "integrity": "sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.2.tgz", + "integrity": "sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.2.tgz", + "integrity": "sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.2.tgz", + "integrity": "sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.2.tgz", + "integrity": "sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.50.2.tgz", + "integrity": "sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.2.tgz", + "integrity": "sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.2.tgz", + "integrity": "sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.2.tgz", + "integrity": "sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.2.tgz", + "integrity": "sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.2.tgz", + "integrity": "sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.2.tgz", + "integrity": "sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.2.tgz", + "integrity": "sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.2.tgz", + "integrity": "sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.2.tgz", + "integrity": "sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.2.tgz", + "integrity": "sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tsconfig/node20": { + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.4.tgz", + "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==", + "license": "MIT" + }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.0.tgz", + "integrity": "sha512-EGDAOGX+uwwekcS0iyxVDmRV9HX6FLSM5kzrAToLTsr9OWCIKG/y3lQheCq18yZ5Xh78rRKJiEpP0ZaCs4ryOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.44.0", + "@typescript-eslint/type-utils": "8.44.0", + "@typescript-eslint/utils": "8.44.0", + "@typescript-eslint/visitor-keys": "8.44.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.44.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.0.tgz", + "integrity": "sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.44.0", + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/typescript-estree": "8.44.0", + "@typescript-eslint/visitor-keys": "8.44.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.44.0.tgz", + "integrity": "sha512-ZeaGNraRsq10GuEohKTo4295Z/SuGcSq2LzfGlqiuEvfArzo/VRrT0ZaJsVPuKZ55lVbNk8U6FcL+ZMH8CoyVA==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.44.0", + "@typescript-eslint/types": "^8.44.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.44.0.tgz", + "integrity": "sha512-87Jv3E+al8wpD+rIdVJm/ItDBe/Im09zXIjFoipOjr5gHUhJmTzfFLuTJ/nPTMc2Srsroy4IBXwcTCHyRR7KzA==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/visitor-keys": "8.44.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.44.0.tgz", + "integrity": "sha512-x5Y0+AuEPqAInc6yd0n5DAcvtoQ/vyaGwuX5HE9n6qAefk1GaedqrLQF8kQGylLUb9pnZyLf+iEiL9fr8APDtQ==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.44.0.tgz", + "integrity": "sha512-9cwsoSxJ8Sak67Be/hD2RNt/fsqmWnNE1iHohG8lxqLSNY8xNfyY7wloo5zpW3Nu9hxVgURevqfcH6vvKCt6yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/typescript-estree": "8.44.0", + "@typescript-eslint/utils": "8.44.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.44.0.tgz", + "integrity": "sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.44.0.tgz", + "integrity": "sha512-lqNj6SgnGcQZwL4/SBJ3xdPEfcBuhCG8zdcwCPgYcmiPLgokiNDKlbPzCwEwu7m279J/lBYWtDYL+87OEfn8Jw==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.44.0", + "@typescript-eslint/tsconfig-utils": "8.44.0", + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/visitor-keys": "8.44.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.44.0.tgz", + "integrity": "sha512-nktOlVcg3ALo0mYlV+L7sWUD58KG4CMj1rb2HUVOO4aL3K/6wcD+NERqd0rrA5Vg06b42YhF6cFxeixsp9Riqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.44.0", + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/typescript-estree": "8.44.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.44.0.tgz", + "integrity": "sha512-zaz9u8EJ4GBmnehlrpoKvj/E3dNbuQ7q0ucyZImm3cLqJ8INTc970B1qEqDX/Rzq65r3TvVTN7kHWPBoyW7DWw==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.44.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.1.tgz", + "integrity": "sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.21.tgz", + "integrity": "sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@vue/shared": "3.5.21", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.21.tgz", + "integrity": "sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz", + "integrity": "sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@vue/compiler-core": "3.5.21", + "@vue/compiler-dom": "3.5.21", + "@vue/compiler-ssr": "3.5.21", + "@vue/shared": "3.5.21", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.18", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.21.tgz", + "integrity": "sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.7.tgz", + "integrity": "sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.7", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.7.tgz", + "integrity": "sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/eslint-config-prettier": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-10.1.0.tgz", + "integrity": "sha512-J6wV91y2pXc0Phha01k0WOHBTPsoSTf4xlmMjoKaeSxBpAdsgTppGF5RZRdOHM7OA74zAXD+VLANrtYXpiPKkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1" + }, + "peerDependencies": { + "eslint": ">= 8.21.0", + "prettier": ">= 3.0.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz", + "integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/reactivity/node_modules/@vue/shared": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "license": "MIT" + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz", + "integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/runtime-core/node_modules/@vue/shared": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "license": "MIT" + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz", + "integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.13", + "@vue/runtime-core": "3.5.13", + "@vue/shared": "3.5.13", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/runtime-dom/node_modules/@vue/shared": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "license": "MIT" + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz", + "integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13" + }, + "peerDependencies": { + "vue": "3.5.13" + } + }, + "node_modules/@vue/server-renderer/node_modules/@vue/compiler-core": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", + "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.13", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/server-renderer/node_modules/@vue/compiler-dom": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", + "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/server-renderer/node_modules/@vue/compiler-ssr": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz", + "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/server-renderer/node_modules/@vue/shared": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "license": "MIT" + }, + "node_modules/@vue/shared": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.21.tgz", + "integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.7.0.tgz", + "integrity": "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vuetify/loader-shared": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vuetify/loader-shared/-/loader-shared-2.1.1.tgz", + "integrity": "sha512-jSZTzTYaoiv8iwonFCVZQ0YYX/M+Uyl4ng+C4egMJT0Hcmh9gIxJL89qfZICDeo3g0IhqrvipW2FFKKRDMtVcA==", + "license": "MIT", + "dependencies": { + "upath": "^2.0.1" + }, + "peerDependencies": { + "vue": "^3.0.0", + "vuetify": "^3.0.0" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz", + "integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/birpc": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.6.1.tgz", + "integrity": "sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", + "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001741", + "electron-to-chromium": "^1.5.218", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "devOptional": true, + "license": "MIT", + "peer": true + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001743", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz", + "integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0", + "peer": true + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "devOptional": true, + "license": "MIT", + "peer": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.222", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.222.tgz", + "integrity": "sha512-gA7psSwSwQRE60CEoLz6JBCQPIxNeuzB2nL8vE03GK/OHxlvykbLyeiumQy1iH5C2f3YbRAZpGCMT12a/9ih9w==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.17.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz", + "integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==", + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.19.0", + "@eslint/core": "^0.9.0", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "9.17.0", + "@eslint/plugin-kit": "^0.2.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", + "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.11.7" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue": { + "version": "9.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.32.0.tgz", + "integrity": "sha512-b/Y05HYmnB/32wqVcjxjHZzNpwxj1onBOvqW89W+V+XNG1dRuaFbNd3vT9CLbr2LXjEoq+3vn8DanWf7XU22Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "globals": "^13.24.0", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^6.0.15", + "semver": "^7.6.3", + "vue-eslint-parser": "^9.4.3", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-vue/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-vue/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.17.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz", + "integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "license": "ISC" + }, + "node_modules/fs-extra": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/null-loader": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/null-loader/-/null-loader-4.0.1.tgz", + "integrity": "sha512-pxqVbi4U6N26lq+LmgIbB5XATP0VdZKOG25DhHi8btMmJJefGArFyDg1yc4U3hWCJbMqSrw0qyrz1UQX+qYXqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/perfect-scrollbar": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/perfect-scrollbar/-/perfect-scrollbar-1.5.6.tgz", + "integrity": "sha512-rixgxw3SxyJbCaSpo1n35A/fwI1r2rdwMKOTCg/AcG+xOEyZcE8UHVjpZMFCVImzsFoCZeJTT+M/rdEIQYO2nw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.0.tgz", + "integrity": "sha512-ohZj3jla0LL0OH5PlLTDMzqKiVw2XARmC1XYLdLWIPBMdhDW/123ZWr4zVAhtJm+aoSkFa13pYXskAvAscIkhQ==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", + "dev": true, + "dependencies": { + "resolve": "^1.1.6" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.2.tgz", + "integrity": "sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.50.2", + "@rollup/rollup-android-arm64": "4.50.2", + "@rollup/rollup-darwin-arm64": "4.50.2", + "@rollup/rollup-darwin-x64": "4.50.2", + "@rollup/rollup-freebsd-arm64": "4.50.2", + "@rollup/rollup-freebsd-x64": "4.50.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.50.2", + "@rollup/rollup-linux-arm-musleabihf": "4.50.2", + "@rollup/rollup-linux-arm64-gnu": "4.50.2", + "@rollup/rollup-linux-arm64-musl": "4.50.2", + "@rollup/rollup-linux-loong64-gnu": "4.50.2", + "@rollup/rollup-linux-ppc64-gnu": "4.50.2", + "@rollup/rollup-linux-riscv64-gnu": "4.50.2", + "@rollup/rollup-linux-riscv64-musl": "4.50.2", + "@rollup/rollup-linux-s390x-gnu": "4.50.2", + "@rollup/rollup-linux-x64-gnu": "4.50.2", + "@rollup/rollup-linux-x64-musl": "4.50.2", + "@rollup/rollup-openharmony-arm64": "4.50.2", + "@rollup/rollup-win32-arm64-msvc": "4.50.2", + "@rollup/rollup-win32-ia32-msvc": "4.50.2", + "@rollup/rollup-win32-x64-msvc": "4.50.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/sass": { + "version": "1.77.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.1.tgz", + "integrity": "sha512-OMEyfirt9XEfyvocduUIOlUSkWOXS/LAt6oblR/ISXCTukyavjex+zQNm51pPCOiFKY1QpWvEH1EeCkgyV3I6w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-loader": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.4.tgz", + "integrity": "sha512-LavLbgbBGUt3wCiYzhuLLu65+fWXaXLmq7YxivLhEqmiupCFZ5sKUAipK3do6V80YSU0jvSxNhEdT13IXNr3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shelljs": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", + "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + }, + "bin": { + "shjs": "bin/shjs" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "devOptional": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/superjson": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz", + "integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==", + "license": "MIT", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tapable": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", + "devOptional": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", + "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.44.0.tgz", + "integrity": "sha512-ib7mCkYuIzYonCq9XWF5XNw+fkj2zg629PSa9KNIQ47RXFF763S5BIX4wqz1+FLPogTZoiw8KmCiRPRa8bL3qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.44.0", + "@typescript-eslint/parser": "8.44.0", + "@typescript-eslint/typescript-estree": "8.44.0", + "@typescript-eslint/utils": "8.44.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/upath": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", + "integrity": "sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==", + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vee-validate": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/vee-validate/-/vee-validate-4.15.1.tgz", + "integrity": "sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.5.2", + "type-fest": "^4.8.3" + }, + "peerDependencies": { + "vue": "^3.4.26" + } + }, + "node_modules/vee-validate/node_modules/@vue/devtools-api": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz", + "integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.7" + } + }, + "node_modules/vite": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.6.tgz", + "integrity": "sha512-NSjmUuckPmDU18bHz7QZ+bTYhRR0iA72cs2QAxCqDpafJ0S6qetco0LB3WW2OxlMHS0JmAv+yZ/R3uPmMyGTjQ==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.24.2", + "postcss": "^8.4.49", + "rollup": "^4.23.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-static-copy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.2.tgz", + "integrity": "sha512-aVmYOzptLVOI2b1jL+cmkF7O6uhRv1u5fvOkQgbohWZp2CbR22kn9ZqkCUIt9umKF7UhdbsEpshn1rf4720QFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.6.0", + "fs-extra": "^11.3.0", + "p-map": "^7.0.3", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.14" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/vite-plugin-vuetify": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/vite-plugin-vuetify/-/vite-plugin-vuetify-2.0.4.tgz", + "integrity": "sha512-A4cliYUoP/u4AWSRVRvAPKgpgR987Pss7LpFa7s1GvOe8WjgDq92Rt3eVXrvgxGCWvZsPKziVqfHHdCMqeDhfw==", + "license": "MIT", + "dependencies": { + "@vuetify/loader-shared": "^2.0.3", + "debug": "^4.3.3", + "upath": "^2.0.1" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": ">=5", + "vue": "^3.0.0", + "vuetify": "^3.0.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", + "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-sfc": "3.5.13", + "@vue/runtime-dom": "3.5.13", + "@vue/server-renderer": "3.5.13", + "@vue/shared": "3.5.13" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-cli-plugin-vuetify": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/vue-cli-plugin-vuetify/-/vue-cli-plugin-vuetify-2.5.8.tgz", + "integrity": "sha512-uqi0/URJETJBbWlQHD1l0pnY7JN8Ytu+AL1fw50HFlGByPa8/xx+mq19GkFXA9FcwFT01IqEc/TkxMPugchomg==", + "dev": true, + "license": "MIT", + "dependencies": { + "null-loader": "^4.0.1", + "semver": "^7.1.2", + "shelljs": "^0.8.3" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "sass-loader": { + "optional": true + }, + "vuetify-loader": { + "optional": true + } + } + }, + "node_modules/vue-eslint-parser": { + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", + "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "eslint-scope": "^7.1.1", + "eslint-visitor-keys": "^3.3.0", + "espree": "^9.3.1", + "esquery": "^1.4.0", + "lodash": "^4.17.21", + "semver": "^7.3.6" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/vue-eslint-parser/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/vue-eslint-parser/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/vue-router": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.0.tgz", + "integrity": "sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vue/node_modules/@vue/compiler-core": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", + "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.13", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.0" + } + }, + "node_modules/vue/node_modules/@vue/compiler-dom": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", + "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/vue/node_modules/@vue/compiler-sfc": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz", + "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/compiler-core": "3.5.13", + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.11", + "postcss": "^8.4.48", + "source-map-js": "^1.2.0" + } + }, + "node_modules/vue/node_modules/@vue/compiler-ssr": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz", + "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/vue/node_modules/@vue/shared": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "license": "MIT" + }, + "node_modules/vue3-perfect-scrollbar": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/vue3-perfect-scrollbar/-/vue3-perfect-scrollbar-2.0.0.tgz", + "integrity": "sha512-nSWVcRyViCgt0Pe3RhU3w/BllLcFSrEzYOGlRBjSyhVmiZlERHHziffW+9P8L0IMEWouC5t+uYrgNJGSAElqMA==", + "dependencies": { + "perfect-scrollbar": "^1.5.5" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vuetify": { + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.7.6.tgz", + "integrity": "sha512-lol0Va5HtMIqZfjccSD5DLv5v31R/asJXzc6s7ULy51PHr1DjXxWylZejhq0kVpMGW64MiV1FmA/p8eYQfOWfQ==", + "license": "MIT", + "engines": { + "node": "^12.20 || >=14.13" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/johnleider" + }, + "peerDependencies": { + "typescript": ">=4.7", + "vite-plugin-vuetify": ">=1.0.0", + "vue": "^3.3.0", + "webpack-plugin-vuetify": ">=2.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vite-plugin-vuetify": { + "optional": true + }, + "webpack-plugin-vuetify": { + "optional": true + } + } + }, + "node_modules/watchpack": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", + "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.101.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", + "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.24.0", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.3", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.2", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.11", + "watchpack": "^2.4.1", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1682a27 --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "ktrix", + "version": "0.0.1", + "private": false, + "license": "AGPL-3.0-or-later", + "author": "Sebastian Krupinski", + "type": "module", + "scripts": { + "build": "vite build --mode production", + "dev": "vite build --mode development", + "watch": "vite build --mode development --watch", + "typecheck": "vue-tsc --noEmit", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", + "build:modules": "for dir in modules/*/; do if [ -f \"$dir/package.json\" ]; then echo \"Building $dir\" && npm run build --prefix \"$dir\"; fi; done", + "build:all": "npm run build && npm run build:modules", + "dev:modules": "for dir in modules/*/; do if [ -f \"$dir/package.json\" ]; then echo \"Building $dir\" && npm run dev --prefix \"$dir\"; fi; done", + "dev:all": "npm run dev && npm run dev:modules" + }, + "dependencies": { + "@fontsource/inter": "5.1.0", + "@fontsource/poppins": "5.1.0", + "@fontsource/public-sans": "5.1.1", + "@fontsource/roboto": "5.1.0", + "@mdi/font": "7.4.47", + "@tsconfig/node20": "20.1.4", + "@typescript-eslint/parser": "^8.18.2", + "@vue/compiler-sfc": "^3.5.16", + "dompurify": "^3.3.1", + "pinia": "2.3.0", + "vee-validate": "^4.15.1", + "vite-plugin-vuetify": "2.0.4", + "vue": "3.5.13", + "vue-router": "4.5.0", + "vue3-perfect-scrollbar": "2.0.0", + "vuetify": "3.7.6" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/dompurify": "^3.0.5", + "@types/node": "22.10.2", + "@vitejs/plugin-vue": "5.2.1", + "@vue/eslint-config-prettier": "10.1.0", + "@vue/tsconfig": "0.7.0", + "eslint": "9.17.0", + "eslint-plugin-vue": "9.32.0", + "prettier": "3.4.2", + "sass": "1.77.1", + "sass-loader": "16.0.4", + "typescript": "5.7.2", + "typescript-eslint": "^8.18.2", + "vite": "6.0.6", + "vite-plugin-static-copy": "^3.1.2", + "vue-cli-plugin-vuetify": "2.5.8", + "vue-tsc": "^2.2.10" + } +} diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..f9937ba --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + tests/php + + + + + + src + + + + trigger_deprecation + + + + + + diff --git a/scripts/generate-vendor-shims.ts b/scripts/generate-vendor-shims.ts new file mode 100644 index 0000000..4c48694 --- /dev/null +++ b/scripts/generate-vendor-shims.ts @@ -0,0 +1,95 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const OUTPUT_DIR = path.resolve(__dirname, '../public/vendor'); + +interface LibraryDefinition { + packageName: string; + globalName: string; + outputFile: string; +} + +interface WriteShimOptions { + outputDir: string; + silent: boolean; +} + +type ShimSource = Pick; + +const libraries: LibraryDefinition[] = [ + { packageName: 'vue', globalName: 'Vue', outputFile: 'vue.mjs' }, + { packageName: 'vue-router', globalName: 'VueRouter', outputFile: 'vue-router.mjs' }, + { packageName: 'pinia', globalName: 'Pinia', outputFile: 'pinia.mjs' }, +]; + +const formatLines = (globalName: string, exports: readonly string[]): string => { + const lines: string[] = []; + lines.push(`const ${globalName} = window.${globalName};`); + lines.push(`if (!${globalName}) {`); + lines.push(` throw new Error('${globalName} runtime is not available on window.');`); + lines.push('}'); + lines.push(`export default ${globalName};`); + + for (const name of exports) { + lines.push(`export const ${name} = ${globalName}.${name};`); + } + + lines.push(''); + return lines.join('\n'); +}; + +const generateShim = ({ packageName, globalName }: ShimSource): string => { + const mod = require(packageName) as Record; + const exportNames = Object.keys(mod) + .filter((key) => key !== 'default') + .sort(); + + return formatLines(globalName, exportNames); +}; + +const writeShim = async ( + { packageName, globalName, outputFile }: LibraryDefinition, + { outputDir, silent }: WriteShimOptions, +): Promise => { + const content = generateShim({ packageName, globalName }); + await writeFile(path.join(outputDir, outputFile), content, 'utf8'); + + if (!silent) { + console.log(`Generated ${outputFile}`); + } +}; + +export interface GenerateVendorShimsOptions { + outputDir?: string; + silent?: boolean; +} + +export type GenerateVendorShims = (options?: GenerateVendorShimsOptions) => Promise; + +export const generateVendorShims: GenerateVendorShims = async (options = {}) => { + const { outputDir = OUTPUT_DIR, silent = false } = options; + + await mkdir(outputDir, { recursive: true }); + + await Promise.all(libraries.map((library) => writeShim(library, { outputDir, silent }))); + + if (!silent) { + console.log(`[generate-vendor-shims] Vendor shims updated in ${outputDir}`); + } +}; + +const isCliExecution = Boolean(process.argv[1] && path.resolve(process.argv[1]) === __filename); + +if (isCliExecution) { + void generateVendorShims().catch((error) => { + console.error('[generate-vendor-shims] Failed to generate shims', error); + process.exitCode = 1; + }); +} diff --git a/shared/lib/Blob/MimeTypes.php b/shared/lib/Blob/MimeTypes.php new file mode 100644 index 0000000..c7c759f --- /dev/null +++ b/shared/lib/Blob/MimeTypes.php @@ -0,0 +1,219 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Blob; + +/** + * MimeTypes - MIME type and format resolution utility + * + * Provides bidirectional mapping between MIME types and file format identifiers. + */ +class MimeTypes { + + /** Default MIME type for unknown/binary content */ + public const MIME_BINARY = 'application/octet-stream'; + + /** Default format for unknown/binary content */ + public const FORMAT_BINARY = 'binary'; + + /** + * MIME type to format mapping + */ + private const MIME_TO_FORMAT = [ + // Images + 'image/jpeg' => 'jpeg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'image/bmp' => 'bmp', + 'image/x-ms-bmp' => 'bmp', + 'image/tiff' => 'tiff', + 'image/x-icon' => 'ico', + 'image/vnd.microsoft.icon' => 'ico', + 'image/svg+xml' => 'svg', + 'image/heic' => 'heic', + 'image/heif' => 'heif', + 'image/avif' => 'avif', + + // Documents + 'application/pdf' => 'pdf', + 'application/rtf' => 'rtf', + 'text/rtf' => 'rtf', + 'application/msword' => 'doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', + 'application/vnd.ms-excel' => 'xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', + 'application/vnd.ms-powerpoint' => 'ppt', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx', + 'application/vnd.oasis.opendocument.text' => 'odt', + 'application/vnd.oasis.opendocument.spreadsheet' => 'ods', + 'application/vnd.oasis.opendocument.presentation' => 'odp', + + // Archives + 'application/zip' => 'zip', + 'application/x-zip-compressed' => 'zip', + 'application/gzip' => 'gzip', + 'application/x-gzip' => 'gzip', + 'application/x-bzip2' => 'bzip2', + 'application/x-xz' => 'xz', + 'application/x-rar-compressed' => 'rar', + 'application/vnd.rar' => 'rar', + 'application/x-7z-compressed' => '7z', + 'application/x-tar' => 'tar', + + // Audio + 'audio/mpeg' => 'mp3', + 'audio/mp3' => 'mp3', + 'audio/ogg' => 'ogg', + 'audio/flac' => 'flac', + 'audio/x-flac' => 'flac', + 'audio/wav' => 'wav', + 'audio/x-wav' => 'wav', + 'audio/aac' => 'aac', + 'audio/mp4' => 'm4a', + 'audio/x-m4a' => 'm4a', + 'audio/webm' => 'webm', + + // Video + 'video/mp4' => 'mp4', + 'video/webm' => 'webm', + 'video/x-msvideo' => 'avi', + 'video/mpeg' => 'mpeg', + 'video/quicktime' => 'mov', + 'video/x-matroska' => 'mkv', + 'video/x-flv' => 'flv', + 'video/3gpp' => '3gp', + + // Fonts + 'font/woff' => 'woff', + 'font/woff2' => 'woff2', + 'font/ttf' => 'ttf', + 'font/otf' => 'otf', + 'application/font-woff' => 'woff', + 'application/font-woff2' => 'woff2', + 'application/x-font-ttf' => 'ttf', + 'application/x-font-otf' => 'otf', + + // Text/Code + 'text/plain' => 'text', + 'text/html' => 'html', + 'text/css' => 'css', + 'text/csv' => 'csv', + 'text/xml' => 'xml', + 'application/xml' => 'xml', + 'application/json' => 'json', + 'application/javascript' => 'js', + 'text/javascript' => 'js', + 'application/x-httpd-php' => 'php', + 'text/x-php' => 'php', + 'text/markdown' => 'md', + 'text/x-python' => 'py', + 'application/x-python-code' => 'py', + + // Other + 'application/epub+zip' => 'epub', + 'application/x-sqlite3' => 'sqlite', + 'application/wasm' => 'wasm', + 'application/octet-stream' => 'binary', + ]; + + /** Cached reverse mapping (format -> mime) */ + private static ?array $formatToMime = null; + + /** + * Get format from MIME type + * + * @param string $mime MIME type + * @return string|null Format or null if not found + */ + public static function toFormat(string $mime): ?string { + return self::MIME_TO_FORMAT[$mime] ?? null; + } + + /** + * Get MIME type from format + * + * @param string $format Format identifier + * @return string|null MIME type or null if not found + */ + public static function toMime(string $format): ?string { + if (self::$formatToMime === null) { + self::$formatToMime = []; + foreach (self::MIME_TO_FORMAT as $mime => $fmt) { + // Keep first occurrence (most canonical MIME type) + if (!isset(self::$formatToMime[$fmt])) { + self::$formatToMime[$fmt] = $mime; + } + } + } + return self::$formatToMime[$format] ?? null; + } + + /** + * Extract format from MIME type string (with fallback parsing) + * + * @param string $mime MIME type + * @return string|null Format or null + */ + public static function parseFormat(string $mime): ?string { + // Check direct mapping first + if (isset(self::MIME_TO_FORMAT[$mime])) { + return self::MIME_TO_FORMAT[$mime]; + } + + // Try to extract from MIME subtype (e.g., "image/jpeg" -> "jpeg") + $parts = explode('/', $mime, 2); + if (count($parts) === 2) { + $subtype = $parts[1]; + // Remove x- prefix and any parameters + $subtype = preg_replace('/^x-/', '', $subtype); + $subtype = explode(';', $subtype)[0]; + $subtype = explode('+', $subtype)[0]; + + if (strlen($subtype) > 0 && strlen($subtype) <= 10) { + return strtolower($subtype); + } + } + + return null; + } + + /** + * Check if MIME type is known + * + * @param string $mime MIME type + * @return bool + */ + public static function isKnownMime(string $mime): bool { + return isset(self::MIME_TO_FORMAT[$mime]); + } + + /** + * Check if format is known + * + * @param string $format Format identifier + * @return bool + */ + public static function isKnownFormat(string $format): bool { + if (self::$formatToMime === null) { + self::toMime($format); // Initialize cache + } + return isset(self::$formatToMime[$format]); + } + + /** + * Get all known MIME types + * + * @return array MIME type to format mapping + */ + public static function all(): array { + return self::MIME_TO_FORMAT; + } + +} diff --git a/shared/lib/Blob/Signature.php b/shared/lib/Blob/Signature.php new file mode 100644 index 0000000..497e267 --- /dev/null +++ b/shared/lib/Blob/Signature.php @@ -0,0 +1,230 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Blob; + +use finfo; + +/** + * Signature - Analyzes binary content to determine MIME type and format + * + * This utility only requires the first bytes of a file to detect its format, + * making it compatible with streams, chunked uploads, and remote storage backends like S3. + * + * Uses PHP's built-in finfo extension (libmagic) for reliable detection with + * fallback to custom magic byte detection if finfo is unavailable. + */ +class Signature { + + /** Minimum bytes needed for reliable detection */ + public const HEADER_SIZE = 256; + + /** + * Fallback magic byte signatures for when finfo is unavailable + */ + private const SIGNATURES = [ + ['offset' => 0, 'bytes' => 'FFD8FF', 'format' => 'jpeg'], + ['offset' => 0, 'bytes' => '89504E470D0A1A0A', 'format' => 'png'], + ['offset' => 0, 'bytes' => '47494638', 'format' => 'gif'], + ['offset' => 0, 'bytes' => '25504446', 'format' => 'pdf'], + ['offset' => 0, 'bytes' => '504B0304', 'format' => 'zip'], + ['offset' => 0, 'bytes' => '1F8B08', 'format' => 'gzip'], + ['offset' => 4, 'bytes' => '66747970', 'format' => 'mp4'], + ['offset' => 0, 'bytes' => '494433', 'format' => 'mp3'], + ['offset' => 0, 'bytes' => 'FFFB', 'format' => 'mp3'], + ['offset' => 0, 'bytes' => '52494646', 'format' => 'riff'], // WAV/AVI/WEBP + ]; + + /** Cached finfo instance */ + private static ?finfo $finfo = null; + + /** + * Detect both MIME type and format from content bytes in a single operation + * + * @param string $headerBytes First bytes of the file content (256 recommended) + * @return array{mime: string, format: string} Array with 'mime' and 'format' keys + */ + public static function detect(string $headerBytes): array { + if (strlen($headerBytes) === 0) { + return ['mime' => MimeTypes::MIME_BINARY, 'format' => MimeTypes::FORMAT_BINARY]; + } + + $mime = null; + $format = null; + + // Try finfo first (most reliable) + if (extension_loaded('fileinfo')) { + $mime = self::detectMimeType($headerBytes); + if ($mime !== null) { + // Get format from MIME + $format = MimeTypes::toFormat($mime); + if ($format === null && $mime !== MimeTypes::MIME_BINARY) { + $format = MimeTypes::parseFormat($mime); + } + } + } + + // Fallback to magic bytes if format not determined + if ($format === null) { + $format = self::detectFromMagicBytes($headerBytes); + } + + // Ensure MIME type is set + if ($mime === null || $mime === MimeTypes::MIME_BINARY) { + $mime = MimeTypes::toMime($format) ?? MimeTypes::MIME_BINARY; + } + + return ['mime' => $mime, 'format' => $format]; + } + + /** + * Detect both MIME type and format from a stream in a single operation + * + * @param resource $stream File stream + * @return array{mime: string, format: string} Array with 'mime' and 'format' keys + */ + public static function detectFromStream($stream): array { + $position = ftell($stream); + $headerBytes = fread($stream, self::HEADER_SIZE); + fseek($stream, $position); + + if ($headerBytes === false || $headerBytes === '') { + return ['mime' => MimeTypes::MIME_BINARY, 'format' => MimeTypes::FORMAT_BINARY]; + } + + return self::detect($headerBytes); + } + + /** + * Detect file format from content bytes + * + * @param string $headerBytes First bytes of the file content (256 recommended) + * @return string Detected format (e.g., 'jpeg', 'png', 'pdf') or 'binary' if unknown + */ + public static function detectFormat(string $headerBytes): string { + return self::detect($headerBytes)['format']; + } + + /** + * Detect MIME type from content bytes using finfo + * + * @param string $headerBytes Content bytes + * @return string|null MIME type or null on failure + */ + public static function detectMimeType(string $headerBytes): ?string { + if (!extension_loaded('fileinfo')) { + return null; + } + + if (self::$finfo === null) { + self::$finfo = new finfo(FILEINFO_MIME_TYPE); + } + + $mime = self::$finfo->buffer($headerBytes); + return $mime !== false ? $mime : null; + } + + /** + * Detect file format from a stream + * + * Reads the header bytes, detects format, and rewinds the stream. + * + * @param resource $stream File stream + * @return string Detected format + */ + public static function detectFormatFromStream($stream): string { + $position = ftell($stream); + $headerBytes = fread($stream, self::HEADER_SIZE); + fseek($stream, $position); + + if ($headerBytes === false || $headerBytes === '') { + return MimeTypes::FORMAT_BINARY; + } + + return self::detectFormat($headerBytes); + } + + /** + * Detect MIME type from a stream + * + * @param resource $stream File stream + * @return string|null MIME type or null + */ + public static function detectMimeTypeFromStream($stream): ?string { + $position = ftell($stream); + $headerBytes = fread($stream, self::HEADER_SIZE); + fseek($stream, $position); + + if ($headerBytes === false || $headerBytes === '') { + return null; + } + + return self::detectMimeType($headerBytes); + } + + /** + * Fallback detection using magic bytes + * + * @param string $headerBytes Content bytes + * @return string Detected format or 'binary' + */ + private static function detectFromMagicBytes(string $headerBytes): string { + $headerHex = strtoupper(bin2hex($headerBytes)); + + foreach (self::SIGNATURES as $sig) { + $offset = $sig['offset'] * 2; + $sigBytes = strtoupper($sig['bytes']); + $sigLength = strlen($sigBytes); + + if (strlen($headerHex) < $offset + $sigLength) { + continue; + } + + $slice = substr($headerHex, $offset, $sigLength); + if ($slice === $sigBytes) { + return $sig['format']; + } + } + + // Check if likely text + if (self::isLikelyText($headerBytes)) { + return 'text'; + } + + return MimeTypes::FORMAT_BINARY; + } + + /** + * Check if content appears to be text + * + * @param string $bytes Content bytes + * @return bool + */ + private static function isLikelyText(string $bytes): bool { + // Check for UTF-8 BOM + if (str_starts_with($bytes, "\xEF\xBB\xBF")) { + return true; + } + + $length = min(strlen($bytes), 256); + $printableCount = 0; + + for ($i = 0; $i < $length; $i++) { + $byte = ord($bytes[$i]); + if (($byte >= 32 && $byte <= 126) || $byte === 9 || $byte === 10 || $byte === 13) { + $printableCount++; + } elseif ($byte >= 128 && $byte <= 247) { + $printableCount++; // UTF-8 bytes + } + } + + return ($printableCount / $length) > 0.9; + } + +} diff --git a/shared/lib/Cache/BlobCacheInterface.php b/shared/lib/Cache/BlobCacheInterface.php new file mode 100644 index 0000000..b408d18 --- /dev/null +++ b/shared/lib/Cache/BlobCacheInterface.php @@ -0,0 +1,123 @@ + 'global', + self::Tenant => $tenantId ? "tenant/{$tenantId}" : 'tenant/_unknown', + self::User => $tenantId && $userId + ? "user/{$tenantId}/{$userId}" + : "user/_unknown/_unknown", + }; + } + + /** + * Validate that required identifiers are provided for this scope + */ + public function validate(?string $tenantId, ?string $userId): bool + { + return match ($this) { + self::Global => true, + self::Tenant => $tenantId !== null, + self::User => $tenantId !== null && $userId !== null, + }; + } +} diff --git a/shared/lib/Cache/EphemeralCacheInterface.php b/shared/lib/Cache/EphemeralCacheInterface.php new file mode 100644 index 0000000..271de30 --- /dev/null +++ b/shared/lib/Cache/EphemeralCacheInterface.php @@ -0,0 +1,57 @@ + $tags Tags for grouping/invalidation + * @param int|null $ttl Time-to-live in seconds (null = default, 0 = indefinite) + * @return bool True if stored successfully + */ + public function setWithTags(string $key, mixed $value, CacheScope $scope, string $usage, array $tags, ?int $ttl = null): bool; + + /** + * Invalidate all entries with a specific tag + * + * @param string $tag Tag to invalidate + * @param CacheScope $scope Cache scope level + * @param string $usage Usage/bucket name + * @return int Number of entries invalidated + */ + public function invalidateByTag(string $tag, CacheScope $scope, string $usage): int; + + /** + * Get the version/timestamp of a cached entry + * + * @param string $key Cache key + * @param CacheScope $scope Cache scope level + * @param string $usage Usage/bucket name + * @return int|null Timestamp when entry was cached, or null if not found + */ + public function getVersion(string $key, CacheScope $scope, string $usage): ?int; + + /** + * Check if an entry is stale based on a reference timestamp + * + * @param string $key Cache key + * @param CacheScope $scope Cache scope level + * @param string $usage Usage/bucket name + * @param int $reference Reference timestamp to compare against + * @return bool True if entry is older than reference (or doesn't exist) + */ + public function isStale(string $key, CacheScope $scope, string $usage, int $reference): bool; +} diff --git a/shared/lib/Cache/Store/FileBlobCache.php b/shared/lib/Cache/Store/FileBlobCache.php new file mode 100644 index 0000000..0ad4da3 --- /dev/null +++ b/shared/lib/Cache/Store/FileBlobCache.php @@ -0,0 +1,412 @@ +basePath = rtrim($projectDir, '/') . '/storage'; + } + + /** + * Set the tenant context for scoped operations + */ + public function setTenantContext(?string $tenantId): void + { + $this->tenantId = $tenantId; + } + + /** + * Set the user context for scoped operations + */ + public function setUserContext(?string $userId): void + { + $this->userId = $userId; + } + + /** + * @inheritDoc + */ + public function get(string $key, CacheScope $scope, string $usage): ?string + { + $path = $this->buildPath($key, $scope, $usage); + + if (!$this->isValid($path)) { + return null; + } + + $content = @file_get_contents($path); + + return $content !== false ? $content : null; + } + + /** + * @inheritDoc + */ + public function getStream(string $key, CacheScope $scope, string $usage) + { + $path = $this->buildPath($key, $scope, $usage); + + if (!$this->isValid($path)) { + return null; + } + + $handle = @fopen($path, 'rb'); + + return $handle !== false ? $handle : null; + } + + /** + * @inheritDoc + */ + public function set(string $key, string $data, CacheScope $scope, string $usage, ?string $mimeType = null, ?int $ttl = null): bool + { + $path = $this->buildPath($key, $scope, $usage); + $dir = dirname($path); + + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true) && !is_dir($dir)) { + return false; + } + } + + // Write data file + $tempPath = $path . '.tmp.' . getmypid(); + + if (file_put_contents($tempPath, $data, LOCK_EX) === false) { + return false; + } + + chmod($tempPath, 0600); + + if (!rename($tempPath, $path)) { + @unlink($tempPath); + return false; + } + + // Write metadata + $this->writeMetadata($path, [ + 'mimeType' => $mimeType, + 'size' => strlen($data), + 'createdAt' => time(), + 'expiresAt' => $ttl !== null && $ttl > 0 ? time() + $ttl : null, + ]); + + return true; + } + + /** + * @inheritDoc + */ + public function putStream(string $key, $stream, CacheScope $scope, string $usage, ?string $mimeType = null, ?int $ttl = null): bool + { + $path = $this->buildPath($key, $scope, $usage); + $dir = dirname($path); + + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true) && !is_dir($dir)) { + return false; + } + } + + // Write data file from stream + $tempPath = $path . '.tmp.' . getmypid(); + $dest = @fopen($tempPath, 'wb'); + + if ($dest === false) { + return false; + } + + $size = 0; + while (!feof($stream)) { + $chunk = fread($stream, 8192); + if ($chunk === false) { + fclose($dest); + @unlink($tempPath); + return false; + } + $written = fwrite($dest, $chunk); + if ($written === false) { + fclose($dest); + @unlink($tempPath); + return false; + } + $size += $written; + } + + fclose($dest); + chmod($tempPath, 0600); + + if (!rename($tempPath, $path)) { + @unlink($tempPath); + return false; + } + + // Write metadata + $this->writeMetadata($path, [ + 'mimeType' => $mimeType, + 'size' => $size, + 'createdAt' => time(), + 'expiresAt' => $ttl !== null && $ttl > 0 ? time() + $ttl : null, + ]); + + return true; + } + + /** + * @inheritDoc + */ + public function has(string $key, CacheScope $scope, string $usage): bool + { + $path = $this->buildPath($key, $scope, $usage); + + return $this->isValid($path); + } + + /** + * @inheritDoc + */ + public function delete(string $key, CacheScope $scope, string $usage): bool + { + $path = $this->buildPath($key, $scope, $usage); + $metaPath = $path . '.meta'; + + $result = true; + + if (file_exists($path)) { + $result = @unlink($path); + } + + if (file_exists($metaPath)) { + @unlink($metaPath); + } + + return $result; + } + + /** + * @inheritDoc + */ + public function getPath(string $key, CacheScope $scope, string $usage): ?string + { + $path = $this->buildPath($key, $scope, $usage); + + if (!$this->isValid($path)) { + return null; + } + + return $path; + } + + /** + * @inheritDoc + */ + public function getMetadata(string $key, CacheScope $scope, string $usage): ?array + { + $path = $this->buildPath($key, $scope, $usage); + + if (!file_exists($path)) { + return null; + } + + return $this->readMetadata($path); + } + + /** + * @inheritDoc + */ + public function clear(CacheScope $scope, string $usage): int + { + $dir = $this->buildDir($scope, $usage); + + if (!is_dir($dir)) { + return 0; + } + + $count = 0; + $files = glob($dir . '/*'); + + foreach ($files as $file) { + if (is_file($file) && !str_ends_with($file, '.meta')) { + if (@unlink($file)) { + $count++; + // Also remove metadata file + @unlink($file . '.meta'); + } + } + } + + return $count; + } + + /** + * @inheritDoc + */ + public function cleanup(): int + { + $count = 0; + $now = time(); + + // Scan all tenant directories + $tenantDirs = glob($this->basePath . '/*', GLOB_ONLYDIR); + + foreach ($tenantDirs as $tenantDir) { + $files = $this->findBlobFiles($tenantDir); + + foreach ($files as $file) { + $meta = $this->readMetadata($file); + + if ($meta !== null && $meta['expiresAt'] !== null && $meta['expiresAt'] < $now) { + if (@unlink($file)) { + @unlink($file . '.meta'); + $count++; + } + } + } + } + + return $count; + } + + /** + * Check if a blob is valid (exists and not expired) + */ + private function isValid(string $path): bool + { + if (!file_exists($path)) { + return false; + } + + $meta = $this->readMetadata($path); + + if ($meta !== null && $meta['expiresAt'] !== null && $meta['expiresAt'] < time()) { + @unlink($path); + @unlink($path . '.meta'); + return false; + } + + return true; + } + + /** + * Build the full path for a blob + */ + private function buildPath(string $key, CacheScope $scope, string $usage): string + { + $dir = $this->buildDir($scope, $usage); + $hash = $this->hashKey($key); + + return $dir . '/' . $hash; + } + + /** + * Build the directory path for a scope/usage combination + */ + private function buildDir(CacheScope $scope, string $usage): string + { + $usage = preg_replace('/[^a-zA-Z0-9_-]/', '_', $usage); + + return match ($scope) { + CacheScope::Global => $this->basePath . '/_global/cache/' . $usage, + CacheScope::Tenant => $this->basePath . '/' . ($this->tenantId ?? '_unknown') . '/cache/' . $usage, + CacheScope::User => $this->basePath . '/' . ($this->tenantId ?? '_unknown') . '/' . ($this->userId ?? '_unknown') . '/cache/' . $usage, + }; + } + + /** + * Hash a cache key for filesystem safety + */ + private function hashKey(string $key): string + { + // Extract extension if present in key + $ext = ''; + if (preg_match('/\.([a-zA-Z0-9]{2,5})$/', $key, $matches)) { + $ext = '.' . strtolower($matches[1]); + } + + $safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($key, 0, 32)); + $hash = substr(hash('sha256', $key), 0, 16); + + return $safe . '_' . $hash . $ext; + } + + /** + * Read metadata for a blob + */ + private function readMetadata(string $path): ?array + { + $metaPath = $path . '.meta'; + + if (!file_exists($metaPath)) { + // Return basic metadata from file stats + if (file_exists($path)) { + $stat = stat($path); + return [ + 'mimeType' => null, + 'size' => $stat['size'] ?? 0, + 'createdAt' => $stat['ctime'] ?? time(), + 'expiresAt' => null, + ]; + } + return null; + } + + $content = @file_get_contents($metaPath); + + if ($content === false) { + return null; + } + + $meta = @unserialize($content); + + return is_array($meta) ? $meta : null; + } + + /** + * Write metadata for a blob + */ + private function writeMetadata(string $path, array $metadata): bool + { + $metaPath = $path . '.meta'; + + return file_put_contents($metaPath, serialize($metadata), LOCK_EX) !== false; + } + + /** + * Recursively find all blob files in a directory + */ + private function findBlobFiles(string $dir): array + { + $files = []; + + $cacheDirs = glob($dir . '/cache/*', GLOB_ONLYDIR) ?: []; + $cacheDirs = array_merge($cacheDirs, glob($dir . '/*/cache/*', GLOB_ONLYDIR) ?: []); + + foreach ($cacheDirs as $cacheDir) { + $blobFiles = glob($cacheDir . '/*'); + foreach ($blobFiles as $file) { + if (is_file($file) && !str_ends_with($file, '.meta')) { + $files[] = $file; + } + } + } + + return $files; + } +} diff --git a/shared/lib/Cache/Store/FileEphemeralCache.php b/shared/lib/Cache/Store/FileEphemeralCache.php new file mode 100644 index 0000000..aab51df --- /dev/null +++ b/shared/lib/Cache/Store/FileEphemeralCache.php @@ -0,0 +1,346 @@ +basePath = rtrim($projectDir, '/') . '/var/cache'; + } + + /** + * Set the tenant context for scoped operations + */ + public function setTenantContext(?string $tenantId): void + { + $this->tenantId = $tenantId; + } + + /** + * Set the user context for scoped operations + */ + public function setUserContext(?string $userId): void + { + $this->userId = $userId; + } + + /** + * @inheritDoc + */ + public function get(string $key, CacheScope $scope, string $usage): mixed + { + $path = $this->buildPath($key, $scope, $usage); + + if (!file_exists($path)) { + return null; + } + + $entry = $this->readEntry($path); + + if ($entry === null) { + return null; + } + + // Check expiration + if ($entry['expiresAt'] > 0 && $entry['expiresAt'] < time()) { + @unlink($path); + return null; + } + + return $entry['value']; + } + + /** + * @inheritDoc + */ + public function set(string $key, mixed $value, CacheScope $scope, string $usage, ?int $ttl = null): bool + { + $path = $this->buildPath($key, $scope, $usage); + $dir = dirname($path); + + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true) && !is_dir($dir)) { + return false; + } + } + + $ttl = $ttl ?? self::DEFAULT_TTL; + $entry = [ + 'key' => $key, + 'value' => $value, + 'createdAt' => time(), + 'expiresAt' => $ttl > 0 ? time() + $ttl : 0, + ]; + + return $this->writeEntry($path, $entry); + } + + /** + * @inheritDoc + */ + public function has(string $key, CacheScope $scope, string $usage): bool + { + return $this->get($key, $scope, $usage) !== null; + } + + /** + * @inheritDoc + */ + public function delete(string $key, CacheScope $scope, string $usage): bool + { + $path = $this->buildPath($key, $scope, $usage); + + if (file_exists($path)) { + return @unlink($path); + } + + return true; + } + + /** + * @inheritDoc + */ + public function clear(CacheScope $scope, string $usage): int + { + $dir = $this->buildDir($scope, $usage); + + if (!is_dir($dir)) { + return 0; + } + + $count = 0; + $files = glob($dir . '/*.cache'); + + foreach ($files as $file) { + if (@unlink($file)) { + $count++; + } + } + + return $count; + } + + /** + * @inheritDoc + */ + public function cleanup(): int + { + $count = 0; + $now = time(); + + // Scan all scope directories + $scopeDirs = glob($this->basePath . '/*', GLOB_ONLYDIR); + + foreach ($scopeDirs as $scopeDir) { + $files = $this->findCacheFiles($scopeDir); + + foreach ($files as $file) { + $entry = $this->readEntry($file); + + if ($entry !== null && $entry['expiresAt'] > 0 && $entry['expiresAt'] < $now) { + if (@unlink($file)) { + $count++; + } + } + } + } + + return $count; + } + + /** + * @inheritDoc + */ + public function remember(string $key, callable $callback, CacheScope $scope, string $usage, ?int $ttl = null): mixed + { + $value = $this->get($key, $scope, $usage); + + if ($value !== null) { + return $value; + } + + $value = $callback(); + $this->set($key, $value, $scope, $usage, $ttl); + + return $value; + } + + /** + * @inheritDoc + */ + public function increment(string $key, CacheScope $scope, string $usage, int $amount = 1): int|false + { + $path = $this->buildPath($key, $scope, $usage); + + // Use file locking for atomic increment + $handle = @fopen($path, 'c+'); + if ($handle === false) { + // File doesn't exist, create with initial value + $dir = dirname($path); + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true) && !is_dir($dir)) { + return false; + } + } + + $handle = @fopen($path, 'c+'); + if ($handle === false) { + return false; + } + } + + if (!flock($handle, LOCK_EX)) { + fclose($handle); + return false; + } + + try { + $content = stream_get_contents($handle); + $entry = $content ? @unserialize($content) : null; + + if ($entry === null || ($entry['expiresAt'] > 0 && $entry['expiresAt'] < time())) { + // Initialize new entry + $newValue = $amount; + $entry = [ + 'key' => $key, + 'value' => $newValue, + 'createdAt' => time(), + 'expiresAt' => time() + self::DEFAULT_TTL, + ]; + } else { + $newValue = (int)$entry['value'] + $amount; + $entry['value'] = $newValue; + } + + ftruncate($handle, 0); + rewind($handle); + fwrite($handle, serialize($entry)); + + return $newValue; + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } + + /** + * @inheritDoc + */ + public function decrement(string $key, CacheScope $scope, string $usage, int $amount = 1): int|false + { + return $this->increment($key, $scope, $usage, -$amount); + } + + /** + * Build the full path for a cache entry + */ + private function buildPath(string $key, CacheScope $scope, string $usage): string + { + $dir = $this->buildDir($scope, $usage); + $hash = $this->hashKey($key); + + return $dir . '/' . $hash . '.cache'; + } + + /** + * Build the directory path for a scope/usage combination + */ + private function buildDir(CacheScope $scope, string $usage): string + { + $prefix = $scope->buildPrefix($this->tenantId, $this->userId); + $usage = preg_replace('/[^a-zA-Z0-9_-]/', '_', $usage); + + return $this->basePath . '/' . $prefix . '/' . $usage; + } + + /** + * Hash a cache key for filesystem safety + */ + private function hashKey(string $key): string + { + // Use a prefix of the original key for debugging + hash for uniqueness + $safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($key, 0, 32)); + $hash = substr(hash('sha256', $key), 0, 16); + + return $safe . '_' . $hash; + } + + /** + * Read and unserialize a cache entry + */ + private function readEntry(string $path): ?array + { + $content = @file_get_contents($path); + + if ($content === false) { + return null; + } + + $entry = @unserialize($content); + + if (!is_array($entry) || !isset($entry['value'])) { + return null; + } + + return $entry; + } + + /** + * Serialize and write a cache entry atomically + */ + private function writeEntry(string $path, array $entry): bool + { + $content = serialize($entry); + $tempPath = $path . '.tmp.' . getmypid(); + + if (file_put_contents($tempPath, $content, LOCK_EX) === false) { + return false; + } + + chmod($tempPath, 0600); + + if (!rename($tempPath, $path)) { + @unlink($tempPath); + return false; + } + + return true; + } + + /** + * Recursively find all cache files in a directory + */ + private function findCacheFiles(string $dir): array + { + $files = []; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'cache') { + $files[] = $file->getPathname(); + } + } + + return $files; + } +} diff --git a/shared/lib/Cache/Store/FilePersistentCache.php b/shared/lib/Cache/Store/FilePersistentCache.php new file mode 100644 index 0000000..25b780d --- /dev/null +++ b/shared/lib/Cache/Store/FilePersistentCache.php @@ -0,0 +1,433 @@ +basePath = rtrim($projectDir, '/') . '/var/cache'; + } + + /** + * Set the tenant context for scoped operations + */ + public function setTenantContext(?string $tenantId): void + { + $this->tenantId = $tenantId; + } + + /** + * Set the user context for scoped operations + */ + public function setUserContext(?string $userId): void + { + $this->userId = $userId; + } + + /** + * @inheritDoc + */ + public function get(string $key, CacheScope $scope, string $usage): mixed + { + $path = $this->buildPath($key, $scope, $usage); + + if (!file_exists($path)) { + return null; + } + + $entry = $this->readEntry($path); + + if ($entry === null) { + return null; + } + + // Check expiration (0 = never expires) + if ($entry['expiresAt'] > 0 && $entry['expiresAt'] < time()) { + @unlink($path); + $this->removeFromTagIndex($key, $scope, $usage, $entry['tags'] ?? []); + return null; + } + + return $entry['value']; + } + + /** + * @inheritDoc + */ + public function set(string $key, mixed $value, CacheScope $scope, string $usage, ?int $ttl = null): bool + { + return $this->setWithTags($key, $value, $scope, $usage, [], $ttl); + } + + /** + * @inheritDoc + */ + public function setWithTags(string $key, mixed $value, CacheScope $scope, string $usage, array $tags, ?int $ttl = null): bool + { + $path = $this->buildPath($key, $scope, $usage); + $dir = dirname($path); + + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true) && !is_dir($dir)) { + return false; + } + } + + // Remove from old tags if entry exists + $existingEntry = $this->readEntry($path); + if ($existingEntry !== null && !empty($existingEntry['tags'])) { + $this->removeFromTagIndex($key, $scope, $usage, $existingEntry['tags']); + } + + $ttl = $ttl ?? self::DEFAULT_TTL; + $entry = [ + 'key' => $key, + 'value' => $value, + 'tags' => $tags, + 'createdAt' => time(), + 'expiresAt' => $ttl > 0 ? time() + $ttl : 0, + ]; + + $result = $this->writeEntry($path, $entry); + + if ($result && !empty($tags)) { + $this->addToTagIndex($key, $scope, $usage, $tags); + } + + return $result; + } + + /** + * @inheritDoc + */ + public function has(string $key, CacheScope $scope, string $usage): bool + { + return $this->get($key, $scope, $usage) !== null; + } + + /** + * @inheritDoc + */ + public function delete(string $key, CacheScope $scope, string $usage): bool + { + $path = $this->buildPath($key, $scope, $usage); + + if (file_exists($path)) { + $entry = $this->readEntry($path); + if ($entry !== null && !empty($entry['tags'])) { + $this->removeFromTagIndex($key, $scope, $usage, $entry['tags']); + } + return @unlink($path); + } + + return true; + } + + /** + * @inheritDoc + */ + public function clear(CacheScope $scope, string $usage): int + { + $dir = $this->buildDir($scope, $usage); + + if (!is_dir($dir)) { + return 0; + } + + $count = 0; + $files = glob($dir . '/*.cache'); + + foreach ($files as $file) { + if (@unlink($file)) { + $count++; + } + } + + // Clear tag index + $tagIndexPath = $dir . '/.tags'; + if (file_exists($tagIndexPath)) { + @unlink($tagIndexPath); + } + + return $count; + } + + /** + * @inheritDoc + */ + public function cleanup(): int + { + $count = 0; + $now = time(); + + // Scan all scope directories + $scopeDirs = glob($this->basePath . '/*', GLOB_ONLYDIR); + + foreach ($scopeDirs as $scopeDir) { + $files = $this->findCacheFiles($scopeDir); + + foreach ($files as $file) { + $entry = $this->readEntry($file); + + if ($entry !== null && $entry['expiresAt'] > 0 && $entry['expiresAt'] < $now) { + if (@unlink($file)) { + $count++; + } + } + } + } + + return $count; + } + + /** + * @inheritDoc + */ + public function invalidateByTag(string $tag, CacheScope $scope, string $usage): int + { + $tagIndex = $this->readTagIndex($scope, $usage); + + if (!isset($tagIndex[$tag])) { + return 0; + } + + $count = 0; + $keys = $tagIndex[$tag]; + + foreach ($keys as $key) { + if ($this->delete($key, $scope, $usage)) { + $count++; + } + } + + return $count; + } + + /** + * @inheritDoc + */ + public function getVersion(string $key, CacheScope $scope, string $usage): ?int + { + $path = $this->buildPath($key, $scope, $usage); + + if (!file_exists($path)) { + return null; + } + + $entry = $this->readEntry($path); + + return $entry['createdAt'] ?? null; + } + + /** + * @inheritDoc + */ + public function isStale(string $key, CacheScope $scope, string $usage, int $reference): bool + { + $version = $this->getVersion($key, $scope, $usage); + + if ($version === null) { + return true; + } + + return $version < $reference; + } + + /** + * Build the full path for a cache entry + */ + private function buildPath(string $key, CacheScope $scope, string $usage): string + { + $dir = $this->buildDir($scope, $usage); + $hash = $this->hashKey($key); + + return $dir . '/' . $hash . '.cache'; + } + + /** + * Build the directory path for a scope/usage combination + */ + private function buildDir(CacheScope $scope, string $usage): string + { + $prefix = $scope->buildPrefix($this->tenantId, $this->userId); + $usage = preg_replace('/[^a-zA-Z0-9_-]/', '_', $usage); + + return $this->basePath . '/' . $prefix . '/' . $usage; + } + + /** + * Hash a cache key for filesystem safety + */ + private function hashKey(string $key): string + { + $safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($key, 0, 32)); + $hash = substr(hash('sha256', $key), 0, 16); + + return $safe . '_' . $hash; + } + + /** + * Read and unserialize a cache entry + */ + private function readEntry(string $path): ?array + { + $content = @file_get_contents($path); + + if ($content === false) { + return null; + } + + $entry = @unserialize($content); + + if (!is_array($entry) || !isset($entry['value'])) { + return null; + } + + return $entry; + } + + /** + * Serialize and write a cache entry atomically + */ + private function writeEntry(string $path, array $entry): bool + { + $content = serialize($entry); + $tempPath = $path . '.tmp.' . getmypid(); + + if (file_put_contents($tempPath, $content, LOCK_EX) === false) { + return false; + } + + chmod($tempPath, 0600); + + if (!rename($tempPath, $path)) { + @unlink($tempPath); + return false; + } + + return true; + } + + /** + * Read the tag index for a usage bucket + */ + private function readTagIndex(CacheScope $scope, string $usage): array + { + $path = $this->buildDir($scope, $usage) . '/.tags'; + + if (!file_exists($path)) { + return []; + } + + $content = @file_get_contents($path); + + if ($content === false) { + return []; + } + + $index = @unserialize($content); + + return is_array($index) ? $index : []; + } + + /** + * Write the tag index for a usage bucket + */ + private function writeTagIndex(CacheScope $scope, string $usage, array $index): bool + { + $path = $this->buildDir($scope, $usage) . '/.tags'; + $dir = dirname($path); + + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true) && !is_dir($dir)) { + return false; + } + } + + return file_put_contents($path, serialize($index), LOCK_EX) !== false; + } + + /** + * Add a key to the tag index + */ + private function addToTagIndex(string $key, CacheScope $scope, string $usage, array $tags): void + { + $index = $this->readTagIndex($scope, $usage); + + foreach ($tags as $tag) { + if (!isset($index[$tag])) { + $index[$tag] = []; + } + if (!in_array($key, $index[$tag], true)) { + $index[$tag][] = $key; + } + } + + $this->writeTagIndex($scope, $usage, $index); + } + + /** + * Remove a key from the tag index + */ + private function removeFromTagIndex(string $key, CacheScope $scope, string $usage, array $tags): void + { + $index = $this->readTagIndex($scope, $usage); + $changed = false; + + foreach ($tags as $tag) { + if (isset($index[$tag])) { + $pos = array_search($key, $index[$tag], true); + if ($pos !== false) { + unset($index[$tag][$pos]); + $index[$tag] = array_values($index[$tag]); + $changed = true; + + if (empty($index[$tag])) { + unset($index[$tag]); + } + } + } + } + + if ($changed) { + $this->writeTagIndex($scope, $usage, $index); + } + } + + /** + * Recursively find all cache files in a directory + */ + private function findCacheFiles(string $dir): array + { + $files = []; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'cache') { + $files[] = $file->getPathname(); + } + } + + return $files; + } +} diff --git a/shared/lib/Chrono/Collection/CollectionContent.php b/shared/lib/Chrono/Collection/CollectionContent.php new file mode 100644 index 0000000..9e0a403 --- /dev/null +++ b/shared/lib/Chrono/Collection/CollectionContent.php @@ -0,0 +1,24 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Collection; + +use JsonSerializable; + +enum CollectionContent: string implements JsonSerializable { + + case Event = 'event'; + case Task = 'task'; + case Journal = 'journal'; + + public function jsonSerialize(): string { + return $this->value; + } + +} diff --git a/shared/lib/Chrono/Collection/CollectionPermissions.php b/shared/lib/Chrono/Collection/CollectionPermissions.php new file mode 100644 index 0000000..8c11f26 --- /dev/null +++ b/shared/lib/Chrono/Collection/CollectionPermissions.php @@ -0,0 +1,26 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Collection; + +use JsonSerializable; + +enum CollectionPermissions: string implements JsonSerializable { + + case View = 'view'; + case Create = 'create'; + case Modify = 'modify'; + case Destroy = 'destroy'; + case Share = 'share'; + + public function jsonSerialize(): string { + return $this->value; + } + +} diff --git a/shared/lib/Chrono/Collection/CollectionRoles.php b/shared/lib/Chrono/Collection/CollectionRoles.php new file mode 100644 index 0000000..602c2c5 --- /dev/null +++ b/shared/lib/Chrono/Collection/CollectionRoles.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Collection; + +use JsonSerializable; + +enum CollectionRoles: string implements JsonSerializable { + + case System = 'system'; + case Individual = 'individual'; + + public function jsonSerialize(): string { + return $this->value; + } + +} diff --git a/shared/lib/Chrono/Collection/ICollectionBase.php b/shared/lib/Chrono/Collection/ICollectionBase.php new file mode 100644 index 0000000..cb5e438 --- /dev/null +++ b/shared/lib/Chrono/Collection/ICollectionBase.php @@ -0,0 +1,160 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Collection; + +use DateTimeImmutable; +use JsonSerializable; + +interface ICollectionBase extends JsonSerializable { + + public const JSON_TYPE = 'chrono.collection'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_PROVIDER = 'provider'; + public const JSON_PROPERTY_SERVICE = 'service'; + public const JSON_PROPERTY_IN = 'in'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_DESCRIPTION = 'description'; + public const JSON_PROPERTY_PRIORITY = 'priority'; + public const JSON_PROPERTY_VISIBILITY = 'visibility'; + public const JSON_PROPERTY_COLOR = 'color'; + public const JSON_PROPERTY_CREATED = 'created'; + public const JSON_PROPERTY_MODIFIED = 'modified'; + public const JSON_PROPERTY_ENABLED = 'enabled'; + public const JSON_PROPERTY_SIGNATURE = 'signature'; + public const JSON_PROPERTY_PERMISSIONS = 'permissions'; + public const JSON_PROPERTY_ROLES = 'roles'; + public const JSON_PROPERTY_CONTENTS = 'contents'; + + /** + * Unique identifier of the service this collection belongs to + * + * @since 2025.05.01 + */ + public function in(): string|int|null; + + /** + * Unique arbitrary text string identifying this collection (e.g. 1 or collection1 or anything else) + * + * @since 2025.05.01 + */ + public function id(): string|int; + + /** + * Gets the creation date of this collection + */ + public function created(): ?DateTimeImmutable; + + /** + * Gets the modification date of this collection + */ + public function modified(): ?DateTimeImmutable; + + /** + * Lists all supported attributes + * + * @since 2025.05.01 + * + * @return array> + */ + public function attributes(): array; + + /** + * Gets the signature of this collection + * + * @since 2025.05.01 + */ + public function signature(): ?string; + + /** + * Gets the role(s) of this collection + * + * @since 2025.05.01 + */ + public function roles(): array; + + /** + * Checks if this collection supports the given role + * + * @since 2025.05.01 + */ + public function role(CollectionRoles $value): bool; + + /** + * Gets the content types of this collection + * + * @since 2025.05.01 + */ + public function contents(): array; + + /** + * Checks if this collection contains the given content type + * + * @since 2025.05.01 + */ + public function contains(CollectionContent $value): bool; + + /** + * Gets the active status of this collection + * + * @since 2025.05.01 + */ + public function getEnabled(): bool; + + /** + * Gets the permissions of this collection + * + * @since 2025.05.01 + */ + public function getPermissions(): array; + + /** + * Checks if this collection has the given permission + * + * @since 2025.05.01 + */ + public function hasPermission(CollectionPermissions $permission): bool; + + /** + * Gets the human friendly name of this collection (e.g. Personal Calendar) + * + * @since 2025.05.01 + */ + public function getLabel(): ?string; + + /** + * Gets the human friendly description of this collection + * + * @since 2025.05.01 + */ + public function getDescription(): ?string; + + /** + * Gets the priority of this collection + * + * @since 2025.05.01 + */ + public function getPriority(): ?int; + + /** + * Gets the visibility of this collection + * + * @since 2025.05.01 + */ + public function getVisibility(): ?bool; + + /** + * Gets the color of this collection + * + * @since 2025.05.01 + */ + public function getColor(): ?string; + +} diff --git a/shared/lib/Chrono/Collection/ICollectionMutable.php b/shared/lib/Chrono/Collection/ICollectionMutable.php new file mode 100644 index 0000000..167f02c --- /dev/null +++ b/shared/lib/Chrono/Collection/ICollectionMutable.php @@ -0,0 +1,58 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Collection; + +use KTXF\Json\JsonDeserializable; + +interface ICollectionMutable extends ICollectionBase, JsonDeserializable { + + /** + * Sets the active status of this collection + * + * @since 2025.05.01 + */ + public function setEnabled(bool $value): self; + + /** + * Sets the human friendly name of this collection (e.g. Personal Calendar) + * + * @since 2025.05.01 + */ + public function setLabel(string $value): self; + + /** + * Sets the human friendly description of this collection + * + * @since 2025.05.01 + */ + public function setDescription(?string $value): self; + + /** + * Sets the priority of this collection + * + * @since 2025.05.01 + */ + public function setPriority(?int $value): self; + + /** + * Sets the visibility of this collection + * + * @since 2025.05.01 + */ + public function setVisibility(?bool $value): self; + + /** + * Sets the color of this collection + * + * @since 2025.05.01 + */ + public function setColor(?string $value): self; + +} diff --git a/shared/lib/Chrono/Entity/EntityPermissions.php b/shared/lib/Chrono/Entity/EntityPermissions.php new file mode 100644 index 0000000..fd77959 --- /dev/null +++ b/shared/lib/Chrono/Entity/EntityPermissions.php @@ -0,0 +1,25 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Entity; + +use JsonSerializable; + +enum EntityPermissions: string implements JsonSerializable { + + case View = 'view'; + case Modify = 'modify'; + case Delete = 'delete'; + case Share = 'share'; + + public function jsonSerialize(): string { + return $this->value; + } + +} diff --git a/shared/lib/Chrono/Entity/IEntityBase.php b/shared/lib/Chrono/Entity/IEntityBase.php new file mode 100644 index 0000000..d3cf50c --- /dev/null +++ b/shared/lib/Chrono/Entity/IEntityBase.php @@ -0,0 +1,93 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Entity; + +use DateTimeImmutable; + +interface IEntityBase extends \JsonSerializable { + + public const JSON_TYPE = 'chrono.entity'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_IN = 'in'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_DATA = 'data'; + public const JSON_PROPERTY_CREATED = 'created'; + public const JSON_PROPERTY_MODIFIED = 'modified'; + public const JSON_PROPERTY_SIGNATURE = 'signature'; + + /** + * Unique arbitrary text string identifying the collection this entity belongs to (e.g. 1 or Collection1 or anything else) + * + * @since 2025.05.01 + */ + public function in(): string|int; + + /** + * Unique arbitrary text string identifying this entity (e.g. 1 or Entity or anything else) + * + * @since 2025.05.01 + */ + public function id(): string|int; + + /** + * Gets the creation date of this entity + */ + public function created(): ?DateTimeImmutable; + + /** + * Gets the modification date of this entity + */ + public function modified(): ?DateTimeImmutable; + + /** + * Gets the signature of this entity + * + * @since 2025.05.01 + */ + public function signature(): ?string; + + /** + * Gets the priority of this entity + * + * @since 2025.05.01 + */ + public function getPriority(): ?int; + + /** + * Gets the visibility of this entity + * + * @since 2025.05.01 + */ + public function getVisibility(): ?bool; + + /** + * Gets the color of this entity + * + * @since 2025.05.01 + */ + public function getColor(): ?string; + + /** + * Gets the object data (event, task, or journal). + * + * @since 2025.05.01 + */ + public function getDataObject(): object|null; + + /** + * Gets the raw data as an associative array or JSON string. + * + * @since 2025.05.01 + * + * @return array|string|null + */ + public function getDataJson(): array|string|null; + +} diff --git a/shared/lib/Chrono/Entity/IEntityMutable.php b/shared/lib/Chrono/Entity/IEntityMutable.php new file mode 100644 index 0000000..244d313 --- /dev/null +++ b/shared/lib/Chrono/Entity/IEntityMutable.php @@ -0,0 +1,51 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Entity; + +use KTXF\Json\JsonDeserializable; + +interface IEntityMutable extends IEntityBase, JsonDeserializable { + + /** + * Sets the priority of this entity + * + * @since 2025.05.01 + */ + public function setPriority(?int $value): static; + + /** + * Sets the visibility of this entity + * + * @since 2025.05.01 + */ + public function setVisibility(?bool $value): static; + + /** + * Sets the color of this entity + * + * @since 2025.05.01 + */ + public function setColor(?string $value): static; + + /** + * Sets the object as a class instance. + * + * @since 2025.05.01 + */ + public function setDataObject(object $value): static; + + /** + * Sets the object data from a json string + * + * @since 2025.05.01 + */ + public function setDataJson(array|string $value): static; + +} diff --git a/shared/lib/Chrono/Event/EventAvailabilityTypes.php b/shared/lib/Chrono/Event/EventAvailabilityTypes.php new file mode 100644 index 0000000..8a5d6f7 --- /dev/null +++ b/shared/lib/Chrono/Event/EventAvailabilityTypes.php @@ -0,0 +1,15 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventAvailabilityTypes: string { + case Free = 'free'; + case Busy = 'busy'; +} diff --git a/shared/lib/Chrono/Event/EventCommonObject.php b/shared/lib/Chrono/Event/EventCommonObject.php new file mode 100644 index 0000000..0260e30 --- /dev/null +++ b/shared/lib/Chrono/Event/EventCommonObject.php @@ -0,0 +1,50 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use DateInterval; +use DateTime; +use DateTimeImmutable; +use DateTimeZone; +use KTXF\Json\JsonSerializableObject; + +class EventCommonObject extends JsonSerializableObject { + + public int|null $sequence = null; + public DateTimeZone|null $timeZone = null; + public DateTime|DateTimeImmutable|null $startsOn = null; + public DateTimeZone|null $startsTZ = null; + public DateTime|DateTimeImmutable|null $endsOn = null; + public DateTimeZone|null $endsTZ = null; + public DateInterval|null $duration = null; + public bool|null $timeless = false; + public string|null $label = null; + public string|null $description = null; + public EventLocationPhysicalCollection $locationsPhysical; + public EventLocationVirtualCollection $locationsVirtual; + public EventAvailabilityTypes|null $availability = null; + public EventSensitivityTypes|null $sensitivity = null; + public int|null $priority = null; + public string|null $color = null; + public EventTagCollection $tags; + public EventOrganizerObject $organizer; + public EventParticipantCollection $participants; + public EventNotificationCollection $notifications; + + public function __construct() { + $this->participants = new EventParticipantCollection(); + $this->locationsPhysical = new EventLocationPhysicalCollection(); + $this->locationsVirtual = new EventLocationVirtualCollection(); + $this->notifications = new EventNotificationCollection(); + $this->organizer = new EventOrganizerObject(); + $this->tags = new EventTagCollection(); + } + +} diff --git a/shared/lib/Chrono/Event/EventLocationPhysicalCollection.php b/shared/lib/Chrono/Event/EventLocationPhysicalCollection.php new file mode 100644 index 0000000..59a9db8 --- /dev/null +++ b/shared/lib/Chrono/Event/EventLocationPhysicalCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableCollection; + +class EventLocationPhysicalCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, EventLocationPhysicalObject::class, 'string'); + } + +} diff --git a/shared/lib/Chrono/Event/EventLocationPhysicalObject.php b/shared/lib/Chrono/Event/EventLocationPhysicalObject.php new file mode 100644 index 0000000..b6d1167 --- /dev/null +++ b/shared/lib/Chrono/Event/EventLocationPhysicalObject.php @@ -0,0 +1,22 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableObject; + +class EventLocationPhysicalObject extends JsonSerializableObject { + + public string|null $identifier = null; + public string|null $label = null; + public string|null $description = null; + public string|null $relation = null; // e.g. start, end of event + public string|null $timeZone = null; + +} diff --git a/shared/lib/Chrono/Event/EventLocationVirtualCollection.php b/shared/lib/Chrono/Event/EventLocationVirtualCollection.php new file mode 100644 index 0000000..7565648 --- /dev/null +++ b/shared/lib/Chrono/Event/EventLocationVirtualCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableCollection; + +class EventLocationVirtualCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, EventLocationVirtualObject::class, 'string'); + } + +} diff --git a/shared/lib/Chrono/Event/EventLocationVirtualObject.php b/shared/lib/Chrono/Event/EventLocationVirtualObject.php new file mode 100644 index 0000000..9fe7624 --- /dev/null +++ b/shared/lib/Chrono/Event/EventLocationVirtualObject.php @@ -0,0 +1,22 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableObject; + +class EventLocationVirtualObject extends JsonSerializableObject { + + public string|null $identifier = null; + public string|null $label = null; + public string|null $description = null; + public string|null $relation = null; + public string|null $location = null; + +} diff --git a/shared/lib/Chrono/Event/EventMutationCollection.php b/shared/lib/Chrono/Event/EventMutationCollection.php new file mode 100644 index 0000000..4d581b7 --- /dev/null +++ b/shared/lib/Chrono/Event/EventMutationCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableCollection; + +class EventMutationCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, EventMutationObject::class, 'string'); + } + +} diff --git a/shared/lib/Chrono/Event/EventMutationObject.php b/shared/lib/Chrono/Event/EventMutationObject.php new file mode 100644 index 0000000..6cf7c32 --- /dev/null +++ b/shared/lib/Chrono/Event/EventMutationObject.php @@ -0,0 +1,21 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use DateTime; +use DateTimeImmutable; + +class EventMutationObject extends EventCommonObject { + + public DateTime|DateTimeImmutable|null $mutationId = null; + public string|null $mutationTz = null; + public bool|null $mutationExclusion = null; + +} diff --git a/shared/lib/Chrono/Event/EventNotificationAnchorTypes.php b/shared/lib/Chrono/Event/EventNotificationAnchorTypes.php new file mode 100644 index 0000000..f4dab63 --- /dev/null +++ b/shared/lib/Chrono/Event/EventNotificationAnchorTypes.php @@ -0,0 +1,15 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventNotificationAnchorTypes: string { + case Start = 'start'; + case End = 'end'; +} diff --git a/shared/lib/Chrono/Event/EventNotificationCollection.php b/shared/lib/Chrono/Event/EventNotificationCollection.php new file mode 100644 index 0000000..817923e --- /dev/null +++ b/shared/lib/Chrono/Event/EventNotificationCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableCollection; + +class EventNotificationCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, EventNotificationObject::class, 'string'); + } + +} diff --git a/shared/lib/Chrono/Event/EventNotificationObject.php b/shared/lib/Chrono/Event/EventNotificationObject.php new file mode 100644 index 0000000..0a5eb8f --- /dev/null +++ b/shared/lib/Chrono/Event/EventNotificationObject.php @@ -0,0 +1,24 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use DateInterval; +use DateTime; +use DateTimeImmutable; +use KTXF\Json\JsonSerializableObject; + +class EventNotificationObject extends JsonSerializableObject { + public string|null $identifier = null; + public EventNotificationTypes|null $Type = null; + public EventNotificationPatterns|null $Pattern = null; + public DateTime|DateTimeImmutable|null $When = null; + public EventNotificationAnchorTypes|null $Anchor = null; + public DateInterval|null $Offset = null; +} diff --git a/shared/lib/Chrono/Event/EventNotificationPatterns.php b/shared/lib/Chrono/Event/EventNotificationPatterns.php new file mode 100644 index 0000000..1d35632 --- /dev/null +++ b/shared/lib/Chrono/Event/EventNotificationPatterns.php @@ -0,0 +1,16 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventNotificationPatterns: string { + case Absolute = 'absolute'; + case Relative = 'relative'; + case Unknown = 'unknown'; +} diff --git a/shared/lib/Chrono/Event/EventNotificationTypes.php b/shared/lib/Chrono/Event/EventNotificationTypes.php new file mode 100644 index 0000000..4ec49a7 --- /dev/null +++ b/shared/lib/Chrono/Event/EventNotificationTypes.php @@ -0,0 +1,16 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventNotificationTypes: string { + case Visual = 'visual'; + case Audible = 'audible'; + case Email = 'email'; +} diff --git a/shared/lib/Chrono/Event/EventObject.php b/shared/lib/Chrono/Event/EventObject.php new file mode 100644 index 0000000..3e1f206 --- /dev/null +++ b/shared/lib/Chrono/Event/EventObject.php @@ -0,0 +1,31 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use DateTimeInterface; + +class EventObject extends EventCommonObject { + + // Meta Information + public string $type = 'event'; + public int $version = 1; + public string|null $urid = null; + public ?DateTimeInterface $created = null; + public ?DateTimeInterface $modified = null; + + public EventOccurrenceObject|null $pattern = null; + public EventMutationCollection $mutations; + + public function __construct() { + parent::__construct(); + $this->mutations = new EventMutationCollection(); + } + +} diff --git a/shared/lib/Chrono/Event/EventOccurrenceObject.php b/shared/lib/Chrono/Event/EventOccurrenceObject.php new file mode 100644 index 0000000..e367a9f --- /dev/null +++ b/shared/lib/Chrono/Event/EventOccurrenceObject.php @@ -0,0 +1,36 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use DateTime; +use DateTimeImmutable; +use KTXF\Json\JsonSerializableObject; + +class EventOccurrenceObject extends JsonSerializableObject { + public EventOccurrencePatternTypes|null $pattern = null; // Pattern - Absolute / Relative + public EventOccurrencePrecisionTypes|null $precision = null; // Time Interval + public int|null $interval = null; // Time Interval - Every 2 Days / Every 4 Weeks / Every 1 Year + public int|null $iterations = null; // Number of recurrence + public DateTime|DateTimeImmutable|null $concludes = null; // Date to stop recurrence + public String|null $scale = null; // calendar system in which this recurrence rule operates + public array $onDayOfWeek = []; + public array $onDayOfMonth = []; + public array $onDayOfYear = []; + public array $onWeekOfMonth = []; + public array $onWeekOfYear = []; + public array $onMonthOfYear = []; + public array $onHour = []; + public array $onMinute = []; + public array $onSecond = []; + public array $onPosition = []; + + public function __construct() { + } +} diff --git a/shared/lib/Chrono/Event/EventOccurrencePatternTypes.php b/shared/lib/Chrono/Event/EventOccurrencePatternTypes.php new file mode 100644 index 0000000..05e4dc5 --- /dev/null +++ b/shared/lib/Chrono/Event/EventOccurrencePatternTypes.php @@ -0,0 +1,15 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventOccurrencePatternTypes: string { + case Absolute = 'absolute'; + case Relative = 'relative'; +} diff --git a/shared/lib/Chrono/Event/EventOccurrencePrecisionTypes.php b/shared/lib/Chrono/Event/EventOccurrencePrecisionTypes.php new file mode 100644 index 0000000..3ccf3a4 --- /dev/null +++ b/shared/lib/Chrono/Event/EventOccurrencePrecisionTypes.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventOccurrencePrecisionTypes: string { + case Yearly = 'yearly'; + case Monthly = 'monthly'; + case Weekly = 'weekly'; + case Daily = 'daily'; + case Hourly = 'hourly'; + case Minutely = 'minutely'; + case Secondly = 'secondly'; +} diff --git a/shared/lib/Chrono/Event/EventOrganizerObject.php b/shared/lib/Chrono/Event/EventOrganizerObject.php new file mode 100644 index 0000000..12f965b --- /dev/null +++ b/shared/lib/Chrono/Event/EventOrganizerObject.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableObject; + +class EventOrganizerObject extends JsonSerializableObject { + + public EventParticipantRealm|null $realm = null; // E - external, I - internal + public string|null $address = null; + public string|null $name = null; + +} diff --git a/shared/lib/Chrono/Event/EventParticipantCollection.php b/shared/lib/Chrono/Event/EventParticipantCollection.php new file mode 100644 index 0000000..c5b9031 --- /dev/null +++ b/shared/lib/Chrono/Event/EventParticipantCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableCollection; + +class EventParticipantCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, EventParticipantObject::class, 'string'); + } + +} diff --git a/shared/lib/Chrono/Event/EventParticipantObject.php b/shared/lib/Chrono/Event/EventParticipantObject.php new file mode 100644 index 0000000..353f83f --- /dev/null +++ b/shared/lib/Chrono/Event/EventParticipantObject.php @@ -0,0 +1,31 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableObject; + +class EventParticipantObject extends JsonSerializableObject { + + public string|null $identifier = null; + public EventParticipantRealm|null $realm = null; // E - external, I - internal + public string|null $name = null; + public string|null $description = null; + public string|null $language = null; + public string|null $address = null; + public EventParticipantTypes|null $type = null; + public EventParticipantStatusTypes|null $status = null; + public string|null $comment = null; + public EventParticipantRoleCollection $roles; + + public function __construct() { + $this->roles = new EventParticipantRoleCollection(); + } + +} diff --git a/shared/lib/Chrono/Event/EventParticipantRealm.php b/shared/lib/Chrono/Event/EventParticipantRealm.php new file mode 100644 index 0000000..0c8a1ac --- /dev/null +++ b/shared/lib/Chrono/Event/EventParticipantRealm.php @@ -0,0 +1,15 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventParticipantRealm: string { + case Internal = 'I'; + case External = 'E'; +} diff --git a/shared/lib/Chrono/Event/EventParticipantRoleCollection.php b/shared/lib/Chrono/Event/EventParticipantRoleCollection.php new file mode 100644 index 0000000..2405108 --- /dev/null +++ b/shared/lib/Chrono/Event/EventParticipantRoleCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableCollection; + +class EventParticipantRoleCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, EventParticipantRoleTypes::class); + } + +} diff --git a/shared/lib/Chrono/Event/EventParticipantRoleTypes.php b/shared/lib/Chrono/Event/EventParticipantRoleTypes.php new file mode 100644 index 0000000..7d3e70c --- /dev/null +++ b/shared/lib/Chrono/Event/EventParticipantRoleTypes.php @@ -0,0 +1,19 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventParticipantRoleTypes: string { + case Owner = 'owner'; + case Chair = 'chair'; + case Attendee = 'attendee'; + case Optional = 'optional'; + case Informational = 'informational'; + case Contact = 'contact'; +} diff --git a/shared/lib/Chrono/Event/EventParticipantStatusTypes.php b/shared/lib/Chrono/Event/EventParticipantStatusTypes.php new file mode 100644 index 0000000..8925c9c --- /dev/null +++ b/shared/lib/Chrono/Event/EventParticipantStatusTypes.php @@ -0,0 +1,18 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventParticipantStatusTypes: string { + case None = 'none'; + case Accepted = 'accepted'; + case Declined = 'declined'; + case Tentative = 'tentative'; + case Delegated = 'delegated'; +} diff --git a/shared/lib/Chrono/Event/EventParticipantTypes.php b/shared/lib/Chrono/Event/EventParticipantTypes.php new file mode 100644 index 0000000..58e66d9 --- /dev/null +++ b/shared/lib/Chrono/Event/EventParticipantTypes.php @@ -0,0 +1,18 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventParticipantTypes: string { + case Unknown = 'unknown'; + case Individual = 'individual'; + case Group = 'group'; + case Resource = 'resource'; + case Location = 'location'; +} diff --git a/shared/lib/Chrono/Event/EventSensitivityTypes.php b/shared/lib/Chrono/Event/EventSensitivityTypes.php new file mode 100644 index 0000000..55ca876 --- /dev/null +++ b/shared/lib/Chrono/Event/EventSensitivityTypes.php @@ -0,0 +1,16 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventSensitivityTypes: string { + case Public = 'public'; + case Private = 'private'; + case Secret = 'secret'; +} diff --git a/shared/lib/Chrono/Event/EventTagCollection.php b/shared/lib/Chrono/Event/EventTagCollection.php new file mode 100644 index 0000000..d1ebcd4 --- /dev/null +++ b/shared/lib/Chrono/Event/EventTagCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableCollection; + +class EventTagCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, 'string'); + } + +} diff --git a/shared/lib/Chrono/Provider/IProviderBase.php b/shared/lib/Chrono/Provider/IProviderBase.php new file mode 100644 index 0000000..d65d3d9 --- /dev/null +++ b/shared/lib/Chrono/Provider/IProviderBase.php @@ -0,0 +1,96 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Provider; + +use JsonSerializable; +use KTXF\Chrono\Service\IServiceBase; + +interface IProviderBase extends JsonSerializable { + + public const CAPABILITY_SERVICE_LIST = 'ServiceList'; + public const CAPABILITY_SERVICE_FETCH = 'ServiceFetch'; + public const CAPABILITY_SERVICE_EXTANT = 'ServiceExtant'; + + public const JSON_TYPE = 'chrono.provider'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_CAPABILITIES = 'capabilities'; + + /** + * Confirms if specific capability is supported (e.g. 'ServiceList') + * + * @since 2025.05.01 + */ + public function capable(string $value): bool; + + /** + * Lists all supported capabilities + * + * @since 2025.05.01 + * + * @return array + */ + public function capabilities(): array; + + /** + * An arbitrary unique text string identifying this provider (e.g. UUID or 'system' or anything else) + * + * @since 2025.05.01 + */ + public function id(): string; + + /** + * The localized human friendly name of this provider (e.g. System Calendar Provider) + * + * @since 2025.05.01 + */ + public function label(): string; + + /** + * Retrieve collection of services for a specific user + * + * @since 2025.05.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param array $filter filter criteria + * + * @return array collection of service objects + */ + public function serviceList(string $tenantId, string $userId, array $filter): array; + + /** + * Determine if any services are configured for a specific user + * + * @since 2025.05.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param int|string ...$identifiers variadic collection of service identifiers + * + * @return array collection of service identifiers with boolean values indicating if the service is available + */ + public function serviceExtant(string $tenantId, string $userId, int|string ...$identifiers): array; + + /** + * Retrieve a service with a specific identifier + * + * @since 2025.05.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string|int $identifier service identifier + * + * @return IServiceBase|null returns service object or null if non found + */ + public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?IServiceBase; + +} diff --git a/shared/lib/Chrono/Provider/IProviderServiceMutate.php b/shared/lib/Chrono/Provider/IProviderServiceMutate.php new file mode 100644 index 0000000..6a90e27 --- /dev/null +++ b/shared/lib/Chrono/Provider/IProviderServiceMutate.php @@ -0,0 +1,50 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Provider; + +use KTXF\Json\JsonDeserializable; +use KTXF\Chrono\Service\IServiceBase; + +interface IProviderServiceMutate extends JsonDeserializable { + + public const CAPABILITY_SERVICE_FRESH = 'ServiceFresh'; + public const CAPABILITY_SERVICE_CREATE = 'ServiceCreate'; + public const CAPABILITY_SERVICE_UPDATE = 'ServiceUpdate'; + public const CAPABILITY_SERVICE_DESTROY = 'ServiceDestroy'; + + /** + * construct and new blank service instance + * + * @since 2025.05.01 + */ + public function serviceFresh(string $uid = ''): IServiceBase; + + /** + * create a service configuration for a specific user + * + * @since 2025.05.01 + */ + public function serviceCreate(string $uid, IServiceBase $service): string; + + /** + * modify a service configuration for a specific user + * + * @since 2025.05.01 + */ + public function serviceModify(string $uid, IServiceBase $service): string; + + /** + * delete a service configuration for a specific user + * + * @since 2025.05.01 + */ + public function serviceDestroy(string $uid, IServiceBase $service): bool; + +} diff --git a/shared/lib/Chrono/Service/IServiceBase.php b/shared/lib/Chrono/Service/IServiceBase.php new file mode 100644 index 0000000..424fd4f --- /dev/null +++ b/shared/lib/Chrono/Service/IServiceBase.php @@ -0,0 +1,197 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Service; + +use JsonSerializable; +use KTXF\Chrono\Collection\ICollectionBase; +use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Range\IRange; +use KTXF\Resource\Range\RangeType; +use KTXF\Resource\Sort\ISort; + +interface IServiceBase extends JsonSerializable { + + public const CAPABILITY_COLLECTION_LIST = 'CollectionList'; + public const CAPABILITY_COLLECTION_LIST_FILTER = 'CollectionListFilter'; + public const CAPABILITY_COLLECTION_LIST_SORT = 'CollectionListSort'; + public const CAPABILITY_COLLECTION_EXTANT = 'CollectionExtant'; + public const CAPABILITY_COLLECTION_FETCH = 'CollectionFetch'; + + public const CAPABILITY_ENTITY_LIST = 'EntityList'; + public const CAPABILITY_ENTITY_LIST_FILTER = 'EntityListFilter'; + public const CAPABILITY_ENTITY_LIST_SORT = 'EntityListSort'; + public const CAPABILITY_ENTITY_LIST_RANGE = 'EntityListRange'; + public const CAPABILITY_ENTITY_DELTA = 'EntityDelta'; + public const CAPABILITY_ENTITY_EXTANT = 'EntityExtant'; + public const CAPABILITY_ENTITY_FETCH = 'EntityFetch'; + + public const CAPABILITY_FILTER_ANY = '*'; + public const CAPABILITY_FILTER_ID = 'id'; + public const CAPABILITY_FILTER_URID = 'urid'; + public const CAPABILITY_FILTER_LABEL = 'label'; + public const CAPABILITY_FILTER_DESCRIPTION = 'description'; + + public const CAPABILITY_SORT_ID = 'id'; + public const CAPABILITY_SORT_URID = 'urid'; + public const CAPABILITY_SORT_LABEL = 'label'; + public const CAPABILITY_SORT_PRIORITY = 'priority'; + + public const CAPABILITY_RANGE_TALLY = 'tally'; + public const CAPABILITY_RANGE_TALLY_ABSOLUTE = 'absolute'; + public const CAPABILITY_RANGE_TALLY_RELATIVE = 'relative'; + public const CAPABILITY_RANGE_DATE = 'date'; + + public const JSON_TYPE = 'chrono.service'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_PROVIDER = 'provider'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_CAPABILITIES = 'capabilities'; + public const JSON_PROPERTY_ENABLED = 'enabled'; + + /** + * Confirms if specific capability is supported + * + * @since 2025.05.01 + * + * @param string $value required ability e.g. 'EntityList' + * + * @return bool + */ + public function capable(string $value): bool; + + /** + * Lists all supported capabilities + * + * @since 2025.05.01 + * + * @return array + */ + public function capabilities(): array; + + /** + * Unique identifier of the provider this service belongs to + * + * @since 2025.05.01 + */ + public function in(): string; + + /** + * Unique arbitrary text string identifying this service (e.g. 1 or service1 or anything else) + * + * @since 2025.05.01 + */ + public function id(): string|int; + + /** + * Gets the localized human friendly name of this service (e.g. ACME Company Calendar Service) + * + * @since 2025.05.01 + */ + public function getLabel(): string; + + /** + * Gets the active status of this service + * + * @since 2025.05.01 + */ + public function getEnabled(): bool; + + /** + * Retrieve collection of collections for this service + * + * @since 2025.05.01 + */ + public function collectionList(?IFilter $filter = null, ?ISort $sort = null): array; + + /** + * Retrieve filter object for collection list + * + * @since 2025.05.01 + */ + public function collectionListFilter(): IFilter; + + /** + * Retrieve sort object for collection list + * + * @since 2025.05.01 + */ + public function collectionListSort(): ISort; + + /** + * Determine if a collection exists + * + * @since 2025.05.01 + */ + public function collectionExtant(string|int $identifier): bool; + + /** + * Retrieve a specific collection + * + * @since 2025.05.01 + */ + public function collectionFetch(string|int $identifier): ?ICollectionBase; + + /** + * Retrieve collection of entities from a specific collection + * + * @since 2025.05.01 + */ + public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $elements = null): array; + + /** + * Retrieve filter object for entity list + * + * @since 2025.05.01 + */ + public function entityListFilter(): IFilter; + + /** + * Retrieve sort object for entity list + * + * @since 2025.05.01 + */ + public function entityListSort(): ISort; + + /** + * Retrieve range object for entity list + * + * @since 2025.05.01 + */ + public function entityListRange(RangeType $type): IRange; + + /** + * Retrieve collection of entities that have changed since a given signature + * + * @since 2025.05.01 + * + * @param string|int $collection collection identifier + * @param string $signature signature to compare against + * @param string $detail level of detail to return (ids, full, etc) + * + * @return array collection of entities or entity identifiers + */ + public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): array; + + /** + * Determine if entities exist in a specific collection + * + * @since 2025.05.01 + */ + public function entityExtant(string|int $collection, string|int ...$identifiers): array; + + /** + * Retrieve specific entities from a specific collection + * + * @since 2025.05.01 + */ + public function entityFetch(string|int $collection, string|int ...$identifiers): array; + +} diff --git a/shared/lib/Chrono/Service/IServiceCollectionMutable.php b/shared/lib/Chrono/Service/IServiceCollectionMutable.php new file mode 100644 index 0000000..78f6413 --- /dev/null +++ b/shared/lib/Chrono/Service/IServiceCollectionMutable.php @@ -0,0 +1,79 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Service; + +use KTXF\Chrono\Collection\ICollectionBase; +use KTXF\Chrono\Collection\ICollectionMutable; + +interface IServiceCollectionMutable extends IServiceBase { + + public const CAPABILITY_COLLECTION_CREATE = 'CollectionCreate'; + public const CAPABILITY_COLLECTION_MODIFY = 'CollectionModify'; + public const CAPABILITY_COLLECTION_DESTROY = 'CollectionDestroy'; + public const CAPABILITY_COLLECTION_MOVE = 'CollectionMove'; + + /** + * Creates a new, empty collection object + * + * @since 2025.05.01 + * + * @return ICollectionMutable + */ + public function collectionFresh(): ICollectionMutable; + + /** + * Creates a new collection at the specified location + * + * @since 2025.05.01 + * + * @param string $location The parent collection to create this collection in, or empty string for root + * @param ICollectionMutable $collection The collection to create + * @param array $options Additional options for the collection creation + * + * @return ICollectionBase + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionCreate(string|int $location, ICollectionMutable $collection, array $options): ICollectionBase; + + /** + * Modifies an existing collection + * + * @since 2025.05.01 + * + * @param string $identifier The ID of the collection to modify + * @param ICollectionMutable $collection The collection with modifications + * + * @return ICollectionBase + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionModify(string|int $identifier, ICollectionMutable $collection): ICollectionBase; + + /** + * Destroys an existing collection + * + * @since 2025.05.01 + * + * @param string $identifier The ID of the collection to destroy + * + * @return bool + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionDestroy(string|int $identifier): bool; + +} diff --git a/shared/lib/Chrono/Service/IServiceEntityMutable.php b/shared/lib/Chrono/Service/IServiceEntityMutable.php new file mode 100644 index 0000000..ebe4575 --- /dev/null +++ b/shared/lib/Chrono/Service/IServiceEntityMutable.php @@ -0,0 +1,81 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Service; + +use KTXF\Chrono\Entity\IEntityMutable; + +interface IServiceEntityMutable extends IServiceBase { + + public const CAPABILITY_ENTITY_CREATE = 'EntityCreate'; + public const CAPABILITY_ENTITY_MODIFY = 'EntityModify'; + public const CAPABILITY_ENTITY_DESTROY = 'EntityDestroy'; + public const CAPABILITY_ENTITY_COPY = 'EntityCopy'; + public const CAPABILITY_ENTITY_MOVE = 'EntityMove'; + + /** + * Creates a fresh entity of the specified type + * + * @since 2025.05.01 + * + * @return IEntityMutable + */ + public function entityFresh(): IEntityMutable; + + /** + * Creates a new entity in the specified collection + * + * @since 2025.05.01 + * + * @param string $collection The collection to create this entity in + * @param IEntityMutable $entity The entity to create + * @param array $options Additional options for the entity creation + * + * @return IEntityMutable + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityCreate(string|int $collection, IEntityMutable $entity, array $options): IEntityMutable; + + /** + * Modifies an existing entity in the specified collection + * + * @since 2025.05.01 + * + * @param string $collection The collection containing the entity to modify + * @param string $identifier The ID of the entity to modify + * @param IEntityMutable $entity The entity with modifications + * + * @return IEntityMutable + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityModify(string|int $collection, string|int $identifier, IEntityMutable $entity): IEntityMutable; + + /** + * Destroys an existing entity in the specified collection + * + * @since 2025.05.01 + * + * @param string $collection The collection containing the entity to destroy + * @param string $identifier The ID of the entity to destroy + * + * @return IEntityMutable + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityDestroy(string|int $collection, string|int $identifier): IEntityMutable; + +} diff --git a/shared/lib/Chrono/Service/IServiceMutable.php b/shared/lib/Chrono/Service/IServiceMutable.php new file mode 100644 index 0000000..02ece43 --- /dev/null +++ b/shared/lib/Chrono/Service/IServiceMutable.php @@ -0,0 +1,30 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Service; + +use KTXF\Json\JsonDeserializable; + +interface IServiceMutable extends IServiceBase, JsonDeserializable { + + /** + * Sets the localized human friendly name of this service (e.g. ACME Company Calendar Service) + * + * @since 2025.05.01 + */ + public function setLabel(string $value): self; + + /** + * Sets the active status of this service + * + * @since 2025.05.01 + */ + public function setEnabled(bool $value): self; + +} diff --git a/shared/lib/Controller/ControllerAbstract.php b/shared/lib/Controller/ControllerAbstract.php new file mode 100644 index 0000000..53840eb --- /dev/null +++ b/shared/lib/Controller/ControllerAbstract.php @@ -0,0 +1,8 @@ +data = $data; + $this->timestamp = microtime(true); + } + + /** + * Get the event name + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get a data value by key + */ + public function get(string $key, mixed $default = null): mixed + { + return $this->data[$key] ?? $default; + } + + /** + * Set a data value + */ + public function set(string $key, mixed $value): self + { + $this->data[$key] = $value; + return $this; + } + + /** + * Check if a data key exists + */ + public function has(string $key): bool + { + return array_key_exists($key, $this->data); + } + + /** + * Get all data + */ + public function getData(): array + { + return $this->data; + } + + /** + * Alias for getData() for backward compatibility + */ + public function all(): array + { + return $this->data; + } + + /** + * Get the event timestamp + */ + public function getTimestamp(): float + { + return $this->timestamp; + } + + /** + * Stop event propagation to subsequent listeners + */ + public function stopPropagation(): void + { + $this->propagationStopped = true; + } + + /** + * Check if propagation is stopped + */ + public function isPropagationStopped(): bool + { + return $this->propagationStopped; + } + + /** + * Get tenant ID for multi-tenant context + */ + public function getTenantId(): ?string + { + return $this->tenantId; + } + + /** + * Set tenant ID for multi-tenant context + */ + public function setTenantId(?string $tenantId): self + { + $this->tenantId = $tenantId; + return $this; + } + + /** + * Get identity ID (user who triggered the event) + */ + public function getIdentityId(): ?string + { + return $this->identityId; + } + + /** + * Set identity ID + */ + public function setIdentityId(?string $identityId): self + { + $this->identityId = $identityId; + return $this; + } + + /** + * Convert event to array for serialization/logging + */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'data' => $this->data, + 'timestamp' => $this->timestamp, + 'tenantId' => $this->tenantId, + 'identityId' => $this->identityId, + ]; + } +} \ No newline at end of file diff --git a/shared/lib/Event/EventBus.php b/shared/lib/Event/EventBus.php new file mode 100644 index 0000000..750a2c7 --- /dev/null +++ b/shared/lib/Event/EventBus.php @@ -0,0 +1,186 @@ +> */ + private array $listeners = []; + + /** @var array> */ + private array $asyncListeners = []; + + /** @var Event[] */ + private array $deferredEvents = []; + + /** + * Subscribe to an event with optional priority + * Higher priority listeners are called first + */ + public function subscribe(string $eventName, callable $listener, int $priority = 0): self + { + $this->listeners[$eventName][] = [ + 'callback' => $listener, + 'priority' => $priority, + ]; + + // Sort by priority (higher first) + usort( + $this->listeners[$eventName], + fn($a, $b) => $b['priority'] <=> $a['priority'] + ); + + return $this; + } + + /** + * Subscribe to an event for async/deferred processing + * These handlers run at the end of the request cycle + */ + public function subscribeAsync(string $eventName, callable $listener): self + { + $this->asyncListeners[$eventName][] = $listener; + return $this; + } + + /** + * Unsubscribe a listener from an event + */ + public function unsubscribe(string $eventName, callable $listener): self + { + if (isset($this->listeners[$eventName])) { + $this->listeners[$eventName] = array_filter( + $this->listeners[$eventName], + fn($item) => $item['callback'] !== $listener + ); + } + + if (isset($this->asyncListeners[$eventName])) { + $this->asyncListeners[$eventName] = array_filter( + $this->asyncListeners[$eventName], + fn($item) => $item !== $listener + ); + } + + return $this; + } + + /** + * Publish an event to all subscribers + */ + public function publish(Event $event): self + { + $eventName = $event->getName(); + + // Execute synchronous listeners + if (isset($this->listeners[$eventName])) { + foreach ($this->listeners[$eventName] as $listenerData) { + if ($event->isPropagationStopped()) { + break; + } + + try { + call_user_func($listenerData['callback'], $event); + } catch (\Throwable $e) { + // Log error but don't break the chain + error_log(sprintf( + 'Event listener error for %s: %s', + $eventName, + $e->getMessage() + )); + } + } + } + + // Queue for async processing if there are async listeners + if (isset($this->asyncListeners[$eventName]) && !empty($this->asyncListeners[$eventName])) { + $this->deferredEvents[] = $event; + } + + return $this; + } + + /** + * Process deferred/async events + * Call this at the end of the request cycle + */ + public function processDeferred(): int + { + $processed = 0; + + foreach ($this->deferredEvents as $event) { + $eventName = $event->getName(); + + if (!isset($this->asyncListeners[$eventName])) { + continue; + } + + foreach ($this->asyncListeners[$eventName] as $listener) { + try { + call_user_func($listener, $event); + $processed++; + } catch (\Throwable $e) { + // Log but don't fail - these are non-critical + error_log(sprintf( + 'Async event handler error for %s: %s', + $eventName, + $e->getMessage() + )); + } + } + } + + $this->deferredEvents = []; + + return $processed; + } + + /** + * Check if an event has any listeners + */ + public function hasListeners(string $eventName): bool + { + return !empty($this->listeners[$eventName]) || !empty($this->asyncListeners[$eventName]); + } + + /** + * Get count of listeners for an event + */ + public function getListenerCount(string $eventName): int + { + $sync = isset($this->listeners[$eventName]) ? count($this->listeners[$eventName]) : 0; + $async = isset($this->asyncListeners[$eventName]) ? count($this->asyncListeners[$eventName]) : 0; + + return $sync + $async; + } + + /** + * Get count of pending deferred events + */ + public function getDeferredCount(): int + { + return count($this->deferredEvents); + } + + /** + * Clear all listeners (useful for testing) + */ + public function clear(): self + { + $this->listeners = []; + $this->asyncListeners = []; + $this->deferredEvents = []; + + return $this; + } +} diff --git a/shared/lib/Event/SecurityEvent.php b/shared/lib/Event/SecurityEvent.php new file mode 100644 index 0000000..82e1eba --- /dev/null +++ b/shared/lib/Event/SecurityEvent.php @@ -0,0 +1,303 @@ +ipAddress = $ipAddress; + $event->deviceFingerprint = $deviceFingerprint; + + // Set default severity based on event type + $event->severity = self::getSeverityForEvent($name); + + return $event; + } + + /** + * Create an authentication failure event + */ + public static function authFailure( + string $ipAddress, + ?string $deviceFingerprint = null, + ?string $userId = null, + ?string $reason = null + ): self { + $event = self::create(self::AUTH_FAILURE, $ipAddress, $deviceFingerprint, [ + 'userId' => $userId, + 'reason' => $reason, + ]); + $event->userId = $userId; + $event->reason = $reason; + return $event; + } + + /** + * Create an authentication success event + */ + public static function authSuccess( + string $ipAddress, + ?string $deviceFingerprint = null, + string $userId = null + ): self { + $event = self::create(self::AUTH_SUCCESS, $ipAddress, $deviceFingerprint, [ + 'userId' => $userId, + ]); + $event->userId = $userId; + return $event; + } + + /** + * Create a brute force detection event + */ + public static function bruteForceDetected( + string $ipAddress, + int $failureCount, + int $windowSeconds + ): self { + $event = self::create(self::BRUTE_FORCE_DETECTED, $ipAddress, null, [ + 'failureCount' => $failureCount, + 'windowSeconds' => $windowSeconds, + ]); + $event->reason = sprintf( + '%d failed attempts in %d seconds', + $failureCount, + $windowSeconds + ); + return $event; + } + + /** + * Create a rate limit exceeded event + */ + public static function rateLimitExceeded( + string $ipAddress, + int $requestCount, + int $windowSeconds, + ?string $endpoint = null + ): self { + $event = self::create(self::RATE_LIMIT_EXCEEDED, $ipAddress, null, [ + 'requestCount' => $requestCount, + 'windowSeconds' => $windowSeconds, + 'endpoint' => $endpoint, + ]); + $event->requestPath = $endpoint; + $event->reason = sprintf( + '%d requests in %d seconds', + $requestCount, + $windowSeconds + ); + return $event; + } + + /** + * Create an access denied event + */ + public static function accessDenied( + string $ipAddress, + ?string $deviceFingerprint = null, + ?string $ruleId = null, + ?string $reason = null + ): self { + $event = self::create(self::ACCESS_DENIED, $ipAddress, $deviceFingerprint, [ + 'ruleId' => $ruleId, + 'reason' => $reason, + ]); + $event->reason = $reason; + return $event; + } + + /** + * Get default severity for event types + */ + private static function getSeverityForEvent(string $eventName): int + { + return match ($eventName) { + self::AUTH_SUCCESS, + self::ACCESS_GRANTED, + self::TOKEN_REFRESH => self::SEVERITY_INFO, + + self::AUTH_FAILURE, + self::ACCESS_DENIED, + self::AUTH_LOGOUT, + self::TOKEN_REVOKED => self::SEVERITY_WARNING, + + self::RATE_LIMIT_EXCEEDED, + self::SUSPICIOUS_ACTIVITY => self::SEVERITY_ERROR, + + self::BRUTE_FORCE_DETECTED, + self::IP_BLOCKED, + self::DEVICE_BLOCKED => self::SEVERITY_CRITICAL, + + default => self::SEVERITY_INFO, + }; + } + + // Getters and setters + + public function getIpAddress(): ?string + { + return $this->ipAddress; + } + + public function setIpAddress(?string $ipAddress): self + { + $this->ipAddress = $ipAddress; + return $this; + } + + public function getDeviceFingerprint(): ?string + { + return $this->deviceFingerprint; + } + + public function setDeviceFingerprint(?string $deviceFingerprint): self + { + $this->deviceFingerprint = $deviceFingerprint; + return $this; + } + + public function getUserAgent(): ?string + { + return $this->userAgent; + } + + public function setUserAgent(?string $userAgent): self + { + $this->userAgent = $userAgent; + return $this; + } + + public function getRequestPath(): ?string + { + return $this->requestPath; + } + + public function setRequestPath(?string $requestPath): self + { + $this->requestPath = $requestPath; + return $this; + } + + public function getRequestMethod(): ?string + { + return $this->requestMethod; + } + + public function setRequestMethod(?string $requestMethod): self + { + $this->requestMethod = $requestMethod; + return $this; + } + + public function getUserId(): ?string + { + return $this->userId; + } + + public function setUserId(?string $userId): self + { + $this->userId = $userId; + return $this; + } + + public function getReason(): ?string + { + return $this->reason; + } + + public function setReason(?string $reason): self + { + $this->reason = $reason; + return $this; + } + + public function getSeverity(): int + { + return $this->severity; + } + + public function setSeverity(int $severity): self + { + $this->severity = $severity; + return $this; + } + + public function getSeverityLabel(): string + { + return match ($this->severity) { + self::SEVERITY_DEBUG => 'DEBUG', + self::SEVERITY_INFO => 'INFO', + self::SEVERITY_WARNING => 'WARNING', + self::SEVERITY_ERROR => 'ERROR', + self::SEVERITY_CRITICAL => 'CRITICAL', + default => 'UNKNOWN', + }; + } + + /** + * Override toArray to include security-specific fields + */ + public function toArray(): array + { + return array_merge(parent::toArray(), [ + 'ipAddress' => $this->ipAddress, + 'deviceFingerprint' => $this->deviceFingerprint, + 'userAgent' => $this->userAgent, + 'requestPath' => $this->requestPath, + 'requestMethod' => $this->requestMethod, + 'userId' => $this->userId, + 'reason' => $this->reason, + 'severity' => $this->severity, + 'severityLabel' => $this->getSeverityLabel(), + ]); + } +} diff --git a/shared/lib/Exception/BaseException.php b/shared/lib/Exception/BaseException.php new file mode 100644 index 0000000..2ad830f --- /dev/null +++ b/shared/lib/Exception/BaseException.php @@ -0,0 +1,175 @@ +message = $message; + $this->code = $code; + $this->previous = $previous; + + // Capture backtrace; first element is this constructor call site + $bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + if (!empty($bt)) { + $first = $bt[0]; + $this->file = $first['file'] ?? 'unknown'; + $this->line = $first['line'] ?? 0; + } + // Exclude current frame for readability + $this->trace = array_slice($bt, 1); + } + + /** + * Clone the exception + * Tries to clone the Exception, which results in Fatal error. + * @link https://php.net/manual/en/exception.clone.php + * @return void + */ + public function __clone(): void + { + // Mimic internal Exception behavior: cloning not allowed. + trigger_error('Trying to clone an uncloneable object of class ' . static::class, E_USER_ERROR); + } + + /** + * String representation of the exception + * @link https://php.net/manual/en/exception.tostring.php + * @return string the string representation of the exception. + */ + public function __toString(): string + { + return sprintf( + "%s: %s in %s:%d\nStack trace:\n%s", + static::class, + $this->getMessage(), + $this->getFile(), + $this->getLine(), + $this->getTraceAsString() + ); + } + + public function __wakeup(): void + { + // On wakeup we don't have original trace; reset trace to empty + $this->trace = []; + } + + /** + * Gets the Exception message + * @link https://php.net/manual/en/exception.getmessage.php + * @return string the Exception message as a string. + */ + final public function getMessage(): string + { + return $this->message; + } + + /** + * Gets the Exception code + * @link https://php.net/manual/en/exception.getcode.php + * @return mixed|int the exception code as integer in + * Exception but possibly as other type in + * Exception descendants (for example as + * string in PDOException). + */ + final public function getCode(): int + { + return $this->code; + } + + /** + * Gets the file in which the exception occurred + * @link https://php.net/manual/en/exception.getfile.php + * @return string the filename in which the exception was created. + */ + final public function getFile(): string + { + return $this->file; + } + + /** + * Gets the line in which the exception occurred + * @link https://php.net/manual/en/exception.getline.php + * @return int the line number where the exception was created. + */ + final public function getLine(): int + { + return $this->line; + } + + /** + * Gets the stack trace + * @link https://php.net/manual/en/exception.gettrace.php + * @return array the Exception stack trace as an array. + */ + final public function getTrace(): array + { + return $this->trace; + } + + /** + * Returns previous Exception + * @link https://php.net/manual/en/exception.getprevious.php + * @return null|Throwable Returns the previous {@see Throwable} if available, or NULL otherwise. + * or null otherwise. + */ + final public function getPrevious(): ?Throwable + { + return $this->previous; + } + + /** + * Gets the stack trace as a string + * @link https://php.net/manual/en/exception.gettraceasstring.php + * @return string the Exception stack trace as a string. + */ + final public function getTraceAsString(): string + { + $lines = []; + foreach ($this->trace as $i => $frame) { + $file = $frame['file'] ?? '[internal function]'; + $line = $frame['line'] ?? 0; + $func = $frame['function'] ?? ''; + $class = $frame['class'] ?? ''; + $type = $frame['type'] ?? ''; + $lines[] = sprintf('#%d %s(%s): %s%s%s()', $i, $file, $line, $class, $type, $func); + } + $lines[] = sprintf('#%d {main}', count($lines)); + return implode("\n", $lines); + } + +} \ No newline at end of file diff --git a/shared/lib/Exception/RuntimeException.php b/shared/lib/Exception/RuntimeException.php new file mode 100644 index 0000000..87bc568 --- /dev/null +++ b/shared/lib/Exception/RuntimeException.php @@ -0,0 +1,9 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Node; + +use DateTimeImmutable; + +interface INodeBase extends \JsonSerializable { + + public const JSON_TYPE = 'files.node'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_IN = 'in'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_CREATED_ON = 'createdOn'; + public const JSON_PROPERTY_CREATED_BY = 'createdBy'; + public const JSON_PROPERTY_MODIFIED_ON = 'modifiedOn'; + public const JSON_PROPERTY_MODIFIED_BY = 'modifiedBy'; + public const JSON_PROPERTY_OWNER = 'owner'; + public const JSON_PROPERTY_SIGNATURE = 'signature'; + public const JSON_PROPERTY_LABEL = 'label'; + + /** + * Unique identifier of the parent node (folder) this node belongs to + * + * @since 2025.11.01 + */ + public function in(): string|int|null; + + /** + * Unique identifier of this node + * + * @since 2025.11.01 + */ + public function id(): string|int; + + /** + * Node type (collection or entity) + * + * @since 2025.11.01 + */ + public function type(): NodeType; + + /** + * Creator user ID + * + * @since 2025.11.01 + */ + public function createdBy(): string|null; + + /** + * Creation timestamp + * + * @since 2025.11.01 + */ + public function createdOn(): DateTimeImmutable|null; + + /** + * Last modifier user ID + * + * @since 2025.11.01 + */ + public function modifiedBy(): string|null; + + /** + * Last modification timestamp + * + * @since 2025.11.01 + */ + public function modifiedOn(): DateTimeImmutable|null; + + /** + * Signature/etag for sync and caching + * + * @since 2025.11.01 + */ + public function signature(): string|null; + + /** + * Check if this node is a collection (folder) + * + * @since 2025.11.01 + */ + public function isCollection(): bool; + + /** + * Check if this node is an entity (file) + * + * @since 2025.11.01 + */ + public function isEntity(): bool; + + /** + * Human-readable name/label of this node + * + * @since 2025.11.01 + */ + public function getLabel(): string|null; + +} diff --git a/shared/lib/Files/Node/INodeCollectionBase.php b/shared/lib/Files/Node/INodeCollectionBase.php new file mode 100644 index 0000000..b69a8cf --- /dev/null +++ b/shared/lib/Files/Node/INodeCollectionBase.php @@ -0,0 +1,22 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Node; + +/** + * Interface for collection (folder) nodes + * + * Collections are containers that can hold other nodes (both collections and entities). + * They inherit common properties from INodeBase and add collection-specific properties. + */ +interface INodeCollectionBase extends INodeBase { + + public const JSON_TYPE = 'files.collection'; + +} diff --git a/shared/lib/Files/Node/INodeCollectionMutable.php b/shared/lib/Files/Node/INodeCollectionMutable.php new file mode 100644 index 0000000..e1e44fc --- /dev/null +++ b/shared/lib/Files/Node/INodeCollectionMutable.php @@ -0,0 +1,35 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Node; + +/** + * Interface for mutable collection (folder) nodes + */ +interface INodeCollectionMutable extends INodeCollectionBase { + + /** + * Deserialize from JSON data + * + * @since 2025.11.01 + * + * @param array|string $data JSON data to deserialize + * + * @return static + */ + public function jsonDeserialize(array|string $data): static; + + /** + * Sets the human-readable name/label of this collection + * + * @since 2025.11.01 + */ + public function setLabel(string $value): static; + +} diff --git a/shared/lib/Files/Node/INodeEntityBase.php b/shared/lib/Files/Node/INodeEntityBase.php new file mode 100644 index 0000000..d6bac3d --- /dev/null +++ b/shared/lib/Files/Node/INodeEntityBase.php @@ -0,0 +1,54 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Node; + +/** + * Interface for entity (file) nodes + * + * Entities are leaf nodes that contain actual file data. + * They inherit common properties from INodeBase and add file-specific properties. + */ +interface INodeEntityBase extends INodeBase { + + public const JSON_TYPE = 'files.entity'; + public const JSON_PROPERTY_SIZE = 'size'; + public const JSON_PROPERTY_MIME = 'mime'; + public const JSON_PROPERTY_FORMAT = 'format'; + public const JSON_PROPERTY_ENCODING = 'encoding'; + + /** + * File size in bytes + * + * @since 2025.11.01 + */ + public function size(): int; + + /** + * MIME type of the file (e.g., 'application/pdf', 'image/png') + * + * @since 2025.11.01 + */ + public function getMime(): string|null; + + /** + * File format/extension (e.g., 'pdf', 'png', 'txt') + * + * @since 2025.11.01 + */ + public function getFormat(): string|null; + + /** + * Character encoding (e.g., 'utf-8') + * + * @since 2025.11.01 + */ + public function getEncoding(): string|null; + +} diff --git a/shared/lib/Files/Node/INodeEntityMutable.php b/shared/lib/Files/Node/INodeEntityMutable.php new file mode 100644 index 0000000..520bd24 --- /dev/null +++ b/shared/lib/Files/Node/INodeEntityMutable.php @@ -0,0 +1,56 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Node; + +/** + * Interface for mutable entity (file) nodes + */ +interface INodeEntityMutable extends INodeEntityBase { + + /** + * Deserialize from JSON data + * + * @since 2025.11.01 + * + * @param array|string $data JSON data to deserialize + * + * @return static + */ + public function jsonDeserialize(array|string $data): static; + + /** + * Sets the human-readable name/label of this entity + * + * @since 2025.11.01 + */ + public function setLabel(string $value): static; + + /** + * Sets the MIME type of the file + * + * @since 2025.11.01 + */ + public function setMime(string $value): static; + + /** + * Sets the file format/extension + * + * @since 2025.11.01 + */ + public function setFormat(string $value): static; + + /** + * Sets the character encoding + * + * @since 2025.11.01 + */ + public function setEncoding(string $value): static; + +} diff --git a/shared/lib/Files/Node/NodeType.php b/shared/lib/Files/Node/NodeType.php new file mode 100644 index 0000000..b09ba5c --- /dev/null +++ b/shared/lib/Files/Node/NodeType.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Node; + +use JsonSerializable; + +enum NodeType: string implements JsonSerializable { + + case Collection = 'C'; + case Entity = 'E'; + + public function jsonSerialize(): string { + return $this->value; + } + +} diff --git a/shared/lib/Files/Provider/IProviderBase.php b/shared/lib/Files/Provider/IProviderBase.php new file mode 100644 index 0000000..169d7f1 --- /dev/null +++ b/shared/lib/Files/Provider/IProviderBase.php @@ -0,0 +1,16 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Provider; + +use KTXF\Resource\Provider\ResourceProviderInterface; + +interface IProviderBase extends ResourceProviderInterface { + +} diff --git a/shared/lib/Files/Service/IServiceBase.php b/shared/lib/Files/Service/IServiceBase.php new file mode 100644 index 0000000..92143e7 --- /dev/null +++ b/shared/lib/Files/Service/IServiceBase.php @@ -0,0 +1,333 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Service; + +use JsonSerializable; +use KTXF\Files\Node\INodeBase; +use KTXF\Files\Node\INodeCollectionBase; +use KTXF\Files\Node\INodeEntityBase; +use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Range\IRange; +use KTXF\Resource\Range\RangeType; +use KTXF\Resource\Sort\ISort; + +interface IServiceBase extends JsonSerializable { + + // Collection Capabilities + public const CAPABILITY_COLLECTION_LIST = 'CollectionList'; + public const CAPABILITY_COLLECTION_LIST_FILTER = 'CollectionListFilter'; + public const CAPABILITY_COLLECTION_LIST_SORT = 'CollectionListSort'; + public const CAPABILITY_COLLECTION_EXTANT = 'CollectionExtant'; + public const CAPABILITY_COLLECTION_FETCH = 'CollectionFetch'; + + // Entity Capabilities + public const CAPABILITY_ENTITY_LIST = 'EntityList'; + public const CAPABILITY_ENTITY_LIST_FILTER = 'EntityListFilter'; + public const CAPABILITY_ENTITY_LIST_SORT = 'EntityListSort'; + public const CAPABILITY_ENTITY_LIST_RANGE = 'EntityListRange'; + public const CAPABILITY_ENTITY_DELTA = 'EntityDelta'; + public const CAPABILITY_ENTITY_EXTANT = 'EntityExtant'; + public const CAPABILITY_ENTITY_FETCH = 'EntityFetch'; + public const CAPABILITY_ENTITY_READ = 'EntityRead'; + public const CAPABILITY_ENTITY_READ_STREAM = 'EntityReadStream'; + public const CAPABILITY_ENTITY_READ_CHUNK = 'EntityReadChunk'; + + // Node Capabilities (recursive/unified) + public const CAPABILITY_NODE_LIST = 'NodeList'; + public const CAPABILITY_NODE_LIST_FILTER = 'NodeListFilter'; + public const CAPABILITY_NODE_LIST_SORT = 'NodeListSort'; + public const CAPABILITY_NODE_LIST_RANGE = 'NodeListRange'; + public const CAPABILITY_NODE_DELTA = 'NodeDelta'; + + // JSON Constants + public const JSON_TYPE = 'files.service'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_PROVIDER = 'provider'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_CAPABILITIES = 'capabilities'; + public const JSON_PROPERTY_ENABLED = 'enabled'; + + /** + * Confirms if specific capability is supported + * + * @since 2025.11.01 + * + * @param string $value required ability e.g. 'EntityList' + * + * @return bool + */ + public function capable(string $value): bool; + + /** + * Lists all supported capabilities + * + * @since 2025.11.01 + * + * @return array + */ + public function capabilities(): array; + + /** + * Unique identifier of the provider this service belongs to + * + * @since 2025.11.01 + */ + public function in(): string; + + /** + * Unique arbitrary text string identifying this service (e.g. 1 or service1 or anything else) + * + * @since 2025.11.01 + */ + public function id(): string|int; + + /** + * Gets the localized human friendly name of this service (e.g. ACME Company File Service) + * + * @since 2025.11.01 + */ + public function getLabel(): string; + + /** + * Gets the active status of this service + * + * @since 2025.11.01 + */ + public function getEnabled(): bool; + + // ==================== Collection Methods ==================== + + /** + * List of accessible collections at a specific location + * + * @since 2025.11.01 + * + * @param string|int|null $location Parent collection identifier, null for root + * + * @return array + */ + public function collectionList(string|int|null $location = null, ?IFilter $filter = null, ?ISort $sort = null): array; + + /** + * Fresh filter for collection list + * + * @since 2025.11.01 + */ + public function collectionListFilter(): IFilter; + + /** + * Fresh sort for collection list + * + * @since 2025.11.01 + */ + public function collectionListSort(): ISort; + + /** + * Confirms if specific collection exists + * + * @since 2025.11.01 + * + * @param string|int|null $identifier Collection identifier + */ + public function collectionExtant(string|int|null $identifier): bool; + + /** + * Fetches details about a specific collection + * + * @since 2025.11.01 + * + * @param string|int|null $identifier Collection identifier + */ + public function collectionFetch(string|int|null $identifier): ?INodeCollectionBase; + + // ==================== Entity Methods ==================== + + /** + * Lists all entities in a specific collection + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * + * @return array + */ + public function entityList(string|int|null $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): array; + + /** + * Fresh filter for entity list + * + * @since 2025.11.01 + */ + public function entityListFilter(): IFilter; + + /** + * Fresh sort for entity list + * + * @since 2025.11.01 + */ + public function entityListSort(): ISort; + + /** + * Fresh range for entity list + * + * @since 2025.11.01 + */ + public function entityListRange(RangeType $type): IRange; + + /** + * Lists all changes from a specific signature + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string $signature Sync token signature + * @param string $detail Detail level: ids | meta | full + * + * @return array{ + * added: array, + * updated: array, + * deleted: array, + * signature: string + * } + */ + public function entityDelta(string|int|null $collection, string $signature, string $detail = 'ids'): array; + + /** + * Confirms if specific entities exist in a collection + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int ...$identifiers Entity identifiers + * + * @return array + */ + public function entityExtant(string|int|null $collection, string|int ...$identifiers): array; + + /** + * Fetches details about specific entities in a collection + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int ...$identifiers Entity identifiers + * + * @return array + */ + public function entityFetch(string|int|null $collection, string|int ...$identifiers): array; + + /** + * Reads the entire content of an entity as a string + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int $identifier Entity identifier + * + * @return string|null File content or null if not found + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityRead(string|int|null $collection, string|int $identifier): ?string; + + /** + * Opens a stream to read the content of an entity + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int $identifier Entity identifier + * + * @return resource|null Stream resource or null if not found + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityReadStream(string|int|null $collection, string|int $identifier); + + /** + * Reads a chunk of content from an entity + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int $identifier Entity identifier + * @param int $offset Starting byte position (0-indexed) + * @param int $length Number of bytes to read + * + * @return string|null Chunk content or null if not found + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityReadChunk(string|int|null $collection, string|int $identifier, int $offset, int $length): ?string; + + // ==================== Node Methods (Recursive/Unified) ==================== + + /** + * Lists all nodes (collections and entities) at a location, optionally recursive + * Returns a flat list with parent references via in() + * + * @since 2025.11.01 + * + * @param string|int|null $location Starting location, null for root + * @param bool $recursive Whether to list recursively + * + * @return array + */ + public function nodeList(string|int|null $location = null, bool $recursive = false, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): array; + + /** + * Fresh filter for node list + * + * @since 2025.11.01 + */ + public function nodeListFilter(): IFilter; + + /** + * Fresh sort for node list + * + * @since 2025.11.01 + */ + public function nodeListSort(): ISort; + + /** + * Fresh range for node list + * + * @since 2025.11.01 + */ + public function nodeListRange(RangeType $type): IRange; + + /** + * Lists all node changes from a specific signature, optionally recursive + * Returns flat list with parent references + * + * @since 2025.11.01 + * + * @param string|int|null $location Starting location, null for root + * @param string $signature Sync token signature + * @param bool $recursive Whether to include recursive changes + * @param string $detail Detail level: ids | meta | full + * + * @return array{ + * added: array|array, + * updated: array|array, + * deleted: array, + * signature: string + * } + */ + public function nodeDelta(string|int|null $location, string $signature, bool $recursive = false, string $detail = 'ids'): array; + +} diff --git a/shared/lib/Files/Service/IServiceCollectionMutable.php b/shared/lib/Files/Service/IServiceCollectionMutable.php new file mode 100644 index 0000000..6b0cf4e --- /dev/null +++ b/shared/lib/Files/Service/IServiceCollectionMutable.php @@ -0,0 +1,100 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Service; + +use KTXF\Files\Node\INodeCollectionBase; +use KTXF\Files\Node\INodeCollectionMutable; + +interface IServiceCollectionMutable extends IServiceBase { + + public const CAPABILITY_COLLECTION_CREATE = 'CollectionCreate'; + public const CAPABILITY_COLLECTION_MODIFY = 'CollectionModify'; + public const CAPABILITY_COLLECTION_DESTROY = 'CollectionDestroy'; + public const CAPABILITY_COLLECTION_COPY = 'CollectionCopy'; + public const CAPABILITY_COLLECTION_MOVE = 'CollectionMove'; + + /** + * Creates a new, empty collection node + * + * @since 2025.11.01 + */ + public function collectionFresh(): INodeCollectionMutable; + + /** + * Creates a new collection at the specified location + * + * @since 2025.11.01 + * + * @param string|int|null $location Parent collection, null for root + * @param INodeCollectionMutable $collection The collection to create + * @param array $options Additional options + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionCreate(string|int|null $location, INodeCollectionMutable $collection, array $options = []): INodeCollectionBase; + + /** + * Modifies an existing collection + * + * @since 2025.11.01 + * + * @param string|int $identifier Collection identifier + * @param INodeCollectionMutable $collection The collection with modifications + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionModify(string|int $identifier, INodeCollectionMutable $collection): INodeCollectionBase; + + /** + * Destroys an existing collection + * + * @since 2025.11.01 + * + * @param string|int $identifier Collection identifier + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionDestroy(string|int $identifier): bool; + + /** + * Copies an existing collection to a new location + * + * @since 2025.11.01 + * + * @param string|int $identifier Collection identifier + * @param string|int|null $location Destination parent collection, null for root + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionCopy(string|int $identifier, string|int|null $location): INodeCollectionBase; + + /** + * Moves an existing collection to a new location + * + * @since 2025.11.01 + * + * @param string|int $identifier Collection identifier + * @param string|int|null $location Destination parent collection, null for root + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionMove(string|int $identifier, string|int|null $location): INodeCollectionBase; + +} diff --git a/shared/lib/Files/Service/IServiceEntityMutable.php b/shared/lib/Files/Service/IServiceEntityMutable.php new file mode 100644 index 0000000..e505335 --- /dev/null +++ b/shared/lib/Files/Service/IServiceEntityMutable.php @@ -0,0 +1,158 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Service; + +use KTXF\Files\Node\INodeEntityBase; +use KTXF\Files\Node\INodeEntityMutable; + +interface IServiceEntityMutable extends IServiceBase { + + public const CAPABILITY_ENTITY_CREATE = 'EntityCreate'; + public const CAPABILITY_ENTITY_MODIFY = 'EntityModify'; + public const CAPABILITY_ENTITY_DESTROY = 'EntityDestroy'; + public const CAPABILITY_ENTITY_COPY = 'EntityCopy'; + public const CAPABILITY_ENTITY_MOVE = 'EntityMove'; + public const CAPABILITY_ENTITY_WRITE = 'EntityWrite'; + public const CAPABILITY_ENTITY_WRITE_STREAM = 'EntityWriteStream'; + public const CAPABILITY_ENTITY_WRITE_CHUNK = 'EntityWriteChunk'; + + /** + * Creates a new, empty entity node + * + * @since 2025.11.01 + */ + public function entityFresh(): INodeEntityMutable; + + /** + * Creates a new entity in the specified collection + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier, null for root + * @param INodeEntityMutable $entity The entity to create + * @param array $options Additional options + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityCreate(string|int|null $collection, INodeEntityMutable $entity, array $options = []): INodeEntityBase; + + /** + * Modifies an existing entity in the specified collection + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int $identifier Entity identifier + * @param INodeEntityMutable $entity The entity with modifications + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityModify(string|int|null $collection, string|int $identifier, INodeEntityMutable $entity): INodeEntityBase; + + /** + * Destroys an existing entity in the specified collection + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int $identifier Entity identifier + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityDestroy(string|int|null $collection, string|int $identifier): bool; + + /** + * Copies an existing entity to a new collection + * + * @since 2025.11.01 + * + * @param string|int|null $collection Source collection identifier + * @param string|int $identifier Entity identifier + * @param string|int|null $destination Destination collection identifier, null for root + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityCopy(string|int|null $collection, string|int $identifier, string|int|null $destination): INodeEntityBase; + + /** + * Moves an existing entity to a new collection + * + * @since 2025.11.01 + * + * @param string|int|null $collection Source collection identifier + * @param string|int $identifier Entity identifier + * @param string|int|null $destination Destination collection identifier, null for root + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityMove(string|int|null $collection, string|int $identifier, string|int|null $destination): INodeEntityBase; + + /** + * Writes the entire content of an entity from a string + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int $identifier Entity identifier + * @param string $data Content to write + * + * @return int Number of bytes written + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityWrite(string|int|null $collection, string|int $identifier, string $data): int; + + /** + * Opens a stream to write the content of an entity + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int $identifier Entity identifier + * + * @return resource Stream resource for writing + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityWriteStream(string|int|null $collection, string|int $identifier); + + /** + * Writes a chunk of content to an entity at a specific position + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int $identifier Entity identifier + * @param int $offset Starting byte position (0-indexed) + * @param string $data Chunk content to write + * + * @return int Number of bytes written + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityWriteChunk(string|int|null $collection, string|int $identifier, int $offset, string $data): int; + +} diff --git a/shared/lib/Files/Service/IServiceMutable.php b/shared/lib/Files/Service/IServiceMutable.php new file mode 100644 index 0000000..43fba83 --- /dev/null +++ b/shared/lib/Files/Service/IServiceMutable.php @@ -0,0 +1,30 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Service; + +use KTXF\Json\JsonDeserializable; + +interface IServiceMutable extends IServiceBase, JsonDeserializable { + + /** + * Sets the localized human friendly name of this service + * + * @since 2025.11.01 + */ + public function setLabel(string $value): static; + + /** + * Sets the active status of this service + * + * @since 2025.11.01 + */ + public function setEnabled(bool $value): static; + +} diff --git a/shared/lib/IpUtils.php b/shared/lib/IpUtils.php new file mode 100644 index 0000000..df12766 --- /dev/null +++ b/shared/lib/IpUtils.php @@ -0,0 +1,275 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXF; + +/** + * Http utility functions. + * + * @author Fabien Potencier + */ +class IpUtils +{ + public const PRIVATE_SUBNETS = [ + '127.0.0.0/8', // RFC1700 (Loopback) + '10.0.0.0/8', // RFC1918 + '192.168.0.0/16', // RFC1918 + '172.16.0.0/12', // RFC1918 + '169.254.0.0/16', // RFC3927 + '0.0.0.0/8', // RFC5735 + '240.0.0.0/4', // RFC1112 + '::1/128', // Loopback + 'fc00::/7', // Unique Local Address + 'fe80::/10', // Link Local Address + '::ffff:0:0/96', // IPv4 translations + '::/128', // Unspecified address + ]; + + private static array $checkedIps = []; + + /** + * This class should not be instantiated. + */ + private function __construct() + { + } + + /** + * Checks if an IPv4 or IPv6 address is contained in the list of given IPs or subnets. + * + * @param string|array $ips List of IPs or subnets (can be a string if only a single one) + */ + public static function checkIp(string $requestIp, string|array $ips): bool + { + if (!\is_array($ips)) { + $ips = [$ips]; + } + + $method = substr_count($requestIp, ':') > 1 ? 'checkIp6' : 'checkIp4'; + + foreach ($ips as $ip) { + if (self::$method($requestIp, $ip)) { + return true; + } + } + + return false; + } + + /** + * Compares two IPv4 addresses. + * In case a subnet is given, it checks if it contains the request IP. + * + * @param string $ip IPv4 address or subnet in CIDR notation + * + * @return bool Whether the request IP matches the IP, or whether the request IP is within the CIDR subnet + */ + public static function checkIp4(string $requestIp, string $ip): bool + { + $cacheKey = $requestIp.'-'.$ip.'-v4'; + if (null !== $cacheValue = self::getCacheResult($cacheKey)) { + return $cacheValue; + } + + if (!filter_var($requestIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4)) { + return self::setCacheResult($cacheKey, false); + } + + if (str_contains($ip, '/')) { + [$address, $netmask] = explode('/', $ip, 2); + + if ('0' === $netmask) { + return self::setCacheResult($cacheKey, false !== filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4)); + } + + if ($netmask < 0 || $netmask > 32) { + return self::setCacheResult($cacheKey, false); + } + } else { + $address = $ip; + $netmask = 32; + } + + if (false === ip2long($address)) { + return self::setCacheResult($cacheKey, false); + } + + return self::setCacheResult($cacheKey, 0 === substr_compare(\sprintf('%032b', ip2long($requestIp)), \sprintf('%032b', ip2long($address)), 0, $netmask)); + } + + /** + * Compares two IPv6 addresses. + * In case a subnet is given, it checks if it contains the request IP. + * + * @author David Soria Parra + * + * @see https://github.com/dsp/v6tools + * + * @param string $ip IPv6 address or subnet in CIDR notation + * + * @throws \RuntimeException When IPV6 support is not enabled + */ + public static function checkIp6(string $requestIp, string $ip): bool + { + $cacheKey = $requestIp.'-'.$ip.'-v6'; + if (null !== $cacheValue = self::getCacheResult($cacheKey)) { + return $cacheValue; + } + + if (!((\extension_loaded('sockets') && \defined('AF_INET6')) || @inet_pton('::1'))) { + throw new \RuntimeException('Unable to check Ipv6. Check that PHP was not compiled with option "disable-ipv6".'); + } + + // Check to see if we were given a IP4 $requestIp or $ip by mistake + if (!filter_var($requestIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) { + return self::setCacheResult($cacheKey, false); + } + + if (str_contains($ip, '/')) { + [$address, $netmask] = explode('/', $ip, 2); + + if (!filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) { + return self::setCacheResult($cacheKey, false); + } + + if ('0' === $netmask) { + return (bool) unpack('n*', @inet_pton($address)); + } + + if ($netmask < 1 || $netmask > 128) { + return self::setCacheResult($cacheKey, false); + } + } else { + if (!filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) { + return self::setCacheResult($cacheKey, false); + } + + $address = $ip; + $netmask = 128; + } + + $bytesAddr = unpack('n*', @inet_pton($address)); + $bytesTest = unpack('n*', @inet_pton($requestIp)); + + if (!$bytesAddr || !$bytesTest) { + return self::setCacheResult($cacheKey, false); + } + + for ($i = 1, $ceil = ceil($netmask / 16); $i <= $ceil; ++$i) { + $left = $netmask - 16 * ($i - 1); + $left = ($left <= 16) ? $left : 16; + $mask = ~(0xFFFF >> $left) & 0xFFFF; + if (($bytesAddr[$i] & $mask) != ($bytesTest[$i] & $mask)) { + return self::setCacheResult($cacheKey, false); + } + } + + return self::setCacheResult($cacheKey, true); + } + + /** + * Anonymizes an IP/IPv6. + * + * Removes the last bytes of IPv4 and IPv6 addresses (1 byte for IPv4 and 8 bytes for IPv6 by default). + * + * @param int<0, 4> $v4Bytes + * @param int<0, 16> $v6Bytes + */ + public static function anonymize(string $ip/* , int $v4Bytes = 1, int $v6Bytes = 8 */): string + { + $v4Bytes = 1 < \func_num_args() ? func_get_arg(1) : 1; + $v6Bytes = 2 < \func_num_args() ? func_get_arg(2) : 8; + + if ($v4Bytes < 0 || $v6Bytes < 0) { + throw new \InvalidArgumentException('Cannot anonymize less than 0 bytes.'); + } + + if ($v4Bytes > 4 || $v6Bytes > 16) { + throw new \InvalidArgumentException('Cannot anonymize more than 4 bytes for IPv4 and 16 bytes for IPv6.'); + } + + /** + * If the IP contains a % symbol, then it is a local-link address with scoping according to RFC 4007 + * In that case, we only care about the part before the % symbol, as the following functions, can only work with + * the IP address itself. As the scope can leak information (containing interface name), we do not want to + * include it in our anonymized IP data. + */ + if (str_contains($ip, '%')) { + $ip = substr($ip, 0, strpos($ip, '%')); + } + + $wrappedIPv6 = false; + if (str_starts_with($ip, '[') && str_ends_with($ip, ']')) { + $wrappedIPv6 = true; + $ip = substr($ip, 1, -1); + } + + $mappedIpV4MaskGenerator = function (string $mask, int $bytesToAnonymize) { + $mask .= str_repeat('ff', 4 - $bytesToAnonymize); + $mask .= str_repeat('00', $bytesToAnonymize); + + return '::'.implode(':', str_split($mask, 4)); + }; + + $packedAddress = inet_pton($ip); + if (4 === \strlen($packedAddress)) { + $mask = rtrim(str_repeat('255.', 4 - $v4Bytes).str_repeat('0.', $v4Bytes), '.'); + } elseif ($ip === inet_ntop($packedAddress & inet_pton('::ffff:ffff:ffff'))) { + $mask = $mappedIpV4MaskGenerator('ffff', $v4Bytes); + } elseif ($ip === inet_ntop($packedAddress & inet_pton('::ffff:ffff'))) { + $mask = $mappedIpV4MaskGenerator('', $v4Bytes); + } else { + $mask = str_repeat('ff', 16 - $v6Bytes).str_repeat('00', $v6Bytes); + $mask = implode(':', str_split($mask, 4)); + } + $ip = inet_ntop($packedAddress & inet_pton($mask)); + + if ($wrappedIPv6) { + $ip = '['.$ip.']'; + } + + return $ip; + } + + /** + * Checks if an IPv4 or IPv6 address is contained in the list of private IP subnets. + */ + public static function isPrivateIp(string $requestIp): bool + { + return self::checkIp($requestIp, self::PRIVATE_SUBNETS); + } + + private static function getCacheResult(string $cacheKey): ?bool + { + if (isset(self::$checkedIps[$cacheKey])) { + // Move the item last in cache (LRU) + $value = self::$checkedIps[$cacheKey]; + unset(self::$checkedIps[$cacheKey]); + self::$checkedIps[$cacheKey] = $value; + + return self::$checkedIps[$cacheKey]; + } + + return null; + } + + private static function setCacheResult(string $cacheKey, bool $result): bool + { + if (1000 < \count(self::$checkedIps)) { + // stop memory leak if there are many keys + self::$checkedIps = \array_slice(self::$checkedIps, 500, null, true); + } + + return self::$checkedIps[$cacheKey] = $result; + } +} diff --git a/shared/lib/Json/JsonDeserializable.php b/shared/lib/Json/JsonDeserializable.php new file mode 100644 index 0000000..e07af2a --- /dev/null +++ b/shared/lib/Json/JsonDeserializable.php @@ -0,0 +1,11 @@ +getArrayCopy(); + } + + public function jsonDeserialize(array|string $data): static { + + if (is_string($data)) { + $data = json_decode($data, true); + } + + $this->exchangeArray([]); + + if (in_array($this->typeValue, $this->primitiveTypes)) { + if ($this->associative) { + foreach ($data as $key => $value) { + $this[$key] = $value; + } + } else { + foreach ($data as $value) { + $this[] = $value; + } + } + } + + if (!in_array($this->typeValue, $this->primitiveTypes) && class_exists($this->typeValue)) { + $reflection = new \ReflectionClass($this->typeValue); + if ($reflection->implementsInterface(JsonDeserializable::class)) { + if ($this->associative) { + foreach ($data as $key => $value) { + $instance = $reflection->newInstance(); + /** @var JsonDeserializable $instance */ + $this[$key] = $instance->jsonDeserialize($value); + } + } else { + foreach ($data as $value) { + $instance = $reflection->newInstance(); + /** @var JsonDeserializable $instance */ + $this[] = $instance->jsonDeserialize($value); + } + } + } + } + + return $this; + } + +} \ No newline at end of file diff --git a/shared/lib/Json/JsonSerializableObject.php b/shared/lib/Json/JsonSerializableObject.php new file mode 100644 index 0000000..b198dd0 --- /dev/null +++ b/shared/lib/Json/JsonSerializableObject.php @@ -0,0 +1,165 @@ +serializableProperties)) { + $vars = array_filter($vars, function($key) { + return in_array($key, $this->serializableProperties); + }, ARRAY_FILTER_USE_KEY); + } + + // Process each property for special types + foreach ($vars as $key => $value) { + // Skip internal control properties + if (in_array($key, $this->nonSerializableProperties)) { + unset($vars[$key]); + continue; + } + + // Handle DateTimeInterface (DateTime/DateTimeImmutable) + if ($value instanceof DateTimeInterface) { + $vars[$key] = $value->format($this->dateTimeFormat); + } + // Handle DateTimeZone + elseif ($value instanceof DateTimeZone) { + $vars[$key] = $value->getName(); + } + // Handle DateInterval + elseif ($value instanceof DateInterval) { + $vars[$key] = $this->fromDateInterval($value); + } + // Handle backed enums + elseif ($value instanceof \BackedEnum) { + $vars[$key] = $value->value; + } + // Handle JsonSerializable objects + elseif ($value instanceof JsonSerializable) { + $vars[$key] = $value->jsonSerialize(); + } + } + + return $vars; + } + + public function jsonDeserialize(array|string $data): static { + + if (is_string($data)) { + $data = json_decode($data, true); + } + + foreach ($data as $key => $value) { + if (property_exists($this, $key)) { + // Skip internal control properties + if (in_array($key, $this->nonSerializableProperties)) { + continue; + } + + // Check if property should be deserialized (if serializableProperties is set) + if (!empty($this->serializableProperties) && !in_array($key, $this->serializableProperties)) { + continue; + } + + $type = gettype($this->$key); + + // Handle JsonDeserializable objects + if ($type === 'object' && $this->$key instanceof JsonDeserializable) { + $this->$key = $this->$key->jsonDeserialize($value); + } + // Handle DateTimeInterface (DateTime/DateTimeImmutable) + elseif ($type === 'object' && $this->$key instanceof DateTimeInterface) { + $this->$key = new \DateTimeImmutable($value); + } + // Handle DateTimeZone + elseif ($type === 'object' && $this->$key instanceof DateTimeZone) { + $this->$key = new DateTimeZone($value); + } + // Handle DateInterval + elseif ($type === 'object' && $this->$key instanceof DateInterval) { + $this->$key = $this->toDateInterval($value); + } + // Handle backed enums + elseif ($type === 'object' && $this->$key instanceof \BackedEnum) { + $enumClass = get_class($this->$key); + $this->$key = $enumClass::from($value); + } + // Handle regular values + else { + $this->$key = $value; + } + } + } + + return $this; + } + + protected function fromDateInterval(DateInterval $interval): string { + $spec = ''; + + // Handle negative intervals + if ($interval->invert === 1) { + $spec = '-'; + } + + $spec .= 'P'; + + if ($interval->y > 0) $spec .= $interval->y . 'Y'; + if ($interval->m > 0) $spec .= $interval->m . 'M'; + if ($interval->d > 0) $spec .= $interval->d . 'D'; + + $timePart = ''; + if ($interval->h > 0) $timePart .= $interval->h . 'H'; + if ($interval->i > 0) $timePart .= $interval->i . 'M'; + if ($interval->s > 0) $timePart .= $interval->s . 'S'; + + if (!empty($timePart)) { + $spec .= 'T' . $timePart; + } + + // Handle edge case of zero duration + if ($spec === 'P' || $spec === '-P') { + $spec = 'PT0S'; + } + + return $spec; + } + + protected function toDateInterval(string $value): DateInterval { + $isNegative = false; + + // Check for negative interval + if (str_starts_with($value, '-')) { + $isNegative = true; + $value = substr($value, 1); + } + + // Create the interval + $interval = new DateInterval($value); + + // Set invert property for negative intervals + if ($isNegative) { + $interval->invert = 1; + } + + return $interval; + } + +} \ No newline at end of file diff --git a/shared/lib/Mail/Collection/CollectionBaseAbstract.php b/shared/lib/Mail/Collection/CollectionBaseAbstract.php new file mode 100644 index 0000000..17959f9 --- /dev/null +++ b/shared/lib/Mail/Collection/CollectionBaseAbstract.php @@ -0,0 +1,31 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Collection; + +use KTXF\Resource\Provider\Node\NodeBaseAbstract; + +/** + * Abstract Mail Collection Base Class + * + * Provides common implementation for mail collections + * + * @since 2025.05.01 + */ +abstract class CollectionBase extends NodeBaseAbstract implements CollectionBaseInterface { + + protected CollectionPropertiesBaseAbstract $properties; + + /** + * @inheritDoc + */ + public function getProperties(): CollectionPropertiesBaseInterface { + return $this->properties; + } +} diff --git a/shared/lib/Mail/Collection/CollectionBaseInterface.php b/shared/lib/Mail/Collection/CollectionBaseInterface.php new file mode 100644 index 0000000..6d83581 --- /dev/null +++ b/shared/lib/Mail/Collection/CollectionBaseInterface.php @@ -0,0 +1,30 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Collection; + +use KTXF\Resource\Provider\Node\NodeBaseInterface; + +/** + * Mail Collection Base Interface + * + * Interface represents a mailbox/folder in a mail service + * + * @since 2025.05.01 + */ +interface CollectionBaseInterface extends NodeBaseInterface { + + /** + * Gets the collection properties + * + * @since 2025.05.01 + */ + public function getProperties(): CollectionPropertiesBaseInterface|CollectionPropertiesMutableInterface; + +} diff --git a/shared/lib/Mail/Collection/CollectionMutableAbstract.php b/shared/lib/Mail/Collection/CollectionMutableAbstract.php new file mode 100644 index 0000000..3dae2c8 --- /dev/null +++ b/shared/lib/Mail/Collection/CollectionMutableAbstract.php @@ -0,0 +1,49 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Collection; + +use KTXF\Resource\Provider\Node\NodeMutableAbstract; +use KTXF\Resource\Provider\Node\NodePropertiesMutableInterface; + +/** + * Abstract Mail Collection Mutable Class + * + * Provides common implementation for mutable mail collections + * + * @since 2025.05.01 + */ +abstract class CollectionMutableAbstract extends NodeMutableAbstract implements CollectionMutableInterface { + + protected CollectionPropertiesMutableAbstract $properties; + + /** + * @inheritDoc + */ + public function getProperties(): CollectionPropertiesMutableInterface { + return $this->properties; + } + + /** + * @inheritDoc + */ + public function setProperties(NodePropertiesMutableInterface $value): static { + if (!$value instanceof CollectionPropertiesMutableInterface) { + throw new \InvalidArgumentException('Properties must implement CollectionPropertiesMutableInterface'); + } + + // Copy all property values + $this->properties->setLabel($value->getLabel()); + $this->properties->setRole($value->getRole()); + $this->properties->setRank($value->getRank()); + $this->properties->setSubscription($value->getSubscription()); + + return $this; + } +} diff --git a/shared/lib/Mail/Collection/CollectionMutableInterface.php b/shared/lib/Mail/Collection/CollectionMutableInterface.php new file mode 100644 index 0000000..159a688 --- /dev/null +++ b/shared/lib/Mail/Collection/CollectionMutableInterface.php @@ -0,0 +1,32 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Collection; + +use KTXF\Resource\Provider\Node\NodeMutableInterface; + +/** + * Mail Collection Mutable Interface + * + * Interface for altering mailbox/folder properties in a mail service + * + * @since 2025.05.01 + * + * @method static setProperties(CollectionPropertiesMutableInterface $value) + */ +interface CollectionMutableInterface extends CollectionBaseInterface, NodeMutableInterface { + + /** + * Gets the collection properties (mutable) + * + * @since 2025.05.01 + */ + public function getProperties(): CollectionPropertiesMutableInterface; + +} diff --git a/shared/lib/Mail/Collection/CollectionPropertiesBaseAbstract.php b/shared/lib/Mail/Collection/CollectionPropertiesBaseAbstract.php new file mode 100644 index 0000000..7cf60b5 --- /dev/null +++ b/shared/lib/Mail/Collection/CollectionPropertiesBaseAbstract.php @@ -0,0 +1,68 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Collection; + +use KTXF\Resource\Provider\Node\NodePropertiesBaseAbstract; + +/** + * Abstract Mail Collection Properties Base Class + * + * Provides common implementation for mail collection properties + * + * @since 2025.05.01 + */ +abstract class CollectionPropertiesBaseAbstract extends NodePropertiesBaseAbstract implements CollectionPropertiesBaseInterface { + + public const JSON_TYPE = CollectionPropertiesBaseInterface::JSON_TYPE; + + /** + * @inheritDoc + */ + public function total(): int { + return $this->data['total'] ?? 0; + } + + /** + * @inheritDoc + */ + public function unread(): int { + return $this->data['unread'] ?? 0; + } + + /** + * @inheritDoc + */ + public function getLabel(): string { + return $this->data['label'] ?? ''; + } + + /** + * @inheritDoc + */ + public function getRole(): CollectionRoles { + return isset($this->data['role']) + ? ($this->data['role'] instanceof CollectionRoles ? $this->data['role'] : CollectionRoles::from($this->data['role'])) + : CollectionRoles::Custom; + } + + /** + * @inheritDoc + */ + public function getRank(): int { + return $this->data['rank'] ?? 0; + } + + /** + * @inheritDoc + */ + public function getSubscription(): bool { + return $this->data['subscribed'] ?? false; + } +} diff --git a/shared/lib/Mail/Collection/CollectionPropertiesBaseInterface.php b/shared/lib/Mail/Collection/CollectionPropertiesBaseInterface.php new file mode 100644 index 0000000..a6197c0 --- /dev/null +++ b/shared/lib/Mail/Collection/CollectionPropertiesBaseInterface.php @@ -0,0 +1,35 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Collection; + +use KTXF\Resource\Provider\Node\NodePropertiesBaseInterface; + +interface CollectionPropertiesBaseInterface extends NodePropertiesBaseInterface { + + public const JSON_TYPE = 'mail.collection'; + public const JSON_PROPERTY_TOTAL = 'total'; + public const JSON_PROPERTY_UNREAD = 'unread'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_ROLE = 'role'; + public const JSON_PROPERTY_RANK = 'rank'; + public const JSON_PROPERTY_SUBSCRIPTION = 'subscription'; + + public function total(): int; + + public function unread(): int; + + public function getLabel(): string; + + public function getRole(): CollectionRoles; + + public function getRank(): int; + + public function getSubscription(): bool; +} diff --git a/shared/lib/Mail/Collection/CollectionPropertiesMutableAbstract.php b/shared/lib/Mail/Collection/CollectionPropertiesMutableAbstract.php new file mode 100644 index 0000000..edc820b --- /dev/null +++ b/shared/lib/Mail/Collection/CollectionPropertiesMutableAbstract.php @@ -0,0 +1,65 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Collection; + +use KTXF\Resource\Provider\Node\NodePropertiesMutableAbstract; + +/** + * Abstract Mail Collection Properties Mutable Class + */ +abstract class CollectionPropertiesMutableAbstract extends CollectionPropertiesBaseAbstract implements CollectionPropertiesMutableInterface { + + public const JSON_TYPE = CollectionPropertiesBaseInterface::JSON_TYPE; + + /** + * @inheritDoc + */ + public function jsonDeserialize(array|string $data): static { + if (is_string($data)) { + $data = json_decode($data, true); + } + + $this->data = $data; + + return $this; + } + + /** + * @inheritDoc + */ + public function setLabel(string $value): static { + $this->data['label'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setRole(CollectionRoles $value): static { + $this->data['role'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setRank(int $value): static { + $this->data['rank'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setSubscription(bool $value): static { + $this->data['subscription'] = $value; + return $this; + } +} diff --git a/shared/lib/Mail/Collection/CollectionPropertiesMutableInterface.php b/shared/lib/Mail/Collection/CollectionPropertiesMutableInterface.php new file mode 100644 index 0000000..a16557c --- /dev/null +++ b/shared/lib/Mail/Collection/CollectionPropertiesMutableInterface.php @@ -0,0 +1,26 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Collection; + +use KTXF\Resource\Provider\Node\NodePropertiesMutableInterface; + +interface CollectionPropertiesMutableInterface extends CollectionPropertiesBaseInterface, NodePropertiesMutableInterface { + + public const JSON_TYPE = CollectionPropertiesBaseInterface::JSON_TYPE; + + public function setLabel(string $value); + + public function setRole(CollectionRoles $value): static; + + public function setRank(int $value): static; + + public function setSubscription(bool $value): static; + +} diff --git a/shared/lib/Mail/Collection/CollectionRoles.php b/shared/lib/Mail/Collection/CollectionRoles.php new file mode 100644 index 0000000..28e9fec --- /dev/null +++ b/shared/lib/Mail/Collection/CollectionRoles.php @@ -0,0 +1,37 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Collection; + +use JsonSerializable; + +/** + * Mail Collection Roles + * + * Standard mailbox/folder roles for mail collections. + * + * @since 2025.05.01 + */ +enum CollectionRoles: string implements JsonSerializable { + + case Inbox = 'inbox'; + case Drafts = 'drafts'; + case Sent = 'sent'; + case Trash = 'trash'; + case Junk = 'junk'; + case Archive = 'archive'; + case Outbox = 'outbox'; + case Queue = 'queue'; + case Custom = 'custom'; + + public function jsonSerialize(): string { + return $this->value; + } + +} diff --git a/shared/lib/Mail/Entity/EntityBaseAbstract.php b/shared/lib/Mail/Entity/EntityBaseAbstract.php new file mode 100644 index 0000000..a88fcb8 --- /dev/null +++ b/shared/lib/Mail/Entity/EntityBaseAbstract.php @@ -0,0 +1,32 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Entity; + +use KTXF\Mail\Object\MessagePropertiesBaseInterface; +use KTXF\Resource\Provider\Node\NodeBaseAbstract; + +/** + * Abstract Mail Entity Base Class + * + * Provides common implementation for mail entities + * + * @since 2025.05.01 + */ +abstract class EntityBaseAbstract extends NodeBaseAbstract implements EntityBaseInterface { + + protected MessagePropertiesBaseInterface $properties; + + /** + * @inheritDoc + */ + public function getProperties(): MessagePropertiesBaseInterface { + return $this->properties; + } +} diff --git a/shared/lib/Mail/Entity/EntityBaseInterface.php b/shared/lib/Mail/Entity/EntityBaseInterface.php new file mode 100644 index 0000000..3a7b054 --- /dev/null +++ b/shared/lib/Mail/Entity/EntityBaseInterface.php @@ -0,0 +1,27 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Entity; + +use KTXF\Mail\Object\MessagePropertiesBaseInterface; +use KTXF\Mail\Object\MessagePropertiesMutableInterface; +use KTXF\Resource\Provider\Node\NodeBaseInterface; + +interface EntityBaseInterface extends NodeBaseInterface { + + public const JSON_TYPE = 'mail.entity'; + + /** + * Gets the entity properties + * + * @since 2025.05.01 + */ + public function getProperties(): MessagePropertiesBaseInterface|MessagePropertiesMutableInterface; + +} diff --git a/shared/lib/Mail/Entity/EntityMutableAbstract.php b/shared/lib/Mail/Entity/EntityMutableAbstract.php new file mode 100644 index 0000000..1148120 --- /dev/null +++ b/shared/lib/Mail/Entity/EntityMutableAbstract.php @@ -0,0 +1,48 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Entity; + +use KTXF\Mail\Object\MessagePropertiesMutableInterface; +use KTXF\Resource\Provider\Node\NodeMutableAbstract; +use KTXF\Resource\Provider\Node\NodePropertiesMutableInterface; + +/** + * Abstract Mail Entity Mutable Class + * + * Provides common implementation for mutable mail entities + * + * @since 2025.05.01 + */ +abstract class EntityMutableAbstract extends NodeMutableAbstract implements EntityMutableInterface { + + public const JSON_TYPE = EntityMutableInterface::JSON_TYPE; + + protected MessagePropertiesMutableInterface $properties; + + /** + * @inheritDoc + */ + public function getProperties(): MessagePropertiesMutableInterface { + return $this->properties; + } + + /** + * @inheritDoc + */ + public function setProperties(NodePropertiesMutableInterface $value): static { + if (!$value instanceof MessagePropertiesMutableInterface) { + throw new \InvalidArgumentException('Properties must implement MessagePropertiesMutableInterface'); + } + + $this->properties = $value; + + return $this; + } +} diff --git a/shared/lib/Mail/Entity/EntityMutableInterface.php b/shared/lib/Mail/Entity/EntityMutableInterface.php new file mode 100644 index 0000000..88bb136 --- /dev/null +++ b/shared/lib/Mail/Entity/EntityMutableInterface.php @@ -0,0 +1,29 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Entity; + +use KTXF\Mail\Object\MessagePropertiesMutableInterface; +use KTXF\Resource\Provider\Node\NodeMutableInterface; + +/** + * @method static setProperties(MessagePropertiesMutableInterface $value) + */ +interface EntityMutableInterface extends EntityBaseInterface, NodeMutableInterface { + + public const JSON_TYPE = EntityBaseInterface::JSON_TYPE; + + /** + * Gets the entity properties (mutable) + * + * @since 2025.05.01 + */ + public function getProperties(): MessagePropertiesMutableInterface; + +} diff --git a/shared/lib/Mail/Exception/SendException.php b/shared/lib/Mail/Exception/SendException.php new file mode 100644 index 0000000..d670d87 --- /dev/null +++ b/shared/lib/Mail/Exception/SendException.php @@ -0,0 +1,68 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Exception; + +use Exception; + +/** + * Mail Send Exception + * + * Exception thrown when mail delivery fails. + * + * @since 2025.05.01 + */ +class SendException extends Exception { + + /** + * @param string $message Error message + * @param int $code Error code + * @param Exception|null $previous Previous exception + * @param string|null $recipient Specific recipient that failed (if applicable) + * @param bool $permanent Whether this is a permanent failure (no retry) + */ + public function __construct( + string $message, + int $code = 0, + ?Exception $previous = null, + public readonly ?string $recipient = null, + public readonly bool $permanent = false, + ) { + parent::__construct($message, $code, $previous); + } + + /** + * Creates a permanent failure exception (no retry) + * + * @since 2025.05.01 + * + * @param string $message + * @param string|null $recipient + * + * @return self + */ + public static function permanent(string $message, ?string $recipient = null): self { + return new self($message, 0, null, $recipient, true); + } + + /** + * Creates a temporary failure exception (will retry) + * + * @since 2025.05.01 + * + * @param string $message + * @param Exception|null $previous + * + * @return self + */ + public static function temporary(string $message, ?Exception $previous = null): self { + return new self($message, 0, $previous, null, false); + } + +} diff --git a/shared/lib/Mail/Object/Address.php b/shared/lib/Mail/Object/Address.php new file mode 100644 index 0000000..f98faaa --- /dev/null +++ b/shared/lib/Mail/Object/Address.php @@ -0,0 +1,127 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Object; + +/** + * Address Implementation + * + * @since 2025.05.01 + */ +class Address implements AddressInterface { + + /** + * @param string $address Email address + * @param string|null $name Display name + */ + public function __construct( + private string $address = '', + private ?string $name = null, + ) {} + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + return array_filter([ + self::JSON_PROPERTY_ADDRESS => $this->address, + self::JSON_PROPERTY_LABEL => $this->name, + ], fn($v) => $v !== null && $v !== ''); + } + + /** + * Creates an Address from a formatted string + * + * @since 2025.05.01 + * + * @param string $value Formatted as "Name
" or just "address" + * + * @return self + */ + public static function fromString(string $value): self { + $value = trim($value); + + // Match "Name
" format + if (preg_match('/^(.+?)\s*<([^>]+)>$/', $value, $matches)) { + return new self(trim($matches[2]), trim($matches[1], ' "\'')); + } + + // Match "
" format + if (preg_match('/^<([^>]+)>$/', $value, $matches)) { + return new self(trim($matches[1])); + } + + // Assume plain address + return new self($value); + } + + /** + * Creates an Address from an array + * + * @since 2025.05.01 + * + * @param array $data Array with 'address' and optional 'name' keys + * + * @return self + */ + public static function fromArray(array $data): self { + return new self( + $data[self::JSON_PROPERTY_ADDRESS] ?? $data['address'] ?? '', + $data[self::JSON_PROPERTY_LABEL] ?? $data['name'] ?? null, + ); + } + + /** + * @inheritDoc + */ + public function getAddress(): string { + return $this->address; + } + + /** + * @inheritDoc + */ + public function setAddress(string $address): static { + $this->address = $address; + return $this; + } + + /** + * @inheritDoc + */ + public function getLabel(): ?string { + return $this->name; + } + + /** + * @inheritDoc + */ + public function setLabel(?string $label): static { + $this->name = $label; + return $this; + } + + /** + * @inheritDoc + */ + public function toString(): string { + if ($this->name !== null && $this->name !== '') { + return sprintf('"%s" <%s>', $this->name, $this->address); + } + return $this->address; + } + + /** + * String representation + */ + public function __toString(): string { + return $this->toString(); + } + +} diff --git a/shared/lib/Mail/Object/AddressInterface.php b/shared/lib/Mail/Object/AddressInterface.php new file mode 100644 index 0000000..fa67592 --- /dev/null +++ b/shared/lib/Mail/Object/AddressInterface.php @@ -0,0 +1,63 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Object; + +use KTXF\Json\JsonSerializable; + +/** + * Address Interface + * + * Represents an email address with optional display name. + * + * @since 2025.05.01 + */ +interface AddressInterface extends JsonSerializable { + + public const JSON_PROPERTY_ADDRESS = 'address'; + public const JSON_PROPERTY_LABEL = 'label'; + + /** + * Gets the email address + * + * @since 2025.05.01 + */ + public function getAddress(): string; + + /** + * Sets the email address + * + * @since 2025.05.01 + */ + public function setAddress(string $value): static; + + /** + * Gets the display name + * + * @since 2025.05.01 + */ + public function getLabel(): ?string; + + /** + * Sets the display name + * + * @since 2025.05.01 + */ + public function setLabel(?string $value): static; + + /** + * Gets the formatted address string + * + * @since 2025.05.01 + * + * @return string Formatted as "Name
" or just "address" if no name + */ + public function toString(): string; + +} diff --git a/shared/lib/Mail/Object/Attachment.php b/shared/lib/Mail/Object/Attachment.php new file mode 100644 index 0000000..58edccf --- /dev/null +++ b/shared/lib/Mail/Object/Attachment.php @@ -0,0 +1,194 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Object; + +/** + * Attachment Implementation + * + * @since 2025.05.01 + */ +class Attachment implements AttachmentInterface { + + /** + * @param string $name File name + * @param string $mimeType MIME type + * @param string $content Binary content + * @param string|null $id Attachment ID + * @param int|null $size Size in bytes + * @param string|null $contentId Content-ID for inline attachments + * @param bool $inline Whether inline attachment + */ + public function __construct( + private string $name, + private string $mimeType, + private string $content, + private ?string $id = null, + private ?int $size = null, + private ?string $contentId = null, + private bool $inline = false, + ) { + if ($this->size === null) { + $this->size = strlen($this->content); + } + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + return array_filter([ + self::JSON_PROPERTY_ID => $this->id, + self::JSON_PROPERTY_NAME => $this->name, + self::JSON_PROPERTY_MIME_TYPE => $this->mimeType, + self::JSON_PROPERTY_SIZE => $this->size, + self::JSON_PROPERTY_CONTENT_ID => $this->contentId, + self::JSON_PROPERTY_INLINE => $this->inline ?: null, + 'contentBase64' => $this->getContentBase64(), + ], fn($v) => $v !== null); + } + + + /** + * Creates an attachment from a file path + * + * @since 2025.05.01 + * + * @param string $path File path + * @param string|null $name Override file name + * @param string|null $mimeType Override MIME type + * + * @return self + */ + public static function fromFile(string $path, ?string $name = null, ?string $mimeType = null): self { + $content = file_get_contents($path); + $name = $name ?? basename($path); + $mimeType = $mimeType ?? mime_content_type($path) ?: 'application/octet-stream'; + + return new self($name, $mimeType, $content); + } + + /** + * Creates an attachment from base64 encoded content + * + * @since 2025.05.01 + * + * @param string $name File name + * @param string $mimeType MIME type + * @param string $base64Content Base64 encoded content + * + * @return self + */ + public static function fromBase64(string $name, string $mimeType, string $base64Content): self { + return new self($name, $mimeType, base64_decode($base64Content)); + } + + /** + * Creates an inline attachment for embedding in HTML + * + * @since 2025.05.01 + * + * @param string $name File name + * @param string $mimeType MIME type + * @param string $content Binary content + * @param string $contentId Content-ID (without cid: prefix) + * + * @return self + */ + public static function inline(string $name, string $mimeType, string $content, string $contentId): self { + return new self($name, $mimeType, $content, null, null, $contentId, true); + } + + /** + * Creates from array data + * + * @since 2025.05.01 + * + * @param array $data + * + * @return self + */ + public static function fromArray(array $data): self { + $content = $data['content'] ?? ''; + if (isset($data['contentBase64'])) { + $content = base64_decode($data['contentBase64']); + } + + return new self( + $data[self::JSON_PROPERTY_NAME] ?? $data['name'] ?? '', + $data[self::JSON_PROPERTY_MIME_TYPE] ?? $data['mimeType'] ?? 'application/octet-stream', + $content, + $data[self::JSON_PROPERTY_ID] ?? $data['id'] ?? null, + $data[self::JSON_PROPERTY_SIZE] ?? $data['size'] ?? null, + $data[self::JSON_PROPERTY_CONTENT_ID] ?? $data['contentId'] ?? null, + $data[self::JSON_PROPERTY_INLINE] ?? $data['inline'] ?? false, + ); + } + + /** + * @inheritDoc + */ + public function getId(): ?string { + return $this->id; + } + + /** + * @inheritDoc + */ + public function getName(): string { + return $this->name; + } + + /** + * @inheritDoc + */ + public function getMimeType(): string { + return $this->mimeType; + } + + /** + * @inheritDoc + */ + public function getSize(): ?int { + return $this->size; + } + + /** + * @inheritDoc + */ + public function getContentId(): ?string { + return $this->contentId; + } + + /** + * @inheritDoc + */ + public function isInline(): bool { + return $this->inline; + } + + /** + * @inheritDoc + */ + public function getContent(): string { + return $this->content; + } + + /** + * Gets the content as base64 encoded string + * + * @since 2025.05.01 + * + * @return string + */ + public function getContentBase64(): string { + return base64_encode($this->content); + } + +} diff --git a/shared/lib/Mail/Object/AttachmentInterface.php b/shared/lib/Mail/Object/AttachmentInterface.php new file mode 100644 index 0000000..ef1d57f --- /dev/null +++ b/shared/lib/Mail/Object/AttachmentInterface.php @@ -0,0 +1,93 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Object; + +use KTXF\Json\JsonSerializable; + +/** + * Attachment Interface + * + * Represents a file attachment on a mail message. + * + * @since 2025.05.01 + */ +interface AttachmentInterface extends JsonSerializable { + + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_NAME = 'name'; + public const JSON_PROPERTY_MIME_TYPE = 'mimeType'; + public const JSON_PROPERTY_SIZE = 'size'; + public const JSON_PROPERTY_CONTENT_ID = 'contentId'; + public const JSON_PROPERTY_INLINE = 'inline'; + + /** + * Gets the attachment identifier + * + * @since 2025.05.01 + * + * @return string|null Attachment ID or null for new attachments + */ + public function getId(): ?string; + + /** + * Gets the file name + * + * @since 2025.05.01 + * + * @return string File name (e.g., "document.pdf") + */ + public function getName(): string; + + /** + * Gets the MIME type + * + * @since 2025.05.01 + * + * @return string MIME type (e.g., "application/pdf") + */ + public function getMimeType(): string; + + /** + * Gets the file size in bytes + * + * @since 2025.05.01 + * + * @return int|null Size in bytes or null if unknown + */ + public function getSize(): ?int; + + /** + * Gets the Content-ID for inline attachments + * + * @since 2025.05.01 + * + * @return string|null Content-ID for referencing in HTML body (e.g., "cid:image1") + */ + public function getContentId(): ?string; + + /** + * Checks if this is an inline attachment (embedded in body) + * + * @since 2025.05.01 + * + * @return bool True if inline, false if regular attachment + */ + public function isInline(): bool; + + /** + * Gets the attachment content + * + * @since 2025.05.01 + * + * @return string Binary content of the attachment + */ + public function getContent(): string; + +} diff --git a/shared/lib/Mail/Object/MessagePartBaseAbstract.php b/shared/lib/Mail/Object/MessagePartBaseAbstract.php new file mode 100644 index 0000000..0abef34 --- /dev/null +++ b/shared/lib/Mail/Object/MessagePartBaseAbstract.php @@ -0,0 +1,122 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Object; + +/** + * Abstract Message Part Base Class + * + * Provides common implementation for message parts (read-only) + * + * @since 2025.05.01 + */ +abstract class MessagePartBaseAbstract implements MessagePartInterface { + + /** + * Internal data storage + */ + protected array $data = []; + + /** + * Sub-parts storage + * @var array + */ + protected array $parts = []; + + /** + * Constructor + * + * @param array &$data Reference to data array + */ + public function __construct(array &$data = null) { + if ($data === null) { + $data = []; + } + $this->data = &$data; + } + + /** + * @inheritDoc + */ + public function getBlobId(): ?string { + return $this->data['blobId'] ?? null; + } + + /** + * @inheritDoc + */ + public function getId(): ?string { + return $this->data['partId'] ?? null; + } + + /** + * @inheritDoc + */ + public function getType(): ?string { + return $this->data['type'] ?? null; + } + + /** + * @inheritDoc + */ + public function getDisposition(): ?string { + return $this->data['disposition'] ?? null; + } + + /** + * @inheritDoc + */ + public function getName(): ?string { + return $this->data['name'] ?? null; + } + + /** + * @inheritDoc + */ + public function getCharset(): ?string { + return $this->data['charset'] ?? null; + } + + /** + * @inheritDoc + */ + public function getLanguage(): ?string { + return $this->data['language'] ?? null; + } + + /** + * @inheritDoc + */ + public function getLocation(): ?string { + return $this->data['location'] ?? null; + } + + /** + * @inheritDoc + */ + public function getParts(): array { + return $this->parts; + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + $result = $this->data; + + if (!empty($this->parts)) { + $result['subParts'] = []; + foreach ($this->parts as $part) { + $result['subParts'][] = $part->jsonSerialize(); + } + } + + return $result; + } +} diff --git a/shared/lib/Mail/Object/MessagePartInterface.php b/shared/lib/Mail/Object/MessagePartInterface.php new file mode 100644 index 0000000..4a576cb --- /dev/null +++ b/shared/lib/Mail/Object/MessagePartInterface.php @@ -0,0 +1,104 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Object; + +use KTXF\Json\JsonSerializable; + +/** + * Message Part Interface + * + * Represents a MIME part of a message (body, attachment, etc.) + * + * @since 2025.05.01 + */ +interface MessagePartInterface extends JsonSerializable { + + /** + * Gets the blob identifier + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getBlobId(): ?string; + + /** + * Gets the part identifier + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getId(): ?string; + + /** + * Gets the MIME type + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getType(): ?string; + + /** + * Gets the content disposition (inline, attachment) + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getDisposition(): ?string; + + /** + * Gets the part name + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getName(): ?string; + + /** + * Gets the character set + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getCharset(): ?string; + + /** + * Gets the language + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getLanguage(): ?string; + + /** + * Gets the location + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getLocation(): ?string; + + /** + * Gets the sub-parts + * + * @since 2025.05.01 + * + * @return array + */ + public function getParts(): array; + +} diff --git a/shared/lib/Mail/Object/MessagePartMutableAbstract.php b/shared/lib/Mail/Object/MessagePartMutableAbstract.php new file mode 100644 index 0000000..92f0456 --- /dev/null +++ b/shared/lib/Mail/Object/MessagePartMutableAbstract.php @@ -0,0 +1,146 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Object; + +/** + * Abstract Message Part Mutable Class + * + * Provides common implementation for mutable message parts + * + * @since 2025.05.01 + */ +abstract class MessagePartMutableAbstract extends MessagePartBaseAbstract { + + /** + * Sets the blob identifier + * + * @since 2025.05.01 + * + * @param string $value + * + * @return static + */ + public function setBlobId(string $value): static { + $this->data['blobId'] = $value; + return $this; + } + + /** + * Sets the part identifier + * + * @since 2025.05.01 + * + * @param string $value + * + * @return static + */ + public function setId(string $value): static { + $this->data['partId'] = $value; + return $this; + } + + /** + * Sets the MIME type + * + * @since 2025.05.01 + * + * @param string $value + * + * @return static + */ + public function setType(string $value): static { + $this->data['type'] = $value; + return $this; + } + + /** + * Sets the content disposition + * + * @since 2025.05.01 + * + * @param string $value + * + * @return static + */ + public function setDisposition(string $value): static { + $this->data['disposition'] = $value; + return $this; + } + + /** + * Sets the part name + * + * @since 2025.05.01 + * + * @param string $value + * + * @return static + */ + public function setName(string $value): static { + $this->data['name'] = $value; + return $this; + } + + /** + * Sets the character set + * + * @since 2025.05.01 + * + * @param string $value + * + * @return static + */ + public function setCharset(string $value): static { + $this->data['charset'] = $value; + return $this; + } + + /** + * Sets the language + * + * @since 2025.05.01 + * + * @param string $value + * + * @return static + */ + public function setLanguage(string $value): static { + $this->data['language'] = $value; + return $this; + } + + /** + * Sets the location + * + * @since 2025.05.01 + * + * @param string $value + * + * @return static + */ + public function setLocation(string $value): static { + $this->data['location'] = $value; + return $this; + } + + /** + * Sets the sub-parts + * + * @since 2025.05.01 + * + * @param MessagePartInterface ...$value + * + * @return static + */ + public function setParts(MessagePartInterface ...$value): static { + $this->parts = $value; + return $this; + } +} diff --git a/shared/lib/Mail/Object/MessagePropertiesBaseAbstract.php b/shared/lib/Mail/Object/MessagePropertiesBaseAbstract.php new file mode 100644 index 0000000..597bd6e --- /dev/null +++ b/shared/lib/Mail/Object/MessagePropertiesBaseAbstract.php @@ -0,0 +1,400 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Object; + +use DateTimeImmutable; +use KTXF\Resource\Provider\Node\NodePropertiesBaseAbstract; + +/** + * Abstract Message Properties Base Class + * + * Provides common implementation for message properties (read-only) + * + * @since 2025.05.01 + */ +abstract class MessagePropertiesBaseAbstract extends NodePropertiesBaseAbstract implements MessagePropertiesBaseInterface { + + public const JSON_TYPE = MessagePropertiesBaseInterface::JSON_TYPE; + + /** + * @inheritDoc + */ + public function version(): int { + return $this->data['version'] ?? 1; + } + + /** + * @inheritDoc + */ + public function getHeaders(): array { + return $this->data['headers'] ?? []; + } + + /** + * @inheritDoc + */ + public function getHeader(string $name): string|array|null { + return $this->data['headers'][$name] ?? null; + } + + /** + * @inheritDoc + */ + public function getUrid(): ?string { + return $this->data['urid'] ?? null; + } + + /** + * @inheritDoc + */ + public function getCreated(): ?DateTimeImmutable { + return $this->data['created'] ?? null; + } + + /** + * @inheritDoc + */ + public function getModified(): ?DateTimeImmutable { + return $this->data['modified'] ?? null; + } + + /** + * @inheritDoc + */ + public function getDate(): ?DateTimeImmutable { + return $this->data['date'] ?? null; + } + + /** + * @inheritDoc + */ + public function getReceived(): ?DateTimeImmutable { + return $this->data['received'] ?? null; + } + + /** + * @inheritDoc + */ + public function getSize(): ?int { + return $this->data['size'] ?? null; + } + + /** + * @inheritDoc + */ + public function getSender(): ?AddressInterface { + return $this->data['sender'] ?? null; + } + + /** + * @inheritDoc + */ + public function getFrom(): ?AddressInterface { + return $this->data['from'] ?? null; + } + + /** + * @inheritDoc + */ + public function getReplyTo(): array { + return $this->data['replyTo'] ?? []; + } + + /** + * @inheritDoc + */ + public function getTo(): array { + return $this->data['to'] ?? []; + } + + /** + * @inheritDoc + */ + public function getCc(): array { + return $this->data['cc'] ?? []; + } + + /** + * @inheritDoc + */ + public function getBcc(): array { + return $this->data['bcc'] ?? []; + } + + /** + * @inheritDoc + */ + public function getInReplyTo(): ?string { + return $this->data['inReplyTo'] ?? null; + } + + /** + * @inheritDoc + */ + public function getReferences(): array { + return $this->data['references'] ?? []; + } + + /** + * @inheritDoc + */ + public function getSubject(): string { + return $this->data['subject'] ?? ''; + } + + /** + * @inheritDoc + */ + public function getSnippet(): ?string { + return $this->data['snippet'] ?? null; + } + + /** + * @inheritDoc + */ + public function getBodyText(): ?string { + return $this->data['bodyText'] ?? null; + } + + /** + * @inheritDoc + */ + public function getBodyTextCharset(): ?string { + return $this->data['bodyTextCharset'] ?? null; + } + + /** + * @inheritDoc + */ + public function getBodyTextSize(): ?int { + return $this->data['bodyTextSize'] ?? null; + } + + /** + * @inheritDoc + */ + public function getBodyHtml(): ?string { + return $this->data['bodyHtml'] ?? null; + } + + /** + * @inheritDoc + */ + public function getBodyHtmlCharset(): ?string { + return $this->data['bodyHtmlCharset'] ?? null; + } + + /** + * @inheritDoc + */ + public function getBodyHtmlSize(): ?int { + return $this->data['bodyHtmlSize'] ?? null; + } + + /** + * @inheritDoc + */ + public function getAttachments(): array { + return $this->data['attachments'] ?? []; + } + + /** + * @inheritDoc + */ + public function getFlags(): array { + return $this->data['flags'] ?? [ + 'read' => false, + 'starred' => false, + 'important' => false, + 'answered' => false, + 'forwarded' => false, + 'draft' => false, + 'deleted' => false, + 'flagged' => false, + ]; + } + + /** + * @inheritDoc + */ + public function getFlag(string $name): bool { + return $this->data['flags'][$name] ?? false; + } + + /** + * Gets message labels + * + * @since 2025.05.01 + * + * @return array + */ + public function getLabels(): array { + return $this->data['labels'] ?? []; + } + + /** + * Gets message tags + * + * @since 2025.05.01 + * + * @return array + */ + public function getTags(): array { + return $this->data['tags'] ?? []; + } + + /** + * Gets message priority + * + * @since 2025.05.01 + * + * @return string + */ + public function getPriority(): string { + return $this->data['priority'] ?? 'normal'; + } + + /** + * Gets message sensitivity + * + * @since 2025.05.01 + * + * @return string + */ + public function getSensitivity(): string { + return $this->data['sensitivity'] ?? 'normal'; + } + + /** + * Gets encryption information + * + * @since 2025.05.01 + * + * @return array{method: string|null, signed: bool, encrypted: bool} + */ + public function getEncryption(): array { + return $this->data['encryption'] ?? [ + 'method' => null, + 'signed' => false, + 'encrypted' => false, + ]; + } + + /** + * Checks if delivery receipt is requested + * + * @since 2025.05.01 + * + * @return bool + */ + public function isDeliveryReceipt(): bool { + return $this->data['deliveryReceipt'] ?? false; + } + + /** + * Checks if read receipt is requested + * + * @since 2025.05.01 + * + * @return bool + */ + public function isReadReceipt(): bool { + return $this->data['readReceipt'] ?? false; + } + + /** + * @inheritDoc + */ + public function hasRecipients(): bool { + return !empty($this->data['to']) || !empty($this->data['cc']) || !empty($this->data['bcc']); + } + + /** + * @inheritDoc + */ + public function hasBody(): bool { + return ($this->data['bodyText'] !== null && $this->data['bodyText'] !== '') + || ($this->data['bodyHtml'] !== null && $this->data['bodyHtml'] !== ''); + } + + /** + * @inheritDoc + */ + public function getBody(): ?MessagePartInterface { + return $this->data['body'] ?? null; + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + $data = [ + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_VERSION => $this->data['version'] ?? 1, + ]; + + if (!empty($this->data['headers'])) { + $data[self::JSON_PROPERTY_HEADERS] = $this->data['headers']; + } + if (isset($this->data['urid']) && $this->data['urid'] !== null) { + $data[self::JSON_PROPERTY_URID] = $this->data['urid']; + } + if (isset($this->data['date']) && $this->data['date'] !== null) { + $data[self::JSON_PROPERTY_DATE] = $this->data['date'] instanceof DateTimeImmutable + ? $this->data['date']->format('c') + : $this->data['date']; + } + if (isset($this->data['received']) && $this->data['received'] !== null) { + $data[self::JSON_PROPERTY_RECEIVED] = $this->data['received'] instanceof DateTimeImmutable + ? $this->data['received']->format('c') + : $this->data['received']; + } + if (isset($this->data['size']) && $this->data['size'] !== null) { + $data[self::JSON_PROPERTY_SIZE] = $this->data['size']; + } + if (isset($this->data['sender']) && $this->data['sender'] !== null) { + $data[self::JSON_PROPERTY_SENDER] = $this->data['sender']; + } + if (isset($this->data['from']) && $this->data['from'] !== null) { + $data[self::JSON_PROPERTY_FROM] = $this->data['from']; + } + if (!empty($this->data['replyTo'])) { + $data[self::JSON_PROPERTY_REPLY_TO] = $this->data['replyTo']; + } + if (!empty($this->data['to'])) { + $data[self::JSON_PROPERTY_TO] = $this->data['to']; + } + if (!empty($this->data['cc'])) { + $data[self::JSON_PROPERTY_CC] = $this->data['cc']; + } + if (!empty($this->data['bcc'])) { + $data[self::JSON_PROPERTY_BCC] = $this->data['bcc']; + } + if (isset($this->data['inReplyTo']) && $this->data['inReplyTo'] !== null) { + $data[self::JSON_PROPERTY_IN_REPLY_TO] = $this->data['inReplyTo']; + } + if (!empty($this->data['references'])) { + $data[self::JSON_PROPERTY_REFERENCES] = $this->data['references']; + } + + if (isset($this->data['snippet']) && $this->data['snippet'] !== null) { + $data[self::JSON_PROPERTY_SNIPPET] = $this->data['snippet']; + } + + if (!empty($this->data['attachments'])) { + $data[self::JSON_PROPERTY_ATTACHMENTS] = $this->data['attachments']; + } + + $data[self::JSON_PROPERTY_SUBJECT] = $this->data['subject'] ?? null; + $data[self::JSON_PROPERTY_BODY] = $this->data['body'] ?? null; + + return $data; + } +} diff --git a/shared/lib/Mail/Object/MessagePropertiesBaseInterface.php b/shared/lib/Mail/Object/MessagePropertiesBaseInterface.php new file mode 100644 index 0000000..6daee90 --- /dev/null +++ b/shared/lib/Mail/Object/MessagePropertiesBaseInterface.php @@ -0,0 +1,252 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Object; + +use DateTimeImmutable; +use KTXF\Resource\Provider\Node\NodePropertiesBaseInterface; + +/** + * Message Properties Base Interface + * + * @since 2025.05.01 + */ +interface MessagePropertiesBaseInterface extends NodePropertiesBaseInterface { + + public const JSON_TYPE = 'mail.message'; + public const JSON_PROPERTY_HEADERS = 'headers'; + public const JSON_PROPERTY_URID = 'urid'; + public const JSON_PROPERTY_DATE = 'date'; + public const JSON_PROPERTY_RECEIVED = 'received'; + public const JSON_PROPERTY_SIZE = 'size'; + public const JSON_PROPERTY_SENDER = 'sender'; + public const JSON_PROPERTY_FROM = 'from'; + public const JSON_PROPERTY_REPLY_TO = 'replyTo'; + public const JSON_PROPERTY_TO = 'to'; + public const JSON_PROPERTY_CC = 'cc'; + public const JSON_PROPERTY_BCC = 'bcc'; + public const JSON_PROPERTY_IN_REPLY_TO = 'inReplyTo'; + public const JSON_PROPERTY_REFERENCES = 'references'; + public const JSON_PROPERTY_SUBJECT = 'subject'; + public const JSON_PROPERTY_SNIPPET = 'snippet'; + public const JSON_PROPERTY_BODY = 'body'; + public const JSON_PROPERTY_ATTACHMENTS = 'attachments'; + public const JSON_PROPERTY_TAGS = 'tags'; + + /** + * Gets custom headers + * + * @since 2025.05.01 + * + * @return array Header name => value + */ + public function getHeaders(): array; + + /** + * Gets a specific header value + * + * @since 2025.05.01 + * + * @param string $name Header name + * + * @return string|array|null Header value(s) or null if not set + */ + public function getHeader(string $name): string|array|null; + + /** + * Gets the universal resource identifier (URN) + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getUrid(): ?string; + + /** + * Gets the message date + * + * @since 2025.05.01 + * + * @return DateTimeImmutable|null + */ + public function getDate(): ?DateTimeImmutable; + + /** + * Gets the received date + * + * @since 2025.05.01 + * + * @return DateTimeImmutable|null + */ + public function getReceived(): ?DateTimeImmutable; + + /** + * Gets the message size in bytes + * + * @since 2025.05.01 + * + * @return int|null + */ + public function getSize(): ?int; + + /** + * Gets the sender address (actual sender, may differ from From) + * + * @since 2025.05.01 + * + * @return AddressInterface|null + */ + public function getSender(): ?AddressInterface; + + /** + * Gets the sender address + * + * @since 2025.05.01 + * + * @return AddressInterface|null + */ + public function getFrom(): ?AddressInterface; + /** + * Gets the reply-to addresses + * + * @since 2025.05.01 + * + * @return array + */ + public function getReplyTo(): array; + + /** + * Gets the primary recipients (To) + * + * @since 2025.05.01 + * + * @return array + */ + public function getTo(): array; + + /** + * Gets the carbon copy recipients (CC) + * + * @since 2025.05.01 + * + * @return array + */ + public function getCc(): array; + + /** + * Gets the blind carbon copy recipients (BCC) + * + * @since 2025.05.01 + * + * @return array + */ + public function getBcc(): array; + + /** + * Gets the message ID this is replying to + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getInReplyTo(): ?string; + + /** + * Gets the references (message IDs in thread) + * + * @since 2025.05.01 + * + * @return array + */ + public function getReferences(): array; + + /** + * Gets the message subject + * + * @since 2025.05.01 + * + * @return string + */ + public function getSubject(): string; + + /** + * Gets the message snippet/preview + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getSnippet(): ?string; + + /** + * Checks if the message has any body content + * + * @since 2025.05.01 + * + * @return bool True if text or HTML body is set + */ + public function hasBody(): bool; + + /** + * Gets the message body structure + * + * @since 2025.05.01 + * + * @return MessagePartInterface|null The body structure or null if no body + */ + public function getBody(): ?MessagePartInterface; + + /** + * Gets the plain text body content + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getBodyText(): ?string; + + /** + * Gets the HTML body content + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getBodyHtml(): ?string; + + /** + * Gets the attachments + * + * @since 2025.05.01 + * + * @return array + */ + public function getAttachments(): array; + + /** + * Gets message flags + * + * @since 2025.05.01 + * + * @return array{read:bool,starred:bool,important:bool,answered:bool,forwarded:bool,draft:bool,deleted:bool,flagged:bool} + */ + public function getFlags(): array; + + /** + * Gets a specific flag value + * + * @since 2025.05.01 + * + * @param string $name Flag name (read, starred, important, answered, forwarded, draft, deleted, flagged) + * + * @return bool + */ + public function getFlag(string $name): bool; + +} diff --git a/shared/lib/Mail/Object/MessagePropertiesMutableAbstract.php b/shared/lib/Mail/Object/MessagePropertiesMutableAbstract.php new file mode 100644 index 0000000..5c13540 --- /dev/null +++ b/shared/lib/Mail/Object/MessagePropertiesMutableAbstract.php @@ -0,0 +1,455 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Object; + +use DateTimeImmutable; + +/** + * Abstract Message Properties Mutable Class + * + * Provides common implementation for mutable message properties + * + * @since 2025.05.01 + */ +abstract class MessagePropertiesMutableAbstract extends MessagePropertiesBaseAbstract implements MessagePropertiesMutableInterface { + + public const JSON_TYPE = MessagePropertiesBaseInterface::JSON_TYPE; + + /** + * @inheritDoc + */ + public function setHeaders(array $value): static { + $this->data['headers'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setHeader(string $name, string|array $value): static { + $this->data['headers'][$name] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setUrid(?string $value): static { + $this->data['urid'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setCreated(DateTimeImmutable $value): static { + $this->data['created'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setModified(DateTimeImmutable $value): static { + $this->data['modified'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setDate(DateTimeImmutable $value): static { + $this->data['date'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setReceived(?DateTimeImmutable $value): static { + $this->data['received'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setSize(?int $value): static { + $this->data['size'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setSender(?AddressInterface $value): static { + $this->data['sender'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setFrom(AddressInterface $value): static { + $this->data['from'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setReplyTo(AddressInterface ...$value): static { + $this->data['replyTo'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setTo(AddressInterface ...$value): static { + $this->data['to'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setCc(AddressInterface ...$value): static { + $this->data['cc'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setBcc(AddressInterface ...$value): static { + $this->data['bcc'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setInReplyTo(?string $value): static { + $this->data['inReplyTo'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setReferences(string ...$value): static { + $this->data['references'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setSubject(string $value): static { + $this->data['subject'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setSnippet(?string $value): static { + $this->data['snippet'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setBodyText(?string $value): static { + $this->data['bodyText'] = $value; + return $this; + } + + /** + * Sets the plain text body charset + * + * @since 2025.05.01 + * + * @param string $value + * + * @return static + */ + public function setBodyTextCharset(string $value): static { + $this->data['bodyTextCharset'] = $value; + return $this; + } + + /** + * Sets the plain text body size + * + * @since 2025.05.01 + * + * @param int $value + * + * @return static + */ + public function setBodyTextSize(int $value): static { + $this->data['bodyTextSize'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setBodyHtml(?string $value): static { + $this->data['bodyHtml'] = $value; + return $this; + } + + /** + * Sets the HTML body charset + * + * @since 2025.05.01 + * + * @param string $value + * + * @return static + */ + public function setBodyHtmlCharset(string $value): static { + $this->data['bodyHtmlCharset'] = $value; + return $this; + } + + /** + * Sets the HTML body size + * + * @since 2025.05.01 + * + * @param int $value + * + * @return static + */ + public function setBodyHtmlSize(int $value): static { + $this->data['bodyHtmlSize'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function setAttachments(AttachmentInterface ...$value): static { + $this->data['attachments'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function addAttachment(AttachmentInterface $value): static { + $this->data['attachments'][] = $value; + return $this; + } + + /** + * Sets message flags + * + * @since 2025.05.01 + * + * @param array $value + * + * @return static + */ + public function setFlags(array $value): static { + if (!isset($this->data['flags'])) { + $this->data['flags'] = [ + 'read' => false, + 'starred' => false, + 'important' => false, + 'answered' => false, + 'forwarded' => false, + 'draft' => false, + 'deleted' => false, + 'flagged' => false, + ]; + } + $this->data['flags'] = array_merge($this->data['flags'], $value); + return $this; + } + + /** + * @inheritDoc + */ + public function setFlag(string $name, bool $value): static { + if (!isset($this->data['flags'])) { + $this->data['flags'] = [ + 'read' => false, + 'starred' => false, + 'important' => false, + 'answered' => false, + 'forwarded' => false, + 'draft' => false, + 'deleted' => false, + 'flagged' => false, + ]; + } + if (array_key_exists($name, $this->data['flags'])) { + $this->data['flags'][$name] = $value; + } + return $this; + } + + /** + * Sets message labels + * + * @since 2025.05.01 + * + * @param string ...$value + * + * @return static + */ + public function setLabels(string ...$value): static { + $this->data['labels'] = $value; + return $this; + } + + /** + * Adds a message label + * + * @since 2025.05.01 + * + * @param string $value + * + * @return static + */ + public function addLabel(string $value): static { + $this->data['labels'][] = $value; + return $this; + } + + /** + * Sets message tags + * + * @since 2025.05.01 + * + * @param string ...$value + * + * @return static + */ + public function setTags(string ...$value): static { + $this->data['tags'] = $value; + return $this; + } + + /** + * Adds a message tag + * + * @since 2025.05.01 + * + * @param string $value + * + * @return static + */ + public function addTag(string $value): static { + $this->data['tags'][] = $value; + return $this; + } + + /** + * Sets message priority + * + * @since 2025.05.01 + * + * @param string $value + * + * @return static + */ + public function setPriority(string $value): static { + $this->data['priority'] = $value; + return $this; + } + + /** + * Sets message sensitivity + * + * @since 2025.05.01 + * + * @param string $value + * + * @return static + */ + public function setSensitivity(string $value): static { + $this->data['sensitivity'] = $value; + return $this; + } + + /** + * Sets encryption information + * + * @since 2025.05.01 + * + * @param array $value + * + * @return static + */ + public function setEncryption(array $value): static { + if (!isset($this->data['encryption'])) { + $this->data['encryption'] = [ + 'method' => null, + 'signed' => false, + 'encrypted' => false, + ]; + } + $this->data['encryption'] = array_merge($this->data['encryption'], $value); + return $this; + } + + /** + * Sets delivery receipt flag + * + * @since 2025.05.01 + * + * @param bool $value + * + * @return static + */ + public function setDeliveryReceipt(bool $value): static { + $this->data['deliveryReceipt'] = $value; + return $this; + } + + /** + * Sets read receipt flag + * + * @since 2025.05.01 + * + * @param bool $value + * + * @return static + */ + public function setReadReceipt(bool $value): static { + $this->data['readReceipt'] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function jsonDeserialize(array|string $data): static { + if (is_string($data)) { + $data = json_decode($data, true); + } + + // Merge deserialized data into internal storage + foreach ($data as $key => $value) { + if (!in_array($key, ['collection', 'identifier', 'signature', 'created', 'modified'])) { + $this->data[$key] = $value; + } + } + + return $this; + } +} diff --git a/shared/lib/Mail/Object/MessagePropertiesMutableInterface.php b/shared/lib/Mail/Object/MessagePropertiesMutableInterface.php new file mode 100644 index 0000000..831bb63 --- /dev/null +++ b/shared/lib/Mail/Object/MessagePropertiesMutableInterface.php @@ -0,0 +1,255 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Object; + +use DateTimeImmutable; +use KTXF\Resource\Provider\Node\NodePropertiesMutableInterface; + +/** + * Message Properties Mutable Interface + * + * @since 2025.05.01 + */ +interface MessagePropertiesMutableInterface extends MessagePropertiesBaseInterface, NodePropertiesMutableInterface { + + public const JSON_TYPE = MessagePropertiesBaseInterface::JSON_TYPE; + + /** + * Sets custom headers + * + * @since 2025.05.01 + * + * @param array> $value Header name => value + * + * @return self + */ + public function setHeaders(array $value): static; + + /** + * Sets a specific header value + * + * @since 2025.05.01 + * + * @param string $name Header name + * @param string|array $value Header value(s) + * + * @return self + */ + public function setHeader(string $name, string|array $value): static; + + /** + * Sets the universal resource identifier (URN) + * + * @since 2025.05.01 + * + * @param string|null $value + * + * @return self + */ + public function setUrid(?string $value): static; + + /** + * Sets the message date + * + * @since 2025.05.01 + * + * @param DateTimeImmutable $value + * + * @return self + */ + public function setDate(DateTimeImmutable $value): static; + + /** + * Sets the received date + * + * @since 2025.05.01 + * + * @param DateTimeImmutable|null $value + * + * @return self + */ + public function setReceived(?DateTimeImmutable $value): static; + + /** + * Sets the message size in bytes + * + * @since 2025.05.01 + * + * @param int|null $value + * + * @return self + */ + public function setSize(?int $value): static; + + /** + * Sets the sender address (actual sender, may differ from From) + * + * @since 2025.05.01 + * + * @param AddressInterface|null $value + * + * @return self + */ + public function setSender(?AddressInterface $value): static; + + /** + * Sets the sender address + * + * @since 2025.05.01 + * + * @param AddressInterface $value + * + * @return self + */ + public function setFrom(AddressInterface $value): static; + + /** + * Sets the reply-to addresses + * + * @since 2025.05.01 + * + * @param AddressInterface ...$value + * + * @return self + */ + public function setReplyTo(AddressInterface ...$value): static; + + /** + * Sets the primary recipients (To) + * + * @since 2025.05.01 + * + * @param AddressInterface ...$value + * + * @return self + */ + public function setTo(AddressInterface ...$value): static; + + /** + * Sets the carbon copy recipients (CC) + * + * @since 2025.05.01 + * + * @param AddressInterface ...$value + * + * @return self + */ + public function setCc(AddressInterface ...$value): static; + + /** + * Sets the blind carbon copy recipients (BCC) + * + * @since 2025.05.01 + * + * @param AddressInterface ...$value + * + * @return self + */ + public function setBcc(AddressInterface ...$value): static; + + /** + * Sets the message ID this is replying to + * + * @since 2025.05.01 + * + * @param string|null $value + * + * @return self + */ + public function setInReplyTo(?string $value): static; + + /** + * Sets the references (message IDs in thread) + * + * @since 2025.05.01 + * + * @param string ...$value + * + * @return self + */ + public function setReferences(string ...$value): static; + + /** + * Sets the message subject + * + * @since 2025.05.01 + * + * @param string $value + * + * @return self + */ + public function setSubject(string $value): static; + + /** + * Sets the message snippet/preview + * + * @since 2025.05.01 + * + * @param string|null $value + * + * @return self + */ + public function setSnippet(?string $value): static; + + /** + * Sets the plain text body content + * + * @since 2025.05.01 + * + * @param string|null $value + * + * @return self + */ + public function setBodyText(?string $value): static; + + /** + * Sets the HTML body content + * + * @since 2025.05.01 + * + * @param string|null $value + * + * @return self + */ + public function setBodyHtml(?string $value): static; + + /** + * Sets the attachments + * + * @since 2025.05.01 + * + * @param AttachmentInterface ...$value + * + * @return self + */ + public function setAttachments(AttachmentInterface ...$value): static; + + /** + * Adds an attachment + * + * @since 2025.05.01 + * + * @param AttachmentInterface $value + * + * @return self + */ + public function addAttachment(AttachmentInterface $value): static; + /** + * Sets message tags + * + * @since 2025.05.01 + * + * @param array{read: bool, starred: bool, important: bool, answered: bool, forwarded: bool, draft: bool, deleted: bool, flagged: bool} $value + * + * @return self + */ + public function setFlag(string $label, bool $value): static; + +} diff --git a/shared/lib/Mail/Provider/ProviderBaseInterface.php b/shared/lib/Mail/Provider/ProviderBaseInterface.php new file mode 100644 index 0000000..42be2e3 --- /dev/null +++ b/shared/lib/Mail/Provider/ProviderBaseInterface.php @@ -0,0 +1,41 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Provider; + +use KTXF\Mail\Service\ServiceBaseInterface; +use KTXF\Resource\Provider\ResourceProviderBaseInterface; + +/** + * Mail Provider Base Interface + * + * Core interface for mail providers with context-aware service discovery. + * + * @since 2025.05.01 + */ +interface ProviderBaseInterface extends ResourceProviderBaseInterface{ + + public const JSON_TYPE = 'mail.provider'; + + /** + * Finds a service that handles a specific email address + * + * Searches within the appropriate scope based on userId context. + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier + * @param string $address Email address to find service for + * + * @return ServiceBaseInterface|null Service handling the address, or null + */ + public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?ServiceBaseInterface; + +} diff --git a/shared/lib/Mail/Provider/ProviderServiceDiscoverInterface.php b/shared/lib/Mail/Provider/ProviderServiceDiscoverInterface.php new file mode 100644 index 0000000..603f5ed --- /dev/null +++ b/shared/lib/Mail/Provider/ProviderServiceDiscoverInterface.php @@ -0,0 +1,52 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Provider; + +use KTXF\Resource\Provider\ResourceServiceLocationInterface; + +/** + * Mail Provider Autodiscovery Interface + * + * Optional interface for mail providers that support automatic service discovery + * from email addresses or domains. Providers implementing this interface can + * discover mail service configurations using various methods specific to their + * protocol or provider type. + * + * Examples: + * - IMAP/SMTP providers: Mozilla Autoconfig, DNS SRV, well-known URIs + * - JMAP providers: Well-known JMAP endpoint discovery + * - Provider-specific: Gmail, Outlook, etc. with known configurations + * + * @since 2025.05.01 + */ +interface ProviderServiceDiscoverInterface extends ProviderBaseInterface { + + /** + * Attempts to discover service configuration using provider-specific methods. + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier + * @param string $identity Identity to discover configuration for (e.g., email address) + * @param string|null $location Optional hostname to test directly (bypasses DNS lookup) + * @param string|null $secret Optional password/token to validate discovered service + * + * @return ResourceServiceLocationInterface|null Discovered location or null if not found + */ + public function serviceDiscover( + string $tenantId, + string $userId, + string $identity, + ?string $location = null, + ?string $secret = null + ): ResourceServiceLocationInterface|null; + +} diff --git a/shared/lib/Mail/Provider/ProviderServiceMutateInterface.php b/shared/lib/Mail/Provider/ProviderServiceMutateInterface.php new file mode 100644 index 0000000..7bf5e92 --- /dev/null +++ b/shared/lib/Mail/Provider/ProviderServiceMutateInterface.php @@ -0,0 +1,35 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Provider; + +use KTXF\Resource\Provider\ResourceProviderServiceMutateInterface; + +/** + * Mail Provider Service Mutate Interface + * + * Optional interface for providers that support service CRUD operations. + * + * Implementations return ServiceMutableInterface instances (which extend ResourceServiceMutateInterface). + * + * @since 2025.05.01 + * + * @method ServiceMutableInterface serviceFresh() Construct a new blank mail service instance + * @method string serviceCreate(string $tenantId, ?string $userId, ServiceMutableInterface $service) Create a mail service configuration + * @method string serviceModify(string $tenantId, ?string $userId, ServiceMutableInterface $service) Modify a mail service configuration + * @method bool serviceDestroy(string $tenantId, ?string $userId, ServiceMutableInterface $service) Delete a mail service configuration + */ +interface ProviderServiceMutateInterface extends ProviderBaseInterface, ResourceProviderServiceMutateInterface { + + public const JSON_TYPE = ProviderBaseInterface::JSON_TYPE; + + // Methods inherited from ResourceProviderServiceMutateInterface + // Implementations should return/accept ServiceMutableInterface instances + +} diff --git a/shared/lib/Mail/Provider/ProviderServiceTestInterface.php b/shared/lib/Mail/Provider/ProviderServiceTestInterface.php new file mode 100644 index 0000000..82196b9 --- /dev/null +++ b/shared/lib/Mail/Provider/ProviderServiceTestInterface.php @@ -0,0 +1,77 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Provider; + +use KTXF\Mail\Service\ServiceBaseInterface; + +/** + * Mail Provider Service Test Interface + * + * Optional interface for mail providers that support testing service connections. + * Providers implementing this interface can validate connection parameters, + * test authentication, and verify service availability before creating a + * persistent service configuration. + * + * Supports two testing modes: + * 1. Testing an existing service (validate current configuration) + * 2. Testing a fresh configuration (validate before saving) + * + * @since 2025.05.01 + */ +interface ProviderServiceTestInterface extends ProviderBaseInterface { + + /** + * Test a service connection + * + * Tests connectivity, authentication, and capabilities of a service. + * + * For new services: use serviceFresh() to create a service, configure it with + * setters, then pass it to this method for testing before persisting. + * + * For existing services: fetch the service and pass it directly. + * + * @since 2025.05.01 + * + * @param ServiceBaseInterface $service Service to test (can be fresh/unsaved or existing) + * @param array $options Provider-specific test options: + * - 'timeout' => int (seconds, default: 10) + * - 'verify_ssl' => bool (default: true) + * - 'test_send' => bool (attempt test send if capable, default: false) + * - 'test_receive' => bool (attempt mailbox access if capable, default: true) + * + * @return array Test results in the format: + * [ + * 'success' => bool, + * 'message' => 'Connection successful' | 'Error message', + * 'details' => [ + * 'connected' => bool, // Socket/HTTP connection succeeded + * 'authenticated' => bool, // Authentication succeeded + * 'capabilities' => ['IMAP4rev1', ...], // Server capabilities (if applicable) + * 'serverInfo' => 'Server version/banner', + * 'latency' => 123, // Connection time in milliseconds + * 'protocols' => [ + * 'inbound' => [ + * 'connected' => bool, + * 'authenticated' => bool, + * 'error' => 'error message if failed' + * ], + * 'outbound' => [ // For split-socket (IMAP+SMTP) + * 'connected' => bool, + * 'authenticated' => bool, + * 'error' => 'error message if failed' + * ] + * ], + * 'errors' => ['Error 1', 'Error 2'], // List of errors encountered + * ] + * ] + */ + public function serviceTest(ServiceBaseInterface $service, array $options = []): array; + +} diff --git a/shared/lib/Mail/Queue/SendOptions.php b/shared/lib/Mail/Queue/SendOptions.php new file mode 100644 index 0000000..d346687 --- /dev/null +++ b/shared/lib/Mail/Queue/SendOptions.php @@ -0,0 +1,81 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Queue; + +use JsonSerializable; + +/** + * Mail Send Options + * + * Configuration options for message delivery behavior. + * + * @since 2025.05.01 + */ +class SendOptions implements JsonSerializable { + + /** + * @param bool $immediate Send immediately bypassing queue (for 2FA, etc.) + * @param int $priority Queue priority (-100 to 100, higher = sooner) + * @param int $retryCount Maximum retry attempts on failure + * @param int|null $delaySeconds Delay before first send attempt + */ + public function __construct( + public readonly bool $immediate = false, + public readonly int $priority = 0, + public readonly int $retryCount = 3, + public readonly ?int $delaySeconds = null, + ) {} + + /** + * Creates options for immediate delivery (bypasses queue) + * + * @since 2025.05.01 + * + * @return self + */ + public static function immediate(): self { + return new self(immediate: true); + } + + /** + * Creates options for high-priority queued delivery + * + * @since 2025.05.01 + * + * @return self + */ + public static function highPriority(): self { + return new self(priority: 50); + } + + /** + * Creates options for low-priority queued delivery (bulk mail) + * + * @since 2025.05.01 + * + * @return self + */ + public static function lowPriority(): self { + return new self(priority: -50); + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + return [ + 'immediate' => $this->immediate, + 'priority' => $this->priority, + 'retryCount' => $this->retryCount, + 'delaySeconds' => $this->delaySeconds, + ]; + } + +} diff --git a/shared/lib/Mail/Service/ServiceBaseInterface.php b/shared/lib/Mail/Service/ServiceBaseInterface.php new file mode 100644 index 0000000..4de6e5c --- /dev/null +++ b/shared/lib/Mail/Service/ServiceBaseInterface.php @@ -0,0 +1,237 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Service; + +use KTXF\Mail\Collection\CollectionBaseInterface; +use KTXF\Mail\Object\AddressInterface; +use KTXF\Resource\Delta\Delta; +use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Provider\ResourceServiceBaseInterface; +use KTXF\Resource\Range\IRange; +use KTXF\Resource\Range\RangeType; +use KTXF\Resource\Sort\ISort; + +/** + * Mail Service Base Interface + * + * Core interface for mail services with full protocol support (IMAP, JMAP, EWS, ActiveSync, Gmail API, etc.) + * Provides identity, addressing, capability information, and collection/message operations. + * + * @since 2025.05.01 + */ +interface ServiceBaseInterface extends ResourceServiceBaseInterface { + + // Collection capabilities + public const CAPABILITY_COLLECTION_LIST = 'CollectionList'; + public const CAPABILITY_COLLECTION_LIST_FILTER = 'CollectionListFilter'; + public const CAPABILITY_COLLECTION_LIST_SORT = 'CollectionListSort'; + public const CAPABILITY_COLLECTION_EXTANT = 'CollectionExtant'; + public const CAPABILITY_COLLECTION_FETCH = 'CollectionFetch'; + // Collection Filter + public const CAPABILITY_COLLECTION_FILTER_LABEL = 'label'; + public const CAPABILITY_COLLECTION_FILTER_ROLE = 'role'; + // Collection Sort + public const CAPABILITY_COLLECTION_SORT_LABEL = 'label'; + public const CAPABILITY_COLLECTION_SORT_RANK = 'rank'; + // Entity capabilities + public const CAPABILITY_ENTITY_LIST = 'EntityList'; + public const CAPABILITY_ENTITY_LIST_FILTER = 'EntityListFilter'; + public const CAPABILITY_ENTITY_LIST_SORT = 'EntityListSort'; + public const CAPABILITY_ENTITY_LIST_RANGE = 'EntityListRange'; + public const CAPABILITY_ENTITY_DELTA = 'EntityDelta'; + public const CAPABILITY_ENTITY_EXTANT = 'EntityExtant'; + public const CAPABILITY_ENTITY_FETCH = 'EntityFetch'; + // Filter capabilities + public const CAPABILITY_ENTITY_FILTER_ALL = '*'; + public const CAPABILITY_ENTITY_FILTER_FROM = 'from'; + public const CAPABILITY_ENTITY_FILTER_TO = 'to'; + public const CAPABILITY_ENTITY_FILTER_CC = 'cc'; + public const CAPABILITY_ENTITY_FILTER_BCC = 'bcc'; + public const CAPABILITY_ENTITY_FILTER_SUBJECT = 'subject'; + public const CAPABILITY_ENTITY_FILTER_BODY = 'body'; + public const CAPABILITY_ENTITY_FILTER_DATE_BEFORE = 'before'; + public const CAPABILITY_ENTITY_FILTER_DATE_AFTER = 'after'; + public const CAPABILITY_ENTITY_FILTER_SIZE_MIN = 'min'; + public const CAPABILITY_ENTITY_FILTER_SIZE_MAX = 'max'; + // Sort capabilities + public const CAPABILITY_ENTITY_SORT_FROM = 'from'; + public const CAPABILITY_ENTITY_SORT_TO = 'to'; + public const CAPABILITY_ENTITY_SORT_SUBJECT = 'subject'; + public const CAPABILITY_ENTITY_SORT_DATE_RECEIVED = 'received'; + public const CAPABILITY_ENTITY_SORT_DATE_SENT = 'sent'; + public const CAPABILITY_ENTITY_SORT_SIZE = 'size'; + + public const JSON_TYPE = 'mail.service'; + public const JSON_PROPERTY_PRIMARY_ADDRESS = 'primaryAddress'; + public const JSON_PROPERTY_SECONDARY_ADDRESSES = 'secondaryAddresses'; + + /** + * Gets the primary mailing address for this service + * + * @since 2025.05.01 + * + * @return AddressInterface + */ + public function getPrimaryAddress(): AddressInterface; + + /** + * Gets the secondary mailing addresses (aliases) for this service + * + * @since 2025.05.01 + * + * @return array + */ + public function getSecondaryAddresses(): array; + + /** + * Checks if this service handles a specific email address + * + * @since 2025.05.01 + * + * @param string $address Email address to check + * + * @return bool True if address matches primary or any secondary address + */ + public function hasAddress(string $address): bool; + + /** + * Lists all collections in this service + * + * @since 2025.05.01 + * + * @param IFilter|null $filter Optional filter criteria + * @param ISort|null $sort Optional sort order + * + * @return array Collections indexed by ID + */ + public function collectionList(string|int $location, ?IFilter $filter = null, ?ISort $sort = null): array; + + /** + * Creates a filter builder for collections + * + * @since 2025.05.01 + * + * @return IFilter + */ + public function collectionListFilter(): IFilter; + + /** + * Creates a sort builder for collections + * + * @since 2025.05.01 + * + * @return ISort + */ + public function collectionListSort(): ISort; + + /** + * Checks if collections exist + * + * @since 2025.05.01 + * + * @param string|int ...$identifiers Collection IDs to check + * + * @return array Map of ID => exists + */ + public function collectionExtant(string|int $location, string|int ...$identifiers): array; + + /** + * Fetches a single collection + * + * @since 2025.05.01 + * + * @param string|int $identifier Collection ID + * + * @return CollectionBaseInterface|null Collection or null if not found + */ + public function collectionFetch(string|int $identifier): ?CollectionBaseInterface; + + /** + * Lists messages in a collection + * + * @since 2025.05.01 + * + * @param string|int $collection Collection ID + * @param IFilter|null $filter Optional filter criteria + * @param ISort|null $sort Optional sort order + * @param IRange|null $range Optional pagination + * @param array|null $properties Optional message properties to fetch + * + * @return array Messages indexed by ID + */ + public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array; + + /** + * Creates a filter builder for messages + * + * @since 2025.05.01 + * + * @return IFilter + */ + public function entityListFilter(): IFilter; + + /** + * Creates a sort builder for messages + * + * @since 2025.05.01 + * + * @return ISort + */ + public function entityListSort(): ISort; + + /** + * Creates a range builder for messages + * + * @since 2025.05.01 + * + * @param RangeType $type Range type (offset, cursor, etc.) + * + * @return IRange + */ + public function entityListRange(RangeType $type): IRange; + + /** + * Gets incremental changes since last sync + * + * @since 2025.05.01 + * + * @param string|int $collection Collection ID + * @param string $signature Sync token from previous sync + * @param string $detail Detail level: 'ids', 'minimal', 'full' + * + * @return array ['signature' => string, 'added' => array, 'modified' => array, 'removed' => array] + */ + public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta; + + /** + * Checks if messages exist + * + * @since 2025.05.01 + * + * @param string|int $collection Collection ID + * @param string|int ...$identifiers Message IDs to check + * + * @return array Map of ID => exists + */ + public function entityExtant(string|int $collection, string|int ...$identifiers): array; + + /** + * Fetches one or more entities + * + * @since 2025.05.01 + * + * @param string|int $collection Collection ID + * @param string|int ...$identifiers Message IDs to fetch + * + * @return array Messages indexed by ID + */ + public function entityFetch(string|int $collection, string|int ...$identifiers): array; + +} diff --git a/shared/lib/Mail/Service/ServiceCollectionMutableInterface.php b/shared/lib/Mail/Service/ServiceCollectionMutableInterface.php new file mode 100644 index 0000000..99da45f --- /dev/null +++ b/shared/lib/Mail/Service/ServiceCollectionMutableInterface.php @@ -0,0 +1,89 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Service; + +use KTXF\Mail\Collection\CollectionBaseInterface; +use KTXF\Mail\Collection\CollectionMutableInterface; + +/** + * Mail Service Collection Mutable Interface + * + * Optional interface for services that support collection CRUD operations. + * Provides mailbox/folder creation, modification, deletion, and moving. + * + * @since 2025.05.01 + */ +interface ServiceCollectionMutableInterface extends ServiceBaseInterface { + + public const CAPABILITY_COLLECTION_CREATE = 'CollectionCreate'; + public const CAPABILITY_COLLECTION_MODIFY = 'CollectionModify'; + public const CAPABILITY_COLLECTION_DESTROY = 'CollectionDestroy'; + public const CAPABILITY_COLLECTION_MOVE = 'CollectionMove'; + + /** + * Creates a fresh collection instance for configuration + * + * @since 2025.05.01 + * + * @return CollectionMutableInterface Fresh collection object + */ + public function collectionFresh(): CollectionMutableInterface; + + /** + * Creates a new collection + * + * @since 2025.05.01 + * + * @param string|int|null $location Parent collection ID (null for root) + * @param CollectionMutableInterface $collection Collection to create + * @param array $options Protocol-specific options + * + * @return CollectionBaseInterface Created collection with assigned ID + */ + public function collectionCreate(string|int|null $location, CollectionMutableInterface $collection, array $options = []): CollectionBaseInterface; + + /** + * Modifies an existing collection + * + * @since 2025.05.01 + * + * @param string|int $identifier Collection ID + * @param CollectionMutableInterface $collection Updated collection data + * + * @return CollectionBaseInterface Modified collection + */ + public function collectionModify(string|int $identifier, CollectionMutableInterface $collection): CollectionBaseInterface; + + /** + * Destroys a collection + * + * @since 2025.05.01 + * + * @param string|int $identifier Collection ID + * @param bool $force Force destruction even if not empty + * @param bool $recursive Recursively destroy contents + * + * @return bool True if destroyed + */ + public function collectionDestroy(string|int $identifier, bool $force = false, bool $recursive = false): bool; + + /** + * Moves a collection to a new parent + * + * @since 2025.05.01 + * + * @param string|int $identifier Collection ID + * @param string|int|null $targetLocation New parent ID (null for root) + * + * @return CollectionBaseInterface Moved collection + */ + public function collectionMove(string|int $identifier, string|int|null $targetLocation): CollectionBaseInterface; + +} diff --git a/shared/lib/Mail/Service/ServiceConfigurableInterface.php b/shared/lib/Mail/Service/ServiceConfigurableInterface.php new file mode 100644 index 0000000..1c842af --- /dev/null +++ b/shared/lib/Mail/Service/ServiceConfigurableInterface.php @@ -0,0 +1,26 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Service; + +use KTXF\Resource\Provider\ResourceServiceConfigureInterface; + +/** + * Mail Service Mutable Interface + * + * Extends base service interface with setter methods for mutable properties. + * Used for service configuration and updates. + * + * @since 2025.05.01 + */ +interface ServiceConfigurableInterface extends ServiceMutableInterface, ResourceServiceConfigureInterface { + + public const JSON_TYPE = ServiceBaseInterface::JSON_TYPE; + +} diff --git a/shared/lib/Mail/Service/ServiceEntityMutableInterface.php b/shared/lib/Mail/Service/ServiceEntityMutableInterface.php new file mode 100644 index 0000000..9736cd0 --- /dev/null +++ b/shared/lib/Mail/Service/ServiceEntityMutableInterface.php @@ -0,0 +1,103 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Service; + +use KTXF\Mail\Entity\EntityBaseInterface; +use KTXF\Mail\Entity\EntityMutableInterface; + +/** + * Mail Service Entity Mutable Interface + * + * Optional interface for services that support entity CRUD operations. + * Provides entity creation, modification, deletion, copying, moving, and flag management. + * + * @since 2025.05.01 + */ +interface ServiceEntityMutableInterface extends ServiceBaseInterface { + + public const CAPABILITY_ENTITY_CREATE = 'EntityCreate'; + public const CAPABILITY_ENTITY_MODIFY = 'EntityModify'; + public const CAPABILITY_ENTITY_DESTROY = 'EntityDestroy'; + public const CAPABILITY_ENTITY_COPY = 'EntityCopy'; + public const CAPABILITY_ENTITY_MOVE = 'EntityMove'; + + /** + * Creates a fresh entity instance for composition + * + * @since 2025.05.01 + * + * @return EntityMutableInterface Fresh entity object + */ + public function entityFresh(): EntityMutableInterface; + + /** + * Creates/imports an entity into a collection + * + * @since 2025.05.01 + * + * @param string|int $collection collection identifier + * @param EntityMutableInterface $entity Entity data + * @param array $options additional options + * + * @return EntityBaseInterface Created entity + */ + public function entityCreate(string|int $collection, EntityMutableInterface $entity, array $options = []): EntityBaseInterface; + + /** + * Modifies an existing entity + * + * @since 2025.05.01 + * + * @param string|int $collection Collection identifier + * @param string|int $identifier Entity identifier + * @param EntityMutableInterface $entity Entity data + * + * @return EntityBaseInterface Modified entity + */ + public function entityModify(string|int $collection, string|int $identifier, EntityMutableInterface $entity): EntityBaseInterface; + /** + * Destroys one or more entities + * + * @since 2025.05.01 + * + * @param string|int $collection Collection identifier + * @param string|int ...$identifiers Entity identifiers to destroy + * + * @return array List of destroyed entity identifiers + */ + public function entityDestroy(string|int $collection, string|int ...$identifiers): array; + + /** + * Copies entities to another collection + * + * @since 2025.05.01 + * + * @param string|int $sourceCollection Source collection identifier + * @param string|int $targetCollection Target collection identifier + * @param string|int ...$identifiers Entity identifiers to copy + * + * @return array Map of source identifier => new identifier + */ + public function entityCopy(string|int $sourceCollection, string|int $targetCollection, string|int ...$identifiers): array; + + /** + * Moves entities to another collection + * + * @since 2025.05.01 + * + * @param string|int $sourceCollection Source collection identifier + * @param string|int $targetCollection Target collection identifier + * @param string|int ...$identifiers Entity identifiers to move + * + * @return array List of moved entity identifiers + */ + public function entityMove(string|int $sourceCollection, string|int $targetCollection, string|int ...$identifiers): array; + +} diff --git a/shared/lib/Mail/Service/ServiceEntityTransmitInterface.php b/shared/lib/Mail/Service/ServiceEntityTransmitInterface.php new file mode 100644 index 0000000..cd268d1 --- /dev/null +++ b/shared/lib/Mail/Service/ServiceEntityTransmitInterface.php @@ -0,0 +1,48 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Service; + +use KTXF\Mail\Entity\EntityMutableInterface; +use KTXF\Mail\Exception\SendException; + +/** + * Mail Service Transmit Interface + * + * Interface for mail services capable of transmitting outbound entities. + * + * @since 2025.05.01 + */ +interface ServiceEntityTransmitInterface extends ServiceBaseInterface { + + public const CAPABILITY_ENTITY_TRANSMIT = 'EntityTransmit'; + + /** + * Creates a fresh entity instance for composition + * + * @since 2025.05.01 + * + * @return EntityMutableInterface Fresh entity object + */ + public function entityFresh(): EntityMutableInterface; + + /** + * Transmits an outbound entity + * + * @since 2025.05.01 + * + * @param EntityMutableInterface $entity Entity to transmit + * + * @return string Entity identifier assigned by the transport + * + * @throws SendException On delivery failure + */ + public function entitySend(EntityMutableInterface $entity): string; + +} diff --git a/shared/lib/Mail/Service/ServiceMutableInterface.php b/shared/lib/Mail/Service/ServiceMutableInterface.php new file mode 100644 index 0000000..5a539c7 --- /dev/null +++ b/shared/lib/Mail/Service/ServiceMutableInterface.php @@ -0,0 +1,46 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Service; + +use KTXF\Mail\Object\AddressInterface; + +/** + * Mail Service Mutable Interface + * + * Extends base service interface with setter methods for mutable properties. + * Used for service configuration and updates. + * + * @since 2025.05.01 + */ +interface ServiceMutableInterface extends ServiceBaseInterface { + + /** + * Sets the primary mailing address for this service + * + * @since 2025.05.01 + * + * @param AddressInterface $value Primary email address + * + * @return static + */ + public function setPrimaryAddress(AddressInterface $value): static; + + /** + * Sets the secondary mailing addresses (aliases) for this service + * + * @since 2025.05.01 + * + * @param array $value Array of secondary addresses + * + * @return static + */ + public function setSecondaryAddresses(array $value): static; + +} diff --git a/shared/lib/Module/ModuleBrowserInterface.php b/shared/lib/Module/ModuleBrowserInterface.php new file mode 100644 index 0000000..f8503f7 --- /dev/null +++ b/shared/lib/Module/ModuleBrowserInterface.php @@ -0,0 +1,13 @@ + [ + * 'label' => 'View Users', + * 'description' => 'View user list and details', + * 'group' => 'User Management' + * ], + * 'user_manager.users.*' => [ + * 'label' => 'Full User Management', + * 'description' => 'All user management permissions', + * 'group' => 'User Management' + * ] + * ]; + */ + public function permissions(): array + { + return []; + } + +} diff --git a/shared/lib/Module/ModuleInstanceInterface.php b/shared/lib/Module/ModuleInstanceInterface.php new file mode 100644 index 0000000..4235538 --- /dev/null +++ b/shared/lib/Module/ModuleInstanceInterface.php @@ -0,0 +1,61 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Collection; + +use JsonSerializable; + +enum CollectionContent: string implements JsonSerializable { + + case Individual = 'individual'; + case Organization = 'organization'; + case Group = 'group'; + + public function jsonSerialize(): string { + return $this->value; + } + +} \ No newline at end of file diff --git a/shared/lib/People/Collection/CollectionPermissions.php b/shared/lib/People/Collection/CollectionPermissions.php new file mode 100644 index 0000000..3136ca7 --- /dev/null +++ b/shared/lib/People/Collection/CollectionPermissions.php @@ -0,0 +1,26 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Collection; + +use JsonSerializable; + +enum CollectionPermissions: string implements JsonSerializable { + + case View = 'view'; + case Create = 'create'; + case Modify = 'modify'; + case Destroy = 'destroy'; + case Share = 'share'; + + public function jsonSerialize(): string { + return $this->value; + } + +} \ No newline at end of file diff --git a/shared/lib/People/Collection/CollectionRoles.php b/shared/lib/People/Collection/CollectionRoles.php new file mode 100644 index 0000000..7a6bbbb --- /dev/null +++ b/shared/lib/People/Collection/CollectionRoles.php @@ -0,0 +1,24 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Collection; + +use JsonSerializable; + +enum CollectionRoles: string implements JsonSerializable { + + case System = 'system'; + case Individual = 'individual'; + case Recent = 'recent'; + + public function jsonSerialize(): string { + return $this->value; + } + +} \ No newline at end of file diff --git a/shared/lib/People/Collection/ICollectionBase.php b/shared/lib/People/Collection/ICollectionBase.php new file mode 100644 index 0000000..cc1617e --- /dev/null +++ b/shared/lib/People/Collection/ICollectionBase.php @@ -0,0 +1,160 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Collection; + +use DateTimeImmutable; +use JsonSerializable; + +interface ICollectionBase extends JsonSerializable { + + public const JSON_TYPE = 'people.collection'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_PROVIDER = 'provider'; + public const JSON_PROPERTY_SERVICE = 'service'; + public const JSON_PROPERTY_IN = 'in'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_DESCRIPTION = 'description'; + public const JSON_PROPERTY_PRIORITY = 'priority'; + public const JSON_PROPERTY_VISIBILITY = 'visibility'; + public const JSON_PROPERTY_COLOR = 'color'; + public const JSON_PROPERTY_CREATED = 'created'; + public const JSON_PROPERTY_MODIFIED = 'modified'; + public const JSON_PROPERTY_ENABLED = 'enabled'; + public const JSON_PROPERTY_SIGNATURE = 'signature'; + public const JSON_PROPERTY_PERMISSIONS = 'permissions'; + public const JSON_PROPERTY_ROLES = 'roles'; + public const JSON_PROPERTY_CONTENTS = 'contents'; + + /** + * Unique identifier of the service this collection belongs to + * + * @since 2025.05.01 + */ + public function in(): string|int|null; + + /** + * Unique arbitrary text string identifying this service (e.g. 1 or collection1 or anything else) + * + * @since 2025.05.01 + */ + public function id(): string|int; + + /** + * Gets the creation date of this collection + */ + public function created(): ?DateTimeImmutable; + + /** + * Gets the modification date of this collection + */ + public function modified(): ?DateTimeImmutable; + + /** + * Lists all supported attributes + * + * @since 2025.05.01 + * + * @return array> + */ + public function attributes(): array; + + /** + * Gets the signature of this collection + * + * @since 2025.05.01 + */ + public function signature(): ?string; + + /** + * Gets the role(s) of this collection + * + * @since 2025.05.01 + */ + public function roles(): array; + + /** + * Checks if this collection supports the given role + * + * @since 2025.05.01 + */ + public function role(CollectionRoles $value): bool; + + /** + * Gets the content types of this collection + * + * @since 2025.05.01 + */ + public function contents(): array; + + /** + * Checks if this collection contains the given content type + * + * @since 2025.05.01 + */ + public function contains(CollectionContent $value): bool; + + /** + * Gets the active status of this collection + * + * @since 2025.05.01 + */ + public function getEnabled(): bool; + + /** + * Gets the active status of this collection + * + * @since 2025.05.01 + */ + public function getPermissions(): array; + + /** + * Checks if this collection has the given permission + * + * @since 2025.05.01 + */ + public function hasPermission(CollectionPermissions $permission): bool; + + /** + * Gets the human friendly name of this collection (e.g. Personal Contacts) + * + * @since 2025.05.01 + */ + public function getLabel(): ?string; + + /** + * Gets the human friendly description of this collection + * + * @since 2025.05.01 + */ + public function getDescription(): ?string; + + /** + * Gets the priority of this collection + * + * @since 2025.05.01 + */ + public function getPriority(): ?int; + + /** + * Gets the visibility of this collection + * + * @since 2025.05.01 + */ + public function getVisibility(): ?bool; + + /** + * Gets the color of this collection + * + * @since 2025.05.01 + */ + public function getColor(): ?string; + +} diff --git a/shared/lib/People/Collection/ICollectionMutable.php b/shared/lib/People/Collection/ICollectionMutable.php new file mode 100644 index 0000000..610a48b --- /dev/null +++ b/shared/lib/People/Collection/ICollectionMutable.php @@ -0,0 +1,58 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Collection; + +use KTXF\Json\JsonDeserializable; + +interface ICollectionMutable extends ICollectionBase, JsonDeserializable { + + /** + * Sets the active status of this collection + * + * @since 2025.05.01 + */ + public function setEnabled(bool $value): self; + + /** + * Sets the human friendly name of this collection (e.g. Personal Contacts) + * + * @since 2025.05.01 + */ + public function setLabel(string $value): self; + + /** + * Sets the human friendly description of this collection + * + * @since 2025.05.01 + */ + public function setDescription(?string $value): self; + + /** + * Sets the priority of this collection + * + * @since 2025.05.01 + */ + public function setPriority(?int $value): self; + + /** + * Sets the visibility of this collection + * + * @since 2025.05.01 + */ + public function setVisibility(?bool $value): self; + + /** + * Sets the color of this collection + * + * @since 2025.05.01 + */ + public function setColor(?string $value): self; + +} diff --git a/shared/lib/People/Entity/EntityPermissions.php b/shared/lib/People/Entity/EntityPermissions.php new file mode 100644 index 0000000..71a20a1 --- /dev/null +++ b/shared/lib/People/Entity/EntityPermissions.php @@ -0,0 +1,25 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity; + +use JsonSerializable; + +enum EntityPermissions: string implements JsonSerializable { + + case View = 'view'; + case Modify = 'modify'; + case Delete = 'delete'; + case Share = 'share'; + + public function jsonSerialize(): string { + return $this->value; + } + +} \ No newline at end of file diff --git a/shared/lib/People/Entity/IEntityBase.php b/shared/lib/People/Entity/IEntityBase.php new file mode 100644 index 0000000..88bdf47 --- /dev/null +++ b/shared/lib/People/Entity/IEntityBase.php @@ -0,0 +1,94 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity; + +use DateTimeImmutable; +use KTXF\People\Entity\Individual\IndividualObject; + +interface IEntityBase extends \JsonSerializable { + + public const JSON_TYPE = 'people.entity'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_IN = 'in'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_DATA = 'data'; + public const JSON_PROPERTY_CREATED = 'created'; + public const JSON_PROPERTY_MODIFIED = 'modified'; + public const JSON_PROPERTY_SIGNATURE = 'signature'; + + /** + * Unique arbitrary text string identifying the collection this entity belongs to (e.g. 1 or Collection1 or anything else) + * + * @since 2025.05.01 + */ + public function in(): string|int; + + /** + * Unique arbitrary text string identifying this service (e.g. 1 or Entity or anything else) + * + * @since 2025.05.01 + */ + public function id(): string|int; + + /** + * Gets the creation date of this entity + */ + public function created(): ?DateTimeImmutable; + + /** + * Gets the modification date of this entity + */ + public function modified(): ?DateTimeImmutable; + + /** + * Gets the signature of this entity + * + * @since 2025.05.01 + */ + public function signature(): ?string; + + /** + * Gets the priority of this entity + * + * @since 2025.05.01 + */ + public function getPriority(): ?int; + + /** + * Gets the visibility of this entity + * + * @since 2025.05.01 + */ + public function getVisibility(): ?bool; + + /** + * Gets the color of this entity + * + * @since 2025.05.01 + */ + public function getColor(): ?string; + + /** + * Gets the object as a class instance. + * + * @since 2025.05.01 + */ + public function getDataObject(): IndividualObject|null; + + /** + * Gets the raw data as an associative array or JSON string. + * + * @since 2025.05.01 + * + * @return array|string|null + */ + public function getDataJson(): array|string|null; + +} diff --git a/shared/lib/People/Entity/IEntityMutable.php b/shared/lib/People/Entity/IEntityMutable.php new file mode 100644 index 0000000..bd30af5 --- /dev/null +++ b/shared/lib/People/Entity/IEntityMutable.php @@ -0,0 +1,52 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity; + +use KTXF\Json\JsonDeserializable; +use KTXF\People\Entity\Individual\IndividualObject; + +interface IEntityMutable extends IEntityBase, JsonDeserializable { + + /** + * Sets the priority of this entity + * + * @since 2025.05.01 + */ + public function setPriority(?int $value): static; + + /** + * Sets the visibility of this entity + * + * @since 2025.05.01 + */ + public function setVisibility(?bool $value): static; + + /** + * Sets the color of this entity + * + * @since 2025.05.01 + */ + public function setColor(?string $value): static; + + /** + * Sets the object as a class instance. + * + * @since 2025.05.01 + */ + public function setDataObject(IndividualObject $value): static; + + /** + * Sets the object data from a json string + * + * @since 2025.05.01 + */ + public function setDataJson(array|string $value): static; + +} diff --git a/shared/lib/People/Individual/IndividualAliasCollection.php b/shared/lib/People/Individual/IndividualAliasCollection.php new file mode 100644 index 0000000..cdc0497 --- /dev/null +++ b/shared/lib/People/Individual/IndividualAliasCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualAliasCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualAliasObject::class); + } + +} diff --git a/shared/lib/People/Individual/IndividualAliasObject.php b/shared/lib/People/Individual/IndividualAliasObject.php new file mode 100644 index 0000000..565808f --- /dev/null +++ b/shared/lib/People/Individual/IndividualAliasObject.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualAliasObject extends JsonSerializableObject { + + public string|null $label = null; + public string|null $context = null; + public int|null $priority = null; + +} diff --git a/shared/lib/People/Individual/IndividualAnniversaryCollection.php b/shared/lib/People/Individual/IndividualAnniversaryCollection.php new file mode 100644 index 0000000..b6a744d --- /dev/null +++ b/shared/lib/People/Individual/IndividualAnniversaryCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualAnniversaryCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualAnniversaryObject::class); + } + +} diff --git a/shared/lib/People/Individual/IndividualAnniversaryObject.php b/shared/lib/People/Individual/IndividualAnniversaryObject.php new file mode 100644 index 0000000..62d7050 --- /dev/null +++ b/shared/lib/People/Individual/IndividualAnniversaryObject.php @@ -0,0 +1,21 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use DateTimeInterface; +use KTXF\Json\JsonSerializableObject; + +class IndividualAnniversaryObject extends JsonSerializableObject { + + public IndividualAnniversaryTypes|null $type = null; + public DateTimeInterface|null $when = null; + public string|null $location = null; + +} diff --git a/shared/lib/People/Individual/IndividualAnniversaryTypes.php b/shared/lib/People/Individual/IndividualAnniversaryTypes.php new file mode 100644 index 0000000..96598dd --- /dev/null +++ b/shared/lib/People/Individual/IndividualAnniversaryTypes.php @@ -0,0 +1,16 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +enum IndividualAnniversaryTypes: string { + case Birth = 'birth'; + case Death = 'death'; + case Nuptial = 'nuptial'; +} diff --git a/shared/lib/People/Individual/IndividualCryptoCollection.php b/shared/lib/People/Individual/IndividualCryptoCollection.php new file mode 100644 index 0000000..8bea37d --- /dev/null +++ b/shared/lib/People/Individual/IndividualCryptoCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualCryptoCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualCryptoObject::class, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualCryptoObject.php b/shared/lib/People/Individual/IndividualCryptoObject.php new file mode 100644 index 0000000..250b4e4 --- /dev/null +++ b/shared/lib/People/Individual/IndividualCryptoObject.php @@ -0,0 +1,21 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualCryptoObject extends JsonSerializableObject { + + public string|null $data = null; + public string|null $type = null; + public string|null $context = null; + public int|null $priority = null; + +} diff --git a/shared/lib/People/Individual/IndividualEmailCollection.php b/shared/lib/People/Individual/IndividualEmailCollection.php new file mode 100644 index 0000000..1ee18d1 --- /dev/null +++ b/shared/lib/People/Individual/IndividualEmailCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualEmailCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualEmailObject::class, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualEmailObject.php b/shared/lib/People/Individual/IndividualEmailObject.php new file mode 100644 index 0000000..e9b9226 --- /dev/null +++ b/shared/lib/People/Individual/IndividualEmailObject.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualEmailObject extends JsonSerializableObject { + + public string|null $address = null; + public string|null $context = null; + public int|null $priority = null; + +} diff --git a/shared/lib/People/Individual/IndividualLanguageCollection.php b/shared/lib/People/Individual/IndividualLanguageCollection.php new file mode 100644 index 0000000..ca596ab --- /dev/null +++ b/shared/lib/People/Individual/IndividualLanguageCollection.php @@ -0,0 +1,18 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualLanguageCollection extends JsonSerializableCollection { + public function __construct(array $data = []) { + parent::__construct($data, IndividualLanguageObject::class); + } +} diff --git a/shared/lib/People/Individual/IndividualLanguageObject.php b/shared/lib/People/Individual/IndividualLanguageObject.php new file mode 100644 index 0000000..a1fbc93 --- /dev/null +++ b/shared/lib/People/Individual/IndividualLanguageObject.php @@ -0,0 +1,22 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualLanguageObject extends JsonSerializableObject { + + public string|null $Data = null; + + public string|null $Id = null; + public int|null $Priority = null; + public string|null $Context = null; + +} diff --git a/shared/lib/People/Individual/IndividualMediaCollection.php b/shared/lib/People/Individual/IndividualMediaCollection.php new file mode 100644 index 0000000..ca49666 --- /dev/null +++ b/shared/lib/People/Individual/IndividualMediaCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualMediaCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualMediaObject::class, 'string'); + } + +} \ No newline at end of file diff --git a/shared/lib/People/Individual/IndividualMediaObject.php b/shared/lib/People/Individual/IndividualMediaObject.php new file mode 100644 index 0000000..0f226dd --- /dev/null +++ b/shared/lib/People/Individual/IndividualMediaObject.php @@ -0,0 +1,24 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualMediaObject extends JsonSerializableObject { + + public string $type = 'Media'; + public string $kind; + public string $uri; + public string|null $mediaType = null; + public array|null $contexts = null; + public int|null $pref = null; + public string|null $label = null; + +} \ No newline at end of file diff --git a/shared/lib/People/Individual/IndividualNameObject.php b/shared/lib/People/Individual/IndividualNameObject.php new file mode 100644 index 0000000..d134abe --- /dev/null +++ b/shared/lib/People/Individual/IndividualNameObject.php @@ -0,0 +1,30 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualNameObject extends JsonSerializableObject { + + public string|null $family = null; + public string|null $given = null; + public string|null $additional = null; + public string|null $prefix = null; + public string|null $suffix = null; + public string|null $phoneticFamily = null; + public string|null $phoneticGiven = null; + public string|null $phoneticAdditional = null; + public IndividualAliasCollection $aliases; + + public function __construct() { + $this->aliases = new IndividualAliasCollection(); + } + +} diff --git a/shared/lib/People/Individual/IndividualNoteCollection.php b/shared/lib/People/Individual/IndividualNoteCollection.php new file mode 100644 index 0000000..2138aa9 --- /dev/null +++ b/shared/lib/People/Individual/IndividualNoteCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualNoteCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualNoteObject::class, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualNoteObject.php b/shared/lib/People/Individual/IndividualNoteObject.php new file mode 100644 index 0000000..efd9c46 --- /dev/null +++ b/shared/lib/People/Individual/IndividualNoteObject.php @@ -0,0 +1,25 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use DateTimeInterface; +use KTXF\Json\JsonSerializableObject; + +class IndividualNoteObject extends JsonSerializableObject { + + public string|null $content = null; + public DateTimeInterface|null $date = null; + public string|null $authorUri = null; + public string|null $authorName = null; + + public string|null $context = null; + public int|null $priority = null; + +} diff --git a/shared/lib/People/Individual/IndividualObject.php b/shared/lib/People/Individual/IndividualObject.php new file mode 100644 index 0000000..55b5501 --- /dev/null +++ b/shared/lib/People/Individual/IndividualObject.php @@ -0,0 +1,62 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use DateTimeInterface; +use KTXF\Json\JsonSerializableObject; + +class IndividualObject extends JsonSerializableObject { + + // Meta Information + public string $type = 'individual'; + public int $version = 1; + public string|null $urid = null; + public ?DateTimeInterface $created = null; + public ?DateTimeInterface $modified = null; + // Personal Information + public string|null $label = null; + public IndividualNameObject $names; + public IndividualTitleCollection $titles; + public IndividualAnniversaryCollection $anniversaries; + // Location Information + public IndividualPhysicalLocationCollection $physicalLocations; + // Communication Information + public IndividualPhoneCollection $phones; + public IndividualEmailCollection $emails; + public IndividualVirtualLocationCollection $virtualLocations; + // Media Information + public IndividualMediaCollection $media; + // Organizations Information + public IndividualOrganizationCollection $organizations; + // Organizational Information + public IndividualTagCollection $tags; + public IndividualNoteCollection $notes; + // Localization Information + public string|null $language = null; + public IndividualLanguageCollection $languages; + // Other Information + public IndividualCryptoCollection $crypto; + + public function __construct() { + $this->names = new IndividualNameObject(); + $this->anniversaries = new IndividualAnniversaryCollection(); + $this->phones = new IndividualPhoneCollection(); + $this->emails = new IndividualEmailCollection(); + $this->physicalLocations = new IndividualPhysicalLocationCollection(); + $this->organizations = new IndividualOrganizationCollection(); + $this->titles = new IndividualTitleCollection(); + $this->tags = new IndividualTagCollection(); + $this->notes = new IndividualNoteCollection(); + $this->crypto = new IndividualCryptoCollection(); + $this->virtualLocations = new IndividualVirtualLocationCollection(); + $this->media = new IndividualMediaCollection(); + } + +} diff --git a/shared/lib/People/Individual/IndividualOrganizationCollection.php b/shared/lib/People/Individual/IndividualOrganizationCollection.php new file mode 100644 index 0000000..fc51b67 --- /dev/null +++ b/shared/lib/People/Individual/IndividualOrganizationCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualOrganizationCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualOrganizationObject::class, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualOrganizationObject.php b/shared/lib/People/Individual/IndividualOrganizationObject.php new file mode 100644 index 0000000..f5aaad3 --- /dev/null +++ b/shared/lib/People/Individual/IndividualOrganizationObject.php @@ -0,0 +1,29 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; +use OCA\JMAPC\Objects\BaseStringCollection; + +class IndividualOrganizationObject extends JsonSerializableObject { + + public string|null $Label; + public BaseStringCollection $Units; + + public string|null $sortName = null; + + public string|null $context = null; + public int|null $priority = null; + + public function __construct() { + $this->units = new BaseStringCollection(); + } + +} diff --git a/shared/lib/People/Individual/IndividualPhoneCollection.php b/shared/lib/People/Individual/IndividualPhoneCollection.php new file mode 100644 index 0000000..342059b --- /dev/null +++ b/shared/lib/People/Individual/IndividualPhoneCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualPhoneCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualPhoneObject::class, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualPhoneObject.php b/shared/lib/People/Individual/IndividualPhoneObject.php new file mode 100644 index 0000000..8ada8bf --- /dev/null +++ b/shared/lib/People/Individual/IndividualPhoneObject.php @@ -0,0 +1,21 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualPhoneObject extends JsonSerializableObject { + + public string|null $number = null; + + public string|null $label = null; + public string|null $context = null; + public int|null $priority = null; +} diff --git a/shared/lib/People/Individual/IndividualPhysicalLocationCollection.php b/shared/lib/People/Individual/IndividualPhysicalLocationCollection.php new file mode 100644 index 0000000..dc3459a --- /dev/null +++ b/shared/lib/People/Individual/IndividualPhysicalLocationCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualPhysicalLocationCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualPhysicalLocationObject::class, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualPhysicalLocationObject.php b/shared/lib/People/Individual/IndividualPhysicalLocationObject.php new file mode 100644 index 0000000..56f3926 --- /dev/null +++ b/shared/lib/People/Individual/IndividualPhysicalLocationObject.php @@ -0,0 +1,31 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualPhysicalLocationObject extends JsonSerializableObject { + + public string|null $box = null; + public string|null $unit = null; + public string|null $street = null; + public string|null $locality = null; + public string|null $region = null; + public string|null $code = null; + public string|null $country = null; + + public string|null $label = null; + public string|null $coordinates = null; + public string|null $timeZone = null; + + public string|null $context = null; + public int|null $priority = null; + +} diff --git a/shared/lib/People/Individual/IndividualPronounCollection.php b/shared/lib/People/Individual/IndividualPronounCollection.php new file mode 100644 index 0000000..d7366c8 --- /dev/null +++ b/shared/lib/People/Individual/IndividualPronounCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualPronounCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualPronounObject::class); + } + +} diff --git a/shared/lib/People/Individual/IndividualPronounObject.php b/shared/lib/People/Individual/IndividualPronounObject.php new file mode 100644 index 0000000..c1392ae --- /dev/null +++ b/shared/lib/People/Individual/IndividualPronounObject.php @@ -0,0 +1,21 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualPronounObject extends JsonSerializableObject { + + public string|null $pronoun = null; + + public string|null $context = null; + public int|null $priority = null; + +} diff --git a/shared/lib/People/Individual/IndividualTagCollection.php b/shared/lib/People/Individual/IndividualTagCollection.php new file mode 100644 index 0000000..56e7b08 --- /dev/null +++ b/shared/lib/People/Individual/IndividualTagCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualTagCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualTitleCollection.php b/shared/lib/People/Individual/IndividualTitleCollection.php new file mode 100644 index 0000000..2c0c0cb --- /dev/null +++ b/shared/lib/People/Individual/IndividualTitleCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualTitleCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualTitleObject::class, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualTitleObject.php b/shared/lib/People/Individual/IndividualTitleObject.php new file mode 100644 index 0000000..28c75c4 --- /dev/null +++ b/shared/lib/People/Individual/IndividualTitleObject.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualTitleObject extends JsonSerializableObject { + + public IndividualTitleTypes|null $kind = null; + public string|null $label = null; + public string|null $relation = null; + + public string|null $context = null; + public int|null $priority = null; + +} diff --git a/shared/lib/People/Individual/IndividualTitleTypes.php b/shared/lib/People/Individual/IndividualTitleTypes.php new file mode 100644 index 0000000..7690c64 --- /dev/null +++ b/shared/lib/People/Individual/IndividualTitleTypes.php @@ -0,0 +1,15 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +enum IndividualTitleTypes: string { + case Title = 't'; + case Role = 'r'; +} diff --git a/shared/lib/People/Individual/IndividualVirtualLocationCollection.php b/shared/lib/People/Individual/IndividualVirtualLocationCollection.php new file mode 100644 index 0000000..9df4fc5 --- /dev/null +++ b/shared/lib/People/Individual/IndividualVirtualLocationCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualVirtualLocationCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualVirtualLocationObject::class, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualVirtualLocationObject.php b/shared/lib/People/Individual/IndividualVirtualLocationObject.php new file mode 100644 index 0000000..76cfa0a --- /dev/null +++ b/shared/lib/People/Individual/IndividualVirtualLocationObject.php @@ -0,0 +1,22 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualVirtualLocationObject extends JsonSerializableObject { + + public string|null $location = null; + + public string|null $label = null; + public string|null $context = null; + public int|null $priority = null; + +} diff --git a/shared/lib/People/Provider/IProviderBase.php b/shared/lib/People/Provider/IProviderBase.php new file mode 100644 index 0000000..5f778af --- /dev/null +++ b/shared/lib/People/Provider/IProviderBase.php @@ -0,0 +1,96 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Provider; + +use JsonSerializable; +use KTXF\People\Service\IServiceBase; + +interface IProviderBase extends JsonSerializable { + + public const CAPABILITY_SERVICE_LIST = 'ServiceList'; + public const CAPABILITY_SERVICE_FETCH = 'ServiceFetch'; + public const CAPABILITY_SERVICE_EXTANT = 'ServiceExtant'; + + public const JSON_TYPE = 'people.provider'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_CAPABILITIES = 'capabilities'; + + /** + * Confirms if specific capability is supported (e.g. 'ServiceList') + * + * @since 2025.05.01 + */ + public function capable(string $value): bool; + + /** + * Lists all supported capabilities + * + * @since 2025.05.01 + * + * @return array + */ + public function capabilities(): array; + + /** + * An arbitrary unique text string identifying this provider (e.g. UUID or 'system' or anything else) + * + * @since 2025.05.01 + */ + public function id(): string; + + /** + * The localized human friendly name of this provider (e.g. System Contacts Provider) + * + * @since 2025.05.01 + */ + public function label(): string; + + /** + * Retrieve collection of services for a specific user + * + * @since 2025.05.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param array $filter filter criteria + * + * @return array collection of service objects + */ + public function serviceList(string $tenantId, string $userId, array $filter): array; + + /** + * Determine if any services are configured for a specific user + * + * @since 2025.05.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param int|string ...$identifiers variadic collection of service identifiers + * + * @return array collection of service identifiers with boolean values indicating if the service is available + */ + public function serviceExtant(string $tenantId, string $userId, int|string ...$identifiers): array; + + /** + * Retrieve a service with a specific identifier + * + * @since 2025.05.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string|int $identifier service identifier + * + * @return IServiceBase|null returns service object or null if non found + */ + public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?IServiceBase; + +} diff --git a/shared/lib/People/Provider/IProviderServiceMutate.php b/shared/lib/People/Provider/IProviderServiceMutate.php new file mode 100644 index 0000000..e128c96 --- /dev/null +++ b/shared/lib/People/Provider/IProviderServiceMutate.php @@ -0,0 +1,69 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Provider; + +use KTXF\Json\JsonDeserializable; +use KTXF\People\Service\IServiceBase; + +interface IProviderServiceMutate extends JsonDeserializable { + + public const CAPABILITY_SERVICE_FRESH = 'ServiceFresh'; + public const CAPABILITY_SERVICE_CREATE = 'ServiceCreate'; + public const CAPABILITY_SERVICE_UPDATE = 'ServiceUpdate'; + public const CAPABILITY_SERVICE_DESTROY = 'ServiceDestroy'; + + /** + * construct and new blank service instance + * + * @since 2025.05.01 + * + * @param string $userId user identifier + * + * @return IServiceBase + */ + public function serviceFresh(string $userId = ''): IServiceBase; + + /** + * create a service configuration for a specific user + * + * @since 2025.05.01 + * + * @param string $userId user identifier + * @param IServiceBase $service service instance + * + * @return string + */ + public function serviceCreate(string $userId, IServiceBase $service): string; + + /** + * modify a service configuration for a specific user + * + * @since 2025.05.01 + * + * @param string $userId user identifier + * @param IServiceBase $service service instance + * + * @return string + */ + public function serviceModify(string $userId, IServiceBase $service): string; + + /** + * delete a service configuration for a specific user + * + * @since 2025.05.01 + * + * @param string $userId user identifier + * @param IServiceBase $service service instance + * + * @return bool + */ + public function serviceDestroy(string $userId, IServiceBase $service): bool; + +} diff --git a/shared/lib/People/Service/IServiceBase.php b/shared/lib/People/Service/IServiceBase.php new file mode 100644 index 0000000..fe8901d --- /dev/null +++ b/shared/lib/People/Service/IServiceBase.php @@ -0,0 +1,221 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Service; + +use JsonSerializable; +use KTXF\People\Collection\ICollectionBase; +use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Range\IRange; +use KTXF\Resource\Range\RangeType; +use KTXF\Resource\Sort\ISort; + +interface IServiceBase extends JsonSerializable { + + public const CAPABILITY_COLLECTION_LIST = 'CollectionList'; + public const CAPABILITY_COLLECTION_LIST_FILTER = 'CollectionListFilter'; + public const CAPABILITY_COLLECTION_LIST_SORT = 'CollectionListSort'; + public const CAPABILITY_COLLECTION_EXTANT = 'CollectionExtant'; + public const CAPABILITY_COLLECTION_FETCH = 'CollectionFetch'; + + public const CAPABILITY_ENTITY_LIST = 'EntityList'; + public const CAPABILITY_ENTITY_LIST_FILTER = 'EntityListFilter'; + public const CAPABILITY_ENTITY_LIST_SORT = 'EntityListSort'; + public const CAPABILITY_ENTITY_LIST_RANGE = 'EntityListRange'; + public const CAPABILITY_ENTITY_DELTA = 'EntityDelta'; + public const CAPABILITY_ENTITY_EXTANT = 'EntityExtant'; + public const CAPABILITY_ENTITY_FETCH = 'EntityFetch'; + + public const JSON_TYPE = 'people.service'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_PROVIDER = 'provider'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_CAPABILITIES = 'capabilities'; + public const JSON_PROPERTY_ENABLED = 'enabled'; + + /** + * Confirms if specific capability is supported + * + * @since 2025.05.01 + * + * @param string $value required ability e.g. 'EntityList' + * + * @return bool + */ + public function capable(string $value): bool; + + /** + * Lists all supported capabilities + * + * @since 2025.05.01 + * + * @return array + */ + public function capabilities(): array; + + /** + * Unique identifier of the provider this service belongs to + * + * @since 2025.05.01 + */ + public function in(): string; + + /** + * Unique arbitrary text string identifying this service (e.g. 1 or service1 or anything else) + * + * @since 2025.05.01 + */ + public function id(): string|int; + + /** + * Gets the localized human friendly name of this service (e.g. ACME Company Mail Service) + * + * @since 2025.05.01 + */ + public function getLabel(): string; + + /** + * Gets the active status of this service + * + * @since 2025.05.01 + */ + public function getEnabled(): bool; + + /** + * List of accessible collection + * + * @since 2025.05.01 + * + * @return array + */ + public function collectionList(?IFilter $filter = null, ?ISort $sort = null): array; + + /** + * Fresh filter for collection list + * + * @since 2025.05.01 + * + * @return IFilter + */ + public function collectionListFilter(): IFilter; + + /** + * Fresh sort for collection list + * + * @since 2025.05.01 + * + * @return ISort + */ + public function collectionListSort(): ISort; + + /** + * Fetches details about a specific collection + * + * @since 2025.05.01 + * + * @param string|int $id collection identifier + */ + public function collectionExtant(string|int $identifier): bool; + + /** + * Fetches details about a specific collection + * + * @since 2025.05.01 + * + * @param string|int $identifier collection identifier + */ + public function collectionFetch(string|int $identifier): ?ICollectionBase; + + /** + * Lists all entities in a specific collection + * + * @since 2025.05.01 + * + * @param string|int $collection collection identifier + * + * @return array + */ + public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $elements = null): array; + + /** + * Fresh filter for entity list + * + * @since 2025.05.01 + * + * @return Filter + */ + public function entityListFilter(): IFilter; + + /** + * Fresh sort for entity list + * + * @since 2025.05.01 + * + * @return ISort + */ + public function entityListSort(): ISort; + + /** + * Fresh range for entity list + * + * @since 2025.05.01 + * + * @param RangeType $type range type + * + * @return IRange + */ + public function entityListRange(RangeType $type): IRange; + + /** + * Lists of all changes from a specific token + * + * @since 2025.05.01 + * + * @param string|int $collection collection identifier + * @param string $signature token signature + * @param string $detail detail level ids | meta | full + * + * @return array + * + * [ + * 'added' => array, + * 'updated' => array, + * 'deleted' => array, + * 'signature' => string + * ] + * + */ + public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): array; + + /** + * Confirms if specific entity exists in a collection + * + * @since 2025.05.01 + * + * @param string|int $collection collection identifier + * @param string|int ...$identifiers list of entity identifiers + * + * @return array + */ + public function entityExtant(string|int $collection, string|int ...$identifiers): array; + + /** + * Fetches details about a specific entities in a collection + * + * @since 2025.05.01 + * + * @param string|int $collection collection identifier + * @param string|int ...$identifiers entity identifier + * + * @return array + */ + public function entityFetch(string|int $collection, string|int ...$identifiers): array; + +} diff --git a/shared/lib/People/Service/IServiceCollectionMutable.php b/shared/lib/People/Service/IServiceCollectionMutable.php new file mode 100644 index 0000000..84fe128 --- /dev/null +++ b/shared/lib/People/Service/IServiceCollectionMutable.php @@ -0,0 +1,79 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Service; + +use KTXF\People\Collection\ICollectionBase; +use KTXF\People\Collection\ICollectionMutable; + +interface IServiceCollectionMutable extends IServiceBase { + + public const CAPABILITY_COLLECTION_CREATE = 'CollectionCreate'; + public const CAPABILITY_COLLECTION_MODIFY = 'CollectionModify'; + public const CAPABILITY_COLLECTION_DESTROY = 'CollectionDestroy'; + public const CAPABILITY_COLLECTION_MOVE = 'CollectionMove'; + + /** + * Creates a new, empty collection object + * + * @since 2025.05.01 + * + * @return ICollectionMutable + */ + public function collectionFresh(): ICollectionMutable; + + /** + * Creates a new collection at the specified location + * + * @since 2025.05.01 + * + * @param string|int $location The parent collection to create this collection in, or empty string for root + * @param ICollectionMutable $collection The collection to create + * @param array $options Additional options for the collection creation + * + * @return ICollectionBase + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionCreate(string|int $location, ICollectionMutable $collection, array $options): ICollectionBase; + + /** + * Modifies an existing collection + * + * @since 2025.05.01 + * + * @param string|int $identifier The ID of the collection to modify + * @param ICollectionMutable $collection The collection with modifications + * + * @return ICollectionBase + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionModify(string|int $identifier, ICollectionMutable $collection): ICollectionBase; + + /** + * Destroys an existing collection + * + * @since 2025.05.01 + * + * @param string|int $identifier The ID of the collection to destroy + * + * @return bool + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionDestroy(string|int $identifier): bool; + +} diff --git a/shared/lib/People/Service/IServiceEntityMutable.php b/shared/lib/People/Service/IServiceEntityMutable.php new file mode 100644 index 0000000..b80f840 --- /dev/null +++ b/shared/lib/People/Service/IServiceEntityMutable.php @@ -0,0 +1,82 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Service; + +use KTXF\People\Entity\IEntityBase; +use KTXF\People\Entity\IEntityMutable; + +interface IServiceEntityMutable extends IServiceBase { + + public const CAPABILITY_ENTITY_CREATE = 'EntityCreate'; + public const CAPABILITY_ENTITY_MODIFY = 'EntityModify'; + public const CAPABILITY_ENTITY_DESTROY = 'EntityDestroy'; + public const CAPABILITY_ENTITY_COPY = 'EntityCopy'; + public const CAPABILITY_ENTITY_MOVE = 'EntityMove'; + + /** + * Creates a fresh entity of the specified type + * + * @since 2025.05.01 + * + * @return IEntityMutable + */ + public function entityFresh(): IEntityMutable; + + /** + * Creates a new entity in the specified collection + * + * @since 2025.05.01 + * + * @param string|int $collection The collection to create this entity in + * @param IEntityMutable $entity The entity to create + * @param array $options Additional options for the entity creation + * + * @return IEntityMutable + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityCreate(string|int $collection, IEntityMutable $entity, array $options): IEntityMutable; + + /** + * Modifies an existing entity in the specified collection + * + * @since 2025.05.01 + * + * @param string|int $collection The collection containing the entity to modify + * @param string|int $identifier The ID of the entity to modify + * @param IEntityMutable $entity The entity with modifications + * + * @return IEntityMutable + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityModify(string|int $collection, string|int $identifier, IEntityMutable $entity): IEntityMutable; + + /** + * Destroys an existing entity in the specified collection + * + * @since 2025.05.01 + * + * @param string|int $collection The collection containing the entity to destroy + * @param string|int $identifier The ID of the entity to destroy + * + * @return bool + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityDestroy(string|int $collection, string|int $identifier): IEntityBase; + +} diff --git a/shared/lib/People/Service/IServiceMutable.php b/shared/lib/People/Service/IServiceMutable.php new file mode 100644 index 0000000..f35c8a9 --- /dev/null +++ b/shared/lib/People/Service/IServiceMutable.php @@ -0,0 +1,30 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Service; + +use KTXF\Json\JsonDeserializable; + +interface IServiceMutable extends IServiceBase, JsonDeserializable { + + /** + * Sets the localized human friendly name of this service (e.g. ACME Company Mail Service) + * + * @since 2025.05.01 + */ + public function setLabel(string $value): self; + + /** + * Sets the active status of this service + * + * @since 2025.05.01 + */ + public function setEnabled(bool $value): self; + +} diff --git a/shared/lib/Resource/Delta/Delta.php b/shared/lib/Resource/Delta/Delta.php new file mode 100644 index 0000000..aadf05a --- /dev/null +++ b/shared/lib/Resource/Delta/Delta.php @@ -0,0 +1,45 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Delta; + +use KTXF\Json\JsonSerializable; + +class Delta implements JsonSerializable { + + public function __construct( + public ?DeltaCollection $additions = null, + public ?DeltaCollection $modifications = null, + public ?DeltaCollection $deletions = null, + public ?string $signature = null, + ) { + if ($this->additions === null) { + $this->additions = new DeltaCollection; + } + if ($this->modifications === null) { + $this->modifications = new DeltaCollection; + } + if ($this->deletions === null) { + $this->deletions = new DeltaCollection; + } + if ($this->signature === null) { + $this->signature = ''; + } + } + + public function jsonSerialize(): array { + return [ + 'signature' => $this->signature, + 'additions' => $this->additions, + 'modifications' => $this->modifications, + 'deletions' => $this->deletions, + ]; + } + +} diff --git a/shared/lib/Resource/Delta/DeltaCollection.php b/shared/lib/Resource/Delta/DeltaCollection.php new file mode 100644 index 0000000..120fe5e --- /dev/null +++ b/shared/lib/Resource/Delta/DeltaCollection.php @@ -0,0 +1,18 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Delta; + +use KTXF\Json\JsonSerializableCollection; + +class DeltaCollection extends JsonSerializableCollection { + public function __construct($data = []) { + parent::__construct($data, self::TYPE_STRING); + } +} diff --git a/shared/lib/Resource/Exceptions/InvalidParameterException.php b/shared/lib/Resource/Exceptions/InvalidParameterException.php new file mode 100644 index 0000000..ecf90ac --- /dev/null +++ b/shared/lib/Resource/Exceptions/InvalidParameterException.php @@ -0,0 +1,21 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Filter; + +class Filter implements IFilter { + + protected array $attributes = []; + protected array $conditions = []; + + /** + * Constructor + * + * @since 2025.05.01 + * + * @param array $attributes List of attributes that can be used in the filter + */ + public function __construct(array $attributes) { + foreach ($attributes as $id => $value) { + // separate the value into components + [$type, $length, $comparatorDefault, $comparatorSupported] = explode(':', $value); + // validate the components + $this->attributes[$id]['type'] = match ($type) { + 's' => 'string', + 'i' => 'integer', + 'b' => 'boolean', + 'a' => 'array', + 'd' => 'date', + default => throw new \InvalidArgumentException("Invalid type '$type' for attribute '$id'."), + }; + $this->attributes[$id]['length'] = (int)$length; + $this->attributes[$id]['comparatorDefault'] = FilterComparisonOperator::from((int)$comparatorDefault); + // comparatorSupported is a bitmask of supported comparators + if ($comparatorSupported !== '0') { + $comparators = FilterComparisonOperator::cases(); + foreach ($comparators as $comparator) { + if (($comparatorSupported & $comparator->value) === $comparator->value) { + $this->attributes[$id]['comparatorSupported'][] = $comparator; + } + } + } else { + $this->attributes[$id]['comparatorSupported'] = []; + } + } + } + + /** + * List of attributes that can be used in the filter + * + * @since 2025.05.01 + * + * @return array + */ + public function attributes(): array { + return $this->attributes; + } + + /** + * List of comparison operators that can be used in the filter + * + * @since 2025.05.01 + */ + public function comparators(): string { + return FilterComparisonOperator::class; + } + + /** + * List of conjunction operators that can be used in the filter + * + * @since 2025.05.01 + */ + public function conjunctions(): string { + return FilterConjunctionOperator::class; + } + + /** + * Define a condition for the filter + * + * @since 2025.05.01 + */ + public function condition(string $attribute, mixed $value, ?FilterComparisonOperator $comparator = null, ?FilterConjunctionOperator $conjunction = null): void { + // check if the attribute is defined in the filter + if (!isset($this->attributes[$attribute])) { + throw new \InvalidArgumentException("Attribute '$attribute' is not defined in the filter"); + } + // check if comparator is valid and supported for the attribute + if ($comparator === null) { + $comparator = $this->attributes[$attribute]['comparatorDefault']; + } + if (!in_array($comparator, $this->attributes[$attribute]['comparatorSupported'], true)) { + throw new \InvalidArgumentException("Comparator '$comparator' is not supported for attribute '$attribute'"); + } + // check if the value type is valid for the attribute + if ($this->attributes[$attribute]['type'] !== gettype($value)) { + throw new \InvalidArgumentException("Value for attribute '$attribute' must be of type '" . $this->attributes[$attribute]['type'] . "'"); + } + // check if the value length is within the defined limit + if ($this->attributes[$attribute]['type'] === 'array' && $this->attributes[$attribute]['length'] <= count($value)) { + throw new \InvalidArgumentException("Value for attribute '$attribute' exceeds the maximum length of " . $this->attributes[$attribute]['length'] . " items"); + } + if ($this->attributes[$attribute]['type'] === 'string' && $this->attributes[$attribute]['length'] <= mb_strlen($value)) { + throw new \InvalidArgumentException("Value for attribute '$attribute' exceeds the maximum length of " . $this->attributes[$attribute]['length'] . " characters"); + } + + $this->conditions[$attribute] = [ + 'attribute' => $attribute, + 'value' => $value, + 'comparator' => $comparator, + 'conjunction' => $conjunction, + ]; + } + + /** + * List of defined conditions + * + * @since 2025.05.01 + * + * @return array + */ + public function conditions(): array { + return $this->conditions; + } + +} diff --git a/shared/lib/Resource/Filter/FilterComparisonOperator.php b/shared/lib/Resource/Filter/FilterComparisonOperator.php new file mode 100644 index 0000000..1e3f4ca --- /dev/null +++ b/shared/lib/Resource/Filter/FilterComparisonOperator.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Filter; + +enum FilterComparisonOperator: int { + case EQ = 1; + case NEQ = 2; + case GT = 4; + case LT = 8; + case GTE = 16; + case LTE = 32; + case IN = 64; + case NIN = 128; + case LIKE = 256; + case NLIKE = 512; +} diff --git a/shared/lib/Resource/Filter/FilterConjunctionOperator.php b/shared/lib/Resource/Filter/FilterConjunctionOperator.php new file mode 100644 index 0000000..2dc4243 --- /dev/null +++ b/shared/lib/Resource/Filter/FilterConjunctionOperator.php @@ -0,0 +1,16 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Filter; + +enum FilterConjunctionOperator: string { + case NONE = ''; + case AND = 'AND'; + case OR = 'OR'; +} diff --git a/shared/lib/Resource/Filter/IFilter.php b/shared/lib/Resource/Filter/IFilter.php new file mode 100644 index 0000000..7d1b861 --- /dev/null +++ b/shared/lib/Resource/Filter/IFilter.php @@ -0,0 +1,53 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Filter; + +interface IFilter { + + /** + * List of attributes that can be used in the filter + * + * @since 2025.05.01 + * + * @return array + */ + public function attributes(): array; + + /** + * List of comparison operators that can be used in the filter + * + * @since 2025.05.01 + */ + public function comparators(): string; + + /** + * List of conjunction operators that can be used in the filter + * + * @since 2025.05.01 + */ + public function conjunctions(): string; + + /** + * Define a filter condition + * + * @since 2025.05.01 + */ + public function condition(string $property, mixed $value, ?FilterComparisonOperator $comparator = null, ?FilterConjunctionOperator $conjunction = null): void; + + /** + * list of defined conditions + * + * @since 2025.05.01 + * + * @return array + */ + public function conditions(): array; + +} diff --git a/shared/lib/Resource/Provider/Node/NodeBaseAbstract.php b/shared/lib/Resource/Provider/Node/NodeBaseAbstract.php new file mode 100644 index 0000000..657e84e --- /dev/null +++ b/shared/lib/Resource/Provider/Node/NodeBaseAbstract.php @@ -0,0 +1,116 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider\Node; + +use DateTimeImmutable; + +/** + * Abstract Node Base Class + * + * Provides common implementation for all resource nodes + * + * @since 2025.05.01 + */ +abstract class NodeBaseAbstract implements NodeBaseInterface { + + /** + * Internal data storage + */ + protected array $data = []; + + public function __construct( + protected readonly string $provider, + protected readonly string|int $service, + ) { + $this->data = [ + static::JSON_PROPERTY_PROVIDER => $this->provider, + static::JSON_PROPERTY_SERVICE => $this->service, + static::JSON_PROPERTY_COLLECTION => null, + static::JSON_PROPERTY_IDENTIFIER => null, + static::JSON_PROPERTY_SIGNATURE => null, + static::JSON_PROPERTY_CREATED => null, + static::JSON_PROPERTY_MODIFIED => null, + ]; + } + + /** + * @inheritDoc + */ + public function type(): string { + return static::RESOURCE_TYPE; + } + + /** + * @inheritDoc + */ + public function provider(): string { + return $this->data[static::JSON_PROPERTY_PROVIDER]; + } + + /** + * @inheritDoc + */ + public function service(): string|int { + return $this->data[static::JSON_PROPERTY_SERVICE]; + } + + /** + * @inheritDoc + */ + public function collection(): string|int|null { + return $this->data[static::JSON_PROPERTY_COLLECTION] ?? null; + } + + /** + * @inheritDoc + */ + public function identifier(): string|int|null { + return $this->data[static::JSON_PROPERTY_IDENTIFIER] ?? null; + } + + /** + * @inheritDoc + */ + public function signature(): string|null { + return $this->data[static::JSON_PROPERTY_SIGNATURE] ?? null; + } + + /** + * @inheritDoc + */ + public function created(): DateTimeImmutable|null { + return isset($this->data[static::JSON_PROPERTY_CREATED]) + ? new DateTimeImmutable($this->data[static::JSON_PROPERTY_CREATED]) + : null; + } + + /** + * @inheritDoc + */ + public function modified(): DateTimeImmutable|null { + return isset($this->data[static::JSON_PROPERTY_MODIFIED]) + ? new DateTimeImmutable($this->data[static::JSON_PROPERTY_MODIFIED]) + : null; + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + $data = $this->data; + $data[static::JSON_PROPERTY_PROPERTIES] = $this->getProperties()->jsonSerialize(); + return $data; + } + + /** + * @inheritDoc + */ + abstract public function getProperties(): NodePropertiesBaseInterface; +} diff --git a/shared/lib/Resource/Provider/Node/NodeBaseInterface.php b/shared/lib/Resource/Provider/Node/NodeBaseInterface.php new file mode 100644 index 0000000..6137d60 --- /dev/null +++ b/shared/lib/Resource/Provider/Node/NodeBaseInterface.php @@ -0,0 +1,95 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider\Node; + +use DateTimeImmutable; +use KTXF\Json\JsonSerializable; + +/** + * Resource Node Read Interface + * + * @since 2025.05.01 + */ +interface NodeBaseInterface extends JsonSerializable { + + public const RESOURCE_TYPE = 'resource.node'; + public const JSON_PROPERTY_PROVIDER = 'provider'; + public const JSON_PROPERTY_SERVICE = 'service'; + public const JSON_PROPERTY_COLLECTION = 'collection'; + public const JSON_PROPERTY_IDENTIFIER = 'identifier'; + public const JSON_PROPERTY_SIGNATURE = 'signature'; + public const JSON_PROPERTY_CREATED = 'created'; + public const JSON_PROPERTY_MODIFIED = 'modified'; + public const JSON_PROPERTY_PROPERTIES = 'properties'; + + /** + * Node type + * + * @since 2025.05.01 + */ + public function type(): string; + + /** + * Provider identifier + * + * @since 2025.05.01 + */ + public function provider(): string; + + /** + * Service identifier + * + * @since 2025.05.01 + */ + public function service(): string|int; + + /** + * Collection identifier + * + * @since 2025.05.01 + */ + public function collection(): string|int|null; + + /** + * Node identifier + * + * @since 2025.05.01 + */ + public function identifier(): string|int|null; + + /** + * Node signature/sync token + * + * @since 2025.05.01 + */ + public function signature(): string|null; + + /** + * Node creation date + * + * @since 2025.05.01 + */ + public function created(): DateTimeImmutable|null; + + /** + * Node modification date + * + * @since 2025.05.01 + */ + public function modified(): DateTimeImmutable|null; + + /** + * Get the node properties + * + * @since 2025.05.01 + */ + public function getProperties(): NodePropertiesBaseInterface|NodePropertiesMutableInterface; + +} diff --git a/shared/lib/Resource/Provider/Node/NodeMutableAbstract.php b/shared/lib/Resource/Provider/Node/NodeMutableAbstract.php new file mode 100644 index 0000000..25fdcc5 --- /dev/null +++ b/shared/lib/Resource/Provider/Node/NodeMutableAbstract.php @@ -0,0 +1,95 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider\Node; + +/** + * Abstract Node Mutable Class + * + * Provides common implementation for mutable resource nodes + * + * @since 2025.05.01 + */ +abstract class NodeMutableAbstract extends NodeBaseAbstract implements NodeMutableInterface { + + /** + * @inheritDoc + */ + public function jsonDeserialize(array|string $data): static { + if (is_string($data)) { + $data = json_decode($data, true); + } + + $this->data = []; + + if (isset($data[static::JSON_PROPERTY_COLLECTION])) { + if (!is_string($data[static::JSON_PROPERTY_COLLECTION]) && !is_int($data[static::JSON_PROPERTY_COLLECTION])) { + throw new \InvalidArgumentException("Collection must be a string or integer"); + } + $this->data[static::JSON_PROPERTY_COLLECTION] = $data[static::JSON_PROPERTY_COLLECTION]; + } else { + $this->data[static::JSON_PROPERTY_COLLECTION] = null; + } + + if (isset($data[static::JSON_PROPERTY_IDENTIFIER])) { + if (!is_string($data[static::JSON_PROPERTY_IDENTIFIER]) && !is_int($data[static::JSON_PROPERTY_IDENTIFIER])) { + throw new \InvalidArgumentException("Identifier must be a string or integer"); + } + $this->data[static::JSON_PROPERTY_IDENTIFIER] = $data[static::JSON_PROPERTY_IDENTIFIER]; + } else { + $this->data[static::JSON_PROPERTY_IDENTIFIER] = null; + } + + if (isset($data[static::JSON_PROPERTY_SIGNATURE])) { + if (!is_string($data[static::JSON_PROPERTY_SIGNATURE]) && !is_int($data[static::JSON_PROPERTY_SIGNATURE])) { + throw new \InvalidArgumentException("Signature must be a string or integer"); + } + $this->data[static::JSON_PROPERTY_SIGNATURE] = $data[static::JSON_PROPERTY_SIGNATURE]; + } else { + $this->data[static::JSON_PROPERTY_SIGNATURE] = null; + } + + if (isset($data[static::JSON_PROPERTY_CREATED])) { + if (!is_string($data[static::JSON_PROPERTY_CREATED])) { + throw new \InvalidArgumentException("Created date must be a string in ISO 8601 format"); + } + $this->data[static::JSON_PROPERTY_CREATED] = $data[static::JSON_PROPERTY_CREATED]; + } else { + $this->data[static::JSON_PROPERTY_CREATED] = null; + } + + if (isset($data[static::JSON_PROPERTY_MODIFIED])) { + if (!is_string($data[static::JSON_PROPERTY_MODIFIED])) { + throw new \InvalidArgumentException("Modified date must be a string in ISO 8601 format"); + } + $this->data[static::JSON_PROPERTY_MODIFIED] = $data[static::JSON_PROPERTY_MODIFIED]; + } else { + $this->data[static::JSON_PROPERTY_MODIFIED] = null; + } + + if (isset($data[static::JSON_PROPERTY_PROPERTIES])) { + if (!is_array($data[static::JSON_PROPERTY_PROPERTIES])) { + throw new \InvalidArgumentException("Properties must be an array"); + } + $this->getProperties()->jsonDeserialize($data[static::JSON_PROPERTY_PROPERTIES]); + } + + return $this; + } + + /** + * @inheritDoc + */ + abstract public function getProperties(): NodePropertiesMutableInterface; + + /** + * @inheritDoc + */ + abstract public function setProperties(NodePropertiesMutableInterface $value): static; +} diff --git a/shared/lib/Resource/Provider/Node/NodeMutableInterface.php b/shared/lib/Resource/Provider/Node/NodeMutableInterface.php new file mode 100644 index 0000000..ea70304 --- /dev/null +++ b/shared/lib/Resource/Provider/Node/NodeMutableInterface.php @@ -0,0 +1,35 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider\Node; + +use KTXF\Json\JsonDeserializable; + +/** + * Node Mutable Write Interface + * + * @since 2025.05.01 + */ +interface NodeMutableInterface extends NodeBaseInterface, JsonDeserializable { + + /** + * Get the node properties + * + * @since 2025.05.01 + */ + public function getProperties(): NodePropertiesMutableInterface; + + /** + * Sets the node properties + * + * @since 2025.05.01 + */ + public function setProperties(NodePropertiesMutableInterface $value): static; + +} diff --git a/shared/lib/Resource/Provider/Node/NodePropertiesBaseAbstract.php b/shared/lib/Resource/Provider/Node/NodePropertiesBaseAbstract.php new file mode 100644 index 0000000..c61734d --- /dev/null +++ b/shared/lib/Resource/Provider/Node/NodePropertiesBaseAbstract.php @@ -0,0 +1,57 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider\Node; + +/** + * Abstract Node Properties Base Class + * + * Provides common implementation for node properties + * + * @since 2025.05.01 + */ +abstract class NodePropertiesBaseAbstract implements NodePropertiesBaseInterface { + + protected array $data = []; + + public function __construct(array $data) { + + if (!isset($data[static::JSON_PROPERTY_TYPE])) { + $data[static::JSON_PROPERTY_TYPE] = static::JSON_TYPE; + } + + if (!isset($data[static::JSON_PROPERTY_VERSION])) { + $data[static::JSON_PROPERTY_VERSION] = 1; + } + + $this->data = $data; + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + return $this->data; + } + + /** + * @inheritDoc + */ + public function type(): string { + return $this->data[static::JSON_PROPERTY_TYPE]; + } + + /** + * @inheritDoc + */ + public function version(): int { + return $this->data[static::JSON_PROPERTY_VERSION]; + } + +} diff --git a/shared/lib/Resource/Provider/Node/NodePropertiesBaseInterface.php b/shared/lib/Resource/Provider/Node/NodePropertiesBaseInterface.php new file mode 100644 index 0000000..0db937a --- /dev/null +++ b/shared/lib/Resource/Provider/Node/NodePropertiesBaseInterface.php @@ -0,0 +1,37 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider\Node; + +use JsonSerializable; + +/** + * Resource Node Properties Read Interface + * + * @since 2025.05.01 + */ +interface NodePropertiesBaseInterface extends JsonSerializable { + + public const RESOURCE_TYPE = 'resource.data'; + + public const JSON_TYPE = 'resource.data'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_VERSION = 'version'; + + /** + * Get resource node properties type + */ + public function type(): string; + + /** + * Get resource node properties version + */ + public function version(): int; + +} diff --git a/shared/lib/Resource/Provider/Node/NodePropertiesMutableAbstract.php b/shared/lib/Resource/Provider/Node/NodePropertiesMutableAbstract.php new file mode 100644 index 0000000..c1da41e --- /dev/null +++ b/shared/lib/Resource/Provider/Node/NodePropertiesMutableAbstract.php @@ -0,0 +1,34 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider\Node; + +/** + * Abstract Node Properties Mutable Class + * + * Provides common implementation for mutable node properties + * + * @since 2025.05.01 + */ +abstract class NodePropertiesMutableAbstract extends NodePropertiesBaseAbstract implements NodePropertiesMutableInterface { + + /** + * @inheritDoc + */ + public function jsonDeserialize(array|string $data): static { + if (is_string($data)) { + $data = json_decode($data, true); + } + + $this->data = $data; + + return $this; + } + +} diff --git a/shared/lib/Resource/Provider/Node/NodePropertiesMutableInterface.php b/shared/lib/Resource/Provider/Node/NodePropertiesMutableInterface.php new file mode 100644 index 0000000..d38d319 --- /dev/null +++ b/shared/lib/Resource/Provider/Node/NodePropertiesMutableInterface.php @@ -0,0 +1,21 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider\Node; + +use KTXF\Json\JsonDeserializable; + +/** + * Resource Node Properties Mutable Interface + * + * @since 2025.05.01 + */ +interface NodePropertiesMutableInterface extends NodePropertiesBaseInterface, JsonDeserializable { + +} diff --git a/shared/lib/Resource/Provider/ProviderInterface.php b/shared/lib/Resource/Provider/ProviderInterface.php new file mode 100644 index 0000000..66027d5 --- /dev/null +++ b/shared/lib/Resource/Provider/ProviderInterface.php @@ -0,0 +1,44 @@ + + */ + public function capabilities(): array; + + /** + * Retrieve collection of services for a specific user + * + * @since 2025.11.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param array $filter filter criteria + * + * @return array collection of service objects + */ + public function serviceList(string $tenantId, string $userId, array $filter): array; + + /** + * Determine if any services are configured for a specific user + * + * @since 2025.11.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param int|string ...$identifiers variadic collection of service identifiers + * + * @return array collection of service identifiers with boolean values indicating if the service is available + */ + public function serviceExtant(string $tenantId, string $userId, int|string ...$identifiers): array; + + /** + * Retrieve a service with a specific identifier + * + * @since 2025.11.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string|int $identifier service identifier + * + * @return ResourceServiceBaseInterface|null returns service object or null if non found + */ + public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?ResourceServiceBaseInterface; + +} diff --git a/shared/lib/Resource/Provider/ResourceProviderServiceMutateInterface.php b/shared/lib/Resource/Provider/ResourceProviderServiceMutateInterface.php new file mode 100644 index 0000000..4a22b71 --- /dev/null +++ b/shared/lib/Resource/Provider/ResourceProviderServiceMutateInterface.php @@ -0,0 +1,44 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider; + +use KTXF\Json\JsonDeserializable; + +interface ResourceProviderServiceMutateInterface extends ResourceProviderBaseInterface, JsonDeserializable { + + /** + * construct and new blank service instance + * + * @since 2025.05.01 + */ + public function serviceFresh(): ResourceServiceMutateInterface; + + /** + * create a service configuration for a specific user + * + * @since 2025.05.01 + */ + public function serviceCreate(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string; + + /** + * modify a service configuration for a specific user + * + * @since 2025.05.01 + */ + public function serviceModify(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string; + + /** + * delete a service configuration for a specific user + * + * @since 2025.05.01 + */ + public function serviceDestroy(string $tenantId, string $userId, ResourceServiceMutateInterface $service): bool; + +} diff --git a/shared/lib/Resource/Provider/ResourceServiceBaseInterface.php b/shared/lib/Resource/Provider/ResourceServiceBaseInterface.php new file mode 100644 index 0000000..539d98e --- /dev/null +++ b/shared/lib/Resource/Provider/ResourceServiceBaseInterface.php @@ -0,0 +1,98 @@ + + */ + public function capabilities(): array; + + /** + * Unique identifier of the provider this service belongs to + * + * @since 2025.11.01 + */ + public function provider(): string; + + /** + * Unique arbitrary text string identifying this service (e.g. 1 or service1 or anything else) + * + * @since 2025.11.01 + */ + public function identifier(): string|int; + + /** + * Gets the localized human friendly name of this service (e.g. ACME Company File Service) + * + * @since 2025.11.01 + */ + public function getLabel(): string|null; + + /** + * Gets the active status of this service + * + * @since 2025.11.01 + */ + public function getEnabled(): bool; + + /** + * Gets the location information of this service + * + * @since 2025.05.01 + * + * @return ResourceServiceLocationInterface + */ + public function getLocation(): ResourceServiceLocationInterface; + + /** + * Gets the identity information of this service + * + * @since 2025.05.01 + * + * @return ResourceServiceIdentityInterface + */ + public function getIdentity(): ResourceServiceIdentityInterface; + + /** + * Gets the auxiliary information of this service + * + * @since 2025.05.01 + * + * @return array + */ + public function getAuxiliary(): array; + +} diff --git a/shared/lib/Resource/Provider/ResourceServiceConfigureInterface.php b/shared/lib/Resource/Provider/ResourceServiceConfigureInterface.php new file mode 100644 index 0000000..563414a --- /dev/null +++ b/shared/lib/Resource/Provider/ResourceServiceConfigureInterface.php @@ -0,0 +1,62 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider; + +/** + * Resource Service Configurable Interface + * + * Extends base service interface with setter methods for mutable properties. + * Used for service configuration and updates. + * + * @since 2025.05.01 + */ +interface ResourceServiceConfigureInterface extends ResourceServiceMutateInterface { + + /** + * Sets the location/configuration of this service + * + * @since 2025.05.01 + * + * @param ResourceServiceLocationInterface $value Service location/configuration + * + * @return self + */ + public function setLocation(ResourceServiceLocationInterface $value): self; + + /** + * Gets a fresh instance of the location/configuration of this service + * + * @since 2025.05.01 + * + * @return ResourceServiceLocationInterface + */ + public function freshLocation(string|null $type, array $data = []): ResourceServiceLocationInterface; + + /** + * Sets the identity used for this service + * + * @since 2025.05.01 + * + * @param ResourceServiceIdentityInterface $value Service identity + * + * @return self + */ + public function setIdentity(ResourceServiceIdentityInterface $value): self; + + /** + * Gets a fresh instance of the identity used for this service + * + * @since 2025.05.01 + * + * @return ResourceServiceIdentityInterface + */ + public function freshIdentity(string|null $type, array $data = []): ResourceServiceIdentityInterface; + +} diff --git a/shared/lib/Resource/Provider/ResourceServiceIdentityBasic.php b/shared/lib/Resource/Provider/ResourceServiceIdentityBasic.php new file mode 100644 index 0000000..671a137 --- /dev/null +++ b/shared/lib/Resource/Provider/ResourceServiceIdentityBasic.php @@ -0,0 +1,61 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider; + +/** + * Resource Service Identity Basic + * + * Basic authentication (username/password) credentials for resource services. + * + * @since 2025.05.01 + */ +interface ResourceServiceIdentityBasic extends ResourceServiceIdentityInterface { + + /** + * Gets the identity/username + * + * @since 2025.05.01 + * + * @return string + */ + public function getIdentity(): string; + + /** + * Sets the identity/username + * + * @since 2025.05.01 + * + * @param string $value + * + * @return void + */ + public function setIdentity(string $value): void; + + /** + * Gets the secret/password + * + * @since 2025.05.01 + * + * @return string + */ + public function getSecret(): string; + + /** + * Sets the secret/password + * + * @since 2025.05.01 + * + * @param string $value + * + * @return void + */ + public function setSecret(string $value): void; + +} diff --git a/shared/lib/Resource/Provider/ResourceServiceIdentityCertificate.php b/shared/lib/Resource/Provider/ResourceServiceIdentityCertificate.php new file mode 100644 index 0000000..c7918fc --- /dev/null +++ b/shared/lib/Resource/Provider/ResourceServiceIdentityCertificate.php @@ -0,0 +1,82 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider; + +/** + * Resource Service Identity Certificate + * + * Client certificate authentication credentials for resource services. + * Uses X.509 certificates for mutual TLS (mTLS) authentication. + * + * @since 2025.05.01 + */ +interface ResourceServiceIdentityCertificate extends ResourceServiceIdentityInterface { + + /** + * Gets the certificate file path or content + * + * @since 2025.05.01 + * + * @return string Path to certificate file or PEM-encoded certificate + */ + public function getCertificate(): string; + + /** + * Sets the certificate file path or content + * + * @since 2025.05.01 + * + * @param string $value Path to certificate file or PEM-encoded certificate + * + * @return void + */ + public function setCertificate(string $value): void; + + /** + * Gets the private key file path or content + * + * @since 2025.05.01 + * + * @return string Path to private key file or PEM-encoded private key + */ + public function getPrivateKey(): string; + + /** + * Sets the private key file path or content + * + * @since 2025.05.01 + * + * @param string $value Path to private key file or PEM-encoded private key + * + * @return void + */ + public function setPrivateKey(string $value): void; + + /** + * Gets the private key passphrase (if encrypted) + * + * @since 2025.05.01 + * + * @return string|null Passphrase for encrypted private key, or null if not encrypted + */ + public function getPassphrase(): ?string; + + /** + * Sets the private key passphrase (if encrypted) + * + * @since 2025.05.01 + * + * @param string|null $value Passphrase for encrypted private key + * + * @return void + */ + public function setPassphrase(?string $value): void; + +} diff --git a/shared/lib/Resource/Provider/ResourceServiceIdentityInterface.php b/shared/lib/Resource/Provider/ResourceServiceIdentityInterface.php new file mode 100644 index 0000000..6f8eee8 --- /dev/null +++ b/shared/lib/Resource/Provider/ResourceServiceIdentityInterface.php @@ -0,0 +1,38 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider; + +use KTXF\Json\JsonSerializable; + +/** + * Resource Service Identity Interface + * + * Base interface for authentication credentials used by resource services. + * + * @since 2025.05.01 + */ +interface ResourceServiceIdentityInterface extends JsonSerializable { + + public const TYPE_NONE = 'NA'; + public const TYPE_BASIC = 'BA'; + public const TYPE_TOKEN = 'TA'; + public const TYPE_OAUTH = 'OA'; + public const TYPE_CERTIFICATE = 'CC'; + + /** + * Gets the identity/authentication type + * + * @since 2025.05.01 + * + * @return string One of: TYPE_NONE, TYPE_BASIC, TYPE_TOKEN, TYPE_OAUTH, TYPE_CERTIFICATE + */ + public function type(): string; + +} diff --git a/shared/lib/Resource/Provider/ResourceServiceIdentityOAuth.php b/shared/lib/Resource/Provider/ResourceServiceIdentityOAuth.php new file mode 100644 index 0000000..84f13ae --- /dev/null +++ b/shared/lib/Resource/Provider/ResourceServiceIdentityOAuth.php @@ -0,0 +1,121 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider; + +/** + * Resource Service Identity OAuth + * + * OAuth authentication credentials for resource services, including token management. + * + * @since 2025.05.01 + */ +interface ResourceServiceIdentityOAuth extends ResourceServiceIdentityInterface { + + /** + * Gets the access token + * + * @since 2025.05.01 + * + * @return string + */ + public function getAccessToken(): string; + + /** + * Sets the access token + * + * @since 2025.05.01 + * + * @param string $value + * + * @return void + */ + public function setAccessToken(string $value): void; + + /** + * Gets the access token scope + * + * @since 2025.05.01 + * + * @return array + */ + public function getAccessScope(): array; + + /** + * Sets the access token scope + * + * @since 2025.05.01 + * + * @param array $value + * + * @return void + */ + public function setAccessScope(array $value): void; + + /** + * Gets the access token expiry timestamp + * + * @since 2025.05.01 + * + * @return int Unix timestamp + */ + public function getAccessExpiry(): int; + + /** + * Sets the access token expiry timestamp + * + * @since 2025.05.01 + * + * @param int $value Unix timestamp + * + * @return void + */ + public function setAccessExpiry(int $value): void; + + /** + * Gets the refresh token + * + * @since 2025.05.01 + * + * @return string + */ + public function getRefreshToken(): string; + + /** + * Sets the refresh token + * + * @since 2025.05.01 + * + * @param string $value + * + * @return void + */ + public function setRefreshToken(string $value): void; + + /** + * Gets the token refresh location/endpoint + * + * @since 2025.05.01 + * + * @return string + */ + public function getRefreshLocation(): string; + + /** + * Sets the token refresh location/endpoint + * + * @since 2025.05.01 + * + * @param string $value + * + * @return void + */ + public function setRefreshLocation(string $value): void; + +} diff --git a/shared/lib/Resource/Provider/ResourceServiceIdentityToken.php b/shared/lib/Resource/Provider/ResourceServiceIdentityToken.php new file mode 100644 index 0000000..2374fdb --- /dev/null +++ b/shared/lib/Resource/Provider/ResourceServiceIdentityToken.php @@ -0,0 +1,42 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider; + +/** + * Resource Service Identity Token + * + * Token authentication credentials for resource services. + * Uses a single static token/key for authentication. + * + * @since 2025.05.01 + */ +interface ResourceServiceIdentityToken extends ResourceServiceIdentityInterface { + + /** + * Gets the authentication token + * + * @since 2025.05.01 + * + * @return string + */ + public function getToken(): string; + + /** + * Sets the authentication token + * + * @since 2025.05.01 + * + * @param string $value + * + * @return void + */ + public function setToken(string $value): void; + +} diff --git a/shared/lib/Resource/Provider/ResourceServiceLocationFile.php b/shared/lib/Resource/Provider/ResourceServiceLocationFile.php new file mode 100644 index 0000000..9f2fd9e --- /dev/null +++ b/shared/lib/Resource/Provider/ResourceServiceLocationFile.php @@ -0,0 +1,51 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider; + +/** + * Resource Service Location File + * + * File-based service location for services using local or network file paths + * (e.g., maildir, mbox, local storage). + * + * @since 2025.05.01 + */ +interface ResourceServiceLocationFile extends ResourceServiceLocationInterface { + + /** + * Gets the complete file location path + * + * @since 2025.05.01 + * + * @return string File path (e.g., "/var/mail/user" or "\\server\share\mail") + */ + public function location(): string; + + /** + * Gets the file location path + * + * @since 2025.05.01 + * + * @return string File path + */ + public function getLocation(): string; + + /** + * Sets the file location path + * + * @since 2025.05.01 + * + * @param string $value + * + * @return void + */ + public function setLocation(string $value): void; + +} diff --git a/shared/lib/Resource/Provider/ResourceServiceLocationInterface.php b/shared/lib/Resource/Provider/ResourceServiceLocationInterface.php new file mode 100644 index 0000000..c3565d8 --- /dev/null +++ b/shared/lib/Resource/Provider/ResourceServiceLocationInterface.php @@ -0,0 +1,37 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider; + +use JsonSerializable; + +/** + * Resource Service Location Interface + * + * Unified interface supporting both URI-based (API services) and socket-based + * (traditional IMAP/SMTP) connection configurations. + * + * @since 2025.05.01 + */ +interface ResourceServiceLocationInterface extends JsonSerializable { + + public const TYPE_URI = 'URI'; + public const TYPE_SOCKET_SOLE = 'SOCKET_SOLE'; + public const TYPE_SOCKET_SPLIT = 'SOCKET_SPLIT'; + public const TYPE_FILE = 'FILE'; + + /** + * Gets the service location type + * + * @since 2025.05.01 + * + * @return string One of: TYPE_URI, TYPE_SOCKET_SOLE, TYPE_SOCKET_SPLIT, TYPE_FILE + */ + public function type(): string; +} diff --git a/shared/lib/Resource/Provider/ResourceServiceLocationSocketSole.php b/shared/lib/Resource/Provider/ResourceServiceLocationSocketSole.php new file mode 100644 index 0000000..08861a8 --- /dev/null +++ b/shared/lib/Resource/Provider/ResourceServiceLocationSocketSole.php @@ -0,0 +1,131 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider; + +/** + * Resource Service Location Socket Sole + * + * Single socket-based service location for services using a single host/port combination + * (e.g., JMAP, unified mail servers). + * + * @since 2025.05.01 + */ +interface ResourceServiceLocationSocketSole extends ResourceServiceLocationInterface { + + /** + * Gets the complete location string + * + * @since 2025.05.01 + * + * @return string Location (e.g., "mail.example.com:993") + */ + public function location(): string; + + /** + * Gets the host + * + * @since 2025.05.01 + * + * @return string Host (e.g., "mail.example.com") + */ + public function getHost(): string; + + /** + * Sets the host + * + * @since 2025.05.01 + * + * @param string $value + * + * @return void + */ + public function setHost(string $value): void; + + /** + * Gets the port + * + * @since 2025.05.01 + * + * @return int Port number + */ + public function getPort(): int; + + /** + * Sets the port + * + * @since 2025.05.01 + * + * @param int $value + * + * @return void + */ + public function setPort(int $value): void; + + /** + * Gets the encryption/security mode + * + * @since 2025.12.01 + * + * @return string One of: 'none', 'ssl', 'tls', 'starttls' + */ + public function getEncryption(): string; + + /** + * Sets the encryption/security mode + * + * @since 2025.12.01 + * + * @param string $value One of: 'none', 'ssl', 'tls', 'starttls' + * + * @return void + */ + public function setEncryption(string $value): void; + + /** + * Gets whether to verify SSL/TLS peer certificate + * + * @since 2025.12.01 + * + * @return bool + */ + public function getVerifyPeer(): bool; + + /** + * Sets whether to verify SSL/TLS peer certificate + * + * @since 2025.12.01 + * + * @param bool $value + * + * @return void + */ + public function setVerifyPeer(bool $value): void; + + /** + * Gets whether to verify SSL/TLS certificate host + * + * @since 2025.12.01 + * + * @return bool + */ + public function getVerifyHost(): bool; + + /** + * Sets whether to verify SSL/TLS certificate host + * + * @since 2025.12.01 + * + * @param bool $value + * + * @return void + */ + public function setVerifyHost(bool $value): void; + +} diff --git a/shared/lib/Resource/Provider/ResourceServiceLocationSocketSplit.php b/shared/lib/Resource/Provider/ResourceServiceLocationSocketSplit.php new file mode 100644 index 0000000..1b94cc8 --- /dev/null +++ b/shared/lib/Resource/Provider/ResourceServiceLocationSocketSplit.php @@ -0,0 +1,240 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider; + +/** + * Resource Service Location Socket Split + * + * Split socket-based service location for services using separate inbound/outbound servers + * (e.g., traditional IMAP/SMTP configurations). + * + * @since 2025.05.01 + */ +interface ResourceServiceLocationSocketSplit extends ResourceServiceLocationInterface { + + /** + * Gets the complete inbound location string + * + * @since 2025.05.01 + * + * @return string Inbound location (e.g., "imap.example.com:993") + */ + public function locationInbound(): string; + + /** + * Gets the complete outbound location string + * + * @since 2025.05.01 + * + * @return string Outbound location (e.g., "smtp.example.com:465") + */ + public function locationOutbound(): string; + + /** + * Gets the inbound host + * + * @since 2025.05.01 + * + * @return string Inbound host (e.g., "imap.example.com") + */ + public function getInboundHost(): string; + + /** + * Sets the inbound host + * + * @since 2025.05.01 + * + * @param string $value + * + * @return void + */ + public function setInboundHost(string $value): void; + + /** + * Gets the outbound host + * + * @since 2025.05.01 + * + * @return string Outbound host (e.g., "smtp.example.com") + */ + public function getOutboundHost(): string; + + /** + * Sets the outbound host + * + * @since 2025.05.01 + * + * @param string $value + * + * @return void + */ + public function setOutboundHost(string $value): void; + + /** + * Gets the inbound port + * + * @since 2025.05.01 + * + * @return int Inbound port number + */ + public function getInboundPort(): int; + + /** + * Sets the inbound port + * + * @since 2025.05.01 + * + * @param int $value + * + * @return void + */ + public function setInboundPort(int $value): void; + + /** + * Gets the outbound port + * + * @since 2025.05.01 + * + * @return int Outbound port number + */ + public function getOutboundPort(): int; + + /** + * Sets the outbound port + * + * @since 2025.05.01 + * + * @param int $value + * + * @return void + */ + public function setOutboundPort(int $value): void; + + /** + * Gets the inbound encryption/security mode + * + * @since 2025.12.01 + * + * @return string One of: 'none', 'ssl', 'tls', 'starttls' + */ + public function getInboundEncryption(): string; + + /** + * Sets the inbound encryption/security mode + * + * @since 2025.12.01 + * + * @param string $value One of: 'none', 'ssl', 'tls', 'starttls' + * + * @return void + */ + public function setInboundEncryption(string $value): void; + + /** + * Gets the outbound encryption/security mode + * + * @since 2025.12.01 + * + * @return string One of: 'none', 'ssl', 'tls', 'starttls' + */ + public function getOutboundEncryption(): string; + + /** + * Sets the outbound encryption/security mode + * + * @since 2025.12.01 + * + * @param string $value One of: 'none', 'ssl', 'tls', 'starttls' + * + * @return void + */ + public function setOutboundEncryption(string $value): void; + + /** + * Gets whether to verify inbound SSL/TLS peer certificate + * + * @since 2025.12.01 + * + * @return bool + */ + public function getInboundVerifyPeer(): bool; + + /** + * Sets whether to verify inbound SSL/TLS peer certificate + * + * @since 2025.12.01 + * + * @param bool $value + * + * @return void + */ + public function setInboundVerifyPeer(bool $value): void; + + /** + * Gets whether to verify inbound SSL/TLS certificate host + * + * @since 2025.12.01 + * + * @return bool + */ + public function getInboundVerifyHost(): bool; + + /** + * Sets whether to verify inbound SSL/TLS certificate host + * + * @since 2025.12.01 + * + * @param bool $value + * + * @return void + */ + public function setInboundVerifyHost(bool $value): void; + + /** + * Gets whether to verify outbound SSL/TLS peer certificate + * + * @since 2025.12.01 + * + * @return bool + */ + public function getOutboundVerifyPeer(): bool; + + /** + * Sets whether to verify outbound SSL/TLS peer certificate + * + * @since 2025.12.01 + * + * @param bool $value + * + * @return void + */ + public function setOutboundVerifyPeer(bool $value): void; + + /** + * Gets whether to verify outbound SSL/TLS certificate host + * + * @since 2025.12.01 + * + * @return bool + */ + public function getOutboundVerifyHost(): bool; + + /** + * Sets whether to verify outbound SSL/TLS certificate host + * + * @since 2025.12.01 + * + * @param bool $value + * + * @return void + */ + public function setOutboundVerifyHost(bool $value): void; + +} diff --git a/shared/lib/Resource/Provider/ResourceServiceLocationUri.php b/shared/lib/Resource/Provider/ResourceServiceLocationUri.php new file mode 100644 index 0000000..e502ea3 --- /dev/null +++ b/shared/lib/Resource/Provider/ResourceServiceLocationUri.php @@ -0,0 +1,150 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider; + +/** + * Resource Service Location Uri + * + * URI-based service location for API and web services (e.g., https://api.example.com:443/v1/endpoint). + * + * @since 2025.05.01 + */ +interface ResourceServiceLocationUri extends ResourceServiceLocationInterface { + + /** + * Gets the complete location URI + * + * @since 2025.05.01 + * + * @return string Complete URI (e.g., "https://api.example.com:443/v1") + */ + public function location(): string; + + /** + * Gets the URI scheme + * + * @since 2025.05.01 + * + * @return string Scheme (e.g., "https", "http") + */ + public function getScheme(): string; + + /** + * Sets the URI scheme + * + * @since 2025.05.01 + * + * @param string $value + * + * @return void + */ + public function setScheme(string $value): void; + + /** + * Gets the host + * + * @since 2025.05.01 + * + * @return string Host (e.g., "api.example.com") + */ + public function getHost(): string; + + /** + * Sets the host + * + * @since 2025.05.01 + * + * @param string $value + * + * @return void + */ + public function setHost(string $value): void; + + /** + * Gets the port + * + * @since 2025.05.01 + * + * @return int Port number + */ + public function getPort(): int; + + /** + * Sets the port + * + * @since 2025.05.01 + * + * @param int $value + * + * @return void + */ + public function setPort(int $value): void; + + /** + * Gets the path + * + * @since 2025.05.01 + * + * @return string Path (e.g., "/v1/api") + */ + public function getPath(): string; + + /** + * Sets the path + * + * @since 2025.05.01 + * + * @param string $value + * + * @return void + */ + public function setPath(string $value): void; + + /** + * Gets whether to verify SSL/TLS peer certificate + * + * @since 2025.12.01 + * + * @return bool + */ + public function getVerifyPeer(): bool; + + /** + * Sets whether to verify SSL/TLS peer certificate + * + * @since 2025.12.01 + * + * @param bool $value + * + * @return void + */ + public function setVerifyPeer(bool $value): void; + + /** + * Gets whether to verify SSL/TLS certificate host + * + * @since 2025.12.01 + * + * @return bool + */ + public function getVerifyHost(): bool; + + /** + * Sets whether to verify SSL/TLS certificate host + * + * @since 2025.12.01 + * + * @param bool $value + * + * @return void + */ + public function setVerifyHost(bool $value): void; + +} diff --git a/shared/lib/Resource/Provider/ResourceServiceMutateInterface.php b/shared/lib/Resource/Provider/ResourceServiceMutateInterface.php new file mode 100644 index 0000000..439fdd0 --- /dev/null +++ b/shared/lib/Resource/Provider/ResourceServiceMutateInterface.php @@ -0,0 +1,57 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Provider; + +use KTXF\Json\JsonDeserializable; + +/** + * Resource Service Configurable Interface + * + * Extends base service interface with setter methods for mutable properties. + * Used for service configuration and updates. + * + * @since 2025.05.01 + */ +interface ResourceServiceMutateInterface extends ResourceServiceBaseInterface, JsonDeserializable { + + /** + * Sets the localized human-friendly name of this service (e.g. ACME Company Mail Service) + * + * @since 2025.05.01 + * + * @param string $value Service label + * + * @return self + */ + public function setLabel(string $value): self; + + /** + * Sets the active status of this service + * + * @since 2025.05.01 + * + * @param bool $value True to enable, false to disable + * + * @return self + */ + public function setEnabled(bool $value): self; + + /** + * Sets the auxiliary information of this service + * + * @since 2025.05.01 + * + * @param array $value Arbitrary key-value pairs for additional service info + * + * @return self + */ + public function setAuxiliary(array $value): self; + +} diff --git a/shared/lib/Resource/Range/IRange.php b/shared/lib/Resource/Range/IRange.php new file mode 100644 index 0000000..4d3596a --- /dev/null +++ b/shared/lib/Resource/Range/IRange.php @@ -0,0 +1,21 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Range; + +interface IRange { + + /** + * Gets the type of this range + * + * @since 1.0.0 + */ + public function type(): RangeType; + +} diff --git a/shared/lib/Resource/Range/IRangeDate.php b/shared/lib/Resource/Range/IRangeDate.php new file mode 100644 index 0000000..c9b221f --- /dev/null +++ b/shared/lib/Resource/Range/IRangeDate.php @@ -0,0 +1,40 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Range; + +use DateTimeInterface; + +interface IRangeDate extends IRange { + + /** + * + * @since 1.0.0 + */ + public function getStart(): DateTimeInterface; + + /** + * + * @since 1.0.0 + */ + public function setStart(DateTimeInterface $value): void; + + /** + * + * @since 1.0.0 + */ + public function getEnd(): DateTimeInterface; + + /** + * + * @since 1.0.0 + */ + public function setEnd(DateTimeInterface $value): void; + +} diff --git a/shared/lib/Resource/Range/IRangeTally.php b/shared/lib/Resource/Range/IRangeTally.php new file mode 100644 index 0000000..b26abfa --- /dev/null +++ b/shared/lib/Resource/Range/IRangeTally.php @@ -0,0 +1,56 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Range; + +interface IRangeTally extends IRange { + + /** + * Gets the anchor type of the range + * + * @since 1.0.0 + */ + public function getAnchor(): RangeAnchorType; + + /** + * Sets the anchor type of the range + * + * @since 1.0.0 + */ + public function setAnchor(RangeAnchorType $value): void; + + /** + * Gets the start position of the range + * + * @since 1.0.0 + */ + public function getPosition(): string|int; + + /** + * Sets the start position of the range + * + * @since 1.0.0 + */ + public function setPosition(string|int $value): void; + + /** + * Gets the count of items in the range + * + * @since 1.0.0 + */ + public function getTally(): int; + + /** + * Sets the count of items in the range + * + * @since 1.0.0 + */ + public function setTally(int $value): void; + +} diff --git a/shared/lib/Resource/Range/Range.php b/shared/lib/Resource/Range/Range.php new file mode 100644 index 0000000..49e38ac --- /dev/null +++ b/shared/lib/Resource/Range/Range.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Range; + +class Range implements IRange { + + /** + * Returns the type of the range + * + * @since 1.0.0 + */ + public function type(): RangeType { + return RangeType::NONE; + } + +} diff --git a/shared/lib/Resource/Range/RangeAnchorType.php b/shared/lib/Resource/Range/RangeAnchorType.php new file mode 100644 index 0000000..c6cb66f --- /dev/null +++ b/shared/lib/Resource/Range/RangeAnchorType.php @@ -0,0 +1,15 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Range; + +enum RangeAnchorType: string { + case RELATIVE = 'relative'; // A relative anchor is used to indicate a starting position based on a record identifier + case ABSOLUTE = 'absolute'; // A absolute anchor is used to indicate a starting position based on record count +} diff --git a/shared/lib/Resource/Range/RangeDate.php b/shared/lib/Resource/Range/RangeDate.php new file mode 100644 index 0000000..069049b --- /dev/null +++ b/shared/lib/Resource/Range/RangeDate.php @@ -0,0 +1,65 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Range; + +use DateTime; +use DateTimeInterface; + +class RangeDate extends Range implements IRangeDate { + + protected DateTimeInterface $start; + protected DateTimeInterface $end; + + /** + * Returns the type of the range + * + * @since 1.0.0 + */ + public function type(): RangeType { + return RangeType::DATE; + } + + /** + * Gets the start date of the range + * + * @since 1.0.0 + */ + public function getStart(): DateTimeInterface { + return $this->start; + } + + /** + * Sets the start date of the range + * + * @since 1.0.0 + */ + public function setStart(DateTimeInterface $value): void { + $this->start = $value; + } + + /** + * Gets the end date of the range + * + * @since 1.0.0 + */ + public function getEnd(): DateTimeInterface { + return $this->end; + } + + /** + * Sets the end date of the range + * + * @since 1.0.0 + */ + public function setEnd(DateTimeInterface $value): void { + $this->end = $value; + } + +} diff --git a/shared/lib/Resource/Range/RangeTally.php b/shared/lib/Resource/Range/RangeTally.php new file mode 100644 index 0000000..4ce4e6d --- /dev/null +++ b/shared/lib/Resource/Range/RangeTally.php @@ -0,0 +1,81 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Range; + +class RangeTally extends Range implements IRangeTally { + + protected RangeAnchorType $anchor = RangeAnchorType::ABSOLUTE; + protected string|int $position = 0; + protected int $tally = 32; + + /** + * Returns the type of the range + * + * @since 1.0.0 + */ + public function type(): RangeType { + return RangeType::TALLY; + } + + /** + * Gets the anchor type of the range + * + * @since 1.0.0 + */ + public function getAnchor(): RangeAnchorType { + return $this->anchor; + } + + /** + * Sets the anchor type of the range + * + * @since 1.0.0 + */ + public function setAnchor(RangeAnchorType $value): void { + $this->anchor = $value; + } + + /** + * Gets the start position of the range + * + * @since 1.0.0 + */ + public function getPosition(): string|int { + return $this->position; + } + + /** + * Sets the start position of the range + * + * @since 1.0.0 + */ + public function setPosition(string|int $value): void { + $this->position = $value; + } + + /** + * Gets the count of items in the range + * + * @since 1.0.0 + */ + public function getTally(): int { + return $this->tally; + } + + /** + * Sets the count of items in the range + * + * @since 1.0.0 + */ + public function setTally(int $value): void { + $this->tally = $value; + } + +} diff --git a/shared/lib/Resource/Range/RangeType.php b/shared/lib/Resource/Range/RangeType.php new file mode 100644 index 0000000..7d4b6af --- /dev/null +++ b/shared/lib/Resource/Range/RangeType.php @@ -0,0 +1,16 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Range; + +enum RangeType: string { + case NONE = 'none'; + case DATE = 'date'; + case TALLY = 'tally'; +} diff --git a/shared/lib/Resource/Selector/CollectionSelector.php b/shared/lib/Resource/Selector/CollectionSelector.php new file mode 100644 index 0000000..7a90fad --- /dev/null +++ b/shared/lib/Resource/Selector/CollectionSelector.php @@ -0,0 +1,22 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Selector; + +/** + * Collection-level selector + */ +class CollectionSelector extends SelectorAbstract { + + protected array $keyTypes = ['string', 'integer']; + protected array $valueTypes = ['boolean', EntitySelector::class, 'string']; + protected string $nestedSelector = EntitySelector::class; + protected string $selectorName = 'CollectionSelector'; + +} diff --git a/shared/lib/Resource/Selector/EntitySelector.php b/shared/lib/Resource/Selector/EntitySelector.php new file mode 100644 index 0000000..23ae89d --- /dev/null +++ b/shared/lib/Resource/Selector/EntitySelector.php @@ -0,0 +1,46 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Selector; + +/** + * Entity-level selector (leaf node) + */ +class EntitySelector extends SelectorAbstract { + + protected array $keyTypes = ['string', 'integer']; + protected array $valueTypes = ['boolean', 'array', 'string', 'integer']; + protected string $selectorName = 'EntitySelector'; + + public function append($value): void { + if (!is_string($value) && !is_int($value)) { + throw new \InvalidArgumentException('EntitySelector values must be string or int'); + } + parent::append($value); + } + + public function offsetSet($key, $value): void { + if ($key !== null && !is_int($key)) { + throw new \InvalidArgumentException('EntitySelector does not support associative keys'); + } + if (!is_string($value) && !is_int($value)) { + throw new \InvalidArgumentException('EntitySelector values must be string or int'); + } + parent::offsetSet($key, $value); + } + + /** + * Get all entity identifiers + * @return array + */ + public function identifiers(): array { + return $this->getArrayCopy(); + } + +} diff --git a/shared/lib/Resource/Selector/SelectorAbstract.php b/shared/lib/Resource/Selector/SelectorAbstract.php new file mode 100644 index 0000000..143566d --- /dev/null +++ b/shared/lib/Resource/Selector/SelectorAbstract.php @@ -0,0 +1,129 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Selector; + +use JsonSerializable; +use KTXF\Json\JsonDeserializable; + +/** + * Abstract base class for all selector types + * Provides common functionality for hierarchical selectors + */ +abstract class SelectorAbstract extends \ArrayObject implements JsonSerializable, JsonDeserializable { + + protected const TYPE_STRING = 'string'; + protected const TYPE_INT = 'int'; + protected const TYPE_BOOL = 'bool'; + + /** @var array Allowed key types: 'string', 'int' */ + protected array $keyTypes = []; + + /** @var array Allowed scalar value types: 'string', 'int', 'bool' */ + protected array $valueTypes = []; + + /** @var class-string selector class for nested structures */ + protected string $nestedSelector = SelectorAbstract::class; + + /** @var string Human-readable name for this selector type */ + protected string $selectorName = 'Selector'; + + /** + * Serialize to JSON-compatible array + * + * @return array + */ + public function jsonSerialize(): array { + $result = []; + foreach ($this as $key => $value) { + if ($value instanceof JsonSerializable) { + $result[$key] = $value->jsonSerialize(); + } else { + $result[$key] = $value; + } + } + return $result; + } + + /** + * Deserialize from JSON-compatible array + * @param array $data + * @return void + * @throws \InvalidArgumentException + */ + public function jsonDeserialize(array|string $data): static { + if (is_string($data)) { + $data = json_decode($data, true); + } + foreach ($data as $key => $value) { + if ($this->nestedSelector !== null && is_array($value)) { + $selector = new $this->nestedSelector(); + $selector->jsonDeserialize($value); + $this->offsetSet($key, $selector); + } else { + $this->offsetSet($key, $value); + } + } + + return $this; + } + + /** + * Validate if a key is of the correct type for this selector + * + * @param mixed $key + * @return bool + */ + protected function validateKey(mixed $key): bool { + return in_array(gettype($key), $this->keyTypes, true); + } + + /** + * Validate if a value is of the correct type for this selector + * + * @param mixed $value + * @return bool + */ + protected function validateValue(mixed $value): bool { + if ($this->nestedSelector !== null && $value instanceof $this->nestedSelector) { + return true; + } + return in_array(gettype($value), $this->valueTypes, true); + } + + /** + * Override offsetSet to enforce type checking + * + * @param mixed $key + * @param mixed $value + * @return void + * @throws \InvalidArgumentException + */ + #[\Override] + public function offsetSet($key, $value): void { + if (!$this->validateKey($key)) { + throw new \InvalidArgumentException("{$this->selectorName} keys must be one of [" . implode(', ', $this->keyTypes) . "], got " . gettype($key)); + } + + if (!$this->validateValue($value)) { + throw new \InvalidArgumentException("{$this->selectorName} values must be one of [" . implode(', ', $this->valueTypes) . "], got " . gettype($value)); + } + parent::offsetSet($key, $value); + } + + /** + * Get all identifiers (keys or values depending on selector type) + * + * @return array + */ + public function identifiers(): array { + return array_keys($this->getArrayCopy()); + } + +} diff --git a/shared/lib/Resource/Selector/ServiceSelector.php b/shared/lib/Resource/Selector/ServiceSelector.php new file mode 100644 index 0000000..7b0e2e6 --- /dev/null +++ b/shared/lib/Resource/Selector/ServiceSelector.php @@ -0,0 +1,22 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Selector; + +/** + * Service-level selector + */ +class ServiceSelector extends SelectorAbstract { + + protected array $keyTypes = ['string', 'int']; + protected array $valueTypes = ['boolean', CollectionSelector::class]; + protected string $nestedSelector = CollectionSelector::class; + protected string $selectorName = 'CollectionSelector'; + +} diff --git a/shared/lib/Resource/Selector/SourceSelector.php b/shared/lib/Resource/Selector/SourceSelector.php new file mode 100644 index 0000000..4c592e4 --- /dev/null +++ b/shared/lib/Resource/Selector/SourceSelector.php @@ -0,0 +1,22 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Selector; + +/** + * Top-level selector for sources + */ +class SourceSelector extends SelectorAbstract { + + protected array $keyTypes = ['string']; + protected array $valueTypes = ['boolean', ServiceSelector::class]; + protected string $nestedSelector = ServiceSelector::class; + protected string $selectorName = 'ProviderSelector'; + +} diff --git a/shared/lib/Resource/Sort/ISort.php b/shared/lib/Resource/Sort/ISort.php new file mode 100644 index 0000000..6af666d --- /dev/null +++ b/shared/lib/Resource/Sort/ISort.php @@ -0,0 +1,42 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Sort; + +interface ISort { + + /** + * List of available attributes + * + * @since 1.0.0 + * + * @return array + */ + public function attributes(): array; + + /** + * Define sort condition + * + * @since 1.0.0 + * + * @param string $attribute attribute name + * @param bool $direction true for ascending, false for descending + */ + public function condition(string $property, bool $direction): void; + + /** + * List of sort conditions + * + * @since 1.0.0 + * + * @return array + */ + public function conditions(): array; + +} diff --git a/shared/lib/Resource/Sort/Sort.php b/shared/lib/Resource/Sort/Sort.php new file mode 100644 index 0000000..f2023b9 --- /dev/null +++ b/shared/lib/Resource/Sort/Sort.php @@ -0,0 +1,57 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Sort; + +class Sort implements ISort { + + protected array $attributes = []; + protected array $conditions = []; + + public function __construct(array $attributes) { + $this->attributes = $attributes; + } + + /** + * + * @since 1.0.0 + * + * @return array + */ + public function attributes(): array { + return $this->attributes; + } + + /** + * + * @since 1.0.0 + * + * @param string $attribute attribute name + * @param bool $direction true for ascending, false for descending + */ + public function condition(string $attribute, bool $direction): void { + if (isset($this->attributes[$attribute])) { + $this->conditions[$attribute] = [ + 'attribute' => $attribute, + 'direction' => $direction, + ]; + } + } + + /** + * + * @since 1.0.0 + * + * @return array + */ + public function conditions(): array { + return $this->conditions; + } + +} diff --git a/shared/lib/Routing/Attributes/AnonymousRoute.php b/shared/lib/Routing/Attributes/AnonymousRoute.php new file mode 100644 index 0000000..7cb67ba --- /dev/null +++ b/shared/lib/Routing/Attributes/AnonymousRoute.php @@ -0,0 +1,15 @@ +state; + } + + /** + * Check if session has expired + */ + public function isExpired(): bool + { + return time() > $this->expiresAt; + } + + /** + * Check if session is in initial state + */ + public function isFresh(): bool + { + return $this->state === self::STATE_FRESH; + } + + /** + * Check if session has identity but awaiting authentication + */ + public function isIdentified(): bool + { + return $this->state === self::STATE_IDENTIFIED; + } + + /** + * Check if session is in the process of authenticating + */ + public function isAuthenticating(): bool + { + return $this->state === self::STATE_AUTHENTICATING; + } + + /** + * Check if session is complete + */ + public function isComplete(): bool + { + return $this->state === self::STATE_COMPLETE; + } + + /** + * Set user identity (before authentication) + */ + public function setIdentity(string $value): void + { + $this->userIdentity = $value; + $this->state = self::STATE_IDENTIFIED; + } + + public function setMethods(array $methods, int $require = 1): void + { + $this->methodsAvailable = $methods; + $this->methodsRequired = $require; + } + + public function methodEligible(string $method): bool + { + return in_array($method, $this->methodsAvailable, true) + && !in_array($method, $this->methodsCompleted, true); + } + /** + * Mark a method as completed + */ + public function methodCompleted(string $method): void + { + if (!in_array($method, $this->methodsCompleted, true)) { + $this->methodsCompleted[] = $method; + } + + // If we have required factors and all are complete, mark session complete + if (count($this->methodsCompleted) >= $this->methodsRequired) { + $this->state = self::STATE_COMPLETE; + } + } + + /** + * Get methods that still need to be completed + */ + public function methodsRemaining(): array + { + return array_values(array_diff($this->methodsAvailable, $this->methodsCompleted)); + } + + /** + * Promote session after successful primary auth (set user info) + */ + public function setUser(string $userIdentifier, string $userIdentity): void + { + $this->userIdentifier = $userIdentifier; + $this->userIdentity = $userIdentity; + } + + /** + * Get metadata value + */ + public function getMeta(string $key, mixed $default = null): mixed + { + return $this->metadata[$key] ?? $default; + } + + /** + * Set metadata value + */ + public function setMeta(string $key, mixed $value): void + { + $this->metadata[$key] = $value; + } + + /** + * Serialize to array for storage + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'state' => $this->state, + 'tenant_identifier' => $this->tenantIdentifier, + 'user_identifier' => $this->userIdentifier, + 'user_identity' => $this->userIdentity, + 'methods_required' => $this->methodsRequired, + 'methods_available' => $this->methodsAvailable, + 'methods_completed' => $this->methodsCompleted, + 'metadata' => $this->metadata, + 'created_at' => $this->createdAt, + 'expires_at' => $this->expiresAt, + ]; + } + + /** + * Deserialize from array + */ + public static function fromArray(array $data): self + { + return new self( + id: $data['id'], + state: $data['state'], + tenantIdentifier: $data['tenant_identifier'], + userIdentifier: $data['user_identifier'] ?? null, + userIdentity: $data['user_identity'] ?? null, + methodsRequired: $data['methods_required'] ?? 1, + methodsAvailable: $data['methods_available'] ?? [], + methodsCompleted: $data['methods_completed'] ?? [], + metadata: $data['metadata'] ?? [], + createdAt: $data['created_at'], + expiresAt: $data['expires_at'], + ); + } +} diff --git a/shared/lib/Security/Authentication/ProviderContext.php b/shared/lib/Security/Authentication/ProviderContext.php new file mode 100644 index 0000000..965eacc --- /dev/null +++ b/shared/lib/Security/Authentication/ProviderContext.php @@ -0,0 +1,75 @@ +metadata[$key] ?? $default; + } + + /** + * Get config value + */ + public function getConfig(string $key, mixed $default = null): mixed + { + return $this->config[$key] ?? $default; + } + + /** + * Create context with updated metadata + */ + public function withMetadata(array $metadata): self + { + return new self( + tenantId: $this->tenantId, + userIdentifier: $this->userIdentifier, + userIdentity: $this->userIdentity, + metadata: $metadata, + config: $this->config, + ); + } + + /** + * Create context with user identifier set + */ + public function withUserIdentifier(string $userIdentifier): self + { + return new self( + tenantId: $this->tenantId, + userIdentifier: $userIdentifier, + userIdentity: $this->userIdentity, + metadata: $this->metadata, + config: $this->config, + ); + } +} diff --git a/shared/lib/Security/Authentication/ProviderResult.php b/shared/lib/Security/Authentication/ProviderResult.php new file mode 100644 index 0000000..3ac8f4e --- /dev/null +++ b/shared/lib/Security/Authentication/ProviderResult.php @@ -0,0 +1,156 @@ + $challengeInfo], + sessionData: $sessionData, + ); + } + + /** + * Create a redirect result (for OIDC/SAML) + */ + public static function redirect(string $url, array $sessionData): self + { + return new self( + status: self::REDIRECT, + clientData: ['redirect_url' => $url], + sessionData: $sessionData, + ); + } + + // ========================================================================= + // Status Checks + // ========================================================================= + + public function isSuccess(): bool + { + return $this->status === self::SUCCESS; + } + + public function isFailed(): bool + { + return $this->status === self::FAILED; + } + + public function isChallenge(): bool + { + return $this->status === self::CHALLENGE; + } + + public function isRedirect(): bool + { + return $this->status === self::REDIRECT; + } + + // ========================================================================= + // Data Access + // ========================================================================= + + /** + * Get identity claim + */ + public function getIdentity(string $key, mixed $default = null): mixed + { + return $this->identity[$key] ?? $default; + } + + /** + * Get client data value + */ + public function getClientData(string $key, mixed $default = null): mixed + { + return $this->clientData[$key] ?? $default; + } + + /** + * Get session data value + */ + public function getSessionData(string $key, mixed $default = null): mixed + { + return $this->sessionData[$key] ?? $default; + } +} diff --git a/shared/lib/Security/Crypto.php b/shared/lib/Security/Crypto.php new file mode 100644 index 0000000..6b78ba7 --- /dev/null +++ b/shared/lib/Security/Crypto.php @@ -0,0 +1,170 @@ +tenantSecret(); + if ($password === null) { + throw new \RuntimeException('Tenant secret unavailable for encryption'); + } + } + + $nonce = random_bytes(self::NONCE_LEN); + $key = hash_hkdf('sha256', $password); + if ($key === false || strlen($key) !== self::KEY_LEN) { + throw new \RuntimeException('Key derivation failed'); + } + + $aes = new AES('gcm'); + $aes->setKey($key); + $aes->setNonce($nonce); + + $encryptedData = $aes->encrypt($data); + if ($encryptedData === false) { + throw new \RuntimeException('Encryption failed'); + } + $tag = $aes->getTag(self::TAG_LEN); + if ($tag === false || strlen($tag) !== self::TAG_LEN) { + throw new \RuntimeException('Authentication tag retrieval failed'); + } + + $nonceLen = strlen($nonce); + $tagLen = strlen($tag); + $dataLen = strlen($encryptedData); + + $header = self::ENCODING_HEADER_TAG + . chr(self::ENCODING_HEADER_VERSION) + . chr(0x00) // flags + . pack('n', $nonceLen) // uint16 BE + . pack('n', $tagLen) // uint16 BE + . pack('N', $dataLen); // uint32 BE + + $binary = $header . $nonce . $tag . $encryptedData; + return bin2hex($binary); + } + + /** + * Decrypt hex-encoded length-prefixed binary envelope. + */ + public function decrypt(string $data, ?string $password = null): string + { + if ($password === null) { + $password = $this->tenantSecret(); + if ($password === null) { + throw new \RuntimeException('Tenant secret unavailable for decryption'); + } + } + + if (!ctype_xdigit($data) || strlen($data) % 2 !== 0) { + throw new \InvalidArgumentException('Invalid data format'); + } + $binary = hex2bin($data); + if ($binary === false || strlen($binary) < self::ENCODING_HEADER_LEN) { + throw new \InvalidArgumentException('Invalid data format'); + } + + if (substr($binary, 0, 4) !== self::ENCODING_HEADER_TAG) { + throw new \InvalidArgumentException('Invalid data format'); + } + + if (ord($binary[4]) !== self::ENCODING_HEADER_VERSION) { + throw new \InvalidArgumentException('Unsupported version'); + } + $flags = ord($binary[5]); // currently unused; reserved for future + $nonceLen = unpack('n', substr($binary, 6, 2))[1]; + $tagLen = unpack('n', substr($binary, 8, 2))[1]; + $dataLen = unpack('N', substr($binary, 10, 4))[1]; + + if (strlen($binary) !== (self::ENCODING_HEADER_LEN + $nonceLen + $tagLen + $dataLen)) { + throw new \InvalidArgumentException('Invalid data format'); + } + + $nonce = substr($binary, 14, $nonceLen); + $tag = substr($binary, 14 + $nonceLen, $tagLen); + $encryptedData = substr($binary, 14 + $nonceLen + $tagLen, $dataLen); + + $key = hash_hkdf('sha256', $password); + if ($key === false || strlen($key) !== self::KEY_LEN) { + throw new \RuntimeException('Key derivation failed'); + } + + $aes = new AES('gcm'); + $aes->setKey($key); + $aes->setNonce($nonce); + $aes->setTag($tag); + + $plainData = $aes->decrypt($encryptedData); + if ($plainData === false) { + throw new \RuntimeException('Decryption failed (auth)'); + } + return $plainData; + } + + private function tenantSecret(): ?string + { + $config = $this->sessionTenant->configuration(); + return $config->security()->code(); + } + + // ========================================================================= + // Password Hashing + // ========================================================================= + + /** + * Hash a password using bcrypt + */ + public function hashPassword(string $password): string + { + return password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]); + } + + /** + * Verify a password against a hash + */ + public function verifyPassword(string $password, string $hash): bool + { + return password_verify($password, $hash); + } + + /** + * Check if a password hash needs to be rehashed + */ + public function needsRehash(string $hash): bool + { + return password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => 12]); + } +} diff --git a/shared/lib/Utile/Collection/CollectionAbstract.php b/shared/lib/Utile/Collection/CollectionAbstract.php new file mode 100644 index 0000000..6bd5529 --- /dev/null +++ b/shared/lib/Utile/Collection/CollectionAbstract.php @@ -0,0 +1,136 @@ + + */ +class CollectionAbstract extends \ArrayObject { + + protected const TYPE_STRING = 'string'; + protected const TYPE_INT = 'int'; + protected const TYPE_FLOAT = 'float'; + protected const TYPE_BOOL = 'bool'; + protected const TYPE_ARRAY = 'array'; + protected const TYPE_DATE = 'date'; + + protected bool $associative = false; + protected string $typeKey = 'int'; + protected string $typeValue = 'string'; + + /** + * @param array $data + * @param class-string|string|null $typeValue + * @param class-string|string|null $typeKey + */ + public function __construct(array $data = [], string $typeValue, string|null $typeKey = null) { + // Ensure that all data entries are of the specified type + $this->typeValue = $typeValue; + if ($typeKey !== null) { + $this->typeKey = $typeKey; + $this->associative = true; + } + + foreach ($data as $key => $value) { + if ($this->associative && !$this->validateKey($key)) { + throw new \InvalidArgumentException('Type error: element key ' . $key . ' is not of type ' . $this->typeKey); + } + if (!$this->validateValue($value)) { + throw new \InvalidArgumentException('Type error: element value at index ' . $key . ' is not of type ' . $this->typeValue); + } + } + + if (!$this->associative) { + parent::__construct(array_values($data)); + } else { + parent::__construct($data); + } + } + + private function validateValue($value): bool { + // Check if the value is of the specified type + return match ($this->typeValue) { + self::TYPE_STRING => is_string($value), + self::TYPE_INT, 'integer' => is_int($value), + self::TYPE_FLOAT => is_float($value), + self::TYPE_BOOL, 'boolean' => is_bool($value), + self::TYPE_ARRAY => is_array($value), + self::TYPE_DATE => $value instanceof \DateTimeInterface, + default => $value instanceof $this->typeValue + }; + } + + protected function validateKey($key): bool { + // Check if the key is of the specified type + return match ($this->typeKey) { + self::TYPE_STRING => is_string($key), + default => is_int($key), + }; + } + + public function add($value, string|int|null $key = null): void { + $this->offsetSet($key, $value); + } + + public function remove(string|int $key): void { + $this->offsetUnset($key); + } + + public function extant(string|int $key): bool { + return $this->offsetExists($key); + } + + #[\Override] + public function append(mixed $value): void { + if ($this->associative) { + throw new \LogicException('Cannot append to an associative collection. Use add() or offsetSet() instead.'); + } + // ensure that the value is of the specified type before appending + if (!$this->validateValue($value)) { + throw new \InvalidArgumentException('Type error: value is not of type ' . $this->typeValue); + } + parent::append($value); + } + + #[\Override] + public function offsetSet(mixed $key, mixed $value): void { + if ($this->associative) { + if ($key === null) { + throw new \LogicException('Logic error: Key cannot be null for associative collections'); + } + if (!$this->validateKey($key)) { + throw new \InvalidArgumentException('Type error: key is not of type ' . $this->typeKey); + } + } else { + if ($key !== null) { + throw new \LogicException('Logic error: Key must be null for non-associative collections'); + } + } + if (!$this->validateValue($value)) { + throw new \InvalidArgumentException('Type error: value is not of type ' . $this->typeValue); + } + parent::offsetSet($key, $value); + } + + #[\Override] + public function offsetUnset(mixed $key): void + { + if (!$this->validateKey($key)) { + throw new \InvalidArgumentException('Type error: key is not of type ' . $this->typeKey); + } + parent::offsetUnset($key); + } + + #[\Override] + public function offsetExists(mixed $key): bool + { + if (!$this->validateKey($key)) { + throw new \InvalidArgumentException('Type error: key is not of type ' . $this->typeKey); + } + return parent::offsetExists($key); + } + +} diff --git a/shared/lib/Utile/UUID.php b/shared/lib/Utile/UUID.php new file mode 100644 index 0000000..ce93023 --- /dev/null +++ b/shared/lib/Utile/UUID.php @@ -0,0 +1,54 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Utile; + +/** + * UUID Generator Utility Class + * + * Generates RFC 4122 compliant UUIDs (version 4 - random) + */ +class UUID { + + /** + * Generate a random UUID v4 + * + * @return string UUID in format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + */ + public static function v4(): string { + // Generate 16 random bytes + $bytes = random_bytes(16); + + // Set version to 4 (random) + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + + // Set variant to RFC 4122 + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + + // Format as UUID string + return vsprintf( + '%s%s-%s-%s-%s-%s%s%s', + str_split(bin2hex($bytes), 4) + ); + } + + /** + * Validate a UUID string + * + * @param string $uuid UUID to validate + * @return bool True if valid UUID format + */ + public static function isValid(string $uuid): bool { + return (bool)preg_match( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', + $uuid + ); + } + +} diff --git a/tests/php/bootstrap.php b/tests/php/bootstrap.php new file mode 100644 index 0000000..60aab4c --- /dev/null +++ b/tests/php/bootstrap.php @@ -0,0 +1,7 @@ +jsonSerialize(); + + $this->assertIsArray($serialized); + $this->assertArrayHasKey('publicProperty', $serialized); + $this->assertEquals('test', $serialized['publicProperty']); + $this->assertArrayHasKey('protectedProperty', $serialized); // get_object_vars includes protected + $this->assertEquals('protected', $serialized['protectedProperty']); + $this->assertArrayNotHasKey('privateProperty', $serialized); // but not private + } + + public function testJsonDeserializeSetsProperties(): void + { + $testObject = new class extends JsonSerializableObject { + public string $name = ''; + public int $age = 0; + }; + + $data = [ + 'name' => 'John Doe', + 'age' => 30, + 'nonexistent' => 'ignored' + ]; + + $result = $testObject->jsonDeserialize($data); + + $this->assertSame($testObject, $result); + $this->assertEquals('John Doe', $testObject->name); + $this->assertEquals(30, $testObject->age); + $this->assertObjectNotHasProperty('nonexistent', $testObject); + } + + public function testJsonDeserializeHandlesJsonString(): void + { + $testObject = new class extends JsonSerializableObject { + public string $message = ''; + }; + + $jsonString = '{"message": "Hello World"}'; + $testObject->jsonDeserialize($jsonString); + + $this->assertEquals('Hello World', $testObject->message); + } +} \ No newline at end of file diff --git a/tests/php/shared/People/Entity/Individual/IndividualObjectTest.php b/tests/php/shared/People/Entity/Individual/IndividualObjectTest.php new file mode 100644 index 0000000..6d4cdd6 --- /dev/null +++ b/tests/php/shared/People/Entity/Individual/IndividualObjectTest.php @@ -0,0 +1,109 @@ +urid = 'test-urid-123'; + $individual->label = 'Test Individual'; + $individual->language = 'en'; + + // Set name + $individual->names->First = 'John'; + $individual->names->Last = 'Doe'; + $individual->names->Prefix = 'Mr.'; + + // Add an alias + $alias = new IndividualAliasObject(); + $alias->label = 'Johnny'; + $individual->names->Aliases[] = $alias; + + // Serialize to JSON + $json = json_encode($individual); + + // Verify JSON structure + $this->assertJson($json); + + $data = json_decode($json, true); + $this->assertEquals('individual', $data['type']); + $this->assertEquals(1, $data['version']); + $this->assertEquals('test-urid-123', $data['urid']); + $this->assertEquals('Test Individual', $data['label']); + $this->assertEquals('en', $data['language']); + $this->assertEquals('John', $data['names']['First']); + $this->assertEquals('Doe', $data['names']['Last']); + $this->assertEquals('Mr.', $data['names']['Prefix']); + $this->assertCount(1, $data['names']['Aliases']); + $this->assertEquals('Johnny', $data['names']['Aliases'][0]['label']); + } + + public function testJsonDeserialization(): void + { + $jsonData = [ + 'type' => 'individual', + 'version' => 1, + 'urid' => 'test-urid-456', + 'label' => 'Deserialized Individual', + 'language' => 'fr', + 'names' => [ + 'First' => 'Jane', + 'Last' => 'Smith', + 'Prefix' => 'Ms.', + 'Aliases' => [ + ['label' => 'Janie'] + ] + ] + ]; + + $individual = new IndividualObject(); + $individual->jsonDeserialize($jsonData); + + $this->assertEquals('test-urid-456', $individual->urid); + $this->assertEquals('Deserialized Individual', $individual->label); + $this->assertEquals('fr', $individual->language); + $this->assertEquals('Jane', $individual->names->First); + $this->assertEquals('Smith', $individual->names->Last); + $this->assertEquals('Ms.', $individual->names->Prefix); + $this->assertCount(1, $individual->names->Aliases); + $this->assertEquals('Janie', $individual->names->Aliases[0]->label); + } + + public function testJsonRoundTrip(): void + { + // Create original object + $original = new IndividualObject(); + $original->urid = 'round-trip-urid'; + $original->label = 'Round Trip Test'; + $original->names->First = 'Alice'; + $original->names->Last = 'Wonderland'; + + // Serialize and deserialize + $json = json_encode($original); + $deserialized = new IndividualObject(); + $deserialized->jsonDeserialize($json); + + // Verify round-trip integrity + $this->assertEquals($original->urid, $deserialized->urid); + $this->assertEquals($original->label, $deserialized->label); + $this->assertEquals($original->names->First, $deserialized->names->First); + $this->assertEquals($original->names->Last, $deserialized->names->Last); + + // Verify JSON representations are identical + $originalJson = json_encode($original); + $deserializedJson = json_encode($deserialized); + $this->assertJsonStringEqualsJsonString($originalJson, $deserializedJson); + } +} \ No newline at end of file diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..78c20a9 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "paths": { + "@KTXC/*": ["./core/src/*"] + } + }, + "include": [ + "./core/src/**/*.ts", + "./core/src/**/*.tsx", + "./core/src/**/*.vue"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..82882da --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts", "scripts/**/*.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..8106dff --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,108 @@ +import { defineConfig, type PluginOption } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import vuetify from 'vite-plugin-vuetify'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { viteStaticCopy } from 'vite-plugin-static-copy'; +import { generateVendorShims } from './scripts/generate-vendor-shims'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const runVendorShimGenerator = async (outputDir: string) => { + await generateVendorShims({ outputDir, silent: true }); +}; + +const generateVendorShimsPlugin = (): PluginOption => { + let outDir = 'dist'; + let rootDir = process.cwd(); + + return { + name: 'generate-vendor-shims', + apply: 'build', + configResolved(config) { + outDir = config.build.outDir; + rootDir = config.root; + }, + async closeBundle() { + try { + const resolvedOutDir = path.isAbsolute(outDir) ? outDir : path.resolve(rootDir, outDir); + const vendorDestination = path.resolve(resolvedOutDir, 'vendor'); + await runVendorShimGenerator(vendorDestination); + } catch (error) { + console.warn('[generate-vendor-shims] Failed to update vendor shims', error); + } + }, + }; +}; + +// https://vite.dev/config/ +export default defineConfig(({ mode }) => ({ + root: path.resolve(__dirname, 'core/src'), + plugins: [ + vue(), + vuetify(), + generateVendorShimsPlugin(), + viteStaticCopy({ + targets: [ + { + src: path.resolve(__dirname, 'core/lib/index.php'), + dest: path.resolve(__dirname, 'public'), + }, + ], + }), + ], + resolve: { + alias: { + '@KTXC': path.resolve(__dirname, 'core/src'), + }, + }, + server: { + fs: { + allow: ['..'], // Allow serving files from one level up to find the project root + }, + host: true, + }, + build: { + outDir: path.resolve(__dirname, 'public'), + emptyOutDir: true, + minify: mode === 'production', + sourcemap: true, + rollupOptions: { + input: { + public: path.resolve(__dirname, 'core/src/public.html'), + private: path.resolve(__dirname, 'core/src/private.html'), + 'shared-utils': path.resolve(__dirname, 'core/src/utils/helpers/shared.ts'), + }, + output: { + // Preserve export names for shared-utils (used via import map by modules) + minifyInternalExports: false, + entryFileNames: (chunkInfo) => { + // Keep shared-utils without hash for stable import map reference + if (chunkInfo.name === 'shared-utils') { + return `js/[name].js`; + } + return `js/[name]-[hash].js`; + }, + chunkFileNames: (chunkInfo) => { + return `js/[name]-[hash].js`; + }, + assetFileNames: (assetInfo) => { + if (assetInfo.name) { + const extType = assetInfo.name.split('.').pop(); + if (extType && /png|jpe?g|svg|gif|tiff|bmp|ico/i.test(extType)) { + return `images/[name]-[hash][extname]`; + } + if (extType && /woff|woff2|eot|ttf|otf/i.test(extType)) { + return `fonts/[name]-[hash][extname]`; + } + if (extType === 'css') { + return `css/[name]-[hash][extname]`; + } + } + return `[name]-[hash][extname]`; + }, + }, + }, + }, +}));