From 79192345e3a089a9897108f73cbd3cd4745883e0 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sat, 14 Feb 2026 15:14:12 +0100 Subject: [PATCH] Upload beautify --- .copilot/categories-analysis.md | 49 + .env.example | 144 + README.md | 366 ++ .../AggregateFeedAnalyticsCommand.php | 106 + ...ggregateSimilarArtworkAnalyticsCommand.php | 54 + app/Console/Commands/AvatarsMigrate.php | 83 + .../BackfillArtworkEmbeddingsCommand.php | 28 + app/Console/Commands/CompareFeedAbCommand.php | 71 + .../Commands/EvaluateFeedWeightsCommand.php | 81 + app/Console/Commands/ImportLegacyUsers.php | 12 +- app/Console/Kernel.php | 17 +- app/DTOs/Artworks/ArtworkDraftResult.php | 14 + app/DTOs/Uploads/UploadChunkResult.php | 17 + app/DTOs/Uploads/UploadInitResult.php | 15 + app/DTOs/Uploads/UploadScanResult.php | 24 + app/DTOs/Uploads/UploadSessionData.php | 22 + app/DTOs/Uploads/UploadStoredFile.php | 23 + app/DTOs/Uploads/UploadValidatedFile.php | 14 + app/DTOs/Uploads/UploadValidationResult.php | 28 + .../Admin/FeedPerformanceReportController.php | 117 + .../Admin/SimilarArtworkReportController.php | 114 + .../Api/Admin/UploadModerationController.php | 83 + .../Controllers/Api/ArtworkController.php | 24 + .../Controllers/Api/ArtworkTagController.php | 91 + .../Api/DiscoveryEventController.php | 49 + .../Api/FeedAnalyticsController.php | 45 + app/Http/Controllers/Api/FeedController.php | 35 + .../Api/SimilarArtworkAnalyticsController.php | 41 + app/Http/Controllers/Api/TagController.php | 52 + app/Http/Controllers/Api/UploadController.php | 497 +++ app/Http/Controllers/ArtworkController.php | 113 +- app/Http/Controllers/AvatarController.php | 47 + .../Controllers/CategoryPageController.php | 70 +- .../Controllers/ContentRouterController.php | 45 + .../Dashboard/ArtworkController.php | 16 +- .../Legacy/PhotographyController.php | 138 +- .../Controllers/Legacy/UserController.php | 109 +- app/Http/Controllers/ManageController.php | 43 +- app/Http/Controllers/ProfileController.php | 138 +- app/Http/Controllers/Web/TagController.php | 29 + .../Middleware/EnsureAdminOrModerator.php | 24 + app/Http/Middleware/HandleInertiaRequests.php | 33 + .../Artworks/ArtworkCreateRequest.php | 46 + .../Artworks/ArtworkTagsStoreRequest.php | 28 + .../Artworks/ArtworkTagsUpdateRequest.php | 28 + .../Dashboard/ArtworkDestroyRequest.php | 68 + .../Requests/Dashboard/ArtworkEditRequest.php | 68 + .../Dashboard/UpdateArtworkRequest.php | 49 +- .../Manage/ManageArtworkDestroyRequest.php | 68 + .../Manage/ManageArtworkEditRequest.php | 68 + .../Manage/ManageArtworkUpdateRequest.php | 74 + app/Http/Requests/ProfileUpdateRequest.php | 17 +- app/Http/Requests/Tags/PopularTagsRequest.php | 27 + app/Http/Requests/Tags/TagSearchRequest.php | 27 + .../Requests/Uploads/UploadCancelRequest.php | 83 + .../Requests/Uploads/UploadChunkRequest.php | 91 + .../Requests/Uploads/UploadFinishRequest.php | 108 + .../Requests/Uploads/UploadInitRequest.php | 22 + .../Requests/Uploads/UploadStatusRequest.php | 87 + app/Jobs/AutoTagArtworkJob.php | 401 +++ app/Jobs/BackfillArtworkEmbeddingsJob.php | 61 + app/Jobs/GenerateArtworkEmbeddingJob.php | 178 + app/Jobs/GenerateDerivativesJob.php | 38 + app/Jobs/IngestUserDiscoveryEventJob.php | 122 + .../RegenerateUserRecommendationCacheJob.php | 47 + app/Models/Artwork.php | 60 +- app/Models/ArtworkEmbedding.php | 37 + app/Models/ArtworkSimilarity.php | 42 + app/Models/Category.php | 46 + app/Models/ContentType.php | 15 + app/Models/Tag.php | 39 + app/Models/Upload.php | 51 + app/Models/User.php | 16 + app/Models/UserDiscoveryEvent.php | 39 + app/Models/UserInterestProfile.php | 31 + app/Models/UserProfile.php | 69 + app/Models/UserRecommendationCache.php | 29 + app/Policies/ArtworkPolicy.php | 27 + app/Providers/AppServiceProvider.php | 49 +- .../Uploads/ArtworkFileRepository.php | 18 + .../Uploads/AuditLogRepository.php | 21 + .../Uploads/UploadSessionRepository.php | 113 + app/Services/Artworks/ArtworkDraftService.php | 55 + app/Services/AvatarService.php | 169 + .../FeedOfflineEvaluationService.php | 139 + .../PersonalizedFeedService.php | 567 ++++ .../SimilarArtworksService.php | 45 + .../UserInterestProfileService.php | 162 + app/Services/TagNormalizer.php | 39 + app/Services/TagService.php | 329 ++ app/Services/ThumbnailService.php | 3 +- .../Contracts/UploadDraftServiceInterface.php | 51 + app/Services/Upload/PreviewService.php | 79 + app/Services/Upload/TagAnalysisService.php | 105 + app/Services/Upload/UploadDraftService.php | 191 ++ app/Services/Uploads/UploadAuditService.php | 19 + app/Services/Uploads/UploadCancelService.php | 84 + app/Services/Uploads/UploadChunkService.php | 204 ++ .../Uploads/UploadDerivativesService.php | 89 + app/Services/Uploads/UploadHashService.php | 21 + .../Uploads/UploadPipelineService.php | 150 + app/Services/Uploads/UploadQuotaService.php | 36 + app/Services/Uploads/UploadScanService.php | 45 + app/Services/Uploads/UploadSessionStatus.php | 16 + app/Services/Uploads/UploadStatusService.php | 50 + app/Services/Uploads/UploadStorageService.php | 136 + app/Services/Uploads/UploadTokenService.php | 36 + .../Uploads/UploadValidationService.php | 112 + .../Vision/ArtworkEmbeddingClient.php | 95 + .../Commands/CleanupUploadsCommand.php | 25 + .../Exceptions/DraftQuotaException.php | 42 + .../Exceptions/UploadNotFoundException.php | 11 + .../Exceptions/UploadOwnershipException.php | 11 + .../UploadPublishValidationException.php | 11 + app/Uploads/Jobs/PreviewGenerationJob.php | 117 + app/Uploads/Jobs/TagAnalysisJob.php | 127 + app/Uploads/Jobs/VirusScanJob.php | 93 + .../Services/ArchiveInspectorService.php | 205 ++ app/Uploads/Services/CleanupService.php | 55 + app/Uploads/Services/DraftQuotaService.php | 89 + app/Uploads/Services/FileMoveService.php | 49 + app/Uploads/Services/InspectionResult.php | 46 + app/Uploads/Services/PublishService.php | 140 + app/Uploads/Services/SlugService.php | 37 + bootstrap/app.php | 8 +- composer.json | 1 + composer.lock | 400 ++- config/cdn.php | 7 + config/discovery.php | 108 + config/features.php | 5 + config/recommendations.php | 37 + config/services.php | 4 + config/tags.php | 28 + config/uploads.php | 81 + config/vision.php | 34 + ...08_000000_update_user_profiles_avatars.php | 77 + ...r_profiles_about_signature_description.php | 54 + ..._000002_enhance_user_profiles_metadata.php | 74 + ...1_000001_create_uploads_sessions_table.php | 26 + ...6_02_11_000002_create_audit_logs_table.php | 26 + ...2_11_000003_create_artwork_files_table.php | 27 + ..._and_failure_to_uploads_sessions_table.php | 22 + .../2026_02_12_000001_create_tags_table.php | 29 + ..._02_12_000002_create_artwork_tag_table.php | 31 + ...02_12_000003_create_tag_synonyms_table.php | 26 + ...2026_02_12_000004_create_uploads_table.php | 47 + ...02_12_000005_create_upload_files_table.php | 30 + ..._02_12_000006_create_upload_tags_table.php | 28 + ..._add_processing_state_to_uploads_table.php | 31 + ...add_moderation_fields_to_uploads_table.php | 43 + ...000009_create_artwork_embeddings_table.php | 34 + ...0010_create_artwork_similarities_table.php | 34 + ...reate_similar_artwork_analytics_tables.php | 45 + ...012_create_discovery_foundation_tables.php | 69 + ...14_000013_create_feed_analytics_tables.php | 58 + docs/feed-rollout-runbook.md | 123 + docs/ui/tag-input.md | 101 + docs/ui/upload-v2-rollout-runbook.md | 130 + package-lock.json | 2944 +++++++++++++++-- package.json | 12 +- public/avatar/1.jpg | Bin 0 -> 6825 bytes public/gfx/mascot_other.webp | Bin 0 -> 34470 bytes public/gfx/mascot_photography.webp | Bin 0 -> 29860 bytes public/gfx/mascot_skins.webp | Bin 0 -> 33364 bytes public/gfx/mascot_wallpapers.webp | Bin 0 -> 33772 bytes resources/css/app.css | 62 + resources/js/Pages/Admin/UploadQueue.jsx | 6 + resources/js/Pages/Upload/Index.jsx | 1035 ++++++ resources/js/bootstrap.js | 5 + .../js/components/admin/AdminUploadQueue.jsx | 94 + .../admin/AdminUploadQueue.test.jsx | 112 + resources/js/components/tags/TagInput.jsx | 498 +++ .../js/components/tags/TagInput.test.jsx | 113 + .../components/upload/ScreenshotUploader.jsx | 156 + .../js/components/upload/UploadActions.jsx | 152 + .../js/components/upload/UploadDropzone.jsx | 199 ++ .../js/components/upload/UploadPreview.jsx | 76 + .../js/components/upload/UploadProgress.jsx | 152 + .../js/components/upload/UploadSidebar.jsx | 96 + .../js/components/upload/UploadStepper.jsx | 53 + .../js/components/upload/UploadWizard.jsx | 1199 +++++++ .../upload/__tests__/UploadWizard.test.jsx | 310 ++ .../components/uploads/ScreenshotUploader.jsx | 130 + .../js/components/uploads/UploadWizard.jsx | 640 ++++ .../components/uploads/UploadWizard.test.jsx | 113 + resources/js/lib/feedAnalytics.js | 18 + resources/js/lib/uploadAnalytics.js | 24 + resources/js/lib/uploadEndpoints.js | 23 + resources/js/test/setupTests.js | 1 + resources/js/upload.jsx | 17 + resources/views/artworks/show.blade.php | 266 ++ resources/views/components/avatar.blade.php | 12 + resources/views/layouts/app.blade.php | 6 +- resources/views/layouts/nova.blade.php | 27 + .../views/legacy/_artwork_card.blade.php | 65 +- resources/views/legacy/art.blade.php | 2 +- resources/views/legacy/browse.blade.php | 2 +- .../views/legacy/category-slug.blade.php | 151 +- resources/views/legacy/chat.blade.php | 2 +- resources/views/legacy/content-type.blade.php | 166 +- resources/views/legacy/gallery.blade.php | 2 +- resources/views/legacy/home.blade.php | 3 +- .../views/legacy/home/featured.blade.php | 20 +- resources/views/legacy/photography.blade.php | 166 +- resources/views/legacy/profile.blade.php | 2 +- resources/views/legacy/user.blade.php | 354 +- resources/views/tags/show.blade.php | 37 + resources/views/upload.blade.php | 34 + routes/api.php | 94 + routes/console.php | 8 + routes/legacy.php | 22 - routes/web.php | 112 +- scripts/check_intervention.php | 10 + scripts/vision-smoke.ps1 | 237 ++ .../Admin/FeedPerformanceReportTest.php | 104 + .../Admin/SimilarArtworkReportTest.php | 99 + tests/Feature/Admin/UploadModerationTest.php | 168 + tests/Feature/Analytics/FeedAnalyticsTest.php | 138 + .../Analytics/FeedEvaluationCommandsTest.php | 93 + .../Analytics/SimilarArtworkAnalyticsTest.php | 72 + tests/Feature/ArtworkJsonLdTest.php | 117 + tests/Feature/AutoTagArtworkJobTest.php | 76 + tests/Feature/AvatarUploadTest.php | 15 + tests/Feature/ContentRouterControllerTest.php | 56 + .../Discovery/DiscoveryEventIngestionTest.php | 48 + tests/Feature/Discovery/FeedEndpointTest.php | 113 + tests/Feature/ProfileTest.php | 42 +- tests/Feature/SimilarArtworksBlockTest.php | 120 + tests/Feature/TagSystemTest.php | 100 + .../Uploads/ArchiveUploadSecurityTest.php | 212 ++ .../Uploads/PreviewGenerationJobTest.php | 47 + tests/Feature/Uploads/UploadAutosaveTest.php | 165 + .../Uploads/UploadCleanupCommandTest.php | 89 + .../Feature/Uploads/UploadFeatureFlagTest.php | 30 + tests/Feature/Uploads/UploadPreloadTest.php | 191 ++ .../Uploads/UploadProcessingLifecycleTest.php | 108 + .../Uploads/UploadPublishEndpointTest.php | 143 + tests/Feature/Uploads/UploadPublishTest.php | 264 ++ tests/Feature/Uploads/UploadQuotaTest.php | 196 ++ tests/Feature/Uploads/UploadStatusTest.php | 129 + tests/Feature/Uploads/VirusScanJobTest.php | 60 + .../FeedOfflineEvaluationServiceTest.php | 120 + .../Discovery/PersonalizedFeedServiceTest.php | 76 + .../UserInterestProfileServiceTest.php | 86 + .../Uploads/ArchiveInspectorServiceTest.php | 168 + tests/Unit/Uploads/CleanupServiceTest.php | 139 + tests/Unit/Uploads/PublishServiceTest.php | 102 + tests/Unit/Uploads/UploadDraftServiceTest.php | 130 + vite.config.js | 9 +- 249 files changed, 24436 insertions(+), 1021 deletions(-) create mode 100644 .copilot/categories-analysis.md create mode 100644 app/Console/Commands/AggregateFeedAnalyticsCommand.php create mode 100644 app/Console/Commands/AggregateSimilarArtworkAnalyticsCommand.php create mode 100644 app/Console/Commands/AvatarsMigrate.php create mode 100644 app/Console/Commands/BackfillArtworkEmbeddingsCommand.php create mode 100644 app/Console/Commands/CompareFeedAbCommand.php create mode 100644 app/Console/Commands/EvaluateFeedWeightsCommand.php create mode 100644 app/DTOs/Artworks/ArtworkDraftResult.php create mode 100644 app/DTOs/Uploads/UploadChunkResult.php create mode 100644 app/DTOs/Uploads/UploadInitResult.php create mode 100644 app/DTOs/Uploads/UploadScanResult.php create mode 100644 app/DTOs/Uploads/UploadSessionData.php create mode 100644 app/DTOs/Uploads/UploadStoredFile.php create mode 100644 app/DTOs/Uploads/UploadValidatedFile.php create mode 100644 app/DTOs/Uploads/UploadValidationResult.php create mode 100644 app/Http/Controllers/Api/Admin/FeedPerformanceReportController.php create mode 100644 app/Http/Controllers/Api/Admin/SimilarArtworkReportController.php create mode 100644 app/Http/Controllers/Api/Admin/UploadModerationController.php create mode 100644 app/Http/Controllers/Api/ArtworkTagController.php create mode 100644 app/Http/Controllers/Api/DiscoveryEventController.php create mode 100644 app/Http/Controllers/Api/FeedAnalyticsController.php create mode 100644 app/Http/Controllers/Api/FeedController.php create mode 100644 app/Http/Controllers/Api/SimilarArtworkAnalyticsController.php create mode 100644 app/Http/Controllers/Api/TagController.php create mode 100644 app/Http/Controllers/Api/UploadController.php create mode 100644 app/Http/Controllers/AvatarController.php create mode 100644 app/Http/Controllers/ContentRouterController.php create mode 100644 app/Http/Controllers/Web/TagController.php create mode 100644 app/Http/Middleware/EnsureAdminOrModerator.php create mode 100644 app/Http/Middleware/HandleInertiaRequests.php create mode 100644 app/Http/Requests/Artworks/ArtworkCreateRequest.php create mode 100644 app/Http/Requests/Artworks/ArtworkTagsStoreRequest.php create mode 100644 app/Http/Requests/Artworks/ArtworkTagsUpdateRequest.php create mode 100644 app/Http/Requests/Dashboard/ArtworkDestroyRequest.php create mode 100644 app/Http/Requests/Dashboard/ArtworkEditRequest.php create mode 100644 app/Http/Requests/Manage/ManageArtworkDestroyRequest.php create mode 100644 app/Http/Requests/Manage/ManageArtworkEditRequest.php create mode 100644 app/Http/Requests/Manage/ManageArtworkUpdateRequest.php create mode 100644 app/Http/Requests/Tags/PopularTagsRequest.php create mode 100644 app/Http/Requests/Tags/TagSearchRequest.php create mode 100644 app/Http/Requests/Uploads/UploadCancelRequest.php create mode 100644 app/Http/Requests/Uploads/UploadChunkRequest.php create mode 100644 app/Http/Requests/Uploads/UploadFinishRequest.php create mode 100644 app/Http/Requests/Uploads/UploadInitRequest.php create mode 100644 app/Http/Requests/Uploads/UploadStatusRequest.php create mode 100644 app/Jobs/AutoTagArtworkJob.php create mode 100644 app/Jobs/BackfillArtworkEmbeddingsJob.php create mode 100644 app/Jobs/GenerateArtworkEmbeddingJob.php create mode 100644 app/Jobs/GenerateDerivativesJob.php create mode 100644 app/Jobs/IngestUserDiscoveryEventJob.php create mode 100644 app/Jobs/RegenerateUserRecommendationCacheJob.php create mode 100644 app/Models/ArtworkEmbedding.php create mode 100644 app/Models/ArtworkSimilarity.php create mode 100644 app/Models/Tag.php create mode 100644 app/Models/Upload.php create mode 100644 app/Models/UserDiscoveryEvent.php create mode 100644 app/Models/UserInterestProfile.php create mode 100644 app/Models/UserProfile.php create mode 100644 app/Models/UserRecommendationCache.php create mode 100644 app/Repositories/Uploads/ArtworkFileRepository.php create mode 100644 app/Repositories/Uploads/AuditLogRepository.php create mode 100644 app/Repositories/Uploads/UploadSessionRepository.php create mode 100644 app/Services/Artworks/ArtworkDraftService.php create mode 100644 app/Services/AvatarService.php create mode 100644 app/Services/Recommendations/FeedOfflineEvaluationService.php create mode 100644 app/Services/Recommendations/PersonalizedFeedService.php create mode 100644 app/Services/Recommendations/SimilarArtworksService.php create mode 100644 app/Services/Recommendations/UserInterestProfileService.php create mode 100644 app/Services/TagNormalizer.php create mode 100644 app/Services/TagService.php create mode 100644 app/Services/Upload/Contracts/UploadDraftServiceInterface.php create mode 100644 app/Services/Upload/PreviewService.php create mode 100644 app/Services/Upload/TagAnalysisService.php create mode 100644 app/Services/Upload/UploadDraftService.php create mode 100644 app/Services/Uploads/UploadAuditService.php create mode 100644 app/Services/Uploads/UploadCancelService.php create mode 100644 app/Services/Uploads/UploadChunkService.php create mode 100644 app/Services/Uploads/UploadDerivativesService.php create mode 100644 app/Services/Uploads/UploadHashService.php create mode 100644 app/Services/Uploads/UploadPipelineService.php create mode 100644 app/Services/Uploads/UploadQuotaService.php create mode 100644 app/Services/Uploads/UploadScanService.php create mode 100644 app/Services/Uploads/UploadSessionStatus.php create mode 100644 app/Services/Uploads/UploadStatusService.php create mode 100644 app/Services/Uploads/UploadStorageService.php create mode 100644 app/Services/Uploads/UploadTokenService.php create mode 100644 app/Services/Uploads/UploadValidationService.php create mode 100644 app/Services/Vision/ArtworkEmbeddingClient.php create mode 100644 app/Uploads/Commands/CleanupUploadsCommand.php create mode 100644 app/Uploads/Exceptions/DraftQuotaException.php create mode 100644 app/Uploads/Exceptions/UploadNotFoundException.php create mode 100644 app/Uploads/Exceptions/UploadOwnershipException.php create mode 100644 app/Uploads/Exceptions/UploadPublishValidationException.php create mode 100644 app/Uploads/Jobs/PreviewGenerationJob.php create mode 100644 app/Uploads/Jobs/TagAnalysisJob.php create mode 100644 app/Uploads/Jobs/VirusScanJob.php create mode 100644 app/Uploads/Services/ArchiveInspectorService.php create mode 100644 app/Uploads/Services/CleanupService.php create mode 100644 app/Uploads/Services/DraftQuotaService.php create mode 100644 app/Uploads/Services/FileMoveService.php create mode 100644 app/Uploads/Services/InspectionResult.php create mode 100644 app/Uploads/Services/PublishService.php create mode 100644 app/Uploads/Services/SlugService.php create mode 100644 config/cdn.php create mode 100644 config/discovery.php create mode 100644 config/features.php create mode 100644 config/recommendations.php create mode 100644 config/tags.php create mode 100644 config/uploads.php create mode 100644 config/vision.php create mode 100644 database/migrations/2026_02_08_000000_update_user_profiles_avatars.php create mode 100644 database/migrations/2026_02_08_000001_update_user_profiles_about_signature_description.php create mode 100644 database/migrations/2026_02_08_000002_enhance_user_profiles_metadata.php create mode 100644 database/migrations/2026_02_11_000001_create_uploads_sessions_table.php create mode 100644 database/migrations/2026_02_11_000002_create_audit_logs_table.php create mode 100644 database/migrations/2026_02_11_000003_create_artwork_files_table.php create mode 100644 database/migrations/2026_02_11_000004_add_progress_and_failure_to_uploads_sessions_table.php create mode 100644 database/migrations/2026_02_12_000001_create_tags_table.php create mode 100644 database/migrations/2026_02_12_000002_create_artwork_tag_table.php create mode 100644 database/migrations/2026_02_12_000003_create_tag_synonyms_table.php create mode 100644 database/migrations/2026_02_12_000004_create_uploads_table.php create mode 100644 database/migrations/2026_02_12_000005_create_upload_files_table.php create mode 100644 database/migrations/2026_02_12_000006_create_upload_tags_table.php create mode 100644 database/migrations/2026_02_13_000007_add_processing_state_to_uploads_table.php create mode 100644 database/migrations/2026_02_13_000008_add_moderation_fields_to_uploads_table.php create mode 100644 database/migrations/2026_02_13_000009_create_artwork_embeddings_table.php create mode 100644 database/migrations/2026_02_13_000010_create_artwork_similarities_table.php create mode 100644 database/migrations/2026_02_14_000011_create_similar_artwork_analytics_tables.php create mode 100644 database/migrations/2026_02_14_000012_create_discovery_foundation_tables.php create mode 100644 database/migrations/2026_02_14_000013_create_feed_analytics_tables.php create mode 100644 docs/feed-rollout-runbook.md create mode 100644 docs/ui/tag-input.md create mode 100644 docs/ui/upload-v2-rollout-runbook.md create mode 100644 public/avatar/1.jpg create mode 100644 public/gfx/mascot_other.webp create mode 100644 public/gfx/mascot_photography.webp create mode 100644 public/gfx/mascot_skins.webp create mode 100644 public/gfx/mascot_wallpapers.webp create mode 100644 resources/js/Pages/Admin/UploadQueue.jsx create mode 100644 resources/js/Pages/Upload/Index.jsx create mode 100644 resources/js/components/admin/AdminUploadQueue.jsx create mode 100644 resources/js/components/admin/AdminUploadQueue.test.jsx create mode 100644 resources/js/components/tags/TagInput.jsx create mode 100644 resources/js/components/tags/TagInput.test.jsx create mode 100644 resources/js/components/upload/ScreenshotUploader.jsx create mode 100644 resources/js/components/upload/UploadActions.jsx create mode 100644 resources/js/components/upload/UploadDropzone.jsx create mode 100644 resources/js/components/upload/UploadPreview.jsx create mode 100644 resources/js/components/upload/UploadProgress.jsx create mode 100644 resources/js/components/upload/UploadSidebar.jsx create mode 100644 resources/js/components/upload/UploadStepper.jsx create mode 100644 resources/js/components/upload/UploadWizard.jsx create mode 100644 resources/js/components/upload/__tests__/UploadWizard.test.jsx create mode 100644 resources/js/components/uploads/ScreenshotUploader.jsx create mode 100644 resources/js/components/uploads/UploadWizard.jsx create mode 100644 resources/js/components/uploads/UploadWizard.test.jsx create mode 100644 resources/js/lib/feedAnalytics.js create mode 100644 resources/js/lib/uploadAnalytics.js create mode 100644 resources/js/lib/uploadEndpoints.js create mode 100644 resources/js/test/setupTests.js create mode 100644 resources/js/upload.jsx create mode 100644 resources/views/artworks/show.blade.php create mode 100644 resources/views/components/avatar.blade.php create mode 100644 resources/views/tags/show.blade.php create mode 100644 resources/views/upload.blade.php create mode 100644 scripts/check_intervention.php create mode 100644 scripts/vision-smoke.ps1 create mode 100644 tests/Feature/Admin/FeedPerformanceReportTest.php create mode 100644 tests/Feature/Admin/SimilarArtworkReportTest.php create mode 100644 tests/Feature/Admin/UploadModerationTest.php create mode 100644 tests/Feature/Analytics/FeedAnalyticsTest.php create mode 100644 tests/Feature/Analytics/FeedEvaluationCommandsTest.php create mode 100644 tests/Feature/Analytics/SimilarArtworkAnalyticsTest.php create mode 100644 tests/Feature/ArtworkJsonLdTest.php create mode 100644 tests/Feature/AutoTagArtworkJobTest.php create mode 100644 tests/Feature/AvatarUploadTest.php create mode 100644 tests/Feature/ContentRouterControllerTest.php create mode 100644 tests/Feature/Discovery/DiscoveryEventIngestionTest.php create mode 100644 tests/Feature/Discovery/FeedEndpointTest.php create mode 100644 tests/Feature/SimilarArtworksBlockTest.php create mode 100644 tests/Feature/TagSystemTest.php create mode 100644 tests/Feature/Uploads/ArchiveUploadSecurityTest.php create mode 100644 tests/Feature/Uploads/PreviewGenerationJobTest.php create mode 100644 tests/Feature/Uploads/UploadAutosaveTest.php create mode 100644 tests/Feature/Uploads/UploadCleanupCommandTest.php create mode 100644 tests/Feature/Uploads/UploadFeatureFlagTest.php create mode 100644 tests/Feature/Uploads/UploadPreloadTest.php create mode 100644 tests/Feature/Uploads/UploadProcessingLifecycleTest.php create mode 100644 tests/Feature/Uploads/UploadPublishEndpointTest.php create mode 100644 tests/Feature/Uploads/UploadPublishTest.php create mode 100644 tests/Feature/Uploads/UploadQuotaTest.php create mode 100644 tests/Feature/Uploads/UploadStatusTest.php create mode 100644 tests/Feature/Uploads/VirusScanJobTest.php create mode 100644 tests/Unit/Discovery/FeedOfflineEvaluationServiceTest.php create mode 100644 tests/Unit/Discovery/PersonalizedFeedServiceTest.php create mode 100644 tests/Unit/Discovery/UserInterestProfileServiceTest.php create mode 100644 tests/Unit/Uploads/ArchiveInspectorServiceTest.php create mode 100644 tests/Unit/Uploads/CleanupServiceTest.php create mode 100644 tests/Unit/Uploads/PublishServiceTest.php create mode 100644 tests/Unit/Uploads/UploadDraftServiceTest.php diff --git a/.copilot/categories-analysis.md b/.copilot/categories-analysis.md new file mode 100644 index 00000000..6dfb52f9 --- /dev/null +++ b/.copilot/categories-analysis.md @@ -0,0 +1,49 @@ +Current DB & Models Analysis — 2026-02-10 + +Summary +- `content_types` is the master namespace (see screenshot). Rows: e.g. id=1 Photography (slug `photography`), id=2 Wallpapers, id=3 Skins, id=544 Members. +- `categories` references `content_types` through `content_type_id`; hierarchical parent/child relation via `parent_id`. + +Observed DB columns (categories) +- id, content_type_id, parent_id, name, slug, description, image, is_active, sort_order, created_at, updated_at, deleted_at + +Models verified +- `ContentType` (app/Models/ContentType.php) + - hasMany `categories()` and `rootCategories()` + - uses `slug` for route binding + - Status: OK and aligns with DB + +- `Category` (app/Models/Category.php) + - belongsTo `contentType()` + - self-referential `parent()` / `children()` (ordered by `sort_order`, then `name`) + - `descendants()` recursive helper + - `seo()` relation, `artworks()` pivot + - scopes: `active()`, `roots()` + - accessors: `full_slug_path`, `url`, `canonical_url`, `breadcrumbs` + - slug validation enforced in `boot()` (lowercase; only a-z0-9- and dashes) + - Status: OK and consistent with screenshots + +Key behaviors and checks +- URL formation: `$category->url` -> `/{content_type.slug}/{category_path}`; canonical URL -> `https://skinbase.org{url}` +- Slug policy: generation with `Str::slug()` + model validation. Do not bypass. +- Ordering: use `children()` (sort_order then name) for deterministic menus. +- Soft deletes: model uses `SoftDeletes`; be explicit when you need trashed categories. +- Eager-loading: `full_slug_path` walks parents — eager-load `parent` (or ancestors) to avoid N+1 when computing multiple paths. + +Copilot / Dev rules (short checklist) +- Always look up content types by `slug`, not by numeric ID. +- Use `->roots()->active()->with('children')` for public category lists. +- Use `$category->url` and `$category->canonical_url` for links and canonical tags. +- Maintain slug rules: lowercase, only `a-z0-9-`. +- When reparenting categories, consider invalidating any cached derived paths for descendants. +- Avoid using legacy `artworks_categories` directly in new controllers; create an adapter if you must read old data. + +Suggested next steps +- Add a PHPUnit test asserting slug validation and `url` generation for nested categories. +- (Optional) Generate a small ER diagram showing `content_types -> categories -> artwork_category`. + +Files referenced +- [app/Models/ContentType.php](app/Models/ContentType.php) +- [app/Models/Category.php](app/Models/Category.php) + +If you want, I can now add the PHPUnit test or generate the ER diagram. diff --git a/.env.example b/.env.example index 026add8e..41fd0ce4 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,150 @@ BROADCAST_CONNECTION=log FILESYSTEM_DISK=local QUEUE_CONNECTION=database +# Upload UI feature flag (legacy upload remains default unless explicitly enabled) +SKINBASE_UPLOADS_V2=false + +# Draft abuse prevention controls +SKINBASE_MAX_DRAFTS=10 +SKINBASE_MAX_DRAFT_STORAGE_MB=1024 +SKINBASE_DUPLICATE_HASH_POLICY=block + +# Vision / AI auto-tagging (local defaults) +VISION_ENABLED=true +VISION_QUEUE=default +VISION_IMAGE_VARIANT=md + +# CLIP service (set base URL to enable CLIP calls) +CLIP_BASE_URL= +CLIP_ANALYZE_ENDPOINT=/analyze +CLIP_TIMEOUT_SECONDS=8 +CLIP_CONNECT_TIMEOUT_SECONDS=2 +CLIP_HTTP_RETRIES=1 +CLIP_HTTP_RETRY_DELAY_MS=200 +CLIP_EMBED_ENDPOINT=/embed +CLIP_EMBED_TIMEOUT_SECONDS=8 +CLIP_EMBED_CONNECT_TIMEOUT_SECONDS=2 +CLIP_EMBED_HTTP_RETRIES=1 +CLIP_EMBED_HTTP_RETRY_DELAY_MS=200 + +# Similar artworks / embedding pipeline +RECOMMENDATIONS_QUEUE=${VISION_QUEUE} +RECOMMENDATIONS_EMBEDDING_ENABLED=true +RECOMMENDATIONS_EMBEDDING_MODEL=clip +RECOMMENDATIONS_EMBEDDING_MODEL_VERSION=v1 +RECOMMENDATIONS_ALGO_VERSION=clip-cosine-v1 +RECOMMENDATIONS_AB_ALGO_VERSIONS=clip-cosine-v1 +RECOMMENDATIONS_MIN_DIM=64 +RECOMMENDATIONS_MAX_DIM=4096 +RECOMMENDATIONS_BACKFILL_BATCH=200 + +# Personalized discovery foundation (Phase 8) +DISCOVERY_QUEUE=${RECOMMENDATIONS_QUEUE} +DISCOVERY_PROFILE_VERSION=profile-v1 +DISCOVERY_EVENT_VERSION=event-v1 +DISCOVERY_ALGO_VERSION=${RECOMMENDATIONS_ALGO_VERSION} +DISCOVERY_CACHE_VERSION=cache-v1 +DISCOVERY_DECAY_HALF_LIFE_HOURS=72 +DISCOVERY_WEIGHT_VIEW=1 +DISCOVERY_WEIGHT_CLICK=2 +DISCOVERY_WEIGHT_FAVORITE=4 +DISCOVERY_WEIGHT_DOWNLOAD=3 +DISCOVERY_CACHE_TTL_MINUTES=60 +DISCOVERY_RANKING_WEIGHTS_VERSION=rank-w-v1 +DISCOVERY_RANKING_W1=0.65 +DISCOVERY_RANKING_W2=0.20 +DISCOVERY_RANKING_W3=0.10 +DISCOVERY_RANKING_W4=0.05 +DISCOVERY_RANKING_WEIGHTS_VERSION_CLIP_COSINE_V1=rank-w-v1 +DISCOVERY_RANKING_W1_CLIP_COSINE_V1=0.65 +DISCOVERY_RANKING_W2_CLIP_COSINE_V1=0.20 +DISCOVERY_RANKING_W3_CLIP_COSINE_V1=0.10 +DISCOVERY_RANKING_W4_CLIP_COSINE_V1=0.05 +DISCOVERY_RANKING_WEIGHTS_VERSION_CLIP_COSINE_V2=rank-w-v2-prod-1 +DISCOVERY_RANKING_W1_CLIP_COSINE_V2=0.52 +DISCOVERY_RANKING_W2_CLIP_COSINE_V2=0.23 +DISCOVERY_RANKING_W3_CLIP_COSINE_V2=0.15 +DISCOVERY_RANKING_W4_CLIP_COSINE_V2=0.10 +DISCOVERY_ROLLOUT_ENABLED=false +DISCOVERY_ROLLOUT_BASELINE_ALGO_VERSION=clip-cosine-v1 +DISCOVERY_ROLLOUT_CANDIDATE_ALGO_VERSION=clip-cosine-v2 +DISCOVERY_ROLLOUT_ACTIVE_GATE=g10 +DISCOVERY_ROLLOUT_GATE_10_PERCENT=10 +DISCOVERY_ROLLOUT_GATE_50_PERCENT=50 +DISCOVERY_ROLLOUT_GATE_100_PERCENT=100 +DISCOVERY_FORCE_ALGO_VERSION= +DISCOVERY_ROLLOUT_WARN_CTR_DROP_PCT=3 +DISCOVERY_ROLLOUT_ROLLBACK_CTR_DROP_PCT=5 +DISCOVERY_ROLLOUT_WARN_LONG_DWELL_DROP_PCT=4 +DISCOVERY_ROLLOUT_ROLLBACK_LONG_DWELL_DROP_PCT=8 +DISCOVERY_ROLLOUT_WARN_DIVERSITY_CONCENTRATION_RISE_PCT=10 +DISCOVERY_ROLLOUT_ROLLBACK_DIVERSITY_CONCENTRATION_RISE_PCT=15 +DISCOVERY_EVAL_WEIGHT_CTR=0.45 +DISCOVERY_EVAL_WEIGHT_SAVE_RATE=0.35 +DISCOVERY_EVAL_WEIGHT_LONG_DWELL=0.25 +DISCOVERY_EVAL_WEIGHT_BOUNCE_PENALTY=0.15 +DISCOVERY_EVAL_SAVE_RATE_INFORMATIONAL=true + +# YOLO service (optional) +YOLO_ENABLED=true +YOLO_BASE_URL= +YOLO_ANALYZE_ENDPOINT=/analyze +YOLO_TIMEOUT_SECONDS=8 +YOLO_CONNECT_TIMEOUT_SECONDS=2 +YOLO_HTTP_RETRIES=1 +YOLO_HTTP_RETRY_DELAY_MS=200 +YOLO_PHOTOGRAPHY_ONLY=true + +# ----------------------------------------------------------------------------- +# Production examples (uncomment and adjust) +# ----------------------------------------------------------------------------- +# VISION_ENABLED=true +# VISION_QUEUE=vision +# VISION_IMAGE_VARIANT=md +# +# CLIP_BASE_URL=https://clip.internal +# CLIP_ANALYZE_ENDPOINT=/analyze +# CLIP_TIMEOUT_SECONDS=5 +# CLIP_CONNECT_TIMEOUT_SECONDS=1 +# CLIP_HTTP_RETRIES=1 +# CLIP_HTTP_RETRY_DELAY_MS=150 +# CLIP_EMBED_ENDPOINT=/embed +# CLIP_EMBED_TIMEOUT_SECONDS=5 +# CLIP_EMBED_CONNECT_TIMEOUT_SECONDS=1 +# CLIP_EMBED_HTTP_RETRIES=1 +# CLIP_EMBED_HTTP_RETRY_DELAY_MS=150 +# RECOMMENDATIONS_QUEUE=vision +# RECOMMENDATIONS_EMBEDDING_ENABLED=true +# RECOMMENDATIONS_EMBEDDING_MODEL=clip +# RECOMMENDATIONS_EMBEDDING_MODEL_VERSION=v1 +# RECOMMENDATIONS_ALGO_VERSION=clip-cosine-v1 +# RECOMMENDATIONS_AB_ALGO_VERSIONS=clip-cosine-v1,clip-cosine-v2 +# RECOMMENDATIONS_BACKFILL_BATCH=250 +# DISCOVERY_QUEUE=vision +# DISCOVERY_PROFILE_VERSION=profile-v1 +# DISCOVERY_EVENT_VERSION=event-v1 +# DISCOVERY_ALGO_VERSION=clip-cosine-v1 +# DISCOVERY_CACHE_VERSION=cache-v1 +# DISCOVERY_DECAY_HALF_LIFE_HOURS=72 +# DISCOVERY_WEIGHT_VIEW=1 +# DISCOVERY_WEIGHT_CLICK=2 +# DISCOVERY_WEIGHT_FAVORITE=4 +# DISCOVERY_WEIGHT_DOWNLOAD=3 +# DISCOVERY_RANKING_WEIGHTS_VERSION=rank-w-v1 +# DISCOVERY_RANKING_W1=0.65 +# DISCOVERY_RANKING_W2=0.20 +# DISCOVERY_RANKING_W3=0.10 +# DISCOVERY_RANKING_W4=0.05 +# +# YOLO_ENABLED=true +# YOLO_BASE_URL=https://yolo.internal +# YOLO_ANALYZE_ENDPOINT=/analyze +# YOLO_TIMEOUT_SECONDS=5 +# YOLO_CONNECT_TIMEOUT_SECONDS=1 +# YOLO_HTTP_RETRIES=1 +# YOLO_HTTP_RETRY_DELAY_MS=150 +# YOLO_PHOTOGRAPHY_ONLY=true + CACHE_STORE=database # CACHE_PREFIX= diff --git a/README.md b/README.md index 0165a773..eded3fb6 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,372 @@ In order to ensure that the Laravel community is welcoming to all, please review If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. +## Vision & AI Auto-Tagging Integration + +## Upload UI Feature Flag (`uploads.v2`) + +The new React upload wizard is behind a feature flag and is **disabled by default**. + +- Flag env var: `SKINBASE_UPLOADS_V2` +- Config key: `features.uploads_v2` +- Client flags source: `window.SKINBASE_FLAGS` + +### Default behavior + +- `SKINBASE_UPLOADS_V2=false` → legacy upload UI is rendered. +- `SKINBASE_UPLOADS_V2=true` → `UploadWizard` is rendered. + +### Setup + +In `.env` (or `.env.example` for project defaults): + +```dotenv +SKINBASE_UPLOADS_V2=false +``` + +Enable explicitly when ready: + +```dotenv +SKINBASE_UPLOADS_V2=true +``` + +After changing env values, clear/reload config as usual: + +```bash +php artisan config:clear +``` + +The system intentionally keeps legacy upload as the default until the flag is explicitly turned on. + +## Upload Moderation UI Flow + +Admin moderation for draft uploads is available through a dedicated queue page. + +- Page route: `/admin/uploads/moderation` +- Access: authenticated users with `role=admin` or `role=moderator` +- Data source: `GET /api/admin/uploads/pending` + +### Queue behavior + +1. The page loads pending draft uploads (`moderation_status=pending`). +2. Moderators can enter an optional note per upload. +3. Approve action calls: + + - `POST /api/admin/uploads/{id}/approve` + - Sets moderation to approved and records moderator + timestamp. + +4. Reject action calls: + + - `POST /api/admin/uploads/{id}/reject` + - Sets upload status/processing state to rejected and stores note. + +### Publish gate + +- Normal users can publish only when `moderation_status=approved`. +- Admin users can publish with override behavior. + +## Similar Artworks Analytics (A/B Evaluation) + +The artwork page similar-items block emits two event types: + +- `impression` (block rendered) +- `click` (item clicked) + +Events are stored in `similar_artwork_events` and aggregated daily into `similar_artwork_daily_metrics` by `algo_version`. + +- Ingest endpoint: `POST /api/analytics/similar-artworks` +- Aggregation command: `php artisan analytics:aggregate-similar-artworks --date=YYYY-MM-DD` +- Scheduler: runs daily at `03:10` + +## Personalized Discovery Foundation (Phase 8) + +This foundation adds versioned, async-only ingestion and profile normalization for personalized discovery. + +- Tables: + - `user_interest_profiles` + - `user_discovery_events` + - `user_recommendation_cache` +- Ingest endpoint: `POST /api/discovery/events` (auth required) +- Supported event types: `view`, `click`, `favorite`, `download` +- Processing model: non-blocking queue job (`IngestUserDiscoveryEventJob`) +- Normalization: recency-decay + score normalization in `UserInterestProfileService` + +No feed ranking/UI behavior is introduced in this foundation step. + +### Feed Endpoint Skeleton + +The backend now exposes a personalized feed API skeleton: + +- Endpoint: `GET /api/v1/feed` (auth required) +- Query params: + - `limit` (1-50, default 24) + - `cursor` (opaque cursor token for pagination) + - `algo_version` (optional override) +- Response includes `data` items and `meta.next_cursor` for cursor pagination. + +Behavior: + +- Reads `user_recommendation_cache` by `user_id + algo_version`. +- On cache miss/stale, returns immediate fallback results and dispatches async regeneration job. +- Regeneration runs in queue (`RegenerateUserRecommendationCacheJob`) and writes refreshed cache. +- Includes cold-start fallback (`popular + similar`) and a diversity guard to avoid near-duplicates. + +## Feed Analytics Instrumentation + +Feed analytics now track: + +- `feed_impression` +- `feed_click` + +Payload dimensions: + +- `user_id` (derived from auth session) +- `artwork_id` +- `position` +- `algo_version` +- `source` (`personalized`, `cold_start`, `fallback`) + +Optional: + +- `dwell_seconds` (for click dwell bucket metrics) + +Endpoints: + +- Ingest: `POST /api/analytics/feed` (auth required) +- Daily aggregation: `php artisan analytics:aggregate-feed --date=YYYY-MM-DD` +- Admin report: `GET /api/admin/reports/feed-performance` + +Daily metrics include CTR, save-rate, and dwell buckets. + +For non-blocking client transport, use `navigator.sendBeacon` with `fetch(..., { keepalive: true })` fallback. +Reference helper: `resources/js/lib/feedAnalytics.js`. + +## Phase 8B: Ranking Weight Tuning (Manual + Data-Driven) + +Discovery ranking now supports versioned blend weights per `algo_version` in `config/discovery.php`. + +- Blend terms: `w1` interest, `w2` recency, `w3` popularity, `w4` novelty +- Per-algo sets: `discovery.ranking.algo_weight_sets` +- Safe rollout: deterministic traffic split by `algo_version` with config gates (`g10`, `g50`, `g100`) +- Emergency rollback: `DISCOVERY_FORCE_ALGO_VERSION=clip-cosine-v1` + +Offline evaluator and A/B helper: + +- Evaluate objective across one/all algos: + - `php artisan analytics:evaluate-feed-weights --from=YYYY-MM-DD --to=YYYY-MM-DD` + - Optional: `--algo=clip-cosine-v1` +- Baseline vs candidate comparison: + - `php artisan analytics:compare-feed-ab clip-cosine-v1 clip-cosine-v2 --from=YYYY-MM-DD --to=YYYY-MM-DD` + +Objective score uses `feed_daily_metrics` and configurable objective weights in `discovery.evaluation.objective_weights`. + +Temporary production policy: set `DISCOVERY_EVAL_SAVE_RATE_INFORMATIONAL=true` to keep `save_rate` visible but excluded from objective score until save-event ingestion is verified. + +Operational runbook: `docs/feed-rollout-runbook.md`. + +## Operations / Runbooks + +- Upload UI v2 rollout, post-deploy monitoring, and rollback: `docs/ui/upload-v2-rollout-runbook.md` +- Feed rollout and rollback: `docs/feed-rollout-runbook.md` + +No automatic tuning is enabled in this phase. + +Skinbase uses asynchronous AI tagging via `AutoTagArtworkJob`. +The job calls external vision services (CLIP and optional YOLO), normalizes tags, and attaches them through `TagService` as AI tags with confidence values. + +### Critical Safety Rule + +⚠️ **Publish must never depend on vision services.** + +- Upload/publish flow dispatches AI tagging to queue after publish work. +- Vision failures, timeouts, or service outages must not block artwork publish. +- If AI tagging fails, artwork remains published and can be tagged later (retry/manual/batch). + +### Environment Variables (Vision) + +Set these in `.env` (all are optional; defaults are in `config/vision.php`): + +#### Global + +- `VISION_ENABLED` (default: `true`) + - Master switch for all AI auto-tagging. +- `VISION_QUEUE` (default: `default`) + - Queue name used by `AutoTagArtworkJob`. +- `VISION_IMAGE_VARIANT` (default: `md`) + - Derivative variant sent to vision services (e.g. `md`, `lg`). + +#### CLIP + +- `CLIP_BASE_URL` (default: empty) + - Base URL for CLIP service (example: `https://clip.internal`). + - If empty, CLIP call is skipped. +- `CLIP_ANALYZE_ENDPOINT` (default: `/analyze`) + - Path appended to `CLIP_BASE_URL`. +- `CLIP_TIMEOUT_SECONDS` (default: `8`) + - Request timeout for CLIP calls. +- `CLIP_CONNECT_TIMEOUT_SECONDS` (default: `2`) + - Connection timeout for CLIP calls. +- `CLIP_HTTP_RETRIES` (default: `1`) + - HTTP retry attempts for CLIP requests. +- `CLIP_HTTP_RETRY_DELAY_MS` (default: `200`) + - Delay between CLIP retries. + +#### YOLO (optional) + +- `YOLO_ENABLED` (default: `true`) + - Enables YOLO integration. +- `YOLO_BASE_URL` (default: empty) + - Base URL for YOLO service. If empty, YOLO call is skipped. +- `YOLO_ANALYZE_ENDPOINT` (default: `/analyze`) + - Path appended to `YOLO_BASE_URL`. +- `YOLO_TIMEOUT_SECONDS` (default: `8`) + - Request timeout for YOLO calls. +- `YOLO_CONNECT_TIMEOUT_SECONDS` (default: `2`) + - Connection timeout for YOLO calls. +- `YOLO_HTTP_RETRIES` (default: `1`) + - HTTP retry attempts for YOLO requests. +- `YOLO_HTTP_RETRY_DELAY_MS` (default: `200`) + - Delay between YOLO retries. +- `YOLO_PHOTOGRAPHY_ONLY` (default: `true`) + - When `true`, YOLO is called only for artworks in photography content type. + +### Expected CLIP Response Format + +CLIP `/analyze` should return tags as either a direct list or under `tags` / `data`: + +```json +[ + { "tag": "cyberpunk", "confidence": 0.42 }, + { "tag": "city", "confidence": 0.31 } +] +``` + +Also accepted: + +```json +{ + "tags": [ + { "tag": "cyberpunk", "confidence": 0.42 } + ] +} +``` + +or + +```json +{ + "data": [ + { "tag": "cyberpunk", "confidence": 0.42 } + ] +} +``` + +### Expected YOLO Response Format + +YOLO may return the same tag list format as CLIP, or object detections: + +```json +{ + "objects": [ + { "label": "person", "confidence": 0.91 }, + { "label": "camera", "confidence": 0.67 } + ] +} +``` + +`label` values are converted to tags, confidence is preserved when present. + +### AutoTagArtworkJob Behavior + +- Calls CLIP `/analyze` when `VISION_ENABLED=true` and `CLIP_BASE_URL` is set. +- Optionally calls YOLO based on `YOLO_ENABLED` and `YOLO_PHOTOGRAPHY_ONLY`. +- Merges CLIP + YOLO tags and keeps highest confidence for duplicates. +- Normalizes tags before attach (lowercase, cleanup, slug-safe format). +- Uses `TagService::attachAiTags()` to store pivot data: + - `source = ai` + - `confidence = ` +- Runs with queue retry + timeout safety (`tries`, `backoff`, `timeout`). +- Logs failures with reference/context for troubleshooting. +- On non-retriable response scenarios (e.g. 4xx), job exits safely without blocking publish. + +### Queue / Worker Requirements (`VISION_QUEUE`) + +- Ensure a worker is running for the configured queue. +- Example worker command: + +```bash +php artisan queue:work --queue=default +``` + +- If `VISION_QUEUE=vision`, run worker for that queue: + +```bash +php artisan queue:work --queue=vision +``` + +- In production, use Supervisor/systemd/Horizon to keep workers alive. +- Without an active worker, auto-tagging jobs remain queued and will not execute. + +### Local vs Production Notes + +#### Local development + +- For fully offline local work, set `VISION_ENABLED=false`. +- Or set only `CLIP_BASE_URL`/`YOLO_BASE_URL` you can reach locally. +- Prefer short timeouts to avoid slow dev feedback loops. + +#### Production + +- Use internal/private service endpoints for CLIP/YOLO when possible. +- Keep conservative timeouts and low retry counts to prevent queue congestion. +- Monitor failed jobs and logs for vision service reliability. +- Scale queue workers based on upload volume and service latency. + +### Verify Setup (Health + Test Call) + +After configuring env vars and restarting workers, verify in this order: + +Quick helper (PowerShell): + +```powershell +pwsh -File ./scripts/vision-smoke.ps1 +``` + +Optional flags: + +```powershell +pwsh -File ./scripts/vision-smoke.ps1 -EnvFile ".env" -SampleImageUrl "https://files.skinbase.org/img/aa/bb/cc/md.webp" +pwsh -File ./scripts/vision-smoke.ps1 -SkipAnalyze +``` + +1. Confirm queue worker is consuming `VISION_QUEUE`. + +```bash +php artisan queue:work --queue=default +``` + +1. Check CLIP/YOLO health endpoints (replace host/port as needed): + +```bash +curl -fsS "$CLIP_BASE_URL/health" +curl -fsS "$YOLO_BASE_URL/health" +``` + +1. Make a direct analyze test call (CLIP example): + +```bash +curl -X POST "$CLIP_BASE_URL$CLIP_ANALYZE_ENDPOINT" \ + -H "Content-Type: application/json" \ + -d '{"image_url":"https://files.skinbase.org/img/aa/bb/cc/md.webp"}' +``` + +1. Trigger an upload/publish and confirm: + +- Publish response succeeds even if CLIP/YOLO is down. +- `AutoTagArtworkJob` is queued/executed asynchronously. +- AI tags appear on the artwork when services are healthy. +- Failures are logged, but publish is unaffected. + ## License The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/app/Console/Commands/AggregateFeedAnalyticsCommand.php b/app/Console/Commands/AggregateFeedAnalyticsCommand.php new file mode 100644 index 00000000..05d60803 --- /dev/null +++ b/app/Console/Commands/AggregateFeedAnalyticsCommand.php @@ -0,0 +1,106 @@ +option('date') + ? (string) $this->option('date') + : now()->subDay()->toDateString(); + + $rows = DB::table('feed_events') + ->selectRaw('algo_version, source') + ->selectRaw("SUM(CASE WHEN event_type = 'feed_impression' THEN 1 ELSE 0 END) AS impressions") + ->selectRaw("SUM(CASE WHEN event_type = 'feed_click' THEN 1 ELSE 0 END) AS clicks") + ->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds IS NOT NULL AND dwell_seconds < 5 THEN 1 ELSE 0 END) AS dwell_0_5") + ->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 5 AND dwell_seconds < 30 THEN 1 ELSE 0 END) AS dwell_5_30") + ->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 30 AND dwell_seconds < 120 THEN 1 ELSE 0 END) AS dwell_30_120") + ->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 120 THEN 1 ELSE 0 END) AS dwell_120_plus") + ->whereDate('event_date', $date) + ->groupBy('algo_version', 'source') + ->get(); + + foreach ($rows as $row) { + $algoVersion = (string) $row->algo_version; + $source = (string) $row->source; + $impressions = (int) ($row->impressions ?? 0); + $clicks = (int) ($row->clicks ?? 0); + + $saves = $this->countSavesForGroup($date, $algoVersion, $source); + + $ctr = $impressions > 0 ? $clicks / $impressions : 0.0; + $saveRate = $clicks > 0 ? $saves / $clicks : 0.0; + + DB::table('feed_daily_metrics')->updateOrInsert( + [ + 'metric_date' => $date, + 'algo_version' => $algoVersion, + 'source' => $source, + ], + [ + 'impressions' => $impressions, + 'clicks' => $clicks, + 'saves' => $saves, + 'ctr' => $ctr, + 'save_rate' => $saveRate, + 'dwell_0_5' => (int) ($row->dwell_0_5 ?? 0), + 'dwell_5_30' => (int) ($row->dwell_5_30 ?? 0), + 'dwell_30_120' => (int) ($row->dwell_30_120 ?? 0), + 'dwell_120_plus' => (int) ($row->dwell_120_plus ?? 0), + 'updated_at' => now(), + 'created_at' => now(), + ] + ); + } + + $this->info("Aggregated feed analytics for {$date}."); + + return self::SUCCESS; + } + + private function countSavesForGroup(string $date, string $algoVersion, string $source): int + { + /** @var Collection $clickedPairs */ + $clickedPairs = DB::table('feed_events') + ->select('user_id', 'artwork_id') + ->whereDate('event_date', $date) + ->where('event_type', 'feed_click') + ->where('algo_version', $algoVersion) + ->where('source', $source) + ->groupBy('user_id', 'artwork_id') + ->get(); + + if ($clickedPairs->isEmpty()) { + return 0; + } + + $saves = 0; + foreach ($clickedPairs as $pair) { + $hasSave = DB::table('user_discovery_events') + ->whereDate('event_date', $date) + ->where('user_id', (int) $pair->user_id) + ->where('artwork_id', (int) $pair->artwork_id) + ->where('algo_version', $algoVersion) + ->whereIn('event_type', ['favorite', 'download']) + ->exists(); + + if ($hasSave) { + $saves++; + } + } + + return $saves; + } +} diff --git a/app/Console/Commands/AggregateSimilarArtworkAnalyticsCommand.php b/app/Console/Commands/AggregateSimilarArtworkAnalyticsCommand.php new file mode 100644 index 00000000..7c35d5a5 --- /dev/null +++ b/app/Console/Commands/AggregateSimilarArtworkAnalyticsCommand.php @@ -0,0 +1,54 @@ +option('date') + ? (string) $this->option('date') + : now()->subDay()->toDateString(); + + $rows = DB::table('similar_artwork_events') + ->selectRaw('algo_version') + ->selectRaw("SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END) AS impressions") + ->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks") + ->whereDate('event_date', $date) + ->groupBy('algo_version') + ->get(); + + foreach ($rows as $row) { + $impressions = (int) ($row->impressions ?? 0); + $clicks = (int) ($row->clicks ?? 0); + $ctr = $impressions > 0 ? $clicks / $impressions : 0.0; + + DB::table('similar_artwork_daily_metrics')->updateOrInsert( + [ + 'metric_date' => $date, + 'algo_version' => (string) $row->algo_version, + ], + [ + 'impressions' => $impressions, + 'clicks' => $clicks, + 'ctr' => $ctr, + 'updated_at' => now(), + 'created_at' => now(), + ] + ); + } + + $this->info("Aggregated similar artwork analytics for {$date}."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/AvatarsMigrate.php b/app/Console/Commands/AvatarsMigrate.php new file mode 100644 index 00000000..45f893d1 --- /dev/null +++ b/app/Console/Commands/AvatarsMigrate.php @@ -0,0 +1,83 @@ +service = $service; + } + + public function handle() + { + $this->info('Starting avatar migration...'); + + // Try to read legacy data from user_profiles.avatar_legacy or users.avatar_legacy or users.icon + $rows = DB::table('user_profiles')->select('user_id', 'avatar_legacy')->whereNotNull('avatar_legacy')->get(); + + if ($rows->isEmpty()) { + // fallback to users table + $rows = DB::table('users')->select('user_id', 'icon as avatar_legacy')->whereNotNull('icon')->get(); + } + + $count = 0; + foreach ($rows as $row) { + $userId = $row->user_id; + $legacy = $row->avatar_legacy ?? null; + if (!$legacy) { + continue; + } + + // Try common legacy paths + $candidates = [ + public_path('user-picture/' . $legacy), + public_path('avatar/' . $userId . '/' . $legacy), + storage_path('app/public/user-picture/' . $legacy), + storage_path('app/public/avatar/' . $userId . '/' . $legacy), + ]; + + $found = false; + foreach ($candidates as $p) { + if (file_exists($p) && is_readable($p)) { + $this->info("Processing user {$userId} from {$p}"); + $hash = $this->service->storeFromLegacyFile($userId, $p); + if ($hash) { + $this->info(" -> migrated, hash={$hash}"); + $count++; + $found = true; + break; + } + } + } + + if (!$found) { + $this->warn("Legacy file not found for user {$userId}, filename={$legacy}"); + } + } + + $this->info("Migration complete. Processed: {$count}"); + return 0; + } +} diff --git a/app/Console/Commands/BackfillArtworkEmbeddingsCommand.php b/app/Console/Commands/BackfillArtworkEmbeddingsCommand.php new file mode 100644 index 00000000..847ed512 --- /dev/null +++ b/app/Console/Commands/BackfillArtworkEmbeddingsCommand.php @@ -0,0 +1,28 @@ +option('after-id')); + $batch = max(1, min((int) $this->option('batch'), 1000)); + $force = (bool) $this->option('force'); + + BackfillArtworkEmbeddingsJob::dispatch($afterId, $batch, $force); + + $this->info("Queued artwork embedding backfill (after_id={$afterId}, batch={$batch}, force=" . ($force ? 'yes' : 'no') . ').'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/CompareFeedAbCommand.php b/app/Console/Commands/CompareFeedAbCommand.php new file mode 100644 index 00000000..45339fca --- /dev/null +++ b/app/Console/Commands/CompareFeedAbCommand.php @@ -0,0 +1,71 @@ +option('from') ?: now()->subDays(29)->toDateString()); + $to = (string) ($this->option('to') ?: now()->toDateString()); + + if ($from > $to) { + $this->error('Invalid range: --from must be <= --to'); + return self::FAILURE; + } + + $baseline = (string) $this->argument('baseline'); + $candidate = (string) $this->argument('candidate'); + + $comparison = $this->evaluator->compareBaselineCandidate($baseline, $candidate, $from, $to); + + if ((bool) $this->option('json')) { + $this->line((string) json_encode($comparison, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + return self::SUCCESS; + } + + $this->table( + ['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'], + [[ + (string) $comparison['baseline']['algo_version'], + (float) $comparison['baseline']['ctr'], + (float) $comparison['baseline']['save_rate'], + (float) $comparison['baseline']['long_dwell_share'], + (float) $comparison['baseline']['bounce_rate'], + (float) $comparison['baseline']['objective_score'], + ], [ + (string) $comparison['candidate']['algo_version'], + (float) $comparison['candidate']['ctr'], + (float) $comparison['candidate']['save_rate'], + (float) $comparison['candidate']['long_dwell_share'], + (float) $comparison['candidate']['bounce_rate'], + (float) $comparison['candidate']['objective_score'], + ]] + ); + + $delta = (array) $comparison['delta']; + $this->line('Δ objective_score: ' . (string) $delta['objective_score']); + $this->line('Δ objective_lift_pct: ' . (string) ($delta['objective_lift_pct'] ?? 'n/a')); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/EvaluateFeedWeightsCommand.php b/app/Console/Commands/EvaluateFeedWeightsCommand.php new file mode 100644 index 00000000..d91ebcef --- /dev/null +++ b/app/Console/Commands/EvaluateFeedWeightsCommand.php @@ -0,0 +1,81 @@ +option('from') ?: now()->subDays(29)->toDateString()); + $to = (string) ($this->option('to') ?: now()->toDateString()); + $algo = $this->option('algo') ? (string) $this->option('algo') : null; + + if ($from > $to) { + $this->error('Invalid range: --from must be <= --to'); + return self::FAILURE; + } + + if ($algo !== null && $algo !== '') { + $result = $this->evaluator->evaluateAlgo($algo, $from, $to); + + if ((bool) $this->option('json')) { + $this->line((string) json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } else { + $this->table( + ['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'], + [[ + (string) $result['algo_version'], + (float) $result['ctr'], + (float) $result['save_rate'], + (float) $result['long_dwell_share'], + (float) $result['bounce_rate'], + (float) $result['objective_score'], + ]] + ); + } + + return self::SUCCESS; + } + + $results = $this->evaluator->evaluateAll($from, $to); + + if ((bool) $this->option('json')) { + $this->line((string) json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + return self::SUCCESS; + } + + $rows = array_map(static fn (array $row): array => [ + (string) $row['algo_version'], + (float) $row['ctr'], + (float) $row['save_rate'], + (float) $row['long_dwell_share'], + (float) $row['bounce_rate'], + (float) $row['objective_score'], + ], $results); + + $this->table( + ['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'], + $rows + ); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ImportLegacyUsers.php b/app/Console/Commands/ImportLegacyUsers.php index 344a6e0b..d870703e 100644 --- a/app/Console/Commands/ImportLegacyUsers.php +++ b/app/Console/Commands/ImportLegacyUsers.php @@ -102,7 +102,7 @@ class ImportLegacyUsers extends Command DB::table('user_profiles')->insert([ 'user_id' => $legacyId, - 'bio' => $row->about_me ?: $row->description ?: null, + 'about' => $row->about_me ?: $row->description ?: null, 'avatar' => $row->picture ?: null, 'cover_image' => $row->cover_art ?: null, 'country' => $row->country ?: null, @@ -115,15 +115,7 @@ class ImportLegacyUsers extends Command 'updated_at' => $now, ]); - if (!empty($row->web)) { - DB::table('user_social_links')->insert([ - 'user_id' => $legacyId, - 'platform' => 'website', - 'url' => $row->web, - 'created_at' => $now, - 'updated_at' => $now, - ]); - } + // Do not duplicate `website` into `user_social_links` — keep canonical site in `user_profiles.website`. DB::table('user_statistics')->insert([ 'user_id' => $legacyId, diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index ea51b4d7..46f875ea 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -6,6 +6,12 @@ use Illuminate\Foundation\Console\Kernel as ConsoleKernel; use App\Console\Commands\ImportLegacyUsers; use App\Console\Commands\ImportCategories; use App\Console\Commands\MigrateFeaturedWorks; +use App\Console\Commands\BackfillArtworkEmbeddingsCommand; +use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand; +use App\Console\Commands\AggregateFeedAnalyticsCommand; +use App\Console\Commands\EvaluateFeedWeightsCommand; +use App\Console\Commands\CompareFeedAbCommand; +use App\Uploads\Commands\CleanupUploadsCommand; class Kernel extends ConsoleKernel { @@ -18,7 +24,14 @@ class Kernel extends ConsoleKernel ImportLegacyUsers::class, ImportCategories::class, MigrateFeaturedWorks::class, + \App\Console\Commands\AvatarsMigrate::class, \App\Console\Commands\ResetAllUserPasswords::class, + CleanupUploadsCommand::class, + BackfillArtworkEmbeddingsCommand::class, + AggregateSimilarArtworkAnalyticsCommand::class, + AggregateFeedAnalyticsCommand::class, + EvaluateFeedWeightsCommand::class, + CompareFeedAbCommand::class, ]; /** @@ -26,7 +39,9 @@ class Kernel extends ConsoleKernel */ protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void { - // $schedule->command('inspire')->hourly(); + $schedule->command('uploads:cleanup')->dailyAt('03:00'); + $schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10'); + $schedule->command('analytics:aggregate-feed')->dailyAt('03:20'); } /** diff --git a/app/DTOs/Artworks/ArtworkDraftResult.php b/app/DTOs/Artworks/ArtworkDraftResult.php new file mode 100644 index 00000000..9e5467fa --- /dev/null +++ b/app/DTOs/Artworks/ArtworkDraftResult.php @@ -0,0 +1,14 @@ +validate([ + 'from' => ['nullable', 'date_format:Y-m-d'], + 'to' => ['nullable', 'date_format:Y-m-d'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:1000'], + ]); + + $from = (string) ($validated['from'] ?? now()->subDays(29)->toDateString()); + $to = (string) ($validated['to'] ?? now()->toDateString()); + $limit = (int) ($validated['limit'] ?? 100); + + if ($from > $to) { + return response()->json([ + 'message' => 'Invalid date range: from must be before or equal to to.', + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $rows = DB::table('feed_daily_metrics') + ->selectRaw('algo_version, source') + ->selectRaw('SUM(impressions) as impressions') + ->selectRaw('SUM(clicks) as clicks') + ->selectRaw('SUM(saves) as saves') + ->selectRaw('SUM(dwell_0_5) as dwell_0_5') + ->selectRaw('SUM(dwell_5_30) as dwell_5_30') + ->selectRaw('SUM(dwell_30_120) as dwell_30_120') + ->selectRaw('SUM(dwell_120_plus) as dwell_120_plus') + ->whereBetween('metric_date', [$from, $to]) + ->groupBy('algo_version', 'source') + ->orderBy('algo_version') + ->orderBy('source') + ->get(); + + $byAlgoSource = $rows->map(static function ($row): array { + $impressions = (int) ($row->impressions ?? 0); + $clicks = (int) ($row->clicks ?? 0); + $saves = (int) ($row->saves ?? 0); + + return [ + 'algo_version' => (string) $row->algo_version, + 'source' => (string) $row->source, + 'impressions' => $impressions, + 'clicks' => $clicks, + 'saves' => $saves, + 'ctr' => round($impressions > 0 ? $clicks / $impressions : 0.0, 6), + 'save_rate' => round($clicks > 0 ? $saves / $clicks : 0.0, 6), + 'dwell_buckets' => [ + '0_5' => (int) ($row->dwell_0_5 ?? 0), + '5_30' => (int) ($row->dwell_5_30 ?? 0), + '30_120' => (int) ($row->dwell_30_120 ?? 0), + '120_plus' => (int) ($row->dwell_120_plus ?? 0), + ], + ]; + })->values(); + + $topClickedArtworks = DB::table('feed_events as e') + ->leftJoin('artworks as a', 'a.id', '=', 'e.artwork_id') + ->selectRaw('e.algo_version') + ->selectRaw('e.source') + ->selectRaw('e.artwork_id') + ->selectRaw('a.title as artwork_title') + ->selectRaw("SUM(CASE WHEN e.event_type = 'feed_impression' THEN 1 ELSE 0 END) AS impressions") + ->selectRaw("SUM(CASE WHEN e.event_type = 'feed_click' THEN 1 ELSE 0 END) AS clicks") + ->whereBetween('e.event_date', [$from, $to]) + ->groupBy('e.algo_version', 'e.source', 'e.artwork_id', 'a.title') + ->get() + ->map(static function ($row): array { + $impressions = (int) ($row->impressions ?? 0); + $clicks = (int) ($row->clicks ?? 0); + + return [ + 'algo_version' => (string) $row->algo_version, + 'source' => (string) $row->source, + 'artwork_id' => (int) $row->artwork_id, + 'artwork_title' => (string) ($row->artwork_title ?? ''), + 'impressions' => $impressions, + 'clicks' => $clicks, + 'ctr' => round($impressions > 0 ? $clicks / $impressions : 0.0, 6), + ]; + }) + ->sort(static function (array $a, array $b): int { + $clickCompare = $b['clicks'] <=> $a['clicks']; + if ($clickCompare !== 0) { + return $clickCompare; + } + + return $b['ctr'] <=> $a['ctr']; + }) + ->take($limit) + ->values(); + + return response()->json([ + 'meta' => [ + 'from' => $from, + 'to' => $to, + 'generated_at' => now()->toISOString(), + 'limit' => $limit, + ], + 'by_algo_source' => $byAlgoSource, + 'top_clicked_artworks' => $topClickedArtworks, + ], Response::HTTP_OK); + } +} diff --git a/app/Http/Controllers/Api/Admin/SimilarArtworkReportController.php b/app/Http/Controllers/Api/Admin/SimilarArtworkReportController.php new file mode 100644 index 00000000..5b39f503 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/SimilarArtworkReportController.php @@ -0,0 +1,114 @@ +validate([ + 'from' => ['nullable', 'date_format:Y-m-d'], + 'to' => ['nullable', 'date_format:Y-m-d'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:1000'], + ]); + + $from = (string) ($validated['from'] ?? now()->subDays(29)->toDateString()); + $to = (string) ($validated['to'] ?? now()->toDateString()); + $limit = (int) ($validated['limit'] ?? 100); + + if ($from > $to) { + return response()->json([ + 'message' => 'Invalid date range: from must be before or equal to to.', + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $byAlgoRows = DB::table('similar_artwork_events') + ->selectRaw('algo_version') + ->selectRaw("SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END) AS impressions") + ->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks") + ->whereBetween('event_date', [$from, $to]) + ->groupBy('algo_version') + ->orderBy('algo_version') + ->get(); + + $byAlgo = $byAlgoRows->map(static function ($row): array { + $impressions = (int) ($row->impressions ?? 0); + $clicks = (int) ($row->clicks ?? 0); + $ctr = $impressions > 0 ? $clicks / $impressions : 0.0; + + return [ + 'algo_version' => (string) $row->algo_version, + 'impressions' => $impressions, + 'clicks' => $clicks, + 'ctr' => round($ctr, 6), + ]; + })->values(); + + $pairRows = DB::table('similar_artwork_events as e') + ->leftJoin('artworks as source', 'source.id', '=', 'e.source_artwork_id') + ->leftJoin('artworks as similar', 'similar.id', '=', 'e.similar_artwork_id') + ->selectRaw('e.algo_version') + ->selectRaw('e.source_artwork_id') + ->selectRaw('e.similar_artwork_id') + ->selectRaw('source.title as source_title') + ->selectRaw('similar.title as similar_title') + ->selectRaw("SUM(CASE WHEN e.event_type = 'impression' THEN 1 ELSE 0 END) AS impressions") + ->selectRaw("SUM(CASE WHEN e.event_type = 'click' THEN 1 ELSE 0 END) AS clicks") + ->whereBetween('e.event_date', [$from, $to]) + ->whereNotNull('e.similar_artwork_id') + ->groupBy('e.algo_version', 'e.source_artwork_id', 'e.similar_artwork_id', 'source.title', 'similar.title') + ->get(); + + $topSimilarities = $pairRows + ->map(static function ($row): array { + $impressions = (int) ($row->impressions ?? 0); + $clicks = (int) ($row->clicks ?? 0); + $ctr = $impressions > 0 ? $clicks / $impressions : 0.0; + + return [ + 'algo_version' => (string) $row->algo_version, + 'source_artwork_id' => (int) $row->source_artwork_id, + 'source_title' => (string) ($row->source_title ?? ''), + 'similar_artwork_id' => (int) $row->similar_artwork_id, + 'similar_title' => (string) ($row->similar_title ?? ''), + 'impressions' => $impressions, + 'clicks' => $clicks, + 'ctr' => round($ctr, 6), + ]; + }) + ->sort(function (array $a, array $b): int { + $ctrCompare = $b['ctr'] <=> $a['ctr']; + if ($ctrCompare !== 0) { + return $ctrCompare; + } + + $clickCompare = $b['clicks'] <=> $a['clicks']; + if ($clickCompare !== 0) { + return $clickCompare; + } + + return $b['impressions'] <=> $a['impressions']; + }) + ->take($limit) + ->values(); + + return response()->json([ + 'meta' => [ + 'from' => $from, + 'to' => $to, + 'generated_at' => now()->toISOString(), + 'limit' => $limit, + ], + 'by_algo_version' => $byAlgo, + 'top_similarities' => $topSimilarities, + ], Response::HTTP_OK); + } +} diff --git a/app/Http/Controllers/Api/Admin/UploadModerationController.php b/app/Http/Controllers/Api/Admin/UploadModerationController.php new file mode 100644 index 00000000..e62aa453 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/UploadModerationController.php @@ -0,0 +1,83 @@ +where('status', 'draft') + ->where('moderation_status', 'pending') + ->orderBy('created_at') + ->get([ + 'id', + 'user_id', + 'type', + 'status', + 'processing_state', + 'title', + 'preview_path', + 'created_at', + 'moderation_status', + ]); + + return response()->json([ + 'data' => $uploads, + ], Response::HTTP_OK); + } + + public function approve(string $id, Request $request): JsonResponse + { + $upload = Upload::query()->find($id); + + if (! $upload) { + return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND); + } + + $upload->moderation_status = 'approved'; + $upload->moderated_at = now(); + $upload->moderated_by = (int) $request->user()->id; + $upload->moderation_note = $request->input('note'); + $upload->save(); + + return response()->json([ + 'success' => true, + 'id' => (string) $upload->id, + 'moderation_status' => (string) $upload->moderation_status, + ], Response::HTTP_OK); + } + + public function reject(string $id, Request $request): JsonResponse + { + $upload = Upload::query()->find($id); + + if (! $upload) { + return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND); + } + + $upload->moderation_status = 'rejected'; + $upload->status = 'rejected'; + $upload->processing_state = 'rejected'; + $upload->moderated_at = now(); + $upload->moderated_by = (int) $request->user()->id; + $upload->moderation_note = (string) $request->input('note', ''); + $upload->save(); + + return response()->json([ + 'success' => true, + 'id' => (string) $upload->id, + 'status' => (string) $upload->status, + 'processing_state' => (string) $upload->processing_state, + 'moderation_status' => (string) $upload->moderation_status, + ], Response::HTTP_OK); + } +} diff --git a/app/Http/Controllers/Api/ArtworkController.php b/app/Http/Controllers/Api/ArtworkController.php index 6be78885..5da22066 100644 --- a/app/Http/Controllers/Api/ArtworkController.php +++ b/app/Http/Controllers/Api/ArtworkController.php @@ -2,11 +2,14 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Requests\Artworks\ArtworkCreateRequest; use App\Http\Resources\ArtworkListResource; use App\Http\Resources\ArtworkResource; use App\Services\ArtworkService; +use App\Services\Artworks\ArtworkDraftService; use App\Models\Category; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Response; class ArtworkController extends Controller { @@ -17,6 +20,27 @@ class ArtworkController extends Controller $this->service = $service; } + /** + * POST /api/artworks + * Creates a draft artwork placeholder for the upload pipeline. + */ + public function store(ArtworkCreateRequest $request, ArtworkDraftService $drafts) + { + $user = $request->user(); + $data = $request->validated(); + + $result = $drafts->createDraft( + (int) $user->id, + (string) $data['title'], + isset($data['description']) ? (string) $data['description'] : null + ); + + return response()->json([ + 'artwork_id' => $result->artworkId, + 'status' => $result->status, + ], Response::HTTP_CREATED); + } + /** * GET /api/v1/artworks/{slug} * Returns a single public artwork resource by slug. diff --git a/app/Http/Controllers/Api/ArtworkTagController.php b/app/Http/Controllers/Api/ArtworkTagController.php new file mode 100644 index 00000000..2332b272 --- /dev/null +++ b/app/Http/Controllers/Api/ArtworkTagController.php @@ -0,0 +1,91 @@ +findOrFail($id); + $this->authorizeOrNotFound($request->user(), $artwork); + + try { + $payload = $request->validated(); + $this->tags->attachUserTags($artwork, $payload['tags']); + + return response()->json(['ok' => true], Response::HTTP_CREATED); + } catch (\Throwable $e) { + $ref = (string) Str::uuid(); + logger()->error('Artwork tag attach failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'user_id' => $request->user()?->id, 'exception' => $e]); + return response()->json([ + 'message' => 'Unable to update tags right now.', + 'ref' => $ref, + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + } + + public function update(int $id, ArtworkTagsUpdateRequest $request): JsonResponse + { + $artwork = Artwork::query()->findOrFail($id); + $this->authorizeOrNotFound($request->user(), $artwork); + + try { + $payload = $request->validated(); + $this->tags->syncTags($artwork, $payload['tags']); + return response()->json(['ok' => true]); + } catch (\Throwable $e) { + $ref = (string) Str::uuid(); + logger()->error('Artwork tag sync failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'user_id' => $request->user()?->id, 'exception' => $e]); + return response()->json([ + 'message' => 'Unable to update tags right now.', + 'ref' => $ref, + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + } + + public function destroy(int $id, Tag $tag): JsonResponse + { + $artwork = Artwork::query()->findOrFail($id); + $this->authorizeOrNotFound(request()->user(), $artwork); + + try { + $this->tags->detachTags($artwork, [$tag->id]); + return response()->json(['ok' => true]); + } catch (\Throwable $e) { + $ref = (string) Str::uuid(); + logger()->error('Artwork tag detach failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'tag_id' => $tag->id, 'user_id' => request()->user()?->id, 'exception' => $e]); + return response()->json([ + 'message' => 'Unable to update tags right now.', + 'ref' => $ref, + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + } + + private function authorizeOrNotFound($user, Artwork $artwork): void + { + if (! $user) { + abort(404); + } + + if (! $user->can('updateTags', $artwork)) { + abort(404); + } + } +} diff --git a/app/Http/Controllers/Api/DiscoveryEventController.php b/app/Http/Controllers/Api/DiscoveryEventController.php new file mode 100644 index 00000000..b6563c69 --- /dev/null +++ b/app/Http/Controllers/Api/DiscoveryEventController.php @@ -0,0 +1,49 @@ +validate([ + 'event_id' => ['nullable', 'uuid'], + 'event_type' => ['required', 'string', 'in:view,click,favorite,download'], + 'artwork_id' => ['required', 'integer', 'exists:artworks,id'], + 'occurred_at' => ['nullable', 'date'], + 'algo_version' => ['nullable', 'string', 'max:64'], + 'meta' => ['nullable', 'array'], + ]); + + $eventId = (string) ($payload['event_id'] ?? (string) Str::uuid()); + $algoVersion = (string) ($payload['algo_version'] ?? config('discovery.algo_version', 'clip-cosine-v1')); + $occurredAt = isset($payload['occurred_at']) + ? (string) $payload['occurred_at'] + : now()->toIso8601String(); + + IngestUserDiscoveryEventJob::dispatch( + eventId: $eventId, + userId: (int) $request->user()->id, + artworkId: (int) $payload['artwork_id'], + eventType: (string) $payload['event_type'], + algoVersion: $algoVersion, + occurredAt: $occurredAt, + meta: (array) ($payload['meta'] ?? []) + )->onQueue((string) config('discovery.queue', 'default')); + + return response()->json([ + 'queued' => true, + 'event_id' => $eventId, + 'algo_version' => $algoVersion, + ], Response::HTTP_ACCEPTED); + } +} diff --git a/app/Http/Controllers/Api/FeedAnalyticsController.php b/app/Http/Controllers/Api/FeedAnalyticsController.php new file mode 100644 index 00000000..a6cb3ba4 --- /dev/null +++ b/app/Http/Controllers/Api/FeedAnalyticsController.php @@ -0,0 +1,45 @@ +validate([ + 'event_type' => ['required', 'string', 'in:feed_impression,feed_click'], + 'artwork_id' => ['required', 'integer', 'exists:artworks,id'], + 'position' => ['nullable', 'integer', 'min:1', 'max:500'], + 'algo_version' => ['required', 'string', 'max:64'], + 'source' => ['required', 'string', 'in:personalized,cold_start,fallback'], + 'dwell_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'], + 'occurred_at' => ['nullable', 'date'], + ]); + + $occurredAt = isset($payload['occurred_at']) ? now()->parse((string) $payload['occurred_at']) : now(); + + DB::table('feed_events')->insert([ + 'event_date' => $occurredAt->toDateString(), + 'event_type' => (string) $payload['event_type'], + 'user_id' => (int) $request->user()->id, + 'artwork_id' => (int) $payload['artwork_id'], + 'position' => isset($payload['position']) ? (int) $payload['position'] : null, + 'algo_version' => (string) $payload['algo_version'], + 'source' => (string) $payload['source'], + 'dwell_seconds' => isset($payload['dwell_seconds']) ? (int) $payload['dwell_seconds'] : null, + 'occurred_at' => $occurredAt, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return response()->json(['success' => true], Response::HTTP_OK); + } +} diff --git a/app/Http/Controllers/Api/FeedController.php b/app/Http/Controllers/Api/FeedController.php new file mode 100644 index 00000000..e52bc439 --- /dev/null +++ b/app/Http/Controllers/Api/FeedController.php @@ -0,0 +1,35 @@ +validate([ + 'limit' => ['nullable', 'integer', 'min:1', 'max:50'], + 'cursor' => ['nullable', 'string', 'max:512'], + 'algo_version' => ['nullable', 'string', 'max:64'], + ]); + + $result = $this->feedService->getFeed( + userId: (int) $request->user()->id, + limit: isset($payload['limit']) ? (int) $payload['limit'] : 24, + cursor: isset($payload['cursor']) ? (string) $payload['cursor'] : null, + algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null + ); + + return response()->json($result); + } +} diff --git a/app/Http/Controllers/Api/SimilarArtworkAnalyticsController.php b/app/Http/Controllers/Api/SimilarArtworkAnalyticsController.php new file mode 100644 index 00000000..c2109e7b --- /dev/null +++ b/app/Http/Controllers/Api/SimilarArtworkAnalyticsController.php @@ -0,0 +1,41 @@ +validate([ + 'event_type' => ['required', 'string', 'in:impression,click'], + 'algo_version' => ['required', 'string', 'max:64'], + 'source_artwork_id' => ['required', 'integer', 'exists:artworks,id'], + 'similar_artwork_id' => ['nullable', 'integer', 'exists:artworks,id'], + 'position' => ['nullable', 'integer', 'min:1', 'max:100'], + 'items_count' => ['nullable', 'integer', 'min:0', 'max:100'], + ]); + + DB::table('similar_artwork_events')->insert([ + 'event_date' => now()->toDateString(), + 'event_type' => (string) $payload['event_type'], + 'algo_version' => (string) $payload['algo_version'], + 'source_artwork_id' => (int) $payload['source_artwork_id'], + 'similar_artwork_id' => isset($payload['similar_artwork_id']) ? (int) $payload['similar_artwork_id'] : null, + 'position' => isset($payload['position']) ? (int) $payload['position'] : null, + 'items_count' => isset($payload['items_count']) ? (int) $payload['items_count'] : null, + 'occurred_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return response()->json(['success' => true], Response::HTTP_OK); + } +} diff --git a/app/Http/Controllers/Api/TagController.php b/app/Http/Controllers/Api/TagController.php new file mode 100644 index 00000000..3b629d2d --- /dev/null +++ b/app/Http/Controllers/Api/TagController.php @@ -0,0 +1,52 @@ +validated()['q'] ?? ''); + $q = trim($q); + + $query = Tag::query()->where('is_active', true); + if ($q !== '') { + $query->where(function ($sub) use ($q): void { + $sub->where('name', 'like', $q . '%') + ->orWhere('slug', 'like', $q . '%'); + }); + } + + $tags = $query + ->orderByDesc('usage_count') + ->limit(20) + ->get(['id', 'name', 'slug', 'usage_count']); + + return response()->json([ + 'data' => $tags, + ]); + } + + public function popular(PopularTagsRequest $request): JsonResponse + { + $limit = (int) ($request->validated()['limit'] ?? 20); + + $tags = Tag::query() + ->where('is_active', true) + ->orderByDesc('usage_count') + ->limit($limit) + ->get(['id', 'name', 'slug', 'usage_count']); + + return response()->json([ + 'data' => $tags, + ]); + } +} diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php new file mode 100644 index 00000000..9b780383 --- /dev/null +++ b/app/Http/Controllers/Api/UploadController.php @@ -0,0 +1,497 @@ +user(); + + try { + $quota->enforce($user->id); + } catch (Throwable $e) { + return response()->json([ + 'message' => $e->getMessage(), + ], Response::HTTP_TOO_MANY_REQUESTS); + } + + $result = $pipeline->initSession($user->id, (string) $request->ip()); + + $audit->log($user->id, 'upload_init_issued', (string) $request->ip(), [ + 'session_id' => $result->sessionId, + ]); + + return response()->json([ + 'session_id' => $result->sessionId, + 'upload_token' => $result->token, + 'status' => $result->status, + ], Response::HTTP_OK); + } + + public function finish( + UploadFinishRequest $request, + UploadPipelineService $pipeline, + UploadSessionRepository $sessions, + UploadAuditService $audit + ) { + $user = $request->user(); + $sessionId = (string) $request->validated('session_id'); + $artworkId = (int) $request->validated('artwork_id'); + + $session = $sessions->getOrFail($sessionId); + + $request->artwork(); + + $validated = $pipeline->validateAndHash($sessionId); + if (! $validated->validation->ok || ! $validated->hash) { + return response()->json([ + 'message' => 'Upload validation failed.', + 'reason' => $validated->validation->reason, + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $scan = $pipeline->scan($sessionId); + if (! $scan->ok) { + return response()->json([ + 'message' => 'Upload scan failed.', + 'reason' => $scan->reason, + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + try { + $previewPath = null; + $status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, &$previewPath) { + if ((bool) config('uploads.queue_derivatives', false)) { + GenerateDerivativesJob::dispatch($sessionId, $validated->hash, $artworkId)->afterCommit(); + return 'queued'; + } + + $result = $pipeline->processAndPublish($sessionId, $validated->hash, $artworkId); + $previewPath = $result['public']['md'] ?? $result['public']['lg'] ?? null; + + // Derivatives are available now; dispatch AI auto-tagging. + AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit(); + GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit(); + return UploadSessionStatus::PROCESSED; + }); + + $audit->log($user->id, 'upload_finished', $session->ip, [ + 'session_id' => $sessionId, + 'hash' => $validated->hash, + 'artwork_id' => $artworkId, + 'status' => $status, + ]); + + return response()->json([ + 'artwork_id' => $artworkId, + 'status' => $status, + 'preview_path' => $previewPath, + ], Response::HTTP_OK); + } catch (Throwable $e) { + Log::error('Upload finish failed', [ + 'session_id' => $sessionId, + 'artwork_id' => $artworkId, + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'message' => 'Upload finish failed.', + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + public function chunk(UploadChunkRequest $request, UploadChunkService $chunks) + { + $user = $request->user(); + $chunkFile = $request->file('chunk'); + + // Debug: log uploaded file object details to help diagnose missing chunk + try { + if (! $chunkFile) { + logger()->warning('Chunk upload: no file present on request', [ + 'session_id' => (string) $request->input('session_id'), + 'headers' => $request->headers->all(), + ]); + } else { + logger()->warning('Chunk upload file details', [ + 'session_id' => (string) $request->input('session_id'), + 'client_name' => $chunkFile->getClientOriginalName() ?? null, + 'client_size' => $chunkFile->getSize() ?? null, + 'error' => $chunkFile->getError(), + 'realpath' => $chunkFile->getRealPath(), + ]); + } + } catch (\Throwable $e) { + logger()->warning('Chunk upload debug logging failed', ['error' => $e->getMessage()]); + } + + try { + // Use getPathname() — this returns the PHP temp filename even when + // getRealPath() may be false (platform/stream wrappers can cause + // getRealPath() to return false). getPathname() is safe for reading + // the uploaded chunk file. + $chunkPath = $chunkFile ? $chunkFile->getPathname() : ''; + + $result = $chunks->appendChunk( + (string) $request->input('session_id'), + (string) $chunkPath, + (int) $request->input('offset'), + (int) $request->input('chunk_size'), + (int) $request->input('total_size'), + (int) $user->id, + (string) $request->ip() + ); + + return response()->json([ + 'session_id' => $result->sessionId, + 'status' => $result->status, + 'received_bytes' => $result->receivedBytes, + 'total_bytes' => $result->totalBytes, + 'progress' => $result->progress, + ], Response::HTTP_OK); + } catch (\Throwable $e) { + logger()->warning('Upload chunk failed', [ + 'session_id' => (string) $request->input('session_id'), + 'error' => $e->getMessage(), + ]); + + // Include the underlying error message in the response during debugging + // so the frontend can show a useful description. Remove or hide this + // in production if you prefer more generic errors. + return response()->json([ + 'message' => 'Upload chunk failed.', + 'error' => $e->getMessage(), + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + } + + public function status(string $id, UploadStatusRequest $request, UploadStatusService $statusService, UploadAuditService $audit) + { + $user = $request->user(); + $payload = $statusService->get($id); + + $audit->log($user->id, 'upload_status_checked', (string) $request->ip(), [ + 'session_id' => $id, + 'status' => $payload['status'], + ]); + + return response()->json([ + 'session_id' => $payload['session_id'], + 'status' => $payload['status'], + 'progress' => $payload['progress'], + 'failure_reason' => $payload['failure_reason'], + 'received_bytes' => $payload['received_bytes'] ?? 0, + ], Response::HTTP_OK); + } + + public function cancel(UploadCancelRequest $request, UploadCancelService $cancel) + { + $user = $request->user(); + + try { + $result = $cancel->cancel( + (string) $request->input('session_id'), + (int) $user->id, + (string) $request->ip() + ); + + return response()->json([ + 'session_id' => $result['session_id'], + 'status' => $result['status'], + ], Response::HTTP_OK); + } catch (\Throwable $e) { + logger()->warning('Upload cancel failed', [ + 'session_id' => (string) $request->input('session_id'), + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'message' => 'Upload cancel failed.', + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * Preload an upload draft: validate main file, create draft and store files. + * + * Returns JSON: { upload_id, status, expires_at } + */ + public function preload(Request $request, UploadDraftServiceInterface $draftService, ArchiveInspectorService $archiveInspector, DraftQuotaService $draftQuotaService) + { + $user = $request->user(); + + $request->validate([ + 'main' => ['required', 'file'], + 'screenshots' => ['sometimes', 'array'], + 'screenshots.*' => ['file', 'image', 'max:5120'], + ]); + + $main = $request->file('main'); + + // Detect type from mime + $mime = (string) $main->getClientMimeType(); + $type = null; + if (str_starts_with($mime, 'image/')) { + $type = 'image'; + } elseif (in_array($mime, ['application/zip', 'application/x-zip-compressed', 'application/x-tar', 'application/x-gzip', 'application/x-rar-compressed', 'application/octet-stream'])) { + $type = 'archive'; + } + + if ($type === null) { + return response()->json([ + 'message' => 'Invalid main file type.', + 'errors' => [ + 'main' => ['The main file must be an image or archive.'], + ], + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + if ($type === 'archive') { + $validator = Validator::make($request->all(), [ + 'screenshots' => ['required', 'array', 'min:1'], + 'screenshots.*' => ['file', 'image', 'max:5120'], + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'The given data was invalid.', + 'errors' => $validator->errors(), + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $inspection = $archiveInspector->inspect((string) $main->getPathname()); + if (! $inspection->valid) { + return response()->json([ + 'message' => 'Archive inspection failed.', + 'reason' => $inspection->reason, + 'stats' => $inspection->stats, + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + } + + $incomingFiles = [$main]; + if ($type === 'archive' && $request->hasFile('screenshots')) { + foreach ($request->file('screenshots') as $screenshot) { + $incomingFiles[] = $screenshot; + } + } + + $mainHash = $draftService->calculateHash((string) $main->getPathname()); + + try { + $warnings = $draftQuotaService->assertCanCreateDraft($user, [ + 'files' => $incomingFiles, + 'main_hash' => $mainHash, + ]); + } catch (DraftQuotaException $e) { + return response()->json([ + 'message' => $e->machineCode(), + 'code' => $e->machineCode(), + ], $e->httpStatus()); + } + + // Create draft record (meta-only) and store main file via service + $draft = $draftService->createDraft(['user_id' => $user->id, 'type' => $type]); + + try { + $mainInfo = $draftService->storeMainFile($draft['id'], $main); + + // If archive, allow optional screenshots to be uploaded in the same request + if ($type === 'archive' && $request->hasFile('screenshots')) { + foreach ($request->file('screenshots') as $ss) { + try { + $draftService->storeScreenshot($draft['id'], $ss); + } catch (Throwable $e) { + // Keep controller thin: log and continue + logger()->warning('Screenshot store failed during preload', ['error' => $e->getMessage(), 'draft' => $draft['id']]); + } + } + } + + // Set expiration (default 7 days) and return info + $ttlDays = (int) config('uploads.draft_ttl_days', 7); + $expiresAt = Carbon::now()->addDays($ttlDays); + $draftService->setExpiration($draft['id'], $expiresAt); + + VirusScanJob::dispatch($draft['id']); + + $response = [ + 'upload_id' => $draft['id'], + 'status' => 'draft', + 'expires_at' => $expiresAt->toISOString(), + ]; + + if (! empty($warnings)) { + $response['warnings'] = array_values($warnings); + } + + return response()->json($response, Response::HTTP_OK); + } catch (Throwable $e) { + logger()->error('Upload preload failed', ['error' => $e->getMessage()]); + return response()->json(['message' => 'Preload failed.'], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + public function autosave(string $id, Request $request) + { + $user = $request->user(); + + $upload = DB::table('uploads')->where('id', $id)->first(); + if (! $upload) { + return response()->json(['message' => 'Upload draft not found.'], Response::HTTP_NOT_FOUND); + } + + if ((int) $upload->user_id !== (int) $user->id) { + return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN); + } + + if ((string) $upload->status !== 'draft') { + return response()->json([ + 'message' => 'Only draft uploads can be autosaved.', + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $validated = $request->validate([ + 'title' => ['nullable', 'string', 'max:255'], + 'category_id' => ['nullable', 'exists:categories,id'], + 'description' => ['nullable', 'string'], + 'tags' => ['nullable', 'array'], + 'license' => ['nullable', 'string'], + 'nsfw' => ['nullable', 'boolean'], + ]); + + $updates = []; + foreach (['title', 'category_id', 'description', 'tags', 'license', 'nsfw'] as $field) { + if (array_key_exists($field, $validated)) { + $updates[$field] = $validated[$field]; + } + } + + $dirty = []; + foreach ($updates as $field => $value) { + $current = $upload->{$field} ?? null; + + if ($field === 'tags') { + $current = $current ? json_decode((string) $current, true) : null; + } + + if ($field === 'nsfw') { + $current = is_null($current) ? null : (bool) $current; + $value = is_null($value) ? null : (bool) $value; + } + + if ($current !== $value) { + $dirty[$field] = $value; + } + } + + if (array_key_exists('tags', $dirty)) { + $dirty['tags'] = json_encode($dirty['tags']); + } + + if (! empty($dirty)) { + $dirty['updated_at'] = now(); + DB::table('uploads')->where('id', $id)->update($dirty); + $upload = DB::table('uploads')->where('id', $id)->first(); + } + + return response()->json([ + 'success' => true, + 'updated_at' => (string) ($upload->updated_at ?? now()->toDateTimeString()), + ], Response::HTTP_OK); + } + + public function processingStatus(string $id, Request $request) + { + $user = $request->user(); + + $upload = DB::table('uploads')->where('id', $id)->first(); + if (! $upload) { + return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND); + } + + if ((int) $upload->user_id !== (int) $user->id) { + return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN); + } + + $status = (string) ($upload->status ?? 'draft'); + $isScanned = (bool) ($upload->is_scanned ?? false); + $previewReady = ! empty($upload->preview_path); + $hasTags = (bool) ($upload->has_tags ?? false); + $processingState = (string) ($upload->processing_state ?? 'pending_scan'); + + return response()->json([ + 'id' => (string) $upload->id, + 'status' => $status, + 'is_scanned' => $isScanned, + 'preview_ready' => $previewReady, + 'has_tags' => $hasTags, + 'processing_state' => $processingState, + ], Response::HTTP_OK); + } + + public function publish(string $id, Request $request, PublishService $publishService) + { + $user = $request->user(); + + try { + $upload = $publishService->publish($id, $user); + + return response()->json([ + 'success' => true, + 'upload_id' => (string) $upload->id, + 'status' => (string) $upload->status, + 'published_at' => optional($upload->published_at)->toISOString(), + 'final_path' => (string) $upload->final_path, + ], Response::HTTP_OK); + } catch (UploadOwnershipException $e) { + return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN); + } catch (UploadNotFoundException $e) { + return response()->json(['message' => $e->getMessage()], Response::HTTP_NOT_FOUND); + } catch (UploadPublishValidationException $e) { + return response()->json(['message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY); + } + } +} diff --git a/app/Http/Controllers/ArtworkController.php b/app/Http/Controllers/ArtworkController.php index 5f1d67d0..f7e8267a 100644 --- a/app/Http/Controllers/ArtworkController.php +++ b/app/Http/Controllers/ArtworkController.php @@ -1,9 +1,12 @@ is_public || ! $artwork->is_approved || $artwork->trashed()) { + // Manually resolve artwork by slug when provided. The route may bind + // the 'artwork' parameter to an Artwork model or pass the slug string. + $foundArtwork = null; + $artworkSlug = null; + if ($artwork instanceof Artwork) { + $foundArtwork = $artwork; + $artworkSlug = $artwork->slug; + } elseif ($artwork) { + $artworkSlug = (string) $artwork; + $foundArtwork = Artwork::where('slug', $artworkSlug)->first(); + } + + // If no artwork was found, treat the request as a category path. + // The route places the artwork slug in the last segment, so include it + // when forwarding to CategoryPageController to support arbitrary-depth paths + if (! $foundArtwork) { + $combinedPath = $categoryPath; + if ($artworkSlug) { + $combinedPath = trim($categoryPath . '/' . $artworkSlug, '/'); + } + return app(CategoryPageController::class)->show(request(), $contentTypeSlug, $combinedPath); + } + + if (! $foundArtwork->is_public || ! $foundArtwork->is_approved || $foundArtwork->trashed()) { abort(404); } - return view('artworks.show', ['artwork' => $artwork]); + $foundArtwork->loadMissing(['categories.contentType', 'user']); + + $defaultAlgoVersion = (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1'); + $selectedAlgoVersion = $this->selectAlgoVersionForRequest($request, $defaultAlgoVersion); + + $similarService = app(SimilarArtworksService::class); + $similarArtworks = $similarService->forArtwork((int) $foundArtwork->id, 12, $selectedAlgoVersion); + + if ($similarArtworks->isEmpty() && $selectedAlgoVersion !== $defaultAlgoVersion) { + $similarArtworks = $similarService->forArtwork((int) $foundArtwork->id, 12, $defaultAlgoVersion); + $selectedAlgoVersion = $defaultAlgoVersion; + } + + $similarArtworks->each(static function (Artwork $item): void { + $item->loadMissing(['categories.contentType', 'user']); + }); + + $similarItems = $similarArtworks + ->map(function (Artwork $item): ?array { + $category = $item->categories->first(); + $contentType = $category?->contentType; + + if (! $category || ! $contentType || empty($item->slug)) { + return null; + } + + return [ + 'id' => (int) $item->id, + 'title' => (string) $item->title, + 'author' => (string) optional($item->user)->name, + 'thumb' => (string) ($item->thumb_url ?? $item->thumb ?? '/gfx/sb_join.jpg'), + 'thumb_srcset' => (string) ($item->thumb_srcset ?? ''), + 'url' => route('artworks.show', [ + 'contentTypeSlug' => (string) $contentType->slug, + 'categoryPath' => (string) $category->slug, + 'artwork' => (string) $item->slug, + ]), + ]; + }) + ->filter() + ->values(); + + return view('artworks.show', [ + 'artwork' => $foundArtwork, + 'similarItems' => $similarItems, + 'similarAlgoVersion' => $selectedAlgoVersion, + ]); + } + + private function selectAlgoVersionForRequest(Request $request, string $default): string + { + $configured = (array) config('recommendations.ab.algo_versions', []); + $versions = array_values(array_filter(array_map(static fn ($value): string => trim((string) $value), $configured))); + + if ($versions === []) { + return $default; + } + + if (! in_array($default, $versions, true)) { + array_unshift($versions, $default); + $versions = array_values(array_unique($versions)); + } + + $forced = trim((string) $request->query('algo_version', '')); + if ($forced !== '' && in_array($forced, $versions, true)) { + return $forced; + } + + if (count($versions) === 1) { + return $versions[0]; + } + + $visitorKey = $request->user()?->id + ? 'u:' . (string) $request->user()->id + : 's:' . (string) $request->session()->getId(); + + $bucket = abs(crc32($visitorKey)) % count($versions); + + return $versions[$bucket] ?? $default; } } diff --git a/app/Http/Controllers/AvatarController.php b/app/Http/Controllers/AvatarController.php new file mode 100644 index 00000000..b80150de --- /dev/null +++ b/app/Http/Controllers/AvatarController.php @@ -0,0 +1,47 @@ +service = $service; + } + + /** + * Handle avatar upload request. + */ + public function upload(Request $request) + { + $user = Auth::user(); + if (!$user) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + $rules = [ + 'avatar' => 'required|image|max:2048|mimes:jpg,jpeg,png,webp', + ]; + + $validator = Validator::make($request->all(), $rules); + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $file = $request->file('avatar'); + + try { + $hash = $this->service->storeFromUploadedFile($user->id, $file); + return response()->json(['success' => true, 'hash' => $hash], 200); + } catch (\Exception $e) { + return response()->json(['error' => 'Processing failed', 'message' => $e->getMessage()], 500); + } + } +} diff --git a/app/Http/Controllers/CategoryPageController.php b/app/Http/Controllers/CategoryPageController.php index 32756ad6..3bee4994 100644 --- a/app/Http/Controllers/CategoryPageController.php +++ b/app/Http/Controllers/CategoryPageController.php @@ -5,12 +5,13 @@ namespace App\Http\Controllers; use App\Models\Category; use App\Models\ContentType; use App\Models\Artwork; +use App\Services\ArtworkService; use Illuminate\Http\Request; use Illuminate\Pagination\LengthAwarePaginator; class CategoryPageController extends Controller { - public function show(Request $request, string $contentTypeSlug, string $categoryPath = null) + public function show(Request $request, string $contentTypeSlug, ?string $categoryPath = null) { $contentType = ContentType::where('slug', strtolower($contentTypeSlug))->first(); if (! $contentType) { @@ -24,38 +25,51 @@ class CategoryPageController extends Controller $page_title = $contentType->name; $page_meta_description = $contentType->description ?? ($contentType->name . ' artworks on Skinbase'); + // Load artworks for this content type (show gallery on the root page) + $perPage = 40; + $artworks = Artwork::whereHas('categories', function ($q) use ($contentType) { + $q->where('categories.content_type_id', $contentType->id); + }) + ->published()->public() + ->with([ + 'user:id,name', + 'categories' => function ($q) { + $q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') + ->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']); + }, + ]) + ->orderBy('published_at', 'desc') + ->paginate($perPage) + ->withQueryString(); + return view('legacy.content-type', compact( 'contentType', 'rootCategories', + 'artworks', 'page_title', 'page_meta_description' )); } $segments = array_filter(explode('/', $categoryPath)); - if (empty($segments)) { + $slugs = array_values(array_map('strtolower', $segments)); + if (empty($slugs)) { return redirect('/browse-categories'); } - // Traverse categories by slug path within the content type - $current = Category::where('content_type_id', $contentType->id) - ->whereNull('parent_id') - ->where('slug', strtolower(array_shift($segments))) - ->first(); + // If the first slug exists but under a different content type, redirect to its canonical URL + $firstSlug = $slugs[0]; + $globalRoot = Category::whereNull('parent_id')->where('slug', $firstSlug)->first(); + if ($globalRoot && $globalRoot->contentType && $globalRoot->contentType->slug !== strtolower($contentType->slug)) { + $redirectPath = '/' . $globalRoot->contentType->slug . '/' . implode('/', $slugs); + return redirect($redirectPath, 301); + } - - if (! $current) { + // Resolve category by path using the helper that validates parent chain and content type + $category = Category::findByPath($contentType->slug, $slugs); + if (! $category) { abort(404); } - - foreach ($segments as $slug) { - $current = $current->children()->where('slug', strtolower($slug))->first(); - if (! $current) { - abort(404); - } - } - - $category = $current; $subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get(); $rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get(); @@ -71,21 +85,23 @@ class CategoryPageController extends Controller $category->load('children'); $gather($category); - // Load artworks that are attached to any of these categories - $query = Artwork::whereHas('categories', function ($q) use ($collected) { - $q->whereIn('categories.id', $collected); - })->published()->public(); - - // Paginate results + // Load artworks via ArtworkService to support arbitrary-depth category paths $perPage = 40; - $artworks = $query->orderBy('published_at', 'desc') - ->paginate($perPage) - ->withQueryString(); + try { + $service = app(ArtworkService::class); + // service expects an array with contentType slug first, then category slugs + $pathSlugs = array_merge([strtolower($contentTypeSlug)], $slugs); + $artworks = $service->getArtworksByCategoryPath($pathSlugs, $perPage); + } catch (\Throwable $e) { + abort(404); + } $page_title = $category->name; $page_meta_description = $category->description ?? ($contentType->name . ' artworks on Skinbase'); $page_meta_keywords = strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography'; + // resolved category and breadcrumbs are used by the view + return view('legacy.category-slug', compact( 'contentType', 'category', diff --git a/app/Http/Controllers/ContentRouterController.php b/app/Http/Controllers/ContentRouterController.php new file mode 100644 index 00000000..f4130e57 --- /dev/null +++ b/app/Http/Controllers/ContentRouterController.php @@ -0,0 +1,45 @@ +show($request, $contentTypeSlug, $normalizedCategoryPath, $artwork); + } + + $path = $categoryPath; + + // If no path provided, render the content-type landing (root) page + if (empty($path)) { + // Special-case photography root to use legacy controller + if (strtolower($contentTypeSlug) === 'photography') { + return app(\App\Http\Controllers\Legacy\PhotographyController::class)->index($request); + } + + return app(\App\Http\Controllers\CategoryPageController::class)->show($request, $contentTypeSlug, null); + } + + $segments = array_values(array_filter(explode('/', $path))); + if (empty($segments)) { + return app(\App\Http\Controllers\CategoryPageController::class)->show($request, $contentTypeSlug, null); + } + + // Treat the last segment as an artwork slug candidate and delegate to ArtworkController::show + $artworkSlug = array_pop($segments); + $categoryPath = implode('/', $segments); + + return app(ArtworkController::class)->show($request, $contentTypeSlug, $categoryPath, $artworkSlug); + } +} diff --git a/app/Http/Controllers/Dashboard/ArtworkController.php b/app/Http/Controllers/Dashboard/ArtworkController.php index bb0451d7..895b196e 100644 --- a/app/Http/Controllers/Dashboard/ArtworkController.php +++ b/app/Http/Controllers/Dashboard/ArtworkController.php @@ -3,8 +3,9 @@ namespace App\Http\Controllers\Dashboard; use App\Http\Controllers\Controller; +use App\Http\Requests\Dashboard\ArtworkEditRequest; +use App\Http\Requests\Dashboard\ArtworkDestroyRequest; use App\Http\Requests\Dashboard\UpdateArtworkRequest; -use App\Models\Artwork; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; @@ -25,10 +26,9 @@ class ArtworkController extends Controller ]); } - public function edit(Request $request, int $id): View + public function edit(ArtworkEditRequest $request, int $id): View { - $artwork = $request->user()->artworks()->whereKey($id)->firstOrFail(); - $this->authorize('update', $artwork); + $artwork = $request->artwork(); return view('artworks.edit', [ 'artwork' => $artwork, @@ -38,8 +38,7 @@ class ArtworkController extends Controller public function update(UpdateArtworkRequest $request, int $id): RedirectResponse { - $artwork = $request->user()->artworks()->whereKey($id)->firstOrFail(); - $this->authorize('update', $artwork); + $artwork = $request->artwork(); $data = $request->validated(); @@ -83,10 +82,9 @@ class ArtworkController extends Controller ->with('status', 'Artwork updated.'); } - public function destroy(Request $request, int $id): RedirectResponse + public function destroy(ArtworkDestroyRequest $request, int $id): RedirectResponse { - $artwork = $request->user()->artworks()->whereKey($id)->firstOrFail(); - $this->authorize('delete', $artwork); + $artwork = $request->artwork(); // Best-effort remove stored file. if (! empty($artwork->file_path) && Storage::disk('public')->exists($artwork->file_path)) { diff --git a/app/Http/Controllers/Legacy/PhotographyController.php b/app/Http/Controllers/Legacy/PhotographyController.php index b804f274..4c0d1d3b 100644 --- a/app/Http/Controllers/Legacy/PhotographyController.php +++ b/app/Http/Controllers/Legacy/PhotographyController.php @@ -21,61 +21,105 @@ class PhotographyController extends Controller public function index(Request $request) { // Legacy group mapping: Photography => id 3 - $group = 'Photography'; - $id = 3; + // Determine the requested content type from the first URL segment (photography|wallpapers|skins) + $segment = strtolower($request->segment(1) ?? 'photography'); + $contentSlug = in_array($segment, ['photography','wallpapers','skins','other']) ? $segment : 'photography'; - // Fetch legacy category info if available - $category = null; - try { - if (Schema::hasTable('artworks_categories')) { - $category = DB::table('artworks_categories') - ->select('category_name', 'rootid', 'section_id', 'description', 'category_id') - ->where('category_id', $id) - ->first(); + // Human-friendly group name (used by legacy templates) + $group = ucfirst($contentSlug); + + // Try to load legacy category id only for photography (legacy mapping); otherwise prefer authoritative ContentType + $id = null; + if ($contentSlug === 'photography') { + $id = 3; // legacy root id for photography in oldSite (kept for backward compatibility) } - } catch (\Throwable $e) { + + // Fetch legacy category info if available (only when we have an id) $category = null; - } - - $page_title = $category->category_name ?? 'Photography'; - $tidy = $category->description ?? null; - - $perPage = 40; - - // Use ArtworkService to get artworks for the content type 'photography' - try { - $artworks = $this->artworks->getArtworksByContentType('photography', $perPage); - } catch (\Throwable $e) { - $artworks = collect(); - } - - // Load subcategories (legacy) if available - $subcategories = collect(); - try { - if (Schema::hasTable('artworks_categories')) { - $subcategories = DB::table('artworks_categories')->select('category_id','category_name')->where('rootid', $id)->orderBy('category_name')->get(); - if ($subcategories->count() == 0 && !empty($category->rootid)) { - $subcategories = DB::table('artworks_categories')->select('category_id','category_name')->where('rootid', $category->rootid)->orderBy('category_name')->get(); + try { + if ($id !== null && Schema::hasTable('artworks_categories')) { + $category = DB::table('artworks_categories') + ->select('category_name', 'rootid', 'section_id', 'description', 'category_id') + ->where('category_id', $id) + ->first(); } + } catch (\Throwable $e) { + $category = null; } - } catch (\Throwable $e) { - $subcategories = collect(); - } - // Fallback to authoritative categories table when legacy table is missing/empty - if (! $subcategories || $subcategories->count() === 0) { - $ct = ContentType::where('slug', 'photography')->first(); - if ($ct) { - $subcategories = $ct->rootCategories() - ->orderBy('sort_order') - ->orderBy('name') - ->get() - ->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name]); - } else { + // Page title and description: prefer legacy category when present, otherwise use ContentType data + $ct = ContentType::where('slug', $contentSlug)->first(); + $page_title = $category->category_name ?? ($ct->name ?? ucfirst($contentSlug)); + $tidy = $category->description ?? ($ct->description ?? null); + + $perPage = 40; + + // Load artworks for the requested content type using standard pagination + try { + $artQuery = \App\Models\Artwork::public() + ->published() + ->whereHas('categories', function ($q) use ($ct) { + $q->where('categories.content_type_id', $ct->id); + }) + ->with([ + 'user:id,name', + 'categories' => function ($q) { + $q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') + ->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']); + }, + ]) + ->orderByDesc('published_at'); + + $artworks = $artQuery->paginate($perPage)->withQueryString(); + } catch (\Throwable $e) { + // Return an empty paginator so views using ->links() / ->firstItem() work + $artworks = new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage, 1, [ + 'path' => url()->current(), + ]); + } + + // Load subcategories: prefer legacy table when id present and data exists, otherwise use ContentType root categories + $subcategories = collect(); + try { + if ($id !== null && Schema::hasTable('artworks_categories')) { + $subcategories = DB::table('artworks_categories')->select('category_id','category_name')->where('rootid', $id)->orderBy('category_name')->get(); + if ($subcategories->count() == 0 && !empty($category->rootid)) { + $subcategories = DB::table('artworks_categories')->select('category_id','category_name')->where('rootid', $category->rootid)->orderBy('category_name')->get(); + } + } + } catch (\Throwable $e) { $subcategories = collect(); } - } - return view('legacy.photography', compact('page_title','tidy','group','artworks','subcategories','id')); + if (! $subcategories || $subcategories->count() === 0) { + if ($ct) { + $subcategories = $ct->rootCategories() + ->orderBy('sort_order') + ->orderBy('name') + ->get() + ->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]); + } else { + $subcategories = collect(); + } + } + + // Coerce collections to a paginator so the view's pagination helpers work + if ($artworks instanceof \Illuminate\Database\Eloquent\Collection || $artworks instanceof \Illuminate\Support\Collection) { + $page = (int) ($request->query('page', 1)); + $artworks = new \Illuminate\Pagination\LengthAwarePaginator($artworks->values()->all(), $artworks->count(), $perPage, $page, [ + 'path' => url()->current(), + 'query' => request()->query(), + ]); + } + + // Prepare variables for the modern content-type view + $contentType = ContentType::where('slug', $contentSlug)->first(); + $rootCategories = $contentType + ? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get() + : collect(); + + $page_meta_description = $tidy; + + return view('legacy.content-type', compact('contentType','rootCategories','artworks','page_title','page_meta_description','subcategories','id')); } } diff --git a/app/Http/Controllers/Legacy/UserController.php b/app/Http/Controllers/Legacy/UserController.php index 8f199c01..a8a35464 100644 --- a/app/Http/Controllers/Legacy/UserController.php +++ b/app/Http/Controllers/Legacy/UserController.php @@ -37,29 +37,46 @@ class UserController extends Controller $request->session()->flash('error', 'Password not changed.'); } } else { - $data = $request->only(['real_name','web','country_code','signature','description','about_me']); - $user->real_name = $data['real_name'] ?? $user->real_name; - $user->web = $data['web'] ?? $user->web; - $user->country_code = $data['country_code'] ?? $user->country_code; - $user->signature = $data['signature'] ?? $user->signature; - $user->description = $data['description'] ?? $user->description; - $user->about_me = $data['about_me'] ?? $user->about_me; + // Map legacy form fields into the modern schema. + $data = $request->only(['name','web','country_code','signature','description','about_me']); + + // Core user column: `name` + if (isset($data['name'])) { + $user->name = $data['name'] ?? $user->name; + } + + // Collect other profile updates to persist into `user_profiles` when available + $profileUpdates = []; + if (!empty($data['web'])) $profileUpdates['website'] = $data['web']; + if (!empty($data['signature'])) $profileUpdates['signature'] = $data['signature']; + if (!empty($data['description'])) $profileUpdates['description'] = $data['description']; + if (!empty($data['about_me'])) $profileUpdates['about'] = $data['about_me']; + if (!empty($data['country_code'])) $profileUpdates['country_code'] = $data['country_code']; $d1 = $request->input('date1'); $d2 = $request->input('date2'); $d3 = $request->input('date3'); if ($d1 && $d2 && $d3) { - $user->birth = sprintf('%04d-%02d-%02d', (int)$d3, (int)$d2, (int)$d1); + $profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int)$d3, (int)$d2, (int)$d1); } - $user->gender = $request->input('gender', $user->gender); - $user->mlist = $request->has('newsletter') ? 1 : 0; - $user->friend_upload_notice = $request->has('friend_upload_notice') ? 1 : 0; + $userGender = $request->input('gender', $user->gender); + if (!empty($userGender)) { + $g = strtolower($userGender); + $map = ['m' => 'M', 'f' => 'F', 'n' => 'X', 'x' => 'X']; + $profileUpdates['gender'] = $map[$g] ?? strtoupper($userGender); + } + $profileUpdates['mlist'] = $request->has('newsletter') ? 1 : 0; + $profileUpdates['friend_upload_notice'] = $request->has('friend_upload_notice') ? 1 : 0; + + // Files: avatar/photo/emoticon if ($request->hasFile('avatar')) { $f = $request->file('avatar'); $name = $user->id . '.' . $f->getClientOriginalExtension(); $f->move(public_path('avatar'), $name); + // store filename in profile avatar (legacy field) — modern avatar pipeline will later migrate + $profileUpdates['avatar'] = $name; $user->icon = $name; } @@ -67,6 +84,7 @@ class UserController extends Controller $f = $request->file('personal_picture'); $name = $user->id . '.' . $f->getClientOriginalExtension(); $f->move(public_path('user-picture'), $name); + $profileUpdates['cover_image'] = $name; $user->picture = $name; } @@ -77,25 +95,28 @@ class UserController extends Controller $user->eicon = $name; } + // Save core user fields $user->save(); + + // Persist profile updates into `user_profiles` when available, otherwise fallback to `users` table + try { + if (!empty($profileUpdates) && Schema::hasTable('user_profiles')) { + DB::table('user_profiles')->updateOrInsert(['user_id' => $user->id], $profileUpdates + ['updated_at' => now(), 'created_at' => now()]); + } elseif (!empty($profileUpdates)) { + DB::table('users')->where('id', $user->id)->update($profileUpdates); + } + } catch (\Throwable $e) { + // ignore persistence errors for legacy path + } + $request->session()->flash('status', 'Profile updated.'); } } - // Prepare birth date parts for the legacy form + // Prepare birth date parts for the legacy form (initialized — parsed after merging profiles) $birthDay = null; $birthMonth = null; $birthYear = null; - if (! empty($user->birth)) { - try { - $dt = Carbon::parse($user->birth); - $birthDay = $dt->format('d'); - $birthMonth = $dt->format('m'); - $birthYear = $dt->format('Y'); - } catch (\Throwable $e) { - // ignore parse errors - } - } // Load country list if available (legacy table names) $countries = collect(); @@ -109,6 +130,50 @@ class UserController extends Controller $countries = collect(); } + // Merge modern `user_profiles` and `user_social_links` into the user object for the view + try { + if (Schema::hasTable('user_profiles')) { + $profile = DB::table('user_profiles')->where('user_id', $user->id)->first(); + if ($profile) { + // map modern profile fields onto the legacy user properties/helpers used by the view + if (isset($profile->website)) $user->homepage = $profile->website; + if (isset($profile->about)) $user->about_me = $profile->about; + if (isset($profile->birthdate)) $user->birth = $profile->birthdate; + if (isset($profile->gender)) $user->gender = $profile->gender; + if (isset($profile->country_code)) $user->country_code = $profile->country_code; + if (isset($profile->avatar)) $user->icon = $profile->avatar; + if (isset($profile->cover_image)) $user->picture = $profile->cover_image; + if (isset($profile->signature)) $user->signature = $profile->signature; + if (isset($profile->description)) $user->description = $profile->description; + } + } + } catch (\Throwable $e) { + // ignore profile merge errors + } + + try { + if (Schema::hasTable('user_social_links')) { + $social = DB::table('user_social_links')->where('user_id', $user->id)->first(); + if ($social) { + $user->social = $social; + } + } + } catch (\Throwable $e) { + // ignore social links errors + } + + // Parse birth date parts after merging `user_profiles` so profile birthdate is used + if (! empty($user->birth)) { + try { + $dt = Carbon::parse($user->birth); + $birthDay = $dt->format('d'); + $birthMonth = $dt->format('m'); + $birthYear = $dt->format('Y'); + } catch (\Throwable $e) { + // ignore parse errors + } + } + return view('legacy.user', [ 'user' => $user, 'birthDay' => $birthDay, diff --git a/app/Http/Controllers/ManageController.php b/app/Http/Controllers/ManageController.php index 611cc6ce..f8f53576 100644 --- a/app/Http/Controllers/ManageController.php +++ b/app/Http/Controllers/ManageController.php @@ -2,8 +2,9 @@ namespace App\Http\Controllers; -use App\Models\Artwork; -use App\Models\ArtworkCategory; +use App\Http\Requests\Manage\ManageArtworkEditRequest; +use App\Http\Requests\Manage\ManageArtworkUpdateRequest; +use App\Http\Requests\Manage\ManageArtworkDestroyRequest; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\DB; @@ -38,13 +39,9 @@ class ManageController extends Controller ]); } - public function edit(Request $request, $id) + public function edit(ManageArtworkEditRequest $request, $id) { - $userId = $request->user()->id; - $artwork = DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first(); - if (! $artwork) { - abort(404); - } + $artwork = $request->artwork(); // If artworks no longer have a single `category` column, fetch pivot selection $selectedCategory = DB::table('artwork_category')->where('artwork_id', (int)$id)->value('category_id'); @@ -63,22 +60,10 @@ class ManageController extends Controller ]); } - public function update(Request $request, $id) + public function update(ManageArtworkUpdateRequest $request, $id) { - $userId = $request->user()->id; - $existing = DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first(); - - if (! $existing) { - abort(404); - } - - $data = $request->validate([ - 'name' => 'required|string|max:255', - 'section' => 'nullable|integer', - 'description' => 'nullable|string', - 'artwork' => 'nullable|file|image', - 'attachment' => 'nullable|file', - ]); + $existing = $request->artwork(); + $data = $request->validated(); $update = [ 'name' => $data['name'], 'description' => $data['description'] ?? $existing->description, @@ -100,7 +85,7 @@ class ManageController extends Controller $update['fname'] = basename($attPath); } - DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->update($update); + DB::table('artworks')->where('id', (int)$id)->update($update); // Update pivot: set single category selection for this artwork if (isset($data['section'])) { @@ -114,13 +99,9 @@ class ManageController extends Controller return redirect()->route('manage')->with('status', 'Artwork was successfully updated.'); } - public function destroy(Request $request, $id) + public function destroy(ManageArtworkDestroyRequest $request, $id) { - $userId = $request->user()->id; - $artwork = DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first(); - if (! $artwork) { - abort(404); - } + $artwork = $request->artwork(); // delete files if present (stored in new storage location) if (!empty($artwork->fname)) { @@ -130,7 +111,7 @@ class ManageController extends Controller Storage::delete('public/uploads/artworks/' . $artwork->picture); } - DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->delete(); + DB::table('artworks')->where('id', (int)$id)->delete(); return redirect()->route('manage')->with('status', 'Artwork deleted.'); } diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index 19f9c9bc..d2d80cb6 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -8,6 +8,8 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Redirect; use Illuminate\View\View; +use Illuminate\Support\Facades\Hash; +use Illuminate\Validation\Rules\Password as PasswordRule; class ProfileController extends Controller { @@ -24,17 +26,124 @@ class ProfileController extends Controller /** * Update the user's profile information. */ - public function update(ProfileUpdateRequest $request): RedirectResponse + public function update(ProfileUpdateRequest $request, \App\Services\AvatarService $avatarService): RedirectResponse { - $request->user()->fill($request->validated()); + $user = $request->user(); - if ($request->user()->isDirty('email')) { - $request->user()->email_verified_at = null; + // Core fields + $validated = $request->validated(); + + logger()->debug('Profile update validated data', $validated); + + // Username is read-only and must not be changed here. + // Use `name` for the real/display name field. + if (isset($validated['name'])) { + $user->name = $validated['name']; } - $request->user()->save(); + // Only allow setting email when we don't have one yet (legacy users) + if (!empty($validated['email']) && empty($user->email)) { + $user->email = $validated['email']; + $user->email_verified_at = null; + } - return Redirect::route('profile.edit')->with('status', 'profile-updated'); + $user->save(); + + // Profile fields - target columns in `user_profiles` per spec + $profileUpdates = []; + if (!empty($validated['about'])) $profileUpdates['about'] = $validated['about']; + + // website / legacy homepage + if (!empty($validated['web'])) { + $profileUpdates['website'] = $validated['web']; + } elseif (!empty($validated['homepage'])) { + $profileUpdates['website'] = $validated['homepage']; + } + + // Birthday -> store as birthdate + $day = $validated['day'] ?? null; + $month = $validated['month'] ?? null; + $year = $validated['year'] ?? null; + if ($year && $month && $day) { + $profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int)$year, (int)$month, (int)$day); + } + + // Gender normalization -> store as provided normalized value + if (!empty($validated['gender'])) { + $g = strtolower($validated['gender']); + $map = ['m' => 'M', 'f' => 'F', 'n' => 'X', 'x' => 'X']; + $profileUpdates['gender'] = $map[$g] ?? strtoupper($validated['gender']); + } + + if (!empty($validated['country'])) $profileUpdates['country_code'] = $validated['country']; + + // Mailing and notify flags: normalize true/false when saving + if (array_key_exists('mailing', $validated)) { + $profileUpdates['mlist'] = filter_var($validated['mailing'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0; + } + if (array_key_exists('notify', $validated)) { + $profileUpdates['friend_upload_notice'] = filter_var($validated['notify'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0; + } + + // signature/description should be stored in their own columns + if (isset($validated['signature'])) $profileUpdates['signature'] = $validated['signature']; + if (isset($validated['description'])) $profileUpdates['description'] = $validated['description']; + + // 'about' direct field (ensure explicit about wins when provided) + if (isset($validated['about'])) $profileUpdates['about'] = $validated['about']; + + // Files: avatar -> use AvatarService, emoticon and photo -> store to public disk + if ($request->hasFile('avatar')) { + try { + $hash = $avatarService->storeFromUploadedFile($user->id, $request->file('avatar')); + // store returned hash into profile avatar column + if (!empty($hash)) { + $profileUpdates['avatar'] = $hash; + } + } catch (\Exception $e) { + return Redirect::back()->with('error', 'Avatar processing failed: ' . $e->getMessage()); + } + } + + if ($request->hasFile('emoticon')) { + $file = $request->file('emoticon'); + $fname = $file->getClientOriginalName(); + $path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname); + try { + \Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['eicon' => $fname]); + } catch (\Exception $e) {} + } + + if ($request->hasFile('photo')) { + $file = $request->file('photo'); + $fname = $file->getClientOriginalName(); + $path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname); + // store cover image filename in user_profiles.cover_image (fallback to users.picture) + if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) { + $profileUpdates['cover_image'] = $fname; + } else { + try { + \Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['picture' => $fname]); + } catch (\Exception $e) {} + } + } + + // Persist profile updates now that files (avatar/cover) have been handled + try { + if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) { + if (!empty($profileUpdates)) { + \Illuminate\Support\Facades\DB::table('user_profiles')->updateOrInsert(['user_id' => $user->id], $profileUpdates); + } + } else { + if (!empty($profileUpdates)) { + \Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update($profileUpdates); + } + } + } catch (\Exception $e) { + logger()->error('Profile update error: '.$e->getMessage()); + } + + return Redirect::to('/user')->with('status', 'profile-updated'); } /** @@ -58,4 +167,21 @@ class ProfileController extends Controller return Redirect::to('/'); } + + /** + * Update the user's password. + */ + public function password(Request $request): RedirectResponse + { + $request->validate([ + 'current_password' => ['required', 'current_password'], + 'password' => ['required', 'confirmed', PasswordRule::min(8)], + ]); + + $user = $request->user(); + $user->password = Hash::make($request->input('password')); + $user->save(); + + return Redirect::to('/user')->with('status', 'password-updated'); + } } diff --git a/app/Http/Controllers/Web/TagController.php b/app/Http/Controllers/Web/TagController.php new file mode 100644 index 00000000..cb9fc431 --- /dev/null +++ b/app/Http/Controllers/Web/TagController.php @@ -0,0 +1,29 @@ +artworks() + ->public() + ->published() + ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') + ->orderByDesc('artwork_stats.views') + ->orderByDesc('artworks.published_at') + ->select('artworks.*') + ->paginate(24); + + return view('tags.show', [ + 'tag' => $tag, + 'artworks' => $artworks, + ]); + } +} diff --git a/app/Http/Middleware/EnsureAdminOrModerator.php b/app/Http/Middleware/EnsureAdminOrModerator.php new file mode 100644 index 00000000..ae7c577a --- /dev/null +++ b/app/Http/Middleware/EnsureAdminOrModerator.php @@ -0,0 +1,24 @@ +user(); + $role = strtolower((string) ($user->role ?? '')); + + if (! in_array($role, ['admin', 'moderator'], true)) { + abort(Response::HTTP_FORBIDDEN, 'Forbidden.'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php new file mode 100644 index 00000000..179df07b --- /dev/null +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -0,0 +1,33 @@ + [ + 'user' => $request->user() ? [ + 'id' => $request->user()->id, + 'name' => $request->user()->name, + ] : null, + ], + 'cdn' => [ + 'files_url' => config('cdn.files_url'), + ], + ]); + } +} diff --git a/app/Http/Requests/Artworks/ArtworkCreateRequest.php b/app/Http/Requests/Artworks/ArtworkCreateRequest.php new file mode 100644 index 00000000..4606e64c --- /dev/null +++ b/app/Http/Requests/Artworks/ArtworkCreateRequest.php @@ -0,0 +1,46 @@ +user()) { + $this->logUnauthorized('missing_user'); + $this->denyAsNotFound(); + } + + return true; + } + + public function rules(): array + { + return [ + 'title' => 'required|string|max:150', + 'description' => 'nullable|string', + 'category' => 'nullable|string|max:120', + 'tags' => 'nullable|string|max:200', + 'license' => 'nullable|boolean', + ]; + } + + private function denyAsNotFound(): void + { + throw new NotFoundHttpException(); + } + + private function logUnauthorized(string $reason): void + { + logger()->warning('Artwork create unauthorized access', [ + 'reason' => $reason, + 'user_id' => $this->user()?->id, + 'ip' => $this->ip(), + ]); + } +} diff --git a/app/Http/Requests/Artworks/ArtworkTagsStoreRequest.php b/app/Http/Requests/Artworks/ArtworkTagsStoreRequest.php new file mode 100644 index 00000000..762dfdaf --- /dev/null +++ b/app/Http/Requests/Artworks/ArtworkTagsStoreRequest.php @@ -0,0 +1,28 @@ +user()) { + throw new NotFoundHttpException(); + } + + return true; + } + + public function rules(): array + { + return [ + 'tags' => 'required|array|max:15', + 'tags.*' => 'required|string|max:64', + ]; + } +} diff --git a/app/Http/Requests/Artworks/ArtworkTagsUpdateRequest.php b/app/Http/Requests/Artworks/ArtworkTagsUpdateRequest.php new file mode 100644 index 00000000..e97e6f29 --- /dev/null +++ b/app/Http/Requests/Artworks/ArtworkTagsUpdateRequest.php @@ -0,0 +1,28 @@ +user()) { + throw new NotFoundHttpException(); + } + + return true; + } + + public function rules(): array + { + return [ + 'tags' => 'required|array|max:15', + 'tags.*' => 'required|string|max:64', + ]; + } +} diff --git a/app/Http/Requests/Dashboard/ArtworkDestroyRequest.php b/app/Http/Requests/Dashboard/ArtworkDestroyRequest.php new file mode 100644 index 00000000..668969ae --- /dev/null +++ b/app/Http/Requests/Dashboard/ArtworkDestroyRequest.php @@ -0,0 +1,68 @@ +user(); + if (! $user) { + $this->logUnauthorized('missing_user'); + $this->denyAsNotFound(); + } + + $id = (int) $this->route('id'); + if ($id <= 0) { + $this->logUnauthorized('missing_artwork_id'); + $this->denyAsNotFound(); + } + + $artwork = Artwork::query()->whereKey($id)->first(); + if (! $artwork || (int) $artwork->user_id !== (int) $user->id) { + $this->logUnauthorized('artwork_not_owned_or_missing'); + $this->denyAsNotFound(); + } + + $this->artwork = $artwork; + + return true; + } + + public function rules(): array + { + return []; + } + + public function artwork(): Artwork + { + if (! $this->artwork) { + $this->denyAsNotFound(); + } + + return $this->artwork; + } + + private function denyAsNotFound(): void + { + throw new NotFoundHttpException(); + } + + private function logUnauthorized(string $reason): void + { + logger()->warning('Dashboard artwork delete unauthorized access', [ + 'reason' => $reason, + 'artwork_id' => $this->route('id'), + 'user_id' => $this->user()?->id, + 'ip' => $this->ip(), + ]); + } +} diff --git a/app/Http/Requests/Dashboard/ArtworkEditRequest.php b/app/Http/Requests/Dashboard/ArtworkEditRequest.php new file mode 100644 index 00000000..d1e2a898 --- /dev/null +++ b/app/Http/Requests/Dashboard/ArtworkEditRequest.php @@ -0,0 +1,68 @@ +user(); + if (! $user) { + $this->logUnauthorized('missing_user'); + $this->denyAsNotFound(); + } + + $id = (int) $this->route('id'); + if ($id <= 0) { + $this->logUnauthorized('missing_artwork_id'); + $this->denyAsNotFound(); + } + + $artwork = Artwork::query()->whereKey($id)->first(); + if (! $artwork || (int) $artwork->user_id !== (int) $user->id) { + $this->logUnauthorized('artwork_not_owned_or_missing'); + $this->denyAsNotFound(); + } + + $this->artwork = $artwork; + + return true; + } + + public function rules(): array + { + return []; + } + + public function artwork(): Artwork + { + if (! $this->artwork) { + $this->denyAsNotFound(); + } + + return $this->artwork; + } + + private function denyAsNotFound(): void + { + throw new NotFoundHttpException(); + } + + private function logUnauthorized(string $reason): void + { + logger()->warning('Dashboard artwork edit unauthorized access', [ + 'reason' => $reason, + 'artwork_id' => $this->route('id'), + 'user_id' => $this->user()?->id, + 'ip' => $this->ip(), + ]); + } +} diff --git a/app/Http/Requests/Dashboard/UpdateArtworkRequest.php b/app/Http/Requests/Dashboard/UpdateArtworkRequest.php index 6ea0abd5..f36c68ec 100644 --- a/app/Http/Requests/Dashboard/UpdateArtworkRequest.php +++ b/app/Http/Requests/Dashboard/UpdateArtworkRequest.php @@ -2,13 +2,36 @@ namespace App\Http\Requests\Dashboard; +use App\Models\Artwork; use Illuminate\Foundation\Http\FormRequest; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class UpdateArtworkRequest extends FormRequest { + private ?Artwork $artwork = null; + public function authorize(): bool { - // Authorization is enforced in the controller via ArtworkPolicy. + $user = $this->user(); + if (! $user) { + $this->logUnauthorized('missing_user'); + $this->denyAsNotFound(); + } + + $id = (int) $this->route('id'); + if ($id <= 0) { + $this->logUnauthorized('missing_artwork_id'); + $this->denyAsNotFound(); + } + + $artwork = Artwork::query()->whereKey($id)->first(); + if (! $artwork || (int) $artwork->user_id !== (int) $user->id) { + $this->logUnauthorized('artwork_not_owned_or_missing'); + $this->denyAsNotFound(); + } + + $this->artwork = $artwork; + return true; } @@ -21,4 +44,28 @@ class UpdateArtworkRequest extends FormRequest 'file' => 'nullable|image|max:102400', ]; } + + public function artwork(): Artwork + { + if (! $this->artwork) { + $this->denyAsNotFound(); + } + + return $this->artwork; + } + + private function denyAsNotFound(): void + { + throw new NotFoundHttpException(); + } + + private function logUnauthorized(string $reason): void + { + logger()->warning('Dashboard artwork update unauthorized access', [ + 'reason' => $reason, + 'artwork_id' => $this->route('id'), + 'user_id' => $this->user()?->id, + 'ip' => $this->ip(), + ]); + } } diff --git a/app/Http/Requests/Manage/ManageArtworkDestroyRequest.php b/app/Http/Requests/Manage/ManageArtworkDestroyRequest.php new file mode 100644 index 00000000..237b52a0 --- /dev/null +++ b/app/Http/Requests/Manage/ManageArtworkDestroyRequest.php @@ -0,0 +1,68 @@ +user(); + if (! $user) { + $this->logUnauthorized('missing_user'); + $this->denyAsNotFound(); + } + + $id = (int) $this->route('id'); + if ($id <= 0) { + $this->logUnauthorized('missing_artwork_id'); + $this->denyAsNotFound(); + } + + $artwork = DB::table('artworks')->where('id', $id)->first(); + if (! $artwork || (int) $artwork->user_id !== (int) $user->id) { + $this->logUnauthorized('artwork_not_owned_or_missing'); + $this->denyAsNotFound(); + } + + $this->artwork = $artwork; + + return true; + } + + public function rules(): array + { + return []; + } + + public function artwork(): object + { + if (! $this->artwork) { + $this->denyAsNotFound(); + } + + return $this->artwork; + } + + private function denyAsNotFound(): void + { + throw new NotFoundHttpException(); + } + + private function logUnauthorized(string $reason): void + { + logger()->warning('Manage artwork delete unauthorized access', [ + 'reason' => $reason, + 'artwork_id' => $this->route('id'), + 'user_id' => $this->user()?->id, + 'ip' => $this->ip(), + ]); + } +} diff --git a/app/Http/Requests/Manage/ManageArtworkEditRequest.php b/app/Http/Requests/Manage/ManageArtworkEditRequest.php new file mode 100644 index 00000000..7df8b5a2 --- /dev/null +++ b/app/Http/Requests/Manage/ManageArtworkEditRequest.php @@ -0,0 +1,68 @@ +user(); + if (! $user) { + $this->logUnauthorized('missing_user'); + $this->denyAsNotFound(); + } + + $id = (int) $this->route('id'); + if ($id <= 0) { + $this->logUnauthorized('missing_artwork_id'); + $this->denyAsNotFound(); + } + + $artwork = DB::table('artworks')->where('id', $id)->first(); + if (! $artwork || (int) $artwork->user_id !== (int) $user->id) { + $this->logUnauthorized('artwork_not_owned_or_missing'); + $this->denyAsNotFound(); + } + + $this->artwork = $artwork; + + return true; + } + + public function rules(): array + { + return []; + } + + public function artwork(): object + { + if (! $this->artwork) { + $this->denyAsNotFound(); + } + + return $this->artwork; + } + + private function denyAsNotFound(): void + { + throw new NotFoundHttpException(); + } + + private function logUnauthorized(string $reason): void + { + logger()->warning('Manage artwork edit unauthorized access', [ + 'reason' => $reason, + 'artwork_id' => $this->route('id'), + 'user_id' => $this->user()?->id, + 'ip' => $this->ip(), + ]); + } +} diff --git a/app/Http/Requests/Manage/ManageArtworkUpdateRequest.php b/app/Http/Requests/Manage/ManageArtworkUpdateRequest.php new file mode 100644 index 00000000..186d8054 --- /dev/null +++ b/app/Http/Requests/Manage/ManageArtworkUpdateRequest.php @@ -0,0 +1,74 @@ +user(); + if (! $user) { + $this->logUnauthorized('missing_user'); + $this->denyAsNotFound(); + } + + $id = (int) $this->route('id'); + if ($id <= 0) { + $this->logUnauthorized('missing_artwork_id'); + $this->denyAsNotFound(); + } + + $artwork = DB::table('artworks')->where('id', $id)->first(); + if (! $artwork || (int) $artwork->user_id !== (int) $user->id) { + $this->logUnauthorized('artwork_not_owned_or_missing'); + $this->denyAsNotFound(); + } + + $this->artwork = $artwork; + + return true; + } + + public function rules(): array + { + return [ + 'name' => 'required|string|max:255', + 'section' => 'nullable|integer', + 'description' => 'nullable|string', + 'artwork' => 'nullable|file|image', + 'attachment' => 'nullable|file', + ]; + } + + public function artwork(): object + { + if (! $this->artwork) { + $this->denyAsNotFound(); + } + + return $this->artwork; + } + + private function denyAsNotFound(): void + { + throw new NotFoundHttpException(); + } + + private function logUnauthorized(string $reason): void + { + logger()->warning('Manage artwork update unauthorized access', [ + 'reason' => $reason, + 'artwork_id' => $this->route('id'), + 'user_id' => $this->user()?->id, + 'ip' => $this->ip(), + ]); + } +} diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/ProfileUpdateRequest.php index 3622a8f3..588d90dd 100644 --- a/app/Http/Requests/ProfileUpdateRequest.php +++ b/app/Http/Requests/ProfileUpdateRequest.php @@ -16,7 +16,7 @@ class ProfileUpdateRequest extends FormRequest public function rules(): array { return [ - 'name' => ['required', 'string', 'max:255'], + 'username' => ['sometimes', 'string', 'max:255'], 'email' => [ 'required', 'string', @@ -25,6 +25,21 @@ class ProfileUpdateRequest extends FormRequest 'max:255', Rule::unique(User::class)->ignore($this->user()->id), ], + 'name' => ['nullable', 'string', 'max:255'], + 'web' => ['nullable', 'url', 'max:255'], + 'day' => ['nullable', 'numeric', 'between:1,31'], + 'month' => ['nullable', 'numeric', 'between:1,12'], + 'year' => ['nullable', 'numeric', 'digits:4'], + 'gender' => ['nullable', 'in:m,f,n,M,F,N,X,x'], + 'country' => ['nullable', 'string', 'max:10'], + 'mailing' => ['nullable', 'boolean'], + 'notify' => ['nullable', 'boolean'], + 'about' => ['nullable', 'string'], + 'signature' => ['nullable', 'string'], + 'description' => ['nullable', 'string'], + 'avatar' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'], + 'emoticon' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'], + 'photo' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'], ]; } } diff --git a/app/Http/Requests/Tags/PopularTagsRequest.php b/app/Http/Requests/Tags/PopularTagsRequest.php new file mode 100644 index 00000000..745c2826 --- /dev/null +++ b/app/Http/Requests/Tags/PopularTagsRequest.php @@ -0,0 +1,27 @@ +user()) { + throw new NotFoundHttpException(); + } + + return true; + } + + public function rules(): array + { + return [ + 'limit' => 'nullable|integer|min:1|max:50', + ]; + } +} diff --git a/app/Http/Requests/Tags/TagSearchRequest.php b/app/Http/Requests/Tags/TagSearchRequest.php new file mode 100644 index 00000000..75960e5c --- /dev/null +++ b/app/Http/Requests/Tags/TagSearchRequest.php @@ -0,0 +1,27 @@ +user()) { + throw new NotFoundHttpException(); + } + + return true; + } + + public function rules(): array + { + return [ + 'q' => 'nullable|string|max:64', + ]; + } +} diff --git a/app/Http/Requests/Uploads/UploadCancelRequest.php b/app/Http/Requests/Uploads/UploadCancelRequest.php new file mode 100644 index 00000000..8027f507 --- /dev/null +++ b/app/Http/Requests/Uploads/UploadCancelRequest.php @@ -0,0 +1,83 @@ +user(); + if (! $user) { + $this->logUnauthorized('missing_user'); + $this->denyAsNotFound(); + } + + $sessionId = (string) $this->input('session_id'); + if ($sessionId === '') { + $this->logUnauthorized('missing_session_id'); + $this->denyAsNotFound(); + } + + $token = $this->header('X-Upload-Token') ?: $this->input('upload_token'); + if (! $token) { + $this->logUnauthorized('missing_token'); + $this->denyAsNotFound(); + } + + $sessions = $this->container->make(UploadSessionRepository::class); + $session = $sessions->get($sessionId); + if (! $session || $session->userId !== $user->id) { + $this->logUnauthorized('not_owned_or_missing'); + $this->denyAsNotFound(); + } + + $tokens = $this->container->make(UploadTokenService::class); + $payload = $tokens->get((string) $token); + if (! $payload) { + $this->logUnauthorized('invalid_token'); + $this->denyAsNotFound(); + } + + if (($payload['session_id'] ?? null) !== $sessionId) { + $this->logUnauthorized('token_session_mismatch'); + $this->denyAsNotFound(); + } + + if ((int) ($payload['user_id'] ?? 0) !== (int) $user->id) { + $this->logUnauthorized('token_user_mismatch'); + $this->denyAsNotFound(); + } + + return true; + } + + public function rules(): array + { + return [ + 'session_id' => 'required|uuid', + 'upload_token' => 'nullable|string|min:40|max:200', + ]; + } + + private function denyAsNotFound(): void + { + throw new NotFoundHttpException(); + } + + private function logUnauthorized(string $reason): void + { + logger()->warning('Upload cancel unauthorized access', [ + 'reason' => $reason, + 'session_id' => (string) $this->input('session_id'), + 'user_id' => $this->user()?->id, + 'ip' => $this->ip(), + ]); + } +} diff --git a/app/Http/Requests/Uploads/UploadChunkRequest.php b/app/Http/Requests/Uploads/UploadChunkRequest.php new file mode 100644 index 00000000..4da808fd --- /dev/null +++ b/app/Http/Requests/Uploads/UploadChunkRequest.php @@ -0,0 +1,91 @@ +user(); + if (! $user) { + $this->logUnauthorized('missing_user'); + $this->denyAsNotFound(); + } + + $sessionId = (string) $this->input('session_id'); + if ($sessionId === '') { + $this->logUnauthorized('missing_session_id'); + $this->denyAsNotFound(); + } + + $token = $this->header('X-Upload-Token') ?: $this->input('upload_token'); + if (! $token) { + $this->logUnauthorized('missing_token'); + $this->denyAsNotFound(); + } + + $sessions = $this->container->make(UploadSessionRepository::class); + $session = $sessions->get($sessionId); + if (! $session || $session->userId !== $user->id) { + $this->logUnauthorized('not_owned_or_missing'); + $this->denyAsNotFound(); + } + + $tokens = $this->container->make(UploadTokenService::class); + $payload = $tokens->get((string) $token); + if (! $payload) { + $this->logUnauthorized('invalid_token'); + $this->denyAsNotFound(); + } + + if (($payload['session_id'] ?? null) !== $sessionId) { + $this->logUnauthorized('token_session_mismatch'); + $this->denyAsNotFound(); + } + + if ((int) ($payload['user_id'] ?? 0) !== (int) $user->id) { + $this->logUnauthorized('token_user_mismatch'); + $this->denyAsNotFound(); + } + + return true; + } + + public function rules(): array + { + $maxBytes = (int) config('uploads.chunk.max_bytes', 0); + $maxKb = $maxBytes > 0 ? (int) ceil($maxBytes / 1024) : 5120; + $chunkSizeRule = $maxBytes > 0 ? 'required|integer|min:1|max:' . $maxBytes : 'required|integer|min:1'; + + return [ + 'session_id' => 'required|uuid', + 'offset' => 'required|integer|min:0', + 'total_size' => 'required|integer|min:1', + 'chunk_size' => $chunkSizeRule, + 'chunk' => 'required|file|max:' . $maxKb, + 'upload_token' => 'nullable|string|min:40|max:200', + ]; + } + + private function denyAsNotFound(): void + { + throw new NotFoundHttpException(); + } + + private function logUnauthorized(string $reason): void + { + logger()->warning('Upload chunk unauthorized access', [ + 'reason' => $reason, + 'session_id' => (string) $this->input('session_id'), + 'user_id' => $this->user()?->id, + 'ip' => $this->ip(), + ]); + } +} diff --git a/app/Http/Requests/Uploads/UploadFinishRequest.php b/app/Http/Requests/Uploads/UploadFinishRequest.php new file mode 100644 index 00000000..b0553fbf --- /dev/null +++ b/app/Http/Requests/Uploads/UploadFinishRequest.php @@ -0,0 +1,108 @@ +user(); + if (! $user) { + $this->logUnauthorized('missing_user'); + $this->denyAsNotFound(); + } + + $sessionId = (string) $this->input('session_id'); + if ($sessionId === '') { + $this->logUnauthorized('missing_session_id'); + $this->denyAsNotFound(); + } + + $sessions = $this->container->make(UploadSessionRepository::class); + $session = $sessions->get($sessionId); + if (! $session || $session->userId !== $user->id) { + $this->logUnauthorized('not_owned_or_missing'); + $this->denyAsNotFound(); + } + + $token = $this->header('X-Upload-Token') ?: $this->input('upload_token'); + if ($token) { + $tokens = $this->container->make(UploadTokenService::class); + $payload = $tokens->get((string) $token); + if (! $payload) { + $this->logUnauthorized('invalid_token'); + $this->denyAsNotFound(); + } + + if (($payload['session_id'] ?? null) !== $sessionId) { + $this->logUnauthorized('token_session_mismatch'); + $this->denyAsNotFound(); + } + + if ((int) ($payload['user_id'] ?? 0) !== (int) $user->id) { + $this->logUnauthorized('token_user_mismatch'); + $this->denyAsNotFound(); + } + } + + $artworkId = (int) $this->input('artwork_id'); + if ($artworkId <= 0) { + $this->logUnauthorized('missing_artwork_id'); + $this->denyAsNotFound(); + } + + $artwork = Artwork::query()->find($artworkId); + if (! $artwork || (int) $artwork->user_id !== (int) $user->id) { + $this->logUnauthorized('artwork_not_owned_or_missing'); + $this->denyAsNotFound(); + } + + $this->artwork = $artwork; + + return true; + } + + public function rules(): array + { + return [ + 'session_id' => 'required|uuid', + 'artwork_id' => 'required|integer', + 'upload_token' => 'nullable|string|min:40|max:200', + ]; + } + + public function artwork(): Artwork + { + if (! $this->artwork) { + $this->denyAsNotFound(); + } + + return $this->artwork; + } + + private function denyAsNotFound(): void + { + throw new NotFoundHttpException(); + } + + private function logUnauthorized(string $reason): void + { + logger()->warning('Upload finish unauthorized access', [ + 'reason' => $reason, + 'session_id' => (string) $this->input('session_id'), + 'artwork_id' => $this->input('artwork_id'), + 'user_id' => $this->user()?->id, + 'ip' => $this->ip(), + ]); + } +} diff --git a/app/Http/Requests/Uploads/UploadInitRequest.php b/app/Http/Requests/Uploads/UploadInitRequest.php new file mode 100644 index 00000000..870ef5ec --- /dev/null +++ b/app/Http/Requests/Uploads/UploadInitRequest.php @@ -0,0 +1,22 @@ +user(); + } + + public function rules(): array + { + return [ + 'client' => 'nullable|string|max:64', + ]; + } +} diff --git a/app/Http/Requests/Uploads/UploadStatusRequest.php b/app/Http/Requests/Uploads/UploadStatusRequest.php new file mode 100644 index 00000000..a7f01009 --- /dev/null +++ b/app/Http/Requests/Uploads/UploadStatusRequest.php @@ -0,0 +1,87 @@ +user(); + if (! $user) { + $this->logUnauthorized('missing_user'); + $this->denyAsNotFound(); + } + + $sessionId = (string) $this->route('id'); + if ($sessionId === '') { + $this->logUnauthorized('missing_session_id'); + $this->denyAsNotFound(); + } + + $sessions = $this->container->make(UploadSessionRepository::class); + $session = $sessions->get($sessionId); + if (! $session || $session->userId !== $user->id) { + $this->logUnauthorized('not_owned_or_missing'); + $this->denyAsNotFound(); + } + + $token = $this->header('X-Upload-Token') ?: $this->input('upload_token'); + if ($token) { + $tokens = $this->container->make(UploadTokenService::class); + $payload = $tokens->get((string) $token); + if (! $payload) { + $this->logUnauthorized('invalid_token'); + $this->denyAsNotFound(); + } + + if (($payload['session_id'] ?? null) !== $sessionId) { + $this->logUnauthorized('token_session_mismatch'); + $this->denyAsNotFound(); + } + + if ((int) ($payload['user_id'] ?? 0) !== (int) $user->id) { + $this->logUnauthorized('token_user_mismatch'); + $this->denyAsNotFound(); + } + } + + return true; + } + + private function denyAsNotFound(): void + { + throw new NotFoundHttpException(); + } + + private function logUnauthorized(string $reason): void + { + logger()->warning('Upload status unauthorized access', [ + 'reason' => $reason, + 'session_id' => (string) $this->route('id'), + 'user_id' => $this->user()?->id, + 'ip' => $this->ip(), + ]); + } + + public function rules(): array + { + return [ + 'id' => 'required|uuid', + 'upload_token' => 'nullable|string|min:40|max:200', + ]; + } + + protected function prepareForValidation(): void + { + $this->merge([ + 'id' => $this->route('id'), + ]); + } +} diff --git a/app/Jobs/AutoTagArtworkJob.php b/app/Jobs/AutoTagArtworkJob.php new file mode 100644 index 00000000..1b236a1d --- /dev/null +++ b/app/Jobs/AutoTagArtworkJob.php @@ -0,0 +1,401 @@ +onQueue($queue); + } + } + + public function backoff(): array + { + return [2, 10, 30]; + } + + public function handle(TagService $tagService, TagNormalizer $normalizer): void + { + if (! (bool) config('vision.enabled', true)) { + return; + } + + $artwork = Artwork::query()->with(['categories.contentType'])->find($this->artworkId); + if (! $artwork) { + return; + } + + $imageUrl = $this->buildImageUrl($this->hash); + if ($imageUrl === null) { + return; + } + + $processingKey = $this->processingKey($this->artworkId, $this->hash); + if (! $this->acquireProcessingLock($processingKey)) { + return; + } + + $ref = (string) Str::uuid(); + + try { + $clipTags = $this->callClip($imageUrl, $ref); + + $yoloTags = []; + if ($this->shouldRunYolo($artwork)) { + $yoloTags = $this->callYolo($imageUrl, $ref); + } + + $merged = $this->mergeTags($clipTags, $yoloTags); + if ($merged === []) { + $this->markProcessed($this->processedKey($this->artworkId, $this->hash)); + return; + } + + // Normalize explicitly (requirement), then attach via TagService (source=ai + confidence). + $payload = []; + foreach ($merged as $row) { + $tag = $normalizer->normalize((string) ($row['tag'] ?? '')); + if ($tag === '') { + continue; + } + $payload[] = [ + 'tag' => $tag, + 'confidence' => isset($row['confidence']) && is_numeric($row['confidence']) ? (float) $row['confidence'] : null, + ]; + } + + $tagService->attachAiTags($artwork, $payload); + + $this->markProcessed($this->processedKey($this->artworkId, $this->hash)); + } catch (\Throwable $e) { + Log::error('AutoTagArtworkJob failed', [ + 'ref' => $ref, + 'artwork_id' => $this->artworkId, + 'hash' => $this->hash, + 'attempt' => $this->attempts(), + 'error' => $e->getMessage(), + ]); + + // Retry-safe: allow queue retry on transient failures. + throw $e; + } finally { + $this->releaseProcessingLock($processingKey); + } + } + + private function buildImageUrl(string $hash): ?string + { + $base = (string) config('cdn.files_url'); + $base = rtrim($base, '/'); + if ($base === '') { + return null; + } + + $variant = (string) config('vision.image_variant', 'md'); + $variant = $variant !== '' ? $variant : 'md'; + + // Matches the upload public path layout used for derivatives (img/aa/bb/cc/variant.webp). + $clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash)); + $clean = str_pad($clean, 6, '0'); + $segments = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00']; + $path = 'img/' . implode('/', $segments) . '/' . $variant . '.webp'; + + return $base . '/' . $path; + } + + /** + * @return array + */ + private function callClip(string $imageUrl, string $ref): array + { + $base = trim((string) config('vision.clip.base_url', '')); + if ($base === '') { + return []; + } + + $endpoint = (string) config('vision.clip.endpoint', '/analyze'); + $url = rtrim($base, '/') . '/' . ltrim($endpoint, '/'); + + $timeout = (int) config('vision.clip.timeout_seconds', 8); + $connectTimeout = (int) config('vision.clip.connect_timeout_seconds', 2); + $retries = (int) config('vision.clip.retries', 1); + $delay = (int) config('vision.clip.retry_delay_ms', 200); + + try { + $response = Http::acceptJson() + ->connectTimeout(max(1, $connectTimeout)) + ->timeout(max(1, $timeout)) + ->retry(max(0, $retries), max(0, $delay), throw: false) + ->post($url, [ + 'image_url' => $imageUrl, + 'artwork_id' => $this->artworkId, + 'hash' => $this->hash, + ]); + } catch (\Throwable $e) { + Log::warning('CLIP analyze request failed', ['ref' => $ref, 'artwork_id' => $this->artworkId, 'error' => $e->getMessage()]); + throw $e; + } + + if ($response->serverError()) { + Log::warning('CLIP analyze server error', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]); + throw new \RuntimeException('CLIP server error: ' . $response->status()); + } + + if (! $response->ok()) { + Log::warning('CLIP analyze non-ok response', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]); + return []; + } + + return $this->extractTagList($response->json()); + } + + /** + * @return array + */ + private function callYolo(string $imageUrl, string $ref): array + { + if (! (bool) config('vision.yolo.enabled', true)) { + return []; + } + + $base = trim((string) config('vision.yolo.base_url', '')); + if ($base === '') { + return []; + } + + $endpoint = (string) config('vision.yolo.endpoint', '/analyze'); + $url = rtrim($base, '/') . '/' . ltrim($endpoint, '/'); + + $timeout = (int) config('vision.yolo.timeout_seconds', 8); + $connectTimeout = (int) config('vision.yolo.connect_timeout_seconds', 2); + $retries = (int) config('vision.yolo.retries', 1); + $delay = (int) config('vision.yolo.retry_delay_ms', 200); + + try { + $response = Http::acceptJson() + ->connectTimeout(max(1, $connectTimeout)) + ->timeout(max(1, $timeout)) + ->retry(max(0, $retries), max(0, $delay), throw: false) + ->post($url, [ + 'image_url' => $imageUrl, + 'artwork_id' => $this->artworkId, + 'hash' => $this->hash, + ]); + } catch (\Throwable $e) { + Log::warning('YOLO analyze request failed', ['ref' => $ref, 'artwork_id' => $this->artworkId, 'error' => $e->getMessage()]); + throw $e; + } + + if ($response->serverError()) { + Log::warning('YOLO analyze server error', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]); + throw new \RuntimeException('YOLO server error: ' . $response->status()); + } + + if (! $response->ok()) { + Log::warning('YOLO analyze non-ok response', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]); + return []; + } + + return $this->extractTagList($response->json()); + } + + private function shouldRunYolo(Artwork $artwork): bool + { + if (! (bool) config('vision.yolo.enabled', true)) { + return false; + } + + if (! (bool) config('vision.yolo.photography_only', true)) { + return true; + } + + foreach ($artwork->categories as $category) { + $slug = strtolower((string) ($category->contentType?->slug ?? '')); + if ($slug === 'photography') { + return true; + } + } + + return false; + } + + /** + * @param mixed $json + * @return array + */ + private function extractTagList(mixed $json): array + { + if (is_array($json) && $this->isListOfTags($json)) { + return $json; + } + + if (is_array($json) && isset($json['tags']) && is_array($json['tags']) && $this->isListOfTags($json['tags'])) { + return $json['tags']; + } + + if (is_array($json) && isset($json['data']) && is_array($json['data']) && $this->isListOfTags($json['data'])) { + return $json['data']; + } + + // Common YOLO-style response: objects: [{label, confidence}] + if (is_array($json) && isset($json['objects']) && is_array($json['objects'])) { + $out = []; + foreach ($json['objects'] as $obj) { + if (! is_array($obj)) { + continue; + } + $label = (string) ($obj['label'] ?? $obj['tag'] ?? ''); + if ($label === '') { + continue; + } + $out[] = ['tag' => $label, 'confidence' => $obj['confidence'] ?? null]; + } + return $out; + } + + return []; + } + + /** + * @param array $arr + */ + private function isListOfTags(array $arr): bool + { + if ($arr === []) { + return true; + } + + foreach ($arr as $row) { + if (! is_array($row)) { + return false; + } + if (! array_key_exists('tag', $row)) { + return false; + } + } + + return true; + } + + /** + * @param array $a + * @param array $b + * @return array + */ + private function mergeTags(array $a, array $b): array + { + $byTag = []; + foreach (array_merge($a, $b) as $row) { + $tag = (string) ($row['tag'] ?? ''); + if ($tag === '') { + continue; + } + $conf = $row['confidence'] ?? null; + $conf = is_numeric($conf) ? (float) $conf : null; + + // Keep highest confidence for duplicates. + if (! isset($byTag[$tag])) { + $byTag[$tag] = ['tag' => $tag, 'confidence' => $conf]; + continue; + } + + $existing = $byTag[$tag]['confidence']; + if ($existing === null || ($conf !== null && $conf > (float) $existing)) { + $byTag[$tag]['confidence'] = $conf; + } + } + + return array_values($byTag); + } + + private function processingKey(int $artworkId, string $hash): string + { + return 'autotag:processing:' . $artworkId . ':' . $hash; + } + + private function processedKey(int $artworkId, string $hash): string + { + return 'autotag:processed:' . $artworkId . ':' . $hash; + } + + private function acquireProcessingLock(string $key): bool + { + try { + $didSet = Redis::setnx($key, 1); + if ($didSet) { + Redis::expire($key, 1800); + } + return (bool) $didSet; + } catch (\Throwable $e) { + // If Redis is unavailable, proceed without dedupe. + return true; + } + } + + private function releaseProcessingLock(string $key): void + { + try { + Redis::del($key); + } catch (\Throwable $e) { + // ignore + } + } + + private function markProcessed(string $key): void + { + try { + Redis::setex($key, 604800, 1); // 7 days + } catch (\Throwable $e) { + // ignore + } + } + + private function safeBody(string $body): string + { + $body = trim($body); + if ($body === '') { + return ''; + } + + return Str::limit($body, 800); + } +} diff --git a/app/Jobs/BackfillArtworkEmbeddingsJob.php b/app/Jobs/BackfillArtworkEmbeddingsJob.php new file mode 100644 index 00000000..503d7367 --- /dev/null +++ b/app/Jobs/BackfillArtworkEmbeddingsJob.php @@ -0,0 +1,61 @@ +onQueue($queue); + } + } + + public function handle(): void + { + $batch = max(1, min($this->batchSize, 1000)); + + $artworks = Artwork::query() + ->where('id', '>', $this->afterId) + ->whereNotNull('hash') + ->orderBy('id') + ->limit($batch) + ->get(['id', 'hash']); + + if ($artworks->isEmpty()) { + return; + } + + foreach ($artworks as $artwork) { + GenerateArtworkEmbeddingJob::dispatch((int) $artwork->id, (string) $artwork->hash, $this->force); + } + + if ($artworks->count() === $batch) { + $lastId = (int) $artworks->last()->id; + self::dispatch($lastId, $batch, $this->force); + } + } +} diff --git a/app/Jobs/GenerateArtworkEmbeddingJob.php b/app/Jobs/GenerateArtworkEmbeddingJob.php new file mode 100644 index 00000000..a787dd4e --- /dev/null +++ b/app/Jobs/GenerateArtworkEmbeddingJob.php @@ -0,0 +1,178 @@ +onQueue($queue); + } + } + + public function backoff(): array + { + return [2, 10, 30]; + } + + public function handle(ArtworkEmbeddingClient $client): void + { + if (! (bool) config('recommendations.embedding.enabled', true)) { + return; + } + + $artwork = Artwork::query()->find($this->artworkId); + if (! $artwork) { + return; + } + + $sourceHash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($this->sourceHash ?? $artwork->hash ?? ''))); + if ($sourceHash === '') { + return; + } + + $model = (string) config('recommendations.embedding.model', 'clip'); + $modelVersion = (string) config('recommendations.embedding.model_version', 'v1'); + $algoVersion = (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1'); + + if (! $this->force) { + $existing = ArtworkEmbedding::query() + ->where('artwork_id', $artwork->id) + ->where('model', $model) + ->where('model_version', $modelVersion) + ->first(); + + if ($existing && (string) ($existing->source_hash ?? '') === $sourceHash) { + return; + } + } + + $lockKey = $this->lockKey($artwork->id, $model, $modelVersion); + if (! $this->acquireLock($lockKey)) { + return; + } + + try { + $imageUrl = $this->buildImageUrl($sourceHash); + if ($imageUrl === null) { + return; + } + + $vector = $client->embed($imageUrl, (int) $artwork->id, $sourceHash); + if ($vector === []) { + return; + } + + $normalized = $this->normalize($vector); + + ArtworkEmbedding::query()->updateOrCreate( + [ + 'artwork_id' => (int) $artwork->id, + 'model' => $model, + 'model_version' => $modelVersion, + ], + [ + 'algo_version' => $algoVersion, + 'dim' => count($normalized), + 'embedding_json' => json_encode($normalized, JSON_THROW_ON_ERROR), + 'source_hash' => $sourceHash, + 'is_normalized' => true, + 'generated_at' => now(), + 'meta' => [ + 'source' => 'clip', + 'image_variant' => (string) config('vision.image_variant', 'md'), + ], + ] + ); + } finally { + $this->releaseLock($lockKey); + } + } + + /** + * @param array $vector + * @return array + */ + private function normalize(array $vector): array + { + $sumSquares = 0.0; + foreach ($vector as $value) { + $sumSquares += ($value * $value); + } + + if ($sumSquares <= 0.0) { + return $vector; + } + + $norm = sqrt($sumSquares); + return array_map(static fn (float $value): float => $value / $norm, $vector); + } + + private function buildImageUrl(string $hash): ?string + { + $base = rtrim((string) config('cdn.files_url', ''), '/'); + if ($base === '') { + return null; + } + + $variant = (string) config('vision.image_variant', 'md'); + $clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash)); + $clean = str_pad($clean, 6, '0'); + $segments = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00']; + + return $base . '/img/' . implode('/', $segments) . '/' . $variant . '.webp'; + } + + private function lockKey(int $artworkId, string $model, string $version): string + { + return 'artwork-embedding:lock:' . $artworkId . ':' . $model . ':' . $version; + } + + private function acquireLock(string $key): bool + { + try { + $didSet = Redis::setnx($key, 1); + if ($didSet) { + Redis::expire($key, 1800); + } + return (bool) $didSet; + } catch (\Throwable) { + return true; + } + } + + private function releaseLock(string $key): void + { + try { + Redis::del($key); + } catch (\Throwable) { + // ignore + } + } +} diff --git a/app/Jobs/GenerateDerivativesJob.php b/app/Jobs/GenerateDerivativesJob.php new file mode 100644 index 00000000..3732b6bf --- /dev/null +++ b/app/Jobs/GenerateDerivativesJob.php @@ -0,0 +1,38 @@ +processAndPublish($this->sessionId, $this->hash, $this->artworkId); + + // Auto-tagging is async and must never block publish. + AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit(); + GenerateArtworkEmbeddingJob::dispatch($this->artworkId, $this->hash)->afterCommit(); + } +} diff --git a/app/Jobs/IngestUserDiscoveryEventJob.php b/app/Jobs/IngestUserDiscoveryEventJob.php new file mode 100644 index 00000000..c1711254 --- /dev/null +++ b/app/Jobs/IngestUserDiscoveryEventJob.php @@ -0,0 +1,122 @@ + */ + public array $backoff = [5, 30, 120]; + + /** + * @param array $meta + */ + public function __construct( + public readonly string $eventId, + public readonly int $userId, + public readonly int $artworkId, + public readonly string $eventType, + public readonly string $algoVersion, + public readonly string $occurredAt, + public readonly array $meta = [] + ) { + } + + public function handle(UserInterestProfileService $profileService): void + { + $idempotencyKey = sprintf('discovery:event:processed:%s', $this->eventId); + + try { + $didSet = false; + try { + $didSet = (bool) Redis::setnx($idempotencyKey, 1); + if ($didSet) { + Redis::expire($idempotencyKey, 86400 * 2); + } + } catch (\Throwable $e) { + Log::warning('Redis unavailable for discovery ingestion; proceeding without redis dedupe', [ + 'event_id' => $this->eventId, + 'error' => $e->getMessage(), + ]); + $didSet = true; + } + + if (! $didSet) { + return; + } + + $occurredAt = CarbonImmutable::parse($this->occurredAt); + $eventVersion = (string) config('discovery.event_version', 'event-v1'); + $eventWeight = (float) ((array) config('discovery.weights', []))[$this->eventType] ?? 1.0; + + $categoryId = DB::table('artwork_category') + ->where('artwork_id', $this->artworkId) + ->orderBy('category_id') + ->value('category_id'); + + $insertPayload = [ + 'event_id' => $this->eventId, + 'user_id' => $this->userId, + 'artwork_id' => $this->artworkId, + 'category_id' => $categoryId !== null ? (int) $categoryId : null, + 'event_type' => $this->eventType, + 'event_version' => $eventVersion, + 'algo_version' => $this->algoVersion, + 'weight' => $eventWeight, + 'event_date' => $occurredAt->toDateString(), + 'occurred_at' => $occurredAt->toDateTimeString(), + 'created_at' => now(), + 'updated_at' => now(), + ]; + + if (Schema::hasColumn('user_discovery_events', 'meta')) { + $insertPayload['meta'] = $this->meta; + } elseif (Schema::hasColumn('user_discovery_events', 'metadata')) { + $insertPayload['metadata'] = json_encode($this->meta, JSON_UNESCAPED_SLASHES); + } + + DB::table('user_discovery_events')->insertOrIgnore($insertPayload); + + $profileService->applyEvent( + userId: $this->userId, + eventType: $this->eventType, + artworkId: $this->artworkId, + categoryId: $categoryId !== null ? (int) $categoryId : null, + occurredAt: $occurredAt, + eventId: $this->eventId, + algoVersion: $this->algoVersion, + eventMeta: $this->meta + ); + } catch (\Throwable $e) { + Log::error('IngestUserDiscoveryEventJob failed', [ + 'event_id' => $this->eventId, + 'user_id' => $this->userId, + 'artwork_id' => $this->artworkId, + 'event_type' => $this->eventType, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } +} diff --git a/app/Jobs/RegenerateUserRecommendationCacheJob.php b/app/Jobs/RegenerateUserRecommendationCacheJob.php new file mode 100644 index 00000000..b2624862 --- /dev/null +++ b/app/Jobs/RegenerateUserRecommendationCacheJob.php @@ -0,0 +1,47 @@ + */ + public array $backoff = [10, 60, 180]; + + public function __construct( + public readonly int $userId, + public readonly string $algoVersion + ) { + } + + public function handle(PersonalizedFeedService $feedService): void + { + try { + $feedService->regenerateCacheForUser($this->userId, $this->algoVersion); + } catch (\Throwable $e) { + Log::error('RegenerateUserRecommendationCacheJob failed', [ + 'user_id' => $this->userId, + 'algo_version' => $this->algoVersion, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } +} diff --git a/app/Models/Artwork.php b/app/Models/Artwork.php index e3fff1ee..a7cf81ee 100644 --- a/app/Models/Artwork.php +++ b/app/Models/Artwork.php @@ -9,6 +9,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use App\Services\ThumbnailService; +use Illuminate\Support\Facades\DB; /** * App\Models\Artwork @@ -74,13 +76,8 @@ class Artwork extends Model return null; } - $size = array_key_exists($size, self::THUMB_SIZES) ? $size : 'md'; - $h = $this->hash; - $h1 = substr($h, 0, 2); - $h2 = substr($h, 2, 2); - $ext = $this->thumb_ext; - - return "https://files.skinbase.org/{$size}/{$h1}/{$h2}/{$h}.{$ext}"; + $sizeKey = array_key_exists($size, self::THUMB_SIZES) ? $size : 'md'; + return ThumbnailService::fromHash($this->hash, $this->thumb_ext, $sizeKey); } /** @@ -99,6 +96,19 @@ class Artwork extends Model return $this->thumbUrl('md'); } + /** + * Backwards-compatible alias used by legacy views: `$art->thumbnail_url`. + * Prefer CDN thumbnail URL, then legacy `thumb` accessor, finally a placeholder. + */ + public function getThumbnailUrlAttribute(): ?string + { + $url = $this->getThumbUrlAttribute(); + if (!empty($url)) return $url; + $thumb = $this->getThumbAttribute(); + if (!empty($thumb)) return $thumb; + return '/images/placeholder.jpg'; + } + /** * Provide a responsive `srcset` for legacy views. */ @@ -132,6 +142,12 @@ class Artwork extends Model return $this->belongsToMany(Category::class, 'artwork_category', 'artwork_id', 'category_id'); } + public function tags(): BelongsToMany + { + return $this->belongsToMany(Tag::class, 'artwork_tag', 'artwork_id', 'tag_id') + ->withPivot(['source', 'confidence']); + } + public function comments(): HasMany { return $this->hasMany(ArtworkComment::class); @@ -142,6 +158,16 @@ class Artwork extends Model return $this->hasMany(ArtworkDownload::class); } + public function embeddings(): HasMany + { + return $this->hasMany(ArtworkEmbedding::class, 'artwork_id'); + } + + public function similarities(): HasMany + { + return $this->hasMany(ArtworkSimilarity::class, 'artwork_id'); + } + public function features(): HasMany { return $this->hasMany(ArtworkFeature::class, 'artwork_id'); @@ -175,4 +201,24 @@ class Artwork extends Model { return 'slug'; } + + protected static function booted(): void + { + static::deleting(function (Artwork $artwork): void { + if (! method_exists($artwork, 'isForceDeleting') || ! $artwork->isForceDeleting()) { + return; + } + + // Cleanup pivot rows and decrement usage counts on force delete. + $tagIds = DB::table('artwork_tag')->where('artwork_id', $artwork->id)->pluck('tag_id')->all(); + if ($tagIds === []) { + return; + } + + DB::table('artwork_tag')->where('artwork_id', $artwork->id)->delete(); + DB::table('tags') + ->whereIn('id', $tagIds) + ->update(['usage_count' => DB::raw('CASE WHEN usage_count > 0 THEN usage_count - 1 ELSE 0 END')]); + }); + } } diff --git a/app/Models/ArtworkEmbedding.php b/app/Models/ArtworkEmbedding.php new file mode 100644 index 00000000..03619c51 --- /dev/null +++ b/app/Models/ArtworkEmbedding.php @@ -0,0 +1,37 @@ + 'boolean', + 'generated_at' => 'datetime', + 'meta' => 'array', + ]; + + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class, 'artwork_id'); + } +} diff --git a/app/Models/ArtworkSimilarity.php b/app/Models/ArtworkSimilarity.php new file mode 100644 index 00000000..d62663e0 --- /dev/null +++ b/app/Models/ArtworkSimilarity.php @@ -0,0 +1,42 @@ + 'integer', + 'score' => 'float', + 'generated_at' => 'datetime', + 'meta' => 'array', + ]; + + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class, 'artwork_id'); + } + + public function similarArtwork(): BelongsTo + { + return $this->belongsTo(Artwork::class, 'similar_artwork_id'); + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php index aedc940d..342d9141 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -113,4 +113,50 @@ class Category extends Model { return 'slug'; } + + /** + * Resolve a category by a content-type slug and a category path (e.g. "audio/winamp"). + * This will locate the category with the final slug and verify its parent chain + * matches the provided path and that the category belongs to the given content type. + * + * @param string $contentTypeSlug + * @param string|array $categoryPath + * @return Category|null + */ + public static function findByPath(string $contentTypeSlug, $categoryPath): ?Category + { + $parts = is_array($categoryPath) + ? array_values(array_map('strtolower', array_filter($categoryPath))) + : array_values(array_map('strtolower', array_filter(explode('/', (string) $categoryPath)))); + + if (empty($parts)) { + return null; + } + + $last = end($parts); + + $category = static::where('slug', $last) + ->whereHas('contentType', function ($q) use ($contentTypeSlug) { + $q->where('slug', strtolower($contentTypeSlug)); + }) + ->first(); + + if (! $category) { + return null; + } + + // Verify parent chain matches the preceding parts in the path + $idx = count($parts) - 2; + $current = $category; + while ($idx >= 0) { + $parent = $current->parent; + if (! $parent || $parent->slug !== $parts[$idx]) { + return null; + } + $current = $parent; + $idx--; + } + + return $category; + } } diff --git a/app/Models/ContentType.php b/app/Models/ContentType.php index e7e0cf2a..f1fe5ee6 100644 --- a/app/Models/ContentType.php +++ b/app/Models/ContentType.php @@ -4,6 +4,9 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Builder as EloquentBuilder; + +use App\Models\Artwork; class ContentType extends Model { @@ -19,6 +22,18 @@ class ContentType extends Model return $this->categories()->whereNull('parent_id'); } + /** + * Return an Eloquent builder for Artworks that belong to this content type. + * This traverses the pivot `artwork_category` via the `categories` relation. + * Note: not a direct Eloquent relation (uses whereHas) so it can be queried/eager-loaded manually. + */ + public function artworks(): EloquentBuilder + { + return Artwork::whereHas('categories', function ($q) { + $q->where('content_type_id', $this->id); + }); + } + public function getRouteKeyName(): string { return 'slug'; diff --git a/app/Models/Tag.php b/app/Models/Tag.php new file mode 100644 index 00000000..795cfae8 --- /dev/null +++ b/app/Models/Tag.php @@ -0,0 +1,39 @@ + 'integer', + 'is_active' => 'boolean', + ]; + + public function artworks(): BelongsToMany + { + return $this->belongsToMany(Artwork::class, 'artwork_tag', 'tag_id', 'artwork_id') + ->withPivot(['source', 'confidence']); + } + + public function getRouteKeyName(): string + { + return 'slug'; + } +} diff --git a/app/Models/Upload.php b/app/Models/Upload.php new file mode 100644 index 00000000..96fcf494 --- /dev/null +++ b/app/Models/Upload.php @@ -0,0 +1,51 @@ + 'array', + 'nsfw' => 'boolean', + 'is_scanned' => 'boolean', + 'has_tags' => 'boolean', + 'published_at' => 'datetime', + 'expires_at' => 'datetime', + 'moderated_at' => 'datetime', + ]; +} diff --git a/app/Models/User.php b/app/Models/User.php index 6dc83b2c..fc76f6cf 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -23,6 +23,7 @@ class User extends Authenticatable 'name', 'email', 'password', + 'role', ]; /** @@ -53,4 +54,19 @@ class User extends Authenticatable { return $this->hasMany(Artwork::class); } + + public function hasRole(string $role): bool + { + return strtolower((string) ($this->role ?? '')) === strtolower($role); + } + + public function isAdmin(): bool + { + return $this->hasRole('admin'); + } + + public function isModerator(): bool + { + return $this->hasRole('moderator'); + } } diff --git a/app/Models/UserDiscoveryEvent.php b/app/Models/UserDiscoveryEvent.php new file mode 100644 index 00000000..3b1a3a09 --- /dev/null +++ b/app/Models/UserDiscoveryEvent.php @@ -0,0 +1,39 @@ + 'datetime', + 'meta' => 'array', + 'weight' => 'float', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } +} diff --git a/app/Models/UserInterestProfile.php b/app/Models/UserInterestProfile.php new file mode 100644 index 00000000..62c718e8 --- /dev/null +++ b/app/Models/UserInterestProfile.php @@ -0,0 +1,31 @@ + 'array', + 'normalized_scores_json' => 'array', + 'last_event_at' => 'datetime', + 'half_life_hours' => 'float', + 'total_weight' => 'float', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/UserProfile.php b/app/Models/UserProfile.php new file mode 100644 index 00000000..46c06ce4 --- /dev/null +++ b/app/Models/UserProfile.php @@ -0,0 +1,69 @@ + 'date', + 'avatar_updated_at' => 'datetime', + ]; + + public $timestamps = true; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * Return a public URL for the avatar when stored on the `public` disk under `avatars/`. + */ + public function getAvatarUrlAttribute(): ?string + { + if (empty($this->avatar)) { + return null; + } + + // If the stored value already looks like a full URL, return it. + if (preg_match('#^https?://#i', $this->avatar)) { + return $this->avatar; + } + + // Prefer `public` disk and avatars folder. + $path = 'avatars/' . ltrim($this->avatar, '/'); + if (Storage::disk('public')->exists($path)) { + return Storage::disk('public')->url($path); + } + + // Fallback: return null if not found + return null; + } +} diff --git a/app/Models/UserRecommendationCache.php b/app/Models/UserRecommendationCache.php new file mode 100644 index 00000000..12ba4d18 --- /dev/null +++ b/app/Models/UserRecommendationCache.php @@ -0,0 +1,29 @@ + 'array', + 'generated_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Policies/ArtworkPolicy.php b/app/Policies/ArtworkPolicy.php index eae9ee97..bcb1506e 100644 --- a/app/Policies/ArtworkPolicy.php +++ b/app/Policies/ArtworkPolicy.php @@ -40,6 +40,25 @@ class ArtworkPolicy return false; } + protected function isModerator(User $user): bool + { + foreach (['is_moderator', 'is_mod', 'moderator'] as $prop) { + if (isset($user->{$prop})) { + return (bool) $user->{$prop}; + } + } + + if (method_exists($user, 'hasRole')) { + return (bool) ($user->hasRole('moderator') || $user->hasRole('mod')); + } + + if (method_exists($user, 'isModerator')) { + return (bool) $user->isModerator(); + } + + return false; + } + /** * Public view: only approved + public + not-deleted artworks. */ @@ -64,6 +83,14 @@ class ArtworkPolicy return $user->id === $artwork->user_id; } + /** + * Tag edits: owner or moderator or admin (admin handled by before()). + */ + public function updateTags(User $user, Artwork $artwork): bool + { + return $user->id === $artwork->user_id || $this->isModerator($user); + } + /** * Owner can delete their own artwork (soft delete). */ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 65898c71..1155fb19 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,12 @@ namespace App\Providers; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; +use App\Services\Upload\Contracts\UploadDraftServiceInterface; +use App\Services\Upload\UploadDraftService; use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Auth; @@ -14,7 +19,10 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + // Bind UploadDraftService interface to implementation + $this->app->singleton(UploadDraftServiceInterface::class, function ($app) { + return new UploadDraftService($app->make('filesystem')); + }); } /** @@ -22,11 +30,14 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + $this->configureUploadRateLimiters(); + // Provide toolbar counts and user info to layout views (port of legacy toolbar logic) View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) { $uploadCount = $favCount = $msgCount = $noticeCount = 0; $avatar = null; $displayName = null; + $userId = null; if (Auth::check()) { $userId = Auth::id(); @@ -72,4 +83,40 @@ class AppServiceProvider extends ServiceProvider $view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'avatar', 'displayName')); }); } + + private function configureUploadRateLimiters(): void + { + RateLimiter::for('uploads-init', function (Request $request): array { + return $this->buildUploadLimits($request, 'init'); + }); + + RateLimiter::for('uploads-finish', function (Request $request): array { + return $this->buildUploadLimits($request, 'finish'); + }); + + RateLimiter::for('uploads-status', function (Request $request): array { + return $this->buildUploadLimits($request, 'status'); + }); + } + + private function buildUploadLimits(Request $request, string $key): array + { + $config = (array) config('uploads.rate_limits.' . $key, []); + $decay = (int) config('uploads.rate_limits.decay_minutes', 1); + $perUser = (int) ($config['per_user'] ?? 0); + $perIp = (int) ($config['per_ip'] ?? 0); + + $limits = []; + + if ($perUser > 0) { + $userId = $request->user()?->id ?? 'guest'; + $limits[] = Limit::perMinutes($decay, $perUser)->by('u:' . $userId); + } + + if ($perIp > 0) { + $limits[] = Limit::perMinutes($decay, $perIp)->by('ip:' . $request->ip()); + } + + return $limits; + } } diff --git a/app/Repositories/Uploads/ArtworkFileRepository.php b/app/Repositories/Uploads/ArtworkFileRepository.php new file mode 100644 index 00000000..965f609d --- /dev/null +++ b/app/Repositories/Uploads/ArtworkFileRepository.php @@ -0,0 +1,18 @@ +updateOrInsert( + ['artwork_id' => $artworkId, 'variant' => $variant], + ['path' => $path, 'mime' => $mime, 'size' => $size] + ); + } +} diff --git a/app/Repositories/Uploads/AuditLogRepository.php b/app/Repositories/Uploads/AuditLogRepository.php new file mode 100644 index 00000000..c91a12ad --- /dev/null +++ b/app/Repositories/Uploads/AuditLogRepository.php @@ -0,0 +1,21 @@ +insert([ + 'user_id' => $userId, + 'action' => $action, + 'ip' => $ip, + 'meta' => empty($meta) ? null : json_encode($meta, JSON_THROW_ON_ERROR), + 'created_at' => now()->toDateTimeString(), + ]); + } +} diff --git a/app/Repositories/Uploads/UploadSessionRepository.php b/app/Repositories/Uploads/UploadSessionRepository.php new file mode 100644 index 00000000..dcd90daa --- /dev/null +++ b/app/Repositories/Uploads/UploadSessionRepository.php @@ -0,0 +1,113 @@ +insert([ + 'id' => $id, + 'user_id' => $userId, + 'temp_path' => $tempPath, + 'status' => $status, + 'ip' => $ip, + 'created_at' => $createdAt->toDateTimeString(), + 'progress' => 0, + 'failure_reason' => null, + ]); + + return new UploadSessionData($id, $userId, $tempPath, $status, $ip, $createdAt, 0, null); + } + + public function get(string $id): ?UploadSessionData + { + $row = DB::table('uploads_sessions')->where('id', $id)->first(); + + if (! $row) { + return null; + } + + return new UploadSessionData( + (string) $row->id, + (int) $row->user_id, + (string) $row->temp_path, + (string) $row->status, + (string) $row->ip, + CarbonImmutable::parse($row->created_at), + (int) ($row->progress ?? 0), + $row->failure_reason ? (string) $row->failure_reason : null + ); + } + + public function getOrFail(string $id): UploadSessionData + { + $session = $this->get($id); + + if (! $session) { + throw new RuntimeException('Upload session not found.'); + } + + return $session; + } + + public function updateStatus(string $id, string $status): void + { + DB::table('uploads_sessions')->where('id', $id)->update([ + 'status' => $status, + ]); + } + + public function updateProgress(string $id, int $progress): void + { + DB::table('uploads_sessions')->where('id', $id)->update([ + 'progress' => max(0, min(100, $progress)), + ]); + } + + public function updateFailureReason(string $id, ?string $reason): void + { + DB::table('uploads_sessions')->where('id', $id)->update([ + 'failure_reason' => $reason, + ]); + } + + public function updateTempPath(string $id, string $tempPath): void + { + DB::table('uploads_sessions')->where('id', $id)->update([ + 'temp_path' => $tempPath, + ]); + } + + public function countActiveForUser(int $userId): int + { + $terminal = [ + UploadSessionStatus::PROCESSED, + UploadSessionStatus::QUARANTINED, + UploadSessionStatus::CANCELLED, + ]; + + return (int) DB::table('uploads_sessions') + ->where('user_id', $userId) + ->whereNotIn('status', $terminal) + ->count(); + } + + public function countForUserSince(int $userId, CarbonImmutable $since): int + { + return (int) DB::table('uploads_sessions') + ->where('user_id', $userId) + ->where('created_at', '>=', $since->toDateTimeString()) + ->count(); + } +} diff --git a/app/Services/Artworks/ArtworkDraftService.php b/app/Services/Artworks/ArtworkDraftService.php new file mode 100644 index 00000000..d4c3e97e --- /dev/null +++ b/app/Services/Artworks/ArtworkDraftService.php @@ -0,0 +1,55 @@ +uniqueSlug($title); + + $artwork = Artwork::create([ + 'user_id' => $userId, + 'title' => $title, + 'slug' => $slug, + 'description' => $description, + 'file_name' => 'pending', + 'file_path' => 'pending', + 'file_size' => 0, + 'mime_type' => 'application/octet-stream', + 'width' => 1, + 'height' => 1, + 'is_public' => false, + 'is_approved' => false, + 'published_at' => null, + ]); + + return new ArtworkDraftResult((int) $artwork->id, 'draft'); + }); + } + + private function uniqueSlug(string $title): string + { + $base = Str::slug($title); + $base = $base !== '' ? $base : 'artwork'; + + for ($i = 0; $i < 5; $i++) { + $suffix = Str::lower(Str::random(6)); + $slug = Str::limit($base . '-' . $suffix, 160, ''); + + if (! Artwork::where('slug', $slug)->exists()) { + return $slug; + } + } + + return Str::limit($base . '-' . Str::uuid()->toString(), 160, ''); + } +} diff --git a/app/Services/AvatarService.php b/app/Services/AvatarService.php new file mode 100644 index 00000000..1430ab98 --- /dev/null +++ b/app/Services/AvatarService.php @@ -0,0 +1,169 @@ + 32, + 'sm' => 64, + 'md' => 128, + 'lg' => 256, + 'xl' => 512, + ]; + + protected $quality = 85; + + public function __construct() + { + // Guard: if Intervention Image is not installed, defer error until actual use + if (class_exists(\Intervention\Image\ImageManagerStatic::class)) { + try { + Image::configure(['driver' => extension_loaded('gd') ? 'gd' : 'imagick']); + $this->imageAvailable = true; + } catch (\Throwable $e) { + // If configuration fails, treat as unavailable and log for diagnostics + logger()->warning('Intervention Image present but configuration failed: '.$e->getMessage()); + $this->imageAvailable = false; + } + } else { + $this->imageAvailable = false; + } + } + + /** + * Process an uploaded file for a user and store webp sizes. + * Returns the computed sha1 hash. + * + * @param int $userId + * @param UploadedFile $file + * @return string sha1 hash + */ + public function storeFromUploadedFile(int $userId, UploadedFile $file): string + { + if (! $this->imageAvailable) { + throw new RuntimeException('Intervention Image is not available. If you just installed the package, restart your PHP process (php artisan serve or PHP-FPM) and run `composer dump-autoload -o`.'); + } + + // Load image and re-encode to webp after validating + try { + $img = Image::make($file->getRealPath()); + } catch (\Throwable $e) { + throw new RuntimeException('Failed to read uploaded image: '.$e->getMessage()); + } + + // Ensure square center crop per spec + $max = max($img->width(), $img->height()); + $img->fit($max, $max); + + $basePath = "avatars/{$userId}"; + Storage::disk('public')->makeDirectory($basePath); + + // Save original as webp + $originalData = (string) $img->encode('webp', $this->quality); + Storage::disk('public')->put($basePath . '/original.webp', $originalData); + + // Generate sizes + foreach ($this->sizes as $name => $size) { + $resized = $img->resize($size, $size, function ($constraint) { + $constraint->upsize(); + })->encode('webp', $this->quality); + Storage::disk('public')->put("{$basePath}/{$size}.webp", (string)$resized); + } + + $hash = sha1($originalData); + $mime = 'image/webp'; + + // Persist metadata to user_profiles if exists, otherwise users table fallbacks + if (SchemaHasTable('user_profiles')) { + DB::table('user_profiles')->where('user_id', $userId)->update([ + 'avatar_hash' => $hash, + 'avatar_updated_at' => Carbon::now(), + 'avatar_mime' => $mime, + ]); + } else { + DB::table('users')->where('id', $userId)->update([ + 'avatar_hash' => $hash, + 'avatar_updated_at' => Carbon::now(), + 'avatar_mime' => $mime, + ]); + } + + return $hash; + } + + /** + * Process a legacy file path for a user (path-to-file). + * Returns sha1 or null when missing. + * + * @param int $userId + * @param string $path Absolute filesystem path + * @return string|null + */ + public function storeFromLegacyFile(int $userId, string $path): ?string + { + if (!file_exists($path) || !is_readable($path)) { + return null; + } + + try { + $img = Image::make($path); + } catch (\Exception $e) { + return null; + } + + $max = max($img->width(), $img->height()); + $img->fit($max, $max); + + $basePath = "avatars/{$userId}"; + Storage::disk('public')->makeDirectory($basePath); + + $originalData = (string) $img->encode('webp', $this->quality); + Storage::disk('public')->put($basePath . '/original.webp', $originalData); + + foreach ($this->sizes as $name => $size) { + $resized = $img->resize($size, $size, function ($constraint) { + $constraint->upsize(); + })->encode('webp', $this->quality); + Storage::disk('public')->put("{$basePath}/{$size}.webp", (string)$resized); + } + + $hash = sha1($originalData); + $mime = 'image/webp'; + + if (SchemaHasTable('user_profiles')) { + DB::table('user_profiles')->where('user_id', $userId)->update([ + 'avatar_hash' => $hash, + 'avatar_updated_at' => Carbon::now(), + 'avatar_mime' => $mime, + ]); + } else { + DB::table('users')->where('id', $userId)->update([ + 'avatar_hash' => $hash, + 'avatar_updated_at' => Carbon::now(), + 'avatar_mime' => $mime, + ]); + } + + return $hash; + } +} + +/** + * Helper: check for table existence without importing Schema facade repeatedly + */ +function SchemaHasTable(string $name): bool +{ + try { + return \Illuminate\Support\Facades\Schema::hasTable($name); + } catch (\Throwable $e) { + return false; + } +} diff --git a/app/Services/Recommendations/FeedOfflineEvaluationService.php b/app/Services/Recommendations/FeedOfflineEvaluationService.php new file mode 100644 index 00000000..44367135 --- /dev/null +++ b/app/Services/Recommendations/FeedOfflineEvaluationService.php @@ -0,0 +1,139 @@ + + */ + public function evaluateAlgo(string $algoVersion, string $from, string $to): array + { + $row = DB::table('feed_daily_metrics') + ->selectRaw('SUM(impressions) as impressions') + ->selectRaw('SUM(clicks) as clicks') + ->selectRaw('SUM(saves) as saves') + ->selectRaw('SUM(dwell_0_5) as dwell_0_5') + ->selectRaw('SUM(dwell_5_30) as dwell_5_30') + ->selectRaw('SUM(dwell_30_120) as dwell_30_120') + ->selectRaw('SUM(dwell_120_plus) as dwell_120_plus') + ->where('algo_version', $algoVersion) + ->whereBetween('metric_date', [$from, $to]) + ->first(); + + $impressions = (int) ($row->impressions ?? 0); + $clicks = (int) ($row->clicks ?? 0); + $saves = (int) ($row->saves ?? 0); + + $dwell05 = (int) ($row->dwell_0_5 ?? 0); + $dwell530 = (int) ($row->dwell_5_30 ?? 0); + $dwell30120 = (int) ($row->dwell_30_120 ?? 0); + $dwell120Plus = (int) ($row->dwell_120_plus ?? 0); + + $ctr = $impressions > 0 ? $clicks / $impressions : 0.0; + $saveRate = $clicks > 0 ? $saves / $clicks : 0.0; + $longDwellShare = $clicks > 0 ? ($dwell30120 + $dwell120Plus) / $clicks : 0.0; + $bounceRate = $clicks > 0 ? $dwell05 / $clicks : 0.0; + + $objectiveWeights = (array) config('discovery.evaluation.objective_weights', []); + $wCtr = (float) ($objectiveWeights['ctr'] ?? 0.45); + $wSave = (float) ($objectiveWeights['save_rate'] ?? 0.35); + $wLong = (float) ($objectiveWeights['long_dwell_share'] ?? 0.25); + $wBouncePenalty = (float) ($objectiveWeights['bounce_rate_penalty'] ?? 0.15); + + $saveRateInformational = (bool) config('discovery.evaluation.save_rate_informational', true); + if ($saveRateInformational) { + $wSave = 0.0; + } + + $normalizationSum = $wCtr + $wSave + $wLong + $wBouncePenalty; + if ($normalizationSum > 0.0) { + $wCtr /= $normalizationSum; + $wSave /= $normalizationSum; + $wLong /= $normalizationSum; + $wBouncePenalty /= $normalizationSum; + } + + $objectiveScore = ($wCtr * $ctr) + + ($wSave * $saveRate) + + ($wLong * $longDwellShare) + - ($wBouncePenalty * $bounceRate); + + return [ + 'algo_version' => $algoVersion, + 'from' => $from, + 'to' => $to, + 'impressions' => $impressions, + 'clicks' => $clicks, + 'saves' => $saves, + 'ctr' => round($ctr, 6), + 'save_rate' => round($saveRate, 6), + 'long_dwell_share' => round($longDwellShare, 6), + 'bounce_rate' => round($bounceRate, 6), + 'dwell_buckets' => [ + '0_5' => $dwell05, + '5_30' => $dwell530, + '30_120' => $dwell30120, + '120_plus' => $dwell120Plus, + ], + 'objective_score' => round($objectiveScore, 6), + ]; + } + + /** + * @return array> + */ + public function evaluateAll(string $from, string $to): array + { + $algoVersions = DB::table('feed_daily_metrics') + ->select('algo_version') + ->whereBetween('metric_date', [$from, $to]) + ->distinct() + ->orderBy('algo_version') + ->pluck('algo_version') + ->map(static fn (mixed $v): string => (string) $v) + ->all(); + + $out = []; + foreach ($algoVersions as $algoVersion) { + $out[] = $this->evaluateAlgo($algoVersion, $from, $to); + } + + usort($out, static fn (array $a, array $b): int => $b['objective_score'] <=> $a['objective_score']); + + return $out; + } + + /** + * @return array + */ + public function compareBaselineCandidate(string $baselineAlgoVersion, string $candidateAlgoVersion, string $from, string $to): array + { + $baseline = $this->evaluateAlgo($baselineAlgoVersion, $from, $to); + $candidate = $this->evaluateAlgo($candidateAlgoVersion, $from, $to); + + $deltaObjective = (float) $candidate['objective_score'] - (float) $baseline['objective_score']; + $objectiveLiftPct = (float) $baseline['objective_score'] !== 0.0 + ? ($deltaObjective / (float) $baseline['objective_score']) * 100.0 + : null; + + return [ + 'from' => $from, + 'to' => $to, + 'baseline' => $baseline, + 'candidate' => $candidate, + 'delta' => [ + 'objective_score' => round($deltaObjective, 6), + 'objective_lift_pct' => $objectiveLiftPct !== null ? round($objectiveLiftPct, 4) : null, + 'ctr' => round((float) $candidate['ctr'] - (float) $baseline['ctr'], 6), + 'save_rate' => round((float) $candidate['save_rate'] - (float) $baseline['save_rate'], 6), + 'long_dwell_share' => round((float) $candidate['long_dwell_share'] - (float) $baseline['long_dwell_share'], 6), + 'bounce_rate' => round((float) $candidate['bounce_rate'] - (float) $baseline['bounce_rate'], 6), + ], + ]; + } +} diff --git a/app/Services/Recommendations/PersonalizedFeedService.php b/app/Services/Recommendations/PersonalizedFeedService.php new file mode 100644 index 00000000..0ad3942f --- /dev/null +++ b/app/Services/Recommendations/PersonalizedFeedService.php @@ -0,0 +1,567 @@ +resolveAlgoVersion($algoVersion, $userId); + $weightSet = $this->resolveRankingWeights($resolvedAlgoVersion); + $offset = $this->decodeCursorToOffset($cursor); + + $cache = UserRecommendationCache::query() + ->where('user_id', $userId) + ->where('algo_version', $resolvedAlgoVersion) + ->first(); + + $cacheItems = $this->extractCacheItems($cache); + $isFresh = $cache !== null && $cache->expires_at !== null && $cache->expires_at->isFuture(); + + $cacheStatus = 'hit'; + if ($cache === null) { + $cacheStatus = 'miss'; + } elseif (! $isFresh) { + $cacheStatus = 'stale'; + } + + if ($cache === null || ! $isFresh) { + RegenerateUserRecommendationCacheJob::dispatch($userId, $resolvedAlgoVersion) + ->onQueue((string) config('discovery.queue', 'default')); + } + + $items = $cacheItems; + if ($items === []) { + $items = $this->buildColdStartRecommendations($resolvedAlgoVersion, 240, 'fallback'); + $cacheStatus = $cacheStatus . '-fallback'; + } + + return $this->buildFeedPageResponse( + items: $items, + offset: $offset, + limit: $safeLimit, + algoVersion: $resolvedAlgoVersion, + weightVersion: (string) $weightSet['version'], + cacheStatus: $cacheStatus, + generatedAt: $cache?->generated_at?->toIso8601String() + ); + } + + public function regenerateCacheForUser(int $userId, ?string $algoVersion = null): void + { + $resolvedAlgoVersion = $this->resolveAlgoVersion($algoVersion, $userId); + $cacheVersion = (string) config('discovery.cache_version', 'cache-v1'); + $ttlMinutes = max(1, (int) config('discovery.cache_ttl_minutes', 60)); + + $items = $this->buildRecommendations($userId, $resolvedAlgoVersion, 240); + $generatedAt = now(); + $expiresAt = now()->addMinutes($ttlMinutes); + + UserRecommendationCache::query()->updateOrCreate( + [ + 'user_id' => $userId, + 'algo_version' => $resolvedAlgoVersion, + ], + [ + 'cache_version' => $cacheVersion, + 'recommendations_json' => [ + 'items' => $items, + 'algo_version' => $resolvedAlgoVersion, + 'weight_version' => (string) $this->resolveRankingWeights($resolvedAlgoVersion)['version'], + 'generated_at' => $generatedAt->toIso8601String(), + ], + 'generated_at' => $generatedAt, + 'expires_at' => $expiresAt, + ] + ); + } + + /** + * @return array + */ + public function buildRecommendations(int $userId, string $algoVersion, int $maxItems = 240): array + { + $profileVersion = (string) config('discovery.profile_version', 'profile-v1'); + + $profile = UserInterestProfile::query() + ->where('user_id', $userId) + ->where('profile_version', $profileVersion) + ->where('algo_version', $algoVersion) + ->first(); + + $normalized = $profile !== null ? (array) ($profile->normalized_scores_json ?? []) : []; + $personalized = $this->buildProfileBasedRecommendations($normalized, $maxItems, $algoVersion); + + if ($personalized === []) { + return $this->buildColdStartRecommendations($algoVersion, $maxItems, 'cold_start'); + } + + $fallback = $this->buildColdStartRecommendations($algoVersion, $maxItems, 'fallback'); + + $combined = []; + foreach (array_merge($personalized, $fallback) as $item) { + $artworkId = (int) ($item['artwork_id'] ?? 0); + if ($artworkId <= 0) { + continue; + } + + if (! isset($combined[$artworkId])) { + $combined[$artworkId] = [ + 'artwork_id' => $artworkId, + 'score' => (float) ($item['score'] ?? 0.0), + 'source' => (string) ($item['source'] ?? 'mixed'), + ]; + continue; + } + + if ((float) $item['score'] > (float) $combined[$artworkId]['score']) { + $combined[$artworkId]['score'] = (float) $item['score']; + $combined[$artworkId]['source'] = (string) ($item['source'] ?? $combined[$artworkId]['source']); + } + } + + $candidates = array_values($combined); + usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']); + + return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems); + } + + /** + * @param array $normalizedScores + * @return array + */ + private function buildProfileBasedRecommendations(array $normalizedScores, int $maxItems, string $algoVersion): array + { + $weightSet = $this->resolveRankingWeights($algoVersion); + $w1 = (float) $weightSet['w1']; + $w2 = (float) $weightSet['w2']; + $w3 = (float) $weightSet['w3']; + $w4 = (float) $weightSet['w4']; + + $categoryAffinities = []; + foreach ($normalizedScores as $key => $score) { + if (! is_numeric($score)) { + continue; + } + + if (! str_starts_with((string) $key, 'category:')) { + continue; + } + + $categoryId = (int) str_replace('category:', '', (string) $key); + if ($categoryId <= 0) { + continue; + } + + $categoryAffinities[$categoryId] = (float) $score; + } + + if ($categoryAffinities === []) { + return []; + } + + $rows = DB::table('artworks') + ->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id') + ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') + ->whereIn('artwork_category.category_id', array_keys($categoryAffinities)) + ->whereNull('artworks.deleted_at') + ->where('artworks.is_public', true) + ->where('artworks.is_approved', true) + ->whereNotNull('artworks.published_at') + ->where('artworks.published_at', '<=', now()) + ->orderByDesc('artworks.published_at') + ->limit(max(200, $maxItems * 8)) + ->get([ + 'artworks.id', + 'artworks.published_at', + 'artwork_category.category_id', + DB::raw('COALESCE(artwork_stats.views, 0) as views'), + ]); + + $scored = []; + foreach ($rows as $row) { + $artworkId = (int) $row->id; + $categoryId = (int) $row->category_id; + $affinity = (float) ($categoryAffinities[$categoryId] ?? 0.0); + if ($affinity <= 0.0) { + continue; + } + + $publishedAt = CarbonImmutable::parse((string) $row->published_at); + $ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400); + $recency = exp(-$ageDays / 30.0); + $popularity = log(1 + max(0, (int) $row->views)) / 10.0; + $novelty = max(0.0, 1.0 - min(1.0, $popularity)); + + // Phase 8B blend with versioned weights (manual tuning, no auto-tuning yet). + $score = ($w1 * $affinity) + ($w2 * $recency) + ($w3 * $popularity) + ($w4 * $novelty); + + if (! isset($scored[$artworkId]) || $score > $scored[$artworkId]['score']) { + $scored[$artworkId] = [ + 'artwork_id' => $artworkId, + 'score' => $score, + 'source' => 'personalized', + ]; + } + } + + $candidates = array_values($scored); + usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']); + + return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems); + } + + /** + * @return array + */ + private function buildColdStartRecommendations(string $algoVersion, int $maxItems, string $sourceLabel = 'cold_start'): array + { + $popularIds = DB::table('artworks') + ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') + ->whereNull('artworks.deleted_at') + ->where('artworks.is_public', true) + ->where('artworks.is_approved', true) + ->whereNotNull('artworks.published_at') + ->where('artworks.published_at', '<=', now()) + ->orderByDesc('artwork_stats.views') + ->orderByDesc('artwork_stats.downloads') + ->orderByDesc('artworks.published_at') + ->limit(max(40, $maxItems)) + ->pluck('artworks.id') + ->map(static fn (mixed $id): int => (int) $id) + ->all(); + + $seedIds = array_slice($popularIds, 0, 12); + + $similarIds = []; + if ($seedIds !== []) { + $similarIds = DB::table('artwork_similarities') + ->where('algo_version', $algoVersion) + ->whereIn('artwork_id', $seedIds) + ->orderBy('rank') + ->orderByDesc('score') + ->limit(max(80, $maxItems * 2)) + ->pluck('similar_artwork_id') + ->map(static fn (mixed $id): int => (int) $id) + ->all(); + } + + $candidates = []; + foreach ($popularIds as $index => $artworkId) { + $candidates[] = [ + 'artwork_id' => $artworkId, + 'score' => max(0.0, 1.0 - ($index * 0.003)), + 'source' => $sourceLabel, + ]; + } + + foreach ($similarIds as $index => $artworkId) { + $candidates[] = [ + 'artwork_id' => $artworkId, + 'score' => max(0.0, 0.75 - ($index * 0.002)), + 'source' => $sourceLabel, + ]; + } + + usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']); + + return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems); + } + + /** + * @param array $candidates + * @return array + */ + private function applyDiversityGuard(array $candidates, string $algoVersion, int $maxItems): array + { + if ($candidates === []) { + return []; + } + + $uniqueCandidates = []; + foreach ($candidates as $candidate) { + $artworkId = (int) ($candidate['artwork_id'] ?? 0); + if ($artworkId <= 0 || isset($uniqueCandidates[$artworkId])) { + continue; + } + + $uniqueCandidates[$artworkId] = [ + 'artwork_id' => $artworkId, + 'score' => (float) ($candidate['score'] ?? 0.0), + 'source' => (string) ($candidate['source'] ?? 'mixed'), + ]; + } + + $flattened = array_values($uniqueCandidates); + $candidateIds = array_map(static fn (array $item): int => (int) $item['artwork_id'], $flattened); + + $nearDuplicatePairs = DB::table('artwork_similarities') + ->where('algo_version', $algoVersion) + ->where('score', '>=', 0.97) + ->whereIn('artwork_id', $candidateIds) + ->whereIn('similar_artwork_id', $candidateIds) + ->get(['artwork_id', 'similar_artwork_id']); + + $adjacency = []; + foreach ($nearDuplicatePairs as $pair) { + $left = (int) $pair->artwork_id; + $right = (int) $pair->similar_artwork_id; + + if ($left === $right) { + continue; + } + + $adjacency[$left][$right] = true; + $adjacency[$right][$left] = true; + } + + $selected = []; + $selectedSet = []; + + foreach ($flattened as $candidate) { + $id = (int) $candidate['artwork_id']; + + $isNearDuplicate = false; + foreach ($selectedSet as $selectedId => $value) { + if (($adjacency[$id][$selectedId] ?? false) || ($adjacency[$selectedId][$id] ?? false)) { + $isNearDuplicate = true; + break; + } + } + + if ($isNearDuplicate) { + continue; + } + + $selected[] = [ + 'artwork_id' => $id, + 'score' => round((float) $candidate['score'], 6), + 'source' => (string) $candidate['source'], + ]; + $selectedSet[$id] = true; + + if (count($selected) >= $maxItems) { + break; + } + } + + return $selected; + } + + /** + * @param array $items + */ + private function buildFeedPageResponse( + array $items, + int $offset, + int $limit, + string $algoVersion, + string $weightVersion, + string $cacheStatus, + ?string $generatedAt + ): array { + $safeOffset = max(0, $offset); + $pageItems = array_slice($items, $safeOffset, $limit); + + $ids = array_values(array_unique(array_map( + static fn (array $item): int => (int) ($item['artwork_id'] ?? 0), + $pageItems + ))); + + /** @var Collection $artworks */ + $artworks = Artwork::query() + ->with(['user:id,name']) + ->whereIn('id', $ids) + ->public() + ->published() + ->get() + ->keyBy('id'); + + $responseItems = []; + foreach ($pageItems as $item) { + $artworkId = (int) ($item['artwork_id'] ?? 0); + $artwork = $artworks->get($artworkId); + if ($artwork === null) { + continue; + } + + $responseItems[] = [ + 'id' => $artwork->id, + 'slug' => $artwork->slug, + 'title' => $artwork->title, + 'thumbnail_url' => $artwork->thumb_url, + 'author' => $artwork->user?->name, + 'score' => (float) ($item['score'] ?? 0.0), + 'source' => (string) ($item['source'] ?? 'mixed'), + ]; + } + + $nextOffset = $safeOffset + $limit; + $hasNext = $nextOffset < count($items); + + return [ + 'data' => $responseItems, + 'meta' => [ + 'algo_version' => $algoVersion, + 'weight_version' => $weightVersion, + 'cursor' => $this->encodeOffsetToCursor($safeOffset), + 'next_cursor' => $hasNext ? $this->encodeOffsetToCursor($nextOffset) : null, + 'limit' => $limit, + 'cache_status' => $cacheStatus, + 'generated_at' => $generatedAt, + 'total_candidates' => count($items), + ], + ]; + } + + private function resolveAlgoVersion(?string $algoVersion = null, ?int $userId = null): string + { + if ($algoVersion !== null && $algoVersion !== '') { + return $algoVersion; + } + + $forcedAlgoVersion = trim((string) config('discovery.rollout.force_algo_version', '')); + if ($forcedAlgoVersion !== '') { + return $forcedAlgoVersion; + } + + $defaultAlgoVersion = (string) config('discovery.algo_version', 'clip-cosine-v1'); + $rolloutEnabled = (bool) config('discovery.rollout.enabled', false); + if (! $rolloutEnabled || $userId === null || $userId <= 0) { + return $defaultAlgoVersion; + } + + $baselineAlgoVersion = (string) config('discovery.rollout.baseline_algo_version', $defaultAlgoVersion); + $candidateAlgoVersion = (string) config('discovery.rollout.candidate_algo_version', $defaultAlgoVersion); + if ($candidateAlgoVersion === '' || $candidateAlgoVersion === $baselineAlgoVersion) { + return $baselineAlgoVersion; + } + + $activeGate = (string) config('discovery.rollout.active_gate', 'g10'); + $gates = (array) config('discovery.rollout.gates', []); + $gate = (array) ($gates[$activeGate] ?? []); + $rolloutPercentage = (int) ($gate['percentage'] ?? 0); + $rolloutPercentage = max(0, min(100, $rolloutPercentage)); + + if ($rolloutPercentage <= 0) { + return $baselineAlgoVersion; + } + + if ($rolloutPercentage >= 100) { + return $candidateAlgoVersion; + } + + $bucket = abs((int) crc32((string) $userId)) % 100; + + return $bucket < $rolloutPercentage + ? $candidateAlgoVersion + : $baselineAlgoVersion; + } + + /** + * @return array{version:string,w1:float,w2:float,w3:float,w4:float} + */ + public function resolveRankingWeights(string $algoVersion): array + { + $defaults = (array) config('discovery.ranking.default_weights', []); + $byAlgo = (array) config('discovery.ranking.algo_weight_sets', []); + $override = (array) ($byAlgo[$algoVersion] ?? []); + + $resolved = array_merge($defaults, $override); + + $weights = [ + 'version' => (string) ($resolved['version'] ?? 'rank-w-v1'), + 'w1' => max(0.0, (float) ($resolved['w1'] ?? 0.65)), + 'w2' => max(0.0, (float) ($resolved['w2'] ?? 0.20)), + 'w3' => max(0.0, (float) ($resolved['w3'] ?? 0.10)), + 'w4' => max(0.0, (float) ($resolved['w4'] ?? 0.05)), + ]; + + $sum = $weights['w1'] + $weights['w2'] + $weights['w3'] + $weights['w4']; + if ($sum > 0.0) { + $weights['w1'] /= $sum; + $weights['w2'] /= $sum; + $weights['w3'] /= $sum; + $weights['w4'] /= $sum; + } + + return $weights; + } + + private function decodeCursorToOffset(?string $cursor): int + { + if ($cursor === null || $cursor === '') { + return 0; + } + + $decoded = base64_decode(strtr($cursor, '-_', '+/'), true); + if ($decoded === false) { + return 0; + } + + $json = json_decode($decoded, true); + if (! is_array($json)) { + return 0; + } + + return max(0, (int) Arr::get($json, 'offset', 0)); + } + + private function encodeOffsetToCursor(int $offset): string + { + $payload = json_encode(['offset' => max(0, $offset)]); + if (! is_string($payload)) { + return ''; + } + + return rtrim(strtr(base64_encode($payload), '+/', '-_'), '='); + } + + /** + * @return array + */ + private function extractCacheItems(?UserRecommendationCache $cache): array + { + if ($cache === null) { + return []; + } + + $raw = (array) ($cache->recommendations_json ?? []); + $items = $raw['items'] ?? null; + if (! is_array($items)) { + return []; + } + + $typed = []; + foreach ($items as $item) { + if (! is_array($item)) { + continue; + } + + $artworkId = (int) ($item['artwork_id'] ?? 0); + if ($artworkId <= 0) { + continue; + } + + $typed[] = [ + 'artwork_id' => $artworkId, + 'score' => (float) ($item['score'] ?? 0.0), + 'source' => (string) ($item['source'] ?? 'mixed'), + ]; + } + + return $typed; + } +} diff --git a/app/Services/Recommendations/SimilarArtworksService.php b/app/Services/Recommendations/SimilarArtworksService.php new file mode 100644 index 00000000..85d2c5c8 --- /dev/null +++ b/app/Services/Recommendations/SimilarArtworksService.php @@ -0,0 +1,45 @@ + + */ + public function forArtwork(int $artworkId, int $limit = 12, ?string $algoVersion = null): Collection + { + $effectiveAlgo = $algoVersion ?: (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1'); + + $ids = DB::table('artwork_similarities') + ->where('artwork_id', $artworkId) + ->where('algo_version', $effectiveAlgo) + ->orderBy('rank') + ->limit(max(1, min($limit, 50))) + ->pluck('similar_artwork_id') + ->map(static fn ($id) => (int) $id) + ->all(); + + if ($ids === []) { + return collect(); + } + + $artworks = Artwork::query() + ->whereIn('id', $ids) + ->public() + ->published() + ->get(); + + $byId = $artworks->keyBy('id'); + + return collect($ids) + ->map(static fn (int $id) => $byId->get($id)) + ->filter(); + } +} diff --git a/app/Services/Recommendations/UserInterestProfileService.php b/app/Services/Recommendations/UserInterestProfileService.php new file mode 100644 index 00000000..f7c23f2a --- /dev/null +++ b/app/Services/Recommendations/UserInterestProfileService.php @@ -0,0 +1,162 @@ + $eventMeta + */ + public function applyEvent( + int $userId, + string $eventType, + int $artworkId, + ?int $categoryId, + CarbonInterface $occurredAt, + string $eventId, + string $algoVersion, + array $eventMeta = [] + ): void { + $profileVersion = (string) config('discovery.profile_version', 'profile-v1'); + $halfLifeHours = (float) config('discovery.decay.half_life_hours', 72); + $weightMap = (array) config('discovery.weights', []); + $eventWeight = (float) ($weightMap[$eventType] ?? 1.0); + + DB::transaction(function () use ( + $userId, + $categoryId, + $artworkId, + $occurredAt, + $eventId, + $algoVersion, + $profileVersion, + $halfLifeHours, + $eventWeight, + $eventMeta + ): void { + $profile = UserInterestProfile::query() + ->where('user_id', $userId) + ->where('profile_version', $profileVersion) + ->where('algo_version', $algoVersion) + ->lockForUpdate() + ->first(); + + $rawScores = $profile !== null ? (array) ($profile->raw_scores_json ?? []) : []; + $lastEventAt = $profile?->last_event_at; + + if ($lastEventAt !== null && $occurredAt->greaterThan($lastEventAt)) { + $hours = max(0.0, (float) $lastEventAt->diffInSeconds($occurredAt) / 3600); + $rawScores = $this->applyRecencyDecay($rawScores, $hours, $halfLifeHours); + } + + $interestKey = $categoryId !== null + ? sprintf('category:%d', $categoryId) + : sprintf('artwork:%d', $artworkId); + + $rawScores[$interestKey] = (float) ($rawScores[$interestKey] ?? 0.0) + $eventWeight; + + $rawScores = array_filter( + $rawScores, + static fn (mixed $value): bool => is_numeric($value) && (float) $value > 0.000001 + ); + + $normalizedScores = $this->normalizeScores($rawScores); + $totalWeight = array_sum($rawScores); + + $payload = [ + 'user_id' => $userId, + 'profile_version' => $profileVersion, + 'algo_version' => $algoVersion, + 'raw_scores_json' => $rawScores, + 'normalized_scores_json' => $normalizedScores, + 'total_weight' => $totalWeight, + 'event_count' => $profile !== null ? ((int) $profile->event_count + 1) : 1, + 'last_event_at' => $lastEventAt === null || $occurredAt->greaterThan($lastEventAt) + ? $occurredAt + : $lastEventAt, + 'half_life_hours' => $halfLifeHours, + 'updated_from_event_id' => $eventId, + 'updated_at' => now(), + ]; + + if ($profile === null) { + $payload['created_at'] = now(); + UserInterestProfile::query()->create($payload); + return; + } + + $profile->fill($payload); + $profile->save(); + }, 3); + } + + /** + * @param array $scores + * @return array + */ + public function applyRecencyDecay(array $scores, float $hoursElapsed, float $halfLifeHours): array + { + if ($hoursElapsed <= 0 || $halfLifeHours <= 0) { + return $this->castToFloatScores($scores); + } + + $decayFactor = exp(-log(2) * ($hoursElapsed / $halfLifeHours)); + $output = []; + + foreach ($scores as $key => $score) { + if (! is_numeric($score)) { + continue; + } + + $decayed = (float) $score * $decayFactor; + if ($decayed > 0.000001) { + $output[(string) $key] = $decayed; + } + } + + return $output; + } + + /** + * @param array $scores + * @return array + */ + public function normalizeScores(array $scores): array + { + $typedScores = $this->castToFloatScores($scores); + $sum = array_sum($typedScores); + + if ($sum <= 0.0) { + return []; + } + + $normalized = []; + foreach ($typedScores as $key => $score) { + $normalized[$key] = $score / $sum; + } + + return $normalized; + } + + /** + * @param array $scores + * @return array + */ + private function castToFloatScores(array $scores): array + { + $output = []; + foreach ($scores as $key => $score) { + if (is_numeric($score) && (float) $score > 0.0) { + $output[(string) $key] = (float) $score; + } + } + + return $output; + } +} diff --git a/app/Services/TagNormalizer.php b/app/Services/TagNormalizer.php new file mode 100644 index 00000000..65f2f4ab --- /dev/null +++ b/app/Services/TagNormalizer.php @@ -0,0 +1,39 @@ + hyphens and collapse repeats. + $value = str_replace(' ', '-', $value); + $value = (string) preg_replace('/\-+/u', '-', $value); + $value = trim($value, "-\t\n\r\0\x0B"); + + $maxLength = (int) config('tags.max_length', 32); + if ($maxLength > 0 && mb_strlen($value, 'UTF-8') > $maxLength) { + $value = mb_substr($value, 0, $maxLength, 'UTF-8'); + $value = rtrim($value, '-'); + } + + return $value; + } +} diff --git a/app/Services/TagService.php b/app/Services/TagService.php new file mode 100644 index 00000000..596e3235 --- /dev/null +++ b/app/Services/TagService.php @@ -0,0 +1,329 @@ +normalizer->normalize($rawTag); + $this->validateNormalizedTag($normalized); + + // Keep tags normalized in both name and slug (spec: normalize all tags). + // Unique(slug) + Unique(name) prevents duplicates. + return Tag::query()->firstOrCreate( + ['slug' => $normalized], + ['name' => $normalized, 'usage_count' => 0, 'is_active' => true] + ); + } + + /** + * @param array $tags + */ + public function attachUserTags(Artwork $artwork, array $tags): void + { + $normalized = $this->normalizeUserTags($tags); + if ($normalized === []) { + return; + } + + DB::transaction(function () use ($artwork, $normalized): void { + $tagIdsBySlug = []; + foreach ($normalized as $tag) { + $model = $this->createOrFindTag($tag); + $tagIdsBySlug[$model->slug] = $model->id; + } + + $tagIds = array_values($tagIdsBySlug); + + $existing = DB::table('artwork_tag') + ->where('artwork_id', $artwork->id) + ->whereIn('tag_id', $tagIds) + ->pluck('source', 'tag_id') + ->all(); + + $toAttach = []; + $toUpdate = []; + $newlyAttachedTagIds = []; + + foreach ($tagIds as $tagId) { + $source = $existing[$tagId] ?? null; + + if ($source === null) { + $toAttach[$tagId] = ['source' => 'user', 'confidence' => null, 'created_at' => now()]; + $newlyAttachedTagIds[] = $tagId; + continue; + } + + if ($source !== 'user') { + // User tags take precedence over AI/system. + $toUpdate[$tagId] = ['source' => 'user', 'confidence' => null]; + } + } + + if ($toAttach !== []) { + $artwork->tags()->syncWithoutDetaching($toAttach); + $this->incrementUsageCounts($newlyAttachedTagIds); + } + + foreach ($toUpdate as $tagId => $payload) { + $artwork->tags()->updateExistingPivot($tagId, $payload); + } + }); + } + + /** + * @param array $aiTags + */ + public function attachAiTags(Artwork $artwork, array $aiTags): void + { + if ($aiTags === []) { + return; + } + + DB::transaction(function () use ($artwork, $aiTags): void { + $payloads = []; + $newlyAttachedTagIds = []; + + foreach ($aiTags as $row) { + $raw = (string) ($row['tag'] ?? ''); + $confidence = $row['confidence'] ?? null; + + $normalized = $this->normalizer->normalize($raw); + if ($normalized === '') { + continue; + } + + // AI tagging must be optional: invalid/banned tags are skipped (not fatal). + try { + $this->validateNormalizedTag($normalized); + } catch (ValidationException $e) { + continue; + } + + $tag = $this->createOrFindTag($normalized); + + $existingSource = DB::table('artwork_tag') + ->where('artwork_id', $artwork->id) + ->where('tag_id', $tag->id) + ->value('source'); + + if ($existingSource === 'user') { + continue; + } + + if ($existingSource === null) { + $payloads[$tag->id] = [ + 'source' => 'ai', + 'confidence' => is_numeric($confidence) ? (float) $confidence : null, + 'created_at' => now(), + ]; + $newlyAttachedTagIds[] = $tag->id; + continue; + } + + if ($existingSource === 'ai') { + $artwork->tags()->updateExistingPivot($tag->id, [ + 'confidence' => is_numeric($confidence) ? (float) $confidence : null, + ]); + } + } + + if ($payloads !== []) { + $artwork->tags()->syncWithoutDetaching($payloads); + $this->incrementUsageCounts($newlyAttachedTagIds); + } + }); + } + + public function detachTags(Artwork $artwork, array $tagSlugsOrIds): void + { + if ($tagSlugsOrIds === []) { + return; + } + + $tagIds = Tag::query() + ->whereIn('id', array_filter($tagSlugsOrIds, 'is_numeric')) + ->orWhereIn('slug', array_filter($tagSlugsOrIds, fn ($v) => is_string($v) && $v !== '')) + ->pluck('id') + ->all(); + + if ($tagIds === []) { + return; + } + + DB::transaction(function () use ($artwork, $tagIds): void { + $existing = DB::table('artwork_tag') + ->where('artwork_id', $artwork->id) + ->whereIn('tag_id', $tagIds) + ->pluck('tag_id') + ->all(); + + if ($existing === []) { + return; + } + + $artwork->tags()->detach($existing); + $this->decrementUsageCounts($existing); + }); + } + + /** + * Sync user tags (PUT semantics): replaces the set of user-origin tags. + * + * @param array $tags + */ + public function syncTags(Artwork $artwork, array $tags): void + { + $normalized = $this->normalizeUserTags($tags); + + DB::transaction(function () use ($artwork, $normalized): void { + $desiredTagIds = []; + foreach ($normalized as $tag) { + $model = $this->createOrFindTag($tag); + $desiredTagIds[] = $model->id; + } + + $desiredTagIds = array_values(array_unique($desiredTagIds)); + + $currentUserTagIds = DB::table('artwork_tag') + ->where('artwork_id', $artwork->id) + ->where('source', 'user') + ->pluck('tag_id') + ->all(); + + $toDetach = array_values(array_diff($currentUserTagIds, $desiredTagIds)); + $toAttach = array_values(array_diff($desiredTagIds, $currentUserTagIds)); + + if ($toDetach !== []) { + $artwork->tags()->detach($toDetach); + $this->decrementUsageCounts($toDetach); + } + + if ($toAttach !== []) { + $payload = []; + foreach ($toAttach as $tagId) { + $payload[$tagId] = ['source' => 'user', 'confidence' => null, 'created_at' => now()]; + } + $artwork->tags()->syncWithoutDetaching($payload); + $this->incrementUsageCounts($toAttach); + } + + // Ensure desired tags are marked as user (user precedence). + if ($desiredTagIds !== []) { + $existingNonUser = DB::table('artwork_tag') + ->where('artwork_id', $artwork->id) + ->whereIn('tag_id', $desiredTagIds) + ->where('source', '!=', 'user') + ->pluck('tag_id') + ->all(); + + foreach ($existingNonUser as $tagId) { + $artwork->tags()->updateExistingPivot($tagId, ['source' => 'user', 'confidence' => null]); + } + } + }); + } + + public function updateUsageCount(Tag $tag): void + { + $count = (int) DB::table('artwork_tag')->where('tag_id', $tag->id)->count(); + $tag->forceFill(['usage_count' => $count])->save(); + } + + /** + * @param array $tags + * @return array + */ + private function normalizeUserTags(array $tags): array + { + $max = (int) config('tags.max_user_tags', 15); + if (count($tags) > $max) { + throw ValidationException::withMessages([ + 'tags' => ["Too many tags (max {$max})."], + ]); + } + + $normalized = []; + foreach ($tags as $tag) { + $value = $this->normalizer->normalize((string) $tag); + if ($value === '') { + continue; + } + $this->validateNormalizedTag($value); + $normalized[] = $value; + } + + $normalized = array_values(array_unique($normalized)); + return $normalized; + } + + private function validateNormalizedTag(string $normalized): void + { + if ($normalized === '') { + throw ValidationException::withMessages([ + 'tags' => ['Invalid tag.'], + ]); + } + + $banned = array_map('strval', (array) config('tags.banned', [])); + if ($banned !== [] && in_array($normalized, $banned, true)) { + throw ValidationException::withMessages([ + 'tags' => ['Tag is not allowed.'], + ]); + } + + $patterns = (array) config('tags.banned_regex', []); + foreach ($patterns as $pattern) { + $pattern = (string) $pattern; + if ($pattern === '') { + continue; + } + if (@preg_match($pattern, $normalized) === 1) { + throw ValidationException::withMessages([ + 'tags' => ['Tag is not allowed.'], + ]); + } + } + } + + /** + * @param array $tagIds + */ + private function incrementUsageCounts(array $tagIds): void + { + if ($tagIds === []) { + return; + } + + Tag::query()->whereIn('id', $tagIds)->increment('usage_count'); + } + + /** + * @param array $tagIds + */ + private function decrementUsageCounts(array $tagIds): void + { + if ($tagIds === []) { + return; + } + + // Never allow negative counts. + DB::table('tags') + ->whereIn('id', $tagIds) + ->update(['usage_count' => DB::raw('CASE WHEN usage_count > 0 THEN usage_count - 1 ELSE 0 END')]); + } +} diff --git a/app/Services/ThumbnailService.php b/app/Services/ThumbnailService.php index ea9d96f0..83d7dd36 100644 --- a/app/Services/ThumbnailService.php +++ b/app/Services/ThumbnailService.php @@ -5,7 +5,8 @@ use Illuminate\Support\Facades\Storage; class ThumbnailService { - protected const CDN_HOST = 'http://files.skinbase.org'; + // Use the thumbnails CDN host (HTTPS) + protected const CDN_HOST = 'https://files.skinbase.org'; protected const VALID_SIZES = ['sm','md','lg','xl']; diff --git a/app/Services/Upload/Contracts/UploadDraftServiceInterface.php b/app/Services/Upload/Contracts/UploadDraftServiceInterface.php new file mode 100644 index 00000000..db6a5ce5 --- /dev/null +++ b/app/Services/Upload/Contracts/UploadDraftServiceInterface.php @@ -0,0 +1,51 @@ + string, 'path' => string, 'meta' => array] + */ + public function createDraft(array $attributes = []): array; + + /** + * Store the main uploaded file for the draft. + * + * @param string $draftId + * @param UploadedFile $file + * @return array Metadata about stored file (path, size, mime, hash) + */ + public function storeMainFile(string $draftId, UploadedFile $file): array; + + /** + * Store a screenshot/preview image for the draft. + * + * @param string $draftId + * @param UploadedFile $file + * @return array Metadata about stored screenshot + */ + public function storeScreenshot(string $draftId, UploadedFile $file): array; + + /** + * Calculate a content hash for a local file path or storage path. + * + * @param string $filePath + * @return string + */ + public function calculateHash(string $filePath): string; + + /** + * Set an expiration timestamp for the draft. + * + * @param string $draftId + * @param \Carbon\Carbon|null $expiresAt + * @return bool + */ + public function setExpiration(string $draftId, ?\Carbon\Carbon $expiresAt = null): bool; +} diff --git a/app/Services/Upload/PreviewService.php b/app/Services/Upload/PreviewService.php new file mode 100644 index 00000000..e7d7706a --- /dev/null +++ b/app/Services/Upload/PreviewService.php @@ -0,0 +1,79 @@ +manager = extension_loaded('gd') ? ImageManager::gd() : ImageManager::imagick(); + } catch (\Throwable $e) { + $this->manager = null; + } + } + + public function generateFromImage(string $uploadId, string $sourcePath): array + { + if ($this->manager === null) { + throw new RuntimeException('PreviewService requires Intervention Image.'); + } + + $disk = Storage::disk('local'); + if (! $disk->exists($sourcePath)) { + return $this->generatePlaceholder($uploadId); + } + + $absolute = $disk->path($sourcePath); + $previewPath = "tmp/drafts/{$uploadId}/preview.webp"; + $thumbPath = "tmp/drafts/{$uploadId}/thumb.webp"; + + $preview = $this->manager->read($absolute)->scaleDown(1280, 1280); + $thumb = $this->manager->read($absolute)->cover(320, 320); + + $previewEncoded = (string) $preview->encode(new \Intervention\Image\Encoders\WebpEncoder(85)); + $thumbEncoded = (string) $thumb->encode(new \Intervention\Image\Encoders\WebpEncoder(82)); + + $disk->put($previewPath, $previewEncoded); + $disk->put($thumbPath, $thumbEncoded); + + return [ + 'preview_path' => $previewPath, + 'thumb_path' => $thumbPath, + ]; + } + + public function generateFromArchive(string $uploadId, ?string $screenshotPath = null): array + { + if ($screenshotPath !== null && Storage::disk('local')->exists($screenshotPath)) { + return $this->generateFromImage($uploadId, $screenshotPath); + } + + return $this->generatePlaceholder($uploadId); + } + + public function generatePlaceholder(string $uploadId): array + { + $disk = Storage::disk('local'); + $previewPath = "tmp/drafts/{$uploadId}/preview.webp"; + $thumbPath = "tmp/drafts/{$uploadId}/thumb.webp"; + + // 1x1 transparent webp + $tinyWebp = base64_decode('UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAAAfQ//73v/+BiOh/AAA='); + $disk->put($previewPath, $tinyWebp ?: ''); + $disk->put($thumbPath, $tinyWebp ?: ''); + + return [ + 'preview_path' => $previewPath, + 'thumb_path' => $thumbPath, + ]; + } +} diff --git a/app/Services/Upload/TagAnalysisService.php b/app/Services/Upload/TagAnalysisService.php new file mode 100644 index 00000000..ffe59f28 --- /dev/null +++ b/app/Services/Upload/TagAnalysisService.php @@ -0,0 +1,105 @@ + + */ + public function analyze(string $filename, ?string $previewPath, ?string $categoryContext): array + { + $results = []; + + foreach ($this->extractFilenameTags($filename) as $tag) { + $results[] = [ + 'tag' => $tag, + 'confidence' => 0.72, + 'source' => 'filename', + ]; + } + + if ($previewPath !== null && $previewPath !== '') { + // Stub AI output for now (real model integration can replace this later) + $results[] = [ + 'tag' => 'ai-detected', + 'confidence' => 0.66, + 'source' => 'ai', + ]; + $results[] = [ + 'tag' => 'visual-content', + 'confidence' => 0.61, + 'source' => 'ai', + ]; + } + + if ($categoryContext !== null && $categoryContext !== '') { + $normalized = $this->normalizer->normalize($categoryContext); + if ($normalized !== '') { + $results[] = [ + 'tag' => $normalized, + 'confidence' => 0.60, + 'source' => 'manual', + ]; + } + } + + return $this->dedupe($results); + } + + /** + * @return array + */ + private function extractFilenameTags(string $filename): array + { + $base = pathinfo($filename, PATHINFO_FILENAME) ?: $filename; + $parts = preg_split('/[\s._\-]+/', mb_strtolower($base, 'UTF-8')) ?: []; + + $tags = []; + foreach ($parts as $part) { + $normalized = $this->normalizer->normalize((string) $part); + if ($normalized !== '' && mb_strlen($normalized, 'UTF-8') >= 3) { + $tags[] = $normalized; + } + } + + return array_values(array_unique($tags)); + } + + /** + * @param array $rows + * @return array + */ + private function dedupe(array $rows): array + { + $best = []; + + foreach ($rows as $row) { + $tag = $this->normalizer->normalize((string) ($row['tag'] ?? '')); + if ($tag === '') { + continue; + } + + $confidence = (float) ($row['confidence'] ?? 0.0); + $source = (string) ($row['source'] ?? 'manual'); + + if (! isset($best[$tag]) || $best[$tag]['confidence'] < $confidence) { + $best[$tag] = [ + 'tag' => $tag, + 'confidence' => max(0.0, min(1.0, $confidence)), + 'source' => in_array($source, ['ai', 'filename', 'manual'], true) ? $source : 'manual', + ]; + } + } + + return array_values($best); + } +} diff --git a/app/Services/Upload/UploadDraftService.php b/app/Services/Upload/UploadDraftService.php new file mode 100644 index 00000000..80fd6c6f --- /dev/null +++ b/app/Services/Upload/UploadDraftService.php @@ -0,0 +1,191 @@ +filesystem = $filesystem; + $this->diskName = $diskName; + $this->disk = $this->filesystem->disk($this->diskName); + } + + public function createDraft(array $attributes = []): array + { + $id = (string) Str::uuid(); + $path = trim($this->basePath, '/') . '/' . $id; + + if (! $this->disk->exists($path)) { + $this->disk->makeDirectory($path); + } + + $meta = array_merge(['id' => $id, 'created_at' => Carbon::now()->toISOString()], $attributes); + + DB::table('uploads')->insert([ + 'id' => $id, + 'user_id' => (int) ($attributes['user_id'] ?? 0), + 'type' => (string) ($attributes['type'] ?? 'image'), + 'status' => 'draft', + 'moderation_status' => 'pending', + 'processing_state' => 'pending_scan', + 'expires_at' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->writeMeta($id, $meta); + + return ['id' => $id, 'path' => $path, 'meta' => $meta]; + } + + public function storeMainFile(string $draftId, UploadedFile $file): array + { + $dir = trim($this->basePath, '/') . '/' . $draftId . '/main'; + if (! $this->disk->exists($dir)) { + $this->disk->makeDirectory($dir); + } + + $filename = time() . '_' . preg_replace('/[^A-Za-z0-9_\.-]/', '_', $file->getClientOriginalName()); + $storedPath = $this->disk->putFileAs($dir, $file, $filename); + + $size = $this->safeSize($storedPath, $file); + $mime = $file->getClientMimeType() ?? $this->safeMimeType($storedPath); + $hash = $this->calculateHash($file->getRealPath() ?: $storedPath); + + $info = ['path' => $storedPath, 'size' => $size, 'mime' => $mime, 'hash' => $hash]; + + $meta = $this->readMeta($draftId); + $meta['main_file'] = $info; + $this->writeMeta($draftId, $meta); + + DB::table('upload_files')->insert([ + 'upload_id' => $draftId, + 'path' => $storedPath, + 'type' => 'main', + 'hash' => $hash, + 'size' => $size, + 'mime' => $mime, + 'created_at' => now(), + ]); + + return $info; + } + + public function storeScreenshot(string $draftId, UploadedFile $file): array + { + $dir = trim($this->basePath, '/') . '/' . $draftId . '/screenshots'; + if (! $this->disk->exists($dir)) { + $this->disk->makeDirectory($dir); + } + + $filename = time() . '_' . preg_replace('/[^A-Za-z0-9_\.-]/', '_', $file->getClientOriginalName()); + $storedPath = $this->disk->putFileAs($dir, $file, $filename); + + $size = $this->safeSize($storedPath, $file); + $mime = $file->getClientMimeType() ?? $this->safeMimeType($storedPath); + $hash = $this->calculateHash($file->getRealPath() ?: $storedPath); + + $info = ['path' => $storedPath, 'size' => $size, 'mime' => $mime, 'hash' => $hash]; + + $meta = $this->readMeta($draftId); + $meta['screenshots'][] = $info; + $this->writeMeta($draftId, $meta); + + DB::table('upload_files')->insert([ + 'upload_id' => $draftId, + 'path' => $storedPath, + 'type' => 'screenshot', + 'hash' => $hash, + 'size' => $size, + 'mime' => $mime, + 'created_at' => now(), + ]); + + return $info; + } + + public function calculateHash(string $filePath): string + { + // If path points to a local filesystem file + if (is_file($filePath)) { + return hash_file('sha256', $filePath); + } + + // If path is a storage-relative path + if ($this->disk->exists($filePath)) { + $contents = $this->disk->get($filePath); + return hash('sha256', $contents); + } + + throw new \RuntimeException('File not found for hashing: ' . $filePath); + } + + public function setExpiration(string $draftId, ?Carbon $expiresAt = null): bool + { + $meta = $this->readMeta($draftId); + $meta['expires_at'] = $expiresAt?->toISOString(); + $this->writeMeta($draftId, $meta); + + DB::table('uploads')->where('id', $draftId)->update([ + 'expires_at' => $expiresAt, + 'updated_at' => now(), + ]); + + return true; + } + + protected function metaPath(string $draftId): string + { + return trim($this->basePath, '/') . '/' . $draftId . '/meta.json'; + } + + protected function readMeta(string $draftId): array + { + $path = $this->metaPath($draftId); + if (! $this->disk->exists($path)) { + return []; + } + + $raw = $this->disk->get($path); + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : []; + } + + protected function writeMeta(string $draftId, array $meta): void + { + $path = $this->metaPath($draftId); + $this->disk->put($path, json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } + + protected function safeSize(string $storedPath, UploadedFile $file): int + { + try { + return $this->disk->size($storedPath); + } catch (\Throwable $e) { + return (int) $file->getSize(); + } + } + + protected function safeMimeType(string $storedPath): ?string + { + try { + return $this->disk->mimeType($storedPath); + } catch (\Throwable $e) { + return null; + } + } +} diff --git a/app/Services/Uploads/UploadAuditService.php b/app/Services/Uploads/UploadAuditService.php new file mode 100644 index 00000000..fe30850d --- /dev/null +++ b/app/Services/Uploads/UploadAuditService.php @@ -0,0 +1,19 @@ +repository->log($userId, $action, $ip, $meta); + } +} diff --git a/app/Services/Uploads/UploadCancelService.php b/app/Services/Uploads/UploadCancelService.php new file mode 100644 index 00000000..30edf736 --- /dev/null +++ b/app/Services/Uploads/UploadCancelService.php @@ -0,0 +1,84 @@ +sessions->getOrFail($sessionId); + + $lockSeconds = (int) config('uploads.chunk.lock_seconds', 10); + $lockWait = (int) config('uploads.chunk.lock_wait_seconds', 5); + $lock = Cache::lock('uploads:cancel:' . $sessionId, $lockSeconds); + + try { + $lock->block($lockWait); + } catch (\Throwable $e) { + $this->audit->log($userId, 'upload_cancel_locked', $ip, [ + 'session_id' => $sessionId, + ]); + throw new RuntimeException('Upload is busy. Please retry.'); + } + + try { + if (in_array($session->status, [UploadSessionStatus::CANCELLED, UploadSessionStatus::PROCESSED, UploadSessionStatus::QUARANTINED], true)) { + $this->audit->log($userId, 'upload_cancel_noop', $ip, [ + 'session_id' => $sessionId, + 'status' => $session->status, + ]); + + return [ + 'session_id' => $sessionId, + 'status' => $session->status, + ]; + } + + $this->safeDeleteTmp($session->tempPath); + + $this->sessions->updateStatus($sessionId, UploadSessionStatus::CANCELLED); + $this->sessions->updateProgress($sessionId, 0); + $this->sessions->updateFailureReason($sessionId, 'cancelled'); + + $this->audit->log($userId, 'upload_cancelled', $ip, [ + 'session_id' => $sessionId, + ]); + + return [ + 'session_id' => $sessionId, + 'status' => UploadSessionStatus::CANCELLED, + ]; + } finally { + optional($lock)->release(); + } + } + + private function safeDeleteTmp(string $path): void + { + $tmpRoot = $this->storage->sectionPath('tmp'); + $realRoot = realpath($tmpRoot); + $realPath = realpath($path); + + if (! $realRoot || ! $realPath || strpos($realPath, $realRoot) !== 0) { + return; + } + + if (File::exists($realPath)) { + File::delete($realPath); + } + } +} diff --git a/app/Services/Uploads/UploadChunkService.php b/app/Services/Uploads/UploadChunkService.php new file mode 100644 index 00000000..91d6c85d --- /dev/null +++ b/app/Services/Uploads/UploadChunkService.php @@ -0,0 +1,204 @@ +sessions->getOrFail($sessionId); + + $this->ensureTmpPath($session->tempPath); + $this->ensureWritable($session->tempPath); + $this->ensureChunkReadable($chunkPath, $chunkSize); + $this->ensureLimits($totalSize, $chunkSize); + + $lockSeconds = (int) config('uploads.chunk.lock_seconds', 10); + $lockWait = (int) config('uploads.chunk.lock_wait_seconds', 5); + $lock = Cache::lock('uploads:chunk:' . $sessionId, $lockSeconds); + + try { + $lock->block($lockWait); + } catch (\Throwable $e) { + $this->audit->log($userId, 'upload_chunk_locked', $ip, [ + 'session_id' => $sessionId, + ]); + throw new RuntimeException('Upload is busy. Please retry.'); + } + + try { + $currentSize = (int) filesize($session->tempPath); + + if ($offset > $currentSize) { + $this->audit->log($userId, 'upload_chunk_offset_mismatch', $ip, [ + 'session_id' => $sessionId, + 'offset' => $offset, + 'current_size' => $currentSize, + ]); + throw new RuntimeException('Invalid chunk offset.'); + } + + if ($offset < $currentSize) { + if ($offset + $chunkSize <= $currentSize) { + return $this->finalizeResult($sessionId, $totalSize, $currentSize); + } + + $this->audit->log($userId, 'upload_chunk_overlap', $ip, [ + 'session_id' => $sessionId, + 'offset' => $offset, + 'current_size' => $currentSize, + ]); + throw new RuntimeException('Chunk overlap detected.'); + } + + $written = $this->appendToFile($session->tempPath, $chunkPath, $offset, $chunkSize); + $newSize = $currentSize + $written; + + if ($newSize > $totalSize) { + $this->audit->log($userId, 'upload_chunk_size_exceeded', $ip, [ + 'session_id' => $sessionId, + 'new_size' => $newSize, + 'total_size' => $totalSize, + ]); + throw new RuntimeException('Upload exceeded expected size.'); + } + + $this->sessions->updateStatus($sessionId, UploadSessionStatus::TMP); + $result = $this->finalizeResult($sessionId, $totalSize, $newSize); + + $this->audit->log($userId, 'upload_chunk_appended', $ip, [ + 'session_id' => $sessionId, + 'received_bytes' => $newSize, + 'total_size' => $totalSize, + 'progress' => $result->progress, + ]); + + return $result; + } finally { + optional($lock)->release(); + } + } + + private function finalizeResult(string $sessionId, int $totalSize, int $currentSize): UploadChunkResult + { + $progress = $totalSize > 0 ? (int) floor(($currentSize / $totalSize) * 100) : 0; + $progress = min(90, max(0, $progress)); + $this->sessions->updateProgress($sessionId, $progress); + + return new UploadChunkResult( + $sessionId, + UploadSessionStatus::TMP, + $currentSize, + $totalSize, + $progress + ); + } + + private function ensureTmpPath(string $path): void + { + $tmpRoot = $this->storage->sectionPath('tmp'); + $realRoot = realpath($tmpRoot); + $realPath = realpath($path); + + if (! $realRoot || ! $realPath || strpos($realPath, $realRoot) !== 0) { + throw new RuntimeException('Invalid temp path.'); + } + } + + private function ensureWritable(string $path): void + { + if (! File::exists($path)) { + File::put($path, ''); + } + + if (! is_writable($path)) { + throw new RuntimeException('Upload path not writable.'); + } + } + + private function ensureLimits(int $totalSize, int $chunkSize): void + { + $maxBytes = (int) config('uploads.max_size_mb', 0) * 1024 * 1024; + if ($maxBytes > 0 && $totalSize > $maxBytes) { + throw new RuntimeException('Upload exceeds max size.'); + } + + $maxChunk = (int) config('uploads.chunk.max_bytes', 0); + if ($maxChunk > 0 && $chunkSize > $maxChunk) { + throw new RuntimeException('Chunk exceeds max size.'); + } + } + + private function ensureChunkReadable(string $chunkPath, int $chunkSize): void + { + $exists = is_file($chunkPath); + $readable = $exists ? is_readable($chunkPath) : false; + $actualSize = $exists ? (int) @filesize($chunkPath) : null; + + if (! $exists || ! $readable) { + logger()->warning('Upload chunk unreadable or missing', [ + 'chunk_path' => $chunkPath, + 'expected_size' => $chunkSize, + 'exists' => $exists, + 'readable' => $readable, + 'actual_size' => $actualSize, + ]); + throw new RuntimeException('Upload chunk missing.'); + } + + if ($actualSize !== $chunkSize) { + logger()->warning('Upload chunk size mismatch', [ + 'chunk_path' => $chunkPath, + 'expected_size' => $chunkSize, + 'actual_size' => $actualSize, + ]); + throw new RuntimeException('Chunk size mismatch.'); + } + } + + private function appendToFile(string $targetPath, string $chunkPath, int $offset, int $chunkSize): int + { + $in = fopen($chunkPath, 'rb'); + if (! $in) { + throw new RuntimeException('Unable to read upload chunk.'); + } + + $out = fopen($targetPath, 'c+b'); + if (! $out) { + fclose($in); + throw new RuntimeException('Unable to write upload chunk.'); + } + + if (fseek($out, $offset) !== 0) { + fclose($in); + fclose($out); + throw new RuntimeException('Failed to seek in upload file.'); + } + + $written = stream_copy_to_stream($in, $out, $chunkSize); + fflush($out); + fclose($in); + fclose($out); + + if ($written === false || (int) $written !== $chunkSize) { + throw new RuntimeException('Incomplete chunk write.'); + } + + return (int) $written; + } +} diff --git a/app/Services/Uploads/UploadDerivativesService.php b/app/Services/Uploads/UploadDerivativesService.php new file mode 100644 index 00000000..70b6c059 --- /dev/null +++ b/app/Services/Uploads/UploadDerivativesService.php @@ -0,0 +1,89 @@ +manager = extension_loaded('gd') ? ImageManager::gd() : ImageManager::imagick(); + $this->imageAvailable = true; + } catch (\Throwable $e) { + logger()->warning('Intervention Image present but configuration failed: ' . $e->getMessage()); + $this->imageAvailable = false; + $this->manager = null; + } + } + + public function storeOriginal(string $sourcePath, string $hash): string + { + $this->assertImageAvailable(); + + $dir = $this->storage->ensureHashDirectory('originals', $hash); + $target = $dir . DIRECTORY_SEPARATOR . 'orig.webp'; + $quality = (int) config('uploads.quality', 85); + + /** @var InterventionImageInterface $img */ + $img = $this->manager->read($sourcePath); + $encoder = new \Intervention\Image\Encoders\WebpEncoder($quality); + $encoded = (string) $img->encode($encoder); + File::put($target, $encoded); + + return $target; + } + + public function generatePublicDerivatives(string $sourcePath, string $hash): array + { + $this->assertImageAvailable(); + $quality = (int) config('uploads.quality', 85); + $variants = (array) config('uploads.derivatives', []); + $dir = $this->storage->publicHashDirectory($hash); + $written = []; + + foreach ($variants as $variant => $options) { + $variant = (string) $variant; + $path = $dir . DIRECTORY_SEPARATOR . $variant . '.webp'; + + /** @var InterventionImageInterface $img */ + $img = $this->manager->read($sourcePath); + + if (isset($options['size'])) { + $size = (int) $options['size']; + $out = $img->cover($size, $size); + } else { + $max = (int) ($options['max'] ?? 0); + if ($max <= 0) { + $max = 2560; + } + + $out = $img->scaleDown($max, $max); + } + + $encoder = new \Intervention\Image\Encoders\WebpEncoder($quality); + $encoded = (string) $out->encode($encoder); + File::put($path, $encoded); + $written[$variant] = $path; + } + + return $written; + } + + private function assertImageAvailable(): void + { + if (! $this->imageAvailable) { + throw new RuntimeException('Intervention Image is not available.'); + } + } +} diff --git a/app/Services/Uploads/UploadHashService.php b/app/Services/Uploads/UploadHashService.php new file mode 100644 index 00000000..040fbc17 --- /dev/null +++ b/app/Services/Uploads/UploadHashService.php @@ -0,0 +1,21 @@ +storage->ensureSection('tmp'); + $filename = Str::uuid()->toString() . '.upload'; + $tempPath = $dir . DIRECTORY_SEPARATOR . $filename; + + File::put($tempPath, ''); + + $sessionId = (string) Str::uuid(); + $session = $this->sessions->create($sessionId, $userId, $tempPath, UploadSessionStatus::INIT, $ip); + $token = $this->tokens->generate($sessionId, $userId); + + $this->audit->log($userId, 'upload_init', $ip, [ + 'session_id' => $sessionId, + ]); + + return new UploadInitResult($session->id, $token, $session->status); + } + + public function receiveToTmp(UploadedFile $file, int $userId, string $ip): UploadSessionData + { + $stored = $this->storage->storeUploadedFile($file, 'tmp'); + $sessionId = (string) Str::uuid(); + $session = $this->sessions->create($sessionId, $userId, $stored->path, UploadSessionStatus::TMP, $ip); + $this->sessions->updateProgress($sessionId, 10); + + $this->audit->log($userId, 'upload_received', $ip, [ + 'session_id' => $sessionId, + 'size' => $stored->size, + ]); + + return $session; + } + + public function validateAndHash(string $sessionId): UploadValidatedFile + { + $session = $this->sessions->getOrFail($sessionId); + $validation = $this->validator->validate($session->tempPath); + + if (! $validation->ok) { + $this->quarantine($session, $validation->reason); + return new UploadValidatedFile($validation, null); + } + + $hash = $this->hasher->hashFile($session->tempPath); + $this->sessions->updateStatus($sessionId, UploadSessionStatus::VALIDATED); + $this->sessions->updateProgress($sessionId, 30); + $this->audit->log($session->userId, 'upload_validated', $session->ip, [ + 'session_id' => $sessionId, + 'hash' => $hash, + ]); + + return new UploadValidatedFile($validation, $hash); + } + + public function scan(string $sessionId): UploadScanResult + { + $session = $this->sessions->getOrFail($sessionId); + $result = $this->scanner->scan($session->tempPath); + + if (! $result->ok) { + $this->quarantine($session, $result->reason); + return $result; + } + + $this->sessions->updateStatus($sessionId, UploadSessionStatus::SCANNED); + $this->sessions->updateProgress($sessionId, 50); + $this->audit->log($session->userId, 'upload_scanned', $session->ip, [ + 'session_id' => $sessionId, + ]); + + return $result; + } + + public function processAndPublish(string $sessionId, string $hash, int $artworkId): array + { + $session = $this->sessions->getOrFail($sessionId); + + $originalPath = $this->derivatives->storeOriginal($session->tempPath, $hash); + $originalRelative = $this->storage->sectionRelativePath('originals', $hash, 'orig.webp'); + $this->artworkFiles->upsert($artworkId, 'orig', $originalRelative, 'image/webp', (int) filesize($originalPath)); + + $publicAbsolute = $this->derivatives->generatePublicDerivatives($session->tempPath, $hash); + $publicRelative = []; + + foreach ($publicAbsolute as $variant => $absolutePath) { + $filename = $variant . '.webp'; + $relativePath = $this->storage->publicRelativePath($hash, $filename); + $this->artworkFiles->upsert($artworkId, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath)); + $publicRelative[$variant] = $relativePath; + } + + $this->sessions->updateStatus($sessionId, UploadSessionStatus::PROCESSED); + $this->sessions->updateProgress($sessionId, 100); + $this->audit->log($session->userId, 'upload_processed', $session->ip, [ + 'session_id' => $sessionId, + 'hash' => $hash, + 'artwork_id' => $artworkId, + ]); + + return [ + 'orig' => $originalRelative, + 'public' => $publicRelative, + ]; + } + + private function quarantine(UploadSessionData $session, string $reason): void + { + $newPath = $this->storage->moveToSection($session->tempPath, 'quarantine'); + $this->sessions->updateTempPath($session->id, $newPath); + $this->sessions->updateStatus($session->id, UploadSessionStatus::QUARANTINED); + $this->sessions->updateFailureReason($session->id, $reason); + $this->sessions->updateProgress($session->id, 0); + $this->audit->log($session->userId, 'upload_quarantined', $session->ip, [ + 'session_id' => $session->id, + 'reason' => $reason, + ]); + } +} diff --git a/app/Services/Uploads/UploadQuotaService.php b/app/Services/Uploads/UploadQuotaService.php new file mode 100644 index 00000000..aec8700d --- /dev/null +++ b/app/Services/Uploads/UploadQuotaService.php @@ -0,0 +1,36 @@ + 0) { + $active = $this->sessions->countActiveForUser($userId); + if ($active >= $activeLimit) { + throw new RuntimeException('Upload limit reached.'); + } + } + + $dailyLimit = (int) config('uploads.quotas.max_daily_sessions', 0); + if ($dailyLimit > 0) { + $since = CarbonImmutable::now()->startOfDay(); + $daily = $this->sessions->countForUserSince($userId, $since); + if ($daily >= $dailyLimit) { + throw new RuntimeException('Daily upload limit reached.'); + } + } + } +} diff --git a/app/Services/Uploads/UploadScanService.php b/app/Services/Uploads/UploadScanService.php new file mode 100644 index 00000000..ef2e7164 --- /dev/null +++ b/app/Services/Uploads/UploadScanService.php @@ -0,0 +1,45 @@ +buildCommand($command, $path); + $process = new Process($command); + $process->run(); + + if ($process->isSuccessful()) { + return UploadScanResult::clean(); + } + + if ($process->getExitCode() === 1) { + return UploadScanResult::infected(trim($process->getOutput())); + } + + throw new RuntimeException('Upload scan failed: ' . trim($process->getErrorOutput())); + } + + private function buildCommand(array $command, string $path): array + { + return array_map(static function (string $part) use ($path): string { + return $part === '{path}' ? $path : $part; + }, $command); + } +} diff --git a/app/Services/Uploads/UploadSessionStatus.php b/app/Services/Uploads/UploadSessionStatus.php new file mode 100644 index 00000000..6fdb91ab --- /dev/null +++ b/app/Services/Uploads/UploadSessionStatus.php @@ -0,0 +1,16 @@ +sessions->getOrFail($sessionId); + $receivedBytes = $this->safeFileSize($session->tempPath); + + return [ + 'session_id' => $session->id, + 'status' => $session->status, + 'progress' => $session->progress, + 'failure_reason' => $session->failureReason, + 'user_id' => $session->userId, + 'received_bytes' => $receivedBytes, + ]; + } + + private function safeFileSize(string $path): int + { + $tmpRoot = $this->storage->sectionPath('tmp'); + $realRoot = realpath($tmpRoot); + $realPath = realpath($path); + + if (! $realRoot || ! $realPath || strpos($realPath, $realRoot) !== 0) { + return 0; + } + + if (! File::exists($realPath)) { + return 0; + } + + return (int) File::size($realPath); + } +} diff --git a/app/Services/Uploads/UploadStorageService.php b/app/Services/Uploads/UploadStorageService.php new file mode 100644 index 00000000..3d035f3b --- /dev/null +++ b/app/Services/Uploads/UploadStorageService.php @@ -0,0 +1,136 @@ +sectionPath($section); + + if (! File::exists($path)) { + File::makeDirectory($path, 0755, true); + } + + return $path; + } + + public function storeUploadedFile(UploadedFile $file, string $section): UploadStoredFile + { + $dir = $this->ensureSection($section); + $extension = $this->safeExtension($file); + $filename = Str::uuid()->toString() . ($extension !== '' ? '.' . $extension : ''); + + $file->move($dir, $filename); + + $path = $dir . DIRECTORY_SEPARATOR . $filename; + + return UploadStoredFile::fromPath($path); + } + + public function moveToSection(string $path, string $section): string + { + if (! is_file($path)) { + throw new RuntimeException('Source file not found for move.'); + } + + $dir = $this->ensureSection($section); + $extension = (string) pathinfo($path, PATHINFO_EXTENSION); + $filename = Str::uuid()->toString() . ($extension !== '' ? '.' . $extension : ''); + $target = $dir . DIRECTORY_SEPARATOR . $filename; + + File::move($path, $target); + + return $target; + } + + public function ensureHashDirectory(string $section, string $hash): string + { + $segments = $this->hashSegments($hash); + $dir = $this->sectionPath($section) . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments); + + if (! File::exists($dir)) { + File::makeDirectory($dir, 0755, true); + } + + return $dir; + } + + public function publicHashDirectory(string $hash): string + { + $prefix = trim((string) config('uploads.public_img_prefix', 'img'), DIRECTORY_SEPARATOR); + $base = $this->sectionPath('public') . DIRECTORY_SEPARATOR . $prefix; + + if (! File::exists($base)) { + File::makeDirectory($base, 0755, true); + } + + $segments = $this->hashSegments($hash); + $dir = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments); + + if (! File::exists($dir)) { + File::makeDirectory($dir, 0755, true); + } + + return $dir; + } + + public function publicRelativePath(string $hash, string $filename): string + { + $prefix = trim((string) config('uploads.public_img_prefix', 'img'), DIRECTORY_SEPARATOR); + $segments = $this->hashSegments($hash); + + return $prefix . '/' . implode('/', $segments) . '/' . ltrim($filename, '/'); + } + + public function sectionRelativePath(string $section, string $hash, string $filename): string + { + $segments = $this->hashSegments($hash); + $section = trim($section, DIRECTORY_SEPARATOR); + + return $section . '/' . implode('/', $segments) . '/' . ltrim($filename, '/'); + } + + private function safeExtension(UploadedFile $file): string + { + $extension = (string) $file->guessExtension(); + $extension = strtolower($extension); + + return preg_match('/^[a-z0-9]+$/', $extension) ? $extension : ''; + } + + private function hashSegments(string $hash): array + { + $hash = strtolower($hash); + $hash = preg_replace('/[^a-z0-9]/', '', $hash) ?? ''; + $hash = str_pad($hash, 6, '0'); + + $segments = [ + substr($hash, 0, 2), + substr($hash, 2, 2), + substr($hash, 4, 2), + ]; + + return array_map(static fn (string $part): string => $part === '' ? '00' : $part, $segments); + } +} diff --git a/app/Services/Uploads/UploadTokenService.php b/app/Services/Uploads/UploadTokenService.php new file mode 100644 index 00000000..8f9a4aad --- /dev/null +++ b/app/Services/Uploads/UploadTokenService.php @@ -0,0 +1,36 @@ +cacheKey($token), [ + 'session_id' => $sessionId, + 'user_id' => $userId, + ], now()->addMinutes($ttl)); + + return $token; + } + + public function get(string $token): ?array + { + $data = Cache::get($this->cacheKey($token)); + + return is_array($data) ? $data : null; + } + + private function cacheKey(string $token): string + { + return 'uploads:token:' . $token; + } +} diff --git a/app/Services/Uploads/UploadValidationService.php b/app/Services/Uploads/UploadValidationService.php new file mode 100644 index 00000000..590b4829 --- /dev/null +++ b/app/Services/Uploads/UploadValidationService.php @@ -0,0 +1,112 @@ +maxSizeBytes(); + if ($maxBytes > 0 && $size > $maxBytes) { + return UploadValidationResult::fail('file_too_large', null, null, null, $size); + } + + $mime = $this->detectMime($path); + if ($mime === '' || ! in_array($mime, $this->allowedMimes(), true)) { + return UploadValidationResult::fail('mime_not_allowed', null, null, $mime, $size); + } + + $info = @getimagesize($path); + if (! $info || empty($info[0]) || empty($info[1])) { + return UploadValidationResult::fail('invalid_image', null, null, $mime, $size); + } + + $width = (int) $info[0]; + $height = (int) $info[1]; + $maxPixels = $this->maxPixels(); + if ($maxPixels > 0 && ($width > $maxPixels || $height > $maxPixels)) { + return UploadValidationResult::fail('image_too_large', $width, $height, $mime, $size); + } + + $data = @file_get_contents($path); + if ($data === false) { + return UploadValidationResult::fail('file_unreadable', $width, $height, $mime, $size); + } + + $image = @imagecreatefromstring($data); + if ($image === false) { + return UploadValidationResult::fail('decode_failed', $width, $height, $mime, $size); + } + + $reencodeOk = $this->reencodeTest($image, $mime); + imagedestroy($image); + + if (! $reencodeOk) { + return UploadValidationResult::fail('reencode_failed', $width, $height, $mime, $size); + } + + return UploadValidationResult::ok($width, $height, $mime, $size); + } + + private function maxSizeBytes(): int + { + return (int) config('uploads.max_size_mb', 0) * 1024 * 1024; + } + + private function maxPixels(): int + { + return (int) config('uploads.max_pixels', 0); + } + + private function allowedMimes(): array + { + $allowed = (array) config('uploads.allowed_mimes', []); + if ((bool) config('uploads.allow_gif', false)) { + $allowed[] = 'image/gif'; + } + + return array_values(array_unique($allowed)); + } + + private function detectMime(string $path): string + { + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $mime = $finfo->file($path); + + return $mime ? (string) $mime : ''; + } + + private function reencodeTest($image, string $mime): bool + { + ob_start(); + $result = false; + + switch ($mime) { + case 'image/jpeg': + $result = function_exists('imagejpeg') ? imagejpeg($image, null, 80) : false; + break; + case 'image/png': + $result = function_exists('imagepng') ? imagepng($image, null, 6) : false; + break; + case 'image/webp': + $result = function_exists('imagewebp') ? imagewebp($image, null, 80) : false; + break; + case 'image/gif': + $result = function_exists('imagegif') ? imagegif($image) : false; + break; + } + + $data = ob_get_clean(); + + return (bool) $result && is_string($data) && $data !== ''; + } +} diff --git a/app/Services/Vision/ArtworkEmbeddingClient.php b/app/Services/Vision/ArtworkEmbeddingClient.php new file mode 100644 index 00000000..f64b6f77 --- /dev/null +++ b/app/Services/Vision/ArtworkEmbeddingClient.php @@ -0,0 +1,95 @@ + + */ + public function embed(string $imageUrl, int $artworkId, string $sourceHash): array + { + $base = trim((string) config('vision.clip.base_url', '')); + if ($base === '') { + return []; + } + + $endpoint = (string) config('recommendations.embedding.endpoint', '/embed'); + $url = rtrim($base, '/') . '/' . ltrim($endpoint, '/'); + + $timeout = (int) config('recommendations.embedding.timeout_seconds', 8); + $connectTimeout = (int) config('recommendations.embedding.connect_timeout_seconds', 2); + $retries = (int) config('recommendations.embedding.retries', 1); + $delay = (int) config('recommendations.embedding.retry_delay_ms', 200); + + $response = Http::acceptJson() + ->connectTimeout(max(1, $connectTimeout)) + ->timeout(max(1, $timeout)) + ->retry(max(0, $retries), max(0, $delay), throw: false) + ->post($url, [ + 'image_url' => $imageUrl, + 'artwork_id' => $artworkId, + 'hash' => $sourceHash, + ]); + + if (! $response->ok()) { + return []; + } + + return $this->extractEmbedding($response->json()); + } + + /** + * @param mixed $json + * @return array + */ + private function extractEmbedding(mixed $json): array + { + $candidate = null; + + if (is_array($json) && $this->isNumericVector($json)) { + $candidate = $json; + } elseif (is_array($json) && isset($json['embedding']) && is_array($json['embedding'])) { + $candidate = $json['embedding']; + } elseif (is_array($json) && isset($json['data']['embedding']) && is_array($json['data']['embedding'])) { + $candidate = $json['data']['embedding']; + } + + if (! is_array($candidate) || ! $this->isNumericVector($candidate)) { + return []; + } + + $vector = array_map(static fn ($value): float => (float) $value, $candidate); + $dim = count($vector); + + $minDim = (int) config('recommendations.embedding.min_dim', 64); + $maxDim = (int) config('recommendations.embedding.max_dim', 4096); + if ($dim < $minDim || $dim > $maxDim) { + return []; + } + + return $vector; + } + + /** + * @param array $arr + */ + private function isNumericVector(array $arr): bool + { + if ($arr === []) { + return false; + } + + foreach ($arr as $value) { + if (! is_numeric($value)) { + return false; + } + } + + return true; + } +} diff --git a/app/Uploads/Commands/CleanupUploadsCommand.php b/app/Uploads/Commands/CleanupUploadsCommand.php new file mode 100644 index 00000000..0a00c911 --- /dev/null +++ b/app/Uploads/Commands/CleanupUploadsCommand.php @@ -0,0 +1,25 @@ +option('limit'); + $deleted = $cleanupService->cleanupStaleDrafts($limit); + + $this->info("Uploads cleanup deleted {$deleted} draft(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Uploads/Exceptions/DraftQuotaException.php b/app/Uploads/Exceptions/DraftQuotaException.php new file mode 100644 index 00000000..5ee3ed54 --- /dev/null +++ b/app/Uploads/Exceptions/DraftQuotaException.php @@ -0,0 +1,42 @@ +machineCode; + } + + public function httpStatus(): int + { + return $this->httpStatus; + } + + public static function draftLimit(): self + { + return new self('draft_limit', 429); + } + + public static function storageLimit(): self + { + return new self('storage_limit', 413); + } + + public static function duplicateUpload(): self + { + return new self('duplicate_upload', 422); + } +} diff --git a/app/Uploads/Exceptions/UploadNotFoundException.php b/app/Uploads/Exceptions/UploadNotFoundException.php new file mode 100644 index 00000000..730b7de4 --- /dev/null +++ b/app/Uploads/Exceptions/UploadNotFoundException.php @@ -0,0 +1,11 @@ +where('id', $this->uploadId)->first(); + if (! $upload) { + return; + } + + if ((string) $upload->status !== 'draft' || ! (bool) $upload->is_scanned) { + return; + } + + $this->advanceProcessingState('generating_preview', ['pending_scan', 'scanning', 'generating_preview']); + + $previewData = null; + + if ((string) $upload->type === 'image') { + $main = DB::table('upload_files') + ->where('upload_id', $this->uploadId) + ->where('type', 'main') + ->orderBy('id') + ->first(['path']); + + if (! $main || ! Storage::disk('local')->exists((string) $main->path)) { + return; + } + + $previewData = $previewService->generateFromImage($this->uploadId, (string) $main->path); + } elseif ((string) $upload->type === 'archive') { + $screenshot = DB::table('upload_files') + ->where('upload_id', $this->uploadId) + ->where('type', 'screenshot') + ->orderBy('id') + ->first(['path']); + + $previewData = $previewService->generateFromArchive($this->uploadId, $screenshot?->path ? (string) $screenshot->path : null); + } else { + return; + } + + $previewPath = (string) ($previewData['preview_path'] ?? ''); + if ($previewPath === '') { + return; + } + + DB::table('uploads')->where('id', $this->uploadId)->update([ + 'preview_path' => $previewPath, + 'updated_at' => now(), + ]); + + $this->advanceProcessingState('analyzing_tags', ['pending_scan', 'scanning', 'generating_preview', 'analyzing_tags']); + + DB::table('upload_files') + ->where('upload_id', $this->uploadId) + ->where('type', 'preview') + ->delete(); + + DB::table('upload_files')->insert([ + 'upload_id' => $this->uploadId, + 'path' => $previewPath, + 'type' => 'preview', + 'hash' => null, + 'size' => Storage::disk('local')->exists($previewPath) ? Storage::disk('local')->size($previewPath) : null, + 'mime' => 'image/webp', + 'created_at' => now(), + ]); + + TagAnalysisJob::dispatch($this->uploadId); + } + + /** + * @param array $allowedCurrentStates + */ + private function advanceProcessingState(string $targetState, array $allowedCurrentStates): void + { + DB::table('uploads') + ->where('id', $this->uploadId) + ->where('status', 'draft') + ->where(function ($query) use ($allowedCurrentStates): void { + $query->whereNull('processing_state') + ->orWhereIn('processing_state', $allowedCurrentStates); + }) + ->update([ + 'processing_state' => $targetState, + 'updated_at' => now(), + ]); + } +} diff --git a/app/Uploads/Jobs/TagAnalysisJob.php b/app/Uploads/Jobs/TagAnalysisJob.php new file mode 100644 index 00000000..0eab1265 --- /dev/null +++ b/app/Uploads/Jobs/TagAnalysisJob.php @@ -0,0 +1,127 @@ +where('id', $this->uploadId)->first(); + if (! $upload) { + return; + } + + if ((string) $upload->status !== 'draft') { + return; + } + + if (! (bool) $upload->is_scanned) { + return; + } + + if (empty($upload->preview_path)) { + return; + } + + $this->advanceProcessingState('analyzing_tags', ['pending_scan', 'scanning', 'generating_preview', 'analyzing_tags']); + + $main = DB::table('upload_files') + ->where('upload_id', $this->uploadId) + ->where('type', 'main') + ->orderBy('id') + ->first(['path']); + + $filename = $main ? basename((string) $main->path) : ''; + $categoryContext = null; + + if (! empty($upload->category_id)) { + $category = DB::table('categories')->where('id', (int) $upload->category_id)->first(['name', 'slug']); + if ($category) { + $categoryContext = (string) ($category->name ?: $category->slug); + } + } + + $tags = $analysis->analyze($filename, (string) $upload->preview_path, $categoryContext); + + DB::transaction(function () use ($tags): void { + DB::table('upload_tags')->where('upload_id', $this->uploadId)->delete(); + + foreach ($tags as $row) { + $tagName = (string) ($row['tag'] ?? ''); + if ($tagName === '') { + continue; + } + + $slug = $tagName; + $tag = DB::table('tags')->where('slug', $slug)->first(['id']); + if (! $tag) { + $tagId = DB::table('tags')->insertGetId([ + 'name' => $tagName, + 'slug' => $slug, + 'usage_count' => 0, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } else { + $tagId = (int) $tag->id; + } + + DB::table('upload_tags')->insert([ + 'upload_id' => $this->uploadId, + 'tag_id' => $tagId, + 'confidence' => (float) ($row['confidence'] ?? 0.0), + 'source' => (string) ($row['source'] ?? 'manual'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + DB::table('uploads')->where('id', $this->uploadId)->update([ + 'has_tags' => true, + 'processing_state' => DB::raw("CASE WHEN processing_state IN ('ready','published','rejected') THEN processing_state ELSE 'ready' END"), + 'updated_at' => now(), + ]); + }); + } + + /** + * @param array $allowedCurrentStates + */ + private function advanceProcessingState(string $targetState, array $allowedCurrentStates): void + { + DB::table('uploads') + ->where('id', $this->uploadId) + ->where('status', 'draft') + ->where(function ($query) use ($allowedCurrentStates): void { + $query->whereNull('processing_state') + ->orWhereIn('processing_state', $allowedCurrentStates); + }) + ->update([ + 'processing_state' => $targetState, + 'updated_at' => now(), + ]); + } +} diff --git a/app/Uploads/Jobs/VirusScanJob.php b/app/Uploads/Jobs/VirusScanJob.php new file mode 100644 index 00000000..0a4b636b --- /dev/null +++ b/app/Uploads/Jobs/VirusScanJob.php @@ -0,0 +1,93 @@ +where('id', $this->uploadId)->first(); + if (! $upload || (string) $upload->status !== 'draft') { + return; + } + + $this->advanceProcessingState('scanning', ['pending_scan', 'scanning']); + + $files = DB::table('upload_files') + ->where('upload_id', $this->uploadId) + ->whereIn('type', ['main', 'screenshot']) + ->get(['path']); + + foreach ($files as $file) { + $path = (string) ($file->path ?? ''); + if ($path === '' || ! Storage::disk('local')->exists($path)) { + continue; + } + + $absolute = Storage::disk('local')->path($path); + $result = $scanner->scan($absolute); + + if (! $result->ok) { + DB::table('uploads')->where('id', $this->uploadId)->update([ + 'status' => 'rejected', + 'processing_state' => 'rejected', + 'updated_at' => now(), + ]); + + Storage::disk('local')->deleteDirectory('tmp/drafts/' . $this->uploadId); + return; + } + } + + DB::table('uploads')->where('id', $this->uploadId)->update([ + 'is_scanned' => true, + 'updated_at' => now(), + ]); + + $this->advanceProcessingState('generating_preview', ['pending_scan', 'scanning', 'generating_preview']); + + PreviewGenerationJob::dispatch($this->uploadId); + } + + /** + * @param array $allowedCurrentStates + */ + private function advanceProcessingState(string $targetState, array $allowedCurrentStates): void + { + DB::table('uploads') + ->where('id', $this->uploadId) + ->where('status', 'draft') + ->where(function ($query) use ($allowedCurrentStates): void { + $query->whereNull('processing_state') + ->orWhereIn('processing_state', $allowedCurrentStates); + }) + ->update([ + 'processing_state' => $targetState, + 'updated_at' => now(), + ]); + } +} diff --git a/app/Uploads/Services/ArchiveInspectorService.php b/app/Uploads/Services/ArchiveInspectorService.php new file mode 100644 index 00000000..d825a0f2 --- /dev/null +++ b/app/Uploads/Services/ArchiveInspectorService.php @@ -0,0 +1,205 @@ + */ + private const BLOCKED_EXTENSIONS = [ + 'php', + 'exe', + 'sh', + 'bat', + 'js', + 'ps1', + 'cmd', + 'vbs', + 'jar', + 'elf', + ]; + + public function inspect(string $archivePath): \App\Uploads\Services\InspectionResult + { + $zip = new ZipArchive(); + $opened = $zip->open($archivePath); + + if ($opened !== true) { + throw new RuntimeException('Unable to read archive metadata.'); + } + + $files = 0; + $maxDepth = 0; + $totalUncompressed = 0; + $totalCompressed = 0; + + for ($index = 0; $index < $zip->numFiles; $index++) { + $stat = $zip->statIndex($index); + if (! is_array($stat)) { + continue; + } + + $entryName = (string) ($stat['name'] ?? ''); + $uncompressedSize = (int) ($stat['size'] ?? 0); + $compressedSize = (int) ($stat['comp_size'] ?? 0); + $isDirectory = str_ends_with($entryName, '/'); + + $pathCheck = $this->validatePath($entryName); + if ($pathCheck !== null) { + return $this->closeAndFail($zip, $pathCheck, $files, $maxDepth, $totalUncompressed, $totalCompressed); + } + + if ($this->isSymlink($zip, $index)) { + return $this->closeAndFail($zip, 'Archive contains a symlink entry.', $files, $maxDepth, $totalUncompressed, $totalCompressed); + } + + $depth = $this->depthForEntry($entryName, $isDirectory); + $maxDepth = max($maxDepth, $depth); + + if ($maxDepth > self::MAX_DEPTH) { + return $this->closeAndFail($zip, 'Archive directory depth exceeds 5.', $files, $maxDepth, $totalUncompressed, $totalCompressed); + } + + if (! $isDirectory) { + $files++; + + if ($files > self::MAX_FILES) { + return $this->closeAndFail($zip, 'Archive file count exceeds 5000.', $files, $maxDepth, $totalUncompressed, $totalCompressed); + } + + $extension = strtolower((string) pathinfo($entryName, PATHINFO_EXTENSION)); + if (in_array($extension, self::BLOCKED_EXTENSIONS, true)) { + return $this->closeAndFail($zip, 'Archive contains blocked executable/script file.', $files, $maxDepth, $totalUncompressed, $totalCompressed); + } + + $totalUncompressed += max(0, $uncompressedSize); + $totalCompressed += max(0, $compressedSize); + + if ($totalUncompressed > self::MAX_UNCOMPRESSED_BYTES) { + return $this->closeAndFail($zip, 'Archive uncompressed size exceeds 500 MB.', $files, $maxDepth, $totalUncompressed, $totalCompressed); + } + + $ratio = $this->compressionRatio($totalUncompressed, $totalCompressed); + if ($ratio > self::MAX_RATIO) { + return $this->closeAndFail($zip, 'Archive compression ratio exceeds safety threshold.', $files, $maxDepth, $totalUncompressed, $totalCompressed); + } + } + } + + $stats = $this->stats($files, $maxDepth, $totalUncompressed, $totalCompressed); + $zip->close(); + + return \App\Uploads\Services\InspectionResult::pass($stats); + } + + private function validatePath(string $entryName): ?string + { + $normalized = str_replace('\\', '/', $entryName); + + if ($normalized === '' || str_contains($normalized, "\0")) { + return 'Archive contains invalid entry path.'; + } + + if ( + strlen($entryName) >= 3 + && ctype_alpha($entryName[0]) + && $entryName[1] === ':' + && in_array($entryName[2], ['\\', '/'], true) + ) { + return 'Archive entry contains drive-letter absolute path.'; + } + + if (str_starts_with($normalized, '/') || str_starts_with($normalized, '\\')) { + return 'Archive entry contains absolute path.'; + } + + if (str_contains($entryName, '../') || str_contains($entryName, '..\\') || str_contains($normalized, '../')) { + return 'Archive entry contains path traversal sequence.'; + } + + $segments = array_filter(explode('/', trim($normalized, '/')), static fn (string $segment): bool => $segment !== ''); + foreach ($segments as $segment) { + if ($segment === '..') { + return 'Archive entry contains parent traversal segment.'; + } + } + + return null; + } + + private function isSymlink(ZipArchive $zip, int $index): bool + { + $attributes = 0; + $opsys = 0; + + if (! $zip->getExternalAttributesIndex($index, $opsys, $attributes)) { + return false; + } + + if ($opsys !== ZipArchive::OPSYS_UNIX) { + return false; + } + + $mode = ($attributes >> 16) & 0xF000; + + return $mode === 0xA000; + } + + private function depthForEntry(string $entryName, bool $isDirectory): int + { + $normalized = trim(str_replace('\\', '/', $entryName), '/'); + if ($normalized === '') { + return 0; + } + + $segments = array_values(array_filter(explode('/', $normalized), static fn (string $segment): bool => $segment !== '')); + if ($segments === []) { + return 0; + } + + return max(0, count($segments) - ($isDirectory ? 0 : 1)); + } + + private function compressionRatio(int $uncompressed, int $compressed): float + { + if ($uncompressed <= 0) { + return 0.0; + } + + if ($compressed <= 0) { + return (float) $uncompressed; + } + + return $uncompressed / $compressed; + } + + /** + * @return array{files:int,depth:int,size:int,ratio:float} + */ + private function stats(int $files, int $depth, int $size, int $compressed): array + { + return [ + 'files' => $files, + 'depth' => $depth, + 'size' => $size, + 'ratio' => $this->compressionRatio($size, $compressed), + ]; + } + + private function closeAndFail(ZipArchive $zip, string $reason, int $files, int $depth, int $size, int $compressed): \App\Uploads\Services\InspectionResult + { + $stats = $this->stats($files, $depth, $size, $compressed); + $zip->close(); + + return \App\Uploads\Services\InspectionResult::fail($reason, $stats); + } +} diff --git a/app/Uploads/Services/CleanupService.php b/app/Uploads/Services/CleanupService.php new file mode 100644 index 00000000..87e83a54 --- /dev/null +++ b/app/Uploads/Services/CleanupService.php @@ -0,0 +1,55 @@ +copy()->subDay(); + + $drafts = DB::table('uploads') + ->select(['id']) + ->where('status', 'draft') + ->where(function ($query) use ($now, $inactiveThreshold): void { + $query->where('expires_at', '<', $now) + ->orWhere(function ($inner) use ($inactiveThreshold): void { + $inner->where('updated_at', '<', $inactiveThreshold) + ->where('status', '!=', 'published'); + }); + }) + ->orderBy('updated_at') + ->limit($limit) + ->get(); + + if ($drafts->isEmpty()) { + Log::info('Upload cleanup completed', ['deleted' => 0]); + return 0; + } + + $deleted = 0; + + DB::transaction(function () use ($drafts, &$deleted): void { + foreach ($drafts as $draft) { + $uploadId = (string) $draft->id; + + DB::table('uploads')->where('id', $uploadId)->delete(); + Storage::disk('local')->deleteDirectory('tmp/drafts/' . $uploadId); + + $deleted++; + } + }); + + Log::info('Upload cleanup completed', ['deleted' => $deleted]); + + return $deleted; + } +} diff --git a/app/Uploads/Services/DraftQuotaService.php b/app/Uploads/Services/DraftQuotaService.php new file mode 100644 index 00000000..d710ea0e --- /dev/null +++ b/app/Uploads/Services/DraftQuotaService.php @@ -0,0 +1,89 @@ +, main_hash:string} $incomingFiles + * @return array + */ + public function assertCanCreateDraft(User $user, array $incomingFiles): array + { + $maxDrafts = max(1, (int) config('uploads.draft_quota.max_drafts_per_user', 10)); + $maxStorageMb = max(1, (int) config('uploads.draft_quota.max_draft_storage_mb_per_user', 1024)); + $maxStorageBytes = $maxStorageMb * 1024 * 1024; + $policy = (string) config('uploads.draft_quota.duplicate_hash_policy', 'block'); + $warnings = []; + + $draftCount = DB::table('uploads') + ->where('user_id', (int) $user->id) + ->where('status', 'draft') + ->count(); + + if ($draftCount >= $maxDrafts) { + throw DraftQuotaException::draftLimit(); + } + + $currentDraftStorage = (int) DB::table('upload_files as uf') + ->join('uploads as u', 'u.id', '=', 'uf.upload_id') + ->where('u.user_id', (int) $user->id) + ->where('u.status', 'draft') + ->sum(DB::raw('COALESCE(uf.size, 0)')); + + $incomingSize = $this->incomingSizeBytes((array) ($incomingFiles['files'] ?? [])); + + if (($currentDraftStorage + $incomingSize) > $maxStorageBytes) { + throw DraftQuotaException::storageLimit(); + } + + $mainHash = strtolower(trim((string) ($incomingFiles['main_hash'] ?? ''))); + if ($mainHash !== '' && $this->publishedMainHashExists($mainHash)) { + if ($policy === 'warn') { + $warnings[] = 'duplicate_hash'; + } else { + throw DraftQuotaException::duplicateUpload(); + } + } + + return $warnings; + } + + /** + * @param array $files + */ + private function incomingSizeBytes(array $files): int + { + $total = 0; + + foreach ($files as $file) { + if (! $file instanceof UploadedFile) { + continue; + } + + $size = $file->getSize(); + if (is_numeric($size)) { + $total += (int) $size; + } + } + + return max(0, $total); + } + + private function publishedMainHashExists(string $hash): bool + { + return DB::table('upload_files as uf') + ->join('uploads as u', 'u.id', '=', 'uf.upload_id') + ->where('uf.type', 'main') + ->where('uf.hash', $hash) + ->where('u.status', 'published') + ->exists(); + } +} diff --git a/app/Uploads/Services/FileMoveService.php b/app/Uploads/Services/FileMoveService.php new file mode 100644 index 00000000..b516043e --- /dev/null +++ b/app/Uploads/Services/FileMoveService.php @@ -0,0 +1,49 @@ +exists($sourceRelativeDir)) { + throw new RuntimeException('Draft directory not found.'); + } + + $targetRelativeDir = trim($targetRelativeDir, '/'); + $stagingRelativeDir = $targetRelativeDir . '.__staging'; + + if ($disk->exists($targetRelativeDir)) { + throw new RuntimeException('Target publish directory already exists.'); + } + + if ($disk->exists($stagingRelativeDir)) { + $disk->deleteDirectory($stagingRelativeDir); + } + + $sourceAbs = $disk->path($sourceRelativeDir); + $stagingAbs = $disk->path($stagingRelativeDir); + $targetAbs = $disk->path($targetRelativeDir); + + if (! File::copyDirectory($sourceAbs, $stagingAbs)) { + throw new RuntimeException('Failed to stage files for publish.'); + } + + if (! File::moveDirectory($stagingAbs, $targetAbs, false)) { + $disk->deleteDirectory($stagingRelativeDir); + throw new RuntimeException('Failed to move staged files to final location.'); + } + } +} diff --git a/app/Uploads/Services/InspectionResult.php b/app/Uploads/Services/InspectionResult.php new file mode 100644 index 00000000..d7a63b75 --- /dev/null +++ b/app/Uploads/Services/InspectionResult.php @@ -0,0 +1,46 @@ + $this->valid, + 'reason' => $this->reason, + 'stats' => $this->stats, + ]; + } +} diff --git a/app/Uploads/Services/PublishService.php b/app/Uploads/Services/PublishService.php new file mode 100644 index 00000000..4a3df86d --- /dev/null +++ b/app/Uploads/Services/PublishService.php @@ -0,0 +1,140 @@ +find($uploadId); + if (! $upload) { + throw new UploadNotFoundException('Upload not found.'); + } + + $this->validateBeforePublish($upload, $user); + + $mainFile = DB::table('upload_files') + ->where('upload_id', $uploadId) + ->where('type', 'main') + ->orderBy('id') + ->first(['path', 'hash']); + + if (! $mainFile || empty($mainFile->hash)) { + throw new UploadPublishValidationException('Main file hash is missing.'); + } + + $hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) $mainFile->hash)); + if ($hash === '' || strlen($hash) < 4) { + throw new UploadPublishValidationException('Invalid main file hash.'); + } + + $aa = substr($hash, 0, 2); + $bb = substr($hash, 2, 2); + $targetDir = "files/artworks/{$aa}/{$bb}/{$hash}"; + + $tempPrefix = 'tmp/drafts/' . $uploadId . '/'; + $promoted = false; + + try { + DB::beginTransaction(); + + $this->fileMoveService->promoteDraft($uploadId, $targetDir); + $promoted = true; + + $files = DB::table('upload_files')->where('upload_id', $uploadId)->get(['id', 'path']); + foreach ($files as $file) { + $oldPath = (string) $file->path; + if (str_starts_with($oldPath, $tempPrefix)) { + $newPath = $targetDir . '/' . ltrim(substr($oldPath, strlen($tempPrefix)), '/'); + DB::table('upload_files')->where('id', $file->id)->update(['path' => $newPath]); + } + } + + $upload->status = 'published'; + $upload->processing_state = 'published'; + if (empty($upload->slug)) { + $upload->slug = $this->slugService->makeSlug((string) ($upload->title ?? '')); + } + $upload->published_at = now(); + $upload->final_path = $targetDir; + $upload->save(); + + DB::commit(); + } catch (\Throwable $e) { + DB::rollBack(); + + if ($promoted) { + Storage::disk('local')->deleteDirectory($targetDir); + } + + throw $e; + } + + Storage::disk('local')->deleteDirectory('tmp/drafts/' . $uploadId); + + return Upload::query()->findOrFail($uploadId); + } + + private function validateBeforePublish(Upload $upload, User $user): void + { + if ((int) $upload->user_id !== (int) $user->id) { + throw new UploadOwnershipException('You do not own this upload.'); + } + + $role = strtolower((string) ($user->role ?? '')); + $isAdmin = $role === 'admin'; + + if (! $isAdmin && (string) ($upload->moderation_status ?? 'pending') !== 'approved') { + throw new UploadPublishValidationException('Upload requires moderation approval before publish.'); + } + + if ((string) $upload->status !== 'draft') { + throw new UploadPublishValidationException('Only draft uploads can be published.'); + } + + if (! (bool) $upload->is_scanned) { + throw new UploadPublishValidationException('Upload must be scanned before publish.'); + } + + if (empty($upload->preview_path)) { + throw new UploadPublishValidationException('Preview is required before publish.'); + } + + if (! (bool) $upload->has_tags) { + throw new UploadPublishValidationException('Tag analysis must complete before publish.'); + } + + if (empty($upload->title) || empty($upload->category_id)) { + throw new UploadPublishValidationException('Title and category are required before publish.'); + } + + if ((string) $upload->type === 'archive') { + $hasScreenshot = DB::table('upload_files') + ->where('upload_id', (string) $upload->id) + ->where('type', 'screenshot') + ->exists(); + + if (! $hasScreenshot) { + throw new UploadPublishValidationException('Archive uploads require at least one screenshot.'); + } + } + } +} diff --git a/app/Uploads/Services/SlugService.php b/app/Uploads/Services/SlugService.php new file mode 100644 index 00000000..dd67d875 --- /dev/null +++ b/app/Uploads/Services/SlugService.php @@ -0,0 +1,37 @@ +publishedSlugExists($candidate)) { + $candidate = $base . '-' . $suffix; + $suffix++; + } + + return $candidate; + } + + private function publishedSlugExists(string $slug): bool + { + return DB::table('uploads') + ->where('status', 'published') + ->where('slug', $slug) + ->exists(); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c3928c57..745f6899 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,7 +12,13 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->web(append: [ + \App\Http\Middleware\HandleInertiaRequests::class, + ]); + + $middleware->alias([ + 'admin.moderation' => \App\Http\Middleware\EnsureAdminOrModerator::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index da4875cc..be0ee1f0 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "require": { "php": "^8.2", "intervention/image": "^3.11", + "inertiajs/inertia-laravel": "^1.0", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1" }, diff --git a/composer.lock b/composer.lock index 6165b65d..67542c77 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b8907494f0d247052cb415463bd9f356", + "content-hash": "2c96d87d3df9f5da68d6593ba44cb150", "packages": [ { "name": "brick/math", - "version": "0.14.1", + "version": "0.14.8", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "shasum": "" }, "require": { @@ -56,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.1" + "source": "https://github.com/brick/math/tree/0.14.8" }, "funding": [ { @@ -64,7 +64,7 @@ "type": "github" } ], - "time": "2025-11-24T14:40:29+00:00" + "time": "2026-02-10T14:33:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -1052,6 +1052,75 @@ ], "time": "2025-08-22T14:27:06+00:00" }, + { + "name": "inertiajs/inertia-laravel", + "version": "v1.3.4", + "source": { + "type": "git", + "url": "https://github.com/inertiajs/inertia-laravel.git", + "reference": "8d52a6753bead9b01a699d40bd142a72668c2a11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/8d52a6753bead9b01a699d40bd142a72668c2a11", + "reference": "8d52a6753bead9b01a699d40bd142a72668c2a11", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel/framework": "^8.74|^9.0|^10.0|^11.0|^12.0", + "php": "^7.3|~8.0.0|~8.1.0|~8.2.0|~8.3.0|~8.4.0", + "symfony/console": "^5.3|^6.0|^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.3.3", + "orchestra/testbench": "^6.45|^7.44|^8.25|^9.3|^10.0", + "phpunit/phpunit": "^8.0|^9.5.8|^10.4|^11.5" + }, + "suggest": { + "ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Inertia\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "./helpers.php" + ], + "psr-4": { + "Inertia\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonathan Reinink", + "email": "jonathan@reinink.ca", + "homepage": "https://reinink.ca" + } + ], + "description": "The Laravel adapter for Inertia.js.", + "keywords": [ + "inertia", + "laravel" + ], + "support": { + "issues": "https://github.com/inertiajs/inertia-laravel/issues", + "source": "https://github.com/inertiajs/inertia-laravel/tree/v1.3.4" + }, + "time": "2025-12-15T14:57:37+00:00" + }, { "name": "intervention/gif", "version": "4.2.4", @@ -1198,16 +1267,16 @@ }, { "name": "laravel/framework", - "version": "v12.49.0", + "version": "v12.51.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "4bde4530545111d8bdd1de6f545fa8824039fcb5" + "reference": "ce4de3feb211e47c4f959d309ccf8a2733b1bc16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/4bde4530545111d8bdd1de6f545fa8824039fcb5", - "reference": "4bde4530545111d8bdd1de6f545fa8824039fcb5", + "url": "https://api.github.com/repos/laravel/framework/zipball/ce4de3feb211e47c4f959d309ccf8a2733b1bc16", + "reference": "ce4de3feb211e47c4f959d309ccf8a2733b1bc16", "shasum": "" }, "require": { @@ -1416,34 +1485,34 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-28T03:40:49+00:00" + "time": "2026-02-10T18:20:19+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.11", + "version": "v0.3.13", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "dd2a2ed95acacbcccd32fd98dee4c946ae7a7217" + "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/dd2a2ed95acacbcccd32fd98dee4c946ae7a7217", - "reference": "dd2a2ed95acacbcccd32fd98dee4c946ae7a7217", + "url": "https://api.github.com/repos/laravel/prompts/zipball/ed8c466571b37e977532fb2fd3c272c784d7050d", + "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "ext-mbstring": "*", "php": "^8.1", - "symfony/console": "^6.2|^7.0" + "symfony/console": "^6.2|^7.0|^8.0" }, "conflict": { "illuminate/console": ">=10.17.0 <10.25.0", "laravel/framework": ">=10.17.0 <10.25.0" }, "require-dev": { - "illuminate/collections": "^10.0|^11.0|^12.0", + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.5", "pestphp/pest": "^2.3|^3.4|^4.0", "phpstan/phpstan": "^1.12.28", @@ -1473,33 +1542,33 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.11" + "source": "https://github.com/laravel/prompts/tree/v0.3.13" }, - "time": "2026-01-27T02:55:06+00:00" + "time": "2026-02-06T12:17:10+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.8", + "version": "v2.0.9", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b" + "reference": "8f631589ab07b7b52fead814965f5a800459cb3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7581a4407012f5f53365e11bafc520fd7f36bc9b", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/8f631589ab07b7b52fead814965f5a800459cb3e", + "reference": "8f631589ab07b7b52fead814965f5a800459cb3e", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.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" + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" }, "type": "library", "extra": { @@ -1536,20 +1605,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-01-08T16:22:46+00:00" + "time": "2026-02-03T06:55:34+00:00" }, { "name": "laravel/tinker", - "version": "v2.11.0", + "version": "v2.11.1", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468" + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/3d34b97c9a1747a81a3fde90482c092bd8b66468", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", "shasum": "" }, "require": { @@ -1600,9 +1669,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.11.0" + "source": "https://github.com/laravel/tinker/tree/v2.11.1" }, - "time": "2025-12-19T19:16:45+00:00" + "time": "2026-02-06T14:12:35+00:00" }, { "name": "league/commonmark", @@ -2268,16 +2337,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.0", + "version": "3.11.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "bdb375400dcd162624531666db4799b36b64e4a1" + "reference": "f438fcc98f92babee98381d399c65336f3a3827f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/bdb375400dcd162624531666db4799b36b64e4a1", - "reference": "bdb375400dcd162624531666db4799b36b64e4a1", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/f438fcc98f92babee98381d399c65336f3a3827f", + "reference": "f438fcc98f92babee98381d399c65336f3a3827f", "shasum": "" }, "require": { @@ -2301,7 +2370,7 @@ "phpstan/extension-installer": "^1.4.3", "phpstan/phpstan": "^2.1.22", "phpunit/phpunit": "^10.5.53", - "squizlabs/php_codesniffer": "^3.13.4" + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" }, "bin": [ "bin/carbon" @@ -2344,14 +2413,14 @@ } ], "description": "An API extension for DateTime that supports 281 different languages.", - "homepage": "https://carbon.nesbot.com", + "homepage": "https://carbonphp.github.io/carbon/", "keywords": [ "date", "datetime", "time" ], "support": { - "docs": "https://carbon.nesbot.com/docs", + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", "issues": "https://github.com/CarbonPHP/carbon/issues", "source": "https://github.com/CarbonPHP/carbon" }, @@ -2369,20 +2438,20 @@ "type": "tidelift" } ], - "time": "2025-12-02T21:04:28+00:00" + "time": "2026-01-29T09:26:29+00:00" }, { "name": "nette/schema", - "version": "v1.3.3", + "version": "v1.3.4", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" + "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", + "url": "https://api.github.com/repos/nette/schema/zipball/086497a2f34b82fede9b5a41cc8e131d087cd8f7", + "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7", "shasum": "" }, "require": { @@ -2390,8 +2459,8 @@ "php": "8.1 - 8.5" }, "require-dev": { - "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^2.0@stable", + "nette/tester": "^2.6", + "phpstan/phpstan": "^2.0@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -2432,22 +2501,22 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.3" + "source": "https://github.com/nette/schema/tree/v1.3.4" }, - "time": "2025-10-30T22:57:59+00:00" + "time": "2026-02-08T02:54:00+00:00" }, { "name": "nette/utils", - "version": "v4.1.1", + "version": "v4.1.2", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72" + "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/c99059c0315591f1a0db7ad6002000288ab8dc72", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72", + "url": "https://api.github.com/repos/nette/utils/zipball/f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", + "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", "shasum": "" }, "require": { @@ -2460,7 +2529,7 @@ "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", "nette/tester": "^2.5", - "phpstan/phpstan-nette": "^2.0@stable", + "phpstan/phpstan": "^2.0@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -2521,9 +2590,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.1" + "source": "https://github.com/nette/utils/tree/v4.1.2" }, - "time": "2025-12-22T12:14:32+00:00" + "time": "2026-02-03T17:21:09+00:00" }, { "name": "nikic/php-parser", @@ -3159,16 +3228,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.18", + "version": "v0.12.20", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196" + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196", - "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373", + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373", "shasum": "" }, "require": { @@ -3232,9 +3301,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.18" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.20" }, - "time": "2025-12-17T14:35:46+00:00" + "time": "2026-02-11T15:05:28+00:00" }, { "name": "ralouphie/getallheaders", @@ -6143,16 +6212,16 @@ "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.16.1", + "version": "v7.17.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b" + "reference": "53cb90a6aa3ef3840458781600628ade058a18b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/53cb90a6aa3ef3840458781600628ade058a18b9", + "reference": "53cb90a6aa3ef3840458781600628ade058a18b9", "shasum": "" }, "require": { @@ -6166,7 +6235,7 @@ "phpunit/php-code-coverage": "^12.5.2", "phpunit/php-file-iterator": "^6", "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.5.4", + "phpunit/phpunit": "^12.5.8", "sebastian/environment": "^8.0.3", "symfony/console": "^7.3.4 || ^8.0.0", "symfony/process": "^7.3.4 || ^8.0.0" @@ -6176,10 +6245,10 @@ "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan": "^2.1.38", "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.11", - "phpstan/phpstan-strict-rules": "^2.0.7", + "phpstan/phpstan-phpunit": "^2.0.12", + "phpstan/phpstan-strict-rules": "^2.0.8", "symfony/filesystem": "^7.3.2 || ^8.0.0" }, "bin": [ @@ -6220,7 +6289,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.16.1" + "source": "https://github.com/paratestphp/paratest/tree/v7.17.0" }, "funding": [ { @@ -6232,33 +6301,33 @@ "type": "paypal" } ], - "time": "2026-01-08T07:23:06+00:00" + "time": "2026-02-05T09:14:44+00:00" }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -6278,9 +6347,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "fakerphp/faker", @@ -6692,16 +6761,16 @@ }, { "name": "laravel/boost", - "version": "v2.0.4", + "version": "v2.1.2", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "6f7a9f70c1b2cc5fcef1585e8aa04b8546f150e9" + "reference": "81ecf79e82c979efd92afaeac012605cc7b2f31f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/6f7a9f70c1b2cc5fcef1585e8aa04b8546f150e9", - "reference": "6f7a9f70c1b2cc5fcef1585e8aa04b8546f150e9", + "url": "https://api.github.com/repos/laravel/boost/zipball/81ecf79e82c979efd92afaeac012605cc7b2f31f", + "reference": "81ecf79e82c979efd92afaeac012605cc7b2f31f", "shasum": "" }, "require": { @@ -6754,7 +6823,7 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-01-28T13:53:50+00:00" + "time": "2026-02-10T17:40:45+00:00" }, { "name": "laravel/breeze", @@ -6819,35 +6888,35 @@ }, { "name": "laravel/mcp", - "version": "v0.5.3", + "version": "v0.5.6", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "39b9791b989927642137dd5b55dde0529f1614f9" + "reference": "87905978bf2a230d6c01f8d03e172249e37917f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/39b9791b989927642137dd5b55dde0529f1614f9", - "reference": "39b9791b989927642137dd5b55dde0529f1614f9", + "url": "https://api.github.com/repos/laravel/mcp/zipball/87905978bf2a230d6c01f8d03e172249e37917f7", + "reference": "87905978bf2a230d6c01f8d03e172249e37917f7", "shasum": "" }, "require": { "ext-json": "*", "ext-mbstring": "*", - "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/container": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/http": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/json-schema": "^12.41.1", - "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/validation": "^10.49.0|^11.45.3|^12.41.1", - "php": "^8.1" + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2" }, "require-dev": { "laravel/pint": "^1.20", - "orchestra/testbench": "^8.36|^9.15|^10.8", - "pestphp/pest": "^2.36.0|^3.8.4|^4.1.0", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", "phpstan/phpstan": "^2.1.27", "rector/rector": "^2.2.4" }, @@ -6888,41 +6957,42 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2026-01-26T10:25:21+00:00" + "time": "2026-02-09T22:08:43+00:00" }, { "name": "laravel/pail", - "version": "v1.2.4", + "version": "v1.2.6", "source": { "type": "git", "url": "https://github.com/laravel/pail.git", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30" + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/49f92285ff5d6fc09816e976a004f8dec6a0ea30", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30", + "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", "shasum": "" }, "require": { "ext-mbstring": "*", - "illuminate/console": "^10.24|^11.0|^12.0", - "illuminate/contracts": "^10.24|^11.0|^12.0", - "illuminate/log": "^10.24|^11.0|^12.0", - "illuminate/process": "^10.24|^11.0|^12.0", - "illuminate/support": "^10.24|^11.0|^12.0", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", "nunomaduro/termwind": "^1.15|^2.0", "php": "^8.2", - "symfony/console": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0" }, "require-dev": { - "laravel/framework": "^10.24|^11.0|^12.0", + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", "laravel/pint": "^1.13", - "orchestra/testbench-core": "^8.13|^9.17|^10.8", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", "pestphp/pest": "^2.20|^3.0|^4.0", "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", "phpstan/phpstan": "^1.12.27", - "symfony/var-dumper": "^6.3|^7.0" + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" }, "type": "library", "extra": { @@ -6967,20 +7037,20 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2025-11-20T16:29:35+00:00" + "time": "2026-02-09T13:44:54+00:00" }, { "name": "laravel/pint", - "version": "v1.27.0", + "version": "v1.27.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90" + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90", + "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5", + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5", "shasum": "" }, "require": { @@ -6991,13 +7061,13 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.92.4", - "illuminate/view": "^12.44.0", - "larastan/larastan": "^3.8.1", - "laravel-zero/framework": "^12.0.4", + "friendsofphp/php-cs-fixer": "^3.93.1", + "illuminate/view": "^12.51.0", + "larastan/larastan": "^3.9.2", + "laravel-zero/framework": "^12.0.5", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.4" + "pestphp/pest": "^3.8.5" }, "bin": [ "builds/pint" @@ -7034,7 +7104,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-01-05T16:49:17+00:00" + "time": "2026-02-10T20:00:20+00:00" }, { "name": "laravel/roster", @@ -7099,28 +7169,28 @@ }, { "name": "laravel/sail", - "version": "v1.52.0", + "version": "v1.53.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3" + "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/64ac7d8abb2dbcf2b76e61289451bae79066b0b3", - "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3", + "url": "https://api.github.com/repos/laravel/sail/zipball/e340eaa2bea9b99192570c48ed837155dbf24fbb", + "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb", "shasum": "" }, "require": { - "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0", - "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0", - "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0", + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0", "php": "^8.0", - "symfony/console": "^6.0|^7.0", - "symfony/yaml": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/yaml": "^6.0|^7.0|^8.0" }, "require-dev": { - "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", "phpstan/phpstan": "^2.0" }, "bin": [ @@ -7158,7 +7228,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-01-01T02:46:03+00:00" + "time": "2026-02-06T12:16:02+00:00" }, { "name": "mockery/mockery", @@ -8374,16 +8444,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "12.5.2", + "version": "12.5.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", "shasum": "" }, "require": { @@ -8439,7 +8509,7 @@ "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/12.5.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" }, "funding": [ { @@ -8459,20 +8529,20 @@ "type": "tidelift" } ], - "time": "2025-12-24T07:03:04+00:00" + "time": "2026-02-06T06:01:44+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "6.0.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782" + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/961bc913d42fe24a257bfff826a5068079ac7782", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", "shasum": "" }, "require": { @@ -8512,15 +8582,27 @@ "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/6.0.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.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/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:37+00:00" + "time": "2026-02-02T14:04:18+00:00" }, { "name": "phpunit/php-invoker", @@ -9838,24 +9920,24 @@ }, { "name": "ta-tikoma/phpunit-architecture-test", - "version": "0.8.5", + "version": "0.8.6", "source": { "type": "git", "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", - "reference": "cf6fb197b676ba716837c886baca842e4db29005" + "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/cf6fb197b676ba716837c886baca842e4db29005", - "reference": "cf6fb197b676ba716837c886baca842e4db29005", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/ad48430b92901fd7d003fdaf2d7b139f96c0906e", + "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e", "shasum": "" }, "require": { "nikic/php-parser": "^4.18.0 || ^5.0.0", "php": "^8.1.0", - "phpdocumentor/reflection-docblock": "^5.3.0", - "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", - "symfony/finder": "^6.4.0 || ^7.0.0" + "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", + "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "require-dev": { "laravel/pint": "^1.13.7", @@ -9891,9 +9973,9 @@ ], "support": { "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", - "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.5" + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.6" }, - "time": "2025-04-20T20:23:40+00:00" + "time": "2026-01-30T07:16:00+00:00" }, { "name": "theseer/tokenizer", diff --git a/config/cdn.php b/config/cdn.php new file mode 100644 index 00000000..b1b1cd0c --- /dev/null +++ b/config/cdn.php @@ -0,0 +1,7 @@ + env('FILES_CDN_URL', 'https://files.skinbase.org'), +]; diff --git a/config/discovery.php b/config/discovery.php new file mode 100644 index 00000000..4179272e --- /dev/null +++ b/config/discovery.php @@ -0,0 +1,108 @@ + env('DISCOVERY_QUEUE', env('RECOMMENDATIONS_QUEUE', env('VISION_QUEUE', 'default'))), + + // Versioned from day one for safe future migrations/experiments. + 'profile_version' => env('DISCOVERY_PROFILE_VERSION', 'profile-v1'), + 'event_version' => env('DISCOVERY_EVENT_VERSION', 'event-v1'), + 'algo_version' => env('DISCOVERY_ALGO_VERSION', env('RECOMMENDATIONS_ALGO_VERSION', 'clip-cosine-v1')), + 'cache_version' => env('DISCOVERY_CACHE_VERSION', 'cache-v1'), + + 'decay' => [ + // Exponential half-life: score * 0.5 every N hours. + 'half_life_hours' => (float) env('DISCOVERY_DECAY_HALF_LIFE_HOURS', 72), + ], + + // Baseline event contribution weights. + 'weights' => [ + 'view' => (float) env('DISCOVERY_WEIGHT_VIEW', 1.0), + 'click' => (float) env('DISCOVERY_WEIGHT_CLICK', 2.0), + 'favorite' => (float) env('DISCOVERY_WEIGHT_FAVORITE', 4.0), + 'download' => (float) env('DISCOVERY_WEIGHT_DOWNLOAD', 3.0), + ], + + // Recommendation cache TTL in minutes (schema foundation only for now). + 'cache_ttl_minutes' => (int) env('DISCOVERY_CACHE_TTL_MINUTES', 60), + + // Phase 8B: versioned ranking blend weights. + // Blend components: w1=interest, w2=recency, w3=popularity, w4=novelty. + 'ranking' => [ + 'default_weights' => [ + 'version' => env('DISCOVERY_RANKING_WEIGHTS_VERSION', 'rank-w-v1'), + 'w1' => (float) env('DISCOVERY_RANKING_W1', 0.65), + 'w2' => (float) env('DISCOVERY_RANKING_W2', 0.20), + 'w3' => (float) env('DISCOVERY_RANKING_W3', 0.10), + 'w4' => (float) env('DISCOVERY_RANKING_W4', 0.05), + ], + + // Per-algo overrides for safe rollout by algo_version. + 'algo_weight_sets' => [ + 'clip-cosine-v1' => [ + 'version' => env('DISCOVERY_RANKING_WEIGHTS_VERSION_CLIP_COSINE_V1', 'rank-w-v1'), + 'w1' => (float) env('DISCOVERY_RANKING_W1_CLIP_COSINE_V1', 0.65), + 'w2' => (float) env('DISCOVERY_RANKING_W2_CLIP_COSINE_V1', 0.20), + 'w3' => (float) env('DISCOVERY_RANKING_W3_CLIP_COSINE_V1', 0.10), + 'w4' => (float) env('DISCOVERY_RANKING_W4_CLIP_COSINE_V1', 0.05), + ], + 'clip-cosine-v2' => [ + 'version' => env('DISCOVERY_RANKING_WEIGHTS_VERSION_CLIP_COSINE_V2', 'rank-w-v2-prod-1'), + 'w1' => (float) env('DISCOVERY_RANKING_W1_CLIP_COSINE_V2', 0.52), + 'w2' => (float) env('DISCOVERY_RANKING_W2_CLIP_COSINE_V2', 0.23), + 'w3' => (float) env('DISCOVERY_RANKING_W3_CLIP_COSINE_V2', 0.15), + 'w4' => (float) env('DISCOVERY_RANKING_W4_CLIP_COSINE_V2', 0.10), + ], + ], + ], + + // Phase 8 production rollout gates (deterministic user bucketing). + 'rollout' => [ + 'enabled' => (bool) env('DISCOVERY_ROLLOUT_ENABLED', false), + 'baseline_algo_version' => env('DISCOVERY_ROLLOUT_BASELINE_ALGO_VERSION', 'clip-cosine-v1'), + 'candidate_algo_version' => env('DISCOVERY_ROLLOUT_CANDIDATE_ALGO_VERSION', 'clip-cosine-v2'), + + // One of: g10, g50, g100. + 'active_gate' => env('DISCOVERY_ROLLOUT_ACTIVE_GATE', 'g10'), + 'gates' => [ + 'g10' => [ + 'name' => '10%', + 'percentage' => (int) env('DISCOVERY_ROLLOUT_GATE_10_PERCENT', 10), + ], + 'g50' => [ + 'name' => '50%', + 'percentage' => (int) env('DISCOVERY_ROLLOUT_GATE_50_PERCENT', 50), + ], + 'g100' => [ + 'name' => '100%', + 'percentage' => (int) env('DISCOVERY_ROLLOUT_GATE_100_PERCENT', 100), + ], + ], + + // Emergency rollback toggle: force all traffic to one algo_version. + 'force_algo_version' => env('DISCOVERY_FORCE_ALGO_VERSION', ''), + + // Guardrails (used operationally in runbook and dashboards). + 'monitoring_thresholds' => [ + 'ctr_warn_drop_pct' => (float) env('DISCOVERY_ROLLOUT_WARN_CTR_DROP_PCT', 3.0), + 'ctr_rollback_drop_pct' => (float) env('DISCOVERY_ROLLOUT_ROLLBACK_CTR_DROP_PCT', 5.0), + 'long_dwell_warn_drop_pct' => (float) env('DISCOVERY_ROLLOUT_WARN_LONG_DWELL_DROP_PCT', 4.0), + 'long_dwell_rollback_drop_pct' => (float) env('DISCOVERY_ROLLOUT_ROLLBACK_LONG_DWELL_DROP_PCT', 8.0), + 'diversity_warn_concentration_rise_pct' => (float) env('DISCOVERY_ROLLOUT_WARN_DIVERSITY_CONCENTRATION_RISE_PCT', 10.0), + 'diversity_rollback_concentration_rise_pct' => (float) env('DISCOVERY_ROLLOUT_ROLLBACK_DIVERSITY_CONCENTRATION_RISE_PCT', 15.0), + ], + ], + + // Offline evaluation objective weights (manual/data-driven tuning). + 'evaluation' => [ + 'objective_weights' => [ + 'ctr' => (float) env('DISCOVERY_EVAL_WEIGHT_CTR', 0.45), + 'save_rate' => (float) env('DISCOVERY_EVAL_WEIGHT_SAVE_RATE', 0.35), + 'long_dwell_share' => (float) env('DISCOVERY_EVAL_WEIGHT_LONG_DWELL', 0.25), + 'bounce_rate_penalty' => (float) env('DISCOVERY_EVAL_WEIGHT_BOUNCE_PENALTY', 0.15), + ], + // Temporary switch: keep save_rate visible but exclude it from objective score. + 'save_rate_informational' => (bool) env('DISCOVERY_EVAL_SAVE_RATE_INFORMATIONAL', true), + ], +]; diff --git a/config/features.php b/config/features.php new file mode 100644 index 00000000..103810c7 --- /dev/null +++ b/config/features.php @@ -0,0 +1,5 @@ + (bool) env('SKINBASE_UPLOADS_V2', true), +]; diff --git a/config/recommendations.php b/config/recommendations.php new file mode 100644 index 00000000..0480d084 --- /dev/null +++ b/config/recommendations.php @@ -0,0 +1,37 @@ + env('RECOMMENDATIONS_QUEUE', env('VISION_QUEUE', 'default')), + + 'embedding' => [ + 'enabled' => env('RECOMMENDATIONS_EMBEDDING_ENABLED', true), + 'model' => env('RECOMMENDATIONS_EMBEDDING_MODEL', 'clip'), + 'model_version' => env('RECOMMENDATIONS_EMBEDDING_MODEL_VERSION', 'v1'), + 'algo_version' => env('RECOMMENDATIONS_ALGO_VERSION', 'clip-cosine-v1'), + + // Preferred CLIP endpoint for embeddings. The service also accepts an embedding payload from the analyze endpoint response. + 'endpoint' => env('CLIP_EMBED_ENDPOINT', '/embed'), + 'timeout_seconds' => (int) env('CLIP_EMBED_TIMEOUT_SECONDS', 8), + 'connect_timeout_seconds' => (int) env('CLIP_EMBED_CONNECT_TIMEOUT_SECONDS', 2), + 'retries' => (int) env('CLIP_EMBED_HTTP_RETRIES', 1), + 'retry_delay_ms' => (int) env('CLIP_EMBED_HTTP_RETRY_DELAY_MS', 200), + + // Guardrails for malformed service responses. + 'min_dim' => (int) env('RECOMMENDATIONS_MIN_DIM', 64), + 'max_dim' => (int) env('RECOMMENDATIONS_MAX_DIM', 4096), + ], + + // Backfill chunk size for resumable queue fan-out. + 'backfill_batch_size' => (int) env('RECOMMENDATIONS_BACKFILL_BATCH', 200), + + // A/B support for recommendation ranking variants. + 'ab' => [ + 'algo_versions' => array_values(array_filter(array_map( + static fn (string $value): string => trim($value), + explode(',', (string) env('RECOMMENDATIONS_AB_ALGO_VERSIONS', env('RECOMMENDATIONS_ALGO_VERSION', 'clip-cosine-v1'))) + ))), + ], +]; diff --git a/config/services.php b/config/services.php index 6a90eb83..ca8621bd 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,8 @@ return [ ], ], + 'image' => [ + 'driver' => env('IMAGE_DRIVER', 'gd'), + ], + ]; diff --git a/config/tags.php b/config/tags.php new file mode 100644 index 00000000..6c15ce58 --- /dev/null +++ b/config/tags.php @@ -0,0 +1,28 @@ + 32, + 'max_user_tags' => 15, + + // Exact-match banned tags after normalization. + 'banned' => [ + // e.g. 'nsfw', 'hate', 'spam' + ], + + // Optional regex patterns (PCRE) to block tags. + 'banned_regex' => [ + // e.g. '/\\b(?:badword1|badword2)\\b/i' + ], +]; diff --git a/config/uploads.php b/config/uploads.php new file mode 100644 index 00000000..18516f9a --- /dev/null +++ b/config/uploads.php @@ -0,0 +1,81 @@ + env('SKINBASE_STORAGE_ROOT', storage_path('app/artworks')), + + 'paths' => [ + 'tmp' => 'tmp', + 'quarantine' => 'quarantine', + 'originals' => 'originals', + 'public' => 'public', + ], + + 'public_img_prefix' => 'img', + + 'max_size_mb' => 50, + 'max_pixels' => 12000, + + 'allowed_mimes' => [ + 'image/jpeg', + 'image/png', + 'image/webp', + ], + + 'allow_gif' => env('UPLOAD_ALLOW_GIF', false), + + 'derivatives' => [ + 'thumb' => ['max' => 320], + 'sq' => ['size' => 512], + 'md' => ['max' => 1024], + 'lg' => ['max' => 1920], + 'xl' => ['max' => 2560], + ], + + 'quality' => 85, + + 'queue_derivatives' => env('UPLOAD_QUEUE_DERIVATIVES', false), + + 'rate_limits' => [ + 'decay_minutes' => env('UPLOAD_RATE_DECAY_MINUTES', 1), + 'init' => [ + 'per_user' => env('UPLOAD_RATE_INIT_USER', 10), + 'per_ip' => env('UPLOAD_RATE_INIT_IP', 30), + ], + 'finish' => [ + 'per_user' => env('UPLOAD_RATE_FINISH_USER', 6), + 'per_ip' => env('UPLOAD_RATE_FINISH_IP', 12), + ], + 'status' => [ + 'per_user' => env('UPLOAD_RATE_STATUS_USER', 60), + 'per_ip' => env('UPLOAD_RATE_STATUS_IP', 120), + ], + ], + + 'quotas' => [ + 'max_active_sessions' => env('UPLOAD_MAX_ACTIVE_SESSIONS', 100), + 'max_daily_sessions' => env('UPLOAD_MAX_DAILY_SESSIONS', 250), + ], + + 'draft_quota' => [ + 'max_drafts_per_user' => env('SKINBASE_MAX_DRAFTS', 10), + 'max_draft_storage_mb_per_user' => env('SKINBASE_MAX_DRAFT_STORAGE_MB', 1024), + 'duplicate_hash_policy' => env('SKINBASE_DUPLICATE_HASH_POLICY', 'block'), // block|warn + ], + + 'tokens' => [ + 'ttl_minutes' => env('UPLOAD_TOKEN_TTL_MINUTES', 60), + ], + + 'chunk' => [ + 'max_bytes' => env('UPLOAD_CHUNK_MAX_BYTES', 5242880), + 'lock_seconds' => env('UPLOAD_CHUNK_LOCK_SECONDS', 10), + 'lock_wait_seconds' => env('UPLOAD_CHUNK_LOCK_WAIT_SECONDS', 5), + ], + + 'scan' => [ + 'enabled' => env('UPLOAD_SCAN_ENABLED', false), + 'command' => env('UPLOAD_SCAN_COMMAND', []), + ], +]; diff --git a/config/vision.php b/config/vision.php new file mode 100644 index 00000000..96d8c4e6 --- /dev/null +++ b/config/vision.php @@ -0,0 +1,34 @@ + env('VISION_ENABLED', true), + + 'queue' => env('VISION_QUEUE', 'default'), + + 'clip' => [ + 'base_url' => env('CLIP_BASE_URL', ''), + 'endpoint' => env('CLIP_ANALYZE_ENDPOINT', '/analyze'), + 'timeout_seconds' => (int) env('CLIP_TIMEOUT_SECONDS', 8), + 'connect_timeout_seconds' => (int) env('CLIP_CONNECT_TIMEOUT_SECONDS', 2), + 'retries' => (int) env('CLIP_HTTP_RETRIES', 1), + 'retry_delay_ms' => (int) env('CLIP_HTTP_RETRY_DELAY_MS', 200), + ], + + 'yolo' => [ + 'enabled' => env('YOLO_ENABLED', true), + 'base_url' => env('YOLO_BASE_URL', ''), + 'endpoint' => env('YOLO_ANALYZE_ENDPOINT', '/analyze'), + 'timeout_seconds' => (int) env('YOLO_TIMEOUT_SECONDS', 8), + 'connect_timeout_seconds' => (int) env('YOLO_CONNECT_TIMEOUT_SECONDS', 2), + 'retries' => (int) env('YOLO_HTTP_RETRIES', 1), + 'retry_delay_ms' => (int) env('YOLO_HTTP_RETRY_DELAY_MS', 200), + + // Only run YOLO for photography content type. + 'photography_only' => env('YOLO_PHOTOGRAPHY_ONLY', true), + ], + + // Which derivative variant to send to vision services. + 'image_variant' => env('VISION_IMAGE_VARIANT', 'md'), +]; diff --git a/database/migrations/2026_02_08_000000_update_user_profiles_avatars.php b/database/migrations/2026_02_08_000000_update_user_profiles_avatars.php new file mode 100644 index 00000000..7df4d53d --- /dev/null +++ b/database/migrations/2026_02_08_000000_update_user_profiles_avatars.php @@ -0,0 +1,77 @@ +char('avatar_hash', 40)->nullable(); + } + if (!Schema::hasColumn('user_profiles', 'avatar_updated_at')) { + $table->dateTime('avatar_updated_at')->nullable(); + } + if (!Schema::hasColumn('user_profiles', 'avatar_mime')) { + $table->string('avatar_mime', 50)->nullable(); + } + }); + + // Attempt to rename legacy `avatar` column to `avatar_legacy` if it exists + if (Schema::hasColumn('user_profiles', 'avatar') && !Schema::hasColumn('user_profiles', 'avatar_legacy')) { + // Use DB statement to avoid requiring doctrine/dbal at runtime + try { + DB::statement('ALTER TABLE user_profiles CHANGE COLUMN `avatar` `avatar_legacy` VARCHAR(255) NULL'); + } catch (\Exception $e) { + // If the rename fails, we'll leave the legacy column as-is; migrations shouldn't break. + } + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + if (!Schema::hasTable('user_profiles')) { + return; + } + + Schema::table('user_profiles', function (Blueprint $table) { + if (Schema::hasColumn('user_profiles', 'avatar_hash')) { + $table->dropColumn('avatar_hash'); + } + if (Schema::hasColumn('user_profiles', 'avatar_updated_at')) { + $table->dropColumn('avatar_updated_at'); + } + if (Schema::hasColumn('user_profiles', 'avatar_mime')) { + $table->dropColumn('avatar_mime'); + } + }); + + // Attempt to rename back avatar_legacy to avatar + if (Schema::hasColumn('user_profiles', 'avatar_legacy') && !Schema::hasColumn('user_profiles', 'avatar')) { + try { + DB::statement('ALTER TABLE user_profiles CHANGE COLUMN `avatar_legacy` `avatar` VARCHAR(255) NULL'); + } catch (\Exception $e) { + } + } + } +} diff --git a/database/migrations/2026_02_08_000001_update_user_profiles_about_signature_description.php b/database/migrations/2026_02_08_000001_update_user_profiles_about_signature_description.php new file mode 100644 index 00000000..cef68fc7 --- /dev/null +++ b/database/migrations/2026_02_08_000001_update_user_profiles_about_signature_description.php @@ -0,0 +1,54 @@ +text('about')->nullable()->after('bio'); + } + if (! Schema::hasColumn('user_profiles', 'signature')) { + $table->text('signature')->nullable()->after('about'); + } + if (! Schema::hasColumn('user_profiles', 'description')) { + $table->text('description')->nullable()->after('signature'); + } + }); + + // Copy existing `bio` data into `about` (safe no-op if bio missing) + try { + if (Schema::hasColumn('user_profiles', 'bio') && Schema::hasColumn('user_profiles', 'about')) { + DB::statement('UPDATE `user_profiles` SET `about` = `bio` WHERE `about` IS NULL OR `about` = ""'); + } + } catch (\Throwable $e) { + // ignore copy errors + } + } + + public function down(): void + { + if (! Schema::hasTable('user_profiles')) { + return; + } + + Schema::table('user_profiles', function (Blueprint $table) { + if (Schema::hasColumn('user_profiles', 'description')) { + $table->dropColumn('description'); + } + if (Schema::hasColumn('user_profiles', 'signature')) { + $table->dropColumn('signature'); + } + // keep `about` on down migration to avoid data loss; do not drop it automatically + }); + } +}; diff --git a/database/migrations/2026_02_08_000002_enhance_user_profiles_metadata.php b/database/migrations/2026_02_08_000002_enhance_user_profiles_metadata.php new file mode 100644 index 00000000..aabdbc72 --- /dev/null +++ b/database/migrations/2026_02_08_000002_enhance_user_profiles_metadata.php @@ -0,0 +1,74 @@ +string('avatar_hash', 64)->nullable()->after('avatar'); + } + if (! Schema::hasColumn('user_profiles', 'avatar_mime')) { + $table->string('avatar_mime', 80)->nullable()->after('avatar_hash'); + } + if (! Schema::hasColumn('user_profiles', 'avatar_updated_at')) { + $table->timestamp('avatar_updated_at')->nullable()->after('avatar_mime'); + } + }); + + // Ensure `user_id` is indexed/unique and add a FK to `users(id)` when possible + try { + // Add unique index on user_id if it doesn't exist + DB::statement('ALTER TABLE `user_profiles` ADD UNIQUE INDEX `user_profiles_user_id_unique` (`user_id`)'); + } catch (\Throwable $e) { + // ignore if index exists or operation unsupported + } + + try { + // Add foreign key constraint to users.id if not present + DB::statement('ALTER TABLE `user_profiles` ADD CONSTRAINT `user_profiles_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE'); + } catch (\Throwable $e) { + // ignore if users table missing or constraint already present + } + } + + public function down(): void + { + if (! Schema::hasTable('user_profiles')) { + return; + } + + Schema::table('user_profiles', function (Blueprint $table) { + if (Schema::hasColumn('user_profiles', 'avatar_updated_at')) { + $table->dropColumn('avatar_updated_at'); + } + if (Schema::hasColumn('user_profiles', 'avatar_mime')) { + $table->dropColumn('avatar_mime'); + } + if (Schema::hasColumn('user_profiles', 'avatar_hash')) { + $table->dropColumn('avatar_hash'); + } + }); + + try { + DB::statement('ALTER TABLE `user_profiles` DROP FOREIGN KEY `user_profiles_user_id_foreign`'); + } catch (\Throwable $e) { + // ignore + } + + try { + DB::statement('ALTER TABLE `user_profiles` DROP INDEX `user_profiles_user_id_unique`'); + } catch (\Throwable $e) { + // ignore + } + } +}; diff --git a/database/migrations/2026_02_11_000001_create_uploads_sessions_table.php b/database/migrations/2026_02_11_000001_create_uploads_sessions_table.php new file mode 100644 index 00000000..e1b42a4d --- /dev/null +++ b/database/migrations/2026_02_11_000001_create_uploads_sessions_table.php @@ -0,0 +1,26 @@ +uuid('id')->primary(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('temp_path'); + $table->string('status', 32)->index(); + $table->string('ip', 64); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['user_id', 'created_at'], 'idx_uploads_sessions_user_created'); + }); + } + + public function down(): void + { + Schema::dropIfExists('uploads_sessions'); + } +}; diff --git a/database/migrations/2026_02_11_000002_create_audit_logs_table.php b/database/migrations/2026_02_11_000002_create_audit_logs_table.php new file mode 100644 index 00000000..3770dc54 --- /dev/null +++ b/database/migrations/2026_02_11_000002_create_audit_logs_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('action', 64)->index(); + $table->string('ip', 64); + $table->json('meta')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['user_id', 'created_at'], 'idx_audit_logs_user_created'); + }); + } + + public function down(): void + { + Schema::dropIfExists('audit_logs'); + } +}; diff --git a/database/migrations/2026_02_11_000003_create_artwork_files_table.php b/database/migrations/2026_02_11_000003_create_artwork_files_table.php new file mode 100644 index 00000000..4318fac3 --- /dev/null +++ b/database/migrations/2026_02_11_000003_create_artwork_files_table.php @@ -0,0 +1,27 @@ +unsignedBigInteger('artwork_id'); + $table->string('variant', 16); + $table->string('path'); + $table->string('mime', 64); + $table->unsignedBigInteger('size'); + + $table->primary(['artwork_id', 'variant']); + $table->index('variant'); + $table->foreign('artwork_id')->references('id')->on('artworks')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('artwork_files'); + } +}; diff --git a/database/migrations/2026_02_11_000004_add_progress_and_failure_to_uploads_sessions_table.php b/database/migrations/2026_02_11_000004_add_progress_and_failure_to_uploads_sessions_table.php new file mode 100644 index 00000000..418a1d5b --- /dev/null +++ b/database/migrations/2026_02_11_000004_add_progress_and_failure_to_uploads_sessions_table.php @@ -0,0 +1,22 @@ +unsignedTinyInteger('progress')->default(0)->after('ip'); + $table->string('failure_reason', 255)->nullable()->after('progress'); + }); + } + + public function down(): void + { + Schema::table('uploads_sessions', function (Blueprint $table) { + $table->dropColumn(['progress', 'failure_reason']); + }); + } +}; diff --git a/database/migrations/2026_02_12_000001_create_tags_table.php b/database/migrations/2026_02_12_000001_create_tags_table.php new file mode 100644 index 00000000..c768542f --- /dev/null +++ b/database/migrations/2026_02_12_000001_create_tags_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('name', 64); + $table->string('slug', 64)->unique(); + $table->unsignedBigInteger('usage_count')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->unique('name'); + $table->index('usage_count'); + }); + } + + public function down(): void + { + Schema::dropIfExists('tags'); + } +}; diff --git a/database/migrations/2026_02_12_000002_create_artwork_tag_table.php b/database/migrations/2026_02_12_000002_create_artwork_tag_table.php new file mode 100644 index 00000000..b96f2dda --- /dev/null +++ b/database/migrations/2026_02_12_000002_create_artwork_tag_table.php @@ -0,0 +1,31 @@ +unsignedBigInteger('artwork_id'); + $table->unsignedBigInteger('tag_id'); + $table->enum('source', ['user', 'ai', 'system']); + $table->float('confidence')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->primary(['artwork_id', 'tag_id']); + $table->index('tag_id'); + + $table->foreign('artwork_id')->references('id')->on('artworks')->cascadeOnDelete(); + $table->foreign('tag_id')->references('id')->on('tags')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('artwork_tag'); + } +}; diff --git a/database/migrations/2026_02_12_000003_create_tag_synonyms_table.php b/database/migrations/2026_02_12_000003_create_tag_synonyms_table.php new file mode 100644 index 00000000..f945f5ae --- /dev/null +++ b/database/migrations/2026_02_12_000003_create_tag_synonyms_table.php @@ -0,0 +1,26 @@ +id(); + $table->unsignedBigInteger('tag_id'); + $table->string('synonym', 64); + + $table->unique(['tag_id', 'synonym']); + $table->foreign('tag_id')->references('id')->on('tags')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('tag_synonyms'); + } +}; diff --git a/database/migrations/2026_02_12_000004_create_uploads_table.php b/database/migrations/2026_02_12_000004_create_uploads_table.php new file mode 100644 index 00000000..e03c7b84 --- /dev/null +++ b/database/migrations/2026_02_12_000004_create_uploads_table.php @@ -0,0 +1,47 @@ +uuid('id')->primary(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('type', 32)->index(); // image, archive, etc. + $table->string('status', 32)->default('draft')->index(); + $table->string('title')->nullable(); + $table->string('slug')->nullable()->unique(); + $table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete(); + $table->text('description')->nullable(); + $table->json('tags')->nullable(); + $table->string('license', 64)->nullable(); + $table->boolean('nsfw')->default(false); + $table->boolean('is_scanned')->default(false)->index(); + $table->boolean('has_tags')->default(false)->index(); + $table->string('preview_path')->nullable(); + $table->timestamp('published_at')->nullable()->index(); + $table->string('final_path')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down(): void + { + Schema::dropIfExists('uploads'); + } +}; diff --git a/database/migrations/2026_02_12_000005_create_upload_files_table.php b/database/migrations/2026_02_12_000005_create_upload_files_table.php new file mode 100644 index 00000000..be616424 --- /dev/null +++ b/database/migrations/2026_02_12_000005_create_upload_files_table.php @@ -0,0 +1,30 @@ +bigIncrements('id'); + $table->uuid('upload_id'); + $table->string('path'); + $table->string('type', 32)->index(); // main/screenshot/preview + $table->string('hash', 128)->nullable()->index(); + $table->unsignedBigInteger('size')->nullable(); + $table->string('mime')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->foreign('upload_id')->references('id')->on('uploads')->cascadeOnDelete(); + $table->index(['upload_id', 'type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('upload_files'); + } +}; diff --git a/database/migrations/2026_02_12_000006_create_upload_tags_table.php b/database/migrations/2026_02_12_000006_create_upload_tags_table.php new file mode 100644 index 00000000..0709b303 --- /dev/null +++ b/database/migrations/2026_02_12_000006_create_upload_tags_table.php @@ -0,0 +1,28 @@ +bigIncrements('id'); + $table->uuid('upload_id'); + $table->foreignId('tag_id')->constrained()->cascadeOnDelete(); + $table->decimal('confidence', 5, 4)->nullable(); + $table->string('source', 24)->default('manual')->index(); // ai|filename|manual + $table->timestamps(); + + $table->foreign('upload_id')->references('id')->on('uploads')->cascadeOnDelete(); + $table->unique(['upload_id', 'tag_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('upload_tags'); + } +}; diff --git a/database/migrations/2026_02_13_000007_add_processing_state_to_uploads_table.php b/database/migrations/2026_02_13_000007_add_processing_state_to_uploads_table.php new file mode 100644 index 00000000..080f0991 --- /dev/null +++ b/database/migrations/2026_02_13_000007_add_processing_state_to_uploads_table.php @@ -0,0 +1,31 @@ +string('processing_state', 32) + ->default('pending_scan') + ->index() + ->after('status'); + }); + + DB::table('uploads') + ->whereNull('processing_state') + ->update(['processing_state' => 'pending_scan']); + } + + public function down(): void + { + Schema::table('uploads', function (Blueprint $table) { + $table->dropIndex(['processing_state']); + $table->dropColumn('processing_state'); + }); + } +}; diff --git a/database/migrations/2026_02_13_000008_add_moderation_fields_to_uploads_table.php b/database/migrations/2026_02_13_000008_add_moderation_fields_to_uploads_table.php new file mode 100644 index 00000000..18d22c24 --- /dev/null +++ b/database/migrations/2026_02_13_000008_add_moderation_fields_to_uploads_table.php @@ -0,0 +1,43 @@ +string('moderation_status', 16)->default('pending')->index(); + $table->timestamp('moderated_at')->nullable()->index(); + $table->foreignId('moderated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->text('moderation_note')->nullable(); + }); + + DB::table('uploads') + ->where('status', 'published') + ->update([ + 'moderation_status' => 'approved', + 'updated_at' => now(), + ]); + + DB::table('uploads') + ->where('status', 'draft') + ->update([ + 'moderation_status' => 'pending', + 'updated_at' => now(), + ]); + } + + public function down(): void + { + Schema::table('uploads', function (Blueprint $table): void { + $table->dropConstrainedForeignId('moderated_by'); + $table->dropColumn('moderation_note'); + $table->dropColumn('moderated_at'); + $table->dropColumn('moderation_status'); + }); + } +}; diff --git a/database/migrations/2026_02_13_000009_create_artwork_embeddings_table.php b/database/migrations/2026_02_13_000009_create_artwork_embeddings_table.php new file mode 100644 index 00000000..2842cac0 --- /dev/null +++ b/database/migrations/2026_02_13_000009_create_artwork_embeddings_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('artwork_id')->constrained('artworks')->cascadeOnDelete(); + $table->string('model', 64); + $table->string('model_version', 64); + $table->string('algo_version', 64)->default('clip-cosine-v1')->index(); + $table->unsignedSmallInteger('dim'); + $table->longText('embedding_json'); + $table->string('source_hash', 128)->nullable()->index(); + $table->boolean('is_normalized')->default(true); + $table->timestamp('generated_at')->nullable()->index(); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->unique(['artwork_id', 'model', 'model_version'], 'artwork_embeddings_artwork_model_unique'); + $table->index(['model', 'model_version'], 'artwork_embeddings_model_lookup_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('artwork_embeddings'); + } +}; diff --git a/database/migrations/2026_02_13_000010_create_artwork_similarities_table.php b/database/migrations/2026_02_13_000010_create_artwork_similarities_table.php new file mode 100644 index 00000000..9d31beff --- /dev/null +++ b/database/migrations/2026_02_13_000010_create_artwork_similarities_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('artwork_id')->constrained('artworks')->cascadeOnDelete(); + $table->foreignId('similar_artwork_id')->constrained('artworks')->cascadeOnDelete(); + $table->string('model', 64); + $table->string('model_version', 64); + $table->string('algo_version', 64)->default('clip-cosine-v1'); + $table->unsignedSmallInteger('rank'); + $table->decimal('score', 10, 7); + $table->timestamp('generated_at')->nullable()->index(); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->unique(['artwork_id', 'similar_artwork_id', 'algo_version'], 'artwork_similarities_unique_pair_algo'); + $table->index(['artwork_id', 'algo_version', 'rank', 'score'], 'artwork_similarities_page_read_idx'); + $table->index(['similar_artwork_id', 'algo_version'], 'artwork_similarities_reverse_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('artwork_similarities'); + } +}; diff --git a/database/migrations/2026_02_14_000011_create_similar_artwork_analytics_tables.php b/database/migrations/2026_02_14_000011_create_similar_artwork_analytics_tables.php new file mode 100644 index 00000000..790d71f0 --- /dev/null +++ b/database/migrations/2026_02_14_000011_create_similar_artwork_analytics_tables.php @@ -0,0 +1,45 @@ +id(); + $table->date('event_date')->index(); + $table->string('event_type', 16)->index(); // impression|click + $table->string('algo_version', 64)->index(); + $table->foreignId('source_artwork_id')->constrained('artworks')->cascadeOnDelete(); + $table->foreignId('similar_artwork_id')->nullable()->constrained('artworks')->nullOnDelete(); + $table->unsignedSmallInteger('position')->nullable(); + $table->unsignedSmallInteger('items_count')->nullable(); + $table->timestamp('occurred_at')->nullable()->index(); + $table->timestamps(); + + $table->index(['event_date', 'algo_version', 'event_type'], 'similar_artwork_events_daily_idx'); + }); + + Schema::create('similar_artwork_daily_metrics', function (Blueprint $table): void { + $table->id(); + $table->date('metric_date'); + $table->string('algo_version', 64); + $table->unsignedInteger('impressions')->default(0); + $table->unsignedInteger('clicks')->default(0); + $table->decimal('ctr', 8, 6)->default(0); + $table->timestamps(); + + $table->unique(['metric_date', 'algo_version'], 'similar_artwork_daily_metrics_unique'); + $table->index(['metric_date', 'algo_version'], 'similar_artwork_daily_metrics_lookup_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('similar_artwork_daily_metrics'); + Schema::dropIfExists('similar_artwork_events'); + } +}; diff --git a/database/migrations/2026_02_14_000012_create_discovery_foundation_tables.php b/database/migrations/2026_02_14_000012_create_discovery_foundation_tables.php new file mode 100644 index 00000000..9b58b187 --- /dev/null +++ b/database/migrations/2026_02_14_000012_create_discovery_foundation_tables.php @@ -0,0 +1,69 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('profile_version', 32)->default('profile-v1'); + $table->string('algo_version', 64)->default('clip-cosine-v1'); + $table->json('raw_scores_json')->nullable(); + $table->json('normalized_scores_json')->nullable(); + $table->decimal('total_weight', 14, 6)->default(0); + $table->unsignedInteger('event_count')->default(0); + $table->timestamp('last_event_at')->nullable()->index(); + $table->double('half_life_hours')->default(72); + $table->uuid('updated_from_event_id')->nullable()->index(); + $table->timestamps(); + + $table->unique(['user_id', 'profile_version', 'algo_version'], 'user_interest_profiles_unique_key'); + $table->index(['algo_version', 'updated_at'], 'user_interest_profiles_algo_updated_idx'); + }); + + Schema::create('user_discovery_events', function (Blueprint $table): void { + $table->id(); + $table->uuid('event_id')->unique(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('artwork_id')->constrained('artworks')->cascadeOnDelete(); + $table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete(); + $table->string('event_type', 16)->index(); // view|click|favorite|download + $table->string('event_version', 32)->default('event-v1')->index(); + $table->string('algo_version', 64)->default('clip-cosine-v1')->index(); + $table->decimal('weight', 10, 4)->default(1.0); + $table->date('event_date')->index(); + $table->timestamp('occurred_at')->nullable()->index(); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'event_date', 'event_type'], 'user_discovery_events_user_date_type_idx'); + $table->index(['user_id', 'algo_version', 'occurred_at'], 'user_discovery_events_user_algo_time_idx'); + }); + + Schema::create('user_recommendation_cache', function (Blueprint $table): void { + $table->id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('algo_version', 64); + $table->string('cache_version', 32)->default('cache-v1'); + $table->json('recommendations_json')->nullable(); + $table->timestamp('generated_at')->nullable()->index(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + + $table->unique(['user_id', 'algo_version'], 'user_recommendation_cache_user_algo_unique'); + $table->index(['algo_version', 'expires_at'], 'user_recommendation_cache_algo_expiry_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_recommendation_cache'); + Schema::dropIfExists('user_discovery_events'); + Schema::dropIfExists('user_interest_profiles'); + } +}; diff --git a/database/migrations/2026_02_14_000013_create_feed_analytics_tables.php b/database/migrations/2026_02_14_000013_create_feed_analytics_tables.php new file mode 100644 index 00000000..3dcb4857 --- /dev/null +++ b/database/migrations/2026_02_14_000013_create_feed_analytics_tables.php @@ -0,0 +1,58 @@ +id(); + $table->date('event_date')->index(); + $table->string('event_type', 24)->index(); // feed_impression|feed_click + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('artwork_id')->constrained('artworks')->cascadeOnDelete(); + $table->unsignedSmallInteger('position')->nullable(); + $table->string('algo_version', 64)->index(); + $table->string('source', 24)->index(); // personalized|cold_start|fallback + $table->unsignedInteger('dwell_seconds')->nullable(); + $table->timestamp('occurred_at')->nullable()->index(); + $table->timestamps(); + + $table->index(['event_date', 'algo_version', 'source', 'event_type'], 'feed_events_daily_agg_idx'); + $table->index(['user_id', 'artwork_id', 'event_date'], 'feed_events_user_art_date_idx'); + }); + + Schema::create('feed_daily_metrics', function (Blueprint $table): void { + $table->id(); + $table->date('metric_date'); + $table->string('algo_version', 64); + $table->string('source', 24); + + $table->unsignedInteger('impressions')->default(0); + $table->unsignedInteger('clicks')->default(0); + $table->unsignedInteger('saves')->default(0); + + $table->decimal('ctr', 8, 6)->default(0); + $table->decimal('save_rate', 8, 6)->default(0); + + $table->unsignedInteger('dwell_0_5')->default(0); + $table->unsignedInteger('dwell_5_30')->default(0); + $table->unsignedInteger('dwell_30_120')->default(0); + $table->unsignedInteger('dwell_120_plus')->default(0); + + $table->timestamps(); + + $table->unique(['metric_date', 'algo_version', 'source'], 'feed_daily_metrics_unique_idx'); + $table->index(['metric_date', 'algo_version', 'source'], 'feed_daily_metrics_lookup_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('feed_daily_metrics'); + Schema::dropIfExists('feed_events'); + } +}; diff --git a/docs/feed-rollout-runbook.md b/docs/feed-rollout-runbook.md new file mode 100644 index 00000000..4f75acd6 --- /dev/null +++ b/docs/feed-rollout-runbook.md @@ -0,0 +1,123 @@ +# Feed Rollout Runbook (clip-cosine-v2, prod set 1) + +## Scope + +- Candidate: `clip-cosine-v2` with weights `w1=0.52, w2=0.23, w3=0.15, w4=0.10` +- Baseline: `clip-cosine-v1` +- Rollout gates: `10% -> 50% -> 100%` +- Temporary policy: `save_rate` is informational only until save-event schema reliability is confirmed in production. + +## Pre-flight checks + +1. Confirm config values: + - `DISCOVERY_ROLLOUT_ENABLED=true` + - `DISCOVERY_ROLLOUT_BASELINE_ALGO_VERSION=clip-cosine-v1` + - `DISCOVERY_ROLLOUT_CANDIDATE_ALGO_VERSION=clip-cosine-v2` + - `DISCOVERY_ROLLOUT_ACTIVE_GATE=g10` + - `DISCOVERY_FORCE_ALGO_VERSION` is empty +2. Confirm candidate weights are active in `config/discovery.php` and env overrides. +3. Confirm ingestion health for discovery events: + - `event_id` populated for all new events + - `favorite` and `download` events present in `user_discovery_events` +4. Run daily aggregation: + - `php artisan analytics:aggregate-feed --date=YYYY-MM-DD` + +## Gate progression + +### Gate 1: 10% + +- Set: `DISCOVERY_ROLLOUT_ACTIVE_GATE=g10` +- Observe for at least 2-3 days with minimum sample volume. +- Required checks: + - CTR delta vs baseline + - Long-dwell-share delta vs baseline + - Diversity concentration delta vs baseline + - Save-rate trend (informational only) + +Promote to 50% only if no rollback trigger fires and no persistent warning trend is present. + +### Gate 2: 50% + +- Set: `DISCOVERY_ROLLOUT_ACTIVE_GATE=g50` +- Observe for 3-5 days with stable daily traffic. +- Apply same checks and thresholds. + +Promote to 100% only with at least 2 consecutive healthy days. + +### Gate 3: 100% + +- Set: `DISCOVERY_ROLLOUT_ACTIVE_GATE=g100` +- Keep baseline available for rapid rollback via force toggle. + +## Monitoring thresholds (candidate vs baseline) + +- CTR: + - Warning: drop >= 3% + - Rollback: drop >= 5% (or >= 10% in a single severe window) +- Long dwell share (`(dwell_30_120 + dwell_120_plus) / clicks`): + - Warning: drop >= 4% + - Rollback: drop >= 8% (or >= 12% in a single severe window) +- Diversity concentration (e.g. top-author/top-category share, near-duplicate concentration): + - Warning: rise >= 10% + - Rollback: rise >= 15% + +## Rollback actions + +### Immediate rollback (fastest) + +- Set `DISCOVERY_FORCE_ALGO_VERSION=clip-cosine-v1` +- Reload config/cache as needed in your deployment flow. +- Verify feed responses show `meta.algo_version=clip-cosine-v1`. + +### Standard rollback + +- Set `DISCOVERY_ROLLOUT_ACTIVE_GATE=g10` (or disable rollout) +- Keep candidate enabled only for controlled validation traffic. + +## Save-event schema note and fix + +Observed issue class in mixed environments: save-event writes can fail if discovery event schema differs from code expectations (e.g., `meta`/`metadata` drift, required `event_id`). + +Implemented fix path: + +- Ingestion now always writes `event_id` and inserts schema-aware metadata (`meta` if present, otherwise `metadata` if present). +- Keep `DISCOVERY_EVAL_SAVE_RATE_INFORMATIONAL=true` until production confirms stable save-event ingestion. + +Validation query examples: + +- Save events by day: + - `SELECT event_date, COUNT(*) FROM user_discovery_events WHERE event_type IN ('favorite','download') GROUP BY event_date ORDER BY event_date DESC;` +- Null/empty event id check: + - `SELECT COUNT(*) FROM user_discovery_events WHERE event_id IS NULL OR event_id = '';` + +## Daily operator checklist + +1. Run feed aggregation for the previous day. +2. Run evaluator and compare commands: + - `php artisan analytics:evaluate-feed-weights --from=YYYY-MM-DD --to=YYYY-MM-DD --json` + - `php artisan analytics:compare-feed-ab clip-cosine-v1 clip-cosine-v2 --from=YYYY-MM-DD --to=YYYY-MM-DD --json` +3. Record deltas for CTR, long_dwell_share, diversity concentration. +4. Record save_rate as informational only. +5. Decide: hold, promote gate, or rollback. + +## First 24h verification checklist + +1. Confirm rollout activation and gate state: + - `DISCOVERY_ROLLOUT_ENABLED=true` + - `DISCOVERY_ROLLOUT_ACTIVE_GATE=g10` + - `DISCOVERY_FORCE_ALGO_VERSION` empty +2. Verify both algos are receiving traffic in analytics: + - candidate (`clip-cosine-v2`) should be near 10% share (allow normal variance) + - baseline (`clip-cosine-v1`) remains dominant +3. Run aggregation/evaluation at least twice in first day (midday + end-of-day): + - `php artisan analytics:aggregate-feed --date=YYYY-MM-DD` + - `php artisan analytics:evaluate-feed-weights --from=YYYY-MM-DD --to=YYYY-MM-DD --json` + - `php artisan analytics:compare-feed-ab clip-cosine-v1 clip-cosine-v2 --from=YYYY-MM-DD --to=YYYY-MM-DD --json` +4. Check guardrails: + - CTR drop < rollback threshold + - long_dwell_share drop < rollback threshold + - diversity concentration rise < rollback threshold +5. Check save-event ingestion health: + - save events (`favorite`,`download`) are arriving in `user_discovery_events` + - `event_id` is always populated +6. If any rollback trigger is breached, apply emergency rollback preset immediately. diff --git a/docs/ui/tag-input.md b/docs/ui/tag-input.md new file mode 100644 index 00000000..6b3ee819 --- /dev/null +++ b/docs/ui/tag-input.md @@ -0,0 +1,101 @@ +# TagInput UI Component + +## Overview + +`TagInput` is the reusable tag entry component for Skinbase artwork flows. + +It is designed for: + +- Upload page +- Artwork edit page +- Admin moderation screens + +The component encapsulates all tag UX behavior (chips, search, keyboard flow, AI suggestions, status hints) so pages stay thin. + +## File Location + +- `resources/js/components/tags/TagInput.jsx` + +## Props + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `value` | `string \| string[]` | required | Controlled selected tags. Can be CSV string or array. | +| `onChange` | `(tags: string[]) => void` | required | Called whenever selected tags change. | +| `suggestedTags` | `Array` | `[]` | AI-suggested tags shown as clickable pills. | +| `disabled` | `boolean` | `false` | Disables input and interactions. | +| `maxTags` | `number` | `15` | Maximum number of selected tags. | +| `minLength` | `number` | `2` | Minimum normalized tag length. | +| `maxLength` | `number` | `32` | Maximum normalized tag length. | +| `placeholder` | `string` | `Type tags…` | Input placeholder text. | +| `searchEndpoint` | `string` | `/api/tags/search` | Search API endpoint. | +| `popularEndpoint` | `string` | `/api/tags/popular` | Popular tags endpoint when input is empty. | + +## Normalization + +Tags are normalized client-side before being added: + +- lowercase +- trim +- spaces → `-` +- remove unsupported characters +- collapse repeated separators +- max length = 32 + +Server-side normalization/validation still applies and remains authoritative. + +## Keyboard & Interaction + +- `Enter` → add tag +- `Comma` → add tag +- `Tab` → accept highlighted suggestion +- `Backspace` (empty input) → remove last tag +- `Escape` → close suggestion dropdown +- Paste CSV (`a, b, c`) → split and add valid tags + +## Accessibility + +- Suggestion dropdown uses `role="listbox"` +- Suggestions use `role="option"` +- Active item uses `aria-selected` +- Input uses `aria-expanded`, `aria-controls`, `aria-autocomplete` + +## API Usage + +The component performs debounced search (300ms): + +- `GET /api/tags/search?q=` +- `GET /api/tags/popular` (empty query) + +Behavior: + +- caches recent query results +- aborts outdated requests +- max 8 suggestions +- excludes already-selected tags +- shows non-blocking message when search fails + +## Upload Integration Example + +```jsx + { + dispatch({ type: 'SET_METADATA', payload: { tags: nextTags.join(', ') } }) + }} + suggestedTags={props.suggested_tags || []} + maxTags={15} + minLength={2} + maxLength={32} +/> +``` + +## Events & Save Strategy + +`TagInput` itself does not persist to backend on keystrokes. + +Persistence is done on save/publish boundary by page logic, e.g.: + +- `PUT /api/artworks/{id}/tags` + +This keeps UI responsive and avoids unnecessary API writes. diff --git a/docs/ui/upload-v2-rollout-runbook.md b/docs/ui/upload-v2-rollout-runbook.md new file mode 100644 index 00000000..31976777 --- /dev/null +++ b/docs/ui/upload-v2-rollout-runbook.md @@ -0,0 +1,130 @@ +# Upload UI v2 Rollout Runbook + +## Status + +- Upload UI v2 is production-ready. +- Feature flag posture: `uploads_v2` default ON. +- Emergency override remains available through `SKINBASE_UPLOADS_V2=false`. + +## Scope + +- Route: `/upload` +- UI: React/Inertia Upload Wizard v2 +- API endpoints in use: + - `POST /api/uploads/init` + - `POST /api/uploads/chunk` + - `POST /api/uploads/finish` + - `GET /api/uploads/{id}/status` + - `POST /api/uploads/{id}/publish` + - `POST /api/uploads/cancel` + +## Legacy Flow Policy + +- Current state: legacy upload flow remains in code behind feature flag branch. +- Removal decision: **scheduled removal** (not immediate deletion). +- Target window: remove legacy branch in the next hardening cycle after stable production operation. +- Suggested checkpoint gates before removal: + 1. 7 consecutive days with no Sev-1/Sev-2 upload regressions. + 2. Upload completion rate at or above pre-v2 baseline. + 3. No unresolved blockers in publish/cancel/status polling. + +## Rollout Checklist + +### 1) Staging + +- Set `SKINBASE_UPLOADS_V2=true` in staging env. +- Build and deploy current commit. +- Verify upload happy paths: + - image upload (jpg/png/webp) + - archive upload with required screenshots + - cancel in-progress upload + - publish after ready state +- Verify failure paths: + - invalid file type + - over-size files + - processing/publish API failure surfaces retry/reset correctly +- Verify analytics events emitted in browser: + - `upload_start` + - `upload_complete` + - `upload_publish` + - `upload_cancel` + - `upload_error` + +### 2) Production Enablement + +- Confirm production env has `SKINBASE_UPLOADS_V2=true` (or unset, default ON). +- Deploy release artifact. +- Run smoke tests on `/upload` with one image and one archive flow. +- Confirm endpoints respond with expected status codes under normal load. + +### 3) Post-Deploy Verification (0-24h) + +- Validate build artifact and route rendering: + - `/upload` renders v2 wizard UI + - no front-end boot errors in browser console +- Validate pipeline behavior: + - init/chunk/finish/status/publish/cancel all reachable + - status polling transitions to ready/publishable where expected +- Validate user outcomes: + - completion and publish rates are stable vs prior day baseline + - no spike in cancellation due to UI confusion + +## Post-Deploy Monitoring Plan + +### Key Metrics + +- Upload start volume (`upload_start`) +- Upload completion volume (`upload_complete`) +- Publish success volume (`upload_publish`) +- Error volume by stage (`upload_error.stage`) +- Cancel volume (`upload_cancel`) +- Derived funnel: + - start -> complete conversion + - complete -> publish conversion + - overall start -> publish conversion + +### Operational Signals + +- API error rates for `/api/uploads/*` +- p95 latency for `init`, `chunk`, `finish`, `status`, `publish` +- 4xx/5xx split by endpoint +- Client-side uncaught exceptions on `/upload` + +### Alert Thresholds (initial) + +- Critical rollback candidate: + - `upload_error` rate > 2x baseline for 15+ minutes, or + - publish failure rate > 5% sustained for 15+ minutes, or + - any endpoint 5xx rate > 3% sustained for 10+ minutes. +- Warning/observe: + - completion funnel drops > 10% vs trailing 7-day average. + +## Rollback Plan + +### Fast Toggle Rollback (preferred) + +1. Set `SKINBASE_UPLOADS_V2=false`. +2. Reload config/cache per deploy process. +3. Verify `/upload` serves legacy flow. +4. Continue API monitoring until error rates normalize. + +### Release Rollback (if needed) + +1. Roll back to prior release artifact. +2. Keep `SKINBASE_UPLOADS_V2=false` during stabilization. +3. Re-run smoke test for upload + publish. + +### Communication + +- Post incident update in release channel with: + - start time + - impact scope (upload, publish, cancel) + - rollback action taken + - follow-up issue link + +## Ownership and Next Actions + +- Owner: Upload frontend + API maintainers. +- First review checkpoint: 24h post deploy. +- Second checkpoint: 7 days post deploy for legacy removal go/no-go. +- If metrics remain healthy, create removal PR for legacy branch in `/upload` page component. diff --git a/package-lock.json b/package-lock.json index 61f68173..b7069052 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,21 +5,28 @@ "packages": { "": { "dependencies": { + "@inertiajs/core": "^1.0.4", + "@inertiajs/react": "^1.0.4", + "framer-motion": "^12.34.0", "react": "^19.2.4", "react-dom": "^19.2.4" }, "devDependencies": { "@tailwindcss/forms": "^0.5.2", "@tailwindcss/vite": "^4.0.0", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", "alpinejs": "^3.4.2", "autoprefixer": "^10.4.2", "axios": "^1.11.0", "concurrently": "^9.0.1", + "jsdom": "^25.0.1", "laravel-vite-plugin": "^2.0.0", "postcss": "^8.4.31", "sass": "^1.70.0", "tailwindcss": "^3.1.0", - "vite": "^7.0.7" + "vite": "^7.0.7", + "vitest": "^2.1.8" } }, "node_modules/@alloc/quick-lru": { @@ -35,10 +42,176 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -53,9 +226,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -70,9 +243,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -87,9 +260,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -104,9 +277,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -121,9 +294,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -138,9 +311,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -155,9 +328,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -172,9 +345,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -189,9 +362,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -206,9 +379,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -223,9 +396,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -240,9 +413,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -257,9 +430,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -274,9 +447,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -291,9 +464,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -308,9 +481,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -325,9 +498,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -342,9 +515,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -359,9 +532,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -376,9 +549,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -393,9 +566,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], @@ -410,9 +583,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -427,9 +600,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -444,9 +617,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -461,9 +634,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -477,6 +650,31 @@ "node": ">=18" } }, + "node_modules/@inertiajs/core": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-1.3.0.tgz", + "integrity": "sha512-TJ8R1eUYY473m9DaKlCPRdHTdznFWTDuy5VvEzXg3t/hohbDQedLj46yn/uAqziJPEUZJrSftZzPI2NMzL9tQA==", + "license": "MIT", + "dependencies": { + "axios": "^1.6.0", + "deepmerge": "^4.0.0", + "nprogress": "^0.2.0", + "qs": "^6.9.0" + } + }, + "node_modules/@inertiajs/react": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@inertiajs/react/-/react-1.3.0.tgz", + "integrity": "sha512-K+PF23xP6jjMkubs8PbxT1MroSDdH1z3VTEGbO3685Xyf0QNwoNIF95hnyqJxlWaeG4fB0GAag40gh04fefRUA==", + "license": "MIT", + "dependencies": { + "@inertiajs/core": "1.3.0", + "lodash.isequal": "^4.5.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -876,9 +1074,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz", - "integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "cpu": [ "arm" ], @@ -890,9 +1088,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz", - "integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "cpu": [ "arm64" ], @@ -904,9 +1102,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz", - "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "cpu": [ "arm64" ], @@ -918,9 +1116,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz", - "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "cpu": [ "x64" ], @@ -932,9 +1130,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz", - "integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "cpu": [ "arm64" ], @@ -946,9 +1144,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz", - "integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "cpu": [ "x64" ], @@ -960,9 +1158,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz", - "integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "cpu": [ "arm" ], @@ -974,9 +1172,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz", - "integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "cpu": [ "arm" ], @@ -988,9 +1186,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz", - "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "cpu": [ "arm64" ], @@ -1002,9 +1200,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz", - "integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "cpu": [ "arm64" ], @@ -1016,9 +1214,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz", - "integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", "cpu": [ "loong64" ], @@ -1030,9 +1228,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz", - "integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", "cpu": [ "loong64" ], @@ -1044,9 +1242,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz", - "integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", "cpu": [ "ppc64" ], @@ -1058,9 +1256,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz", - "integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "cpu": [ "ppc64" ], @@ -1072,9 +1270,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz", - "integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", "cpu": [ "riscv64" ], @@ -1086,9 +1284,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz", - "integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "cpu": [ "riscv64" ], @@ -1100,9 +1298,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz", - "integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "cpu": [ "s390x" ], @@ -1114,9 +1312,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz", - "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "cpu": [ "x64" ], @@ -1128,9 +1326,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz", - "integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "cpu": [ "x64" ], @@ -1142,9 +1340,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz", - "integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", "cpu": [ "x64" ], @@ -1156,9 +1354,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz", - "integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", "cpu": [ "arm64" ], @@ -1170,9 +1368,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz", - "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "cpu": [ "arm64" ], @@ -1184,9 +1382,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz", - "integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "cpu": [ "ia32" ], @@ -1198,9 +1396,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz", - "integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", "cpu": [ "x64" ], @@ -1212,9 +1410,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz", - "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "cpu": [ "x64" ], @@ -1524,6 +1722,77 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1531,6 +1800,92 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue/reactivity": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", @@ -1548,10 +1903,20 @@ "dev": true, "license": "MIT" }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/alpinejs": { - "version": "3.15.5", - "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.5.tgz", - "integrity": "sha512-l1R4em/uCUr7eJimcO/b0L1+H2tVcB1Y7cQ3d+pzwVnv0zWs7gw4MhwdsLjfLccWV2iH0ahlfaJWitNRFOZdvQ==", + "version": "3.15.8", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.8.tgz", + "integrity": "sha512-zxIfCRTBGvF1CCLIOMQOxAyBuqibxSEwS6Jm1a3HGA9rgrJVcjEWlwLcQTVGAWGS8YhAsTRLVrtQ5a5QT9bSSQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1625,17 +1990,37 @@ "dev": true, "license": "MIT" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.4.23", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", - "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", "dev": true, "funding": [ { @@ -1654,7 +2039,7 @@ "license": "MIT", "dependencies": { "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001760", + "caniuse-lite": "^1.0.30001766", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -1670,14 +2055,13 @@ } }, "node_modules/axios": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", - "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", - "dev": true, + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -1751,11 +2135,20 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1765,6 +2158,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1776,9 +2185,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001766", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", - "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", "dev": true, "funding": [ { @@ -1796,6 +2205,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1826,42 +2252,30 @@ "node": ">=8" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": 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" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 14.16.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==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" } }, "node_modules/cliui": { @@ -1903,7 +2317,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -1960,16 +2373,105 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1994,11 +2496,18 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -2010,9 +2519,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.282", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.282.tgz", - "integrity": "sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ==", + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "dev": true, "license": "ISC" }, @@ -2024,24 +2533,36 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.18.4", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", - "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2051,17 +2572,22 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, + "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" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -2074,7 +2600,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2087,9 +2612,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2100,32 +2625,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/escalade": { @@ -2138,6 +2663,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -2213,7 +2758,6 @@ "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "dev": true, "funding": [ { "type": "individual", @@ -2234,7 +2778,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -2261,6 +2804,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.34.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.0.tgz", + "integrity": "sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.34.0", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2280,7 +2850,6 @@ "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" @@ -2300,7 +2869,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -2325,7 +2893,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -2352,7 +2919,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2382,7 +2948,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2395,7 +2960,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -2411,7 +2975,6 @@ "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" @@ -2420,6 +2983,60 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/immutable": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", @@ -2499,6 +3116,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -2509,6 +3133,55 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/laravel-vite-plugin": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.1.0.tgz", @@ -2810,6 +3483,38 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2824,7 +3529,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2871,7 +3575,6 @@ "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", "engines": { "node": ">= 0.6" @@ -2881,7 +3584,6 @@ "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", "dependencies": { "mime-db": "1.52.0" @@ -2900,6 +3602,28 @@ "mini-svg-data-uri": "cli.js" } }, + "node_modules/motion-dom": { + "version": "12.34.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.0.tgz", + "integrity": "sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -2956,6 +3680,19 @@ "node": ">=0.10.0" } }, + "node_modules/nprogress": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", + "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==", + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2976,6 +3713,31 @@ "node": ">= 6" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2983,6 +3745,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3186,13 +3965,67 @@ "dev": true, "license": "MIT" }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3235,6 +4068,14 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -3246,29 +4087,17 @@ } }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">= 14.18.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/require-directory": { @@ -3314,9 +4143,9 @@ } }, "node_modules/rollup": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", - "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "dev": true, "license": "MIT", "dependencies": { @@ -3330,34 +4159,41 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.0", - "@rollup/rollup-android-arm64": "4.57.0", - "@rollup/rollup-darwin-arm64": "4.57.0", - "@rollup/rollup-darwin-x64": "4.57.0", - "@rollup/rollup-freebsd-arm64": "4.57.0", - "@rollup/rollup-freebsd-x64": "4.57.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", - "@rollup/rollup-linux-arm-musleabihf": "4.57.0", - "@rollup/rollup-linux-arm64-gnu": "4.57.0", - "@rollup/rollup-linux-arm64-musl": "4.57.0", - "@rollup/rollup-linux-loong64-gnu": "4.57.0", - "@rollup/rollup-linux-loong64-musl": "4.57.0", - "@rollup/rollup-linux-ppc64-gnu": "4.57.0", - "@rollup/rollup-linux-ppc64-musl": "4.57.0", - "@rollup/rollup-linux-riscv64-gnu": "4.57.0", - "@rollup/rollup-linux-riscv64-musl": "4.57.0", - "@rollup/rollup-linux-s390x-gnu": "4.57.0", - "@rollup/rollup-linux-x64-gnu": "4.57.0", - "@rollup/rollup-linux-x64-musl": "4.57.0", - "@rollup/rollup-openbsd-x64": "4.57.0", - "@rollup/rollup-openharmony-arm64": "4.57.0", - "@rollup/rollup-win32-arm64-msvc": "4.57.0", - "@rollup/rollup-win32-ia32-msvc": "4.57.0", - "@rollup/rollup-win32-x64-gnu": "4.57.0", - "@rollup/rollup-win32-x64-msvc": "4.57.0", + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -3392,6 +4228,13 @@ "tslib": "^2.1.0" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/sass": { "version": "1.97.3", "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", @@ -3413,34 +4256,17 @@ "@parcel/watcher": "^2.4.1" } }, - "node_modules/sass/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "readdirp": "^4.0.1" + "xmlchars": "^2.2.0" }, "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/sass/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "node": ">=v12.22.7" } }, "node_modules/scheduler": { @@ -3462,6 +4288,85 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3472,6 +4377,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3552,6 +4471,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -3590,6 +4516,44 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": 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/tailwindcss/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==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/tailwindcss/node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -3600,6 +4564,32 @@ "jiti": "bin/jiti.js" } }, + "node_modules/tailwindcss/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tailwindcss/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -3637,6 +4627,20 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3654,6 +4658,56 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3667,6 +4721,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -3688,7 +4768,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/update-browserslist-db": { @@ -3804,6 +4883,519 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.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", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, "node_modules/vite-plugin-full-reload": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", @@ -3828,6 +5420,667 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.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", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -3846,6 +6099,45 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 7fa7e851..02c4dc52 100644 --- a/package.json +++ b/package.json @@ -4,22 +4,30 @@ "type": "module", "scripts": { "build": "vite build", - "dev": "vite" + "dev": "vite", + "test:ui": "vitest run" }, "devDependencies": { "@tailwindcss/forms": "^0.5.2", "@tailwindcss/vite": "^4.0.0", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", "alpinejs": "^3.4.2", "autoprefixer": "^10.4.2", "axios": "^1.11.0", "concurrently": "^9.0.1", + "jsdom": "^25.0.1", "laravel-vite-plugin": "^2.0.0", "postcss": "^8.4.31", "sass": "^1.70.0", "tailwindcss": "^3.1.0", - "vite": "^7.0.7" + "vite": "^7.0.7", + "vitest": "^2.1.8" }, "dependencies": { + "@inertiajs/core": "^1.0.4", + "@inertiajs/react": "^1.0.4", + "framer-motion": "^12.34.0", "react": "^19.2.4", "react-dom": "^19.2.4" } diff --git a/public/avatar/1.jpg b/public/avatar/1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7a061d02c010f2c0e43b589d212c81797c7ee963 GIT binary patch literal 6825 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%0A`i>Q&B~Q|C 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 zNlgRo0000001yC30000100IC=0000100KBdNlgSO0000001N;C00KYo00000001~b bNlgSp0000001i-MWmf?Z00sb100000W>?Tx literal 0 HcmV?d00001 diff --git a/public/gfx/mascot_skins.webp b/public/gfx/mascot_skins.webp new file mode 100644 index 0000000000000000000000000000000000000000..f156d2ff6f6e04a7195b281bbcd6137d91f7dcce GIT binary patch literal 33364 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%0AqC# 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 literal 0 HcmV?d00001 diff --git a/resources/css/app.css b/resources/css/app.css index b5c61c95..9231bd52 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,3 +1,65 @@ @tailwind base; @tailwind components; @tailwind utilities; + +.form-label { + @apply block text-sm text-soft mb-1; +} + +.form-input { + @apply w-full bg-deep border border-nebula-500/30 rounded-lg px-4 py-2 + text-white placeholder-gray-500 + focus:outline-none focus:ring-2 focus:ring-accent; +} + +.form-textarea { + @apply w-full bg-deep border border-nebula-500/30 rounded-lg px-4 py-2 + text-white resize-none + focus:outline-none focus:ring-2 focus:ring-accent; +} + +.form-file { + @apply w-full text-sm text-soft + file:bg-panel file:border-0 file:px-4 file:py-2 + file:rounded-lg file:text-white + hover:file:bg-nebula-600/40; +} + +.btn-primary { + @apply bg-accent text-deep px-6 py-2 rounded-lg + font-medium hover:brightness-110 transition; +} + +.btn-secondary { + @apply bg-nebula-500/30 text-white px-5 py-2 rounded-lg + hover:bg-nebula-500/50 transition; +} + +@layer components { + /* Ensure plain inputs, textareas and selects match the dark form styles + so we don't end up with white backgrounds + white text. */ + input, + input[type="text"], + input[type="email"], + input[type="password"], + textarea, + select { + @apply bg-deep text-white border border-nebula-500/30 rounded-lg px-4 py-2 + placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-accent; + } + + /* Keep file inputs styled separately (file controls use .form-file class). + This prevents the native file button from inheriting the same padding. */ + input[type="file"] { + @apply bg-transparent text-soft p-0; + } +} + + +@layer base { + *,:before,:after { + box-sizing: border-box; + border: 0 solid transparent; + } +} + diff --git a/resources/js/Pages/Admin/UploadQueue.jsx b/resources/js/Pages/Admin/UploadQueue.jsx new file mode 100644 index 00000000..540b80d2 --- /dev/null +++ b/resources/js/Pages/Admin/UploadQueue.jsx @@ -0,0 +1,6 @@ +import React from 'react' +import AdminUploadQueue from '../../components/admin/AdminUploadQueue' + +export default function UploadQueuePage() { + return +} diff --git a/resources/js/Pages/Upload/Index.jsx b/resources/js/Pages/Upload/Index.jsx new file mode 100644 index 00000000..515351d1 --- /dev/null +++ b/resources/js/Pages/Upload/Index.jsx @@ -0,0 +1,1035 @@ +import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' +import { usePage } from '@inertiajs/react' +import TagInput from '../../components/tags/TagInput' +import UploadWizard from '../../components/upload/UploadWizard' + +const phases = { + idle: 'idle', + initializing: 'initializing', + ready: 'ready', + uploading: 'uploading', + finishing: 'finishing', + polling: 'polling', + cancelling: 'cancelling', + success: 'success', + error: 'error', +} + +const initialState = { + phase: phases.idle, + sessionId: null, + uploadToken: null, + file: null, + filePreviewUrl: null, + previewUrl: null, + progress: 0, + pipelineStatus: null, + failureReason: null, + error: null, + cancelledAt: null, + notices: [], + artworkId: null, + draftId: null, + metadata: { + title: '', + type: '', + category: '', + tags: '', + description: '', + licenseAccepted: false, + }, +} + +const STORAGE_VERSION = 1 + +function storageKey(userId) { + return `sb.upload.session.${userId}.v${STORAGE_VERSION}` +} + +function readStoredSession(userId) { + try { + const raw = window.localStorage.getItem(storageKey(userId)) + if (!raw) return null + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object') return null + return parsed + } catch (error) { + return null + } +} + +function writeStoredSession(userId, payload) { + try { + window.localStorage.setItem(storageKey(userId), JSON.stringify(payload)) + } catch (error) { + // ignore write failures + } +} + +function clearStoredSession(userId) { + try { + window.localStorage.removeItem(storageKey(userId)) + } catch (error) { + // ignore removal failures + } +} + +function reducer(state, action) { + switch (action.type) { + case 'SET_DRAFT': + return { ...state, draftId: action.draftId, artworkId: action.artworkId ?? state.artworkId } + case 'SET_FILE': + return { ...state, file: action.file, filePreviewUrl: action.previewUrl, error: null, cancelledAt: null } + case 'SET_METADATA': + return { ...state, metadata: { ...state.metadata, ...action.payload } } + case 'INIT_START': + return { ...state, phase: phases.initializing, error: null, cancelledAt: null } + case 'INIT_SUCCESS': + return { ...state, phase: phases.ready, sessionId: action.sessionId, uploadToken: action.uploadToken, pipelineStatus: action.status } + case 'RESTORE_SESSION': + return { ...state, phase: phases.ready, sessionId: action.sessionId, uploadToken: action.uploadToken, pipelineStatus: action.status ?? state.pipelineStatus } + case 'RESUME_SESSION': + return { ...state, phase: phases.polling, sessionId: action.sessionId, pipelineStatus: action.status ?? state.pipelineStatus } + case 'INIT_ERROR': + return { ...state, phase: phases.error, error: action.error } + case 'UPLOAD_START': + return { ...state, phase: phases.uploading, progress: 0, error: null } + case 'UPLOAD_PROGRESS': + return { ...state, progress: action.progress } + case 'UPLOAD_ERROR': + return { ...state, phase: phases.error, error: action.error } + case 'FINISH_START': + return { ...state, phase: phases.finishing, error: null } + case 'FINISH_SUCCESS': + return { ...state, phase: phases.polling, pipelineStatus: action.status, previewUrl: action.previewUrl ?? null } + case 'FINISH_ERROR': + return { ...state, phase: phases.error, error: action.error } + case 'CANCEL_START': + return { ...state, phase: phases.cancelling, error: null } + case 'CANCEL_SUCCESS': + return { ...state, phase: phases.idle, cancelledAt: Date.now() } + case 'CLEAR_CANCELLED': + return { ...state, cancelledAt: null } + case 'CANCEL_ERROR': + return { ...state, phase: phases.error, error: action.error } + case 'STATUS_UPDATE': + return { ...state, pipelineStatus: action.status, progress: action.progress ?? state.progress, failureReason: action.failureReason ?? null } + case 'STATUS_ERROR': + return { ...state, phase: phases.error, error: action.error } + case 'SUCCESS': + return { ...state, phase: phases.success } + case 'RESET': + return { ...initialState, draftId: state.draftId, cancelledAt: state.cancelledAt } + case 'PUSH_NOTICE': + return { ...state, notices: [...state.notices, action.notice] } + case 'REMOVE_NOTICE': + return { ...state, notices: state.notices.filter((notice) => notice.id !== action.id) } + default: + return state + } +} + +const MAX_CHUNK_RETRIES = 3 +const RETRY_DELAY_MS = 900 + +function normalizeUiTag(rawTag) { + const raw = String(rawTag ?? '').trim().toLowerCase() + if (!raw) return '' + + return raw + .replace(/\s+/g, '-') + .replace(/[^a-z0-9_-]/g, '') + .replace(/-+/g, '-') + .replace(/_+/g, '_') + .replace(/^[-_]+|[-_]+$/g, '') + .slice(0, 32) +} + +function parseUiTags(csvValue) { + return String(csvValue ?? '') + .split(/[\n,]+/) + .map((item) => normalizeUiTag(item)) + .filter(Boolean) +} + +function getTypeKey(ct) { + if (!ct) return 'default' + if (ct.slug && typeof ct.slug === 'string') { + return ct.slug.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '') + } + return String(ct.name || '').toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '') +} + +function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) { + const [state, dispatch] = useReducer(reducer, { ...initialState, draftId }) + const pollRef = useRef(null) + + const extractErrorMessage = useCallback((error, fallback) => { + const message = error?.response?.data?.message + if (message && typeof message === 'string') return message + const errors = error?.response?.data?.errors + if (errors && typeof errors === 'object') { + const firstKey = Object.keys(errors)[0] + if (firstKey && Array.isArray(errors[firstKey]) && errors[firstKey][0]) { + return errors[firstKey][0] + } + } + return fallback + }, []) + + const pushNotice = useCallback((type, message) => { + const id = `${Date.now()}-${Math.random().toString(16).slice(2)}` + dispatch({ type: 'PUSH_NOTICE', notice: { id, type, message } }) + window.setTimeout(() => { + dispatch({ type: 'REMOVE_NOTICE', id }) + }, 4500) + }, []) + + const previewUrl = useMemo(() => { + if (state.previewUrl) return state.previewUrl + if (!state.filePreviewUrl) return null + return state.filePreviewUrl + }, [state.previewUrl, state.filePreviewUrl]) + + const stopPolling = useCallback(() => { + if (pollRef.current) { + window.clearInterval(pollRef.current) + pollRef.current = null + } + }, []) + + const startPolling = useCallback(() => { + if (!state.sessionId) return + stopPolling() + pollRef.current = window.setInterval(async () => { + try { + const res = await window.axios.get(`/api/uploads/status/${state.sessionId}`, { + headers: state.uploadToken ? { 'X-Upload-Token': state.uploadToken } : undefined, + params: state.uploadToken ? { upload_token: state.uploadToken } : undefined, + }) + const data = res.data || {} + dispatch({ + type: 'STATUS_UPDATE', + status: data.status, + progress: typeof data.progress === 'number' ? data.progress : state.progress, + failureReason: data.failure_reason, + }) + + if (data.status === 'processed') { + dispatch({ type: 'SUCCESS' }) + stopPolling() + } + + if (data.failure_reason) { + dispatch({ type: 'STATUS_ERROR', error: data.failure_reason }) + stopPolling() + } + } catch (error) { + dispatch({ type: 'STATUS_ERROR', error: 'Status check failed.' }) + stopPolling() + } + }, 2500) + }, [state.sessionId, state.uploadToken, state.progress, stopPolling]) + + useEffect(() => { + if (draftId && !state.sessionId) { + dispatch({ type: 'RESUME_SESSION', sessionId: draftId, status: 'resuming' }) + } + }, [draftId, state.sessionId]) + + useEffect(() => { + if (state.phase === phases.polling) { + startPolling() + } + + return () => stopPolling() + }, [state.phase, startPolling, stopPolling]) + + useEffect(() => { + return () => { + if (state.filePreviewUrl) { + URL.revokeObjectURL(state.filePreviewUrl) + } + } + }, [state.filePreviewUrl]) + + const initSession = useCallback(async () => { + dispatch({ type: 'INIT_START' }) + try { + const res = await window.axios.post('/api/uploads/init', { client: 'web' }) + const data = res.data || {} + dispatch({ + type: 'INIT_SUCCESS', + sessionId: data.session_id, + uploadToken: data.upload_token, + status: data.status, + }) + if (userId && data.session_id && data.upload_token && state.file) { + writeStoredSession(userId, { + session_id: data.session_id, + upload_token: data.upload_token, + file_name: state.file.name, + file_size: state.file.size, + updated_at: Date.now(), + }) + } + return { sessionId: data.session_id, uploadToken: data.upload_token } + } catch (error) { + const message = extractErrorMessage(error, 'Failed to initialize upload session.') + dispatch({ type: 'INIT_ERROR', error: message }) + pushNotice('error', message) + return null + } + }, [state.file, userId, extractErrorMessage, pushNotice]) + + const createDraft = useCallback(async () => { + if (state.artworkId) return state.artworkId + try { + const res = await window.axios.post('/api/artworks', { + title: state.metadata.title, + category: state.metadata.category, + tags: state.metadata.tags, + description: state.metadata.description, + license: state.metadata.licenseAccepted, + }) + + const data = res.data || {} + const artworkId = data.artwork_id ?? data.id + if (artworkId) { + dispatch({ type: 'SET_DRAFT', draftId: state.draftId, artworkId }) + return artworkId + } + throw new Error('missing_artwork_id') + } catch (error) { + const message = extractErrorMessage(error, 'Unable to create draft metadata.') + dispatch({ type: 'FINISH_ERROR', error: message }) + pushNotice('error', message) + return null + } + }, [state.artworkId, state.metadata, state.draftId, extractErrorMessage, pushNotice]) + + const syncArtworkTags = useCallback(async (artworkId) => { + const tags = Array.from(new Set(parseUiTags(state.metadata.tags))) + if (tags.length === 0) { + return true + } + + try { + await window.axios.put(`/api/artworks/${artworkId}/tags`, { tags }) + return true + } catch (error) { + const message = extractErrorMessage(error, 'Tag sync failed. Upload will continue.') + pushNotice('error', message) + return false + } + }, [state.metadata.tags, extractErrorMessage, pushNotice]) + + const fetchStatus = useCallback(async (sessionId, uploadToken) => { + const res = await window.axios.get(`/api/uploads/status/${sessionId}`, { + headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined, + params: uploadToken ? { upload_token: uploadToken } : undefined, + }) + return res.data || {} + }, []) + + const validateStoredSession = useCallback(async (file) => { + if (!userId) return null + const stored = readStoredSession(userId) + if (!stored) return null + + if (stored.file_name !== file.name || stored.file_size !== file.size) { + clearStoredSession(userId) + return null + } + + try { + const data = await fetchStatus(stored.session_id, stored.upload_token) + if (!data || !data.session_id) { + clearStoredSession(userId) + return null + } + dispatch({ type: 'RESTORE_SESSION', sessionId: stored.session_id, uploadToken: stored.upload_token, status: data.status }) + return { sessionId: stored.session_id, uploadToken: stored.upload_token } + } catch (error) { + clearStoredSession(userId) + return null + } + }, [fetchStatus, userId]) + + const uploadChunk = useCallback(async (sessionId, uploadToken, blob, offset, totalSize, attempt) => { + const payload = new FormData() + payload.append('session_id', sessionId) + payload.append('offset', String(offset)) + payload.append('chunk_size', String(blob.size)) + payload.append('total_size', String(totalSize)) + payload.append('chunk', blob) + payload.append('upload_token', uploadToken) + + try { + const res = await window.axios.post('/api/uploads/chunk', payload, { + headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined, + }) + + const data = res.data || {} + if (typeof data.progress === 'number') { + dispatch({ type: 'UPLOAD_PROGRESS', progress: data.progress }) + } + + return data + } catch (error) { + if (attempt < MAX_CHUNK_RETRIES) { + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS * (attempt + 1))) + return uploadChunk(sessionId, uploadToken, blob, offset, totalSize, attempt + 1) + } + throw error + } + }, []) + + const uploadFile = useCallback(async (sessionId, uploadToken, file) => { + dispatch({ type: 'UPLOAD_START' }) + + let status + try { + status = await fetchStatus(sessionId, uploadToken) + } catch (error) { + const message = extractErrorMessage(error, 'Unable to resume upload.') + dispatch({ type: 'UPLOAD_ERROR', error: message }) + pushNotice('error', message) + return false + } + + let offset = Number(status.received_bytes || 0) + const totalSize = file.size + if (offset > totalSize) offset = 0 + + while (offset < totalSize) { + const nextOffset = Math.min(offset + chunkSize, totalSize) + const chunk = file.slice(offset, nextOffset) + + try { + const data = await uploadChunk(sessionId, uploadToken, chunk, offset, totalSize, 0) + offset = Number(data.received_bytes ?? nextOffset) + if (offset < nextOffset) { + offset = nextOffset + } + } catch (error) { + const message = extractErrorMessage(error, 'File upload failed. Please retry.') + dispatch({ type: 'UPLOAD_ERROR', error: message }) + pushNotice('error', message) + return false + } + } + + return true + }, [chunkSize, fetchStatus, uploadChunk]) + + const finishUpload = useCallback(async (sessionId, uploadToken, artworkId) => { + dispatch({ type: 'FINISH_START' }) + try { + const res = await window.axios.post( + '/api/uploads/finish', + { session_id: sessionId, artwork_id: artworkId, upload_token: uploadToken }, + { headers: { 'X-Upload-Token': uploadToken } } + ) + const data = res.data || {} + const previewPath = data.preview_path + const previewUrl = previewPath ? `${filesCdnUrl}/${previewPath}` : null + dispatch({ type: 'FINISH_SUCCESS', status: data.status, previewUrl }) + if (userId) { + clearStoredSession(userId) + } + return true + } catch (error) { + const message = extractErrorMessage(error, 'Upload finalization failed.') + dispatch({ type: 'FINISH_ERROR', error: message }) + pushNotice('error', message) + return false + } + }, [filesCdnUrl, userId]) + + const startUpload = useCallback(async () => { + if (!state.file) { + const message = 'Please select a file first.' + dispatch({ type: 'UPLOAD_ERROR', error: message }) + pushNotice('error', message) + return + } + + if (!state.metadata.title.trim()) { + const message = 'Title is required to start the upload.' + dispatch({ type: 'UPLOAD_ERROR', error: message }) + pushNotice('error', message) + return + } + + if (!state.metadata.type) { + const message = 'Please select a Type.' + dispatch({ type: 'UPLOAD_ERROR', error: message }) + pushNotice('error', message) + return + } + + if (!state.metadata.category) { + const message = 'Please select a Category.' + dispatch({ type: 'UPLOAD_ERROR', error: message }) + pushNotice('error', message) + return + } + + if (parseUiTags(state.metadata.tags).length === 0) { + const message = 'Please add at least one tag.' + dispatch({ type: 'UPLOAD_ERROR', error: message }) + pushNotice('error', message) + return + } + + if (!state.metadata.description.trim()) { + const message = 'Please provide a description.' + dispatch({ type: 'UPLOAD_ERROR', error: message }) + pushNotice('error', message) + return + } + + if (!state.metadata.licenseAccepted) { + const message = 'You must confirm ownership of the artwork.' + dispatch({ type: 'UPLOAD_ERROR', error: message }) + pushNotice('error', message) + return + } + + let init = null + if (userId) { + init = await validateStoredSession(state.file) + } + + if (!init) { + init = await initSession() + } + + if (!init) return + + const artworkId = await createDraft() + if (!artworkId) return + + await syncArtworkTags(artworkId) + + const ok = await uploadFile(init.sessionId, init.uploadToken, state.file) + if (!ok) return + + await finishUpload(init.sessionId, init.uploadToken, artworkId) + }, [state.file, state.metadata, initSession, createDraft, syncArtworkTags, uploadFile, finishUpload, validateStoredSession, userId, pushNotice]) + + const cancelUpload = useCallback(async () => { + dispatch({ type: 'CANCEL_START' }) + + if (!state.sessionId || !state.uploadToken) { + if (userId) { + clearStoredSession(userId) + } + dispatch({ type: 'CANCEL_SUCCESS' }) + dispatch({ type: 'RESET' }) + return + } + + try { + await window.axios.post( + '/api/uploads/cancel', + { session_id: state.sessionId, upload_token: state.uploadToken }, + { headers: { 'X-Upload-Token': state.uploadToken } } + ) + } catch (error) { + // ignore error, always reset local state to avoid leaking session info + } + + if (userId) { + clearStoredSession(userId) + } + dispatch({ type: 'CANCEL_SUCCESS' }) + dispatch({ type: 'RESET' }) + }, [state.sessionId, state.uploadToken, userId]) + + return { + state, + dispatch, + previewUrl, + startUpload, + initSession, + cancelUpload, + pushNotice, + } +} + +export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) { + const { props } = usePage() + + const windowFlags = window?.SKINBASE_FLAGS || {} + const propFlagRaw = props?.feature_flags?.uploads_v2 + const windowFlagRaw = (windowFlags?.uploads && windowFlags.uploads.v2) ?? windowFlags?.uploads_v2 + + const toBooleanFlag = (value) => { + if (typeof value === 'boolean') return value + if (typeof value === 'number') return value === 1 + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase() + return ['1', 'true', 'yes', 'on'].includes(normalized) + } + return false + } + + const uploadsV2Enabled = toBooleanFlag(propFlagRaw) || toBooleanFlag(windowFlagRaw) + + if (uploadsV2Enabled) { + return ( +
+
+ +
+
+ ) + } + + const userId = props?.auth?.user?.id ?? null + const suggestedTags = Array.isArray(props?.suggested_tags) ? props.suggested_tags : [] + const safeChunkSize = Math.max(1, Number(chunkSize || 0)) + const { state, dispatch, previewUrl, startUpload, cancelUpload } = useUploadMachine({ draftId, filesCdnUrl, chunkSize: safeChunkSize, userId }) + const fileInputRef = useRef(null) + const [confirmCancel, setConfirmCancel] = useState(false) + const [contentTypes, setContentTypes] = useState([]) + const [selectedParentCategory, setSelectedParentCategory] = useState(null) + + const availableTypes = useMemo(() => (props?.content_types && Array.isArray(props.content_types)) ? props.content_types : contentTypes, [props, contentTypes]) + const selectedType = useMemo( + () => availableTypes.find((t) => String(t.id) === String(state.metadata.type)), + [availableTypes, state.metadata.type] + ) + const categoryOptions = useMemo(() => selectedType?.categories || [], [selectedType]) + const hasAtLeastOneTag = useMemo(() => parseUiTags(state.metadata.tags).length > 0, [state.metadata.tags]) + + useEffect(() => { + // Prefer server-provided props, else try fetching from API endpoints + if (props?.content_types && Array.isArray(props.content_types)) { + setContentTypes(props.content_types) + return + } + + let mounted = true + ;(async () => { + try { + const res = await window.axios.get('/api/content-types') + if (!mounted) return + setContentTypes(res.data || []) + } catch (e) { + // ignore, content types optional here + } + })() + + return () => { mounted = false } + }, [props]) + + const onFileSelect = useCallback((file) => { + if (!file) return + const preview = URL.createObjectURL(file) + dispatch({ type: 'SET_FILE', file, previewUrl: preview }) + }, [dispatch]) + + const onDrop = useCallback((event) => { + event.preventDefault() + const file = event.dataTransfer.files?.[0] + onFileSelect(file) + }, [onFileSelect]) + + const onBrowse = useCallback(() => { + fileInputRef.current?.click() + }, []) + + const onChange = useCallback((event) => { + const file = event.target.files?.[0] + onFileSelect(file) + }, [onFileSelect]) + + const statusLabel = state.pipelineStatus || state.phase + + useEffect(() => { + if (!confirmCancel) return + const timer = window.setTimeout(() => { + setConfirmCancel(false) + }, 5000) + return () => window.clearTimeout(timer) + }, [confirmCancel]) + + useEffect(() => { + if (!state.cancelledAt) return + const timer = window.setTimeout(() => { + dispatch({ type: 'CLEAR_CANCELLED' }) + }, 3500) + return () => window.clearTimeout(timer) + }, [state.cancelledAt, dispatch]) + + return ( +
+
+ {state.notices.length > 0 && ( +
+ {state.notices.map((notice) => ( +
+ {notice.message} +
+ ))} +
+ )} +
+
+
+
+

Upload artwork

+ Secure pipeline +
+

All uploads are scanned, re-encoded, and published through the Skinbase pipeline.

+ +
e.preventDefault()} + onDrop={onDrop} + > +
+ +
+

Drag & drop your file

+

JPG, PNG, or WebP. Up to 50MB.

+ + +
+ + {state.file && ( +
+
+ Preview +
+
+
{state.file.name}
+
{(state.file.size / 1024 / 1024).toFixed(2)} MB
+
+
+ )} + +
+ +
+ +
+
+
+
Choose a type
+
Step 1: Pick what kind of artwork this is.
+
+ Type +
+ + {availableTypes.length === 0 ? ( +
+ No content types available. +
+ ) : ( +
+ {availableTypes.map((ct) => { + const active = String(ct.id) === String(state.metadata.type) + const iconKey = getTypeKey(ct) + const iconPath = `/gfx/mascot_${iconKey}.webp` + return ( + + ) + })} +
+ )} +
+ +
+
+
+
Choose a category
+
Step 2: Pick a subcategory inside the type.
+
+ Category +
+ + {!selectedType ? ( +
+ Select a type first to see categories. +
+ ) : categoryOptions.length === 0 ? ( +
+ No categories available for {selectedType.name}. +
+ ) : ( +
+
+ {categoryOptions.map((cat) => { + const isSelected = String(cat.id) === String(state.metadata.category) + const isExpanded = String(cat.id) === String(selectedParentCategory) + const hasChildren = Array.isArray(cat.children) && cat.children.length > 0 + return ( + + ) + })} +
+ + {selectedParentCategory && (() => { + const parent = categoryOptions.find((c) => String(c.id) === String(selectedParentCategory)) + if (!parent || !Array.isArray(parent.children) || parent.children.length === 0) return null + return ( +
+
+
+
Subcategories for {parent.name}
+
Choose a subcategory below
+
+ +
+
+ {parent.children.map((child) => { + const activeChild = String(child.id) === String(state.metadata.category) + return ( + + ) + })} +
+
+ ) + })()} +
+ )} +
+ +
+ Tags +
+ { + dispatch({ type: 'SET_METADATA', payload: { tags: nextTags.join(', ') } }) + }} + suggestedTags={suggestedTags} + maxTags={15} + minLength={2} + maxLength={32} + searchEndpoint="/api/tags/search" + popularEndpoint="/api/tags/popular" + placeholder="Type tags (e.g. cyberpunk, city)" + /> +
+
+ +
+ + +
+ + +
+ + +
+ + +
+ +
+
-
- mlist ?? 0) ? 'checked' : '' }}> Mailing list
- friend_upload_notice ?? 0) ? 'checked' : '' }}> Friends upload notice + + +
+
-
- - -
+ -
- - -
- -
- -
-
-
- - @if(!empty($user->icon)) -
- @endif -
- -
-
- -
- -
-
- - @if(!empty($user->picture)) -
- @endif -
- -
-
- - + + +
+ +

+ Change Password +

+ +
+ @csrf + @method('PUT') + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+ +
+
- - - - -
- -

Change password

-
- @csrf -
- - -
-
- - -
-
- - -
- - -
- +
+ @endsection diff --git a/resources/views/tags/show.blade.php b/resources/views/tags/show.blade.php new file mode 100644 index 00000000..e0be5081 --- /dev/null +++ b/resources/views/tags/show.blade.php @@ -0,0 +1,37 @@ +@extends('layouts.legacy') + +@section('content') +
+@endsection diff --git a/resources/views/upload.blade.php b/resources/views/upload.blade.php new file mode 100644 index 00000000..be362882 --- /dev/null +++ b/resources/views/upload.blade.php @@ -0,0 +1,34 @@ + + + + {{ $page_title ?? 'Upload Artwork' }} + + + + + + + + + + + @vite(['resources/css/app.css','resources/scss/nova.scss','resources/js/entry-topbar.jsx','resources/js/upload.jsx']) + + +
+ @include('layouts.nova.toolbar') + +
+ @inertia +
+ + @include('layouts.nova.footer') + + diff --git a/routes/api.php b/routes/api.php index 734d8040..d5138991 100644 --- a/routes/api.php +++ b/routes/api.php @@ -32,4 +32,98 @@ Route::prefix('v1')->name('api.v1.')->group(function () { // Category artworks (Category route-model binding uses slug) Route::get('categories/{category}/artworks', [\App\Http\Controllers\Api\ArtworkController::class, 'categoryArtworks']) ->name('categories.artworks'); + + // Personalized feed (auth required) + Route::middleware(['web', 'auth'])->get('feed', [\App\Http\Controllers\Api\FeedController::class, 'index']) + ->name('feed'); +}); + +Route::middleware(['web', 'auth'])->prefix('artworks')->name('api.artworks.')->group(function () { + Route::post('/', [\App\Http\Controllers\Api\ArtworkController::class, 'store']) + ->name('store'); +}); + +Route::middleware(['web', 'auth'])->prefix('uploads')->name('api.uploads.')->group(function () { + Route::post('init', [\App\Http\Controllers\Api\UploadController::class, 'init']) + ->middleware('throttle:uploads-init') + ->name('init'); + + Route::post('preload', [\App\Http\Controllers\Api\UploadController::class, 'preload']) + ->middleware('throttle:uploads-init') + ->name('preload'); + + Route::post('{id}/autosave', [\App\Http\Controllers\Api\UploadController::class, 'autosave']) + ->middleware('throttle:uploads-finish') + ->name('autosave'); + + Route::post('{id}/publish', [\App\Http\Controllers\Api\UploadController::class, 'publish']) + ->middleware('throttle:uploads-finish') + ->name('publish'); + + Route::get('{id}/status', [\App\Http\Controllers\Api\UploadController::class, 'processingStatus']) + ->middleware('throttle:uploads-status') + ->name('processing-status'); + + Route::post('chunk', [\App\Http\Controllers\Api\UploadController::class, 'chunk']) + ->middleware('throttle:uploads-init') + ->name('chunk'); + + Route::post('finish', [\App\Http\Controllers\Api\UploadController::class, 'finish']) + ->middleware('throttle:uploads-finish') + ->name('finish'); + + Route::post('cancel', [\App\Http\Controllers\Api\UploadController::class, 'cancel']) + ->middleware('throttle:uploads-finish') + ->name('cancel'); + + Route::get('status/{id}', [\App\Http\Controllers\Api\UploadController::class, 'status']) + ->middleware('throttle:uploads-status') + ->name('status'); +}); + +Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/uploads')->name('api.admin.uploads.')->group(function () { + Route::get('pending', [\App\Http\Controllers\Api\Admin\UploadModerationController::class, 'pending']) + ->name('pending'); + + Route::post('{id}/approve', [\App\Http\Controllers\Api\Admin\UploadModerationController::class, 'approve']) + ->whereUuid('id') + ->name('approve'); + + Route::post('{id}/reject', [\App\Http\Controllers\Api\Admin\UploadModerationController::class, 'reject']) + ->whereUuid('id') + ->name('reject'); +}); + +Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/reports')->name('api.admin.reports.')->group(function () { + Route::get('similar-artworks', [\App\Http\Controllers\Api\Admin\SimilarArtworkReportController::class, 'index']) + ->name('similar-artworks'); + + Route::get('feed-performance', [\App\Http\Controllers\Api\Admin\FeedPerformanceReportController::class, 'index']) + ->name('feed-performance'); +}); + +Route::post('analytics/similar-artworks', [\App\Http\Controllers\Api\SimilarArtworkAnalyticsController::class, 'store']) + ->middleware('throttle:uploads-status') + ->name('api.analytics.similar-artworks.store'); + +Route::middleware(['web', 'auth'])->post('analytics/feed', [\App\Http\Controllers\Api\FeedAnalyticsController::class, 'store']) + ->middleware('throttle:uploads-status') + ->name('api.analytics.feed.store'); + +Route::middleware(['web', 'auth'])->prefix('discovery')->name('api.discovery.')->group(function () { + Route::post('events', [\App\Http\Controllers\Api\DiscoveryEventController::class, 'store']) + ->middleware('throttle:uploads-status') + ->name('events.store'); +}); + +// Tag system (auth-protected; 404-only ownership checks handled in requests/controllers) +Route::middleware(['web', 'auth'])->prefix('tags')->name('api.tags.')->group(function () { + Route::get('search', [\App\Http\Controllers\Api\TagController::class, 'search'])->name('search'); + Route::get('popular', [\App\Http\Controllers\Api\TagController::class, 'popular'])->name('popular'); +}); + +Route::middleware(['web', 'auth'])->prefix('artworks')->name('api.artworks.tags.')->group(function () { + Route::post('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'store'])->whereNumber('id')->name('store'); + Route::put('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'update'])->whereNumber('id')->name('update'); + Route::delete('{id}/tags/{tag}', [\App\Http\Controllers\Api\ArtworkTagController::class, 'destroy'])->whereNumber('id')->name('destroy'); }); diff --git a/routes/console.php b/routes/console.php index 3c9adf1a..5d3e087e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,15 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use App\Uploads\Services\CleanupService; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Artisan::command('uploads:cleanup {--limit=100 : Maximum drafts to clean in one run}', function (): void { + $limit = (int) $this->option('limit'); + $deleted = app(CleanupService::class)->cleanupStaleDrafts($limit); + + $this->info("Uploads cleanup deleted {$deleted} draft(s)."); +})->purpose('Delete stale draft uploads and temporary files'); diff --git a/routes/legacy.php b/routes/legacy.php index 03018ca8..6c9d9309 100644 --- a/routes/legacy.php +++ b/routes/legacy.php @@ -46,25 +46,6 @@ Route::get('/news/{id}/{slug?}', [NewsController::class, 'show'])->where('id', ' Route::get('/categories', [CategoryController::class, 'index'])->name('legacy.categories'); Route::get('/category/{group}/{slug?}/{id?}', [CategoryController::class, 'show'])->name('legacy.category'); -// Short legacy routes for top-level category URLs like /Photography/3 -// Short legacy routes for top-level category URLs (mapped to CategoryController@show) -/* -Route::get('/Photography/{id}', [CategoryController::class, 'show']) - ->defaults('group', 'Photography') - ->where('id', '\\d+'); - -Route::get('/Wallpapers/{id}', [CategoryController::class, 'show']) - ->defaults('group', 'Wallpapers') - ->where('id', '\\d+'); - -Route::get('/Skins/{id}', [CategoryController::class, 'show']) - ->defaults('group', 'Skins') - ->where('id', '\\d+'); - -Route::get('/Other/{id}', [CategoryController::class, 'show']) - ->defaults('group', 'Other') - ->where('id', '\\d+'); -*/ Route::get('/browse', [BrowseController::class, 'index'])->name('legacy.browse'); Route::get('/featured', [FeaturedArtworksController::class, 'index'])->name('legacy.featured'); Route::get('/featured-artworks', [FeaturedArtworksController::class, 'index'])->name('legacy.featured_artworks'); @@ -104,9 +85,6 @@ Route::middleware('auth')->get('/recieved-comments', [ReceivedCommentsController // User account settings (legacy /user) Route::middleware('auth')->match(['get','post'], '/user', [LegacyUserController::class, 'index'])->name('legacy.user'); -// Content-type landing pages (legacy look) -Route::get('/photography', [PhotographyController::class, 'index'])->name('legacy.photography'); - Route::get('/today-in-history', [TodayInHistoryController::class, 'index'])->name('legacy.today_in_history'); Route::get('/today-downloads', [TodayDownloadsController::class, 'index'])->name('legacy.today_downloads'); diff --git a/routes/web.php b/routes/web.php index 40038cb3..013a97c3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,8 +1,12 @@ prefix('dashboard')->name('dashboard.')->group(func Route::middleware('auth')->group(function () { Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); - Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); + Route::match(['post','put','patch'], '/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); + // Password change endpoint (accepts POST or PUT from legacy and new forms) + Route::match(['post', 'put'], '/profile/password', [ProfileController::class, 'password'])->name('profile.password'); + // Avatar upload (backend only) - processes and stores avatars + Route::post('/avatar/upload', [AvatarController::class, 'upload'])->name('avatar.upload'); +}); + +Route::middleware(['auth'])->group(function () { + Route::get('/upload', function () { + $contentTypes = ContentType::with(['rootCategories.children'])->get()->map(function ($ct) { + return [ + 'id' => $ct->id, + 'name' => $ct->name, + 'categories' => $ct->rootCategories->map(function ($c) { + return [ + 'id' => $c->id, + 'name' => $c->name, + 'children' => $c->children->map(function ($ch) { + return ['id' => $ch->id, 'name' => $ch->name]; + })->values()->all(), + ]; + })->values()->all(), + ]; + })->values()->all(); + + return Inertia::render('Upload/Index', [ + 'draftId' => null, + 'content_types' => $contentTypes, + 'suggested_tags' => [], + 'filesCdnUrl' => config('cdn.files_url'), + 'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880), + 'feature_flags' => [ + 'uploads_v2' => (bool) config('features.uploads_v2', false), + ], + ]); + })->name('upload'); + + Route::get('/upload/draft/{id}', function (string $id) { + $contentTypes = ContentType::with(['rootCategories.children'])->get()->map(function ($ct) { + return [ + 'id' => $ct->id, + 'name' => $ct->name, + 'categories' => $ct->rootCategories->map(function ($c) { + return [ + 'id' => $c->id, + 'name' => $c->name, + 'children' => $c->children->map(function ($ch) { + return ['id' => $ch->id, 'name' => $ch->name]; + })->values()->all(), + ]; + })->values()->all(), + ]; + })->values()->all(); + + return Inertia::render('Upload/Index', [ + 'draftId' => $id, + 'content_types' => $contentTypes, + 'suggested_tags' => [], + 'filesCdnUrl' => config('cdn.files_url'), + 'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880), + 'feature_flags' => [ + 'uploads_v2' => (bool) config('features.uploads_v2', false), + ], + ]); + })->whereUuid('id')->name('upload.draft'); }); require __DIR__.'/auth.php'; +Route::get('/tag/{tag:slug}', [\App\Http\Controllers\Web\TagController::class, 'show']) + ->where('tag', '[a-z0-9\-]+') + ->name('tags.show'); + Route::view('/blank', 'blank')->name('blank'); -// Artwork public show (slug-based). This must come before the category route so the artwork -// slug (last segment) is matched correctly while allowing multi-segment category paths. -Route::get('/{contentTypeSlug}/{categoryPath}/{artwork}', [\App\Http\Controllers\ArtworkController::class, 'show']) - ->where([ - 'contentTypeSlug' => 'photography|wallpapers|skins|other', - 'categoryPath' => '.*', - 'artwork' => '[A-Za-z0-9\-]+' - ]) +// Bind the artwork route parameter to a model if it exists, otherwise return null +use App\Models\Artwork; +Route::bind('artwork', function ($value) { + return Artwork::where('slug', $value)->first(); +}); + +// Universal content router: handles content-type roots, nested categories and artwork slugs. +// Keep the explicit /photography route above (if present) so the legacy controller can continue +// to serve photography's root page. This catch-all route delegates to a controller that +// will forward to the appropriate existing controller (artwork or category handlers). +// Provide a named route alias for legacy artwork URL generation used in tests. +Route::get('/{contentTypeSlug}/{categoryPath}/{artwork}', [\App\Http\Controllers\ContentRouterController::class, 'handle']) + ->where('contentTypeSlug', 'photography|wallpapers|skins|other') + ->where('categoryPath', '[^/]+(?:/[^/]+)*') ->name('artworks.show'); -// New slug-based category routes (e.g., /photography/audio/winamp) -Route::get('/{contentTypeSlug}/{categoryPath?}', [\App\Http\Controllers\CategoryPageController::class, 'show']) +Route::get('/{contentTypeSlug}/{path?}', [\App\Http\Controllers\ContentRouterController::class, 'handle']) ->where('contentTypeSlug', 'photography|wallpapers|skins|other') - ->where('categoryPath', '.*') - ->name('category.slug'); + ->where('path', '.*') + ->name('content.route'); + + -use App\Http\Controllers\ManageController; Route::middleware(['auth'])->group(function () { Route::get('/manage', [ManageController::class, 'index'])->name('manage'); @@ -59,6 +137,10 @@ Route::middleware(['auth'])->group(function () { // Admin routes for artworks (separated from public routes) Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function () { + Route::get('uploads/moderation', function () { + return Inertia::render('Admin/UploadQueue'); + })->middleware('admin.moderation')->name('uploads.moderation'); + Route::resource('artworks', \App\Http\Controllers\Admin\ArtworkController::class)->except(['show']); }); diff --git a/scripts/check_intervention.php b/scripts/check_intervention.php new file mode 100644 index 00000000..1edbc899 --- /dev/null +++ b/scripts/check_intervention.php @@ -0,0 +1,10 @@ +getMessage() . PHP_EOL; +} diff --git a/scripts/vision-smoke.ps1 b/scripts/vision-smoke.ps1 new file mode 100644 index 00000000..5a57973b --- /dev/null +++ b/scripts/vision-smoke.ps1 @@ -0,0 +1,237 @@ +param( + [string]$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot ".." )).Path, + [string]$EnvFile = ".env", + [string]$SampleImageUrl = "https://files.skinbase.org/img/aa/bb/cc/md.webp", + [switch]$SkipAnalyze +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Write-Info([string]$Message) { + Write-Host "[INFO] $Message" -ForegroundColor Cyan +} + +function Write-Ok([string]$Message) { + Write-Host "[OK] $Message" -ForegroundColor Green +} + +function Write-Fail([string]$Message) { + Write-Host "[FAIL] $Message" -ForegroundColor Red +} + +function Get-EnvMap([string]$Root, [string]$RelativeEnvFile) { + $map = @{} + + $envPath = Join-Path $Root $RelativeEnvFile + if (-not (Test-Path $envPath)) { + $fallback = Join-Path $Root ".env.example" + if (Test-Path $fallback) { + Write-Info "Env file '$RelativeEnvFile' not found. Falling back to .env.example." + $envPath = $fallback + } else { + throw "Neither '$RelativeEnvFile' nor '.env.example' was found in $Root" + } + } + + Get-Content -Path $envPath | ForEach-Object { + $line = $_.Trim() + if ($line -eq "" -or $line.StartsWith("#")) { return } + $idx = $line.IndexOf("=") + if ($idx -lt 1) { return } + + $key = $line.Substring(0, $idx).Trim() + $val = $line.Substring($idx + 1).Trim() + + if ($val.StartsWith('"') -and $val.EndsWith('"') -and $val.Length -ge 2) { + $val = $val.Substring(1, $val.Length - 2) + } + + if (-not $map.ContainsKey($key)) { + $map[$key] = $val + } + } + + return $map +} + +function Get-Setting([hashtable]$Map, [string]$Key, [string]$Default = "") { + $fromProcess = [Environment]::GetEnvironmentVariable($Key) + if (-not [string]::IsNullOrWhiteSpace($fromProcess)) { + return $fromProcess.Trim() + } + + if ($Map.ContainsKey($Key)) { + return [string]$Map[$Key] + } + + return $Default +} + +function Test-Truthy([string]$Value, [bool]$Default = $false) { + if ([string]::IsNullOrWhiteSpace($Value)) { return $Default } + + switch ($Value.Trim().ToLowerInvariant()) { + "1" { return $true } + "true" { return $true } + "yes" { return $true } + "on" { return $true } + "0" { return $false } + "false" { return $false } + "no" { return $false } + "off" { return $false } + default { return $Default } + } +} + +function Join-Url([string]$Base, [string]$Path) { + $left = $Base.TrimEnd('/') + $right = $Path.TrimStart('/') + return "$left/$right" +} + +function Invoke-Health([string]$Name, [string]$BaseUrl) { + if ([string]::IsNullOrWhiteSpace($BaseUrl)) { + throw "$Name base URL is empty" + } + + $url = Join-Url $BaseUrl "/health" + Write-Info "Checking $Name health: $url" + + try { + $response = Invoke-WebRequest -Uri $url -Method GET -TimeoutSec 10 -UseBasicParsing + } catch { + throw "$Name health request failed: $($_.Exception.Message)" + } + + if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 300) { + throw "$Name health returned status $($response.StatusCode)" + } + + Write-Ok "$Name health check passed" +} + +function Invoke-Analyze([string]$ClipBaseUrl, [string]$AnalyzeEndpoint, [string]$ImageUrl) { + if ([string]::IsNullOrWhiteSpace($ClipBaseUrl)) { + throw "CLIP base URL is empty" + } + + $url = Join-Url $ClipBaseUrl $AnalyzeEndpoint + Write-Info "Running sample CLIP analyze call: $url" + + $payload = @{ + image_url = $ImageUrl + } | ConvertTo-Json -Depth 4 + + try { + $response = Invoke-WebRequest -Uri $url -Method POST -ContentType "application/json" -Body $payload -TimeoutSec 15 -UseBasicParsing + } catch { + throw "CLIP analyze request failed: $($_.Exception.Message)" + } + + if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 300) { + throw "CLIP analyze returned status $($response.StatusCode)" + } + + $json = $null + try { + $json = $response.Content | ConvertFrom-Json + } catch { + throw "CLIP analyze response is not valid JSON" + } + + $hasTags = $false + if ($json -is [System.Collections.IEnumerable] -and -not ($json -is [string])) { + $hasTags = $true + } + if ($null -ne $json.tags) { $hasTags = $true } + if ($null -ne $json.data) { $hasTags = $true } + + if (-not $hasTags) { + throw "CLIP analyze response does not contain expected tags/data payload" + } + + Write-Ok "Sample CLIP analyze call passed" +} + +function Test-NonBlockingPublish([string]$Root) { + Write-Info "Validating non-blocking publish path (code assertions)" + + $uploadController = Join-Path $Root "app/Http/Controllers/Api/UploadController.php" + $derivativesJob = Join-Path $Root "app/Jobs/GenerateDerivativesJob.php" + + if (-not (Test-Path $uploadController)) { throw "Missing file: $uploadController" } + if (-not (Test-Path $derivativesJob)) { throw "Missing file: $derivativesJob" } + + $uploadText = Get-Content -Raw -Path $uploadController + $jobText = Get-Content -Raw -Path $derivativesJob + + if ($uploadText -notmatch "AutoTagArtworkJob::dispatch\(") { + throw "UploadController does not dispatch AutoTagArtworkJob" + } + + if ($jobText -notmatch "AutoTagArtworkJob::dispatch\(") { + throw "GenerateDerivativesJob does not dispatch AutoTagArtworkJob" + } + + if ($uploadText -match "dispatchSync\(" -or $jobText -match "dispatchSync\(") { + throw "Found dispatchSync in publish path; auto-tagging must remain async" + } + + if ($uploadText -match "Illuminate\\Support\\Facades\\Http" -or $uploadText -match "Http::") { + throw "UploadController appears to call external vision HTTP directly" + } + + Write-Ok "Non-blocking publish path validation passed" +} + +$failed = $false + +try { + Write-Info "Vision smoke test starting" + Write-Info "Project root: $ProjectRoot" + + $envMap = Get-EnvMap -Root $ProjectRoot -RelativeEnvFile $EnvFile + + $visionEnabled = Test-Truthy (Get-Setting -Map $envMap -Key "VISION_ENABLED" -Default "true") $true + if (-not $visionEnabled) { + throw "VISION_ENABLED=false. Vision integration is disabled; smoke check cannot continue." + } + + $clipBaseUrl = Get-Setting -Map $envMap -Key "CLIP_BASE_URL" + $clipAnalyzeEndpoint = Get-Setting -Map $envMap -Key "CLIP_ANALYZE_ENDPOINT" -Default "/analyze" + + $yoloEnabled = Test-Truthy (Get-Setting -Map $envMap -Key "YOLO_ENABLED" -Default "true") $true + $yoloBaseUrl = Get-Setting -Map $envMap -Key "YOLO_BASE_URL" + + Invoke-Health -Name "CLIP" -BaseUrl $clipBaseUrl + + if ($yoloEnabled) { + if ([string]::IsNullOrWhiteSpace($yoloBaseUrl)) { + Write-Info "YOLO is enabled but YOLO_BASE_URL is empty; skipping YOLO /health check." + } else { + Invoke-Health -Name "YOLO" -BaseUrl $yoloBaseUrl + } + } else { + Write-Info "YOLO is disabled; skipping YOLO /health check." + } + + if ($SkipAnalyze) { + Write-Info "Skipping sample analyze call (SkipAnalyze set)." + } else { + Invoke-Analyze -ClipBaseUrl $clipBaseUrl -AnalyzeEndpoint $clipAnalyzeEndpoint -ImageUrl $SampleImageUrl + } + + Test-NonBlockingPublish -Root $ProjectRoot + + Write-Ok "Vision smoke test completed successfully" +} catch { + $failed = $true + Write-Fail $_.Exception.Message +} + +if ($failed) { + exit 1 +} + +exit 0 diff --git a/tests/Feature/Admin/FeedPerformanceReportTest.php b/tests/Feature/Admin/FeedPerformanceReportTest.php new file mode 100644 index 00000000..c429a37b --- /dev/null +++ b/tests/Feature/Admin/FeedPerformanceReportTest.php @@ -0,0 +1,104 @@ +create(['role' => 'admin']); + + $artworkA = Artwork::factory()->create(['title' => 'Feed Artwork A']); + $artworkB = Artwork::factory()->create(['title' => 'Feed Artwork B']); + + $metricDate = now()->subDay()->toDateString(); + + DB::table('feed_daily_metrics')->insert([ + [ + 'metric_date' => $metricDate, + 'algo_version' => 'clip-cosine-v1', + 'source' => 'personalized', + 'impressions' => 10, + 'clicks' => 4, + 'saves' => 2, + 'ctr' => 0.4, + 'save_rate' => 0.5, + 'dwell_0_5' => 1, + 'dwell_5_30' => 1, + 'dwell_30_120' => 1, + 'dwell_120_plus' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + DB::table('feed_events')->insert([ + [ + 'event_date' => $metricDate, + 'event_type' => 'feed_impression', + 'user_id' => $admin->id, + 'artwork_id' => $artworkA->id, + 'position' => 1, + 'algo_version' => 'clip-cosine-v1', + 'source' => 'personalized', + 'dwell_seconds' => null, + 'occurred_at' => now()->subDay(), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'event_date' => $metricDate, + 'event_type' => 'feed_click', + 'user_id' => $admin->id, + 'artwork_id' => $artworkA->id, + 'position' => 1, + 'algo_version' => 'clip-cosine-v1', + 'source' => 'personalized', + 'dwell_seconds' => 12, + 'occurred_at' => now()->subDay(), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'event_date' => $metricDate, + 'event_type' => 'feed_click', + 'user_id' => $admin->id, + 'artwork_id' => $artworkB->id, + 'position' => 2, + 'algo_version' => 'clip-cosine-v1', + 'source' => 'personalized', + 'dwell_seconds' => 7, + 'occurred_at' => now()->subDay(), + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $response = $this->actingAs($admin)->getJson('/api/admin/reports/feed-performance?from=' . $metricDate . '&to=' . $metricDate); + + $response->assertOk(); + $response->assertJsonPath('meta.from', $metricDate); + $response->assertJsonPath('meta.to', $metricDate); + + $rows = collect($response->json('by_algo_source')); + expect($rows->count())->toBe(1); + expect($rows->first()['algo_version'])->toBe('clip-cosine-v1'); + expect($rows->first()['source'])->toBe('personalized'); + expect((float) $rows->first()['ctr'])->toBe(0.4); + + $top = collect($response->json('top_clicked_artworks')); + expect($top->isNotEmpty())->toBeTrue(); + expect((int) $top->first()['artwork_id'])->toBe($artworkA->id); +}); + +it('non-admin is denied feed performance report endpoint', function () { + $user = User::factory()->create(['role' => 'user']); + + $response = $this->actingAs($user)->getJson('/api/admin/reports/feed-performance'); + + $response->assertStatus(403); +}); diff --git a/tests/Feature/Admin/SimilarArtworkReportTest.php b/tests/Feature/Admin/SimilarArtworkReportTest.php new file mode 100644 index 00000000..186e3403 --- /dev/null +++ b/tests/Feature/Admin/SimilarArtworkReportTest.php @@ -0,0 +1,99 @@ +create(['role' => 'admin']); + + $sourceA = Artwork::factory()->create(['title' => 'Source A']); + $similarA = Artwork::factory()->create(['title' => 'Similar A']); + $sourceB = Artwork::factory()->create(['title' => 'Source B']); + $similarB = Artwork::factory()->create(['title' => 'Similar B']); + + $inRangeDate = now()->subDay()->toDateString(); + $outRangeDate = now()->subDays(5)->toDateString(); + + DB::table('similar_artwork_events')->insert([ + [ + 'event_date' => $inRangeDate, + 'event_type' => 'impression', + 'algo_version' => 'clip-cosine-v1', + 'source_artwork_id' => $sourceA->id, + 'similar_artwork_id' => $similarA->id, + 'position' => 1, + 'items_count' => 4, + 'occurred_at' => now()->subDay(), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'event_date' => $inRangeDate, + 'event_type' => 'click', + 'algo_version' => 'clip-cosine-v1', + 'source_artwork_id' => $sourceA->id, + 'similar_artwork_id' => $similarA->id, + 'position' => 1, + 'items_count' => null, + 'occurred_at' => now()->subDay(), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'event_date' => $inRangeDate, + 'event_type' => 'impression', + 'algo_version' => 'clip-cosine-v2', + 'source_artwork_id' => $sourceB->id, + 'similar_artwork_id' => $similarB->id, + 'position' => 2, + 'items_count' => 4, + 'occurred_at' => now()->subDay(), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'event_date' => $outRangeDate, + 'event_type' => 'click', + 'algo_version' => 'clip-cosine-v1', + 'source_artwork_id' => $sourceA->id, + 'similar_artwork_id' => $similarA->id, + 'position' => 1, + 'items_count' => null, + 'occurred_at' => now()->subDays(5), + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $response = $this->actingAs($admin)->getJson('/api/admin/reports/similar-artworks?from=' . $inRangeDate . '&to=' . $inRangeDate); + + $response->assertOk(); + $response->assertJsonPath('meta.from', $inRangeDate); + $response->assertJsonPath('meta.to', $inRangeDate); + + $byAlgo = collect($response->json('by_algo_version')); + expect($byAlgo->count())->toBe(2); + + $v1 = $byAlgo->firstWhere('algo_version', 'clip-cosine-v1'); + expect($v1['impressions'])->toBe(1); + expect($v1['clicks'])->toBe(1); + expect((float) $v1['ctr'])->toBe(1.0); + + $top = collect($response->json('top_similarities')); + expect($top->isNotEmpty())->toBeTrue(); + expect($top->first()['source_artwork_id'])->toBe($sourceA->id); + expect($top->first()['similar_artwork_id'])->toBe($similarA->id); + expect((float) $top->first()['ctr'])->toBe(1.0); +}); + +it('non-admin is denied similar artwork report endpoint', function () { + $user = User::factory()->create(['role' => 'user']); + + $response = $this->actingAs($user)->getJson('/api/admin/reports/similar-artworks'); + + $response->assertStatus(403); +}); diff --git a/tests/Feature/Admin/UploadModerationTest.php b/tests/Feature/Admin/UploadModerationTest.php new file mode 100644 index 00000000..7b50779c --- /dev/null +++ b/tests/Feature/Admin/UploadModerationTest.php @@ -0,0 +1,168 @@ +insertGetId([ + 'name' => 'Photography', + 'slug' => 'photography-' . Str::lower(Str::random(6)), + 'description' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return DB::table('categories')->insertGetId([ + 'content_type_id' => $contentTypeId, + 'parent_id' => null, + 'name' => 'Moderation', + 'slug' => 'moderation-' . Str::lower(Str::random(6)), + 'description' => null, + 'image' => null, + 'is_active' => true, + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +function createModerationDraft(int $userId, int $categoryId, array $overrides = []): string +{ + $uploadId = (string) Str::uuid(); + + DB::table('uploads')->insert(array_merge([ + 'id' => $uploadId, + 'user_id' => $userId, + 'type' => 'image', + 'status' => 'draft', + 'processing_state' => 'ready', + 'moderation_status' => 'pending', + 'title' => 'Pending Moderation Upload', + 'category_id' => $categoryId, + 'is_scanned' => true, + 'has_tags' => true, + 'preview_path' => "tmp/drafts/{$uploadId}/preview.webp", + 'created_at' => now(), + 'updated_at' => now(), + ], $overrides)); + + return $uploadId; +} + +function addReadyMainFile(string $uploadId, string $hash = 'aabbccddeeff00112233'): void +{ + Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/main.jpg", 'jpg'); + Storage::disk('local')->put("tmp/drafts/{$uploadId}/preview.webp", 'preview'); + + DB::table('upload_files')->insert([ + 'upload_id' => $uploadId, + 'path' => "tmp/drafts/{$uploadId}/main/main.jpg", + 'type' => 'main', + 'hash' => $hash, + 'size' => 3, + 'mime' => 'image/jpeg', + 'created_at' => now(), + ]); +} + +it('admin sees pending uploads', function () { + $admin = User::factory()->create(['role' => 'admin']); + $owner = User::factory()->create(); + $categoryId = createModerationCategory(); + + createModerationDraft($owner->id, $categoryId, ['title' => 'First Pending']); + createModerationDraft($owner->id, $categoryId, ['title' => 'Second Pending']); + + $response = $this->actingAs($admin)->getJson('/api/admin/uploads/pending'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); +}); + +it('non-admin is denied moderation API access', function () { + $user = User::factory()->create(['role' => 'user']); + + $response = $this->actingAs($user)->getJson('/api/admin/uploads/pending'); + + $response->assertStatus(403); +}); + +it('approve works', function () { + $admin = User::factory()->create(['role' => 'moderator']); + $owner = User::factory()->create(); + $categoryId = createModerationCategory(); + $uploadId = createModerationDraft($owner->id, $categoryId); + + $response = $this->actingAs($admin)->postJson("/api/admin/uploads/{$uploadId}/approve", [ + 'note' => 'Looks good.', + ]); + + $response->assertOk(); + + $row = DB::table('uploads')->where('id', $uploadId)->first([ + 'moderation_status', + 'moderation_note', + 'moderated_by', + 'moderated_at', + ]); + + expect($row->moderation_status)->toBe('approved'); + expect($row->moderation_note)->toBe('Looks good.'); + expect((int) $row->moderated_by)->toBe((int) $admin->id); + expect($row->moderated_at)->not->toBeNull(); +}); + +it('reject works', function () { + $admin = User::factory()->create(['role' => 'admin']); + $owner = User::factory()->create(); + $categoryId = createModerationCategory(); + $uploadId = createModerationDraft($owner->id, $categoryId); + + $response = $this->actingAs($admin)->postJson("/api/admin/uploads/{$uploadId}/reject", [ + 'note' => 'Policy violation.', + ]); + + $response->assertOk(); + + $row = DB::table('uploads')->where('id', $uploadId)->first([ + 'status', + 'processing_state', + 'moderation_status', + 'moderation_note', + 'moderated_by', + 'moderated_at', + ]); + + expect($row->status)->toBe('rejected'); + expect($row->processing_state)->toBe('rejected'); + expect($row->moderation_status)->toBe('rejected'); + expect($row->moderation_note)->toBe('Policy violation.'); + expect((int) $row->moderated_by)->toBe((int) $admin->id); + expect($row->moderated_at)->not->toBeNull(); +}); + +it('user cannot publish without approval', function () { + Storage::fake('local'); + + $owner = User::factory()->create(['role' => 'user']); + $categoryId = createModerationCategory(); + $uploadId = createModerationDraft($owner->id, $categoryId, [ + 'moderation_status' => 'pending', + 'title' => 'Blocked Publish', + ]); + + addReadyMainFile($uploadId); + + $response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/publish"); + + $response->assertStatus(422); + $response->assertJsonFragment([ + 'message' => 'Upload requires moderation approval before publish.', + ]); +}); diff --git a/tests/Feature/Analytics/FeedAnalyticsTest.php b/tests/Feature/Analytics/FeedAnalyticsTest.php new file mode 100644 index 00000000..cce41f1c --- /dev/null +++ b/tests/Feature/Analytics/FeedAnalyticsTest.php @@ -0,0 +1,138 @@ +create(); + $artwork = Artwork::factory()->create(); + + $response = $this->actingAs($user)->postJson('/api/analytics/feed', [ + 'event_type' => 'feed_click', + 'artwork_id' => $artwork->id, + 'position' => 3, + 'algo_version' => 'clip-cosine-v1', + 'source' => 'personalized', + 'dwell_seconds' => 27, + ]); + + $response->assertOk()->assertJson(['success' => true]); + + $this->assertDatabaseHas('feed_events', [ + 'user_id' => $user->id, + 'artwork_id' => $artwork->id, + 'event_type' => 'feed_click', + 'position' => 3, + 'algo_version' => 'clip-cosine-v1', + 'source' => 'personalized', + 'dwell_seconds' => 27, + ]); +}); + +it('aggregates daily feed analytics with ctr save-rate and dwell buckets', function () { + $user = User::factory()->create(); + + $artworkA = Artwork::factory()->create(); + $artworkB = Artwork::factory()->create(); + + $metricDate = now()->subDay()->toDateString(); + + DB::table('feed_events')->insert([ + [ + 'event_date' => $metricDate, + 'event_type' => 'feed_impression', + 'user_id' => $user->id, + 'artwork_id' => $artworkA->id, + 'position' => 1, + 'algo_version' => 'clip-cosine-v1', + 'source' => 'personalized', + 'dwell_seconds' => null, + 'occurred_at' => now()->subDay(), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'event_date' => $metricDate, + 'event_type' => 'feed_impression', + 'user_id' => $user->id, + 'artwork_id' => $artworkB->id, + 'position' => 2, + 'algo_version' => 'clip-cosine-v1', + 'source' => 'personalized', + 'dwell_seconds' => null, + 'occurred_at' => now()->subDay(), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'event_date' => $metricDate, + 'event_type' => 'feed_click', + 'user_id' => $user->id, + 'artwork_id' => $artworkA->id, + 'position' => 1, + 'algo_version' => 'clip-cosine-v1', + 'source' => 'personalized', + 'dwell_seconds' => 3, + 'occurred_at' => now()->subDay(), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'event_date' => $metricDate, + 'event_type' => 'feed_click', + 'user_id' => $user->id, + 'artwork_id' => $artworkB->id, + 'position' => 2, + 'algo_version' => 'clip-cosine-v1', + 'source' => 'personalized', + 'dwell_seconds' => 35, + 'occurred_at' => now()->subDay(), + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + DB::table('user_discovery_events')->insert([ + 'event_id' => '33333333-3333-3333-3333-333333333333', + 'user_id' => $user->id, + 'artwork_id' => $artworkA->id, + 'category_id' => null, + 'event_type' => 'favorite', + 'event_version' => 'event-v1', + 'algo_version' => 'clip-cosine-v1', + 'weight' => 1, + 'event_date' => $metricDate, + 'occurred_at' => now()->subDay(), + 'meta' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->artisan('analytics:aggregate-feed', ['--date' => $metricDate])->assertSuccessful(); + + $this->assertDatabaseHas('feed_daily_metrics', [ + 'metric_date' => $metricDate, + 'algo_version' => 'clip-cosine-v1', + 'source' => 'personalized', + 'impressions' => 2, + 'clicks' => 2, + 'saves' => 1, + 'dwell_0_5' => 1, + 'dwell_30_120' => 1, + ]); + + $metric = DB::table('feed_daily_metrics') + ->where('metric_date', $metricDate) + ->where('algo_version', 'clip-cosine-v1') + ->where('source', 'personalized') + ->first(); + + expect((float) $metric->ctr)->toBe(1.0); + expect((float) $metric->save_rate)->toBe(0.5); +}); diff --git a/tests/Feature/Analytics/FeedEvaluationCommandsTest.php b/tests/Feature/Analytics/FeedEvaluationCommandsTest.php new file mode 100644 index 00000000..b8faaca4 --- /dev/null +++ b/tests/Feature/Analytics/FeedEvaluationCommandsTest.php @@ -0,0 +1,93 @@ +subDay()->toDateString(); + + DB::table('feed_daily_metrics')->insert([ + [ + 'metric_date' => $metricDate, + 'algo_version' => 'clip-cosine-v1', + 'source' => 'personalized', + 'impressions' => 100, + 'clicks' => 20, + 'saves' => 6, + 'ctr' => 0.2, + 'save_rate' => 0.3, + 'dwell_0_5' => 4, + 'dwell_5_30' => 8, + 'dwell_30_120' => 5, + 'dwell_120_plus' => 3, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'metric_date' => $metricDate, + 'algo_version' => 'clip-cosine-v2', + 'source' => 'personalized', + 'impressions' => 100, + 'clicks' => 22, + 'saves' => 8, + 'ctr' => 0.22, + 'save_rate' => 0.36, + 'dwell_0_5' => 3, + 'dwell_5_30' => 9, + 'dwell_30_120' => 6, + 'dwell_120_plus' => 4, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $this->artisan('analytics:evaluate-feed-weights', ['--from' => $metricDate, '--to' => $metricDate]) + ->assertSuccessful(); +}); + +it('compares baseline and candidate feed algos', function () { + $metricDate = now()->subDay()->toDateString(); + + DB::table('feed_daily_metrics')->insert([ + [ + 'metric_date' => $metricDate, + 'algo_version' => 'clip-cosine-v1', + 'source' => 'personalized', + 'impressions' => 100, + 'clicks' => 20, + 'saves' => 6, + 'ctr' => 0.2, + 'save_rate' => 0.3, + 'dwell_0_5' => 4, + 'dwell_5_30' => 8, + 'dwell_30_120' => 5, + 'dwell_120_plus' => 3, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'metric_date' => $metricDate, + 'algo_version' => 'clip-cosine-v2', + 'source' => 'personalized', + 'impressions' => 100, + 'clicks' => 24, + 'saves' => 10, + 'ctr' => 0.24, + 'save_rate' => 0.416, + 'dwell_0_5' => 3, + 'dwell_5_30' => 8, + 'dwell_30_120' => 7, + 'dwell_120_plus' => 6, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $this->artisan('analytics:compare-feed-ab', [ + 'baseline' => 'clip-cosine-v1', + 'candidate' => 'clip-cosine-v2', + '--from' => $metricDate, + '--to' => $metricDate, + ])->assertSuccessful(); +}); diff --git a/tests/Feature/Analytics/SimilarArtworkAnalyticsTest.php b/tests/Feature/Analytics/SimilarArtworkAnalyticsTest.php new file mode 100644 index 00000000..094c9601 --- /dev/null +++ b/tests/Feature/Analytics/SimilarArtworkAnalyticsTest.php @@ -0,0 +1,72 @@ +create(); + + $source = Artwork::factory()->create(['user_id' => $author->id]); + $similar = Artwork::factory()->create(['user_id' => $author->id]); + + $response = $this->postJson('/api/analytics/similar-artworks', [ + 'event_type' => 'click', + 'algo_version' => 'clip-cosine-v1', + 'source_artwork_id' => $source->id, + 'similar_artwork_id' => $similar->id, + 'position' => 2, + ]); + + $response->assertOk()->assertJson(['success' => true]); + + $this->assertDatabaseHas('similar_artwork_events', [ + 'event_type' => 'click', + 'algo_version' => 'clip-cosine-v1', + 'source_artwork_id' => $source->id, + 'similar_artwork_id' => $similar->id, + 'position' => 2, + ]); +}); + +it('aggregates daily analytics counts by algo version', function () { + DB::table('similar_artwork_events')->insert([ + [ + 'event_date' => now()->subDay()->toDateString(), + 'event_type' => 'impression', + 'algo_version' => 'clip-cosine-v1', + 'source_artwork_id' => Artwork::factory()->create()->id, + 'similar_artwork_id' => null, + 'position' => null, + 'items_count' => 8, + 'occurred_at' => now()->subDay(), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'event_date' => now()->subDay()->toDateString(), + 'event_type' => 'click', + 'algo_version' => 'clip-cosine-v1', + 'source_artwork_id' => Artwork::factory()->create()->id, + 'similar_artwork_id' => Artwork::factory()->create()->id, + 'position' => 1, + 'items_count' => null, + 'occurred_at' => now()->subDay(), + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $this->artisan('analytics:aggregate-similar-artworks', ['--date' => now()->subDay()->toDateString()]) + ->assertSuccessful(); + + $this->assertDatabaseHas('similar_artwork_daily_metrics', [ + 'metric_date' => now()->subDay()->toDateString(), + 'algo_version' => 'clip-cosine-v1', + 'impressions' => 1, + 'clicks' => 1, + ]); +}); diff --git a/tests/Feature/ArtworkJsonLdTest.php b/tests/Feature/ArtworkJsonLdTest.php new file mode 100644 index 00000000..b4e16073 --- /dev/null +++ b/tests/Feature/ArtworkJsonLdTest.php @@ -0,0 +1,117 @@ +create(['name' => 'Schema Author']); + + $contentType = ContentType::create([ + 'name' => 'Photography', + 'slug' => 'photography', + 'description' => 'Photography content', + ]); + + $category = Category::create([ + 'content_type_id' => $contentType->id, + 'parent_id' => null, + 'name' => 'Abstract', + 'slug' => 'abstract-' . Str::lower(Str::random(5)), + 'description' => 'Abstract works', + 'is_active' => true, + 'sort_order' => 0, + ]); + + $artwork = Artwork::factory()->create([ + 'user_id' => $user->id, + 'title' => 'Schema Ready Artwork', + 'slug' => 'schema-ready-artwork', + 'description' => 'Artwork description for schema test.', + 'mime_type' => 'image/jpeg', + 'published_at' => now()->subMinute(), + 'is_public' => true, + 'is_approved' => true, + ]); + + $artwork->categories()->attach($category->id); + + $tagA = Tag::create(['name' => 'neon', 'slug' => 'neon', 'usage_count' => 0, 'is_active' => true]); + $tagB = Tag::create(['name' => 'city', 'slug' => 'city', 'usage_count' => 0, 'is_active' => true]); + $artwork->tags()->attach([ + $tagA->id => ['source' => 'user', 'confidence' => 0.9], + $tagB->id => ['source' => 'user', 'confidence' => 0.8], + ]); + + $html = view('artworks.show', ['artwork' => $artwork])->render(); + + expect($html) + ->toContain('application/ld+json') + ->toContain('"@type":"ImageObject"') + ->toContain('"name":"Schema Ready Artwork"') + ->toContain('"keywords":["neon","city"]'); +}); + +it('renders JSON-LD via routed artwork show endpoint', function () { + $user = User::factory()->create(['name' => 'Schema Route Author']); + + $contentType = ContentType::create([ + 'name' => 'Photography', + 'slug' => 'photography', + 'description' => 'Photography content', + ]); + + $category = Category::create([ + 'content_type_id' => $contentType->id, + 'parent_id' => null, + 'name' => 'Abstract', + 'slug' => 'abstract-route', + 'description' => 'Abstract works', + 'is_active' => true, + 'sort_order' => 0, + ]); + + $artwork = Artwork::factory()->create([ + 'user_id' => $user->id, + 'title' => 'Schema Route Artwork', + 'slug' => 'schema-route-artwork', + 'description' => 'Artwork description for routed schema test.', + 'mime_type' => 'image/png', + 'published_at' => now()->subMinute(), + 'is_public' => true, + 'is_approved' => true, + ]); + + $artwork->categories()->attach($category->id); + + $tag = Tag::create(['name' => 'route-tag', 'slug' => 'route-tag', 'usage_count' => 0, 'is_active' => true]); + $artwork->tags()->attach([ + $tag->id => ['source' => 'user', 'confidence' => 0.95], + ]); + + $url = route('artworks.show', [ + 'contentTypeSlug' => $contentType->slug, + 'categoryPath' => $category->slug, + 'artwork' => $artwork->slug, + ]); + + expect($url)->toContain('/photography/abstract-route/schema-route-artwork'); + + $matchedRoute = app('router')->getRoutes()->match(Request::create($url, 'GET')); + expect($matchedRoute->getName())->toBe('artworks.show'); + + $response = $this->get($url); + + $response->assertOk(); + $response->assertSee('application/ld+json', false); + $response->assertSee('"@type":"ImageObject"', false); + $response->assertSee('"name":"Schema Route Artwork"', false); + $response->assertSee('"keywords":["route-tag"]', false); +}); diff --git a/tests/Feature/AutoTagArtworkJobTest.php b/tests/Feature/AutoTagArtworkJobTest.php new file mode 100644 index 00000000..fd4d380f --- /dev/null +++ b/tests/Feature/AutoTagArtworkJobTest.php @@ -0,0 +1,76 @@ +set('vision.enabled', true); + config()->set('vision.clip.base_url', 'https://clip.local'); + config()->set('vision.clip.endpoint', '/analyze'); + config()->set('vision.yolo.enabled', false); + config()->set('cdn.files_url', 'https://files.local'); + + Http::fake([ + 'https://clip.local/analyze' => Http::response([ + ['tag' => 'Cyber Punk', 'confidence' => 0.42], + ['tag' => 'City', 'confidence' => 0.31], + ], 200), + ]); + + $artwork = Artwork::factory()->create(); + $hash = 'abcdef123456'; + + (new AutoTagArtworkJob($artwork->id, $hash))->handle(app(\App\Services\TagService::class), app(\App\Services\TagNormalizer::class)); + + expect(Tag::query()->whereIn('slug', ['cyber-punk', 'city'])->count())->toBe(2); + expect($artwork->tags()->pluck('slug')->all())->toContain('cyber-punk', 'city'); +}); + +it('optionally calls YOLO for photography', function () { + config()->set('vision.enabled', true); + config()->set('vision.clip.base_url', 'https://clip.local'); + config()->set('vision.yolo.base_url', 'https://yolo.local'); + config()->set('vision.yolo.enabled', true); + config()->set('vision.yolo.photography_only', true); + config()->set('cdn.files_url', 'https://files.local'); + + Http::fake([ + 'https://clip.local/analyze' => Http::response([['tag' => 'tree', 'confidence' => 0.2]], 200), + 'https://yolo.local/analyze' => Http::response(['objects' => [['label' => 'person', 'confidence' => 0.9]]], 200), + ]); + + $photoType = ContentType::query()->create(['name' => 'Photography', 'slug' => 'photography', 'description' => '']); + $cat = Category::query()->create(['content_type_id' => $photoType->id, 'parent_id' => null, 'name' => 'Test', 'slug' => 'test', 'description' => '', 'image' => null, 'is_active' => true, 'sort_order' => 0]); + + $artwork = Artwork::factory()->create(); + $artwork->categories()->attach($cat->id); + + $hash = 'abcdef123456'; + (new AutoTagArtworkJob($artwork->id, $hash))->handle(app(\App\Services\TagService::class), app(\App\Services\TagNormalizer::class)); + + expect($artwork->tags()->pluck('slug')->all())->toContain('tree', 'person'); +}); + +it('does not throw on CLIP 4xx and never blocks publish', function () { + config()->set('vision.enabled', true); + config()->set('vision.clip.base_url', 'https://clip.local'); + config()->set('vision.yolo.enabled', false); + config()->set('cdn.files_url', 'https://files.local'); + + Http::fake([ + 'https://clip.local/analyze' => Http::response(['message' => 'bad'], 422), + ]); + + $artwork = Artwork::factory()->create(); + $hash = 'abcdef123456'; + + (new AutoTagArtworkJob($artwork->id, $hash))->handle(app(\App\Services\TagService::class), app(\App\Services\TagNormalizer::class)); + + expect($artwork->tags()->count())->toBe(0); +}); diff --git a/tests/Feature/AvatarUploadTest.php b/tests/Feature/AvatarUploadTest.php new file mode 100644 index 00000000..aafbf672 --- /dev/null +++ b/tests/Feature/AvatarUploadTest.php @@ -0,0 +1,15 @@ +postJson(route('avatar.upload')); + $response->assertStatus(401); + } +} diff --git a/tests/Feature/ContentRouterControllerTest.php b/tests/Feature/ContentRouterControllerTest.php new file mode 100644 index 00000000..4fa59c75 --- /dev/null +++ b/tests/Feature/ContentRouterControllerTest.php @@ -0,0 +1,56 @@ +create([ + 'slug' => 'bound-model-artwork', + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subMinute(), + ]); + + $request = Request::create('/photography/abstract/bound-model-artwork', 'GET'); + + $response = app(ContentRouterController::class)->handle( + $request, + 'photography', + 'abstract', + $artwork + ); + + expect($response)->toBeInstanceOf(View::class); + expect($response->name())->toBe('artworks.show'); + expect($response->getData()['artwork']->id)->toBe($artwork->id); +}); + +it('binds routed artwork model and renders published artwork page', function () { + $artwork = Artwork::factory()->create([ + 'title' => 'Routed Binding Artwork', + 'slug' => 'routed-binding-artwork', + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subMinute(), + ]); + + $url = route('artworks.show', [ + 'contentTypeSlug' => 'photography', + 'categoryPath' => 'abstract', + 'artwork' => $artwork->slug, + ]); + + $matchedRoute = app('router')->getRoutes()->match(Request::create($url, 'GET')); + app('router')->substituteBindings($matchedRoute); + expect($matchedRoute->parameter('artwork'))->toBeInstanceOf(Artwork::class); + expect($matchedRoute->parameter('artwork')->id)->toBe($artwork->id); + + $this->get($url) + ->assertOk() + ->assertSee('Routed Binding Artwork'); +}); diff --git a/tests/Feature/Discovery/DiscoveryEventIngestionTest.php b/tests/Feature/Discovery/DiscoveryEventIngestionTest.php new file mode 100644 index 00000000..e7b537f7 --- /dev/null +++ b/tests/Feature/Discovery/DiscoveryEventIngestionTest.php @@ -0,0 +1,48 @@ +create(); + $artwork = Artwork::factory()->create(); + + $response = $this->actingAs($user)->postJson('/api/discovery/events', [ + 'event_type' => 'view', + 'artwork_id' => $artwork->id, + 'meta' => ['source' => 'artwork_show'], + ]); + + $response + ->assertStatus(202) + ->assertJsonPath('queued', true) + ->assertJsonPath('algo_version', (string) config('discovery.algo_version')); + + Queue::assertPushed(IngestUserDiscoveryEventJob::class, function (IngestUserDiscoveryEventJob $job) use ($user, $artwork): bool { + return $job->userId === $user->id + && $job->artworkId === $artwork->id + && $job->eventType === 'view'; + }); +}); + +it('validates discovery event payload', function () { + $user = User::factory()->create(); + $artwork = Artwork::factory()->create(); + + $response = $this->actingAs($user)->postJson('/api/discovery/events', [ + 'event_type' => 'impression', + 'artwork_id' => $artwork->id, + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['event_type']); +}); diff --git a/tests/Feature/Discovery/FeedEndpointTest.php b/tests/Feature/Discovery/FeedEndpointTest.php new file mode 100644 index 00000000..73afca42 --- /dev/null +++ b/tests/Feature/Discovery/FeedEndpointTest.php @@ -0,0 +1,113 @@ +create(); + + $artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(3)]); + $artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]); + $artworkC = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(1)]); + + UserRecommendationCache::query()->create([ + 'user_id' => $user->id, + 'algo_version' => (string) config('discovery.algo_version'), + 'cache_version' => (string) config('discovery.cache_version'), + 'recommendations_json' => [ + 'items' => [ + ['artwork_id' => $artworkA->id, 'score' => 0.9, 'source' => 'profile'], + ['artwork_id' => $artworkB->id, 'score' => 0.8, 'source' => 'profile'], + ['artwork_id' => $artworkC->id, 'score' => 0.7, 'source' => 'profile'], + ], + ], + 'generated_at' => now(), + 'expires_at' => now()->addMinutes(30), + ]); + + $first = $this->actingAs($user)->getJson('/api/v1/feed?limit=2'); + + $first->assertOk(); + $first->assertJsonPath('meta.cache_status', 'hit'); + expect(count((array) $first->json('data')))->toBe(2); + + $nextCursor = $first->json('meta.next_cursor'); + expect($nextCursor)->not->toBeNull(); + + $second = $this->actingAs($user)->getJson('/api/v1/feed?limit=2&cursor=' . urlencode((string) $nextCursor)); + + $second->assertOk(); + expect(count((array) $second->json('data')))->toBe(1); + expect($second->json('meta.next_cursor'))->toBeNull(); +}); + +it('dispatches async regeneration on cache miss and returns cold start items', function () { + Queue::fake(); + + $user = User::factory()->create(); + + $artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(3)]); + $artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]); + + DB::table('artwork_stats')->insert([ + ['artwork_id' => $artworkA->id, 'views' => 100, 'downloads' => 30, 'favorites' => 10, 'rating_avg' => 0, 'rating_count' => 0], + ['artwork_id' => $artworkB->id, 'views' => 80, 'downloads' => 10, 'favorites' => 5, 'rating_avg' => 0, 'rating_count' => 0], + ]); + + $response = $this->actingAs($user)->getJson('/api/v1/feed?limit=10'); + + $response->assertOk(); + expect(count((array) $response->json('data')))->toBeGreaterThan(0); + expect((string) $response->json('meta.cache_status'))->toContain('miss'); + + Queue::assertPushed(RegenerateUserRecommendationCacheJob::class, function (RegenerateUserRecommendationCacheJob $job) use ($user): bool { + return $job->userId === $user->id; + }); +}); + +it('applies diversity guard to avoid near-duplicates in cold start fallback', function () { + Queue::fake(); + + $user = User::factory()->create(); + + $artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(3)]); + $artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]); + $artworkC = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(1)]); + + DB::table('artwork_stats')->insert([ + ['artwork_id' => $artworkA->id, 'views' => 200, 'downloads' => 20, 'favorites' => 5, 'rating_avg' => 0, 'rating_count' => 0], + ['artwork_id' => $artworkB->id, 'views' => 190, 'downloads' => 18, 'favorites' => 4, 'rating_avg' => 0, 'rating_count' => 0], + ['artwork_id' => $artworkC->id, 'views' => 180, 'downloads' => 12, 'favorites' => 3, 'rating_avg' => 0, 'rating_count' => 0], + ]); + + DB::table('artwork_similarities')->insert([ + 'artwork_id' => $artworkA->id, + 'similar_artwork_id' => $artworkB->id, + 'model' => 'clip', + 'model_version' => 'v1', + 'algo_version' => (string) config('discovery.algo_version'), + 'rank' => 1, + 'score' => 0.991, + 'generated_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->actingAs($user)->getJson('/api/v1/feed?limit=10'); + + $response->assertOk(); + + $ids = collect((array) $response->json('data'))->pluck('id')->all(); + + expect(in_array($artworkA->id, $ids, true) && in_array($artworkB->id, $ids, true))->toBeFalse(); + expect(in_array($artworkC->id, $ids, true))->toBeTrue(); +}); diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php index 2bacbbd6..5b10ce47 100644 --- a/tests/Feature/ProfileTest.php +++ b/tests/Feature/ProfileTest.php @@ -3,28 +3,28 @@ use App\Models\User; test('profile page is displayed', function () { - $user = User::factory()->create(); + $user = User::factory()->create(['email' => null]); - $response = $this - ->actingAs($user) - ->get('/profile'); + $response = $this + ->actingAs($user) + ->get('/profile'); $response->assertOk(); }); test('profile information can be updated', function () { - $user = User::factory()->create(); + $user = User::factory()->create(['email' => null]); $response = $this ->actingAs($user) - ->patch('/profile', [ + ->patch('/profile', [ 'name' => 'Test User', 'email' => 'test@example.com', ]); - $response - ->assertSessionHasNoErrors() - ->assertRedirect('/profile'); + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/user'); $user->refresh(); @@ -38,14 +38,14 @@ test('email verification status is unchanged when the email address is unchanged $response = $this ->actingAs($user) - ->patch('/profile', [ + ->patch('/profile', [ 'name' => 'Test User', 'email' => $user->email, ]); - $response - ->assertSessionHasNoErrors() - ->assertRedirect('/profile'); + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/user'); $this->assertNotNull($user->refresh()->email_verified_at); }); @@ -55,13 +55,13 @@ test('user can delete their account', function () { $response = $this ->actingAs($user) - ->delete('/profile', [ + ->delete('/profile', [ 'password' => 'password', ]); - $response - ->assertSessionHasNoErrors() - ->assertRedirect('/'); + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/'); $this->assertGuest(); // User should be soft-deleted, not permanently removed @@ -74,13 +74,13 @@ test('correct password must be provided to delete account', function () { $response = $this ->actingAs($user) ->from('/profile') - ->delete('/profile', [ + ->delete('/profile', [ 'password' => 'wrong-password', ]); - $response - ->assertSessionHasErrorsIn('userDeletion', 'password') - ->assertRedirect('/profile'); + $response + ->assertSessionHasErrorsIn('userDeletion', 'password') + ->assertRedirect('/profile'); $this->assertNotNull($user->fresh()); }); diff --git a/tests/Feature/SimilarArtworksBlockTest.php b/tests/Feature/SimilarArtworksBlockTest.php new file mode 100644 index 00000000..26ba3d42 --- /dev/null +++ b/tests/Feature/SimilarArtworksBlockTest.php @@ -0,0 +1,120 @@ + 'Photography', + 'slug' => 'photography', + 'description' => 'Photography content', + ]); + + $category = Category::create([ + 'content_type_id' => $contentType->id, + 'parent_id' => null, + 'name' => 'Abstract', + 'slug' => 'abstract-similar', + 'description' => 'Abstract works', + 'is_active' => true, + 'sort_order' => 0, + ]); + + return [$contentType, $category]; +} + +it('renders similar artworks block when similarities exist with algo version and CDN thumbnails', function () { + [$contentType, $category] = createContentTypeAndCategoryForSimilarTests(); + + $author = User::factory()->create(); + + $source = Artwork::factory()->create([ + 'user_id' => $author->id, + 'title' => 'Source Artwork', + 'slug' => 'source-artwork', + 'hash' => 'aa11bb22cc33', + 'thumb_ext' => 'webp', + 'published_at' => now()->subMinute(), + 'is_public' => true, + 'is_approved' => true, + ]); + + $similar = Artwork::factory()->create([ + 'user_id' => $author->id, + 'title' => 'Similar Artwork', + 'slug' => 'similar-artwork', + 'hash' => 'bb22cc33dd44', + 'thumb_ext' => 'webp', + 'published_at' => now()->subMinute(), + 'is_public' => true, + 'is_approved' => true, + ]); + + $source->categories()->attach($category->id); + $similar->categories()->attach($category->id); + + DB::table('artwork_similarities')->insert([ + 'artwork_id' => $source->id, + 'similar_artwork_id' => $similar->id, + 'model' => 'clip', + 'model_version' => 'v1', + 'algo_version' => 'clip-cosine-v1', + 'rank' => 1, + 'score' => 0.9234567, + 'generated_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $url = route('artworks.show', [ + 'contentTypeSlug' => $contentType->slug, + 'categoryPath' => $category->slug, + 'artwork' => $source->slug, + ]); + + $response = $this->get($url); + + $response->assertOk(); + $response->assertSee('Similar artworks'); + $response->assertSee('data-algo-version="clip-cosine-v1"', false); + $response->assertSee((string) $similar->thumb_url, false); + $response->assertSee('https://files.skinbase.org', false); +}); + +it('hides similar artworks block when no similarities exist', function () { + [$contentType, $category] = createContentTypeAndCategoryForSimilarTests(); + + $author = User::factory()->create(); + + $source = Artwork::factory()->create([ + 'user_id' => $author->id, + 'title' => 'Lonely Artwork', + 'slug' => 'lonely-artwork', + 'hash' => 'cc33dd44ee55', + 'thumb_ext' => 'webp', + 'published_at' => now()->subMinute(), + 'is_public' => true, + 'is_approved' => true, + ]); + + $source->categories()->attach($category->id); + + $url = route('artworks.show', [ + 'contentTypeSlug' => $contentType->slug, + 'categoryPath' => $category->slug, + 'artwork' => $source->slug, + ]); + + $response = $this->get($url); + + $response->assertOk(); + $response->assertDontSee('Similar artworks', false); + $response->assertDontSee('data-similar-analytics', false); +}); diff --git a/tests/Feature/TagSystemTest.php b/tests/Feature/TagSystemTest.php new file mode 100644 index 00000000..3b404dcd --- /dev/null +++ b/tests/Feature/TagSystemTest.php @@ -0,0 +1,100 @@ +normalize(' Cyber Punk!! '))->toBe('cyber-punk'); + expect($n->normalize("🚀 Rocket "))->toBe('rocket'); + expect($n->normalize(''))->toBe(''); +}); + +it('prevents duplicate tags and duplicate pivots for user tags', function () { + $service = app(TagService::class); + $artwork = Artwork::factory()->create(); + + $service->attachUserTags($artwork, ['CyberPunk', ' cyberpunk ', 'CYBERPUNK']); + + expect(Tag::query()->count())->toBe(1); + expect($artwork->tags()->count())->toBe(1); + expect(Tag::first()->usage_count)->toBe(1); +}); + +it('attaches AI tags with source and confidence without blocking existing user tags', function () { + $service = app(TagService::class); + $artwork = Artwork::factory()->create(); + + $service->attachUserTags($artwork, ['city']); + $service->attachAiTags($artwork, [ + ['tag' => 'city', 'confidence' => 0.2], + ['tag' => 'cyberpunk', 'confidence' => 0.42], + ]); + + $artwork->load('tags'); + $city = $artwork->tags->firstWhere('slug', 'city'); + $cyber = $artwork->tags->firstWhere('slug', 'cyberpunk'); + + expect($city)->not->toBeNull(); + expect($city->pivot->source)->toBe('user'); + expect($city->pivot->confidence)->toBeNull(); + + expect($cyber)->not->toBeNull(); + expect($cyber->pivot->source)->toBe('ai'); + expect($cyber->pivot->confidence)->toBeFloat(); +}); + +it('syncs user tags and maintains usage counts', function () { + $service = app(TagService::class); + $artwork = Artwork::factory()->create(); + + $service->attachUserTags($artwork, ['one', 'two']); + expect(Tag::query()->whereIn('slug', ['one', 'two'])->pluck('usage_count', 'slug')->all()) + ->toMatchArray(['one' => 1, 'two' => 1]); + + $service->syncTags($artwork, ['two', 'three']); + $artwork->refresh(); + + expect($artwork->tags()->pluck('slug')->all())->toContain('two', 'three'); + + $counts = Tag::query()->whereIn('slug', ['one', 'two', 'three'])->pluck('usage_count', 'slug')->all(); + expect($counts['one'])->toBe(0); + expect($counts['two'])->toBe(1); + expect($counts['three'])->toBe(1); +}); + +it('enforces pivot integrity (single row per artwork-tag) and user precedence', function () { + $service = app(TagService::class); + $artwork = Artwork::factory()->create(); + + $service->attachAiTags($artwork, [['tag' => 'future', 'confidence' => 0.31]]); + $service->attachUserTags($artwork, ['future']); + + $artwork->load('tags'); + $future = $artwork->tags->firstWhere('slug', 'future'); + + expect($future)->not->toBeNull(); + expect($future->pivot->source)->toBe('user'); + expect($future->pivot->confidence)->toBeNull(); + expect(DB::table('artwork_tag')->where('artwork_id', $artwork->id)->where('tag_id', $future->id)->count())->toBe(1); +}); + +it('cleans up pivots and decrements usage counts when artwork is force deleted', function () { + $service = app(TagService::class); + $artwork = Artwork::factory()->create(); + + $service->attachUserTags($artwork, ['a', 'b']); + $tagIds = Tag::query()->pluck('id')->all(); + + expect(DB::table('artwork_tag')->where('artwork_id', $artwork->id)->count())->toBe(2); + $artwork->forceDelete(); + + expect(DB::table('artwork_tag')->whereIn('tag_id', $tagIds)->count())->toBe(0); + expect(Tag::query()->whereIn('id', $tagIds)->sum('usage_count'))->toBe(0); +}); diff --git a/tests/Feature/Uploads/ArchiveUploadSecurityTest.php b/tests/Feature/Uploads/ArchiveUploadSecurityTest.php new file mode 100644 index 00000000..240dfc4c --- /dev/null +++ b/tests/Feature/Uploads/ArchiveUploadSecurityTest.php @@ -0,0 +1,212 @@ + */ +$tempArchives = []; + +afterEach(function () use (&$tempArchives): void { + foreach ($tempArchives as $path) { + if (is_file($path)) { + @unlink($path); + } + } + + $tempArchives = []; +}); + +/** + * @param array $entries + * @param (callable(\ZipArchive,string):void)|null $entryCallback + */ +function makeArchiveUpload(array $entries, array &$tempArchives, ?callable $entryCallback = null): UploadedFile +{ + if (! class_exists(\ZipArchive::class)) { + test()->markTestSkipped('ZipArchive extension is required.'); + } + + $path = tempnam(sys_get_temp_dir(), 'sb_upload_zip_'); + if ($path === false) { + throw new \RuntimeException('Failed to allocate temp archive path.'); + } + + $tempArchives[] = $path; + + $zip = new \ZipArchive(); + if ($zip->open($path, \ZipArchive::OVERWRITE | \ZipArchive::CREATE) !== true) { + throw new \RuntimeException('Failed to create test zip archive.'); + } + + foreach ($entries as $name => $content) { + $zip->addFromString($name, $content); + if ($entryCallback !== null) { + $entryCallback($zip, $name); + } + } + + $zip->close(); + + return new UploadedFile($path, 'archive.zip', 'application/zip', null, true); +} + +it('rejects archive with zip slip path during preload', function () use (&$tempArchives) { + Storage::fake('local'); + $user = User::factory()->create(); + + $archive = makeArchiveUpload([ + '../evil.txt' => 'x', + ], $tempArchives); + + $screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600); + + $response = $this->actingAs($user)->postJson('/api/uploads/preload', [ + 'main' => $archive, + 'screenshots' => [$screenshot], + ]); + + $response->assertStatus(422) + ->assertJsonPath('message', 'Archive inspection failed.'); + + expect(strtolower((string) $response->json('reason')))->toContain('path'); +}); + +it('rejects archive with symlink during preload', function () use (&$tempArchives) { + Storage::fake('local'); + $user = User::factory()->create(); + + $archive = makeArchiveUpload([ + 'safe/readme.txt' => 'ok', + 'safe/link' => 'target', + ], $tempArchives, function (\ZipArchive $zip, string $entryName): void { + if ($entryName === 'safe/link') { + $zip->setExternalAttributesName($entryName, \ZipArchive::OPSYS_UNIX, 0120777 << 16); + } + }); + + $screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600); + + $response = $this->actingAs($user)->postJson('/api/uploads/preload', [ + 'main' => $archive, + 'screenshots' => [$screenshot], + ]); + + $response->assertStatus(422) + ->assertJsonPath('message', 'Archive inspection failed.'); + + expect(strtolower((string) $response->json('reason')))->toContain('symlink'); +}); + +it('rejects archive with deep nesting during preload', function () use (&$tempArchives) { + Storage::fake('local'); + $user = User::factory()->create(); + + $archive = makeArchiveUpload([ + 'a/b/c/d/e/f/file.txt' => 'deep', + ], $tempArchives); + + $screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600); + + $response = $this->actingAs($user)->postJson('/api/uploads/preload', [ + 'main' => $archive, + 'screenshots' => [$screenshot], + ]); + + $response->assertStatus(422) + ->assertJsonPath('message', 'Archive inspection failed.'); + + expect(strtolower((string) $response->json('reason')))->toContain('depth'); +}); + +it('rejects archive with too many files during preload', function () use (&$tempArchives) { + Storage::fake('local'); + $user = User::factory()->create(); + + $entries = []; + for ($index = 0; $index < 5001; $index++) { + $entries['f' . $index . '.txt'] = 'x'; + } + + $archive = makeArchiveUpload($entries, $tempArchives); + $screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600); + + $response = $this->actingAs($user)->postJson('/api/uploads/preload', [ + 'main' => $archive, + 'screenshots' => [$screenshot], + ]); + + $response->assertStatus(422) + ->assertJsonPath('message', 'Archive inspection failed.'); + + expect((string) $response->json('reason'))->toContain('5000'); +}); + +it('rejects archive with executable inside during preload', function () use (&$tempArchives) { + Storage::fake('local'); + $user = User::factory()->create(); + + $archive = makeArchiveUpload([ + 'safe/readme.txt' => 'ok', + 'safe/run.exe' => 'MZ', + ], $tempArchives); + + $screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600); + + $response = $this->actingAs($user)->postJson('/api/uploads/preload', [ + 'main' => $archive, + 'screenshots' => [$screenshot], + ]); + + $response->assertStatus(422) + ->assertJsonPath('message', 'Archive inspection failed.'); + + expect(strtolower((string) $response->json('reason')))->toContain('blocked'); +}); + +it('rejects archive with zip bomb ratio during preload', function () use (&$tempArchives) { + Storage::fake('local'); + $user = User::factory()->create(); + + $archive = makeArchiveUpload([ + 'payload.txt' => str_repeat('A', 6 * 1024 * 1024), + ], $tempArchives); + + $screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600); + + $response = $this->actingAs($user)->postJson('/api/uploads/preload', [ + 'main' => $archive, + 'screenshots' => [$screenshot], + ]); + + $response->assertStatus(422) + ->assertJsonPath('message', 'Archive inspection failed.'); + + expect(strtolower((string) $response->json('reason')))->toContain('ratio'); +}); + +it('accepts valid archive during preload', function () use (&$tempArchives) { + Storage::fake('local'); + $user = User::factory()->create(); + + $archive = makeArchiveUpload([ + 'skins/theme/readme.txt' => 'hello', + 'skins/theme/layout.ini' => 'v=1', + ], $tempArchives); + + $screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600); + + $response = $this->actingAs($user)->postJson('/api/uploads/preload', [ + 'main' => $archive, + 'screenshots' => [$screenshot], + ]); + + $response->assertOk()->assertJsonStructure([ + 'upload_id', + 'status', + 'expires_at', + ]); +}); diff --git a/tests/Feature/Uploads/PreviewGenerationJobTest.php b/tests/Feature/Uploads/PreviewGenerationJobTest.php new file mode 100644 index 00000000..84b1476c --- /dev/null +++ b/tests/Feature/Uploads/PreviewGenerationJobTest.php @@ -0,0 +1,47 @@ +create(); + $uploadId = (string) Str::uuid(); + + DB::table('uploads')->insert([ + 'id' => $uploadId, + 'user_id' => $user->id, + 'type' => 'archive', + 'status' => 'draft', + 'is_scanned' => true, + 'has_tags' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // archive path with no screenshot uses placeholder path in PreviewService + $job = new PreviewGenerationJob($uploadId); + $job->handle(app(\App\Services\Upload\PreviewService::class)); + + $this->assertDatabaseHas('uploads', [ + 'id' => $uploadId, + ]); + + Bus::assertDispatched(TagAnalysisJob::class, function (TagAnalysisJob $queuedJob) use ($uploadId) { + $reflect = new ReflectionClass($queuedJob); + $property = $reflect->getProperty('uploadId'); + $property->setAccessible(true); + + return $property->getValue($queuedJob) === $uploadId; + }); +}); diff --git a/tests/Feature/Uploads/UploadAutosaveTest.php b/tests/Feature/Uploads/UploadAutosaveTest.php new file mode 100644 index 00000000..7cfa1cf8 --- /dev/null +++ b/tests/Feature/Uploads/UploadAutosaveTest.php @@ -0,0 +1,165 @@ +insertGetId([ + 'name' => 'Photography', + 'slug' => 'photography-' . Str::lower(Str::random(6)), + 'description' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return DB::table('categories')->insertGetId([ + 'content_type_id' => $contentTypeId, + 'parent_id' => null, + 'name' => 'Nature', + 'slug' => 'nature-' . Str::lower(Str::random(6)), + 'description' => null, + 'image' => null, + 'is_active' => true, + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +function createDraftUploadForAutosave(int $userId, string $status = 'draft'): string +{ + $id = (string) Str::uuid(); + + DB::table('uploads')->insert([ + 'id' => $id, + 'user_id' => $userId, + 'type' => 'image', + 'status' => $status, + 'title' => 'Original Title', + 'description' => 'Original Description', + 'license' => 'default', + 'nsfw' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $id; +} + +it('owner can autosave', function () { + Storage::fake('local'); + + $owner = User::factory()->create(); + $categoryId = createCategoryForAutosaveTests(); + $uploadId = createDraftUploadForAutosave($owner->id); + + $response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/autosave", [ + 'title' => 'Updated Title', + 'category_id' => $categoryId, + 'description' => 'Updated Description', + 'tags' => ['night', 'city'], + 'license' => 'cc-by', + 'nsfw' => true, + ]); + + $response->assertOk()->assertJsonStructure([ + 'success', + 'updated_at', + ])->assertJson([ + 'success' => true, + ]); + + $this->assertDatabaseHas('uploads', [ + 'id' => $uploadId, + 'title' => 'Updated Title', + 'category_id' => $categoryId, + 'description' => 'Updated Description', + 'license' => 'cc-by', + 'nsfw' => 1, + ]); + + $row = DB::table('uploads')->where('id', $uploadId)->first(); + expect(json_decode((string) $row->tags, true))->toBe(['night', 'city']); +}); + +it('partial update works', function () { + Storage::fake('local'); + + $owner = User::factory()->create(); + $uploadId = createDraftUploadForAutosave($owner->id); + + $before = DB::table('uploads')->where('id', $uploadId)->first(); + + $response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/autosave", [ + 'title' => 'Only Title Changed', + ]); + + $response->assertOk()->assertJson([ + 'success' => true, + ]); + + $after = DB::table('uploads')->where('id', $uploadId)->first(); + + expect($after->title)->toBe('Only Title Changed'); + expect($after->description)->toBe($before->description); + expect($after->license)->toBe($before->license); +}); + +it('guest denied', function () { + Storage::fake('local'); + + $owner = User::factory()->create(); + $uploadId = createDraftUploadForAutosave($owner->id); + + $response = $this->postJson("/api/uploads/{$uploadId}/autosave", [ + 'title' => 'Nope', + ]); + + expect(in_array($response->getStatusCode(), [401, 403]))->toBeTrue(); +}); + +it('other user denied', function () { + Storage::fake('local'); + + $owner = User::factory()->create(); + $other = User::factory()->create(); + $uploadId = createDraftUploadForAutosave($owner->id); + + $response = $this->actingAs($other)->postJson("/api/uploads/{$uploadId}/autosave", [ + 'title' => 'Hacker Update', + ]); + + $response->assertStatus(403); +}); + +it('published upload rejected', function () { + Storage::fake('local'); + + $owner = User::factory()->create(); + $uploadId = createDraftUploadForAutosave($owner->id, 'published'); + + $response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/autosave", [ + 'title' => 'Should Not Save', + ]); + + $response->assertStatus(422); +}); + +it('invalid category rejected', function () { + Storage::fake('local'); + + $owner = User::factory()->create(); + $uploadId = createDraftUploadForAutosave($owner->id); + + $response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/autosave", [ + 'category_id' => 999999, + ]); + + $response->assertStatus(422)->assertJsonValidationErrors(['category_id']); +}); diff --git a/tests/Feature/Uploads/UploadCleanupCommandTest.php b/tests/Feature/Uploads/UploadCleanupCommandTest.php new file mode 100644 index 00000000..0897d23e --- /dev/null +++ b/tests/Feature/Uploads/UploadCleanupCommandTest.php @@ -0,0 +1,89 @@ + $id, + 'user_id' => User::factory()->create()->id, + 'type' => 'image', + 'status' => 'draft', + 'title' => null, + 'slug' => null, + 'category_id' => null, + 'description' => null, + 'tags' => null, + 'license' => null, + 'nsfw' => false, + 'is_scanned' => false, + 'has_tags' => false, + 'preview_path' => null, + 'published_at' => null, + 'final_path' => null, + 'expires_at' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]; + + DB::table('uploads')->insert(array_merge($defaults, $overrides)); + + return $id; +} + +it('runs uploads cleanup command and deletes stale drafts', function () { + Storage::fake('local'); + + $expiredId = createCleanupUpload([ + 'status' => 'draft', + 'expires_at' => now()->subMinute(), + ]); + + $activeId = createCleanupUpload([ + 'status' => 'draft', + 'expires_at' => now()->addHours(2), + 'updated_at' => now()->subHours(2), + ]); + + Storage::disk('local')->put("tmp/drafts/{$expiredId}/meta.json", '{}'); + Storage::disk('local')->put("tmp/drafts/{$activeId}/meta.json", '{}'); + + $code = Artisan::call('uploads:cleanup'); + + expect($code)->toBe(0); + expect(Artisan::output())->toContain('Uploads cleanup deleted 1 draft(s).'); + + expect(DB::table('uploads')->where('id', $expiredId)->exists())->toBeFalse(); + expect(DB::table('uploads')->where('id', $activeId)->where('status', 'draft')->exists())->toBeTrue(); + expect(Storage::disk('local')->exists("tmp/drafts/{$expiredId}/meta.json"))->toBeFalse(); + expect(Storage::disk('local')->exists("tmp/drafts/{$activeId}/meta.json"))->toBeTrue(); +}); + +it('respects command limit option', function () { + Storage::fake('local'); + + for ($index = 0; $index < 5; $index++) { + $uploadId = createCleanupUpload([ + 'status' => 'draft', + 'updated_at' => now()->subHours(26), + ]); + + Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}'); + } + + $code = Artisan::call('uploads:cleanup', ['--limit' => 2]); + + expect($code)->toBe(0); + expect(Artisan::output())->toContain('Uploads cleanup deleted 2 draft(s).'); + + expect(DB::table('uploads')->count())->toBe(3); +}); diff --git a/tests/Feature/Uploads/UploadFeatureFlagTest.php b/tests/Feature/Uploads/UploadFeatureFlagTest.php new file mode 100644 index 00000000..b40dfe1d --- /dev/null +++ b/tests/Feature/Uploads/UploadFeatureFlagTest.php @@ -0,0 +1,30 @@ + false]); + + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get('/upload'); + + $response->assertOk(); + $response->assertSee('window.SKINBASE_FLAGS', false); + $response->assertSee('uploads_v2: false', false); +}); + +it('injects uploads v2 flag as true when enabled', function () { + config(['features.uploads_v2' => true]); + + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get('/upload'); + + $response->assertOk(); + $response->assertSee('window.SKINBASE_FLAGS', false); + $response->assertSee('uploads_v2: true', false); +}); diff --git a/tests/Feature/Uploads/UploadPreloadTest.php b/tests/Feature/Uploads/UploadPreloadTest.php new file mode 100644 index 00000000..8e97ee6a --- /dev/null +++ b/tests/Feature/Uploads/UploadPreloadTest.php @@ -0,0 +1,191 @@ +open($path, \ZipArchive::OVERWRITE | \ZipArchive::CREATE) !== true) { + throw new \RuntimeException('Unable to create temporary zip file.'); + } + + $zip->addFromString('skins/theme/readme.txt', 'safe'); + $zip->addFromString('skins/theme/colors.ini', 'accent=blue'); + $zip->close(); + + return new UploadedFile($path, 'pack.zip', 'application/zip', null, true); +} + +it('authenticated user can preload image', function () { + Storage::fake('local'); + $user = User::factory()->create(); + + $main = UploadedFile::fake()->image('main.jpg', 1200, 800); + + $response = $this + ->actingAs($user) + ->postJson('/api/uploads/preload', [ + 'main' => $main, + ]); + + $response->assertOk()->assertJsonStructure([ + 'upload_id', + 'status', + 'expires_at', + ]); + + $uploadId = $response->json('upload_id'); + + $this->assertDatabaseHas('uploads', [ + 'id' => $uploadId, + 'user_id' => $user->id, + 'type' => 'image', + 'status' => 'draft', + ]); + + $this->assertDatabaseHas('upload_files', [ + 'upload_id' => $uploadId, + 'type' => 'main', + ]); + + Storage::disk('local')->assertExists("tmp/drafts/{$uploadId}/meta.json"); + expect(Storage::disk('local')->allFiles("tmp/drafts/{$uploadId}"))->not->toBeEmpty(); +}); + +it('authenticated user can preload archive with screenshot', function () { + Storage::fake('local'); + $user = User::factory()->create(); + + $main = makeValidArchiveUpload(); + $screenshot = UploadedFile::fake()->image('screen1.jpg', 800, 600); + + $response = $this + ->actingAs($user) + ->postJson('/api/uploads/preload', [ + 'main' => $main, + 'screenshots' => [$screenshot], + ]); + + $response->assertOk()->assertJsonStructure([ + 'upload_id', + 'status', + 'expires_at', + ]); + + $uploadId = $response->json('upload_id'); + + $this->assertDatabaseHas('uploads', [ + 'id' => $uploadId, + 'user_id' => $user->id, + 'type' => 'archive', + 'status' => 'draft', + ]); + + $this->assertDatabaseHas('upload_files', [ + 'upload_id' => $uploadId, + 'type' => 'main', + ]); + + $this->assertDatabaseHas('upload_files', [ + 'upload_id' => $uploadId, + 'type' => 'screenshot', + ]); + + Storage::disk('local')->assertExists("tmp/drafts/{$uploadId}/meta.json"); + expect(Storage::disk('local')->allFiles("tmp/drafts/{$uploadId}"))->not->toBeEmpty(); +}); + +it('guest is rejected', function () { + Storage::fake('local'); + + $main = UploadedFile::fake()->image('main.jpg', 1200, 800); + + $response = $this->postJson('/api/uploads/preload', [ + 'main' => $main, + ]); + + expect(in_array($response->getStatusCode(), [401, 403]))->toBeTrue(); +}); + +it('missing main file fails', function () { + Storage::fake('local'); + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->postJson('/api/uploads/preload', []); + + $response->assertStatus(422)->assertJsonValidationErrors(['main']); +}); + +it('archive without screenshot fails', function () { + Storage::fake('local'); + $user = User::factory()->create(); + + $main = UploadedFile::fake()->create('pack.zip', 1024, 'application/zip'); + + $response = $this + ->actingAs($user) + ->postJson('/api/uploads/preload', [ + 'main' => $main, + ]); + + $response->assertStatus(422)->assertJsonValidationErrors(['screenshots']); +}); + +it('invalid file type is rejected', function () { + Storage::fake('local'); + $user = User::factory()->create(); + + $main = UploadedFile::fake()->create('notes.txt', 4, 'text/plain'); + + $response = $this + ->actingAs($user) + ->postJson('/api/uploads/preload', [ + 'main' => $main, + ]); + + $response->assertStatus(422)->assertJsonValidationErrors(['main']); +}); + +it('preload dispatches VirusScanJob', function () { + Storage::fake('local'); + Bus::fake(); + + $user = User::factory()->create(); + $main = UploadedFile::fake()->image('main.jpg', 1200, 800); + + $response = $this + ->actingAs($user) + ->postJson('/api/uploads/preload', [ + 'main' => $main, + ]); + + $response->assertOk()->assertJsonStructure([ + 'upload_id', + 'status', + 'expires_at', + ]); + + $uploadId = $response->json('upload_id'); + + Bus::assertDispatched(VirusScanJob::class, function (VirusScanJob $job) use ($uploadId) { + $reflect = new \ReflectionClass($job); + $property = $reflect->getProperty('uploadId'); + $property->setAccessible(true); + + return $property->getValue($job) === $uploadId; + }); +}); diff --git a/tests/Feature/Uploads/UploadProcessingLifecycleTest.php b/tests/Feature/Uploads/UploadProcessingLifecycleTest.php new file mode 100644 index 00000000..fdd46a36 --- /dev/null +++ b/tests/Feature/Uploads/UploadProcessingLifecycleTest.php @@ -0,0 +1,108 @@ + $id, + 'user_id' => $userId, + 'type' => 'archive', + 'status' => 'draft', + 'processing_state' => 'pending_scan', + 'is_scanned' => false, + 'has_tags' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]; + + DB::table('uploads')->insert(array_merge($defaults, $overrides)); + + return $id; +} + +it('moves through explicit processing state lifecycle', function () { + Storage::fake('local'); + Bus::fake(); + + $user = User::factory()->create(); + $uploadId = createUploadForLifecycle($user->id); + + $mainPath = "tmp/drafts/{$uploadId}/main/archive.zip"; + Storage::disk('local')->put($mainPath, 'archive-bytes'); + + DB::table('upload_files')->insert([ + 'upload_id' => $uploadId, + 'path' => $mainPath, + 'type' => 'main', + 'hash' => 'aa11bb22cc33dd44', + 'size' => 100, + 'mime' => 'application/zip', + 'created_at' => now(), + ]); + + (new VirusScanJob($uploadId))->handle(app(UploadScanService::class)); + expect(DB::table('uploads')->where('id', $uploadId)->value('processing_state'))->toBe('generating_preview'); + + (new PreviewGenerationJob($uploadId))->handle(app(PreviewService::class)); + expect(DB::table('uploads')->where('id', $uploadId)->value('processing_state'))->toBe('analyzing_tags'); + + (new TagAnalysisJob($uploadId))->handle(app(TagAnalysisService::class)); + expect(DB::table('uploads')->where('id', $uploadId)->value('processing_state'))->toBe('ready'); + expect((bool) DB::table('uploads')->where('id', $uploadId)->value('has_tags'))->toBeTrue(); +}); + +it('does not regress processing state when jobs rerun', function () { + Storage::fake('local'); + Bus::fake(); + + $user = User::factory()->create(); + $uploadId = (string) Str::uuid(); + + DB::table('uploads')->insert([ + 'id' => $uploadId, + 'user_id' => $user->id, + 'type' => 'archive', + 'status' => 'draft', + 'processing_state' => 'ready', + 'is_scanned' => true, + 'has_tags' => true, + 'preview_path' => "tmp/drafts/{$uploadId}/preview.webp", + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $mainPath = "tmp/drafts/{$uploadId}/main/archive.zip"; + Storage::disk('local')->put($mainPath, 'archive-bytes'); + + DB::table('upload_files')->insert([ + 'upload_id' => $uploadId, + 'path' => $mainPath, + 'type' => 'main', + 'hash' => 'ee11bb22cc33dd44', + 'size' => 100, + 'mime' => 'application/zip', + 'created_at' => now(), + ]); + + (new VirusScanJob($uploadId))->handle(app(UploadScanService::class)); + (new PreviewGenerationJob($uploadId))->handle(app(PreviewService::class)); + (new TagAnalysisJob($uploadId))->handle(app(TagAnalysisService::class)); + + expect(DB::table('uploads')->where('id', $uploadId)->value('processing_state'))->toBe('ready'); +}); diff --git a/tests/Feature/Uploads/UploadPublishEndpointTest.php b/tests/Feature/Uploads/UploadPublishEndpointTest.php new file mode 100644 index 00000000..871aaca3 --- /dev/null +++ b/tests/Feature/Uploads/UploadPublishEndpointTest.php @@ -0,0 +1,143 @@ +insertGetId([ + 'name' => 'Photography', + 'slug' => 'photography-' . Str::lower(Str::random(6)), + 'description' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return DB::table('categories')->insertGetId([ + 'content_type_id' => $contentTypeId, + 'parent_id' => null, + 'name' => 'Street', + 'slug' => 'street-' . Str::lower(Str::random(6)), + 'description' => null, + 'image' => null, + 'is_active' => true, + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +function createReadyDraftForPublishEndpoint(int $ownerId, int $categoryId, string $status = 'draft'): array +{ + $uploadId = (string) Str::uuid(); + $hash = 'aabbccddeeff00112233'; + + DB::table('uploads')->insert([ + 'id' => $uploadId, + 'user_id' => $ownerId, + 'type' => 'image', + 'status' => $status, + 'moderation_status' => 'approved', + 'title' => 'Publish Endpoint Test', + 'category_id' => $categoryId, + 'is_scanned' => true, + 'has_tags' => true, + 'preview_path' => "tmp/drafts/{$uploadId}/preview.webp", + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/main.jpg", 'jpg'); + Storage::disk('local')->put("tmp/drafts/{$uploadId}/preview.webp", 'preview'); + + DB::table('upload_files')->insert([ + 'upload_id' => $uploadId, + 'path' => "tmp/drafts/{$uploadId}/main/main.jpg", + 'type' => 'main', + 'hash' => $hash, + 'size' => 3, + 'mime' => 'image/jpeg', + 'created_at' => now(), + ]); + + return [$uploadId, $hash]; +} + +it('owner can publish valid draft', function () { + Storage::fake('local'); + + $owner = User::factory()->create(); + $categoryId = createCategoryForPublishEndpointTests(); + [$uploadId, $hash] = createReadyDraftForPublishEndpoint($owner->id, $categoryId); + + $response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/publish"); + + $response->assertOk()->assertJsonStructure([ + 'success', + 'upload_id', + 'status', + 'published_at', + 'final_path', + ])->assertJson([ + 'success' => true, + 'upload_id' => $uploadId, + 'status' => 'published', + 'final_path' => 'files/artworks/aa/bb/' . $hash, + ]); +}); + +it('guest denied', function () { + Storage::fake('local'); + + $owner = User::factory()->create(); + $categoryId = createCategoryForPublishEndpointTests(); + [$uploadId] = createReadyDraftForPublishEndpoint($owner->id, $categoryId); + + $response = $this->postJson("/api/uploads/{$uploadId}/publish"); + + expect(in_array($response->getStatusCode(), [401, 403]))->toBeTrue(); +}); + +it('other user denied', function () { + Storage::fake('local'); + + $owner = User::factory()->create(); + $other = User::factory()->create(); + $categoryId = createCategoryForPublishEndpointTests(); + [$uploadId] = createReadyDraftForPublishEndpoint($owner->id, $categoryId); + + $response = $this->actingAs($other)->postJson("/api/uploads/{$uploadId}/publish"); + + $response->assertStatus(403); +}); + +it('incomplete draft rejected', function () { + Storage::fake('local'); + + $owner = User::factory()->create(); + $categoryId = createCategoryForPublishEndpointTests(); + [$uploadId] = createReadyDraftForPublishEndpoint($owner->id, $categoryId); + + DB::table('uploads')->where('id', $uploadId)->update(['title' => null]); + + $response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/publish"); + + $response->assertStatus(422); +}); + +it('already published rejected', function () { + Storage::fake('local'); + + $owner = User::factory()->create(); + $categoryId = createCategoryForPublishEndpointTests(); + [$uploadId] = createReadyDraftForPublishEndpoint($owner->id, $categoryId, 'published'); + + $response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/publish"); + + $response->assertStatus(422); +}); diff --git a/tests/Feature/Uploads/UploadPublishTest.php b/tests/Feature/Uploads/UploadPublishTest.php new file mode 100644 index 00000000..87bedb5b --- /dev/null +++ b/tests/Feature/Uploads/UploadPublishTest.php @@ -0,0 +1,264 @@ +insertGetId([ + 'name' => 'Photography', + 'slug' => 'photography-' . Str::lower(Str::random(6)), + 'description' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return DB::table('categories')->insertGetId([ + 'content_type_id' => $contentTypeId, + 'parent_id' => null, + 'name' => 'Urban', + 'slug' => 'urban-' . Str::lower(Str::random(6)), + 'description' => null, + 'image' => null, + 'is_active' => true, + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +it('publishes upload and moves draft files', function () { + Storage::fake('local'); + + $user = User::factory()->create(); + $categoryId = createCategoryForUploadPublishFeatureTests(); + $uploadId = (string) Str::uuid(); + $hash = 'aabbccddeeff00112233'; + + DB::table('uploads')->insert([ + 'id' => $uploadId, + 'user_id' => $user->id, + 'type' => 'image', + 'status' => 'draft', + 'moderation_status' => 'approved', + 'title' => 'Night City', + 'category_id' => $categoryId, + 'is_scanned' => true, + 'has_tags' => true, + 'preview_path' => "tmp/drafts/{$uploadId}/preview.webp", + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/night-city.jpg", 'jpg-binary'); + Storage::disk('local')->put("tmp/drafts/{$uploadId}/preview.webp", 'preview'); + Storage::disk('local')->put("tmp/drafts/{$uploadId}/thumb.webp", 'thumb'); + + DB::table('upload_files')->insert([ + [ + 'upload_id' => $uploadId, + 'path' => "tmp/drafts/{$uploadId}/main/night-city.jpg", + 'type' => 'main', + 'hash' => $hash, + 'size' => 10, + 'mime' => 'image/jpeg', + 'created_at' => now(), + ], + [ + 'upload_id' => $uploadId, + 'path' => "tmp/drafts/{$uploadId}/preview.webp", + 'type' => 'preview', + 'hash' => null, + 'size' => 7, + 'mime' => 'image/webp', + 'created_at' => now(), + ], + ]); + + $published = app(PublishService::class)->publish($uploadId, $user); + + expect($published)->toBeInstanceOf(Upload::class); + expect($published->status)->toBe('published'); + expect($published->published_at)->not->toBeNull(); + expect($published->final_path)->toBe('files/artworks/aa/bb/' . $hash); + + Storage::disk('local')->assertMissing("tmp/drafts/{$uploadId}/main/night-city.jpg"); + Storage::disk('local')->assertExists('files/artworks/aa/bb/' . $hash . '/main/night-city.jpg'); + + $updatedMain = DB::table('upload_files') + ->where('upload_id', $uploadId) + ->where('type', 'main') + ->value('path'); + expect($updatedMain)->toBe('files/artworks/aa/bb/' . $hash . '/main/night-city.jpg'); +}); + +it('does not delete temp files on publish failure', function () { + Storage::fake('local'); + + $user = User::factory()->create(); + $categoryId = createCategoryForUploadPublishFeatureTests(); + $uploadId = (string) Str::uuid(); + + DB::table('uploads')->insert([ + 'id' => $uploadId, + 'user_id' => $user->id, + 'type' => 'image', + 'status' => 'draft', + 'moderation_status' => 'approved', + 'title' => 'Will Fail', + 'category_id' => $categoryId, + 'is_scanned' => true, + 'has_tags' => true, + 'preview_path' => "tmp/drafts/{$uploadId}/preview.webp", + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/file.jpg", 'jpg-binary'); + Storage::disk('local')->put("tmp/drafts/{$uploadId}/preview.webp", 'preview'); + + // missing hash should trigger failure and preserve temp files + DB::table('upload_files')->insert([ + 'upload_id' => $uploadId, + 'path' => "tmp/drafts/{$uploadId}/main/file.jpg", + 'type' => 'main', + 'hash' => null, + 'size' => 10, + 'mime' => 'image/jpeg', + 'created_at' => now(), + ]); + + expect(fn () => app(PublishService::class)->publish($uploadId, $user)) + ->toThrow(RuntimeException::class); + + Storage::disk('local')->assertExists("tmp/drafts/{$uploadId}/main/file.jpg"); + $status = DB::table('uploads')->where('id', $uploadId)->value('status'); + expect($status)->toBe('draft'); +}); + +it('publish persists generated slug when missing', function () { + Storage::fake('local'); + + $user = User::factory()->create(); + $categoryId = createCategoryForUploadPublishFeatureTests(); + $uploadId = (string) Str::uuid(); + $hash = '0011aabbccddeeff2233'; + + DB::table('uploads')->insert([ + 'id' => $uploadId, + 'user_id' => $user->id, + 'type' => 'image', + 'status' => 'draft', + 'moderation_status' => 'approved', + 'processing_state' => 'ready', + 'title' => 'My Amazing Artwork', + 'slug' => null, + 'category_id' => $categoryId, + 'is_scanned' => true, + 'has_tags' => true, + 'preview_path' => "tmp/drafts/{$uploadId}/preview.webp", + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/file.jpg", 'jpg-binary'); + Storage::disk('local')->put("tmp/drafts/{$uploadId}/preview.webp", 'preview'); + + DB::table('upload_files')->insert([ + 'upload_id' => $uploadId, + 'path' => "tmp/drafts/{$uploadId}/main/file.jpg", + 'type' => 'main', + 'hash' => $hash, + 'size' => 10, + 'mime' => 'image/jpeg', + 'created_at' => now(), + ]); + + app(PublishService::class)->publish($uploadId, $user); + + expect(DB::table('uploads')->where('id', $uploadId)->value('slug'))->toBe('my-amazing-artwork'); +}); + +it('publish slug uniqueness appends numeric suffix for published uploads', function () { + Storage::fake('local'); + + $user = User::factory()->create(); + $categoryId = createCategoryForUploadPublishFeatureTests(); + + $firstUploadId = (string) Str::uuid(); + $secondUploadId = (string) Str::uuid(); + + DB::table('uploads')->insert([ + [ + 'id' => $firstUploadId, + 'user_id' => $user->id, + 'type' => 'image', + 'status' => 'draft', + 'moderation_status' => 'approved', + 'processing_state' => 'ready', + 'title' => 'Duplicate Title', + 'slug' => null, + 'category_id' => $categoryId, + 'is_scanned' => true, + 'has_tags' => true, + 'preview_path' => "tmp/drafts/{$firstUploadId}/preview.webp", + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'id' => $secondUploadId, + 'user_id' => $user->id, + 'type' => 'image', + 'status' => 'draft', + 'moderation_status' => 'approved', + 'processing_state' => 'ready', + 'title' => 'Duplicate Title', + 'slug' => null, + 'category_id' => $categoryId, + 'is_scanned' => true, + 'has_tags' => true, + 'preview_path' => "tmp/drafts/{$secondUploadId}/preview.webp", + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + Storage::disk('local')->put("tmp/drafts/{$firstUploadId}/main/file.jpg", 'first'); + Storage::disk('local')->put("tmp/drafts/{$firstUploadId}/preview.webp", 'preview'); + Storage::disk('local')->put("tmp/drafts/{$secondUploadId}/main/file.jpg", 'second'); + Storage::disk('local')->put("tmp/drafts/{$secondUploadId}/preview.webp", 'preview'); + + DB::table('upload_files')->insert([ + [ + 'upload_id' => $firstUploadId, + 'path' => "tmp/drafts/{$firstUploadId}/main/file.jpg", + 'type' => 'main', + 'hash' => 'aa11bb22cc33dd44', + 'size' => 10, + 'mime' => 'image/jpeg', + 'created_at' => now(), + ], + [ + 'upload_id' => $secondUploadId, + 'path' => "tmp/drafts/{$secondUploadId}/main/file.jpg", + 'type' => 'main', + 'hash' => 'ee11ff22cc33dd44', + 'size' => 10, + 'mime' => 'image/jpeg', + 'created_at' => now(), + ], + ]); + + app(PublishService::class)->publish($firstUploadId, $user); + app(PublishService::class)->publish($secondUploadId, $user); + + expect(DB::table('uploads')->where('id', $firstUploadId)->value('slug'))->toBe('duplicate-title'); + expect(DB::table('uploads')->where('id', $secondUploadId)->value('slug'))->toBe('duplicate-title-2'); +}); diff --git a/tests/Feature/Uploads/UploadQuotaTest.php b/tests/Feature/Uploads/UploadQuotaTest.php new file mode 100644 index 00000000..2d3f567c --- /dev/null +++ b/tests/Feature/Uploads/UploadQuotaTest.php @@ -0,0 +1,196 @@ + $id, + 'user_id' => $userId, + 'type' => 'image', + 'status' => 'draft', + 'title' => null, + 'slug' => null, + 'category_id' => null, + 'description' => null, + 'tags' => null, + 'license' => null, + 'nsfw' => false, + 'is_scanned' => false, + 'has_tags' => false, + 'preview_path' => null, + 'published_at' => null, + 'final_path' => null, + 'expires_at' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]; + + DB::table('uploads')->insert(array_merge($defaults, $overrides)); + + return $id; +} + +function attachMainUploadFileForQuota(string $uploadId, int $size, string $hash = 'hash-main'): void +{ + DB::table('upload_files')->insert([ + 'upload_id' => $uploadId, + 'path' => "tmp/drafts/{$uploadId}/main/file.bin", + 'type' => 'main', + 'hash' => $hash, + 'size' => $size, + 'mime' => 'application/octet-stream', + 'created_at' => now(), + ]); +} + +it('enforces draft count limit', function () { + Storage::fake('local'); + config(['uploads.draft_quota.max_drafts_per_user' => 1]); + + $user = User::factory()->create(); + createUploadRowForQuota($user->id, ['status' => 'draft']); + + $main = UploadedFile::fake()->image('wallpaper.jpg', 600, 400); + + $response = $this->actingAs($user)->postJson('/api/uploads/preload', [ + 'main' => $main, + ]); + + $response->assertStatus(429) + ->assertJsonPath('message', 'draft_limit') + ->assertJsonPath('code', 'draft_limit'); +}); + +it('enforces draft storage limit', function () { + Storage::fake('local'); + config([ + 'uploads.draft_quota.max_drafts_per_user' => 20, + 'uploads.draft_quota.max_draft_storage_mb_per_user' => 1, + ]); + + $user = User::factory()->create(); + + $existingDraftId = createUploadRowForQuota($user->id, ['status' => 'draft']); + attachMainUploadFileForQuota($existingDraftId, 400 * 1024, 'existing-hash'); + + $main = UploadedFile::fake()->create('large.jpg', 700, 'image/jpeg'); + + $response = $this->actingAs($user)->postJson('/api/uploads/preload', [ + 'main' => $main, + ]); + + $response->assertStatus(413) + ->assertJsonPath('message', 'storage_limit') + ->assertJsonPath('code', 'storage_limit'); +}); + +it('blocks duplicate hash when policy is block', function () { + Storage::fake('local'); + config([ + 'uploads.draft_quota.max_drafts_per_user' => 20, + 'uploads.draft_quota.duplicate_hash_policy' => 'block', + ]); + + $owner = User::factory()->create(); + $uploader = User::factory()->create(); + + $main = UploadedFile::fake()->image('dupe.jpg', 400, 400); + $hash = hash_file('sha256', $main->getPathname()); + + $publishedUploadId = createUploadRowForQuota($owner->id, [ + 'status' => 'published', + 'published_at' => now()->subMinute(), + ]); + + attachMainUploadFileForQuota($publishedUploadId, (int) $main->getSize(), $hash); + + $response = $this->actingAs($uploader)->postJson('/api/uploads/preload', [ + 'main' => $main, + ]); + + $response->assertStatus(422) + ->assertJsonPath('message', 'duplicate_upload') + ->assertJsonPath('code', 'duplicate_upload'); +}); + +it('allows duplicate hash and returns warning when policy is warn', function () { + Storage::fake('local'); + config([ + 'uploads.draft_quota.max_drafts_per_user' => 20, + 'uploads.draft_quota.duplicate_hash_policy' => 'warn', + ]); + + $owner = User::factory()->create(); + $uploader = User::factory()->create(); + + $main = UploadedFile::fake()->image('dupe-warn.jpg', 400, 400); + $hash = hash_file('sha256', $main->getPathname()); + + $publishedUploadId = createUploadRowForQuota($owner->id, [ + 'status' => 'published', + 'published_at' => now()->subMinute(), + ]); + + attachMainUploadFileForQuota($publishedUploadId, (int) $main->getSize(), $hash); + + $response = $this->actingAs($uploader)->postJson('/api/uploads/preload', [ + 'main' => $main, + ]); + + $response->assertOk() + ->assertJsonStructure(['upload_id', 'status', 'expires_at', 'warnings']) + ->assertJsonPath('warnings.0', 'duplicate_hash'); +}); + +it('does not count published uploads as drafts', function () { + Storage::fake('local'); + config(['uploads.draft_quota.max_drafts_per_user' => 1]); + + $user = User::factory()->create(); + createUploadRowForQuota($user->id, [ + 'status' => 'published', + 'published_at' => now()->subHour(), + ]); + + $main = UploadedFile::fake()->image('new.jpg', 640, 480); + + $response = $this->actingAs($user)->postJson('/api/uploads/preload', [ + 'main' => $main, + ]); + + $response->assertOk()->assertJsonStructure([ + 'upload_id', + 'status', + 'expires_at', + ]); +}); + +it('returns stable machine codes for quota errors', function () { + Storage::fake('local'); + config(['uploads.draft_quota.max_drafts_per_user' => 1]); + + $user = User::factory()->create(); + createUploadRowForQuota($user->id, ['status' => 'draft']); + + $main = UploadedFile::fake()->image('machine-code.jpg', 600, 400); + + $response = $this->actingAs($user)->postJson('/api/uploads/preload', [ + 'main' => $main, + ]); + + $response->assertStatus(429) + ->assertJson([ + 'message' => 'draft_limit', + 'code' => 'draft_limit', + ]); +}); diff --git a/tests/Feature/Uploads/UploadStatusTest.php b/tests/Feature/Uploads/UploadStatusTest.php new file mode 100644 index 00000000..64c88b66 --- /dev/null +++ b/tests/Feature/Uploads/UploadStatusTest.php @@ -0,0 +1,129 @@ + $id, + 'user_id' => $userId, + 'type' => 'image', + 'status' => 'draft', + 'title' => null, + 'slug' => null, + 'category_id' => null, + 'description' => null, + 'tags' => null, + 'license' => null, + 'nsfw' => false, + 'is_scanned' => false, + 'has_tags' => false, + 'preview_path' => null, + 'published_at' => null, + 'final_path' => null, + 'expires_at' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]; + + DB::table('uploads')->insert(array_merge($defaults, $overrides)); + + return $id; +} + +it('owner sees processing status payload', function () { + $owner = User::factory()->create(); + $uploadId = createUploadForStatusTests($owner->id, [ + 'status' => 'draft', + 'processing_state' => 'analyzing_tags', + 'is_scanned' => true, + 'preview_path' => 'tmp/drafts/preview.webp', + 'has_tags' => false, + ]); + + $response = $this->actingAs($owner)->getJson("/api/uploads/{$uploadId}/status"); + + $response->assertOk()->assertJson([ + 'id' => $uploadId, + 'status' => 'draft', + 'is_scanned' => true, + 'preview_ready' => true, + 'has_tags' => false, + 'processing_state' => 'analyzing_tags', + ]); +}); + +it('other user is denied', function () { + $owner = User::factory()->create(); + $other = User::factory()->create(); + + $uploadId = createUploadForStatusTests($owner->id); + + $response = $this->actingAs($other)->getJson("/api/uploads/{$uploadId}/status"); + + $response->assertStatus(403); +}); + +it('returns explicit processing states', function (array $input, string $expectedState) { + $owner = User::factory()->create(); + + $uploadId = createUploadForStatusTests($owner->id, $input); + + $response = $this->actingAs($owner)->getJson("/api/uploads/{$uploadId}/status"); + + $response->assertOk()->assertJsonPath('processing_state', $expectedState); +}) + ->with([ + 'pending scan' => [[ + 'status' => 'draft', + 'processing_state' => 'pending_scan', + ], 'pending_scan'], + 'scanning status' => [[ + 'status' => 'scanning', + 'processing_state' => 'scanning', + ], 'scanning'], + 'generating preview' => [[ + 'status' => 'draft', + 'processing_state' => 'generating_preview', + ], 'generating_preview'], + 'analyzing tags' => [[ + 'status' => 'draft', + 'processing_state' => 'analyzing_tags', + ], 'analyzing_tags'], + 'ready' => [[ + 'status' => 'draft', + 'processing_state' => 'ready', + ], 'ready'], + ]); + +it('returns rejected processing step when upload is rejected', function () { + $owner = User::factory()->create(); + $uploadId = createUploadForStatusTests($owner->id, [ + 'status' => 'rejected', + 'processing_state' => 'rejected', + ]); + + $response = $this->actingAs($owner)->getJson("/api/uploads/{$uploadId}/status"); + + $response->assertOk()->assertJsonPath('processing_state', 'rejected'); +}); + +it('returns published processing step when upload is published', function () { + $owner = User::factory()->create(); + $uploadId = createUploadForStatusTests($owner->id, [ + 'status' => 'published', + 'processing_state' => 'published', + 'published_at' => now()->subMinute(), + ]); + + $response = $this->actingAs($owner)->getJson("/api/uploads/{$uploadId}/status"); + + $response->assertOk()->assertJsonPath('processing_state', 'published'); +}); diff --git a/tests/Feature/Uploads/VirusScanJobTest.php b/tests/Feature/Uploads/VirusScanJobTest.php new file mode 100644 index 00000000..53cfcc0e --- /dev/null +++ b/tests/Feature/Uploads/VirusScanJobTest.php @@ -0,0 +1,60 @@ +create(); + $uploadId = (string) Str::uuid(); + + DB::table('uploads')->insert([ + 'id' => $uploadId, + 'user_id' => $user->id, + 'type' => 'image', + 'status' => 'draft', + 'is_scanned' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $mainPath = "tmp/drafts/{$uploadId}/main/main.jpg"; + Storage::disk('local')->put($mainPath, 'fake-image-content'); + + DB::table('upload_files')->insert([ + 'upload_id' => $uploadId, + 'path' => $mainPath, + 'type' => 'main', + 'hash' => null, + 'size' => 18, + 'mime' => 'image/jpeg', + 'created_at' => now(), + ]); + + $job = new VirusScanJob($uploadId); + $job->handle(app(UploadScanService::class)); + + $this->assertDatabaseHas('uploads', [ + 'id' => $uploadId, + 'is_scanned' => 1, + ]); + + Bus::assertDispatched(PreviewGenerationJob::class, function (PreviewGenerationJob $queuedJob) use ($uploadId) { + $reflect = new ReflectionClass($queuedJob); + $property = $reflect->getProperty('uploadId'); + $property->setAccessible(true); + + return $property->getValue($queuedJob) === $uploadId; + }); +}); diff --git a/tests/Unit/Discovery/FeedOfflineEvaluationServiceTest.php b/tests/Unit/Discovery/FeedOfflineEvaluationServiceTest.php new file mode 100644 index 00000000..af1f9f24 --- /dev/null +++ b/tests/Unit/Discovery/FeedOfflineEvaluationServiceTest.php @@ -0,0 +1,120 @@ +subDay()->toDateString(); + + DB::table('feed_daily_metrics')->insert([ + 'metric_date' => $metricDate, + 'algo_version' => 'clip-cosine-v1', + 'source' => 'personalized', + 'impressions' => 100, + 'clicks' => 20, + 'saves' => 8, + 'ctr' => 0.2, + 'save_rate' => 0.4, + 'dwell_0_5' => 3, + 'dwell_5_30' => 7, + 'dwell_30_120' => 6, + 'dwell_120_plus' => 4, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $result = app(FeedOfflineEvaluationService::class)->evaluateAlgo('clip-cosine-v1', $metricDate, $metricDate); + + expect((string) $result['algo_version'])->toBe('clip-cosine-v1'); + expect((float) $result['ctr'])->toBe(0.2); + expect((float) $result['save_rate'])->toBe(0.4); + expect((float) $result['long_dwell_share'])->toBe(0.5); + expect((float) $result['bounce_rate'])->toBe(0.15); + expect((float) $result['objective_score'])->toBeGreaterThan(0); +}); + +it('compares baseline vs candidate with delta and lift', function () { + $metricDate = now()->subDay()->toDateString(); + + DB::table('feed_daily_metrics')->insert([ + [ + 'metric_date' => $metricDate, + 'algo_version' => 'clip-cosine-v1', + 'source' => 'personalized', + 'impressions' => 100, + 'clicks' => 20, + 'saves' => 6, + 'ctr' => 0.2, + 'save_rate' => 0.3, + 'dwell_0_5' => 4, + 'dwell_5_30' => 8, + 'dwell_30_120' => 5, + 'dwell_120_plus' => 3, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'metric_date' => $metricDate, + 'algo_version' => 'clip-cosine-v2', + 'source' => 'personalized', + 'impressions' => 100, + 'clicks' => 25, + 'saves' => 10, + 'ctr' => 0.25, + 'save_rate' => 0.4, + 'dwell_0_5' => 3, + 'dwell_5_30' => 8, + 'dwell_30_120' => 8, + 'dwell_120_plus' => 6, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $comparison = app(FeedOfflineEvaluationService::class) + ->compareBaselineCandidate('clip-cosine-v1', 'clip-cosine-v2', $metricDate, $metricDate); + + expect((float) $comparison['delta']['objective_score'])->toBeGreaterThan(0.0); + expect((float) $comparison['delta']['ctr'])->toBeGreaterThan(0.0); + expect((float) $comparison['delta']['save_rate'])->toBeGreaterThan(0.0); +}); + +it('treats save_rate as informational when configured', function () { + $metricDate = now()->subDay()->toDateString(); + + config()->set('discovery.evaluation.objective_weights', [ + 'ctr' => 0.45, + 'save_rate' => 0.35, + 'long_dwell_share' => 0.25, + 'bounce_rate_penalty' => 0.15, + ]); + config()->set('discovery.evaluation.save_rate_informational', true); + + DB::table('feed_daily_metrics')->insert([ + 'metric_date' => $metricDate, + 'algo_version' => 'clip-cosine-v1', + 'source' => 'personalized', + 'impressions' => 100, + 'clicks' => 20, + 'saves' => 8, + 'ctr' => 0.2, + 'save_rate' => 0.4, + 'dwell_0_5' => 3, + 'dwell_5_30' => 7, + 'dwell_30_120' => 6, + 'dwell_120_plus' => 4, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $result = app(FeedOfflineEvaluationService::class)->evaluateAlgo('clip-cosine-v1', $metricDate, $metricDate); + + expect((float) $result['save_rate'])->toBe(0.4); + expect((float) $result['objective_score'])->toBe(0.226471); +}); diff --git a/tests/Unit/Discovery/PersonalizedFeedServiceTest.php b/tests/Unit/Discovery/PersonalizedFeedServiceTest.php new file mode 100644 index 00000000..4e8a05a9 --- /dev/null +++ b/tests/Unit/Discovery/PersonalizedFeedServiceTest.php @@ -0,0 +1,76 @@ +create(); + + $artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]); + $artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(1)]); + + DB::table('artwork_stats')->insert([ + ['artwork_id' => $artworkA->id, 'views' => 120, 'downloads' => 30, 'favorites' => 2, 'rating_avg' => 0, 'rating_count' => 0], + ['artwork_id' => $artworkB->id, 'views' => 100, 'downloads' => 20, 'favorites' => 1, 'rating_avg' => 0, 'rating_count' => 0], + ]); + + app(PersonalizedFeedService::class)->regenerateCacheForUser($user->id, (string) config('discovery.algo_version')); + + $cache = UserRecommendationCache::query() + ->where('user_id', $user->id) + ->where('algo_version', (string) config('discovery.algo_version')) + ->first(); + + expect($cache)->not->toBeNull(); + expect($cache?->generated_at)->not->toBeNull(); + expect($cache?->expires_at)->not->toBeNull(); + + $items = (array) ($cache?->recommendations_json['items'] ?? []); + expect(count($items))->toBeGreaterThan(0); + expect((int) ($items[0]['artwork_id'] ?? 0))->toBeGreaterThan(0); +}); + +it('uses rollout gate g100 to select candidate algo version', function () { + $user = User::factory()->create(); + + config()->set('discovery.rollout.enabled', true); + config()->set('discovery.rollout.baseline_algo_version', 'clip-cosine-v1'); + config()->set('discovery.rollout.candidate_algo_version', 'clip-cosine-v2'); + config()->set('discovery.rollout.active_gate', 'g100'); + config()->set('discovery.rollout.gates.g100.percentage', 100); + config()->set('discovery.rollout.force_algo_version', ''); + + app(PersonalizedFeedService::class)->regenerateCacheForUser($user->id); + + $cache = UserRecommendationCache::query()->where('user_id', $user->id)->first(); + + expect($cache)->not->toBeNull(); + expect((string) $cache?->algo_version)->toBe('clip-cosine-v2'); +}); + +it('forces rollback algo version when force toggle is set', function () { + $user = User::factory()->create(); + + config()->set('discovery.rollout.enabled', true); + config()->set('discovery.rollout.baseline_algo_version', 'clip-cosine-v1'); + config()->set('discovery.rollout.candidate_algo_version', 'clip-cosine-v2'); + config()->set('discovery.rollout.active_gate', 'g100'); + config()->set('discovery.rollout.gates.g100.percentage', 100); + config()->set('discovery.rollout.force_algo_version', 'clip-cosine-v1'); + + app(PersonalizedFeedService::class)->regenerateCacheForUser($user->id); + + $cache = UserRecommendationCache::query()->where('user_id', $user->id)->first(); + + expect($cache)->not->toBeNull(); + expect((string) $cache?->algo_version)->toBe('clip-cosine-v1'); +}); diff --git a/tests/Unit/Discovery/UserInterestProfileServiceTest.php b/tests/Unit/Discovery/UserInterestProfileServiceTest.php new file mode 100644 index 00000000..878fa957 --- /dev/null +++ b/tests/Unit/Discovery/UserInterestProfileServiceTest.php @@ -0,0 +1,86 @@ +set('discovery.decay.half_life_hours', 72); + config()->set('discovery.weights.view', 1.0); + + $service = app(UserInterestProfileService::class); + + $user = User::factory()->create(); + + $contentType = ContentType::create([ + 'name' => 'Digital Art', + 'slug' => 'digital-art', + 'description' => 'Digital artworks', + ]); + + $categoryA = Category::create([ + 'content_type_id' => $contentType->id, + 'parent_id' => null, + 'name' => 'Sci-Fi', + 'slug' => 'sci-fi', + 'description' => 'Sci-Fi category', + 'is_active' => true, + 'sort_order' => 0, + ]); + + $categoryB = Category::create([ + 'content_type_id' => $contentType->id, + 'parent_id' => null, + 'name' => 'Fantasy', + 'slug' => 'fantasy', + 'description' => 'Fantasy category', + 'is_active' => true, + 'sort_order' => 0, + ]); + + $artworkA = Artwork::factory()->create(); + $artworkB = Artwork::factory()->create(); + + $t0 = CarbonImmutable::parse('2026-02-14 00:00:00'); + + $service->applyEvent( + userId: $user->id, + eventType: 'view', + artworkId: $artworkA->id, + categoryId: $categoryA->id, + occurredAt: $t0, + eventId: '11111111-1111-1111-1111-111111111111', + algoVersion: 'clip-cosine-v1' + ); + + $service->applyEvent( + userId: $user->id, + eventType: 'view', + artworkId: $artworkB->id, + categoryId: $categoryB->id, + occurredAt: $t0->addHours(72), + eventId: '22222222-2222-2222-2222-222222222222', + algoVersion: 'clip-cosine-v1' + ); + + $profile = \App\Models\UserInterestProfile::query()->where('user_id', $user->id)->firstOrFail(); + + expect((int) $profile->event_count)->toBe(2); + + $normalized = (array) $profile->normalized_scores_json; + + expect($normalized)->toHaveKey('category:' . $categoryA->id); + expect($normalized)->toHaveKey('category:' . $categoryB->id); + + expect((float) $normalized['category:' . $categoryA->id])->toBeGreaterThan(0.30)->toBeLessThan(0.35); + expect((float) $normalized['category:' . $categoryB->id])->toBeGreaterThan(0.65)->toBeLessThan(0.70); +}); diff --git a/tests/Unit/Uploads/ArchiveInspectorServiceTest.php b/tests/Unit/Uploads/ArchiveInspectorServiceTest.php new file mode 100644 index 00000000..96fa5042 --- /dev/null +++ b/tests/Unit/Uploads/ArchiveInspectorServiceTest.php @@ -0,0 +1,168 @@ + */ + private array $tempFiles = []; + + protected function tearDown(): void + { + foreach ($this->tempFiles as $file) { + if (is_file($file)) { + @unlink($file); + } + } + + parent::tearDown(); + } + + public function test_rejects_zip_slip_path(): void + { + $archive = $this->makeZip([ + '../evil.txt' => 'x', + ]); + + $result = app(ArchiveInspectorService::class)->inspect($archive); + + $this->assertFalse($result->valid); + $this->assertStringContainsString('path traversal', (string) $result->reason); + } + + public function test_rejects_symlink_entries(): void + { + $archive = $this->makeZipWithCallback([ + 'safe/file.txt' => 'ok', + 'safe/link' => 'target', + ], function (ZipArchive $zip, string $entryName): void { + if ($entryName === 'safe/link') { + $zip->setExternalAttributesName($entryName, ZipArchive::OPSYS_UNIX, 0120777 << 16); + } + }); + + $result = app(ArchiveInspectorService::class)->inspect($archive); + + $this->assertFalse($result->valid); + $this->assertStringContainsString('symlink', strtolower((string) $result->reason)); + } + + public function test_rejects_deep_nesting(): void + { + $archive = $this->makeZip([ + 'a/b/c/d/e/f/file.txt' => 'too deep', + ]); + + $result = app(ArchiveInspectorService::class)->inspect($archive); + + $this->assertFalse($result->valid); + $this->assertStringContainsString('depth', strtolower((string) $result->reason)); + } + + public function test_rejects_too_many_files(): void + { + $entries = []; + for ($index = 0; $index < 5001; $index++) { + $entries['f' . $index . '.txt'] = 'x'; + } + + $archive = $this->makeZip($entries); + + $result = app(ArchiveInspectorService::class)->inspect($archive); + + $this->assertFalse($result->valid); + $this->assertStringContainsString('5000', (string) $result->reason); + } + + public function test_rejects_executable_extensions(): void + { + $archive = $this->makeZip([ + 'skins/readme.txt' => 'ok', + 'skins/run.exe' => 'MZ', + ]); + + $result = app(ArchiveInspectorService::class)->inspect($archive); + + $this->assertFalse($result->valid); + $this->assertStringContainsString('blocked', strtolower((string) $result->reason)); + } + + public function test_rejects_zip_bomb_ratio(): void + { + $archive = $this->makeZip([ + 'payload.txt' => str_repeat('A', 6 * 1024 * 1024), + ]); + + $result = app(ArchiveInspectorService::class)->inspect($archive); + + $this->assertFalse($result->valid); + $this->assertStringContainsString('ratio', strtolower((string) $result->reason)); + } + + public function test_valid_archive_passes(): void + { + $archive = $this->makeZip([ + 'skins/theme/readme.txt' => 'safe', + 'skins/theme/colors.ini' => 'accent=blue', + ]); + + $result = app(ArchiveInspectorService::class)->inspect($archive); + + $this->assertInstanceOf(InspectionResult::class, $result); + $this->assertTrue($result->valid); + $this->assertNull($result->reason); + $this->assertIsArray($result->stats); + $this->assertArrayHasKey('files', $result->stats); + $this->assertArrayHasKey('depth', $result->stats); + $this->assertArrayHasKey('size', $result->stats); + $this->assertArrayHasKey('ratio', $result->stats); + } + + /** + * @param array $entries + */ + private function makeZip(array $entries): string + { + return $this->makeZipWithCallback($entries, null); + } + + /** + * @param array $entries + * @param (callable(ZipArchive,string):void)|null $entryCallback + */ + private function makeZipWithCallback(array $entries, ?callable $entryCallback): string + { + if (! class_exists(ZipArchive::class)) { + $this->markTestSkipped('ZipArchive extension is required.'); + } + + $path = tempnam(sys_get_temp_dir(), 'sb_zip_'); + if ($path === false) { + throw new \RuntimeException('Unable to create temporary zip path.'); + } + + $this->tempFiles[] = $path; + + $zip = new ZipArchive(); + if ($zip->open($path, ZipArchive::OVERWRITE | ZipArchive::CREATE) !== true) { + throw new \RuntimeException('Unable to open temporary zip for writing.'); + } + + foreach ($entries as $name => $content) { + $zip->addFromString($name, $content); + + if ($entryCallback !== null) { + $entryCallback($zip, $name); + } + } + + $zip->close(); + + return $path; + } +} diff --git a/tests/Unit/Uploads/CleanupServiceTest.php b/tests/Unit/Uploads/CleanupServiceTest.php new file mode 100644 index 00000000..bff9b9e2 --- /dev/null +++ b/tests/Unit/Uploads/CleanupServiceTest.php @@ -0,0 +1,139 @@ + $id, + 'user_id' => User::factory()->create()->id, + 'type' => 'image', + 'status' => 'draft', + 'title' => null, + 'slug' => null, + 'category_id' => null, + 'description' => null, + 'tags' => null, + 'license' => null, + 'nsfw' => false, + 'is_scanned' => false, + 'has_tags' => false, + 'preview_path' => null, + 'published_at' => null, + 'final_path' => null, + 'expires_at' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]; + + DB::table('uploads')->insert(array_merge($defaults, $overrides)); + + return $id; + } + + public function test_deletes_expired_draft_uploads_and_returns_count(): void + { + Storage::fake('local'); + + $uploadId = $this->insertUploadRow([ + 'status' => 'draft', + 'expires_at' => now()->subMinute(), + ]); + + Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}'); + + $deleted = app(CleanupService::class)->cleanupStaleDrafts(); + + $this->assertSame(1, $deleted); + $this->assertFalse(DB::table('uploads')->where('id', $uploadId)->exists()); + } + + public function test_keeps_active_drafts_untouched(): void + { + Storage::fake('local'); + + $uploadId = $this->insertUploadRow([ + 'status' => 'draft', + 'expires_at' => now()->addDay(), + 'updated_at' => now()->subHours(2), + ]); + + Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}'); + + $deleted = app(CleanupService::class)->cleanupStaleDrafts(); + + $this->assertSame(0, $deleted); + $this->assertTrue(DB::table('uploads')->where('id', $uploadId)->exists()); + $this->assertTrue(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/meta.json")); + } + + public function test_removes_temp_folder_when_deleting_stale_drafts(): void + { + Storage::fake('local'); + + $uploadId = $this->insertUploadRow([ + 'status' => 'draft', + 'updated_at' => now()->subHours(25), + ]); + + Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/file.bin", 'x'); + $this->assertTrue(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/main/file.bin")); + + $deleted = app(CleanupService::class)->cleanupStaleDrafts(); + + $this->assertSame(1, $deleted); + $this->assertFalse(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/main/file.bin")); + } + + public function test_enforces_hard_cleanup_limit_of_100_per_run(): void + { + Storage::fake('local'); + + for ($index = 0; $index < 120; $index++) { + $uploadId = $this->insertUploadRow([ + 'status' => 'draft', + 'updated_at' => now()->subHours(30), + ]); + + Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}'); + } + + $deleted = app(CleanupService::class)->cleanupStaleDrafts(999); + + $this->assertSame(100, $deleted); + $this->assertSame(20, DB::table('uploads')->count()); + } + + public function test_never_deletes_published_uploads(): void + { + Storage::fake('local'); + + $uploadId = $this->insertUploadRow([ + 'status' => 'published', + 'updated_at' => now()->subDays(5), + 'published_at' => now()->subDays(4), + ]); + + Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}'); + + $deleted = app(CleanupService::class)->cleanupStaleDrafts(); + + $this->assertSame(0, $deleted); + $this->assertTrue(DB::table('uploads')->where('id', $uploadId)->where('status', 'published')->exists()); + $this->assertTrue(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/meta.json")); + } +} diff --git a/tests/Unit/Uploads/PublishServiceTest.php b/tests/Unit/Uploads/PublishServiceTest.php new file mode 100644 index 00000000..41d868fa --- /dev/null +++ b/tests/Unit/Uploads/PublishServiceTest.php @@ -0,0 +1,102 @@ +insertGetId([ + 'name' => 'Skins', + 'slug' => 'skins-' . Str::lower(Str::random(6)), + 'description' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return DB::table('categories')->insertGetId([ + 'content_type_id' => $contentTypeId, + 'parent_id' => null, + 'name' => 'Winamp', + 'slug' => 'winamp-' . Str::lower(Str::random(6)), + 'description' => null, + 'image' => null, + 'is_active' => true, + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +it('rejects publish when user is not owner', function () { + Storage::fake('local'); + + $owner = User::factory()->create(); + $other = User::factory()->create(); + $categoryId = createCategoryForPublishTests(); + $uploadId = (string) Str::uuid(); + + DB::table('uploads')->insert([ + 'id' => $uploadId, + 'user_id' => $owner->id, + 'type' => 'image', + 'status' => 'draft', + 'moderation_status' => 'approved', + 'title' => 'City Lights', + 'category_id' => $categoryId, + 'is_scanned' => true, + 'has_tags' => true, + 'preview_path' => "tmp/drafts/{$uploadId}/preview.webp", + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = app(PublishService::class); + + expect(fn () => $service->publish($uploadId, $other)) + ->toThrow(RuntimeException::class, 'You do not own this upload.'); +}); + +it('rejects archive publish without screenshots', function () { + Storage::fake('local'); + + $owner = User::factory()->create(); + $categoryId = createCategoryForPublishTests(); + $uploadId = (string) Str::uuid(); + + DB::table('uploads')->insert([ + 'id' => $uploadId, + 'user_id' => $owner->id, + 'type' => 'archive', + 'status' => 'draft', + 'moderation_status' => 'approved', + 'title' => 'Skin Pack', + 'category_id' => $categoryId, + 'is_scanned' => true, + 'has_tags' => true, + 'preview_path' => "tmp/drafts/{$uploadId}/preview.webp", + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('upload_files')->insert([ + 'upload_id' => $uploadId, + 'path' => "tmp/drafts/{$uploadId}/main/pack.zip", + 'type' => 'main', + 'hash' => 'aabbccddeeff0011', + 'size' => 1024, + 'mime' => 'application/zip', + 'created_at' => now(), + ]); + + $service = app(PublishService::class); + + expect(fn () => $service->publish($uploadId, $owner)) + ->toThrow(RuntimeException::class, 'Archive uploads require at least one screenshot.'); +}); diff --git a/tests/Unit/Uploads/UploadDraftServiceTest.php b/tests/Unit/Uploads/UploadDraftServiceTest.php new file mode 100644 index 00000000..897fe868 --- /dev/null +++ b/tests/Unit/Uploads/UploadDraftServiceTest.php @@ -0,0 +1,130 @@ +user = User::factory()->create(); + + // Provide a dummy clamav scanner binding so any scanning calls are mocked + $this->app->instance('clamav', new class { + public function scan(string $path): bool + { + return true; + } + }); + + $filesystem = $this->app->make(FilesystemManager::class); + $this->service = new UploadDraftService($filesystem, 'local'); + } + + public function test_createDraft_creates_directory_and_writes_meta() + { + $result = $this->service->createDraft(['title' => 'Test Draft', 'user_id' => $this->user->id, 'type' => 'image']); + + $this->assertArrayHasKey('id', $result); + $id = $result['id']; + + Storage::disk('local')->assertExists("tmp/drafts/{$id}"); + Storage::disk('local')->assertExists("tmp/drafts/{$id}/meta.json"); + + $meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true); + $this->assertSame('Test Draft', $meta['title']); + $this->assertSame($id, $meta['id']); + } + + public function test_storeMainFile_saves_file_and_updates_meta() + { + $draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']); + $id = $draft['id']; + + $file = UploadedFile::fake()->create('song.mp3', 1500, 'audio/mpeg'); + + $info = $this->service->storeMainFile($id, $file); + + $this->assertArrayHasKey('path', $info); + Storage::disk('local')->assertExists($info['path']); + + $meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true); + $this->assertArrayHasKey('main_file', $meta); + $this->assertSame($info['hash'], $meta['main_file']['hash']); + } + + public function test_storeScreenshot_saves_file_and_appends_meta() + { + $draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']); + $id = $draft['id']; + + $img = UploadedFile::fake()->image('thumb.jpg', 640, 480); + + $info = $this->service->storeScreenshot($id, $img); + + $this->assertArrayHasKey('path', $info); + Storage::disk('local')->assertExists($info['path']); + + $meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true); + $this->assertArrayHasKey('screenshots', $meta); + $this->assertCount(1, $meta['screenshots']); + $this->assertSame($info['hash'], $meta['screenshots'][0]['hash']); + } + + public function test_calculateHash_for_local_file_and_storage_path() + { + $file = UploadedFile::fake()->create('doc.pdf', 10); + $realPath = $file->getRealPath(); + + $expected = hash_file('sha256', $realPath); + $this->assertSame($expected, $this->service->calculateHash($realPath)); + + // Store into drafts and calculate by storage path + $draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']); + $id = $draft['id']; + $info = $this->service->storeMainFile($id, $file); + + $storageHash = $this->service->calculateHash($info['path']); + $storedContents = Storage::disk('local')->get($info['path']); + $this->assertSame(hash('sha256', $storedContents), $storageHash); + } + + public function test_setExpiration_writes_expires_at_in_meta() + { + $draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']); + $id = $draft['id']; + + $when = Carbon::now()->addDays(3); + $ok = $this->service->setExpiration($id, $when); + $this->assertTrue($ok); + + $meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true); + $this->assertArrayHasKey('expires_at', $meta); + $this->assertSame($when->toISOString(), $meta['expires_at']); + } + + public function test_calculateHash_throws_for_missing_file() + { + $this->expectException(\RuntimeException::class); + $this->service->calculateHash('this/path/does/not/exist'); + } +} diff --git a/vite.config.js b/vite.config.js index 8fa23a06..02557dc4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -9,9 +9,16 @@ export default defineConfig({ 'resources/js/app.js', 'resources/scss/nova.scss', 'resources/js/nova.js', - 'resources/js/entry-topbar.jsx' + 'resources/js/entry-topbar.jsx', + 'resources/js/upload.jsx' ], refresh: true, }), ], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['resources/js/test/setupTests.js'], + include: ['resources/js/**/*.test.{js,jsx}'], + }, });